Compare commits
310 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5fb4d611a | ||
|
|
bc9dc9704c | ||
|
|
e88eb1a884 | ||
|
|
d8f3206e8b | ||
|
|
729d306157 | ||
|
|
c35df48754 | ||
|
|
0f97f37a79 | ||
|
|
b08219dacf | ||
|
|
dd10fb97c0 | ||
|
|
87354df2de | ||
|
|
1bd04498b9 | ||
|
|
52394bc99b | ||
|
|
add85e9747 | ||
|
|
e82986adff | ||
|
|
f201267e4e | ||
|
|
9db41f8830 | ||
|
|
ba64c59632 | ||
|
|
d2626d8337 | ||
|
|
ded1090190 | ||
|
|
1114baaaa0 | ||
|
|
cf13c1c671 | ||
|
|
e70de6a59e | ||
|
|
5110eaf10f | ||
|
|
0234682720 | ||
|
|
80a7322fa1 | ||
|
|
59bdc0ce0d | ||
|
|
a288d0925b | ||
|
|
e7d2f0d82b | ||
|
|
825d8269ff | ||
|
|
f7775d173a | ||
|
|
58bced5f09 | ||
|
|
6e08507dde | ||
|
|
617a03fc15 | ||
|
|
f86bda304d | ||
|
|
1d414e659b | ||
|
|
87f7390eca | ||
|
|
ed01752546 | ||
|
|
46002a2171 | ||
|
|
14716d36a6 | ||
|
|
b4bc8a31aa | ||
|
|
b01fc316c3 | ||
|
|
4479249ac7 | ||
|
|
0529837ac8 | ||
|
|
d51ffa17ed | ||
|
|
c434a44bc4 | ||
|
|
7b5ac23a4b | ||
|
|
87ef769086 | ||
|
|
bcefb8e43c | ||
|
|
a1641c5bcc | ||
|
|
e6839480d9 | ||
|
|
4e64d9efad | ||
|
|
d68f4514cc | ||
|
|
8a69c09939 | ||
|
|
e87af81db4 | ||
|
|
6043c59da8 | ||
|
|
4cb7b97416 | ||
|
|
b1db450e00 | ||
|
|
2e8ac98924 | ||
|
|
529a628368 | ||
|
|
8a2e821c8f | ||
|
|
3cd11d6bc4 | ||
|
|
db092d2440 | ||
|
|
a4a7c91fc1 | ||
|
|
543fd44cb2 | ||
|
|
eab262c3f7 | ||
|
|
52bde8ea6d | ||
|
|
03de73560c | ||
|
|
bcb7de1b9a | ||
|
|
ca94bd32f2 | ||
|
|
cd10727795 | ||
|
|
8262a9a45b | ||
|
|
b433437636 | ||
|
|
02825ed109 | ||
|
|
a97e6149bb | ||
|
|
946b1e7f54 | ||
|
|
b5ed7cd555 | ||
|
|
233349fb2a | ||
|
|
c54e6ff0ea | ||
|
|
98c4102f72 | ||
|
|
640ee7a88e | ||
|
|
8a85246a0b | ||
|
|
655bfc95ca | ||
|
|
37a066e6bd | ||
|
|
9e959a6b7b | ||
|
|
2b6560b9e1 | ||
|
|
d8836d53bf | ||
|
|
aa15876aa2 | ||
|
|
7ca960b521 | ||
|
|
4eaedcf825 | ||
|
|
b337ba1d7f | ||
|
|
c9b72f724f | ||
|
|
35d8996e00 | ||
|
|
6e61c5f1e4 | ||
|
|
6bb147c349 | ||
|
|
3668aa4e8e | ||
|
|
4c324bff73 | ||
|
|
741575df15 | ||
|
|
055fc39305 | ||
|
|
5ae3a38204 | ||
|
|
44747e75b0 | ||
|
|
e4f22ebb01 | ||
|
|
bfb848a1ec | ||
|
|
c16c7830a4 | ||
|
|
8f383c9f5e | ||
|
|
5b68556a9a | ||
|
|
cb1c481f54 | ||
|
|
a93ff63605 | ||
|
|
856683610a | ||
|
|
b9fda9dd0b | ||
|
|
7e27fee006 | ||
|
|
f65d19ad84 | ||
|
|
94f771fc1c | ||
|
|
0ac3d20162 | ||
|
|
df0f3a154f | ||
|
|
6419178d87 | ||
|
|
91714ba0e6 | ||
|
|
b5ba5054a5 | ||
|
|
6f38077ca0 | ||
|
|
7f82aafff9 | ||
|
|
14a4715eb8 | ||
|
|
e4f1936698 | ||
|
|
4f62a07da6 | ||
|
|
1a1fcebc46 | ||
|
|
f9f7db17d4 | ||
|
|
929d94f705 | ||
|
|
2c4ea6f52a | ||
|
|
3505b215a2 | ||
|
|
8827996553 | ||
|
|
556a6b49db | ||
|
|
180ec83a17 | ||
|
|
062796b38c | ||
|
|
67f88188e1 | ||
|
|
3209c53201 | ||
|
|
ec7aa80928 | ||
|
|
f6e391f8a9 | ||
|
|
e64fad9584 | ||
|
|
9e6ee8d239 | ||
|
|
2c66f93101 | ||
|
|
5c2e2d7d36 | ||
|
|
376e8d4621 | ||
|
|
ec7cb53d93 | ||
|
|
b7176fc8f3 | ||
|
|
f8fc74116c | ||
|
|
4094df3a61 | ||
|
|
a5f9e2615c | ||
|
|
4a78ce1b16 | ||
|
|
f8f1e01cb4 | ||
|
|
c7463f2b9f | ||
|
|
a975466fc7 | ||
|
|
539c0ccb1d | ||
|
|
5f4dcb09ea | ||
|
|
6de5dce176 | ||
|
|
b5c158d1b3 | ||
|
|
7f01d1ec7e | ||
|
|
8bf7a0e1d6 | ||
|
|
140fd93ec9 | ||
|
|
bdcb34c989 | ||
|
|
aaaa86b147 | ||
|
|
6e9b84c6c7 | ||
|
|
cce241caa4 | ||
|
|
1e9787c4d7 | ||
|
|
71aa9946f5 | ||
|
|
12239808fc | ||
|
|
94e9d4f270 | ||
|
|
34a8053967 | ||
|
|
ee92e338cb | ||
|
|
1a3ad04e03 | ||
|
|
9c061774a3 | ||
|
|
3336b0a7d9 | ||
|
|
f034eed431 | ||
|
|
6b6d3fabc0 | ||
|
|
59d541dd1d | ||
|
|
abff85d61e | ||
|
|
02641ec007 | ||
|
|
92179cbbb2 | ||
|
|
299152413a | ||
|
|
703a3c41c9 | ||
|
|
31d1153916 | ||
|
|
c1577d3ba5 | ||
|
|
c4400eb0a3 | ||
|
|
a57498f8f7 | ||
|
|
1b0dffc1ab | ||
|
|
bea37d62b4 | ||
|
|
d53b6be5b9 | ||
|
|
6c31263e60 | ||
|
|
b464fa5b3f | ||
|
|
c0a3bbeefc | ||
|
|
10d348c052 | ||
|
|
6cf6661f2e | ||
|
|
23ab1208cd | ||
|
|
5b0fac429b | ||
|
|
efca56ceca | ||
|
|
64f0a23969 | ||
|
|
4245da7792 | ||
|
|
cedf80a869 | ||
|
|
76cea9d3c3 | ||
|
|
10ef430826 | ||
|
|
d672017af0 | ||
|
|
7a82571921 | ||
|
|
e81f8ac387 | ||
|
|
05faa88e6a | ||
|
|
73aae62c2e | ||
|
|
af4877ca30 | ||
|
|
c407fe9af0 | ||
|
|
13c9497951 | ||
|
|
4274096645 | ||
|
|
a213b70a1c | ||
|
|
66cc0a4b24 | ||
|
|
f051f6a5f8 | ||
|
|
b9f142c28c | ||
|
|
45e1283b83 | ||
|
|
94cb5f2798 | ||
|
|
2883467b2b | ||
|
|
0c77190161 | ||
|
|
8d4d072343 | ||
|
|
d6e0daf52a | ||
|
|
22e9ede766 | ||
|
|
9ab359d3cf | ||
|
|
5447ccad47 | ||
|
|
3e51d79c37 | ||
|
|
0996d60224 | ||
|
|
7a5ec067f5 | ||
|
|
98563d643d | ||
|
|
268e364bd4 | ||
|
|
dd84a9fd35 | ||
|
|
2f4e537f72 | ||
|
|
9637363cf3 | ||
|
|
73d0dd25ec | ||
|
|
2ecf5572ba | ||
|
|
5e97167ee0 | ||
|
|
1a4862ecd9 | ||
|
|
6235d15fa2 | ||
|
|
4694642674 | ||
|
|
56c0b86025 | ||
|
|
82e3f3c7c1 | ||
|
|
38a9c535b8 | ||
|
|
34c83e7c17 | ||
|
|
fe5732d75a | ||
|
|
cc32b50d82 | ||
|
|
764e043e83 | ||
|
|
cec9339f6d | ||
|
|
f96f04f876 | ||
|
|
06b1c2200b | ||
|
|
e88e2bf3dc | ||
|
|
8621a45383 | ||
|
|
f2ddee9216 | ||
|
|
f350d61ee2 | ||
|
|
2d670c585d | ||
|
|
55d1c00903 | ||
|
|
78a9086b55 | ||
|
|
4ee169fea5 | ||
|
|
a286bed54c | ||
|
|
314cee081a | ||
|
|
e287124632 | ||
|
|
9cccefd3fa | ||
|
|
ec95f63806 | ||
|
|
812fe20df7 | ||
|
|
ddfcbc546b | ||
|
|
c74d5496af | ||
|
|
060846d70a | ||
|
|
e03e2b8d67 | ||
|
|
c46879694d | ||
|
|
61a68e5be1 | ||
|
|
bd43a2a2c2 | ||
|
|
3aeca6af2f | ||
|
|
3e95269a7c | ||
|
|
53b02dd55f | ||
|
|
43ba9d5c6a | ||
|
|
1cb4a711c3 | ||
|
|
aef99c3bd9 | ||
|
|
138cbc13d6 | ||
|
|
62d5ae8236 | ||
|
|
8ce605d65e | ||
|
|
c8743201a2 | ||
|
|
f16e22e521 | ||
|
|
9710d0d2f1 | ||
|
|
2889d151ea | ||
|
|
ce6e887d1b | ||
|
|
b4cf5bb1c0 | ||
|
|
9bc7773607 | ||
|
|
3362a3d1cf | ||
|
|
3b13fadde2 | ||
|
|
99d79f7d2d | ||
|
|
1fb23ff673 | ||
|
|
29529d1a84 | ||
|
|
9f84629b92 | ||
|
|
d2284c3fed | ||
|
|
eb420bef3a | ||
|
|
9cf6c167b0 | ||
|
|
fbc7f79660 | ||
|
|
37170f2bdb | ||
|
|
af4c05e692 | ||
|
|
202a506485 | ||
|
|
aa3866c8ed | ||
|
|
f9c0d0b89d | ||
|
|
ec5b1a833d | ||
|
|
1cfda8fb9f | ||
|
|
2168db6ebd | ||
|
|
e64ef49e97 | ||
|
|
54e0240dd8 | ||
|
|
05f52ad15a | ||
|
|
8ffb3a0cc8 | ||
|
|
953d7cac1e | ||
|
|
1cfd3cdd30 | ||
|
|
b4a3cb9ce6 | ||
|
|
7a6fbc8346 | ||
|
|
76cffb16de | ||
|
|
13f7d016e6 | ||
|
|
7a8dccfc97 | ||
|
|
68824935e9 |
4
.github/workflows/docker-images.yml
vendored
@@ -3,7 +3,7 @@ name: Make docker images
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- '*'
|
- 'v*'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -71,5 +71,3 @@ jobs:
|
|||||||
push: ${{ github.ref_type == 'tag' }}
|
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 }}
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha
|
|
||||||
|
|||||||
4
.gitignore
vendored
@@ -11,3 +11,7 @@ dist
|
|||||||
beszel/cmd/hub/hub
|
beszel/cmd/hub/hub
|
||||||
beszel/cmd/agent/agent
|
beszel/cmd/agent/agent
|
||||||
node_modules
|
node_modules
|
||||||
|
beszel/build
|
||||||
|
*timestamp*
|
||||||
|
.swc
|
||||||
|
beszel/site/src/locales/**/*.ts
|
||||||
7
SECURITY.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
If you find a vulnerability in the latest version, please [submit a private advisory](https://github.com/henrygd/beszel/security/advisories/new).
|
||||||
|
|
||||||
|
If it's low severity (use best judgement) you may open an issue instead of an advisory.
|
||||||
67
beszel/Makefile
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Default OS/ARCH values
|
||||||
|
OS ?= $(shell go env GOOS)
|
||||||
|
ARCH ?= $(shell go env GOARCH)
|
||||||
|
# Skip building the web UI if true
|
||||||
|
SKIP_WEB ?= false
|
||||||
|
|
||||||
|
.PHONY: tidy build-agent build-hub build clean lint dev-server dev-agent dev-hub dev generate-locales
|
||||||
|
.DEFAULT_GOAL := build
|
||||||
|
|
||||||
|
clean:
|
||||||
|
go clean
|
||||||
|
rm -rf ./build
|
||||||
|
|
||||||
|
lint:
|
||||||
|
golangci-lint run
|
||||||
|
|
||||||
|
tidy:
|
||||||
|
go mod tidy
|
||||||
|
|
||||||
|
build-web-ui:
|
||||||
|
@if command -v bun >/dev/null 2>&1; then \
|
||||||
|
bun install --cwd ./site && \
|
||||||
|
bun run --cwd ./site build; \
|
||||||
|
else \
|
||||||
|
npm install --prefix ./site && \
|
||||||
|
npm run --prefix ./site build; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
build-agent: tidy
|
||||||
|
CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel-agent_$(OS)_$(ARCH) -ldflags "-w -s" beszel/cmd/agent
|
||||||
|
|
||||||
|
build-hub: tidy $(if $(filter false,$(SKIP_WEB)),build-web-ui)
|
||||||
|
CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel_$(OS)_$(ARCH) -ldflags "-w -s" beszel/cmd/hub
|
||||||
|
|
||||||
|
build: build-agent build-hub
|
||||||
|
|
||||||
|
generate-locales:
|
||||||
|
@if [ ! -f ./site/src/locales/en/en.ts ]; then \
|
||||||
|
echo "Generating locales..."; \
|
||||||
|
command -v bun >/dev/null 2>&1 && cd ./site && bun install && bun run sync || cd ./site && npm install && npm run sync; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
dev-server: generate-locales
|
||||||
|
cd ./site
|
||||||
|
@if command -v bun >/dev/null 2>&1; then \
|
||||||
|
cd ./site && bun run dev; \
|
||||||
|
else \
|
||||||
|
cd ./site && npm run dev; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
dev-hub:
|
||||||
|
mkdir -p ./site/dist && touch ./site/dist/index.html
|
||||||
|
@if command -v entr >/dev/null 2>&1; then \
|
||||||
|
find ./cmd/hub/*.go ./internal/{alerts,hub,records,users}/*.go | entr -r -s "cd ./cmd/hub && go run . serve"; \
|
||||||
|
else \
|
||||||
|
cd ./cmd/hub && go run . serve; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
dev-agent:
|
||||||
|
@if command -v entr >/dev/null 2>&1; then \
|
||||||
|
find ./cmd/agent/*.go ./internal/agent/*.go | entr -r go run beszel/cmd/agent; \
|
||||||
|
else \
|
||||||
|
go run beszel/cmd/agent; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# KEY="..." make -j dev
|
||||||
|
dev: dev-server dev-hub dev-agent
|
||||||
@@ -3,7 +3,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
"beszel"
|
"beszel"
|
||||||
"beszel/internal/agent"
|
"beszel/internal/agent"
|
||||||
"beszel/internal/update"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
@@ -17,7 +16,7 @@ func main() {
|
|||||||
case "-v":
|
case "-v":
|
||||||
fmt.Println(beszel.AppName+"-agent", beszel.Version)
|
fmt.Println(beszel.AppName+"-agent", beszel.Version)
|
||||||
case "update":
|
case "update":
|
||||||
update.UpdateBeszelAgent()
|
agent.Update()
|
||||||
}
|
}
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
@@ -38,5 +37,5 @@ func main() {
|
|||||||
addr = portEnvVar
|
addr = portEnvVar
|
||||||
}
|
}
|
||||||
|
|
||||||
agent.NewAgent(pubKey, addr).Run()
|
agent.NewAgent().Run(pubKey, addr)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"beszel"
|
"beszel"
|
||||||
"beszel/internal/hub"
|
"beszel/internal/hub"
|
||||||
"beszel/internal/update"
|
|
||||||
_ "beszel/migrations"
|
_ "beszel/migrations"
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase"
|
"github.com/pocketbase/pocketbase"
|
||||||
@@ -22,7 +22,7 @@ func main() {
|
|||||||
app.RootCmd.AddCommand(&cobra.Command{
|
app.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: func(_ *cobra.Command, _ []string) { update.UpdateBeszel() },
|
Run: hub.Update,
|
||||||
})
|
})
|
||||||
|
|
||||||
hub.NewHub(app).Run()
|
hub.NewHub(app).Run()
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ COPY internal ./internal
|
|||||||
|
|
||||||
# Build
|
# Build
|
||||||
ARG TARGETOS TARGETARCH
|
ARG TARGETOS TARGETARCH
|
||||||
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./cmd/agent
|
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./cmd/agent
|
||||||
|
|
||||||
# ? -------------------------
|
# ? -------------------------
|
||||||
FROM scratch
|
FROM scratch
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ RUN update-ca-certificates
|
|||||||
|
|
||||||
# Build
|
# Build
|
||||||
ARG TARGETOS TARGETARCH
|
ARG TARGETOS TARGETARCH
|
||||||
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /beszel ./cmd/hub
|
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /beszel ./cmd/hub
|
||||||
|
|
||||||
# ? -------------------------
|
# ? -------------------------
|
||||||
FROM scratch
|
FROM scratch
|
||||||
|
|||||||
112
beszel/go.mod
@@ -1,99 +1,101 @@
|
|||||||
module beszel
|
module beszel
|
||||||
|
|
||||||
go 1.22.4
|
go 1.23
|
||||||
|
|
||||||
|
toolchain go1.23.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/blang/semver v3.5.1+incompatible
|
github.com/blang/semver v3.5.1+incompatible
|
||||||
github.com/gliderlabs/ssh v0.3.7
|
github.com/containrrr/shoutrrr v0.8.0
|
||||||
github.com/goccy/go-json v0.10.3
|
github.com/gliderlabs/ssh v0.3.8
|
||||||
github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61
|
github.com/goccy/go-json v0.10.4
|
||||||
github.com/pocketbase/dbx v1.10.1
|
github.com/pocketbase/dbx v1.11.0
|
||||||
github.com/pocketbase/pocketbase v0.22.19
|
github.com/pocketbase/pocketbase v0.23.9
|
||||||
github.com/rhysd/go-github-selfupdate v1.2.3
|
github.com/rhysd/go-github-selfupdate v1.2.3
|
||||||
github.com/shirou/gopsutil/v4 v4.24.7
|
github.com/shirou/gopsutil/v4 v4.24.11
|
||||||
|
github.com/spf13/cast v1.7.0
|
||||||
github.com/spf13/cobra v1.8.1
|
github.com/spf13/cobra v1.8.1
|
||||||
golang.org/x/crypto v0.26.0
|
golang.org/x/crypto v0.31.0
|
||||||
|
golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect
|
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect
|
||||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
||||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2 v1.30.4 // indirect
|
github.com/aws/aws-sdk-go-v2 v1.32.6 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.4 // indirect
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.27.28 // indirect
|
github.com/aws/aws-sdk-go-v2/config v1.28.6 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.28 // indirect
|
github.com/aws/aws-sdk-go-v2/credentials v1.17.47 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.12 // indirect
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.21 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.12 // indirect
|
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.43 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.25 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.25 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.16 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.25 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.4 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.18 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.6 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.18 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.6 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.16 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.6 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.60.0 // indirect
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.71.0 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.22.5 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sso v1.24.7 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.5 // indirect
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.6 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.30.4 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sts v1.33.2 // indirect
|
||||||
github.com/aws/smithy-go v1.20.4 // indirect
|
github.com/aws/smithy-go v1.22.1 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/disintegration/imaging v1.6.2 // indirect
|
github.com/disintegration/imaging v1.6.2 // indirect
|
||||||
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/fatih/color v1.17.0 // indirect
|
github.com/ebitengine/purego v0.8.1 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.5 // indirect
|
github.com/fatih/color v1.18.0 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.7 // indirect
|
||||||
github.com/ganigeorgiev/fexpr v0.4.1 // indirect
|
github.com/ganigeorgiev/fexpr v0.4.1 // indirect
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
|
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
|
github.com/golang-jwt/jwt/v4 v4.5.1 // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||||
github.com/google/go-github/v30 v30.1.0 // indirect
|
github.com/google/go-github/v30 v30.1.0 // indirect
|
||||||
github.com/google/go-querystring v1.1.0 // indirect
|
github.com/google/go-querystring v1.1.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/googleapis/gax-go/v2 v2.13.0 // indirect
|
github.com/googleapis/gax-go/v2 v2.14.0 // indirect
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||||
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // 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/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20240819163618-b1d8f4d146e7 // indirect
|
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
|
||||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
|
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // 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/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/shoenig/go-m1cpu v0.1.6 // indirect
|
|
||||||
github.com/spf13/cast v1.7.0 // indirect
|
|
||||||
github.com/spf13/pflag v1.0.5 // indirect
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
github.com/tcnksm/go-gitconfig v0.1.2 // indirect
|
github.com/tcnksm/go-gitconfig v0.1.2 // indirect
|
||||||
github.com/tklauser/go-sysconf v0.3.14 // indirect
|
github.com/tklauser/go-sysconf v0.3.14 // indirect
|
||||||
github.com/tklauser/numcpus v0.8.0 // indirect
|
github.com/tklauser/numcpus v0.9.0 // indirect
|
||||||
github.com/ulikunitz/xz v0.5.12 // indirect
|
github.com/ulikunitz/xz v0.5.12 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
|
||||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
go.opencensus.io v0.24.0 // indirect
|
go.opencensus.io v0.24.0 // indirect
|
||||||
gocloud.dev v0.39.0 // indirect
|
gocloud.dev v0.40.0 // indirect
|
||||||
golang.org/x/image v0.19.0 // indirect
|
golang.org/x/image v0.23.0 // indirect
|
||||||
golang.org/x/net v0.28.0 // indirect
|
golang.org/x/net v0.32.0 // indirect
|
||||||
golang.org/x/oauth2 v0.22.0 // indirect
|
golang.org/x/oauth2 v0.24.0 // indirect
|
||||||
golang.org/x/sync v0.8.0 // indirect
|
golang.org/x/sync v0.10.0 // indirect
|
||||||
golang.org/x/sys v0.24.0 // indirect
|
golang.org/x/sys v0.28.0 // indirect
|
||||||
golang.org/x/term v0.23.0 // indirect
|
golang.org/x/term v0.27.0 // indirect
|
||||||
golang.org/x/text v0.17.0 // indirect
|
golang.org/x/text v0.21.0 // indirect
|
||||||
golang.org/x/time v0.6.0 // indirect
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
||||||
golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 // indirect
|
google.golang.org/api v0.212.0 // indirect
|
||||||
google.golang.org/api v0.194.0 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20241216192217-9240e9c98484 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240822170219-fc7c04adadcd // indirect
|
google.golang.org/grpc v1.69.0 // indirect
|
||||||
google.golang.org/grpc v1.65.0 // indirect
|
google.golang.org/protobuf v1.36.0 // indirect
|
||||||
google.golang.org/protobuf v1.34.2 // indirect
|
modernc.org/gc/v3 v3.0.0-20241213165251-3bc300f6d0c9 // indirect
|
||||||
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // indirect
|
modernc.org/libc v1.61.4 // indirect
|
||||||
modernc.org/libc v1.59.9 // indirect
|
|
||||||
modernc.org/mathutil v1.6.0 // indirect
|
modernc.org/mathutil v1.6.0 // indirect
|
||||||
modernc.org/memory v1.8.0 // indirect
|
modernc.org/memory v1.8.0 // indirect
|
||||||
modernc.org/sqlite v1.32.0 // indirect
|
modernc.org/sqlite v1.34.2 // indirect
|
||||||
modernc.org/strutil v1.2.0 // indirect
|
modernc.org/strutil v1.2.0 // indirect
|
||||||
modernc.org/token v1.1.0 // indirect
|
modernc.org/token v1.1.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
301
beszel/go.sum
@@ -1,12 +1,13 @@
|
|||||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
cloud.google.com/go v0.115.1 h1:Jo0SM9cQnSkYfp44+v+NQXHpcHqlnRJk2qxh6yvxxxQ=
|
cloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14=
|
||||||
cloud.google.com/go v0.115.1/go.mod h1:DuujITeaufu3gL68/lOFIirVNJwQeyf5UXyi+Wbgknc=
|
cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU=
|
||||||
cloud.google.com/go/auth v0.9.1 h1:+pMtLEV2k0AXKvs/tGZojuj6QaioxfUjOpMsG5Gtx+w=
|
cloud.google.com/go/auth v0.13.0 h1:8Fu8TZy167JkW8Tj3q7dIkr2v4cndv41ouecJx0PAHs=
|
||||||
cloud.google.com/go/auth v0.9.1/go.mod h1:Sw8ocT5mhhXxFklyhT12Eiy0ed6tTrPMCJjSI8KhYLk=
|
cloud.google.com/go/auth v0.13.0/go.mod h1:COOjD9gwfKNKz+IIduatIhYJQIc0mG3H102r/EMxX6Q=
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.4 h1:0GWE/FUsXhf6C+jAkWgYm7X9tK8cuEIfy19DBn6B6bY=
|
cloud.google.com/go/auth/oauth2adapt v0.2.6 h1:V6a6XDu2lTwPZWOawrAa9HUK+DB2zfJyTuciBG5hFkU=
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.4/go.mod h1:jC/jOpwFP6JBxhB3P5Rr0a9HLMC/Pe3eaL4NmdvqPtc=
|
cloud.google.com/go/auth/oauth2adapt v0.2.6/go.mod h1:AlmsELtlEBnaNTL7jCj8VQFLy6mbZv0s4Q7NGBeQ5E8=
|
||||||
cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY=
|
cloud.google.com/go/compute v1.14.0 h1:hfm2+FfxVmnRlh6LpB7cg1ZNU+5edAHmW679JePztk0=
|
||||||
cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY=
|
cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
|
||||||
|
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
|
||||||
cloud.google.com/go/iam v1.1.13 h1:7zWBXG9ERbMLrzQBRhFliAV+kjcRToDTgQT3CTwYyv4=
|
cloud.google.com/go/iam v1.1.13 h1:7zWBXG9ERbMLrzQBRhFliAV+kjcRToDTgQT3CTwYyv4=
|
||||||
cloud.google.com/go/iam v1.1.13/go.mod h1:K8mY0uSXwEXS30KrnVb+j54LB/ntfZu1dr+4zFMNbus=
|
cloud.google.com/go/iam v1.1.13/go.mod h1:K8mY0uSXwEXS30KrnVb+j54LB/ntfZu1dr+4zFMNbus=
|
||||||
cloud.google.com/go/storage v1.43.0 h1:CcxnSohZwizt4LCzQHWvBf1/kvtHUn7gk9QERXPyXFs=
|
cloud.google.com/go/storage v1.43.0 h1:CcxnSohZwizt4LCzQHWvBf1/kvtHUn7gk9QERXPyXFs=
|
||||||
@@ -25,78 +26,83 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d
|
|||||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||||
github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
|
github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
|
||||||
github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
|
github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.30.4 h1:frhcagrVNrzmT95RJImMHgabt99vkXGslubDaDagTk8=
|
github.com/aws/aws-sdk-go-v2 v1.32.6 h1:7BokKRgRPuGmKkFMhEg/jSul+tB9VvXhcViILtfG8b4=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.30.4/go.mod h1:CT+ZPWXbYrci8chcARI3OmI/qgd+f6WtuLOoaIA8PR0=
|
github.com/aws/aws-sdk-go-v2 v1.32.6/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U=
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.4 h1:70PVAiL15/aBMh5LThwgXdSQorVr91L127ttckI9QQU=
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 h1:lL7IfaFzngfx0ZwUGOZdsFFnQ5uLvR0hWqqhyE7Q9M8=
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.4/go.mod h1:/MQxMqci8tlqDH+pjmoLu1i0tbWCUP1hhyMRuFxpQCw=
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7/go.mod h1:QraP0UcVlQJsmHfioCrveWOC1nbiWUl3ej08h4mXWoc=
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.27.28 h1:OTxWGW/91C61QlneCtnD62NLb4W616/NM1jA8LhJqbg=
|
github.com/aws/aws-sdk-go-v2/config v1.28.6 h1:D89IKtGrs/I3QXOLNTH93NJYtDhm8SYa9Q5CsPShmyo=
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.27.28/go.mod h1:uzVRVtJSU5EFv6Fu82AoVFKozJi2ZCY6WRCXj06rbvs=
|
github.com/aws/aws-sdk-go-v2/config v1.28.6/go.mod h1:GDzxJ5wyyFSCoLkS+UhGB0dArhb9mI+Co4dHtoTxbko=
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.28 h1:m8+AHY/ND8CMHJnPoH7PJIRakWGa4gbfbxuY9TGTUXM=
|
github.com/aws/aws-sdk-go-v2/credentials v1.17.47 h1:48bA+3/fCdi2yAwVt+3COvmatZ6jUDNkDTIsqDiMUdw=
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.28/go.mod h1:6TF7dSc78ehD1SL6KpRIPKMA1GyyWflIkjqg+qmf4+c=
|
github.com/aws/aws-sdk-go-v2/credentials v1.17.47/go.mod h1:+KdckOejLW3Ks3b0E3b5rHsr2f9yuORBum0WPnE5o5w=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.12 h1:yjwoSyDZF8Jth+mUk5lSPJCkMC0lMy6FaCD51jm6ayE=
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.21 h1:AmoU1pziydclFT/xRV+xXE/Vb8fttJCLRPv8oAkprc0=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.12/go.mod h1:fuR57fAgMk7ot3WcNQfb6rSEn+SUffl7ri+aa8uKysI=
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.21/go.mod h1:AjUdLYe4Tgs6kpH4Bv7uMZo7pottoyHMn4eTcIcneaY=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.12 h1:i7cJ1izNlox4ka6cvbHPTztYGtbpW4Je/jyQIKOIU4A=
|
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.43 h1:iLdpkYZ4cXIQMO7ud+cqMWR1xK5ESbt1rvN77tRi1BY=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.12/go.mod h1:lHnam/4CTEVHaANZD54IrpE80VLK+lUU84WEeJ1FJ8M=
|
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.43/go.mod h1:OgbsKPAswXDd5kxnR4vZov69p3oYjbvUyIRBAAV0y9o=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16 h1:TNyt/+X43KJ9IJJMjKfa3bNTiZbUP7DeCxfbTROESwY=
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.25 h1:s/fF4+yDQDoElYhfIVvSNyeCydfbuTKzhxSXDXCPasU=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16/go.mod h1:2DwJF39FlNAUiX5pAc0UNeiz16lK2t7IaFcm0LFHEgc=
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.25/go.mod h1:IgPfDv5jqFIzQSNbUEMoitNooSMXjRSDkhXv8jiROvU=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16 h1:jYfy8UPmd+6kJW5YhY0L1/KftReOGxI/4NtVSTh9O/I=
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.25 h1:ZntTCl5EsYnhN/IygQEUugpdwbhdkom9uHcbCftiGgA=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16/go.mod h1:7ZfEPZxkW42Afq4uQB8H2E2e6ebh6mXTueEpYzjCzcs=
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.25/go.mod h1:DBdPrgeocww+CSl1C8cEV8PN1mHMBhuCDLpXezyvWkE=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ=
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.16 h1:mimdLQkIX1zr8GIPY1ZtALdBQGxcASiBd2MOp8m/dMc=
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.25 h1:r67ps7oHCYnflpgDy2LZU0MAQtQbYIOqNNnqGO6xQkE=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.16/go.mod h1:YHk6owoSwrIsok+cAH9PENCOGoH5PU2EllX4vLtSrsY=
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.25/go.mod h1:GrGY+Q4fIokYLtjCVB/aFfCVL6hhGUFl8inD18fDalE=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.4 h1:KypMCbLPPHEmf9DgMGw51jMj77VfGPAN2Kv4cfhlfgI=
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 h1:iXtILhvDxB6kPvEXgsDhGaZCSC6LQET5ZHSdJozeI0Y=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.4/go.mod h1:Vz1JQXliGcQktFTN/LN6uGppAIRoLBR2bMvIMP0gOjc=
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1/go.mod h1:9nu0fVANtYiAePIBh2/pFUSwtJ402hLnp854CNoDOeE=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.18 h1:GckUnpm4EJOAio1c8o25a+b3lVfwVzC9gnSBqiiNmZM=
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.6 h1:HCpPsWqmYQieU7SS6E9HXfdAMSud0pteVXieJmcpIRI=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.18/go.mod h1:Br6+bxfG33Dk3ynmkhsW2Z/t9D4+lRqdLDNCKi85w0U=
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.6/go.mod h1:ngUiVRCco++u+soRRVBIvBZxSMMvOVMXA4PJ36JLfSw=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.18 h1:tJ5RnkHCiSH0jyd6gROjlJtNwov0eGYNz8s8nFcR0jQ=
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.6 h1:50+XsN70RS7dwJ2CkVNXzj7U2L1HKP8nqTd3XWEXBN4=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.18/go.mod h1:++NHzT+nAF7ZPrHPsA+ENvsXkOO8wEu+C6RXltAG4/c=
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.6/go.mod h1:WqgLmwY7so32kG01zD8CPTJWVWM+TzJoOVHwTg4aPug=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.16 h1:jg16PhLPUiHIj8zYIW6bqzeQSuHVEiWnGA0Brz5Xv2I=
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.6 h1:BbGDtTi0T1DYlmjBiCr/le3wzhA37O8QTC5/Ab8+EXk=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.16/go.mod h1:Uyk1zE1VVdsHSU7096h/rwnXDzOzYQVl+FNPhPw7ShY=
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.6/go.mod h1:hLMJt7Q8ePgViKupeymbqI0la+t9/iYFBjxQCFwuAwI=
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.60.0 h1:2QXGJvG19QwqXUvgcdoCOZPyLuvZf8LiXPCN4P53TdI=
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.71.0 h1:nyuzXooUNJexRT0Oy0UQY6AhOzxPxhtt4DcBIHyCnmw=
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.60.0/go.mod h1:BSPI0EfnYUuNHPS0uqIo5VrRwzie+Fp+YhQOUs16sKI=
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.71.0/go.mod h1:sT/iQz8JK3u/5gZkT+Hmr7GzVZehUMkRZpOaAwYXeGY=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.22.5 h1:zCsFCKvbj25i7p1u94imVoO447I/sFv8qq+lGJhRN0c=
|
github.com/aws/aws-sdk-go-v2/service/sso v1.24.7 h1:rLnYAfXQ3YAccocshIH5mzNNwZBkBo+bP6EhIxak6Hw=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.22.5/go.mod h1:ZeDX1SnKsVlejeuz41GiajjZpRSWR7/42q/EyA/QEiM=
|
github.com/aws/aws-sdk-go-v2/service/sso v1.24.7/go.mod h1:ZHtuQJ6t9A/+YDuxOLnbryAmITtr8UysSny3qcyvJTc=
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.5 h1:SKvPgvdvmiTWoi0GAJ7AsJfOz3ngVkD/ERbs5pUnHNI=
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.6 h1:JnhTZR3PiYDNKlXy50/pNeix9aGMo6lLpXwJ1mw8MD4=
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.5/go.mod h1:20sz31hv/WsPa3HhU3hfrIet2kxM4Pe0r20eBZ20Tac=
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.6/go.mod h1:URronUEGfXZN1VpdktPSD1EkAL9mfrV+2F4sjH38qOY=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.30.4 h1:iAckBT2OeEK/kBDyN/jDtpEExhjeeA/Im2q4X0rJZT8=
|
github.com/aws/aws-sdk-go-v2/service/sts v1.33.2 h1:s4074ZO1Hk8qv65GqNXqDjmkf4HSQqJukaLuuW0TpDA=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.30.4/go.mod h1:vmSqFK+BVIwVpDAGZB3CoCXHzurt4qBE8lf+I/kRTh0=
|
github.com/aws/aws-sdk-go-v2/service/sts v1.33.2/go.mod h1:mVggCnIWoM09jP71Wh+ea7+5gAp53q+49wDFs1SW5z8=
|
||||||
github.com/aws/smithy-go v1.20.4 h1:2HK1zBdPgRbjFOHlfeQZfpC4r72MOb9bZkiFwggKO+4=
|
github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro=
|
||||||
github.com/aws/smithy-go v1.20.4/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
|
github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
|
||||||
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
|
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
|
||||||
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
|
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
|
||||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||||
|
github.com/containrrr/shoutrrr v0.8.0 h1:mfG2ATzIS7NR2Ec6XL+xyoHzN97H8WPjir8aYzJUSec=
|
||||||
|
github.com/containrrr/shoutrrr v0.8.0/go.mod h1:ioyQAyu1LJY6sILuNyKaQaw+9Ttik5QePU8atnAdO2o=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
|
github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
|
||||||
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
|
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/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
|
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
|
||||||
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/ebitengine/purego v0.8.1 h1:sdRKd6plj7KYW33EH5As6YKfe8m9zbN9JMrOjNVF/BE=
|
||||||
|
github.com/ebitengine/purego v0.8.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||||
github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4=
|
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||||
github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI=
|
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
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/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.5 h1:J7wGKdGu33ocBOhGy0z653k/lFKLFDPJMG8Gql0kxn4=
|
github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.5/go.mod h1:ibHel+/kbxn9x2407k1izTA1S81ku1z/DlgOW2QE0M4=
|
github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU=
|
||||||
github.com/ganigeorgiev/fexpr v0.4.1 h1:hpUgbUEEWIZhSDBtf4M9aUNfQQ0BZkGRaMePy7Gcx5k=
|
github.com/ganigeorgiev/fexpr v0.4.1 h1:hpUgbUEEWIZhSDBtf4M9aUNfQQ0BZkGRaMePy7Gcx5k=
|
||||||
github.com/ganigeorgiev/fexpr v0.4.1/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
|
github.com/ganigeorgiev/fexpr v0.4.1/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
|
||||||
github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE=
|
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||||
github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8=
|
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
@@ -109,14 +115,16 @@ github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRi
|
|||||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||||
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
|
||||||
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
|
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo=
|
||||||
|
github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
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=
|
||||||
@@ -144,8 +152,8 @@ github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQF
|
|||||||
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
|
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 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||||
github.com/google/pprof v0.0.0-20240625030939-27f56978b8b0 h1:e+8XbKB6IMn8A4OAyZccO4pYfB3s7bt6azNIPE7AnPg=
|
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k=
|
||||||
github.com/google/pprof v0.0.0-20240625030939-27f56978b8b0/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
|
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
|
||||||
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
|
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
|
||||||
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
|
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
|
||||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
@@ -153,10 +161,10 @@ 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/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI=
|
github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI=
|
||||||
github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA=
|
github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA=
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
|
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
|
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
|
||||||
github.com/googleapis/gax-go/v2 v2.13.0 h1:yitjD5f7jQHhyDsnhKEBU52NdvvdSeGzlAnDPT0hH1s=
|
github.com/googleapis/gax-go/v2 v2.14.0 h1:f+jMrjBPl+DL9nI4IQzLUxMq7XrAqFYB7hBPqMNIe8o=
|
||||||
github.com/googleapis/gax-go/v2 v2.13.0/go.mod h1:Z/fvTZXF8/uw7Xu5GuslPw+bplx6SS338j1Is2S+B7A=
|
github.com/googleapis/gax-go/v2 v2.14.0/go.mod h1:lhBCnjdLrWRaPvLWhmc8IS24m9mr07qSYnHncrgo+zk=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
|
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
|
||||||
@@ -166,6 +174,8 @@ github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7V
|
|||||||
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg=
|
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.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc=
|
||||||
|
github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
|
||||||
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
|
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
|
||||||
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||||
@@ -177,10 +187,8 @@ 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.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/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61 h1:FwuzbVh87iLiUQj1+uQUsuw9x5t9m5n5g7rG7o4svW4=
|
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0=
|
||||||
github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61/go.mod h1:paQfF1YtHe+GrGg5fOgjsjoCX/UKDr9bc1DoWpZfns8=
|
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
|
||||||
github.com/lufia/plan9stats v0.0.0-20240819163618-b1d8f4d146e7 h1:5RK988zAqB3/AN3opGfRpoQgAVqr6/A5+qRTi67VUZY=
|
|
||||||
github.com/lufia/plan9stats v0.0.0-20240819163618-b1d8f4d146e7/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
|
|
||||||
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
||||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
@@ -188,22 +196,25 @@ github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd
|
|||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
|
||||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
|
||||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
||||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
|
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
|
||||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
||||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
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/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 v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
github.com/onsi/gomega v1.4.2 h1:3mYCb7aPxS/RU7TI1y4rkEn1oKmPRjNJLNEXgw7MH2I=
|
github.com/onsi/ginkgo/v2 v2.9.2 h1:BA2GMJOtfGAfagzYtrAlufIP0lq6QERkFmHLMLPwFSU=
|
||||||
|
github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxeMeDuts=
|
||||||
github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
|
||||||
|
github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
|
||||||
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=
|
||||||
github.com/pocketbase/dbx v1.10.1 h1:cw+vsyfCJD8YObOVeqb93YErnlxwYMkNZ4rwN0G0AaA=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/pocketbase/dbx v1.10.1/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pocketbase/pocketbase v0.22.19 h1:Hu9J2nsRQIaw8MiDLzE65xUPyMPjf4DcS2f+QmH1G+c=
|
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
|
||||||
github.com/pocketbase/pocketbase v0.22.19/go.mod h1:0QFvDOOW7ANId78ChZSagyHbmP6CgMxDQrQFXzeaDpA=
|
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||||
|
github.com/pocketbase/pocketbase v0.23.9 h1:0P3BaMTUO8QzyamYqd/OpPM4L7zmu6HrmDGFQmX+eu4=
|
||||||
|
github.com/pocketbase/pocketbase v0.23.9/go.mod h1:8qIx1v60b+YES3e8H4J2QQF48J0uiydPhRi4ZHlKNjk=
|
||||||
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/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||||
@@ -214,12 +225,8 @@ github.com/rhysd/go-github-selfupdate v1.2.3/go.mod h1:mp/N8zj6jFfBQy/XMYoWsmfzx
|
|||||||
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.24.7 h1:V9UGTK4gQ8HvcnPKf6Zt3XHyQq/peaekfxpJ2HSocJk=
|
github.com/shirou/gopsutil/v4 v4.24.11 h1:WaU9xqGFKvFfsUv94SXcUPD7rCkU0vr/asVdQOBZNj8=
|
||||||
github.com/shirou/gopsutil/v4 v4.24.7/go.mod h1:0uW/073rP7FYLOkvxolUQM5rMOLTNmRXnFKafpb71rw=
|
github.com/shirou/gopsutil/v4 v4.24.11/go.mod h1:s4D/wg+ag4rG0WO7AiTj2BeYCRhym0vM7DHbZRxnIT8=
|
||||||
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
|
||||||
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
|
||||||
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
|
|
||||||
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
|
|
||||||
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
|
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
|
||||||
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||||
@@ -240,48 +247,50 @@ github.com/tcnksm/go-gitconfig v0.1.2 h1:iiDhRitByXAEyjgBqsKi9QU4o2TNtv9kPP3RgPg
|
|||||||
github.com/tcnksm/go-gitconfig v0.1.2/go.mod h1:/8EhP4H7oJZdIPyT+/UIsG87kTzrzM4UsLGSItWYCpE=
|
github.com/tcnksm/go-gitconfig v0.1.2/go.mod h1:/8EhP4H7oJZdIPyT+/UIsG87kTzrzM4UsLGSItWYCpE=
|
||||||
github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU=
|
github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU=
|
||||||
github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY=
|
github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY=
|
||||||
github.com/tklauser/numcpus v0.8.0 h1:Mx4Wwe/FjZLeQsK/6kt2EOepwwSl7SmJrK5bV/dXYgY=
|
github.com/tklauser/numcpus v0.9.0 h1:lmyCHtANi8aRUgkckBgoDk1nHCux3n2cgkJLXdQGPDo=
|
||||||
github.com/tklauser/numcpus v0.8.0/go.mod h1:ZJZlAY+dmR4eut8epnzf0u/VwodKmryxR8txiloSqBE=
|
github.com/tklauser/numcpus v0.9.0/go.mod h1:SN6Nq1O3VychhC1npsWostA+oW+VOQTxZrS604NSRyI=
|
||||||
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||||
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
|
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
|
||||||
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
|
||||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
|
||||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0 h1:9G6E0TXzGFVfTnawRzrPl83iHOAV7L8NJiR8RSGYV1g=
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc=
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0/go.mod h1:azvtTADFQJA8mX80jIH/akaE7h+dbm/sVuaHqN13w74=
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 h1:4K4tsIXefpVJtvA/8srF4V4y0akAoPHkIslgAkjixJA=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0/go.mod h1:jjdQuTGVsXV4vSs+CJ2qYDeDPf9yIJV23qlIzBm73Vg=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
||||||
go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo=
|
go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY=
|
||||||
go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4=
|
go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE=
|
||||||
go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q=
|
go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE=
|
||||||
go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s=
|
go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY=
|
||||||
go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g=
|
go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk=
|
||||||
go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI=
|
go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0=
|
||||||
gocloud.dev v0.39.0 h1:EYABYGhAalPUaMrbSKOr5lejxoxvXj99nE8XFtsDgds=
|
go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc=
|
||||||
gocloud.dev v0.39.0/go.mod h1:drz+VyYNBvrMTW0KZiBAYEdl8lbNZx+OQ7oQvdrFmSQ=
|
go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8=
|
||||||
|
go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys=
|
||||||
|
go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A=
|
||||||
|
gocloud.dev v0.40.0 h1:f8LgP+4WDqOG/RXoUcyLpeIAGOcAbZrZbDQCUee10ng=
|
||||||
|
gocloud.dev v0.40.0/go.mod h1:drz+VyYNBvrMTW0KZiBAYEdl8lbNZx+OQ7oQvdrFmSQ=
|
||||||
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-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
|
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||||
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
|
golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 h1:1UoZQm6f0P/ZO0w1Ri+f+ifG/gXhegadRdwBIXEFWDo=
|
||||||
|
golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/image v0.19.0 h1:D9FX4QWkLfkeqaC62SonffIIuYdOk/UE2XKUBgRIBIQ=
|
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
|
||||||
golang.org/x/image v0.19.0/go.mod h1:y0zrRqlQRWQ5PXaYCOMLTW2fpsxZ8Qh9I/ohnInJEys=
|
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
|
||||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
|
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
|
||||||
golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/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-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
@@ -293,18 +302,18 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
|
|||||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
|
golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI=
|
||||||
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
|
golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
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.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA=
|
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
|
||||||
golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
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=
|
||||||
@@ -321,23 +330,23 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
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.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
|
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||||
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU=
|
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
|
||||||
golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk=
|
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||||
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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
|
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||||
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
|
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
|
||||||
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
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.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||||
@@ -345,14 +354,14 @@ golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3
|
|||||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
|
golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8=
|
||||||
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
|
golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 h1:LLhsEBxRTBLuKlQxFBYUOU8xyFgXv6cOTp2HASDlsDk=
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
|
||||||
golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||||
google.golang.org/api v0.194.0 h1:dztZKG9HgtIpbI35FhfuSNR/zmaMVdxNlntHj1sIS4s=
|
google.golang.org/api v0.212.0 h1:BcRj3MJfHF3FYD29rk7u9kuu1SyfGqfHcA0hSwKqkHg=
|
||||||
google.golang.org/api v0.194.0/go.mod h1:AgvUFdojGANh3vI+P7EVnxj3AISHllxGCJSFmggmnd0=
|
google.golang.org/api v0.212.0/go.mod h1:gICpLlpp12/E8mycRMzgy3SQ9cFh2XnVJ6vJi/kQbvI=
|
||||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
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.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
@@ -360,19 +369,19 @@ google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCID
|
|||||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||||
google.golang.org/genproto v0.0.0-20240814211410-ddb44dafa142 h1:oLiyxGgE+rt22duwci1+TG7bg2/L1LQsXwfjPlmuJA0=
|
google.golang.org/genproto v0.0.0-20240812133136-8ffd90a71988 h1:CT2Thj5AuPV9phrYMtzX11k+XkzMGfRAet42PmoTATM=
|
||||||
google.golang.org/genproto v0.0.0-20240814211410-ddb44dafa142/go.mod h1:G11eXq53iI5Q+kyNOmCvnzBaxEA2Q/Ik5Tj7nqBE8j4=
|
google.golang.org/genproto v0.0.0-20240812133136-8ffd90a71988/go.mod h1:7uvplUBj4RjHAxIZ//98LzOvrQ04JBkaixRmCMI29hc=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20240812133136-8ffd90a71988 h1:+/tmTy5zAieooKIXfzDm9KiA3Bv6JBwriRN9LY+yayk=
|
google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28 h1:M0KvPgPmDZHPlbRbaNU1APr28TvwvvdUPlSv7PUvy8g=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20240812133136-8ffd90a71988/go.mod h1:4+X6GvPs+25wZKbQq9qyAXrwIRExv7w0Ea6MgZLZiDM=
|
google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28/go.mod h1:dguCy7UOdZhTvLzDyt15+rOrawrpM4q7DD9dQ1P11P4=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240822170219-fc7c04adadcd h1:6TEm2ZxXoQmFWFlt1vNxvVOa1Q0dXFQD1m/rYjXmS0E=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20241216192217-9240e9c98484 h1:Z7FRVJPSMaHQxD0uXU8WdgFh8PseLM8Q8NzhnpMrBhQ=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240822170219-fc7c04adadcd/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20241216192217-9240e9c98484/go.mod h1:lcTa1sDdWEIHMWlITnIczmw5w60CF9ffkb8Z+DVmmjA=
|
||||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||||
google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc=
|
google.golang.org/grpc v1.69.0 h1:quSiOM1GJPmPH5XtU+BCoVXcDVJJAzNcoyfC2cCjGkI=
|
||||||
google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ=
|
google.golang.org/grpc v1.69.0/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4=
|
||||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||||
@@ -382,32 +391,32 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
|
|||||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
google.golang.org/protobuf v1.36.0 h1:mjIs9gYtt56AzC4ZaffQuh88TZurBGhIJMBZGSxNerQ=
|
||||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
google.golang.org/protobuf v1.36.0/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||||
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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
gopkg.in/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/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.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
|
||||||
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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
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=
|
||||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
modernc.org/cc/v4 v4.23.1 h1:WqJoPL3x4cUufQVHkXpXX7ThFJ1C4ik80i2eXEXbhD8=
|
||||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
modernc.org/cc/v4 v4.23.1/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||||
modernc.org/ccgo/v4 v4.20.7 h1:skrinQsjxWfvj6nbC3ztZPJy+NuwmB3hV9zX/pthNYQ=
|
modernc.org/ccgo/v4 v4.23.1 h1:N49a7JiWGWV7lkPE4yYcvjkBGZQi93/JabRYjdWmJXc=
|
||||||
modernc.org/ccgo/v4 v4.20.7/go.mod h1:UOkI3JSG2zT4E2ioHlncSOZsXbuDCZLvPi3uMlZT5GY=
|
modernc.org/ccgo/v4 v4.23.1/go.mod h1:JoIUegEIfutvoWV/BBfDFpPpfR2nc3U0jKucGcbmwDU=
|
||||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||||
modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M=
|
modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M=
|
||||||
modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||||
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a h1:CfbpOLEo2IwNzJdMvE8aiRbPMxoTpgAJeyePh0SmO8M=
|
modernc.org/gc/v3 v3.0.0-20241213165251-3bc300f6d0c9 h1:ovz6yUKX71igz2yvk4NpiCL5fvdjZAI+DhuDEGx1xyU=
|
||||||
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
modernc.org/gc/v3 v3.0.0-20241213165251-3bc300f6d0c9/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||||
modernc.org/libc v1.59.9 h1:k+nNDDakwipimgmJ1D9H466LhFeSkaPPycAs1OZiDmY=
|
modernc.org/libc v1.61.4 h1:wVyqEx6tlltte9lPTjq0kDAdtdM9c4JH8rU6M1ZVawA=
|
||||||
modernc.org/libc v1.59.9/go.mod h1:EY/egGEU7Ju66eU6SBqCNYaFUDuc4npICkMWnU5EE3A=
|
modernc.org/libc v1.61.4/go.mod h1:VfXVuM/Shh5XsMNrh3C6OkfL78G3loa4ZC/Ljv9k7xc=
|
||||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||||
@@ -416,8 +425,8 @@ modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
|||||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||||
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||||
modernc.org/sqlite v1.32.0 h1:6BM4uGza7bWypsw4fdLRsLxut6bHe4c58VeqjRgST8s=
|
modernc.org/sqlite v1.34.2 h1:J9n76TPsfYYkFkZ9Uy1QphILYifiVEwwOT7yP5b++2Y=
|
||||||
modernc.org/sqlite v1.32.0/go.mod h1:UqoylwmTb9F+IqXERT8bW9zzOWN8qwAIcLdzeBZs4hA=
|
modernc.org/sqlite v1.34.2/go.mod h1:dnR723UrTtjKpoHCAMN0Q/gZ9MT4r+iRvIBb9umWFkU=
|
||||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
|||||||
@@ -1,518 +1,116 @@
|
|||||||
|
// Package agent handles the agent's SSH server and system stats collection.
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel"
|
"beszel"
|
||||||
"beszel/internal/entities/container"
|
|
||||||
"beszel/internal/entities/system"
|
"beszel/internal/entities/system"
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"log/slog"
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"math"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/cpu"
|
"github.com/shirou/gopsutil/v4/common"
|
||||||
"github.com/shirou/gopsutil/v4/disk"
|
|
||||||
"github.com/shirou/gopsutil/v4/host"
|
|
||||||
"github.com/shirou/gopsutil/v4/mem"
|
|
||||||
"github.com/shirou/gopsutil/v4/sensors"
|
|
||||||
|
|
||||||
sshServer "github.com/gliderlabs/ssh"
|
|
||||||
psutilNet "github.com/shirou/gopsutil/v4/net"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Agent struct {
|
type Agent struct {
|
||||||
addr string
|
debug bool // true if LOG_LEVEL is set to debug
|
||||||
pubKey []byte
|
zfs bool // true if system has arcstats
|
||||||
sem chan struct{}
|
memCalc string // Memory calculation formula
|
||||||
containerStatsMap map[string]*container.PrevContainerStats
|
fsNames []string // List of filesystem device names being monitored
|
||||||
containerStatsMutex *sync.Mutex
|
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
|
||||||
diskIoStats *system.DiskIoStats
|
netInterfaces map[string]struct{} // Stores all valid network interfaces
|
||||||
netIoStats *system.NetIoStats
|
netIoStats system.NetIoStats // Keeps track of bandwidth usage
|
||||||
dockerClient *http.Client
|
dockerManager *dockerManager // Manages Docker API requests
|
||||||
containerStatsPool *sync.Pool
|
sensorsContext context.Context // Sensors context to override sys location
|
||||||
bufferPool *sync.Pool
|
sensorsWhitelist map[string]struct{} // List of sensors to monitor
|
||||||
|
systemInfo system.Info // Host system info
|
||||||
|
gpuManager *GPUManager // Manages GPU data
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAgent(pubKey []byte, addr string) *Agent {
|
func NewAgent() *Agent {
|
||||||
return &Agent{
|
return &Agent{
|
||||||
addr: addr,
|
sensorsContext: context.Background(),
|
||||||
pubKey: pubKey,
|
memCalc: os.Getenv("MEM_CALC"),
|
||||||
sem: make(chan struct{}, 15),
|
fsStats: make(map[string]*system.FsStats),
|
||||||
containerStatsMap: make(map[string]*container.PrevContainerStats),
|
|
||||||
containerStatsMutex: &sync.Mutex{},
|
|
||||||
diskIoStats: &system.DiskIoStats{},
|
|
||||||
netIoStats: &system.NetIoStats{},
|
|
||||||
dockerClient: newDockerClient(),
|
|
||||||
containerStatsPool: &sync.Pool{
|
|
||||||
New: func() interface{} {
|
|
||||||
return new(container.Stats)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
bufferPool: &sync.Pool{
|
|
||||||
New: func() interface{} {
|
|
||||||
return new(bytes.Buffer)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) acquireSemaphore() {
|
func (a *Agent) Run(pubKey []byte, addr string) {
|
||||||
a.sem <- struct{}{}
|
// Set up slog with a log level determined by the LOG_LEVEL env var
|
||||||
}
|
if logLevelStr, exists := os.LookupEnv("LOG_LEVEL"); exists {
|
||||||
|
switch strings.ToLower(logLevelStr) {
|
||||||
func (a *Agent) releaseSemaphore() {
|
case "debug":
|
||||||
<-a.sem
|
a.debug = true
|
||||||
}
|
slog.SetLogLoggerLevel(slog.LevelDebug)
|
||||||
|
case "warn":
|
||||||
func (a *Agent) getSystemStats() (*system.Info, *system.Stats) {
|
slog.SetLogLoggerLevel(slog.LevelWarn)
|
||||||
systemStats := &system.Stats{}
|
case "error":
|
||||||
|
slog.SetLogLoggerLevel(slog.LevelError)
|
||||||
// cpu percent
|
|
||||||
cpuPct, err := cpu.Percent(0, false)
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Error getting cpu percent:", err)
|
|
||||||
} else if len(cpuPct) > 0 {
|
|
||||||
systemStats.Cpu = twoDecimals(cpuPct[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
// memory
|
|
||||||
if v, err := mem.VirtualMemory(); err == nil {
|
|
||||||
systemStats.Mem = bytesToGigabytes(v.Total)
|
|
||||||
systemStats.MemUsed = bytesToGigabytes(v.Used)
|
|
||||||
systemStats.MemBuffCache = bytesToGigabytes(v.Total - v.Free - v.Used)
|
|
||||||
systemStats.MemPct = twoDecimals(v.UsedPercent)
|
|
||||||
systemStats.Swap = bytesToGigabytes(v.SwapTotal)
|
|
||||||
systemStats.SwapUsed = bytesToGigabytes(v.SwapTotal - v.SwapFree)
|
|
||||||
}
|
|
||||||
|
|
||||||
// disk usage
|
|
||||||
if d, err := disk.Usage("/"); err == nil {
|
|
||||||
systemStats.Disk = bytesToGigabytes(d.Total)
|
|
||||||
systemStats.DiskUsed = bytesToGigabytes(d.Used)
|
|
||||||
systemStats.DiskPct = twoDecimals(d.UsedPercent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// disk i/o
|
|
||||||
if io, err := disk.IOCounters(a.diskIoStats.Filesystem); err == nil {
|
|
||||||
for _, d := range io {
|
|
||||||
// add to systemStats
|
|
||||||
secondsElapsed := time.Since(a.diskIoStats.Time).Seconds()
|
|
||||||
readPerSecond := float64(d.ReadBytes-a.diskIoStats.Read) / secondsElapsed
|
|
||||||
systemStats.DiskRead = bytesToMegabytes(readPerSecond)
|
|
||||||
writePerSecond := float64(d.WriteBytes-a.diskIoStats.Write) / secondsElapsed
|
|
||||||
systemStats.DiskWrite = bytesToMegabytes(writePerSecond)
|
|
||||||
// update diskIoStats
|
|
||||||
a.diskIoStats.Time = time.Now()
|
|
||||||
a.diskIoStats.Read = d.ReadBytes
|
|
||||||
a.diskIoStats.Write = d.WriteBytes
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// network stats
|
slog.Debug(beszel.Version)
|
||||||
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
|
||||||
bytesSent := uint64(0)
|
// Set sensors context (allows overriding sys location for sensors)
|
||||||
bytesRecv := uint64(0)
|
if sysSensors, exists := os.LookupEnv("SYS_SENSORS"); exists {
|
||||||
for _, v := range netIO {
|
slog.Info("SYS_SENSORS", "path", sysSensors)
|
||||||
if skipNetworkInterface(&v) {
|
a.sensorsContext = context.WithValue(a.sensorsContext,
|
||||||
continue
|
common.EnvKey, common.EnvMap{common.HostSysEnvKey: sysSensors},
|
||||||
}
|
)
|
||||||
// log.Printf("%+v: %+v recv, %+v sent\n", v.Name, v.BytesRecv, v.BytesSent)
|
|
||||||
bytesSent += v.BytesSent
|
|
||||||
bytesRecv += v.BytesRecv
|
|
||||||
}
|
|
||||||
// add to systemStats
|
|
||||||
secondsElapsed := time.Since(a.netIoStats.Time).Seconds()
|
|
||||||
sentPerSecond := float64(bytesSent-a.netIoStats.BytesSent) / secondsElapsed
|
|
||||||
recvPerSecond := float64(bytesRecv-a.netIoStats.BytesRecv) / secondsElapsed
|
|
||||||
systemStats.NetworkSent = bytesToMegabytes(sentPerSecond)
|
|
||||||
systemStats.NetworkRecv = bytesToMegabytes(recvPerSecond)
|
|
||||||
// update netIoStats
|
|
||||||
a.netIoStats.BytesSent = bytesSent
|
|
||||||
a.netIoStats.BytesRecv = bytesRecv
|
|
||||||
a.netIoStats.Time = time.Now()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// temperatures
|
// Set sensors whitelist
|
||||||
if temps, err := sensors.SensorsTemperatures(); err == nil {
|
if sensors, exists := os.LookupEnv("SENSORS"); exists {
|
||||||
systemStats.Temperatures = make(map[string]float64)
|
a.sensorsWhitelist = make(map[string]struct{})
|
||||||
// log.Printf("Temperatures: %+v\n", temps)
|
for _, sensor := range strings.Split(sensors, ",") {
|
||||||
for i, temp := range temps {
|
if sensor != "" {
|
||||||
if _, ok := systemStats.Temperatures[temp.SensorKey]; ok {
|
a.sensorsWhitelist[sensor] = struct{}{}
|
||||||
// if key already exists, append int to key
|
|
||||||
systemStats.Temperatures[temp.SensorKey+"_"+strconv.Itoa(i)] = twoDecimals(temp.Temperature)
|
|
||||||
} else {
|
|
||||||
systemStats.Temperatures[temp.SensorKey] = twoDecimals(temp.Temperature)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// log.Printf("Temperature map: %+v\n", systemStats.Temperatures)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
systemInfo := &system.Info{
|
// initialize system info / docker manager
|
||||||
Cpu: systemStats.Cpu,
|
a.initializeSystemInfo()
|
||||||
MemPct: systemStats.MemPct,
|
a.initializeDiskInfo()
|
||||||
DiskPct: systemStats.DiskPct,
|
a.initializeNetIoStats()
|
||||||
AgentVersion: beszel.Version,
|
a.dockerManager = newDockerManager(a)
|
||||||
|
|
||||||
|
// initialize GPU manager
|
||||||
|
if gm, err := NewGPUManager(); err != nil {
|
||||||
|
slog.Debug("GPU", "err", err)
|
||||||
|
} else {
|
||||||
|
a.gpuManager = gm
|
||||||
}
|
}
|
||||||
|
|
||||||
// add host info
|
// if debugging, print stats
|
||||||
if info, err := host.Info(); err == nil {
|
if a.debug {
|
||||||
systemInfo.Uptime = info.Uptime
|
slog.Debug("Stats", "data", a.gatherStats())
|
||||||
// systemInfo.Os = info.OS
|
|
||||||
}
|
|
||||||
// add cpu stats
|
|
||||||
if info, err := cpu.Info(); err == nil && len(info) > 0 {
|
|
||||||
systemInfo.CpuModel = info[0].ModelName
|
|
||||||
}
|
|
||||||
if cores, err := cpu.Counts(false); err == nil {
|
|
||||||
systemInfo.Cores = cores
|
|
||||||
}
|
|
||||||
if threads, err := cpu.Counts(true); err == nil {
|
|
||||||
systemInfo.Threads = threads
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return systemInfo, systemStats
|
a.startServer(pubKey, addr)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) getDockerStats() ([]*container.Stats, error) {
|
func (a *Agent) gatherStats() system.CombinedData {
|
||||||
resp, err := a.dockerClient.Get("http://localhost/containers/json")
|
slog.Debug("Getting stats")
|
||||||
if err != nil {
|
systemData := system.CombinedData{
|
||||||
a.closeIdleConnections(err)
|
Stats: a.getSystemStats(),
|
||||||
return nil, err
|
Info: a.systemInfo,
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
slog.Debug("System stats", "data", systemData)
|
||||||
|
// add docker stats
|
||||||
var containers []*container.ApiInfo
|
if containerStats, err := a.dockerManager.getDockerStats(); err == nil {
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&containers); err != nil {
|
|
||||||
log.Printf("Error decoding containers: %+v\n", err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
containerStats := make([]*container.Stats, 0, len(containers))
|
|
||||||
|
|
||||||
// store valid ids to clean up old container ids from map
|
|
||||||
validIds := make(map[string]struct{}, len(containers))
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
|
|
||||||
for _, ctr := range containers {
|
|
||||||
ctr.IdShort = ctr.Id[:12]
|
|
||||||
validIds[ctr.IdShort] = struct{}{}
|
|
||||||
// check if container is less than 1 minute old (possible restart)
|
|
||||||
// note: can't use Created field because it's not updated on restart
|
|
||||||
if strings.Contains(ctr.Status, "second") {
|
|
||||||
// if so, remove old container data
|
|
||||||
a.deleteContainerStatsSync(ctr.IdShort)
|
|
||||||
}
|
|
||||||
wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
cstats, err := a.getContainerStats(ctr)
|
|
||||||
if err != nil {
|
|
||||||
// close idle connections if error is a network timeout
|
|
||||||
isTimeout := a.closeIdleConnections(err)
|
|
||||||
// delete container from map if not a timeout
|
|
||||||
if !isTimeout {
|
|
||||||
a.deleteContainerStatsSync(ctr.IdShort)
|
|
||||||
}
|
|
||||||
// retry once
|
|
||||||
cstats, err = a.getContainerStats(ctr)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error getting container stats: %+v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
containerStats = append(containerStats, cstats)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
for id := range a.containerStatsMap {
|
|
||||||
if _, exists := validIds[id]; !exists {
|
|
||||||
// log.Printf("Removing container cpu map entry: %+v\n", id)
|
|
||||||
delete(a.containerStatsMap, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return containerStats, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Agent) getContainerStats(ctr *container.ApiInfo) (*container.Stats, error) {
|
|
||||||
// use semaphore to limit concurrency
|
|
||||||
a.acquireSemaphore()
|
|
||||||
defer a.releaseSemaphore()
|
|
||||||
|
|
||||||
resp, err := a.dockerClient.Get("http://localhost/containers/" + ctr.IdShort + "/stats?stream=0&one-shot=1")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
// get a buffer from the pool
|
|
||||||
buf := a.bufferPool.Get().(*bytes.Buffer)
|
|
||||||
defer a.bufferPool.Put(buf)
|
|
||||||
buf.Reset()
|
|
||||||
// read the response body into the buffer
|
|
||||||
_, err = io.Copy(buf, resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// unmarshal the json data from the buffer
|
|
||||||
var statsJson container.ApiStats
|
|
||||||
if err := json.Unmarshal(buf.Bytes(), &statsJson); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
name := ctr.Names[0][1:]
|
|
||||||
|
|
||||||
// check if container has valid data, otherwise may be in restart loop (#103)
|
|
||||||
if statsJson.MemoryStats.Usage == 0 {
|
|
||||||
return nil, fmt.Errorf("%s - invalid data", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// memory (https://docs.docker.com/reference/cli/docker/container/stats/)
|
|
||||||
memCache := statsJson.MemoryStats.Stats["inactive_file"]
|
|
||||||
if memCache == 0 {
|
|
||||||
memCache = statsJson.MemoryStats.Stats["cache"]
|
|
||||||
}
|
|
||||||
usedMemory := statsJson.MemoryStats.Usage - memCache
|
|
||||||
|
|
||||||
a.containerStatsMutex.Lock()
|
|
||||||
defer a.containerStatsMutex.Unlock()
|
|
||||||
|
|
||||||
// add empty values if they doesn't exist in map
|
|
||||||
stats, initialized := a.containerStatsMap[ctr.IdShort]
|
|
||||||
if !initialized {
|
|
||||||
stats = &container.PrevContainerStats{}
|
|
||||||
a.containerStatsMap[ctr.IdShort] = stats
|
|
||||||
}
|
|
||||||
|
|
||||||
// cpu
|
|
||||||
cpuDelta := statsJson.CPUStats.CPUUsage.TotalUsage - stats.Cpu[0]
|
|
||||||
systemDelta := statsJson.CPUStats.SystemUsage - stats.Cpu[1]
|
|
||||||
cpuPct := float64(cpuDelta) / float64(systemDelta) * 100
|
|
||||||
if cpuPct > 100 {
|
|
||||||
return nil, fmt.Errorf("%s cpu pct greater than 100: %+v", name, cpuPct)
|
|
||||||
}
|
|
||||||
stats.Cpu = [2]uint64{statsJson.CPUStats.CPUUsage.TotalUsage, statsJson.CPUStats.SystemUsage}
|
|
||||||
|
|
||||||
// network
|
|
||||||
var total_sent, total_recv uint64
|
|
||||||
for _, v := range statsJson.Networks {
|
|
||||||
total_sent += v.TxBytes
|
|
||||||
total_recv += v.RxBytes
|
|
||||||
}
|
|
||||||
var sent_delta, recv_delta float64
|
|
||||||
// prevent first run from sending all prev sent/recv bytes
|
|
||||||
if initialized {
|
|
||||||
secondsElapsed := time.Since(stats.Net.Time).Seconds()
|
|
||||||
sent_delta = float64(total_sent-stats.Net.Sent) / secondsElapsed
|
|
||||||
recv_delta = float64(total_recv-stats.Net.Recv) / secondsElapsed
|
|
||||||
// log.Printf("sent delta: %+v, recv delta: %+v\n", sent_delta, recv_delta)
|
|
||||||
}
|
|
||||||
stats.Net.Sent = total_sent
|
|
||||||
stats.Net.Recv = total_recv
|
|
||||||
stats.Net.Time = time.Now()
|
|
||||||
|
|
||||||
cStats := a.containerStatsPool.Get().(*container.Stats)
|
|
||||||
cStats.Name = name
|
|
||||||
cStats.Cpu = twoDecimals(cpuPct)
|
|
||||||
cStats.Mem = bytesToMegabytes(float64(usedMemory))
|
|
||||||
cStats.NetworkSent = bytesToMegabytes(sent_delta)
|
|
||||||
cStats.NetworkRecv = bytesToMegabytes(recv_delta)
|
|
||||||
|
|
||||||
return cStats, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// delete container stats from map using mutex
|
|
||||||
func (a *Agent) deleteContainerStatsSync(id string) {
|
|
||||||
a.containerStatsMutex.Lock()
|
|
||||||
defer a.containerStatsMutex.Unlock()
|
|
||||||
delete(a.containerStatsMap, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Agent) gatherStats() *system.CombinedData {
|
|
||||||
systemInfo, systemStats := a.getSystemStats()
|
|
||||||
systemData := &system.CombinedData{
|
|
||||||
Stats: systemStats,
|
|
||||||
Info: systemInfo,
|
|
||||||
}
|
|
||||||
if containerStats, err := a.getDockerStats(); err == nil {
|
|
||||||
systemData.Containers = containerStats
|
systemData.Containers = containerStats
|
||||||
|
slog.Debug("Docker stats", "data", systemData.Containers)
|
||||||
|
} else {
|
||||||
|
slog.Debug("Error getting docker stats", "err", err)
|
||||||
}
|
}
|
||||||
// fmt.Printf("%+v\n", systemData)
|
// add extra filesystems
|
||||||
|
systemData.Stats.ExtraFs = make(map[string]*system.FsStats)
|
||||||
|
for name, stats := range a.fsStats {
|
||||||
|
if !stats.Root && stats.DiskTotal > 0 {
|
||||||
|
systemData.Stats.ExtraFs[name] = stats
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slog.Debug("Extra filesystems", "data", systemData.Stats.ExtraFs)
|
||||||
return systemData
|
return systemData
|
||||||
}
|
}
|
||||||
|
|
||||||
// return container stats to pool
|
|
||||||
func (a *Agent) returnStatsToPool(containerStats []*container.Stats) {
|
|
||||||
for _, stats := range containerStats {
|
|
||||||
a.containerStatsPool.Put(stats)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Agent) startServer() {
|
|
||||||
sshServer.Handle(a.handleSession)
|
|
||||||
|
|
||||||
log.Printf("Starting SSH server on %s", a.addr)
|
|
||||||
if err := sshServer.ListenAndServe(a.addr, nil, sshServer.NoPty(),
|
|
||||||
sshServer.PublicKeyAuth(func(ctx sshServer.Context, key sshServer.PublicKey) bool {
|
|
||||||
allowed, _, _, _, _ := sshServer.ParseAuthorizedKey(a.pubKey)
|
|
||||||
return sshServer.KeysEqual(key, allowed)
|
|
||||||
}),
|
|
||||||
); err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Agent) handleSession(s sshServer.Session) {
|
|
||||||
stats := a.gatherStats()
|
|
||||||
defer a.returnStatsToPool(stats.Containers)
|
|
||||||
encoder := json.NewEncoder(s)
|
|
||||||
if err := encoder.Encode(stats); err != nil {
|
|
||||||
log.Println("Error encoding stats:", err.Error())
|
|
||||||
s.Exit(1)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.Exit(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Agent) Run() {
|
|
||||||
if filesystem, exists := os.LookupEnv("FILESYSTEM"); exists {
|
|
||||||
a.diskIoStats.Filesystem = filesystem
|
|
||||||
} else {
|
|
||||||
a.diskIoStats.Filesystem = findDefaultFilesystem()
|
|
||||||
}
|
|
||||||
|
|
||||||
a.initializeDiskIoStats()
|
|
||||||
a.initializeNetIoStats()
|
|
||||||
|
|
||||||
a.startServer()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Agent) initializeDiskIoStats() {
|
|
||||||
if io, err := disk.IOCounters(a.diskIoStats.Filesystem); err == nil {
|
|
||||||
for _, d := range io {
|
|
||||||
a.diskIoStats.Time = time.Now()
|
|
||||||
a.diskIoStats.Read = d.ReadBytes
|
|
||||||
a.diskIoStats.Write = d.WriteBytes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Agent) initializeNetIoStats() {
|
|
||||||
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
|
||||||
bytesSent := uint64(0)
|
|
||||||
bytesRecv := uint64(0)
|
|
||||||
for _, v := range netIO {
|
|
||||||
if skipNetworkInterface(&v) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
log.Printf("Found network interface: %+v (%+v recv, %+v sent)\n", v.Name, v.BytesRecv, v.BytesSent)
|
|
||||||
bytesSent += v.BytesSent
|
|
||||||
bytesRecv += v.BytesRecv
|
|
||||||
}
|
|
||||||
a.netIoStats.BytesSent = bytesSent
|
|
||||||
a.netIoStats.BytesRecv = bytesRecv
|
|
||||||
a.netIoStats.Time = time.Now()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func bytesToMegabytes(b float64) float64 {
|
|
||||||
return twoDecimals(b / 1048576)
|
|
||||||
}
|
|
||||||
|
|
||||||
func bytesToGigabytes(b uint64) float64 {
|
|
||||||
return twoDecimals(float64(b) / 1073741824)
|
|
||||||
}
|
|
||||||
|
|
||||||
func twoDecimals(value float64) float64 {
|
|
||||||
return math.Round(value*100) / 100
|
|
||||||
}
|
|
||||||
|
|
||||||
func findDefaultFilesystem() string {
|
|
||||||
if partitions, err := disk.Partitions(false); err == nil {
|
|
||||||
for _, v := range partitions {
|
|
||||||
if v.Mountpoint == "/" {
|
|
||||||
log.Printf("Using filesystem: %+v\n", v.Device)
|
|
||||||
return v.Device
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func skipNetworkInterface(v *psutilNet.IOCountersStat) bool {
|
|
||||||
switch {
|
|
||||||
case strings.HasPrefix(v.Name, "lo"),
|
|
||||||
strings.HasPrefix(v.Name, "docker"),
|
|
||||||
strings.HasPrefix(v.Name, "br-"),
|
|
||||||
strings.HasPrefix(v.Name, "veth"),
|
|
||||||
v.BytesRecv == 0,
|
|
||||||
v.BytesSent == 0:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newDockerClient() *http.Client {
|
|
||||||
dockerHost := "unix:///var/run/docker.sock"
|
|
||||||
if dockerHostEnv, exists := os.LookupEnv("DOCKER_HOST"); exists {
|
|
||||||
dockerHost = dockerHostEnv
|
|
||||||
}
|
|
||||||
|
|
||||||
parsedURL, err := url.Parse(dockerHost)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Error parsing DOCKER_HOST: " + err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
transport := &http.Transport{
|
|
||||||
ForceAttemptHTTP2: false,
|
|
||||||
IdleConnTimeout: 90 * time.Second,
|
|
||||||
DisableCompression: true,
|
|
||||||
MaxConnsPerHost: 20,
|
|
||||||
MaxIdleConnsPerHost: 20,
|
|
||||||
DisableKeepAlives: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
switch parsedURL.Scheme {
|
|
||||||
case "unix":
|
|
||||||
transport.DialContext = func(ctx context.Context, proto, addr string) (net.Conn, error) {
|
|
||||||
return (&net.Dialer{}).DialContext(ctx, "unix", parsedURL.Path)
|
|
||||||
}
|
|
||||||
case "tcp", "http", "https":
|
|
||||||
log.Println("Using DOCKER_HOST: " + dockerHost)
|
|
||||||
transport.DialContext = func(ctx context.Context, proto, addr string) (net.Conn, error) {
|
|
||||||
return (&net.Dialer{}).DialContext(ctx, "tcp", parsedURL.Host)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
log.Fatal("Unsupported DOCKER_HOST: " + parsedURL.Scheme)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &http.Client{
|
|
||||||
Timeout: time.Second,
|
|
||||||
Transport: transport,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// closes idle connections on timeouts to prevent reuse of stale connections
|
|
||||||
func (a *Agent) closeIdleConnections(err error) (isTimeout bool) {
|
|
||||||
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
|
||||||
log.Printf("Closing idle connections. Error: %+v\n", err)
|
|
||||||
a.dockerClient.Transport.(*http.Transport).CloseIdleConnections()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|||||||
185
beszel/internal/agent/disk.go
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel/internal/entities/system"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/shirou/gopsutil/v4/disk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sets up the filesystems to monitor for disk usage and I/O.
|
||||||
|
func (a *Agent) initializeDiskInfo() {
|
||||||
|
filesystem := os.Getenv("FILESYSTEM")
|
||||||
|
efPath := "/extra-filesystems"
|
||||||
|
hasRoot := false
|
||||||
|
|
||||||
|
partitions, err := disk.Partitions(false)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error getting disk partitions", "err", err)
|
||||||
|
}
|
||||||
|
slog.Debug("Disk", "partitions", partitions)
|
||||||
|
|
||||||
|
// ioContext := context.WithValue(a.sensorsContext,
|
||||||
|
// common.EnvKey, common.EnvMap{common.HostProcEnvKey: "/tmp/testproc"},
|
||||||
|
// )
|
||||||
|
// diskIoCounters, err := disk.IOCountersWithContext(ioContext)
|
||||||
|
|
||||||
|
diskIoCounters, err := disk.IOCounters()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error getting diskstats", "err", err)
|
||||||
|
}
|
||||||
|
slog.Debug("Disk I/O", "diskstats", diskIoCounters)
|
||||||
|
|
||||||
|
// Helper function to add a filesystem to fsStats if it doesn't exist
|
||||||
|
addFsStat := func(device, mountpoint string, root bool) {
|
||||||
|
key := filepath.Base(device)
|
||||||
|
var ioMatch bool
|
||||||
|
if _, exists := a.fsStats[key]; !exists {
|
||||||
|
if root {
|
||||||
|
slog.Info("Detected root device", "name", key)
|
||||||
|
// Check if root device is in /proc/diskstats, use fallback if not
|
||||||
|
if _, ioMatch = diskIoCounters[key]; !ioMatch {
|
||||||
|
key, ioMatch = findIoDevice(filesystem, diskIoCounters, a.fsStats)
|
||||||
|
if !ioMatch {
|
||||||
|
slog.Info("Using I/O fallback", "device", device, "mountpoint", mountpoint, "fallback", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Check if non-root has diskstats and fall back to folder name if not
|
||||||
|
// Scenario: device is encrypted and named luks-2bcb02be-999d-4417-8d18-5c61e660fb6e - not in /proc/diskstats.
|
||||||
|
// However, the device can be specified by mounting folder from luks device at /extra-filesystems/sda1
|
||||||
|
if _, ioMatch = diskIoCounters[key]; !ioMatch {
|
||||||
|
efBase := filepath.Base(mountpoint)
|
||||||
|
if _, ioMatch = diskIoCounters[efBase]; ioMatch {
|
||||||
|
key = efBase
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.fsStats[key] = &system.FsStats{Root: root, Mountpoint: mountpoint}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use FILESYSTEM env var to find root filesystem
|
||||||
|
if filesystem != "" {
|
||||||
|
for _, p := range partitions {
|
||||||
|
if strings.HasSuffix(p.Device, filesystem) || p.Mountpoint == filesystem {
|
||||||
|
addFsStat(p.Device, p.Mountpoint, true)
|
||||||
|
hasRoot = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasRoot {
|
||||||
|
slog.Warn("Partition details not found", "filesystem", filesystem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add EXTRA_FILESYSTEMS env var values to fsStats
|
||||||
|
if extraFilesystems, exists := os.LookupEnv("EXTRA_FILESYSTEMS"); exists {
|
||||||
|
for _, fs := range strings.Split(extraFilesystems, ",") {
|
||||||
|
found := false
|
||||||
|
for _, p := range partitions {
|
||||||
|
if strings.HasSuffix(p.Device, fs) || p.Mountpoint == fs {
|
||||||
|
addFsStat(p.Device, p.Mountpoint, false)
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// if not in partitions, test if we can get disk usage
|
||||||
|
if !found {
|
||||||
|
if _, err := disk.Usage(fs); err == nil {
|
||||||
|
addFsStat(filepath.Base(fs), fs, false)
|
||||||
|
} else {
|
||||||
|
slog.Error("Invalid filesystem", "name", fs, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process partitions for various mount points
|
||||||
|
for _, p := range partitions {
|
||||||
|
// fmt.Println(p.Device, p.Mountpoint)
|
||||||
|
// Binary root fallback or docker root fallback
|
||||||
|
if !hasRoot && (p.Mountpoint == "/" || (p.Mountpoint == "/etc/hosts" && strings.HasPrefix(p.Device, "/dev"))) {
|
||||||
|
fs, match := findIoDevice(filepath.Base(p.Device), diskIoCounters, a.fsStats)
|
||||||
|
if match {
|
||||||
|
addFsStat(fs, p.Mountpoint, true)
|
||||||
|
hasRoot = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if device is in /extra-filesystems
|
||||||
|
if strings.HasPrefix(p.Mountpoint, efPath) {
|
||||||
|
addFsStat(p.Device, p.Mountpoint, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check all folders in /extra-filesystems and add them if not already present
|
||||||
|
if folders, err := os.ReadDir(efPath); err == nil {
|
||||||
|
existingMountpoints := make(map[string]bool)
|
||||||
|
for _, stats := range a.fsStats {
|
||||||
|
existingMountpoints[stats.Mountpoint] = true
|
||||||
|
}
|
||||||
|
for _, folder := range folders {
|
||||||
|
if folder.IsDir() {
|
||||||
|
mountpoint := filepath.Join(efPath, folder.Name())
|
||||||
|
slog.Debug("/extra-filesystems", "mountpoint", mountpoint)
|
||||||
|
if !existingMountpoints[mountpoint] {
|
||||||
|
addFsStat(folder.Name(), mountpoint, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no root filesystem set, use fallback
|
||||||
|
if !hasRoot {
|
||||||
|
rootDevice, _ := findIoDevice(filepath.Base(filesystem), diskIoCounters, a.fsStats)
|
||||||
|
slog.Info("Root disk", "mountpoint", "/", "io", rootDevice)
|
||||||
|
a.fsStats[rootDevice] = &system.FsStats{Root: true, Mountpoint: "/"}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.initializeDiskIoStats(diskIoCounters)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns matching device from /proc/diskstats,
|
||||||
|
// or the device with the most reads if no match is found.
|
||||||
|
// bool is true if a match was found.
|
||||||
|
func findIoDevice(filesystem string, diskIoCounters map[string]disk.IOCountersStat, fsStats map[string]*system.FsStats) (string, bool) {
|
||||||
|
var maxReadBytes uint64
|
||||||
|
maxReadDevice := "/"
|
||||||
|
for _, d := range diskIoCounters {
|
||||||
|
if d.Name == filesystem || (d.Label != "" && d.Label == filesystem) {
|
||||||
|
return d.Name, true
|
||||||
|
}
|
||||||
|
if d.ReadBytes > maxReadBytes {
|
||||||
|
// don't use if device already exists in fsStats
|
||||||
|
if _, exists := fsStats[d.Name]; !exists {
|
||||||
|
maxReadBytes = d.ReadBytes
|
||||||
|
maxReadDevice = d.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maxReadDevice, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sets start values for disk I/O stats.
|
||||||
|
func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersStat) {
|
||||||
|
for device, stats := range a.fsStats {
|
||||||
|
// skip if not in diskIoCounters
|
||||||
|
d, exists := diskIoCounters[device]
|
||||||
|
if !exists {
|
||||||
|
slog.Warn("Device not found in diskstats", "name", device)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// populate initial values
|
||||||
|
stats.Time = time.Now()
|
||||||
|
stats.TotalRead = d.ReadBytes
|
||||||
|
stats.TotalWrite = d.WriteBytes
|
||||||
|
// add to list of valid io device names
|
||||||
|
a.fsNames = append(a.fsNames, device)
|
||||||
|
}
|
||||||
|
}
|
||||||
304
beszel/internal/agent/docker.go
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel/internal/entities/container"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/blang/semver"
|
||||||
|
)
|
||||||
|
|
||||||
|
type dockerManager struct {
|
||||||
|
client *http.Client // Client to query Docker API
|
||||||
|
wg sync.WaitGroup // WaitGroup to wait for all goroutines to finish
|
||||||
|
sem chan struct{} // Semaphore to limit concurrent container requests
|
||||||
|
containerStatsMutex sync.RWMutex // Mutex to prevent concurrent access to containerStatsMap
|
||||||
|
apiContainerList *[]container.ApiInfo // List of containers from Docker API
|
||||||
|
containerStatsMap map[string]*container.Stats // Keeps track of container stats
|
||||||
|
validIds map[string]struct{} // Map of valid container ids, used to prune invalid containers from containerStatsMap
|
||||||
|
goodDockerVersion bool // Whether docker version is at least 25.0.0 (one-shot works correctly)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add goroutine to the queue
|
||||||
|
func (d *dockerManager) queue() {
|
||||||
|
d.wg.Add(1)
|
||||||
|
if d.goodDockerVersion {
|
||||||
|
d.sem <- struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove goroutine from the queue
|
||||||
|
func (d *dockerManager) dequeue() {
|
||||||
|
d.wg.Done()
|
||||||
|
if d.goodDockerVersion {
|
||||||
|
<-d.sem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns stats for all running containers
|
||||||
|
func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
|
||||||
|
resp, err := dm.client.Get("http://localhost/containers/json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&dm.apiContainerList); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
containersLength := len(*dm.apiContainerList)
|
||||||
|
|
||||||
|
// store valid ids to clean up old container ids from map
|
||||||
|
if dm.validIds == nil {
|
||||||
|
dm.validIds = make(map[string]struct{}, containersLength)
|
||||||
|
} else {
|
||||||
|
clear(dm.validIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
var failedContainters []container.ApiInfo
|
||||||
|
|
||||||
|
for _, ctr := range *dm.apiContainerList {
|
||||||
|
ctr.IdShort = ctr.Id[:12]
|
||||||
|
dm.validIds[ctr.IdShort] = struct{}{}
|
||||||
|
// check if container is less than 1 minute old (possible restart)
|
||||||
|
// note: can't use Created field because it's not updated on restart
|
||||||
|
if strings.Contains(ctr.Status, "second") {
|
||||||
|
// if so, remove old container data
|
||||||
|
dm.deleteContainerStatsSync(ctr.IdShort)
|
||||||
|
}
|
||||||
|
dm.queue()
|
||||||
|
go func() {
|
||||||
|
defer dm.dequeue()
|
||||||
|
err := dm.updateContainerStats(ctr)
|
||||||
|
// if error, delete from map and add to failed list to retry
|
||||||
|
if err != nil {
|
||||||
|
dm.containerStatsMutex.Lock()
|
||||||
|
delete(dm.containerStatsMap, ctr.IdShort)
|
||||||
|
failedContainters = append(failedContainters, ctr)
|
||||||
|
dm.containerStatsMutex.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
dm.wg.Wait()
|
||||||
|
|
||||||
|
// retry failed containers separately so we can run them in parallel (docker 24 bug)
|
||||||
|
if len(failedContainters) > 0 {
|
||||||
|
slog.Debug("Retrying failed containers", "count", len(failedContainters))
|
||||||
|
for _, ctr := range failedContainters {
|
||||||
|
dm.queue()
|
||||||
|
go func() {
|
||||||
|
defer dm.dequeue()
|
||||||
|
err = dm.updateContainerStats(ctr)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error getting container stats", "err", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
dm.wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// populate final stats and remove old / invalid container stats
|
||||||
|
stats := make([]*container.Stats, 0, containersLength)
|
||||||
|
for id, v := range dm.containerStatsMap {
|
||||||
|
if _, exists := dm.validIds[id]; !exists {
|
||||||
|
delete(dm.containerStatsMap, id)
|
||||||
|
} else {
|
||||||
|
stats = append(stats, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Updates stats for individual container
|
||||||
|
func (dm *dockerManager) updateContainerStats(ctr container.ApiInfo) error {
|
||||||
|
name := ctr.Names[0][1:]
|
||||||
|
|
||||||
|
resp, err := dm.client.Get("http://localhost/containers/" + ctr.IdShort + "/stats?stream=0&one-shot=1")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
dm.containerStatsMutex.Lock()
|
||||||
|
defer dm.containerStatsMutex.Unlock()
|
||||||
|
|
||||||
|
// add empty values if they doesn't exist in map
|
||||||
|
stats, initialized := dm.containerStatsMap[ctr.IdShort]
|
||||||
|
if !initialized {
|
||||||
|
stats = &container.Stats{Name: name}
|
||||||
|
dm.containerStatsMap[ctr.IdShort] = stats
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset current stats
|
||||||
|
stats.Cpu = 0
|
||||||
|
stats.Mem = 0
|
||||||
|
stats.NetworkSent = 0
|
||||||
|
stats.NetworkRecv = 0
|
||||||
|
|
||||||
|
// docker host container stats response
|
||||||
|
var res container.ApiStats
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if container has valid data, otherwise may be in restart loop (#103)
|
||||||
|
if res.MemoryStats.Usage == 0 {
|
||||||
|
return fmt.Errorf("%s - no memory stats - see https://github.com/henrygd/beszel/issues/144", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// memory (https://docs.docker.com/reference/cli/docker/container/stats/)
|
||||||
|
memCache := res.MemoryStats.Stats.InactiveFile
|
||||||
|
if memCache == 0 {
|
||||||
|
memCache = res.MemoryStats.Stats.Cache
|
||||||
|
}
|
||||||
|
usedMemory := res.MemoryStats.Usage - memCache
|
||||||
|
|
||||||
|
// cpu
|
||||||
|
cpuDelta := res.CPUStats.CPUUsage.TotalUsage - stats.PrevCpu[0]
|
||||||
|
systemDelta := res.CPUStats.SystemUsage - stats.PrevCpu[1]
|
||||||
|
cpuPct := float64(cpuDelta) / float64(systemDelta) * 100
|
||||||
|
if cpuPct > 100 {
|
||||||
|
return fmt.Errorf("%s cpu pct greater than 100: %+v", name, cpuPct)
|
||||||
|
}
|
||||||
|
stats.PrevCpu = [2]uint64{res.CPUStats.CPUUsage.TotalUsage, res.CPUStats.SystemUsage}
|
||||||
|
|
||||||
|
// network
|
||||||
|
var total_sent, total_recv uint64
|
||||||
|
for _, v := range res.Networks {
|
||||||
|
total_sent += v.TxBytes
|
||||||
|
total_recv += v.RxBytes
|
||||||
|
}
|
||||||
|
var sent_delta, recv_delta float64
|
||||||
|
// prevent first run from sending all prev sent/recv bytes
|
||||||
|
if initialized {
|
||||||
|
secondsElapsed := time.Since(stats.PrevNet.Time).Seconds()
|
||||||
|
sent_delta = float64(total_sent-stats.PrevNet.Sent) / secondsElapsed
|
||||||
|
recv_delta = float64(total_recv-stats.PrevNet.Recv) / secondsElapsed
|
||||||
|
}
|
||||||
|
stats.PrevNet.Sent = total_sent
|
||||||
|
stats.PrevNet.Recv = total_recv
|
||||||
|
stats.PrevNet.Time = time.Now()
|
||||||
|
|
||||||
|
stats.Cpu = twoDecimals(cpuPct)
|
||||||
|
stats.Mem = bytesToMegabytes(float64(usedMemory))
|
||||||
|
stats.NetworkSent = bytesToMegabytes(sent_delta)
|
||||||
|
stats.NetworkRecv = bytesToMegabytes(recv_delta)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete container stats from map using mutex
|
||||||
|
func (dm *dockerManager) deleteContainerStatsSync(id string) {
|
||||||
|
dm.containerStatsMutex.Lock()
|
||||||
|
defer dm.containerStatsMutex.Unlock()
|
||||||
|
delete(dm.containerStatsMap, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creates a new http client for Docker or Podman API
|
||||||
|
func newDockerManager(a *Agent) *dockerManager {
|
||||||
|
dockerHost, exists := os.LookupEnv("DOCKER_HOST")
|
||||||
|
if exists {
|
||||||
|
slog.Info("DOCKER_HOST", "host", dockerHost)
|
||||||
|
} else {
|
||||||
|
dockerHost = getDockerHost()
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedURL, err := url.Parse(dockerHost)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error parsing DOCKER_HOST", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
transport := &http.Transport{
|
||||||
|
DisableCompression: true,
|
||||||
|
MaxConnsPerHost: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch parsedURL.Scheme {
|
||||||
|
case "unix":
|
||||||
|
transport.DialContext = func(ctx context.Context, proto, addr string) (net.Conn, error) {
|
||||||
|
return (&net.Dialer{}).DialContext(ctx, "unix", parsedURL.Path)
|
||||||
|
}
|
||||||
|
case "tcp", "http", "https":
|
||||||
|
transport.DialContext = func(ctx context.Context, proto, addr string) (net.Conn, error) {
|
||||||
|
return (&net.Dialer{}).DialContext(ctx, "tcp", parsedURL.Host)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
slog.Error("Invalid DOCKER_HOST", "scheme", parsedURL.Scheme)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// configurable timeout
|
||||||
|
timeout := time.Millisecond * 2100
|
||||||
|
if t, set := os.LookupEnv("DOCKER_TIMEOUT"); set {
|
||||||
|
timeout, err = time.ParseDuration(t)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error(err.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
slog.Info("DOCKER_TIMEOUT", "timeout", timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
dockerClient := &dockerManager{
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: timeout,
|
||||||
|
Transport: transport,
|
||||||
|
},
|
||||||
|
containerStatsMap: make(map[string]*container.Stats),
|
||||||
|
sem: make(chan struct{}, 5),
|
||||||
|
}
|
||||||
|
|
||||||
|
// If using podman, return client
|
||||||
|
if strings.Contains(dockerHost, "podman") {
|
||||||
|
a.systemInfo.Podman = true
|
||||||
|
dockerClient.goodDockerVersion = true
|
||||||
|
return dockerClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check docker version
|
||||||
|
// (versions before 25.0.0 have a bug with one-shot which requires all requests to be made in one batch)
|
||||||
|
var versionInfo struct {
|
||||||
|
Version string `json:"Version"`
|
||||||
|
}
|
||||||
|
resp, err := dockerClient.client.Get("http://localhost/version")
|
||||||
|
if err != nil {
|
||||||
|
return dockerClient
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&versionInfo); err != nil {
|
||||||
|
return dockerClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// if version > 24, one-shot works correctly and we can limit concurrent operations
|
||||||
|
if dockerVersion, err := semver.Parse(versionInfo.Version); err == nil && dockerVersion.Major > 24 {
|
||||||
|
dockerClient.goodDockerVersion = true
|
||||||
|
} else {
|
||||||
|
slog.Info(fmt.Sprintf("Docker %s is outdated. Upgrade if possible. See https://github.com/henrygd/beszel/issues/58", versionInfo.Version))
|
||||||
|
}
|
||||||
|
|
||||||
|
return dockerClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test docker / podman sockets and return if one exists
|
||||||
|
func getDockerHost() string {
|
||||||
|
scheme := "unix://"
|
||||||
|
socks := []string{"/var/run/docker.sock", fmt.Sprintf("/run/user/%v/podman/podman.sock", os.Getuid())}
|
||||||
|
for _, sock := range socks {
|
||||||
|
if _, err := os.Stat(sock); err == nil {
|
||||||
|
return scheme + sock
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scheme + socks[0]
|
||||||
|
}
|
||||||
235
beszel/internal/agent/gpu.go
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel/internal/entities/system"
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/exp/slog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GPUManager manages data collection for GPUs (either Nvidia or AMD)
|
||||||
|
type GPUManager struct {
|
||||||
|
nvidiaSmi bool
|
||||||
|
rocmSmi bool
|
||||||
|
GpuDataMap map[string]*system.GPUData
|
||||||
|
mutex sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// RocmSmiJson represents the JSON structure of rocm-smi output
|
||||||
|
type RocmSmiJson struct {
|
||||||
|
ID string `json:"Device ID"`
|
||||||
|
Name string `json:"Card series"`
|
||||||
|
Temperature string `json:"Temperature (Sensor edge) (C)"`
|
||||||
|
MemoryUsed string `json:"VRAM Total Used Memory (B)"`
|
||||||
|
MemoryTotal string `json:"VRAM Total Memory (B)"`
|
||||||
|
Usage string `json:"GPU use (%)"`
|
||||||
|
Power string `json:"Current Socket Graphics Package Power (W)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// gpuCollector defines a collector for a specific GPU management utility (nvidia-smi or rocm-smi)
|
||||||
|
type gpuCollector struct {
|
||||||
|
name string
|
||||||
|
cmd *exec.Cmd
|
||||||
|
parse func([]byte) bool // returns true if valid data was found
|
||||||
|
}
|
||||||
|
|
||||||
|
var errNoValidData = fmt.Errorf("no valid GPU data found") // Error for missing data
|
||||||
|
|
||||||
|
// starts and manages the ongoing collection of GPU data for the specified GPU management utility
|
||||||
|
func (c *gpuCollector) start() {
|
||||||
|
for {
|
||||||
|
err := c.collect()
|
||||||
|
if err != nil {
|
||||||
|
if err == errNoValidData {
|
||||||
|
slog.Warn(c.name + " found no valid GPU data, stopping")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
slog.Warn(c.name+" failed, restarting", "err", err)
|
||||||
|
time.Sleep(time.Second * 5)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// collect executes the command, parses output with the assigned parser function
|
||||||
|
func (c *gpuCollector) collect() error {
|
||||||
|
stdout, err := c.cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.cmd.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(stdout)
|
||||||
|
buf := make([]byte, 0, 8*1024)
|
||||||
|
scanner.Buffer(buf, bufio.MaxScanTokenSize)
|
||||||
|
|
||||||
|
hasValidData := false
|
||||||
|
for scanner.Scan() {
|
||||||
|
if c.parse(scanner.Bytes()) {
|
||||||
|
hasValidData = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasValidData {
|
||||||
|
return errNoValidData
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return fmt.Errorf("scanner error: %w", err)
|
||||||
|
}
|
||||||
|
return c.cmd.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseNvidiaData parses the output of nvidia-smi and updates the GPUData map
|
||||||
|
func (gm *GPUManager) parseNvidiaData(output []byte) bool {
|
||||||
|
fields := strings.Split(string(output), ", ")
|
||||||
|
if len(fields) < 7 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
gm.mutex.Lock()
|
||||||
|
defer gm.mutex.Unlock()
|
||||||
|
lines := strings.Split(string(output), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
if line != "" {
|
||||||
|
fields := strings.Split(line, ", ")
|
||||||
|
if len(fields) >= 7 {
|
||||||
|
id := fields[0]
|
||||||
|
temp, _ := strconv.ParseFloat(fields[2], 64)
|
||||||
|
memoryUsage, _ := strconv.ParseFloat(fields[3], 64)
|
||||||
|
totalMemory, _ := strconv.ParseFloat(fields[4], 64)
|
||||||
|
usage, _ := strconv.ParseFloat(fields[5], 64)
|
||||||
|
power, _ := strconv.ParseFloat(fields[6], 64)
|
||||||
|
// add gpu if not exists
|
||||||
|
if _, ok := gm.GpuDataMap[id]; !ok {
|
||||||
|
name := strings.TrimPrefix(fields[1], "NVIDIA ")
|
||||||
|
gm.GpuDataMap[id] = &system.GPUData{Name: strings.TrimSuffix(name, " Laptop GPU")}
|
||||||
|
}
|
||||||
|
// update gpu data
|
||||||
|
gpu := gm.GpuDataMap[id]
|
||||||
|
gpu.Temperature = temp
|
||||||
|
gpu.MemoryUsed = memoryUsage / 1.024
|
||||||
|
gpu.MemoryTotal = totalMemory / 1.024
|
||||||
|
gpu.Usage += usage
|
||||||
|
gpu.Power += power
|
||||||
|
gpu.Count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAmdData parses the output of rocm-smi and updates the GPUData map
|
||||||
|
func (gm *GPUManager) parseAmdData(output []byte) bool {
|
||||||
|
var rocmSmiInfo map[string]RocmSmiJson
|
||||||
|
if err := json.Unmarshal(output, &rocmSmiInfo); err != nil || len(rocmSmiInfo) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
gm.mutex.Lock()
|
||||||
|
defer gm.mutex.Unlock()
|
||||||
|
for _, v := range rocmSmiInfo {
|
||||||
|
temp, _ := strconv.ParseFloat(v.Temperature, 64)
|
||||||
|
memoryUsage, _ := strconv.ParseFloat(v.MemoryUsed, 64)
|
||||||
|
totalMemory, _ := strconv.ParseFloat(v.MemoryTotal, 64)
|
||||||
|
usage, _ := strconv.ParseFloat(v.Usage, 64)
|
||||||
|
power, _ := strconv.ParseFloat(v.Power, 64)
|
||||||
|
memoryUsage = bytesToMegabytes(memoryUsage)
|
||||||
|
totalMemory = bytesToMegabytes(totalMemory)
|
||||||
|
|
||||||
|
if _, ok := gm.GpuDataMap[v.ID]; !ok {
|
||||||
|
gm.GpuDataMap[v.ID] = &system.GPUData{Name: v.Name}
|
||||||
|
}
|
||||||
|
gpu := gm.GpuDataMap[v.ID]
|
||||||
|
gpu.Temperature = temp
|
||||||
|
gpu.MemoryUsed = memoryUsage
|
||||||
|
gpu.MemoryTotal = totalMemory
|
||||||
|
gpu.Usage += usage
|
||||||
|
gpu.Power += power
|
||||||
|
gpu.Count++
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// sums and resets the current GPU utilization data since the last update
|
||||||
|
func (gm *GPUManager) GetCurrentData() map[string]system.GPUData {
|
||||||
|
gm.mutex.Lock()
|
||||||
|
defer gm.mutex.Unlock()
|
||||||
|
// copy / reset the data
|
||||||
|
gpuData := make(map[string]system.GPUData, len(gm.GpuDataMap))
|
||||||
|
for id, gpu := range gm.GpuDataMap {
|
||||||
|
// sum the data
|
||||||
|
gpu.Temperature = twoDecimals(gpu.Temperature)
|
||||||
|
gpu.MemoryUsed = twoDecimals(gpu.MemoryUsed)
|
||||||
|
gpu.MemoryTotal = twoDecimals(gpu.MemoryTotal)
|
||||||
|
gpu.Usage = twoDecimals(gpu.Usage / gpu.Count)
|
||||||
|
gpu.Power = twoDecimals(gpu.Power / gpu.Count)
|
||||||
|
gpuData[id] = *gpu
|
||||||
|
// reset the count
|
||||||
|
gpu.Count = 1
|
||||||
|
}
|
||||||
|
return gpuData
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectGPUs returns the GPU brand (nvidia or amd) or an error if none is found
|
||||||
|
// todo: make sure there's actually a GPU, not just if the command exists
|
||||||
|
func (gm *GPUManager) detectGPUs() error {
|
||||||
|
if err := exec.Command("nvidia-smi").Run(); err == nil {
|
||||||
|
gm.nvidiaSmi = true
|
||||||
|
}
|
||||||
|
if err := exec.Command("rocm-smi").Run(); err == nil {
|
||||||
|
gm.rocmSmi = true
|
||||||
|
}
|
||||||
|
if gm.nvidiaSmi || gm.rocmSmi {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("no GPU found - install nvidia-smi or rocm-smi")
|
||||||
|
}
|
||||||
|
|
||||||
|
// startCollector starts the appropriate GPU data collector based on the command
|
||||||
|
func (gm *GPUManager) startCollector(command string) {
|
||||||
|
switch command {
|
||||||
|
case "nvidia-smi":
|
||||||
|
nvidia := gpuCollector{
|
||||||
|
name: "nvidia-smi",
|
||||||
|
cmd: exec.Command("nvidia-smi", "-l", "4",
|
||||||
|
"--query-gpu=index,name,temperature.gpu,memory.used,memory.total,utilization.gpu,power.draw",
|
||||||
|
"--format=csv,noheader,nounits"),
|
||||||
|
parse: gm.parseNvidiaData,
|
||||||
|
}
|
||||||
|
go nvidia.start()
|
||||||
|
case "rocm-smi":
|
||||||
|
amdCollector := gpuCollector{
|
||||||
|
name: "rocm-smi",
|
||||||
|
cmd: exec.Command("/bin/sh", "-c",
|
||||||
|
"while true; do rocm-smi --showid --showtemp --showuse --showpower --showproductname --showmeminfo vram --json; sleep 4.3; done"),
|
||||||
|
parse: gm.parseAmdData,
|
||||||
|
}
|
||||||
|
go amdCollector.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGPUManager creates and initializes a new GPUManager
|
||||||
|
func NewGPUManager() (*GPUManager, error) {
|
||||||
|
var gm GPUManager
|
||||||
|
if err := gm.detectGPUs(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
gm.GpuDataMap = make(map[string]*system.GPUData, 1)
|
||||||
|
|
||||||
|
if gm.nvidiaSmi {
|
||||||
|
gm.startCollector("nvidia-smi")
|
||||||
|
}
|
||||||
|
if gm.rocmSmi {
|
||||||
|
gm.startCollector("rocm-smi")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &gm, nil
|
||||||
|
}
|
||||||
67
beszel/internal/agent/network.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
psutilNet "github.com/shirou/gopsutil/v4/net"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *Agent) initializeNetIoStats() {
|
||||||
|
// reset valid network interfaces
|
||||||
|
a.netInterfaces = make(map[string]struct{}, 0)
|
||||||
|
|
||||||
|
// map of network interface names passed in via NICS env var
|
||||||
|
var nicsMap map[string]struct{}
|
||||||
|
nics, nicsEnvExists := os.LookupEnv("NICS")
|
||||||
|
if nicsEnvExists {
|
||||||
|
nicsMap = make(map[string]struct{}, 0)
|
||||||
|
for _, nic := range strings.Split(nics, ",") {
|
||||||
|
nicsMap[nic] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset network I/O stats
|
||||||
|
a.netIoStats.BytesSent = 0
|
||||||
|
a.netIoStats.BytesRecv = 0
|
||||||
|
|
||||||
|
// get intial network I/O stats
|
||||||
|
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
||||||
|
a.netIoStats.Time = time.Now()
|
||||||
|
for _, v := range netIO {
|
||||||
|
switch {
|
||||||
|
// skip if nics exists and the interface is not in the list
|
||||||
|
case nicsEnvExists:
|
||||||
|
if _, nameInNics := nicsMap[v.Name]; !nameInNics {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// otherwise run the interface name through the skipNetworkInterface function
|
||||||
|
default:
|
||||||
|
if a.skipNetworkInterface(v) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slog.Info("Detected network interface", "name", v.Name, "sent", v.BytesSent, "recv", v.BytesRecv)
|
||||||
|
a.netIoStats.BytesSent += v.BytesSent
|
||||||
|
a.netIoStats.BytesRecv += v.BytesRecv
|
||||||
|
// store as a valid network interface
|
||||||
|
a.netInterfaces[v.Name] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) skipNetworkInterface(v psutilNet.IOCountersStat) bool {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(v.Name, "lo"),
|
||||||
|
strings.HasPrefix(v.Name, "docker"),
|
||||||
|
strings.HasPrefix(v.Name, "br-"),
|
||||||
|
strings.HasPrefix(v.Name, "veth"),
|
||||||
|
v.BytesRecv == 0,
|
||||||
|
v.BytesSent == 0:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
34
beszel/internal/agent/server.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
sshServer "github.com/gliderlabs/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *Agent) startServer(pubKey []byte, addr string) {
|
||||||
|
sshServer.Handle(a.handleSession)
|
||||||
|
|
||||||
|
slog.Info("Starting SSH server", "address", addr)
|
||||||
|
if err := sshServer.ListenAndServe(addr, nil, sshServer.NoPty(),
|
||||||
|
sshServer.PublicKeyAuth(func(ctx sshServer.Context, key sshServer.PublicKey) bool {
|
||||||
|
allowed, _, _, _, _ := sshServer.ParseAuthorizedKey(pubKey)
|
||||||
|
return sshServer.KeysEqual(key, allowed)
|
||||||
|
}),
|
||||||
|
); err != nil {
|
||||||
|
slog.Error("Error starting SSH server", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) handleSession(s sshServer.Session) {
|
||||||
|
stats := a.gatherStats()
|
||||||
|
if err := json.NewEncoder(s).Encode(stats); err != nil {
|
||||||
|
slog.Error("Error encoding stats", "err", err)
|
||||||
|
s.Exit(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.Exit(0)
|
||||||
|
}
|
||||||
266
beszel/internal/agent/system.go
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel"
|
||||||
|
"beszel/internal/entities/system"
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shirou/gopsutil/v4/cpu"
|
||||||
|
"github.com/shirou/gopsutil/v4/disk"
|
||||||
|
"github.com/shirou/gopsutil/v4/host"
|
||||||
|
"github.com/shirou/gopsutil/v4/mem"
|
||||||
|
psutilNet "github.com/shirou/gopsutil/v4/net"
|
||||||
|
"github.com/shirou/gopsutil/v4/sensors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sets initial / non-changing values about the host system
|
||||||
|
func (a *Agent) initializeSystemInfo() {
|
||||||
|
a.systemInfo.AgentVersion = beszel.Version
|
||||||
|
a.systemInfo.Hostname, _ = os.Hostname()
|
||||||
|
a.systemInfo.KernelVersion, _ = host.KernelVersion()
|
||||||
|
|
||||||
|
// cpu model
|
||||||
|
if info, err := cpu.Info(); err == nil && len(info) > 0 {
|
||||||
|
a.systemInfo.CpuModel = info[0].ModelName
|
||||||
|
}
|
||||||
|
// cores / threads
|
||||||
|
a.systemInfo.Cores, _ = cpu.Counts(false)
|
||||||
|
if threads, err := cpu.Counts(true); err == nil {
|
||||||
|
if threads > 0 && threads < a.systemInfo.Cores {
|
||||||
|
// in lxc logical cores reflects container limits, so use that as cores if lower
|
||||||
|
a.systemInfo.Cores = threads
|
||||||
|
} else {
|
||||||
|
a.systemInfo.Threads = threads
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// zfs
|
||||||
|
if _, err := getARCSize(); err == nil {
|
||||||
|
a.zfs = true
|
||||||
|
} else {
|
||||||
|
slog.Debug("Not monitoring ZFS ARC", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns current info, stats about the host system
|
||||||
|
func (a *Agent) getSystemStats() system.Stats {
|
||||||
|
systemStats := system.Stats{}
|
||||||
|
|
||||||
|
// cpu percent
|
||||||
|
cpuPct, err := cpu.Percent(0, false)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error getting cpu percent", "err", err)
|
||||||
|
} else if len(cpuPct) > 0 {
|
||||||
|
systemStats.Cpu = twoDecimals(cpuPct[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// memory
|
||||||
|
if v, err := mem.VirtualMemory(); err == nil {
|
||||||
|
// swap
|
||||||
|
systemStats.Swap = bytesToGigabytes(v.SwapTotal)
|
||||||
|
systemStats.SwapUsed = bytesToGigabytes(v.SwapTotal - v.SwapFree - v.SwapCached)
|
||||||
|
// cache + buffers value for default mem calculation
|
||||||
|
cacheBuff := v.Total - v.Free - v.Used
|
||||||
|
// htop memory calculation overrides
|
||||||
|
if a.memCalc == "htop" {
|
||||||
|
// note: gopsutil automatically adds SReclaimable to v.Cached
|
||||||
|
cacheBuff = v.Cached + v.Buffers - v.Shared
|
||||||
|
v.Used = v.Total - (v.Free + cacheBuff)
|
||||||
|
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
|
||||||
|
}
|
||||||
|
// subtract ZFS ARC size from used memory and add as its own category
|
||||||
|
if a.zfs {
|
||||||
|
if arcSize, _ := getARCSize(); arcSize > 0 && arcSize < v.Used {
|
||||||
|
v.Used = v.Used - arcSize
|
||||||
|
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
|
||||||
|
systemStats.MemZfsArc = bytesToGigabytes(arcSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
systemStats.Mem = bytesToGigabytes(v.Total)
|
||||||
|
systemStats.MemBuffCache = bytesToGigabytes(cacheBuff)
|
||||||
|
systemStats.MemUsed = bytesToGigabytes(v.Used)
|
||||||
|
systemStats.MemPct = twoDecimals(v.UsedPercent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// disk usage
|
||||||
|
for _, stats := range a.fsStats {
|
||||||
|
if d, err := disk.Usage(stats.Mountpoint); err == nil {
|
||||||
|
stats.DiskTotal = bytesToGigabytes(d.Total)
|
||||||
|
stats.DiskUsed = bytesToGigabytes(d.Used)
|
||||||
|
if stats.Root {
|
||||||
|
systemStats.DiskTotal = bytesToGigabytes(d.Total)
|
||||||
|
systemStats.DiskUsed = bytesToGigabytes(d.Used)
|
||||||
|
systemStats.DiskPct = twoDecimals(d.UsedPercent)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// reset stats if error (likely unmounted)
|
||||||
|
slog.Error("Error getting disk stats", "name", stats.Mountpoint, "err", err)
|
||||||
|
stats.DiskTotal = 0
|
||||||
|
stats.DiskUsed = 0
|
||||||
|
stats.TotalRead = 0
|
||||||
|
stats.TotalWrite = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// disk i/o
|
||||||
|
if ioCounters, err := disk.IOCounters(a.fsNames...); err == nil {
|
||||||
|
for _, d := range ioCounters {
|
||||||
|
stats := a.fsStats[d.Name]
|
||||||
|
if stats == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
secondsElapsed := time.Since(stats.Time).Seconds()
|
||||||
|
readPerSecond := bytesToMegabytes(float64(d.ReadBytes-stats.TotalRead) / secondsElapsed)
|
||||||
|
writePerSecond := bytesToMegabytes(float64(d.WriteBytes-stats.TotalWrite) / secondsElapsed)
|
||||||
|
// check for invalid values and reset stats if so
|
||||||
|
if readPerSecond < 0 || writePerSecond < 0 || readPerSecond > 50_000 || writePerSecond > 50_000 {
|
||||||
|
slog.Warn("Invalid disk I/O. Resetting.", "name", d.Name, "read", readPerSecond, "write", writePerSecond)
|
||||||
|
a.initializeDiskIoStats(ioCounters)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
stats.Time = time.Now()
|
||||||
|
stats.DiskReadPs = readPerSecond
|
||||||
|
stats.DiskWritePs = writePerSecond
|
||||||
|
stats.TotalRead = d.ReadBytes
|
||||||
|
stats.TotalWrite = d.WriteBytes
|
||||||
|
// if root filesystem, update system stats
|
||||||
|
if stats.Root {
|
||||||
|
systemStats.DiskReadPs = stats.DiskReadPs
|
||||||
|
systemStats.DiskWritePs = stats.DiskWritePs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// network stats
|
||||||
|
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
||||||
|
secondsElapsed := time.Since(a.netIoStats.Time).Seconds()
|
||||||
|
a.netIoStats.Time = time.Now()
|
||||||
|
bytesSent := uint64(0)
|
||||||
|
bytesRecv := uint64(0)
|
||||||
|
// sum all bytes sent and received
|
||||||
|
for _, v := range netIO {
|
||||||
|
// skip if not in valid network interfaces list
|
||||||
|
if _, exists := a.netInterfaces[v.Name]; !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bytesSent += v.BytesSent
|
||||||
|
bytesRecv += v.BytesRecv
|
||||||
|
}
|
||||||
|
// add to systemStats
|
||||||
|
sentPerSecond := float64(bytesSent-a.netIoStats.BytesSent) / secondsElapsed
|
||||||
|
recvPerSecond := float64(bytesRecv-a.netIoStats.BytesRecv) / secondsElapsed
|
||||||
|
networkSentPs := bytesToMegabytes(sentPerSecond)
|
||||||
|
networkRecvPs := bytesToMegabytes(recvPerSecond)
|
||||||
|
// add check for issue (#150) where sent is a massive number
|
||||||
|
if networkSentPs > 10_000 || networkRecvPs > 10_000 {
|
||||||
|
slog.Warn("Invalid net stats. Resetting.", "sent", networkSentPs, "recv", networkRecvPs)
|
||||||
|
for _, v := range netIO {
|
||||||
|
if _, exists := a.netInterfaces[v.Name]; !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
slog.Info(v.Name, "recv", v.BytesRecv, "sent", v.BytesSent)
|
||||||
|
}
|
||||||
|
// reset network I/O stats
|
||||||
|
a.initializeNetIoStats()
|
||||||
|
} else {
|
||||||
|
systemStats.NetworkSent = networkSentPs
|
||||||
|
systemStats.NetworkRecv = networkRecvPs
|
||||||
|
// update netIoStats
|
||||||
|
a.netIoStats.BytesSent = bytesSent
|
||||||
|
a.netIoStats.BytesRecv = bytesRecv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// temperatures (skip if sensors whitelist is set to empty string)
|
||||||
|
if a.sensorsWhitelist != nil && len(a.sensorsWhitelist) == 0 {
|
||||||
|
slog.Debug("Skipping temperature collection")
|
||||||
|
} else {
|
||||||
|
temps, err := sensors.TemperaturesWithContext(a.sensorsContext)
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("Sensor error", "err", err)
|
||||||
|
}
|
||||||
|
slog.Debug("Temperature", "sensors", temps)
|
||||||
|
if len(temps) > 0 {
|
||||||
|
systemStats.Temperatures = make(map[string]float64, len(temps))
|
||||||
|
for i, sensor := range temps {
|
||||||
|
// skip if temperature is 0
|
||||||
|
if sensor.Temperature <= 0 || sensor.Temperature >= 200 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := systemStats.Temperatures[sensor.SensorKey]; ok {
|
||||||
|
// if key already exists, append int to key
|
||||||
|
systemStats.Temperatures[sensor.SensorKey+"_"+strconv.Itoa(i)] = twoDecimals(sensor.Temperature)
|
||||||
|
} else {
|
||||||
|
systemStats.Temperatures[sensor.SensorKey] = twoDecimals(sensor.Temperature)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// remove sensors from systemStats if whitelist exists and sensor is not in whitelist
|
||||||
|
// (do this here instead of in initial loop so we have correct keys if int was appended)
|
||||||
|
if a.sensorsWhitelist != nil {
|
||||||
|
for key := range systemStats.Temperatures {
|
||||||
|
if _, nameInWhitelist := a.sensorsWhitelist[key]; !nameInWhitelist {
|
||||||
|
delete(systemStats.Temperatures, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GPU data
|
||||||
|
if a.gpuManager != nil {
|
||||||
|
if gpuData := a.gpuManager.GetCurrentData(); len(gpuData) > 0 {
|
||||||
|
systemStats.GPUData = gpuData
|
||||||
|
// add temperatures
|
||||||
|
if systemStats.Temperatures == nil {
|
||||||
|
systemStats.Temperatures = make(map[string]float64, len(gpuData))
|
||||||
|
}
|
||||||
|
for _, gpu := range gpuData {
|
||||||
|
if gpu.Temperature > 0 {
|
||||||
|
systemStats.Temperatures[gpu.Name] = gpu.Temperature
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// update base system info
|
||||||
|
a.systemInfo.Cpu = systemStats.Cpu
|
||||||
|
a.systemInfo.MemPct = systemStats.MemPct
|
||||||
|
a.systemInfo.DiskPct = systemStats.DiskPct
|
||||||
|
a.systemInfo.Uptime, _ = host.Uptime()
|
||||||
|
a.systemInfo.Bandwidth = twoDecimals(systemStats.NetworkSent + systemStats.NetworkRecv)
|
||||||
|
slog.Debug("sysinfo", "data", a.systemInfo)
|
||||||
|
|
||||||
|
return systemStats
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the size of the ZFS ARC memory cache in bytes
|
||||||
|
func getARCSize() (uint64, error) {
|
||||||
|
file, err := os.Open("/proc/spl/kstat/zfs/arcstats")
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Scan the lines
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if strings.HasPrefix(line, "size") {
|
||||||
|
// Example line: size 4 15032385536
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 3 {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
// Return the size as uint64
|
||||||
|
return strconv.ParseUint(fields[2], 10, 64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, fmt.Errorf("failed to parse size field")
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package update
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel"
|
"beszel"
|
||||||
@@ -10,51 +10,8 @@ import (
|
|||||||
"github.com/rhysd/go-github-selfupdate/selfupdate"
|
"github.com/rhysd/go-github-selfupdate/selfupdate"
|
||||||
)
|
)
|
||||||
|
|
||||||
func UpdateBeszel() {
|
// Update updates beszel-agent to the latest version
|
||||||
var latest *selfupdate.Release
|
func Update() {
|
||||||
var found bool
|
|
||||||
var err error
|
|
||||||
currentVersion := semver.MustParse(beszel.Version)
|
|
||||||
fmt.Println("beszel", currentVersion)
|
|
||||||
fmt.Println("Checking for updates...")
|
|
||||||
updater, _ := selfupdate.NewUpdater(selfupdate.Config{
|
|
||||||
Filters: []string{"beszel_"},
|
|
||||||
})
|
|
||||||
latest, found, err = updater.DetectLatest("henrygd/beszel")
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println("Error checking for updates:", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !found {
|
|
||||||
fmt.Println("No updates found")
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("Latest version:", latest.Version)
|
|
||||||
|
|
||||||
if latest.Version.LTE(currentVersion) {
|
|
||||||
fmt.Println("You are up to date")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var binaryPath string
|
|
||||||
fmt.Printf("Updating from %s to %s...\n", currentVersion, latest.Version)
|
|
||||||
binaryPath, err = os.Executable()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println("Error getting binary path:", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
err = selfupdate.UpdateTo(latest.AssetURL, binaryPath)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println("Please try rerunning with sudo. Error:", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
fmt.Printf("Successfully updated to %s\n\n%s\n", latest.Version, strings.TrimSpace(latest.ReleaseNotes))
|
|
||||||
}
|
|
||||||
|
|
||||||
func UpdateBeszelAgent() {
|
|
||||||
var latest *selfupdate.Release
|
var latest *selfupdate.Release
|
||||||
var found bool
|
var found bool
|
||||||
var err error
|
var err error
|
||||||
15
beszel/internal/agent/utils.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import "math"
|
||||||
|
|
||||||
|
func bytesToMegabytes(b float64) float64 {
|
||||||
|
return twoDecimals(b / 1048576)
|
||||||
|
}
|
||||||
|
|
||||||
|
func bytesToGigabytes(b uint64) float64 {
|
||||||
|
return twoDecimals(float64(b) / 1073741824)
|
||||||
|
}
|
||||||
|
|
||||||
|
func twoDecimals(value float64) float64 {
|
||||||
|
return math.Round(value*100) / 100
|
||||||
|
}
|
||||||
@@ -5,149 +5,525 @@ import (
|
|||||||
"beszel/internal/entities/system"
|
"beszel/internal/entities/system"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/containrrr/shoutrrr"
|
||||||
|
"github.com/goccy/go-json"
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase"
|
"github.com/pocketbase/pocketbase"
|
||||||
"github.com/pocketbase/pocketbase/models"
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
|
"github.com/spf13/cast"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AlertManager struct {
|
type AlertManager struct {
|
||||||
app *pocketbase.PocketBase
|
app *pocketbase.PocketBase
|
||||||
mailClient mailer.Mailer
|
}
|
||||||
|
|
||||||
|
type AlertMessageData struct {
|
||||||
|
UserID string
|
||||||
|
Title string
|
||||||
|
Message string
|
||||||
|
Link string
|
||||||
|
LinkText string
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserNotificationSettings struct {
|
||||||
|
Emails []string `json:"emails"`
|
||||||
|
Webhooks []string `json:"webhooks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SystemAlertStats struct {
|
||||||
|
Cpu float64 `json:"cpu"`
|
||||||
|
Mem float64 `json:"mp"`
|
||||||
|
Disk float64 `json:"dp"`
|
||||||
|
NetSent float64 `json:"ns"`
|
||||||
|
NetRecv float64 `json:"nr"`
|
||||||
|
Temperatures map[string]float32 `json:"t"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SystemAlertData struct {
|
||||||
|
systemRecord *core.Record
|
||||||
|
alertRecord *core.Record
|
||||||
|
name string
|
||||||
|
unit string
|
||||||
|
val float64
|
||||||
|
threshold float64
|
||||||
|
triggered bool
|
||||||
|
time time.Time
|
||||||
|
count uint8
|
||||||
|
min uint8
|
||||||
|
mapSums map[string]float32
|
||||||
|
descriptor string // override descriptor in notification body (for temp sensor, disk partition, etc)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAlertManager(app *pocketbase.PocketBase) *AlertManager {
|
func NewAlertManager(app *pocketbase.PocketBase) *AlertManager {
|
||||||
return &AlertManager{
|
return &AlertManager{
|
||||||
app: app,
|
app: app,
|
||||||
mailClient: app.NewMailClient(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (am *AlertManager) HandleSystemAlerts(newStatus string, newRecord *models.Record, oldRecord *models.Record) {
|
func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, systemInfo system.Info, temperatures map[string]float64, extraFs map[string]*system.FsStats) error {
|
||||||
alertRecords, err := am.app.Dao().FindRecordsByExpr("alerts",
|
// start := time.Now()
|
||||||
dbx.NewExp("system = {:system}", dbx.Params{"system": oldRecord.GetId()}),
|
// defer func() {
|
||||||
|
// log.Println("alert stats took", time.Since(start))
|
||||||
|
// }()
|
||||||
|
alertRecords, err := am.app.FindAllRecords("alerts",
|
||||||
|
dbx.NewExp("system={:system}", dbx.Params{"system": systemRecord.Id}),
|
||||||
)
|
)
|
||||||
if err != nil || len(alertRecords) == 0 {
|
if err != nil || len(alertRecords) == 0 {
|
||||||
// log.Println("no alerts found for system")
|
// log.Println("no alerts found for system")
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
// log.Println("found alerts", len(alertRecords))
|
|
||||||
var systemInfo *system.Info
|
var validAlerts []SystemAlertData
|
||||||
|
now := systemRecord.GetDateTime("updated").Time().UTC()
|
||||||
|
oldestTime := now
|
||||||
|
|
||||||
for _, alertRecord := range alertRecords {
|
for _, alertRecord := range alertRecords {
|
||||||
name := alertRecord.GetString("name")
|
name := alertRecord.GetString("name")
|
||||||
|
var val float64
|
||||||
|
unit := "%"
|
||||||
|
|
||||||
switch name {
|
switch name {
|
||||||
case "Status":
|
case "CPU":
|
||||||
am.handleStatusAlerts(newStatus, oldRecord, alertRecord)
|
val = systemInfo.Cpu
|
||||||
case "CPU", "Memory", "Disk":
|
case "Memory":
|
||||||
if newStatus != "up" {
|
val = systemInfo.MemPct
|
||||||
|
case "Bandwidth":
|
||||||
|
val = systemInfo.Bandwidth
|
||||||
|
unit = " MB/s"
|
||||||
|
case "Disk":
|
||||||
|
maxUsedPct := systemInfo.DiskPct
|
||||||
|
for _, fs := range extraFs {
|
||||||
|
usedPct := fs.DiskUsed / fs.DiskTotal * 100
|
||||||
|
if usedPct > maxUsedPct {
|
||||||
|
maxUsedPct = usedPct
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val = maxUsedPct
|
||||||
|
case "Temperature":
|
||||||
|
if temperatures == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if systemInfo == nil {
|
for _, temp := range temperatures {
|
||||||
systemInfo = getSystemInfo(newRecord)
|
if temp > val {
|
||||||
|
val = temp
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if name == "CPU" {
|
unit = "°C"
|
||||||
am.handleSlidingValueAlert(newRecord, alertRecord, name, systemInfo.Cpu)
|
}
|
||||||
} else if name == "Memory" {
|
|
||||||
am.handleSlidingValueAlert(newRecord, alertRecord, name, systemInfo.MemPct)
|
triggered := alertRecord.GetBool("triggered")
|
||||||
} else if name == "Disk" {
|
threshold := alertRecord.GetFloat("value")
|
||||||
am.handleSlidingValueAlert(newRecord, alertRecord, name, systemInfo.DiskPct)
|
|
||||||
|
// CONTINUE
|
||||||
|
// IF alert is not triggered and curValue is less than threshold
|
||||||
|
// OR alert is triggered and curValue is greater than threshold
|
||||||
|
if (!triggered && val <= threshold) || (triggered && val > threshold) {
|
||||||
|
// log.Printf("Skipping alert %s: val %f | threshold %f | triggered %v\n", name, val, threshold, triggered)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
min := max(1, cast.ToUint8(alertRecord.Get("min")))
|
||||||
|
// add time to alert time to make sure it's slighty after record creation
|
||||||
|
time := now.Add(-time.Duration(min) * time.Minute)
|
||||||
|
if time.Before(oldestTime) {
|
||||||
|
oldestTime = time
|
||||||
|
}
|
||||||
|
|
||||||
|
validAlerts = append(validAlerts, SystemAlertData{
|
||||||
|
systemRecord: systemRecord,
|
||||||
|
alertRecord: alertRecord,
|
||||||
|
name: name,
|
||||||
|
unit: unit,
|
||||||
|
val: val,
|
||||||
|
threshold: threshold,
|
||||||
|
triggered: triggered,
|
||||||
|
time: time,
|
||||||
|
min: min,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
systemStats := []struct {
|
||||||
|
Stats []byte `db:"stats"`
|
||||||
|
Created types.DateTime `db:"created"`
|
||||||
|
}{}
|
||||||
|
|
||||||
|
err = am.app.DB().
|
||||||
|
Select("stats", "created").
|
||||||
|
From("system_stats").
|
||||||
|
Where(dbx.NewExp(
|
||||||
|
"system={:system} AND type='1m' AND created > {:created}",
|
||||||
|
dbx.Params{
|
||||||
|
"system": systemRecord.Id,
|
||||||
|
// subtract some time to give us a bit of buffer
|
||||||
|
"created": oldestTime.Add(-time.Second * 90),
|
||||||
|
},
|
||||||
|
)).
|
||||||
|
OrderBy("created").
|
||||||
|
All(&systemStats)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// get oldest record creation time from first record in the slice
|
||||||
|
oldestRecordTime := systemStats[0].Created.Time()
|
||||||
|
// log.Println("oldestRecordTime", oldestRecordTime.String())
|
||||||
|
|
||||||
|
// delete from validAlerts if time is older than oldestRecord
|
||||||
|
for i := 0; i < len(validAlerts); i++ {
|
||||||
|
if validAlerts[i].time.Before(oldestRecordTime) {
|
||||||
|
// log.Println("deleting alert - time is older than oldestRecord", validAlerts[i].name, oldestRecordTime, validAlerts[i].time)
|
||||||
|
validAlerts = append(validAlerts[:i], validAlerts[i+1:]...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(validAlerts) == 0 {
|
||||||
|
// log.Println("no valid alerts found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats SystemAlertStats
|
||||||
|
|
||||||
|
// we can skip the latest systemStats record since it's the current value
|
||||||
|
for i := 0; i < len(systemStats); i++ {
|
||||||
|
stat := systemStats[i]
|
||||||
|
// subtract 10 seconds to give a small time buffer
|
||||||
|
systemStatsCreation := stat.Created.Time().Add(-time.Second * 10)
|
||||||
|
if err := json.Unmarshal(stat.Stats, &stats); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// log.Println("stats", stats)
|
||||||
|
for j := range validAlerts {
|
||||||
|
alert := &validAlerts[j]
|
||||||
|
// reset alert val on first iteration
|
||||||
|
if i == 0 {
|
||||||
|
alert.val = 0
|
||||||
|
}
|
||||||
|
// continue if system_stats is older than alert time range
|
||||||
|
if systemStatsCreation.Before(alert.time) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// add to alert value
|
||||||
|
switch alert.name {
|
||||||
|
case "CPU":
|
||||||
|
alert.val += stats.Cpu
|
||||||
|
case "Memory":
|
||||||
|
alert.val += stats.Mem
|
||||||
|
case "Bandwidth":
|
||||||
|
alert.val += stats.NetSent + stats.NetRecv
|
||||||
|
case "Disk":
|
||||||
|
if alert.mapSums == nil {
|
||||||
|
alert.mapSums = make(map[string]float32, len(extraFs)+1)
|
||||||
|
}
|
||||||
|
// add root disk
|
||||||
|
if _, ok := alert.mapSums["root"]; !ok {
|
||||||
|
alert.mapSums["root"] = 0.0
|
||||||
|
}
|
||||||
|
alert.mapSums["root"] += float32(stats.Disk)
|
||||||
|
// add extra disks
|
||||||
|
for key, fs := range extraFs {
|
||||||
|
if _, ok := alert.mapSums[key]; !ok {
|
||||||
|
alert.mapSums[key] = 0.0
|
||||||
|
}
|
||||||
|
alert.mapSums[key] += float32(fs.DiskUsed / fs.DiskTotal * 100)
|
||||||
|
}
|
||||||
|
case "Temperature":
|
||||||
|
if alert.mapSums == nil {
|
||||||
|
alert.mapSums = make(map[string]float32, len(stats.Temperatures))
|
||||||
|
}
|
||||||
|
for key, temp := range stats.Temperatures {
|
||||||
|
if _, ok := alert.mapSums[key]; !ok {
|
||||||
|
alert.mapSums[key] = float32(0)
|
||||||
|
}
|
||||||
|
alert.mapSums[key] += temp
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
alert.count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// sum up vals for each alert
|
||||||
|
for _, alert := range validAlerts {
|
||||||
|
switch alert.name {
|
||||||
|
case "Disk":
|
||||||
|
maxPct := float32(0)
|
||||||
|
for key, value := range alert.mapSums {
|
||||||
|
sumPct := float32(value)
|
||||||
|
if sumPct > maxPct {
|
||||||
|
maxPct = sumPct
|
||||||
|
alert.descriptor = fmt.Sprintf("Usage of %s", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
alert.val = float64(maxPct / float32(alert.count))
|
||||||
|
case "Temperature":
|
||||||
|
maxTemp := float32(0)
|
||||||
|
for key, value := range alert.mapSums {
|
||||||
|
sumTemp := float32(value) / float32(alert.count)
|
||||||
|
if sumTemp > maxTemp {
|
||||||
|
maxTemp = sumTemp
|
||||||
|
alert.descriptor = fmt.Sprintf("Highest sensor %s", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
alert.val = float64(maxTemp)
|
||||||
|
default:
|
||||||
|
alert.val = alert.val / float64(alert.count)
|
||||||
|
}
|
||||||
|
minCount := float32(alert.min) / 1.2
|
||||||
|
// log.Println("alert", alert.name, "val", alert.val, "threshold", alert.threshold, "triggered", alert.triggered)
|
||||||
|
// log.Printf("%s: val %f | count %d | min-count %f | threshold %f\n", alert.name, alert.val, alert.count, minCount, alert.threshold)
|
||||||
|
// pass through alert if count is greater than or equal to minCount
|
||||||
|
if float32(alert.count) >= minCount {
|
||||||
|
if !alert.triggered && alert.val > alert.threshold {
|
||||||
|
alert.triggered = true
|
||||||
|
go am.sendSystemAlert(alert)
|
||||||
|
} else if alert.triggered && alert.val <= alert.threshold {
|
||||||
|
alert.triggered = false
|
||||||
|
go am.sendSystemAlert(alert)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getSystemInfo(record *models.Record) *system.Info {
|
func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
||||||
var SystemInfo system.Info
|
// log.Printf("Sending alert %s: val %f | count %d | threshold %f\n", alert.name, alert.val, alert.count, alert.threshold)
|
||||||
record.UnmarshalJSONField("info", &SystemInfo)
|
systemName := alert.systemRecord.GetString("name")
|
||||||
return &SystemInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
func (am *AlertManager) handleSlidingValueAlert(newRecord *models.Record, alertRecord *models.Record, name string, curValue float64) {
|
// change Disk to Disk usage
|
||||||
triggered := alertRecord.GetBool("triggered")
|
if alert.name == "Disk" {
|
||||||
threshold := alertRecord.GetFloat("value")
|
alert.name += " usage"
|
||||||
// fmt.Println(name, curValue, "threshold", threshold, "triggered", triggered)
|
|
||||||
var subject string
|
|
||||||
var body string
|
|
||||||
if !triggered && curValue > threshold {
|
|
||||||
alertRecord.Set("triggered", true)
|
|
||||||
systemName := newRecord.GetString("name")
|
|
||||||
subject = fmt.Sprintf("%s usage above threshold on %s", name, systemName)
|
|
||||||
body = fmt.Sprintf("%s usage on %s is %.1f%%.\n\n%s\n\n- Beszel", name, systemName, curValue, am.app.Settings().Meta.AppUrl+"/system/"+systemName)
|
|
||||||
} else if triggered && curValue <= threshold {
|
|
||||||
alertRecord.Set("triggered", false)
|
|
||||||
systemName := newRecord.GetString("name")
|
|
||||||
subject = fmt.Sprintf("%s usage below threshold on %s", name, systemName)
|
|
||||||
body = fmt.Sprintf("%s usage on %s is below threshold at %.1f%%.\n\n%s\n\n- Beszel", name, systemName, curValue, am.app.Settings().Meta.AppUrl+"/system/"+systemName)
|
|
||||||
} else {
|
|
||||||
// fmt.Println(name, "not triggered")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if err := am.app.Dao().SaveRecord(alertRecord); err != nil {
|
|
||||||
|
// make title alert name lowercase if not CPU
|
||||||
|
titleAlertName := alert.name
|
||||||
|
if titleAlertName != "CPU" {
|
||||||
|
titleAlertName = strings.ToLower(titleAlertName)
|
||||||
|
}
|
||||||
|
|
||||||
|
var subject string
|
||||||
|
if alert.triggered {
|
||||||
|
subject = fmt.Sprintf("%s %s above threshold", systemName, titleAlertName)
|
||||||
|
} else {
|
||||||
|
subject = fmt.Sprintf("%s %s below threshold", systemName, titleAlertName)
|
||||||
|
}
|
||||||
|
minutesLabel := "minute"
|
||||||
|
if alert.min > 1 {
|
||||||
|
minutesLabel += "s"
|
||||||
|
}
|
||||||
|
if alert.descriptor == "" {
|
||||||
|
alert.descriptor = alert.name
|
||||||
|
}
|
||||||
|
body := fmt.Sprintf("%s averaged %.2f%s for the previous %v %s.", alert.descriptor, alert.val, alert.unit, alert.min, minutesLabel)
|
||||||
|
|
||||||
|
alert.alertRecord.Set("triggered", alert.triggered)
|
||||||
|
if err := am.app.Save(alert.alertRecord); err != nil {
|
||||||
// app.Logger().Error("failed to save alert record", "err", err.Error())
|
// app.Logger().Error("failed to save alert record", "err", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// expand the user relation and send the alert
|
// expand the user relation and send the alert
|
||||||
if errs := am.app.Dao().ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
if errs := am.app.ExpandRecord(alert.alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
||||||
// app.Logger().Error("failed to expand user relation", "errs", errs)
|
// app.Logger().Error("failed to expand user relation", "errs", errs)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if user := alertRecord.ExpandedOne("user"); user != nil {
|
if user := alert.alertRecord.ExpandedOne("user"); user != nil {
|
||||||
am.sendAlert(&mailer.Message{
|
am.sendAlert(AlertMessageData{
|
||||||
To: []mail.Address{{Address: user.GetString("email")}},
|
UserID: user.Id,
|
||||||
Subject: subject,
|
Title: subject,
|
||||||
Text: body,
|
Message: body,
|
||||||
|
Link: am.app.Settings().Meta.AppURL + "/system/" + url.PathEscape(systemName),
|
||||||
|
LinkText: "View " + systemName,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (am *AlertManager) handleStatusAlerts(newStatus string, oldRecord *models.Record, alertRecord *models.Record) error {
|
// todo: allow x minutes downtime before sending alert
|
||||||
|
func (am *AlertManager) HandleStatusAlerts(newStatus string, oldSystemRecord *core.Record) error {
|
||||||
var alertStatus string
|
var alertStatus string
|
||||||
switch newStatus {
|
switch newStatus {
|
||||||
case "up":
|
case "up":
|
||||||
if oldRecord.GetString("status") == "down" {
|
if oldSystemRecord.GetString("status") == "down" {
|
||||||
alertStatus = "up"
|
alertStatus = "up"
|
||||||
}
|
}
|
||||||
case "down":
|
case "down":
|
||||||
if oldRecord.GetString("status") == "up" {
|
if oldSystemRecord.GetString("status") == "up" {
|
||||||
alertStatus = "down"
|
alertStatus = "down"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if alertStatus == "" {
|
if alertStatus == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// expand the user relation
|
// check if use
|
||||||
if errs := am.app.Dao().ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
alertRecords, err := am.app.FindAllRecords("alerts",
|
||||||
return fmt.Errorf("failed to expand: %v", errs)
|
dbx.HashExp{
|
||||||
}
|
"system": oldSystemRecord.Id,
|
||||||
user := alertRecord.ExpandedOne("user")
|
"name": "Status",
|
||||||
if user == nil {
|
},
|
||||||
|
)
|
||||||
|
if err != nil || len(alertRecords) == 0 {
|
||||||
|
// log.Println("no alerts found for system")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
emoji := "\U0001F534"
|
for _, alertRecord := range alertRecords {
|
||||||
if alertStatus == "up" {
|
// expand the user relation
|
||||||
emoji = "\u2705"
|
if errs := am.app.ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
||||||
|
return fmt.Errorf("failed to expand: %v", errs)
|
||||||
|
}
|
||||||
|
user := alertRecord.ExpandedOne("user")
|
||||||
|
if user == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
emoji := "\U0001F534"
|
||||||
|
if alertStatus == "up" {
|
||||||
|
emoji = "\u2705"
|
||||||
|
}
|
||||||
|
// send alert
|
||||||
|
systemName := oldSystemRecord.GetString("name")
|
||||||
|
am.sendAlert(AlertMessageData{
|
||||||
|
UserID: user.Id,
|
||||||
|
Title: fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji),
|
||||||
|
Message: fmt.Sprintf("Connection to %s is %s", systemName, alertStatus),
|
||||||
|
Link: am.app.Settings().Meta.AppURL + "/system/" + url.PathEscape(systemName),
|
||||||
|
LinkText: "View " + systemName,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
// send alert
|
|
||||||
systemName := oldRecord.GetString("name")
|
|
||||||
am.sendAlert(&mailer.Message{
|
|
||||||
To: []mail.Address{{Address: user.GetString("email")}},
|
|
||||||
Subject: fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji),
|
|
||||||
Text: fmt.Sprintf("Connection to %s is %s\n\n- Beszel", systemName, alertStatus),
|
|
||||||
})
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (am *AlertManager) sendAlert(message *mailer.Message) {
|
func (am *AlertManager) sendAlert(data AlertMessageData) {
|
||||||
// fmt.Println("sending alert", "to", message.To, "subj", message.Subject, "body", message.Text)
|
// get user settings
|
||||||
message.From = mail.Address{
|
record, err := am.app.FindFirstRecordByFilter(
|
||||||
Address: am.app.Settings().Meta.SenderAddress,
|
"user_settings", "user={:user}",
|
||||||
Name: am.app.Settings().Meta.SenderName,
|
dbx.Params{"user": data.UserID},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
am.app.Logger().Error("Failed to get user settings", "err", err.Error())
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if err := am.mailClient.Send(message); err != nil {
|
// unmarshal user settings
|
||||||
|
userAlertSettings := UserNotificationSettings{
|
||||||
|
Emails: []string{},
|
||||||
|
Webhooks: []string{},
|
||||||
|
}
|
||||||
|
if err := record.UnmarshalJSONField("settings", &userAlertSettings); err != nil {
|
||||||
|
am.app.Logger().Error("Failed to unmarshal user settings", "err", err.Error())
|
||||||
|
}
|
||||||
|
// send alerts via webhooks
|
||||||
|
for _, webhook := range userAlertSettings.Webhooks {
|
||||||
|
if err := am.SendShoutrrrAlert(webhook, data.Title, data.Message, data.Link, data.LinkText); err != nil {
|
||||||
|
am.app.Logger().Error("Failed to send shoutrrr alert", "err", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// send alerts via email
|
||||||
|
if len(userAlertSettings.Emails) == 0 {
|
||||||
|
// log.Println("No email addresses found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addresses := []mail.Address{}
|
||||||
|
for _, email := range userAlertSettings.Emails {
|
||||||
|
addresses = append(addresses, mail.Address{Address: email})
|
||||||
|
}
|
||||||
|
message := mailer.Message{
|
||||||
|
To: addresses,
|
||||||
|
Subject: data.Title,
|
||||||
|
Text: data.Message + fmt.Sprintf("\n\n%s", data.Link),
|
||||||
|
From: mail.Address{
|
||||||
|
Address: am.app.Settings().Meta.SenderAddress,
|
||||||
|
Name: am.app.Settings().Meta.SenderName,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := am.app.NewMailClient().Send(&message); err != nil {
|
||||||
am.app.Logger().Error("Failed to send alert: ", "err", err.Error())
|
am.app.Logger().Error("Failed to send alert: ", "err", err.Error())
|
||||||
} else {
|
} else {
|
||||||
am.app.Logger().Info("Sent alert", "to", message.To, "subj", message.Subject)
|
am.app.Logger().Info("Sent email alert", "to", message.To, "subj", message.Subject)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendShoutrrrAlert sends an alert via a Shoutrrr URL
|
||||||
|
func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link, linkText string) error {
|
||||||
|
// services that support title param
|
||||||
|
supportsTitle := []string{"bark", "discord", "gotify", "ifttt", "join", "matrix", "ntfy", "opsgenie", "pushbullet", "pushover", "slack", "teams", "telegram", "zulip"}
|
||||||
|
|
||||||
|
// Parse the URL
|
||||||
|
parsedURL, err := url.Parse(notificationUrl)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error parsing URL: %v", err)
|
||||||
|
}
|
||||||
|
scheme := parsedURL.Scheme
|
||||||
|
queryParams := parsedURL.Query()
|
||||||
|
|
||||||
|
// Add title
|
||||||
|
if sliceContains(supportsTitle, scheme) {
|
||||||
|
queryParams.Add("title", title)
|
||||||
|
} else if scheme == "mattermost" {
|
||||||
|
// use markdown title for mattermost
|
||||||
|
message = "##### " + title + "\n\n" + message
|
||||||
|
} else if scheme == "generic" && queryParams.Has("template") {
|
||||||
|
// add title as property if using generic with template json
|
||||||
|
titleKey := queryParams.Get("titlekey")
|
||||||
|
if titleKey == "" {
|
||||||
|
titleKey = "title"
|
||||||
|
}
|
||||||
|
queryParams.Add("$"+titleKey, title)
|
||||||
|
} else {
|
||||||
|
// otherwise just add title to message
|
||||||
|
message = title + "\n\n" + message
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add link
|
||||||
|
if scheme == "ntfy" {
|
||||||
|
// if ntfy, add link to actions
|
||||||
|
queryParams.Add("Actions", fmt.Sprintf("view, %s, %s", linkText, link))
|
||||||
|
} else {
|
||||||
|
// else add link directly to the message
|
||||||
|
message += "\n\n" + link
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode the modified query parameters back into the URL
|
||||||
|
parsedURL.RawQuery = queryParams.Encode()
|
||||||
|
// log.Println("URL after modification:", parsedURL.String())
|
||||||
|
|
||||||
|
err = shoutrrr.Send(parsedURL.String(), message)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
am.app.Logger().Info("Sent shoutrrr alert", "title", title)
|
||||||
|
} else {
|
||||||
|
am.app.Logger().Error("Error sending shoutrrr alert", "err", err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contains checks if a string is present in a slice of strings
|
||||||
|
func sliceContains(slice []string, item string) bool {
|
||||||
|
for _, v := range slice {
|
||||||
|
if v == item {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
|
||||||
|
info, _ := e.RequestInfo()
|
||||||
|
if info.Auth == nil {
|
||||||
|
return apis.NewForbiddenError("Forbidden", nil)
|
||||||
|
}
|
||||||
|
url := e.Request.URL.Query().Get("url")
|
||||||
|
// log.Println("url", url)
|
||||||
|
if url == "" {
|
||||||
|
return e.JSON(200, map[string]string{"err": "URL is required"})
|
||||||
|
}
|
||||||
|
err := am.SendShoutrrrAlert(url, "Test Alert", "This is a notification from Beszel.", am.app.Settings().Meta.AppURL, "View Beszel")
|
||||||
|
if err != nil {
|
||||||
|
return e.JSON(200, map[string]string{"err": err.Error()})
|
||||||
|
}
|
||||||
|
return e.JSON(200, map[string]bool{"err": false})
|
||||||
|
}
|
||||||
|
|||||||
@@ -85,15 +85,13 @@ type CPUUsage struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type MemoryStats struct {
|
type MemoryStats struct {
|
||||||
|
|
||||||
// current res_counter usage for memory
|
// current res_counter usage for memory
|
||||||
Usage uint64 `json:"usage,omitempty"`
|
Usage uint64 `json:"usage,omitempty"`
|
||||||
Cache uint64 `json:"cache,omitempty"`
|
// all the stats exported via memory.stat.
|
||||||
|
Stats MemoryStatsStats `json:"stats,omitempty"`
|
||||||
// maximum usage ever recorded.
|
// maximum usage ever recorded.
|
||||||
// MaxUsage uint64 `json:"max_usage,omitempty"`
|
// MaxUsage uint64 `json:"max_usage,omitempty"`
|
||||||
// TODO(vishh): Export these as stronger types.
|
// TODO(vishh): Export these as stronger types.
|
||||||
// all the stats exported via memory.stat.
|
|
||||||
Stats map[string]uint64 `json:"stats,omitempty"`
|
|
||||||
// number of times memory usage hits limits.
|
// number of times memory usage hits limits.
|
||||||
// Failcnt uint64 `json:"failcnt,omitempty"`
|
// Failcnt uint64 `json:"failcnt,omitempty"`
|
||||||
// Limit uint64 `json:"limit,omitempty"`
|
// Limit uint64 `json:"limit,omitempty"`
|
||||||
@@ -106,6 +104,11 @@ type MemoryStats struct {
|
|||||||
// PrivateWorkingSet uint64 `json:"privateworkingset,omitempty"`
|
// PrivateWorkingSet uint64 `json:"privateworkingset,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MemoryStatsStats struct {
|
||||||
|
Cache uint64 `json:"cache,omitempty"`
|
||||||
|
InactiveFile uint64 `json:"inactive_file,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type NetworkStats struct {
|
type NetworkStats struct {
|
||||||
// Bytes received. Windows and Linux.
|
// Bytes received. Windows and Linux.
|
||||||
RxBytes uint64 `json:"rx_bytes"`
|
RxBytes uint64 `json:"rx_bytes"`
|
||||||
@@ -113,21 +116,19 @@ type NetworkStats struct {
|
|||||||
TxBytes uint64 `json:"tx_bytes"`
|
TxBytes uint64 `json:"tx_bytes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Container stats to return to the hub
|
type prevNetStats struct {
|
||||||
type Stats struct {
|
Sent uint64
|
||||||
Name string `json:"n"`
|
Recv uint64
|
||||||
Cpu float64 `json:"c"`
|
Time time.Time
|
||||||
Mem float64 `json:"m"`
|
|
||||||
NetworkSent float64 `json:"ns"`
|
|
||||||
NetworkRecv float64 `json:"nr"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keeps track of container stats from previous run
|
// Docker container stats
|
||||||
type PrevContainerStats struct {
|
type Stats struct {
|
||||||
Cpu [2]uint64
|
Name string `json:"n"`
|
||||||
Net struct {
|
Cpu float64 `json:"c"`
|
||||||
Sent uint64
|
Mem float64 `json:"m"`
|
||||||
Recv uint64
|
NetworkSent float64 `json:"ns"`
|
||||||
Time time.Time
|
NetworkRecv float64 `json:"nr"`
|
||||||
}
|
PrevCpu [2]uint64 `json:"-"`
|
||||||
|
PrevNet prevNetStats `json:"-"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,28 +6,53 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Stats struct {
|
type Stats struct {
|
||||||
Cpu float64 `json:"cpu"`
|
Cpu float64 `json:"cpu"`
|
||||||
Mem float64 `json:"m"`
|
MaxCpu float64 `json:"cpum,omitempty"`
|
||||||
MemUsed float64 `json:"mu"`
|
Mem float64 `json:"m"`
|
||||||
MemPct float64 `json:"mp"`
|
MemUsed float64 `json:"mu"`
|
||||||
MemBuffCache float64 `json:"mb"`
|
MemPct float64 `json:"mp"`
|
||||||
Swap float64 `json:"s"`
|
MemBuffCache float64 `json:"mb"`
|
||||||
SwapUsed float64 `json:"su"`
|
MemZfsArc float64 `json:"mz,omitempty"` // ZFS ARC memory
|
||||||
Disk float64 `json:"d"`
|
Swap float64 `json:"s,omitempty"`
|
||||||
DiskUsed float64 `json:"du"`
|
SwapUsed float64 `json:"su,omitempty"`
|
||||||
DiskPct float64 `json:"dp"`
|
DiskTotal float64 `json:"d"`
|
||||||
DiskRead float64 `json:"dr"`
|
DiskUsed float64 `json:"du"`
|
||||||
DiskWrite float64 `json:"dw"`
|
DiskPct float64 `json:"dp"`
|
||||||
NetworkSent float64 `json:"ns"`
|
DiskReadPs float64 `json:"dr"`
|
||||||
NetworkRecv float64 `json:"nr"`
|
DiskWritePs float64 `json:"dw"`
|
||||||
Temperatures map[string]float64 `json:"t,omitempty"`
|
MaxDiskReadPs float64 `json:"drm,omitempty"`
|
||||||
|
MaxDiskWritePs float64 `json:"dwm,omitempty"`
|
||||||
|
NetworkSent float64 `json:"ns"`
|
||||||
|
NetworkRecv float64 `json:"nr"`
|
||||||
|
MaxNetworkSent float64 `json:"nsm,omitempty"`
|
||||||
|
MaxNetworkRecv float64 `json:"nrm,omitempty"`
|
||||||
|
Temperatures map[string]float64 `json:"t,omitempty"`
|
||||||
|
ExtraFs map[string]*FsStats `json:"efs,omitempty"`
|
||||||
|
GPUData map[string]GPUData `json:"g,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DiskIoStats struct {
|
type GPUData struct {
|
||||||
Read uint64
|
Name string `json:"n"`
|
||||||
Write uint64
|
Temperature float64 `json:"-"`
|
||||||
Time time.Time
|
MemoryUsed float64 `json:"mu,omitempty"`
|
||||||
Filesystem string
|
MemoryTotal float64 `json:"mt,omitempty"`
|
||||||
|
Usage float64 `json:"u"`
|
||||||
|
Power float64 `json:"p,omitempty"`
|
||||||
|
Count float64 `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FsStats struct {
|
||||||
|
Time time.Time `json:"-"`
|
||||||
|
Root bool `json:"-"`
|
||||||
|
Mountpoint string `json:"-"`
|
||||||
|
DiskTotal float64 `json:"d"`
|
||||||
|
DiskUsed float64 `json:"du"`
|
||||||
|
TotalRead uint64 `json:"-"`
|
||||||
|
TotalWrite uint64 `json:"-"`
|
||||||
|
DiskReadPs float64 `json:"r"`
|
||||||
|
DiskWritePs float64 `json:"w"`
|
||||||
|
MaxDiskReadPS float64 `json:"rm,omitempty"`
|
||||||
|
MaxDiskWritePS float64 `json:"wm,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type NetIoStats struct {
|
type NetIoStats struct {
|
||||||
@@ -38,20 +63,23 @@ type NetIoStats struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Info struct {
|
type Info struct {
|
||||||
Cores int `json:"c"`
|
Hostname string `json:"h"`
|
||||||
Threads int `json:"t"`
|
KernelVersion string `json:"k,omitempty"`
|
||||||
CpuModel string `json:"m"`
|
Cores int `json:"c"`
|
||||||
// Os string `json:"o"`
|
Threads int `json:"t,omitempty"`
|
||||||
Uptime uint64 `json:"u"`
|
CpuModel string `json:"m"`
|
||||||
Cpu float64 `json:"cpu"`
|
Uptime uint64 `json:"u"`
|
||||||
MemPct float64 `json:"mp"`
|
Cpu float64 `json:"cpu"`
|
||||||
DiskPct float64 `json:"dp"`
|
MemPct float64 `json:"mp"`
|
||||||
AgentVersion string `json:"v"`
|
DiskPct float64 `json:"dp"`
|
||||||
|
Bandwidth float64 `json:"b"`
|
||||||
|
AgentVersion string `json:"v"`
|
||||||
|
Podman bool `json:"p,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final data structure to return to the hub
|
// Final data structure to return to the hub
|
||||||
type CombinedData struct {
|
type CombinedData struct {
|
||||||
Stats *Stats `json:"stats"`
|
Stats Stats `json:"stats"`
|
||||||
Info *Info `json:"info"`
|
Info Info `json:"info"`
|
||||||
Containers []*container.Stats `json:"container"`
|
Containers []*container.Stats `json:"container"`
|
||||||
}
|
}
|
||||||
|
|||||||
221
beszel/internal/hub/config.go
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
package hub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel/internal/entities/system"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/spf13/cast"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Systems []SystemConfig `yaml:"systems"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SystemConfig struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Host string `yaml:"host"`
|
||||||
|
Port uint16 `yaml:"port"`
|
||||||
|
Users []string `yaml:"users"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Syncs systems with the config.yml file
|
||||||
|
func (h *Hub) syncSystemsWithConfig() error {
|
||||||
|
configPath := filepath.Join(h.app.DataDir(), "config.yml")
|
||||||
|
configData, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var config Config
|
||||||
|
err = yaml.Unmarshal(configData, &config)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse config.yml: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(config.Systems) == 0 {
|
||||||
|
log.Println("No systems defined in config.yml.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var firstUser *core.Record
|
||||||
|
|
||||||
|
// Create a map of email to user ID
|
||||||
|
userEmailToID := make(map[string]string)
|
||||||
|
users, err := h.app.FindAllRecords("users", dbx.NewExp("id != ''"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(users) > 0 {
|
||||||
|
firstUser = users[0]
|
||||||
|
for _, user := range users {
|
||||||
|
userEmailToID[user.GetString("email")] = user.Id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// add default settings for systems if not defined in config
|
||||||
|
for i := range config.Systems {
|
||||||
|
system := &config.Systems[i]
|
||||||
|
if system.Port == 0 {
|
||||||
|
system.Port = 45876
|
||||||
|
}
|
||||||
|
if len(users) > 0 && len(system.Users) == 0 {
|
||||||
|
// default to first user if none are defined
|
||||||
|
system.Users = []string{firstUser.Id}
|
||||||
|
} else {
|
||||||
|
// Convert email addresses to user IDs
|
||||||
|
userIDs := make([]string, 0, len(system.Users))
|
||||||
|
for _, email := range system.Users {
|
||||||
|
if id, ok := userEmailToID[email]; ok {
|
||||||
|
userIDs = append(userIDs, id)
|
||||||
|
} else {
|
||||||
|
log.Printf("User %s not found", email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
system.Users = userIDs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get existing systems
|
||||||
|
existingSystems, err := h.app.FindAllRecords("systems", dbx.NewExp("id != ''"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a map of existing systems for easy lookup
|
||||||
|
existingSystemsMap := make(map[string]*core.Record)
|
||||||
|
for _, system := range existingSystems {
|
||||||
|
key := system.GetString("host") + ":" + system.GetString("port")
|
||||||
|
existingSystemsMap[key] = system
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process systems from config
|
||||||
|
for _, sysConfig := range config.Systems {
|
||||||
|
key := sysConfig.Host + ":" + strconv.Itoa(int(sysConfig.Port))
|
||||||
|
if existingSystem, ok := existingSystemsMap[key]; ok {
|
||||||
|
// Update existing system
|
||||||
|
existingSystem.Set("name", sysConfig.Name)
|
||||||
|
existingSystem.Set("users", sysConfig.Users)
|
||||||
|
existingSystem.Set("port", sysConfig.Port)
|
||||||
|
if err := h.app.Save(existingSystem); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
delete(existingSystemsMap, key)
|
||||||
|
} else {
|
||||||
|
// Create new system
|
||||||
|
systemsCollection, err := h.app.FindCollectionByNameOrId("systems")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to find systems collection: %v", err)
|
||||||
|
}
|
||||||
|
newSystem := core.NewRecord(systemsCollection)
|
||||||
|
newSystem.Set("name", sysConfig.Name)
|
||||||
|
newSystem.Set("host", sysConfig.Host)
|
||||||
|
newSystem.Set("port", sysConfig.Port)
|
||||||
|
newSystem.Set("users", sysConfig.Users)
|
||||||
|
newSystem.Set("info", system.Info{})
|
||||||
|
newSystem.Set("status", "pending")
|
||||||
|
if err := h.app.Save(newSystem); err != nil {
|
||||||
|
return fmt.Errorf("failed to create new system: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete systems not in config
|
||||||
|
for _, system := range existingSystemsMap {
|
||||||
|
if err := h.app.Delete(system); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Systems synced with config.yml")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generates content for the config.yml file as a YAML string
|
||||||
|
func (h *Hub) generateConfigYAML() (string, error) {
|
||||||
|
// Fetch all systems from the database
|
||||||
|
systems, err := h.app.FindRecordsByFilter("systems", "id != ''", "name", -1, 0)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a Config struct to hold the data
|
||||||
|
config := Config{
|
||||||
|
Systems: make([]SystemConfig, 0, len(systems)),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all users at once
|
||||||
|
allUserIDs := make([]string, 0)
|
||||||
|
for _, system := range systems {
|
||||||
|
allUserIDs = append(allUserIDs, system.GetStringSlice("users")...)
|
||||||
|
}
|
||||||
|
userEmailMap, err := h.getUserEmailMap(allUserIDs)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate the Config struct with system data
|
||||||
|
for _, system := range systems {
|
||||||
|
userIDs := system.GetStringSlice("users")
|
||||||
|
userEmails := make([]string, 0, len(userIDs))
|
||||||
|
for _, userID := range userIDs {
|
||||||
|
if email, ok := userEmailMap[userID]; ok {
|
||||||
|
userEmails = append(userEmails, email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sysConfig := SystemConfig{
|
||||||
|
Name: system.GetString("name"),
|
||||||
|
Host: system.GetString("host"),
|
||||||
|
Port: cast.ToUint16(system.Get("port")),
|
||||||
|
Users: userEmails,
|
||||||
|
}
|
||||||
|
config.Systems = append(config.Systems, sysConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marshal the Config struct to YAML
|
||||||
|
yamlData, err := yaml.Marshal(&config)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a header to the YAML
|
||||||
|
yamlData = append([]byte("# Values for port and users are optional.\n# Defaults are port 45876 and the first created user.\n\n"), yamlData...)
|
||||||
|
|
||||||
|
return string(yamlData), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// New helper function to get a map of user IDs to emails
|
||||||
|
func (h *Hub) getUserEmailMap(userIDs []string) (map[string]string, error) {
|
||||||
|
users, err := h.app.FindRecordsByIds("users", userIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
userEmailMap := make(map[string]string, len(users))
|
||||||
|
for _, user := range users {
|
||||||
|
userEmailMap[user.Id] = user.GetString("email")
|
||||||
|
}
|
||||||
|
|
||||||
|
return userEmailMap, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the current config.yml file as a JSON object
|
||||||
|
func (h *Hub) getYamlConfig(e *core.RequestEvent) error {
|
||||||
|
info, _ := e.RequestInfo()
|
||||||
|
if info.Auth == nil || info.Auth.GetString("role") != "admin" {
|
||||||
|
return apis.NewForbiddenError("Forbidden", nil)
|
||||||
|
}
|
||||||
|
configContent, err := h.generateConfigYAML()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return e.JSON(200, map[string]string{"config": configContent})
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package hub handles updating systems and serving the web UI.
|
||||||
package hub
|
package hub
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -5,11 +6,15 @@ import (
|
|||||||
"beszel/internal/alerts"
|
"beszel/internal/alerts"
|
||||||
"beszel/internal/entities/system"
|
"beszel/internal/entities/system"
|
||||||
"beszel/internal/records"
|
"beszel/internal/records"
|
||||||
|
"beszel/internal/users"
|
||||||
"beszel/site"
|
"beszel/site"
|
||||||
|
|
||||||
|
"context"
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -19,41 +24,39 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/goccy/go-json"
|
"github.com/goccy/go-json"
|
||||||
|
|
||||||
"github.com/labstack/echo/v5"
|
|
||||||
"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"
|
||||||
"github.com/pocketbase/pocketbase/models"
|
|
||||||
"github.com/pocketbase/pocketbase/plugins/migratecmd"
|
"github.com/pocketbase/pocketbase/plugins/migratecmd"
|
||||||
"github.com/pocketbase/pocketbase/tools/cron"
|
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Hub struct {
|
type Hub struct {
|
||||||
app *pocketbase.PocketBase
|
app *pocketbase.PocketBase
|
||||||
connectionLock *sync.Mutex
|
systemConnections sync.Map
|
||||||
systemConnections map[string]*ssh.Client
|
|
||||||
sshClientConfig *ssh.ClientConfig
|
sshClientConfig *ssh.ClientConfig
|
||||||
pubKey string
|
pubKey string
|
||||||
|
am *alerts.AlertManager
|
||||||
|
um *users.UserManager
|
||||||
|
rm *records.RecordManager
|
||||||
|
systemStats *core.Collection
|
||||||
|
containerStats *core.Collection
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHub(app *pocketbase.PocketBase) *Hub {
|
func NewHub(app *pocketbase.PocketBase) *Hub {
|
||||||
return &Hub{
|
return &Hub{
|
||||||
app: app,
|
app: app,
|
||||||
connectionLock: &sync.Mutex{},
|
am: alerts.NewAlertManager(app),
|
||||||
systemConnections: make(map[string]*ssh.Client),
|
um: users.NewUserManager(app),
|
||||||
|
rm: records.NewRecordManager(app),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Hub) Run() {
|
func (h *Hub) Run() {
|
||||||
var rm *records.RecordManager
|
|
||||||
var am *alerts.AlertManager
|
|
||||||
|
|
||||||
// loosely check if it was executed using "go run"
|
// loosely check if it was executed using "go run"
|
||||||
isGoRun := strings.HasPrefix(os.Args[0], os.TempDir())
|
isGoRun := strings.HasPrefix(os.Args[0], os.TempDir())
|
||||||
|
|
||||||
// // enable auto creation of migration files when making collection changes in the Admin UI
|
// enable auto creation of migration files when making collection changes in the Admin UI
|
||||||
migratecmd.MustRegister(h.app, h.app.RootCmd, migratecmd.Config{
|
migratecmd.MustRegister(h.app, h.app.RootCmd, migratecmd.Config{
|
||||||
// (the isGoRun check is to enable it only during development)
|
// (the isGoRun check is to enable it only during development)
|
||||||
Automigrate: isGoRun,
|
Automigrate: isGoRun,
|
||||||
@@ -61,114 +64,140 @@ func (h *Hub) Run() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// initial setup
|
// initial setup
|
||||||
h.app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
|
h.app.OnServe().BindFunc(func(se *core.ServeEvent) error {
|
||||||
// set up record manager and alert manager
|
|
||||||
rm = records.NewRecordManager(h.app)
|
|
||||||
am = alerts.NewAlertManager(h.app)
|
|
||||||
// create ssh client config
|
// create ssh client config
|
||||||
err := h.createSSHClientConfig()
|
err := h.createSSHClientConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
// set general settings
|
||||||
|
settings := h.app.Settings()
|
||||||
|
// batch requests (for global alerts)
|
||||||
|
settings.Batch.Enabled = true
|
||||||
// set auth settings
|
// set auth settings
|
||||||
usersCollection, err := h.app.Dao().FindCollectionByNameOrId("users")
|
usersCollection, err := h.app.FindCollectionByNameOrId("users")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
usersAuthOptions := usersCollection.AuthOptions()
|
// disable email auth if DISABLE_PASSWORD_AUTH env var is set
|
||||||
usersAuthOptions.AllowUsernameAuth = false
|
usersCollection.PasswordAuth.Enabled = os.Getenv("DISABLE_PASSWORD_AUTH") != "true"
|
||||||
if os.Getenv("DISABLE_PASSWORD_AUTH") == "true" {
|
usersCollection.PasswordAuth.IdentityFields = []string{"email"}
|
||||||
usersAuthOptions.AllowEmailAuth = false
|
// disable oauth if no providers are configured (todo: remove this in post 0.9.0 release)
|
||||||
} else {
|
if usersCollection.OAuth2.Enabled {
|
||||||
usersAuthOptions.AllowEmailAuth = true
|
usersCollection.OAuth2.Enabled = len(usersCollection.OAuth2.Providers) > 0
|
||||||
}
|
}
|
||||||
usersCollection.SetOptions(usersAuthOptions)
|
// allow oauth user creation if USER_CREATION is set
|
||||||
if err := h.app.Dao().SaveCollection(usersCollection); err != nil {
|
if os.Getenv("USER_CREATION") == "true" {
|
||||||
|
cr := "@request.context = 'oauth2'"
|
||||||
|
usersCollection.CreateRule = &cr
|
||||||
|
} else {
|
||||||
|
usersCollection.CreateRule = nil
|
||||||
|
}
|
||||||
|
if err := h.app.Save(usersCollection); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
// sync systems with config
|
||||||
|
h.syncSystemsWithConfig()
|
||||||
|
return se.Next()
|
||||||
})
|
})
|
||||||
|
|
||||||
// serve site
|
// serve web ui
|
||||||
h.app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
|
h.app.OnServe().BindFunc(func(se *core.ServeEvent) error {
|
||||||
switch isGoRun {
|
switch isGoRun {
|
||||||
case true:
|
case true:
|
||||||
proxy := httputil.NewSingleHostReverseProxy(&url.URL{
|
proxy := httputil.NewSingleHostReverseProxy(&url.URL{
|
||||||
Scheme: "http",
|
Scheme: "http",
|
||||||
Host: "localhost:5173",
|
Host: "localhost:5173",
|
||||||
})
|
})
|
||||||
e.Router.GET("/static/*", apis.StaticDirectoryHandler(os.DirFS("../../site/public/static"), false))
|
se.Router.Any("/", func(e *core.RequestEvent) error {
|
||||||
e.Router.Any("/*", echo.WrapHandler(proxy))
|
proxy.ServeHTTP(e.Response, e.Request)
|
||||||
// e.Router.Any("/", echo.WrapHandler(proxy))
|
return nil
|
||||||
|
})
|
||||||
default:
|
default:
|
||||||
e.Router.GET("/static/*", apis.StaticDirectoryHandler(site.Static, false))
|
csp, cspExists := os.LookupEnv("CSP")
|
||||||
e.Router.Any("/*", apis.StaticDirectoryHandler(site.Dist, true))
|
se.Router.Any("/{path...}", func(e *core.RequestEvent) error {
|
||||||
|
if cspExists {
|
||||||
|
e.Response.Header().Del("X-Frame-Options")
|
||||||
|
e.Response.Header().Set("Content-Security-Policy", csp)
|
||||||
|
}
|
||||||
|
indexFallback := !strings.HasPrefix(e.Request.URL.Path, "/static/")
|
||||||
|
return apis.Static(site.DistDirFS, indexFallback)(e)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return nil
|
return se.Next()
|
||||||
})
|
})
|
||||||
|
|
||||||
// set up scheduled jobs / ticker for system updates
|
// set up scheduled jobs / ticker for system updates
|
||||||
h.app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
|
h.app.OnServe().BindFunc(func(se *core.ServeEvent) error {
|
||||||
// 15 second ticker for system updates
|
// 15 second ticker for system updates
|
||||||
go h.startSystemUpdateTicker()
|
go h.startSystemUpdateTicker()
|
||||||
// set up cron jobs
|
// set up cron jobs
|
||||||
scheduler := cron.New()
|
|
||||||
// delete old records once every hour
|
// delete old records once every hour
|
||||||
scheduler.MustAdd("delete old records", "8 * * * *", rm.DeleteOldRecords)
|
h.app.Cron().MustAdd("delete old records", "8 * * * *", h.rm.DeleteOldRecords)
|
||||||
// create longer records every 10 minutes
|
// create longer records every 10 minutes
|
||||||
scheduler.MustAdd("create longer records", "*/10 * * * *", rm.CreateLongerRecords)
|
h.app.Cron().MustAdd("create longer records", "*/10 * * * *", func() {
|
||||||
scheduler.Start()
|
if systemStats, containerStats, err := h.getCollections(); err == nil {
|
||||||
return nil
|
h.rm.CreateLongerRecords([]*core.Collection{systemStats, containerStats})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return se.Next()
|
||||||
})
|
})
|
||||||
|
|
||||||
// custom api routes
|
// custom api routes
|
||||||
h.app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
|
h.app.OnServe().BindFunc(func(se *core.ServeEvent) error {
|
||||||
// returns public key
|
// returns public key
|
||||||
e.Router.GET("/api/beszel/getkey", func(c echo.Context) error {
|
se.Router.GET("/api/beszel/getkey", func(e *core.RequestEvent) error {
|
||||||
requestData := apis.RequestInfo(c)
|
info, _ := e.RequestInfo()
|
||||||
if requestData.AuthRecord == nil {
|
if info.Auth == nil {
|
||||||
return apis.NewForbiddenError("Forbidden", nil)
|
return apis.NewForbiddenError("Forbidden", nil)
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusOK, map[string]string{"key": h.pubKey, "v": beszel.Version})
|
return e.JSON(http.StatusOK, map[string]string{"key": h.pubKey, "v": beszel.Version})
|
||||||
})
|
})
|
||||||
// check if first time setup on login page
|
// check if first time setup on login page
|
||||||
e.Router.GET("/api/beszel/first-run", func(c echo.Context) error {
|
se.Router.GET("/api/beszel/first-run", func(e *core.RequestEvent) error {
|
||||||
adminNum, err := h.app.Dao().TotalAdmins()
|
total, err := h.app.CountRecords("users")
|
||||||
if err != nil {
|
return e.JSON(http.StatusOK, map[string]bool{"firstRun": err == nil && total == 0})
|
||||||
return err
|
|
||||||
}
|
|
||||||
return c.JSON(http.StatusOK, map[string]bool{"firstRun": adminNum == 0})
|
|
||||||
})
|
})
|
||||||
return nil
|
// send test notification
|
||||||
})
|
se.Router.GET("/api/beszel/send-test-notification", h.am.SendTestNotification)
|
||||||
|
// API endpoint to get config.yml content
|
||||||
// user creation - set default role to user if unset
|
se.Router.GET("/api/beszel/config-yaml", h.getYamlConfig)
|
||||||
h.app.OnModelBeforeCreate("users").Add(func(e *core.ModelEvent) error {
|
// create first user endpoint only needed if no users exist
|
||||||
user := e.Model.(*models.Record)
|
if totalUsers, _ := h.app.CountRecords("users"); totalUsers == 0 {
|
||||||
if user.GetString("role") == "" {
|
se.Router.POST("/api/beszel/create-user", h.um.CreateFirstUser)
|
||||||
user.Set("role", "user")
|
|
||||||
}
|
}
|
||||||
return nil
|
return se.Next()
|
||||||
})
|
})
|
||||||
|
|
||||||
// system creation defaults
|
// system creation defaults
|
||||||
h.app.OnModelBeforeCreate("systems").Add(func(e *core.ModelEvent) error {
|
h.app.OnRecordCreate("systems").BindFunc(func(e *core.RecordEvent) error {
|
||||||
record := e.Model.(*models.Record)
|
e.Record.Set("info", system.Info{})
|
||||||
record.Set("info", system.Info{})
|
e.Record.Set("status", "pending")
|
||||||
record.Set("status", "pending")
|
return e.Next()
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// immediately create connection for new systems
|
// immediately create connection for new systems
|
||||||
h.app.OnModelAfterCreate("systems").Add(func(e *core.ModelEvent) error {
|
h.app.OnRecordAfterCreateSuccess("systems").BindFunc(func(e *core.RecordEvent) error {
|
||||||
go h.updateSystem(e.Model.(*models.Record))
|
go h.updateSystem(e.Record)
|
||||||
return nil
|
return e.Next()
|
||||||
|
})
|
||||||
|
|
||||||
|
// handle default values for user / user_settings creation
|
||||||
|
h.app.OnRecordCreate("users").BindFunc(h.um.InitializeUserRole)
|
||||||
|
h.app.OnRecordCreate("user_settings").BindFunc(h.um.InitializeUserSettings)
|
||||||
|
|
||||||
|
// empty info for systems that are paused
|
||||||
|
h.app.OnRecordUpdate("systems").BindFunc(func(e *core.RecordEvent) error {
|
||||||
|
if e.Record.GetString("status") == "paused" {
|
||||||
|
e.Record.Set("info", system.Info{})
|
||||||
|
}
|
||||||
|
return e.Next()
|
||||||
})
|
})
|
||||||
|
|
||||||
// do things after a systems record is updated
|
// do things after a systems record is updated
|
||||||
h.app.OnModelAfterUpdate("systems").Add(func(e *core.ModelEvent) error {
|
h.app.OnRecordAfterUpdateSuccess("systems").BindFunc(func(e *core.RecordEvent) error {
|
||||||
newRecord := e.Model.(*models.Record)
|
newRecord := e.Record.Fresh()
|
||||||
oldRecord := newRecord.OriginalCopy()
|
oldRecord := newRecord.Original()
|
||||||
newStatus := newRecord.GetString("status")
|
newStatus := newRecord.GetString("status")
|
||||||
|
|
||||||
// if system is disconnected and connection exists, remove it
|
// if system is disconnected and connection exists, remove it
|
||||||
@@ -179,18 +208,17 @@ func (h *Hub) Run() {
|
|||||||
// if system is set to pending (unpause), try to connect immediately
|
// if system is set to pending (unpause), try to connect immediately
|
||||||
if newStatus == "pending" {
|
if newStatus == "pending" {
|
||||||
go h.updateSystem(newRecord)
|
go h.updateSystem(newRecord)
|
||||||
}
|
} else {
|
||||||
|
h.am.HandleStatusAlerts(newStatus, oldRecord)
|
||||||
|
|
||||||
// alerts
|
}
|
||||||
am.HandleSystemAlerts(newStatus, newRecord, oldRecord)
|
return e.Next()
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// do things after a systems record is deleted
|
// if system is deleted, close connection
|
||||||
h.app.OnModelAfterDelete("systems").Add(func(e *core.ModelEvent) error {
|
h.app.OnRecordAfterDeleteSuccess("systems").BindFunc(func(e *core.RecordEvent) error {
|
||||||
// if system connection exists, close it
|
h.deleteSystemConnection(e.Record)
|
||||||
h.deleteSystemConnection(e.Model.(*models.Record))
|
return e.Next()
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if err := h.app.Start(); err != nil {
|
if err := h.app.Start(); err != nil {
|
||||||
@@ -206,7 +234,7 @@ func (h *Hub) startSystemUpdateTicker() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Hub) updateSystems() {
|
func (h *Hub) updateSystems() {
|
||||||
records, err := h.app.Dao().FindRecordsByFilter(
|
records, err := h.app.FindRecordsByFilter(
|
||||||
"2hz5ncl8tizk5nx", // systems collection
|
"2hz5ncl8tizk5nx", // systems collection
|
||||||
"status != 'paused'", // filter
|
"status != 'paused'", // filter
|
||||||
"updated", // sort
|
"updated", // sort
|
||||||
@@ -235,32 +263,33 @@ func (h *Hub) updateSystems() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Hub) updateSystem(record *models.Record) {
|
func (h *Hub) updateSystem(record *core.Record) {
|
||||||
var client *ssh.Client
|
var client *ssh.Client
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
// check if system connection data exists
|
// check if system connection exists
|
||||||
if _, ok := h.systemConnections[record.Id]; ok {
|
if existingClient, ok := h.systemConnections.Load(record.Id); ok {
|
||||||
client = h.systemConnections[record.Id]
|
client = existingClient.(*ssh.Client)
|
||||||
} else {
|
} else {
|
||||||
// create system connection
|
// create system connection
|
||||||
client, err = h.createSystemConnection(record)
|
client, err = h.createSystemConnection(record)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.app.Logger().Error("Failed to connect:", "err", err.Error(), "system", record.GetString("host"), "port", record.GetString("port"))
|
if record.GetString("status") != "down" {
|
||||||
h.updateSystemStatus(record, "down")
|
h.app.Logger().Error("Failed to connect:", "err", err.Error(), "system", record.GetString("host"), "port", record.GetString("port"))
|
||||||
|
h.updateSystemStatus(record, "down")
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.connectionLock.Lock()
|
h.systemConnections.Store(record.Id, client)
|
||||||
h.systemConnections[record.Id] = client
|
|
||||||
h.connectionLock.Unlock()
|
|
||||||
}
|
}
|
||||||
// get system stats from agent
|
// get system stats from agent
|
||||||
var systemData system.CombinedData
|
var systemData system.CombinedData
|
||||||
if err := requestJsonFromAgent(client, &systemData); err != nil {
|
if err := h.requestJsonFromAgent(client, &systemData); err != nil {
|
||||||
if err.Error() == "bad client" {
|
if err.Error() == "bad client" {
|
||||||
// if previous connection was closed, try again
|
// if previous connection was closed, try again
|
||||||
h.app.Logger().Error("Existing SSH connection closed. Retrying...", "host", record.GetString("host"), "port", record.GetString("port"))
|
h.app.Logger().Error("Existing SSH connection closed. Retrying...", "host", record.GetString("host"), "port", record.GetString("port"))
|
||||||
h.deleteSystemConnection(record)
|
h.deleteSystemConnection(record)
|
||||||
|
time.Sleep(time.Millisecond * 100)
|
||||||
h.updateSystem(record)
|
h.updateSystem(record)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -271,54 +300,80 @@ func (h *Hub) updateSystem(record *models.Record) {
|
|||||||
// update system record
|
// update system record
|
||||||
record.Set("status", "up")
|
record.Set("status", "up")
|
||||||
record.Set("info", systemData.Info)
|
record.Set("info", systemData.Info)
|
||||||
if err := h.app.Dao().SaveRecord(record); err != nil {
|
if err := h.app.SaveNoValidate(record); err != nil {
|
||||||
h.app.Logger().Error("Failed to update record: ", "err", err.Error())
|
h.app.Logger().Error("Failed to update record: ", "err", err.Error())
|
||||||
}
|
}
|
||||||
// add new system_stats record
|
// add system_stats and container_stats records
|
||||||
system_stats, _ := h.app.Dao().FindCollectionByNameOrId("system_stats")
|
if systemStats, containerStats, err := h.getCollections(); err != nil {
|
||||||
systemStatsRecord := models.NewRecord(system_stats)
|
h.app.Logger().Error("Failed to get collections: ", "err", err.Error())
|
||||||
systemStatsRecord.Set("system", record.Id)
|
} else {
|
||||||
systemStatsRecord.Set("stats", systemData.Stats)
|
// add new system_stats record
|
||||||
systemStatsRecord.Set("type", "1m")
|
systemStatsRecord := core.NewRecord(systemStats)
|
||||||
if err := h.app.Dao().SaveRecord(systemStatsRecord); err != nil {
|
systemStatsRecord.Set("system", record.Id)
|
||||||
h.app.Logger().Error("Failed to save record: ", "err", err.Error())
|
systemStatsRecord.Set("stats", systemData.Stats)
|
||||||
}
|
systemStatsRecord.Set("type", "1m")
|
||||||
// add new container_stats record
|
if err := h.app.SaveNoValidate(systemStatsRecord); err != nil {
|
||||||
if len(systemData.Containers) > 0 {
|
|
||||||
container_stats, _ := h.app.Dao().FindCollectionByNameOrId("container_stats")
|
|
||||||
containerStatsRecord := models.NewRecord(container_stats)
|
|
||||||
containerStatsRecord.Set("system", record.Id)
|
|
||||||
containerStatsRecord.Set("stats", systemData.Containers)
|
|
||||||
containerStatsRecord.Set("type", "1m")
|
|
||||||
if err := h.app.Dao().SaveRecord(containerStatsRecord); err != nil {
|
|
||||||
h.app.Logger().Error("Failed to save record: ", "err", err.Error())
|
h.app.Logger().Error("Failed to save record: ", "err", err.Error())
|
||||||
}
|
}
|
||||||
|
// add new container_stats record
|
||||||
|
if len(systemData.Containers) > 0 {
|
||||||
|
containerStatsRecord := core.NewRecord(containerStats)
|
||||||
|
containerStatsRecord.Set("system", record.Id)
|
||||||
|
containerStatsRecord.Set("stats", systemData.Containers)
|
||||||
|
containerStatsRecord.Set("type", "1m")
|
||||||
|
if err := h.app.SaveNoValidate(containerStatsRecord); err != nil {
|
||||||
|
h.app.Logger().Error("Failed to save record: ", "err", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// system info alerts
|
||||||
|
if err := h.am.HandleSystemAlerts(record, systemData.Info, systemData.Stats.Temperatures, systemData.Stats.ExtraFs); err != nil {
|
||||||
|
h.app.Logger().Error("System alerts error", "err", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// return system_stats and container_stats collections
|
||||||
|
func (h *Hub) getCollections() (*core.Collection, *core.Collection, error) {
|
||||||
|
if h.systemStats == nil {
|
||||||
|
systemStats, err := h.app.FindCollectionByNameOrId("system_stats")
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
h.systemStats = systemStats
|
||||||
|
}
|
||||||
|
if h.containerStats == nil {
|
||||||
|
containerStats, err := h.app.FindCollectionByNameOrId("container_stats")
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
h.containerStats = containerStats
|
||||||
|
}
|
||||||
|
return h.systemStats, h.containerStats, nil
|
||||||
|
}
|
||||||
|
|
||||||
// set system to specified status and save record
|
// set system to specified status and save record
|
||||||
func (h *Hub) updateSystemStatus(record *models.Record, status string) {
|
func (h *Hub) updateSystemStatus(record *core.Record, status string) {
|
||||||
if record.GetString("status") != status {
|
if record.Fresh().GetString("status") != status {
|
||||||
record.Set("status", status)
|
record.Set("status", status)
|
||||||
if err := h.app.Dao().SaveRecord(record); err != nil {
|
if err := h.app.SaveNoValidate(record); err != nil {
|
||||||
h.app.Logger().Error("Failed to update record: ", "err", err.Error())
|
h.app.Logger().Error("Failed to update record: ", "err", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Hub) deleteSystemConnection(record *models.Record) {
|
// delete system connection from map and close connection
|
||||||
if _, ok := h.systemConnections[record.Id]; ok {
|
func (h *Hub) deleteSystemConnection(record *core.Record) {
|
||||||
if h.systemConnections[record.Id] != nil {
|
if client, ok := h.systemConnections.Load(record.Id); ok {
|
||||||
h.systemConnections[record.Id].Close()
|
if sshClient := client.(*ssh.Client); sshClient != nil {
|
||||||
|
sshClient.Close()
|
||||||
}
|
}
|
||||||
h.connectionLock.Lock()
|
h.systemConnections.Delete(record.Id)
|
||||||
defer h.connectionLock.Unlock()
|
|
||||||
delete(h.systemConnections, record.Id)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Hub) createSystemConnection(record *models.Record) (*ssh.Client, error) {
|
func (h *Hub) createSystemConnection(record *core.Record) (*ssh.Client, error) {
|
||||||
client, err := ssh.Dial("tcp", fmt.Sprintf("%s:%s", record.GetString("host"), record.GetString("port")), h.sshClientConfig)
|
client, err := ssh.Dial("tcp", net.JoinHostPort(record.GetString("host"), record.GetString("port")), h.sshClientConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -344,13 +399,14 @@ func (h *Hub) createSSHClientConfig() error {
|
|||||||
ssh.PublicKeys(signer),
|
ssh.PublicKeys(signer),
|
||||||
},
|
},
|
||||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||||
Timeout: 5 * time.Second,
|
Timeout: 4 * time.Second,
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestJsonFromAgent(client *ssh.Client, systemData *system.CombinedData) error {
|
// Fetches system stats from the agent and decodes the json data into the provided struct
|
||||||
session, err := client.NewSession()
|
func (h *Hub) requestJsonFromAgent(client *ssh.Client, systemData *system.CombinedData) error {
|
||||||
|
session, err := newSessionWithTimeout(client, 4*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("bad client")
|
return fmt.Errorf("bad client")
|
||||||
}
|
}
|
||||||
@@ -377,6 +433,32 @@ func requestJsonFromAgent(client *ssh.Client, systemData *system.CombinedData) e
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Adds timeout to SSH session creation to avoid hanging in case of network issues
|
||||||
|
func newSessionWithTimeout(client *ssh.Client, timeout time.Duration) (*ssh.Session, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// use goroutine to create the session
|
||||||
|
sessionChan := make(chan *ssh.Session, 1)
|
||||||
|
errChan := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
if session, err := client.NewSession(); err != nil {
|
||||||
|
errChan <- err
|
||||||
|
} else {
|
||||||
|
sessionChan <- session
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case session := <-sessionChan:
|
||||||
|
return session, nil
|
||||||
|
case err := <-errChan:
|
||||||
|
return nil, err
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, fmt.Errorf("session creation timed out")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Hub) getSSHKey() ([]byte, error) {
|
func (h *Hub) getSSHKey() ([]byte, error) {
|
||||||
dataDir := h.app.DataDir()
|
dataDir := h.app.DataDir()
|
||||||
// check if the key pair already exists
|
// check if the key pair already exists
|
||||||
|
|||||||
57
beszel/internal/hub/update.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package hub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/blang/semver"
|
||||||
|
"github.com/rhysd/go-github-selfupdate/selfupdate"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Update updates beszel to the latest version
|
||||||
|
func Update(_ *cobra.Command, _ []string) {
|
||||||
|
var latest *selfupdate.Release
|
||||||
|
var found bool
|
||||||
|
var err error
|
||||||
|
currentVersion := semver.MustParse(beszel.Version)
|
||||||
|
fmt.Println("beszel", currentVersion)
|
||||||
|
fmt.Println("Checking for updates...")
|
||||||
|
updater, _ := selfupdate.NewUpdater(selfupdate.Config{
|
||||||
|
Filters: []string{"beszel_"},
|
||||||
|
})
|
||||||
|
latest, found, err = updater.DetectLatest("henrygd/beszel")
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Error checking for updates:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
fmt.Println("No updates found")
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Latest version:", latest.Version)
|
||||||
|
|
||||||
|
if latest.Version.LTE(currentVersion) {
|
||||||
|
fmt.Println("You are up to date")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var binaryPath string
|
||||||
|
fmt.Printf("Updating from %s to %s...\n", currentVersion, latest.Version)
|
||||||
|
binaryPath, err = os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Error getting binary path:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
err = selfupdate.UpdateTo(latest.AssetURL, binaryPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Please try rerunning with sudo. Error:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("Successfully updated to %s\n\n%s\n", latest.Version, strings.TrimSpace(latest.ReleaseNotes))
|
||||||
|
}
|
||||||
@@ -8,10 +8,11 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/goccy/go-json"
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase"
|
"github.com/pocketbase/pocketbase"
|
||||||
"github.com/pocketbase/pocketbase/daos"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/models"
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
type RecordManager struct {
|
type RecordManager struct {
|
||||||
@@ -19,10 +20,10 @@ type RecordManager struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type LongerRecordData struct {
|
type LongerRecordData struct {
|
||||||
shorterType string
|
shorterType string
|
||||||
longerType string
|
longerType string
|
||||||
longerTimeDuration time.Duration
|
longerTimeDuration time.Duration
|
||||||
expectedShorterRecords int
|
minShorterRecords int
|
||||||
}
|
}
|
||||||
|
|
||||||
type RecordDeletionData struct {
|
type RecordDeletionData struct {
|
||||||
@@ -30,57 +31,57 @@ type RecordDeletionData struct {
|
|||||||
retention time.Duration
|
retention time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RecordStats []struct {
|
||||||
|
Stats []byte `db:"stats"`
|
||||||
|
}
|
||||||
|
|
||||||
func NewRecordManager(app *pocketbase.PocketBase) *RecordManager {
|
func NewRecordManager(app *pocketbase.PocketBase) *RecordManager {
|
||||||
return &RecordManager{app}
|
return &RecordManager{app}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create longer records by averaging shorter records
|
// Create longer records by averaging shorter records
|
||||||
func (rm *RecordManager) CreateLongerRecords() {
|
func (rm *RecordManager) CreateLongerRecords(collections []*core.Collection) {
|
||||||
// start := time.Now()
|
// start := time.Now()
|
||||||
recordData := []LongerRecordData{
|
longerRecordData := []LongerRecordData{
|
||||||
{
|
{
|
||||||
shorterType: "1m",
|
shorterType: "1m",
|
||||||
expectedShorterRecords: 10,
|
// change to 9 from 10 to allow edge case timing or short pauses
|
||||||
longerType: "10m",
|
minShorterRecords: 9,
|
||||||
longerTimeDuration: -10 * time.Minute,
|
longerType: "10m",
|
||||||
|
longerTimeDuration: -10 * time.Minute,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
shorterType: "10m",
|
shorterType: "10m",
|
||||||
expectedShorterRecords: 2,
|
minShorterRecords: 2,
|
||||||
longerType: "20m",
|
longerType: "20m",
|
||||||
longerTimeDuration: -20 * time.Minute,
|
longerTimeDuration: -20 * time.Minute,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
shorterType: "20m",
|
shorterType: "20m",
|
||||||
expectedShorterRecords: 6,
|
minShorterRecords: 6,
|
||||||
longerType: "120m",
|
longerType: "120m",
|
||||||
longerTimeDuration: -120 * time.Minute,
|
longerTimeDuration: -120 * time.Minute,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
shorterType: "120m",
|
shorterType: "120m",
|
||||||
expectedShorterRecords: 4,
|
minShorterRecords: 4,
|
||||||
longerType: "480m",
|
longerType: "480m",
|
||||||
longerTimeDuration: -480 * time.Minute,
|
longerTimeDuration: -480 * time.Minute,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
// wrap the operations in a transaction
|
// wrap the operations in a transaction
|
||||||
rm.app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
|
rm.app.RunInTransaction(func(txApp core.App) error {
|
||||||
activeSystems, err := txDao.FindRecordsByExpr("systems", dbx.NewExp("status = 'up'"))
|
activeSystems, err := txApp.FindAllRecords("systems", dbx.NewExp("status = 'up'"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("failed to get active systems", "err", err.Error())
|
log.Println("failed to get active systems", "err", err.Error())
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
collections := map[string]*models.Collection{}
|
|
||||||
for _, collectionName := range []string{"system_stats", "container_stats"} {
|
|
||||||
collection, _ := txDao.FindCollectionByNameOrId(collectionName)
|
|
||||||
collections[collectionName] = collection
|
|
||||||
}
|
|
||||||
|
|
||||||
// loop through all active systems, time periods, and collections
|
// loop through all active systems, time periods, and collections
|
||||||
for _, system := range activeSystems {
|
for _, system := range activeSystems {
|
||||||
// log.Println("processing system", system.GetString("name"))
|
// log.Println("processing system", system.GetString("name"))
|
||||||
for _, recordData := range recordData {
|
for i := range longerRecordData {
|
||||||
|
recordData := longerRecordData[i]
|
||||||
// log.Println("processing longer record type", recordData.longerType)
|
// log.Println("processing longer record type", recordData.longerType)
|
||||||
// add one minute padding for longer records because they are created slightly later than the job start time
|
// add one minute padding for longer records because they are created slightly later than the job start time
|
||||||
longerRecordPeriod := time.Now().UTC().Add(recordData.longerTimeDuration + time.Minute)
|
longerRecordPeriod := time.Now().UTC().Add(recordData.longerTimeDuration + time.Minute)
|
||||||
@@ -90,7 +91,7 @@ func (rm *RecordManager) CreateLongerRecords() {
|
|||||||
for _, collection := range collections {
|
for _, collection := range collections {
|
||||||
// check creation time of last longer record if not 10m, since 10m is created every run
|
// check creation time of last longer record if not 10m, since 10m is created every run
|
||||||
if recordData.longerType != "10m" {
|
if recordData.longerType != "10m" {
|
||||||
lastLongerRecord, err := txDao.FindFirstRecordByFilter(
|
lastLongerRecord, err := txApp.FindFirstRecordByFilter(
|
||||||
collection.Id,
|
collection.Id,
|
||||||
"type = {:type} && system = {:system} && created > {:created}",
|
"type = {:type} && system = {:system} && created > {:created}",
|
||||||
dbx.Params{"type": recordData.longerType, "system": system.Id, "created": longerRecordPeriod},
|
dbx.Params{"type": recordData.longerType, "system": system.Id, "created": longerRecordPeriod},
|
||||||
@@ -102,32 +103,37 @@ func (rm *RecordManager) CreateLongerRecords() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// get shorter records from the past x minutes
|
// get shorter records from the past x minutes
|
||||||
allShorterRecords, err := txDao.FindRecordsByExpr(
|
var stats RecordStats
|
||||||
collection.Id,
|
|
||||||
dbx.NewExp(
|
err := txApp.DB().
|
||||||
"type = {:type} AND system = {:system} AND created > {:created}",
|
Select("stats").
|
||||||
dbx.Params{"type": recordData.shorterType, "system": system.Id, "created": shorterRecordPeriod},
|
From(collection.Name).
|
||||||
),
|
AndWhere(dbx.NewExp(
|
||||||
)
|
"type={:type} AND system={:system} AND created > {:created}",
|
||||||
|
dbx.Params{
|
||||||
|
"type": recordData.shorterType,
|
||||||
|
"system": system.Id,
|
||||||
|
"created": shorterRecordPeriod,
|
||||||
|
},
|
||||||
|
)).
|
||||||
|
All(&stats)
|
||||||
|
|
||||||
// continue if not enough shorter records
|
// continue if not enough shorter records
|
||||||
if err != nil || len(allShorterRecords) < recordData.expectedShorterRecords {
|
if err != nil || len(stats) < recordData.minShorterRecords {
|
||||||
// log.Println("not enough shorter records. continue.", len(allShorterRecords), recordData.expectedShorterRecords)
|
// log.Println("not enough shorter records. continue.", len(allShorterRecords), recordData.expectedShorterRecords)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// average the shorter records and create longer record
|
// average the shorter records and create longer record
|
||||||
var stats interface{}
|
longerRecord := core.NewRecord(collection)
|
||||||
|
longerRecord.Set("system", system.Id)
|
||||||
|
longerRecord.Set("type", recordData.longerType)
|
||||||
switch collection.Name {
|
switch collection.Name {
|
||||||
case "system_stats":
|
case "system_stats":
|
||||||
stats = rm.AverageSystemStats(allShorterRecords)
|
longerRecord.Set("stats", rm.AverageSystemStats(stats))
|
||||||
case "container_stats":
|
case "container_stats":
|
||||||
stats = rm.AverageContainerStats(allShorterRecords)
|
longerRecord.Set("stats", rm.AverageContainerStats(stats))
|
||||||
}
|
}
|
||||||
longerRecord := models.NewRecord(collection)
|
if err := txApp.SaveNoValidate(longerRecord); err != nil {
|
||||||
longerRecord.Set("system", system.Id)
|
|
||||||
longerRecord.Set("stats", stats)
|
|
||||||
longerRecord.Set("type", recordData.longerType)
|
|
||||||
if err := txDao.SaveRecord(longerRecord); err != nil {
|
|
||||||
log.Println("failed to save longer record", "err", err.Error())
|
log.Println("failed to save longer record", "err", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,56 +146,42 @@ func (rm *RecordManager) CreateLongerRecords() {
|
|||||||
// log.Println("finished creating longer records", "time (ms)", time.Since(start).Milliseconds())
|
// log.Println("finished creating longer records", "time (ms)", time.Since(start).Milliseconds())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate the average stats of a list of system_stats records with reflect
|
|
||||||
// func (rm *RecordManager) AverageSystemStats(records []*models.Record) system.Stats {
|
|
||||||
// count := float64(len(records))
|
|
||||||
// sum := reflect.New(reflect.TypeOf(system.Stats{})).Elem()
|
|
||||||
|
|
||||||
// var stats system.Stats
|
|
||||||
// for _, record := range records {
|
|
||||||
// record.UnmarshalJSONField("stats", &stats)
|
|
||||||
// statValue := reflect.ValueOf(stats)
|
|
||||||
// for i := 0; i < statValue.NumField(); i++ {
|
|
||||||
// field := sum.Field(i)
|
|
||||||
// field.SetFloat(field.Float() + statValue.Field(i).Float())
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// average := reflect.New(reflect.TypeOf(system.Stats{})).Elem()
|
|
||||||
// for i := 0; i < sum.NumField(); i++ {
|
|
||||||
// average.Field(i).SetFloat(twoDecimals(sum.Field(i).Float() / count))
|
|
||||||
// }
|
|
||||||
|
|
||||||
// return average.Interface().(system.Stats)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Calculate the average stats of a list of system_stats records without reflect
|
// Calculate the average stats of a list of system_stats records without reflect
|
||||||
func (rm *RecordManager) AverageSystemStats(records []*models.Record) system.Stats {
|
func (rm *RecordManager) AverageSystemStats(records RecordStats) system.Stats {
|
||||||
var sum system.Stats
|
sum := system.Stats{}
|
||||||
sum.Temperatures = make(map[string]float64)
|
|
||||||
|
|
||||||
count := float64(len(records))
|
count := float64(len(records))
|
||||||
// use different counter for temps in case some records don't have them
|
// use different counter for temps in case some records don't have them
|
||||||
tempCount := float64(0)
|
tempCount := float64(0)
|
||||||
|
|
||||||
var stats system.Stats
|
var stats system.Stats
|
||||||
for _, record := range records {
|
for i := range records {
|
||||||
record.UnmarshalJSONField("stats", &stats)
|
json.Unmarshal(records[i].Stats, &stats)
|
||||||
sum.Cpu += stats.Cpu
|
sum.Cpu += stats.Cpu
|
||||||
sum.Mem += stats.Mem
|
sum.Mem += stats.Mem
|
||||||
sum.MemUsed += stats.MemUsed
|
sum.MemUsed += stats.MemUsed
|
||||||
sum.MemPct += stats.MemPct
|
sum.MemPct += stats.MemPct
|
||||||
sum.MemBuffCache += stats.MemBuffCache
|
sum.MemBuffCache += stats.MemBuffCache
|
||||||
|
sum.MemZfsArc += stats.MemZfsArc
|
||||||
sum.Swap += stats.Swap
|
sum.Swap += stats.Swap
|
||||||
sum.SwapUsed += stats.SwapUsed
|
sum.SwapUsed += stats.SwapUsed
|
||||||
sum.Disk += stats.Disk
|
sum.DiskTotal += stats.DiskTotal
|
||||||
sum.DiskUsed += stats.DiskUsed
|
sum.DiskUsed += stats.DiskUsed
|
||||||
sum.DiskPct += stats.DiskPct
|
sum.DiskPct += stats.DiskPct
|
||||||
sum.DiskRead += stats.DiskRead
|
sum.DiskReadPs += stats.DiskReadPs
|
||||||
sum.DiskWrite += stats.DiskWrite
|
sum.DiskWritePs += stats.DiskWritePs
|
||||||
sum.NetworkSent += stats.NetworkSent
|
sum.NetworkSent += stats.NetworkSent
|
||||||
sum.NetworkRecv += stats.NetworkRecv
|
sum.NetworkRecv += stats.NetworkRecv
|
||||||
|
// set peak values
|
||||||
|
sum.MaxCpu = max(sum.MaxCpu, stats.MaxCpu, stats.Cpu)
|
||||||
|
sum.MaxNetworkSent = max(sum.MaxNetworkSent, stats.MaxNetworkSent, stats.NetworkSent)
|
||||||
|
sum.MaxNetworkRecv = max(sum.MaxNetworkRecv, stats.MaxNetworkRecv, stats.NetworkRecv)
|
||||||
|
sum.MaxDiskReadPs = max(sum.MaxDiskReadPs, stats.MaxDiskReadPs, stats.DiskReadPs)
|
||||||
|
sum.MaxDiskWritePs = max(sum.MaxDiskWritePs, stats.MaxDiskWritePs, stats.DiskWritePs)
|
||||||
|
// add temps to sum
|
||||||
if stats.Temperatures != nil {
|
if stats.Temperatures != nil {
|
||||||
|
if sum.Temperatures == nil {
|
||||||
|
sum.Temperatures = make(map[string]float64, len(stats.Temperatures))
|
||||||
|
}
|
||||||
tempCount++
|
tempCount++
|
||||||
for key, value := range stats.Temperatures {
|
for key, value := range stats.Temperatures {
|
||||||
if _, ok := sum.Temperatures[key]; !ok {
|
if _, ok := sum.Temperatures[key]; !ok {
|
||||||
@@ -198,46 +190,123 @@ func (rm *RecordManager) AverageSystemStats(records []*models.Record) system.Sta
|
|||||||
sum.Temperatures[key] += value
|
sum.Temperatures[key] += value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// add extra fs to sum
|
||||||
|
if stats.ExtraFs != nil {
|
||||||
|
if sum.ExtraFs == nil {
|
||||||
|
sum.ExtraFs = make(map[string]*system.FsStats, len(stats.ExtraFs))
|
||||||
|
}
|
||||||
|
for key, value := range stats.ExtraFs {
|
||||||
|
if _, ok := sum.ExtraFs[key]; !ok {
|
||||||
|
sum.ExtraFs[key] = &system.FsStats{}
|
||||||
|
}
|
||||||
|
sum.ExtraFs[key].DiskTotal += value.DiskTotal
|
||||||
|
sum.ExtraFs[key].DiskUsed += value.DiskUsed
|
||||||
|
sum.ExtraFs[key].DiskWritePs += value.DiskWritePs
|
||||||
|
sum.ExtraFs[key].DiskReadPs += value.DiskReadPs
|
||||||
|
// peak values
|
||||||
|
sum.ExtraFs[key].MaxDiskReadPS = max(sum.ExtraFs[key].MaxDiskReadPS, value.MaxDiskReadPS, value.DiskReadPs)
|
||||||
|
sum.ExtraFs[key].MaxDiskWritePS = max(sum.ExtraFs[key].MaxDiskWritePS, value.MaxDiskWritePS, value.DiskWritePs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// add GPU data
|
||||||
|
if stats.GPUData != nil {
|
||||||
|
if sum.GPUData == nil {
|
||||||
|
sum.GPUData = make(map[string]system.GPUData, len(stats.GPUData))
|
||||||
|
}
|
||||||
|
for id, value := range stats.GPUData {
|
||||||
|
if _, ok := sum.GPUData[id]; !ok {
|
||||||
|
sum.GPUData[id] = system.GPUData{Name: value.Name}
|
||||||
|
}
|
||||||
|
gpu := sum.GPUData[id]
|
||||||
|
gpu.Temperature += value.Temperature
|
||||||
|
gpu.MemoryUsed += value.MemoryUsed
|
||||||
|
gpu.MemoryTotal += value.MemoryTotal
|
||||||
|
gpu.Usage += value.Usage
|
||||||
|
gpu.Power += value.Power
|
||||||
|
gpu.Count += value.Count
|
||||||
|
sum.GPUData[id] = gpu
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stats = system.Stats{
|
stats = system.Stats{
|
||||||
Cpu: twoDecimals(sum.Cpu / count),
|
Cpu: twoDecimals(sum.Cpu / count),
|
||||||
Mem: twoDecimals(sum.Mem / count),
|
Mem: twoDecimals(sum.Mem / count),
|
||||||
MemUsed: twoDecimals(sum.MemUsed / count),
|
MemUsed: twoDecimals(sum.MemUsed / count),
|
||||||
MemPct: twoDecimals(sum.MemPct / count),
|
MemPct: twoDecimals(sum.MemPct / count),
|
||||||
MemBuffCache: twoDecimals(sum.MemBuffCache / count),
|
MemBuffCache: twoDecimals(sum.MemBuffCache / count),
|
||||||
Swap: twoDecimals(sum.Swap / count),
|
MemZfsArc: twoDecimals(sum.MemZfsArc / count),
|
||||||
SwapUsed: twoDecimals(sum.SwapUsed / count),
|
Swap: twoDecimals(sum.Swap / count),
|
||||||
Disk: twoDecimals(sum.Disk / count),
|
SwapUsed: twoDecimals(sum.SwapUsed / count),
|
||||||
DiskUsed: twoDecimals(sum.DiskUsed / count),
|
DiskTotal: twoDecimals(sum.DiskTotal / count),
|
||||||
DiskPct: twoDecimals(sum.DiskPct / count),
|
DiskUsed: twoDecimals(sum.DiskUsed / count),
|
||||||
DiskRead: twoDecimals(sum.DiskRead / count),
|
DiskPct: twoDecimals(sum.DiskPct / count),
|
||||||
DiskWrite: twoDecimals(sum.DiskWrite / count),
|
DiskReadPs: twoDecimals(sum.DiskReadPs / count),
|
||||||
NetworkSent: twoDecimals(sum.NetworkSent / count),
|
DiskWritePs: twoDecimals(sum.DiskWritePs / count),
|
||||||
NetworkRecv: twoDecimals(sum.NetworkRecv / count),
|
NetworkSent: twoDecimals(sum.NetworkSent / count),
|
||||||
|
NetworkRecv: twoDecimals(sum.NetworkRecv / count),
|
||||||
|
MaxCpu: sum.MaxCpu,
|
||||||
|
MaxDiskReadPs: sum.MaxDiskReadPs,
|
||||||
|
MaxDiskWritePs: sum.MaxDiskWritePs,
|
||||||
|
MaxNetworkSent: sum.MaxNetworkSent,
|
||||||
|
MaxNetworkRecv: sum.MaxNetworkRecv,
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(sum.Temperatures) != 0 {
|
if sum.Temperatures != nil {
|
||||||
stats.Temperatures = make(map[string]float64)
|
stats.Temperatures = make(map[string]float64, len(sum.Temperatures))
|
||||||
for key, value := range sum.Temperatures {
|
for key, value := range sum.Temperatures {
|
||||||
stats.Temperatures[key] = twoDecimals(value / tempCount)
|
stats.Temperatures[key] = twoDecimals(value / tempCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if sum.ExtraFs != nil {
|
||||||
|
stats.ExtraFs = make(map[string]*system.FsStats, len(sum.ExtraFs))
|
||||||
|
for key, value := range sum.ExtraFs {
|
||||||
|
stats.ExtraFs[key] = &system.FsStats{
|
||||||
|
DiskTotal: twoDecimals(value.DiskTotal / count),
|
||||||
|
DiskUsed: twoDecimals(value.DiskUsed / count),
|
||||||
|
DiskWritePs: twoDecimals(value.DiskWritePs / count),
|
||||||
|
DiskReadPs: twoDecimals(value.DiskReadPs / count),
|
||||||
|
MaxDiskReadPS: value.MaxDiskReadPS,
|
||||||
|
MaxDiskWritePS: value.MaxDiskWritePS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if sum.GPUData != nil {
|
||||||
|
stats.GPUData = make(map[string]system.GPUData, len(sum.GPUData))
|
||||||
|
for id, value := range sum.GPUData {
|
||||||
|
stats.GPUData[id] = system.GPUData{
|
||||||
|
Name: value.Name,
|
||||||
|
Temperature: twoDecimals(value.Temperature / count),
|
||||||
|
MemoryUsed: twoDecimals(value.MemoryUsed / count),
|
||||||
|
MemoryTotal: twoDecimals(value.MemoryTotal / count),
|
||||||
|
Usage: twoDecimals(value.Usage / count),
|
||||||
|
Power: twoDecimals(value.Power / count),
|
||||||
|
Count: twoDecimals(value.Count / count),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return stats
|
return stats
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate the average stats of a list of container_stats records
|
// Calculate the average stats of a list of container_stats records
|
||||||
func (rm *RecordManager) AverageContainerStats(records []*models.Record) (stats []container.Stats) {
|
func (rm *RecordManager) AverageContainerStats(records RecordStats) []container.Stats {
|
||||||
sums := make(map[string]*container.Stats)
|
sums := make(map[string]*container.Stats)
|
||||||
count := float64(len(records))
|
count := float64(len(records))
|
||||||
|
|
||||||
var containerStats []container.Stats
|
var containerStats []container.Stats
|
||||||
for _, record := range records {
|
for i := range records {
|
||||||
record.UnmarshalJSONField("stats", &containerStats)
|
// Reset the slice length to 0, but keep the capacity
|
||||||
for _, stat := range containerStats {
|
containerStats = containerStats[:0]
|
||||||
|
if err := json.Unmarshal(records[i].Stats, &containerStats); err != nil {
|
||||||
|
return []container.Stats{}
|
||||||
|
}
|
||||||
|
for i := range containerStats {
|
||||||
|
stat := containerStats[i]
|
||||||
if _, ok := sums[stat.Name]; !ok {
|
if _, ok := sums[stat.Name]; !ok {
|
||||||
sums[stat.Name] = &container.Stats{Name: stat.Name, Cpu: 0, Mem: 0}
|
sums[stat.Name] = &container.Stats{Name: stat.Name}
|
||||||
}
|
}
|
||||||
sums[stat.Name].Cpu += stat.Cpu
|
sums[stat.Name].Cpu += stat.Cpu
|
||||||
sums[stat.Name].Mem += stat.Mem
|
sums[stat.Name].Mem += stat.Mem
|
||||||
@@ -246,8 +315,9 @@ func (rm *RecordManager) AverageContainerStats(records []*models.Record) (stats
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
result := make([]container.Stats, 0, len(sums))
|
||||||
for _, value := range sums {
|
for _, value := range sums {
|
||||||
stats = append(stats, container.Stats{
|
result = append(result, container.Stats{
|
||||||
Name: value.Name,
|
Name: value.Name,
|
||||||
Cpu: twoDecimals(value.Cpu / count),
|
Cpu: twoDecimals(value.Cpu / count),
|
||||||
Mem: twoDecimals(value.Mem / count),
|
Mem: twoDecimals(value.Mem / count),
|
||||||
@@ -255,11 +325,11 @@ func (rm *RecordManager) AverageContainerStats(records []*models.Record) (stats
|
|||||||
NetworkRecv: twoDecimals(value.NetworkRecv / count),
|
NetworkRecv: twoDecimals(value.NetworkRecv / count),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return stats
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deletes records older than what is displayed in the UI
|
||||||
func (rm *RecordManager) DeleteOldRecords() {
|
func (rm *RecordManager) DeleteOldRecords() {
|
||||||
// start := time.Now()
|
|
||||||
collections := []string{"system_stats", "container_stats"}
|
collections := []string{"system_stats", "container_stats"}
|
||||||
recordData := []RecordDeletionData{
|
recordData := []RecordDeletionData{
|
||||||
{
|
{
|
||||||
@@ -283,29 +353,17 @@ func (rm *RecordManager) DeleteOldRecords() {
|
|||||||
retention: 30 * 24 * time.Hour,
|
retention: 30 * 24 * time.Hour,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
rm.app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
|
db := rm.app.NonconcurrentDB()
|
||||||
for _, recordData := range recordData {
|
for _, recordData := range recordData {
|
||||||
exp := dbx.NewExp(
|
for _, collectionSlug := range collections {
|
||||||
"type = {:type} AND created < {:created}",
|
formattedDate := time.Now().UTC().Add(-recordData.retention).Format(types.DefaultDateLayout)
|
||||||
dbx.Params{"type": recordData.recordType, "created": time.Now().UTC().Add(-recordData.retention)},
|
expr := dbx.NewExp("[[created]] < {:date} AND [[type]] = {:type}", dbx.Params{"date": formattedDate, "type": recordData.recordType})
|
||||||
)
|
_, err := db.Delete(collectionSlug, expr).Execute()
|
||||||
for _, collectionSlug := range collections {
|
if err != nil {
|
||||||
collectionRecords, err := txDao.FindRecordsByExpr(collectionSlug, exp)
|
rm.app.Logger().Error("Failed to delete records", "err", err.Error())
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, record := range collectionRecords {
|
|
||||||
err := txDao.DeleteRecord(record)
|
|
||||||
if err != nil {
|
|
||||||
rm.app.Logger().Error("Failed to delete records", "err", err.Error())
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
}
|
||||||
})
|
|
||||||
// log.Println("finished deleting old records", "time (ms)", time.Since(start).Milliseconds())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Round float to two decimals */
|
/* Round float to two decimals */
|
||||||
|
|||||||
120
beszel/internal/users/users.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
// Package users handles user-related custom functionality.
|
||||||
|
package users
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel/migrations"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserManager struct {
|
||||||
|
app *pocketbase.PocketBase
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserSettings struct {
|
||||||
|
ChartTime string `json:"chartTime"`
|
||||||
|
NotificationEmails []string `json:"emails"`
|
||||||
|
NotificationWebhooks []string `json:"webhooks"`
|
||||||
|
// Language string `json:"lang"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserManager(app *pocketbase.PocketBase) *UserManager {
|
||||||
|
return &UserManager{
|
||||||
|
app: app,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize user role if not set
|
||||||
|
func (um *UserManager) InitializeUserRole(e *core.RecordEvent) error {
|
||||||
|
if e.Record.GetString("role") == "" {
|
||||||
|
e.Record.Set("role", "user")
|
||||||
|
}
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize user settings with defaults if not set
|
||||||
|
func (um *UserManager) InitializeUserSettings(e *core.RecordEvent) error {
|
||||||
|
record := e.Record
|
||||||
|
// intialize settings with defaults
|
||||||
|
settings := UserSettings{
|
||||||
|
// Language: "en",
|
||||||
|
ChartTime: "1h",
|
||||||
|
NotificationEmails: []string{},
|
||||||
|
NotificationWebhooks: []string{},
|
||||||
|
}
|
||||||
|
record.UnmarshalJSONField("settings", &settings)
|
||||||
|
if len(settings.NotificationEmails) == 0 {
|
||||||
|
// get user email from auth record
|
||||||
|
if errs := um.app.ExpandRecord(record, []string{"user"}, nil); len(errs) == 0 {
|
||||||
|
// app.Logger().Error("failed to expand user relation", "errs", errs)
|
||||||
|
if user := record.ExpandedOne("user"); user != nil {
|
||||||
|
settings.NotificationEmails = []string{user.GetString("email")}
|
||||||
|
} else {
|
||||||
|
log.Println("Failed to get user email from auth record")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Println("failed to expand user relation", "errs", errs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// if len(settings.NotificationWebhooks) == 0 {
|
||||||
|
// settings.NotificationWebhooks = []string{""}
|
||||||
|
// }
|
||||||
|
record.Set("settings", settings)
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom API endpoint to create the first user.
|
||||||
|
// Mimics previous default behavior in PocketBase < 0.23.0 allowing user to be created through the Beszel UI.
|
||||||
|
func (um *UserManager) CreateFirstUser(e *core.RequestEvent) error {
|
||||||
|
// check that there are no users
|
||||||
|
totalUsers, err := um.app.CountRecords("users")
|
||||||
|
if err != nil || totalUsers > 0 {
|
||||||
|
return e.JSON(http.StatusForbidden, map[string]string{"err": "Forbidden"})
|
||||||
|
}
|
||||||
|
// check that there is only one superuser and the email matches the email of the superuser we set up in initial-settings.go
|
||||||
|
adminUsers, err := um.app.FindAllRecords(core.CollectionNameSuperusers)
|
||||||
|
if err != nil || len(adminUsers) != 1 || adminUsers[0].GetString("email") != migrations.TempAdminEmail {
|
||||||
|
return e.JSON(http.StatusForbidden, map[string]string{"err": "Forbidden"})
|
||||||
|
}
|
||||||
|
// create first user using supplied email and password in request body
|
||||||
|
data := struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}{}
|
||||||
|
if err := e.BindBody(&data); err != nil {
|
||||||
|
return e.JSON(http.StatusBadRequest, map[string]string{"err": err.Error()})
|
||||||
|
}
|
||||||
|
if data.Email == "" || data.Password == "" {
|
||||||
|
return e.JSON(http.StatusBadRequest, map[string]string{"err": "Bad request"})
|
||||||
|
}
|
||||||
|
|
||||||
|
collection, _ := um.app.FindCollectionByNameOrId("users")
|
||||||
|
user := core.NewRecord(collection)
|
||||||
|
user.SetEmail(data.Email)
|
||||||
|
user.SetPassword(data.Password)
|
||||||
|
user.Set("role", "admin")
|
||||||
|
user.Set("verified", true)
|
||||||
|
if username := strings.Split(data.Email, "@")[0]; len(username) > 2 {
|
||||||
|
user.Set("username", username)
|
||||||
|
}
|
||||||
|
if err := um.app.Save(user); err != nil {
|
||||||
|
return e.JSON(http.StatusInternalServerError, map[string]string{"err": err.Error()})
|
||||||
|
}
|
||||||
|
// create superuser using the email of the first user
|
||||||
|
collection, _ = um.app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
|
||||||
|
adminUser := core.NewRecord(collection)
|
||||||
|
adminUser.SetEmail(data.Email)
|
||||||
|
adminUser.SetPassword(data.Password)
|
||||||
|
if err := um.app.Save(adminUser); err != nil {
|
||||||
|
return e.JSON(http.StatusInternalServerError, map[string]string{"err": err.Error()})
|
||||||
|
}
|
||||||
|
// delete the intial superuser
|
||||||
|
if err := um.app.Delete(adminUsers[0]); err != nil {
|
||||||
|
return e.JSON(http.StatusInternalServerError, map[string]string{"err": err.Error()})
|
||||||
|
}
|
||||||
|
return e.JSON(http.StatusOK, map[string]string{"msg": "User created"})
|
||||||
|
}
|
||||||
@@ -1,418 +0,0 @@
|
|||||||
package migrations
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
|
|
||||||
"github.com/pocketbase/dbx"
|
|
||||||
"github.com/pocketbase/pocketbase/daos"
|
|
||||||
m "github.com/pocketbase/pocketbase/migrations"
|
|
||||||
"github.com/pocketbase/pocketbase/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
m.Register(func(db dbx.Builder) error {
|
|
||||||
jsonData := `[
|
|
||||||
{
|
|
||||||
"id": "2hz5ncl8tizk5nx",
|
|
||||||
"created": "2024-07-07 16:08:20.979Z",
|
|
||||||
"updated": "2024-07-28 17:00:47.996Z",
|
|
||||||
"name": "systems",
|
|
||||||
"type": "base",
|
|
||||||
"system": false,
|
|
||||||
"schema": [
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "7xloxkwk",
|
|
||||||
"name": "name",
|
|
||||||
"type": "text",
|
|
||||||
"required": true,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"min": null,
|
|
||||||
"max": null,
|
|
||||||
"pattern": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "waj7seaf",
|
|
||||||
"name": "status",
|
|
||||||
"type": "select",
|
|
||||||
"required": false,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"maxSelect": 1,
|
|
||||||
"values": [
|
|
||||||
"up",
|
|
||||||
"down",
|
|
||||||
"paused",
|
|
||||||
"pending"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "ve781smf",
|
|
||||||
"name": "host",
|
|
||||||
"type": "text",
|
|
||||||
"required": true,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"min": null,
|
|
||||||
"max": null,
|
|
||||||
"pattern": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "pij0k2jk",
|
|
||||||
"name": "port",
|
|
||||||
"type": "text",
|
|
||||||
"required": true,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"min": null,
|
|
||||||
"max": null,
|
|
||||||
"pattern": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "qoq64ntl",
|
|
||||||
"name": "info",
|
|
||||||
"type": "json",
|
|
||||||
"required": false,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"maxSize": 2000000
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "jcarjnjj",
|
|
||||||
"name": "users",
|
|
||||||
"type": "relation",
|
|
||||||
"required": true,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"collectionId": "_pb_users_auth_",
|
|
||||||
"cascadeDelete": true,
|
|
||||||
"minSelect": null,
|
|
||||||
"maxSelect": null,
|
|
||||||
"displayFields": null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"indexes": [],
|
|
||||||
"listRule": "@request.auth.id != \"\" && users.id ?= @request.auth.id",
|
|
||||||
"viewRule": "@request.auth.id != \"\" && users.id ?= @request.auth.id",
|
|
||||||
"createRule": "@request.auth.id != \"\" && users.id ?= @request.auth.id && @request.auth.role != \"readonly\"",
|
|
||||||
"updateRule": "@request.auth.id != \"\" && users.id ?= @request.auth.id && @request.auth.role != \"readonly\"",
|
|
||||||
"deleteRule": "@request.auth.id != \"\" && users.id ?= @request.auth.id && @request.auth.role != \"readonly\"",
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ej9oowivz8b2mht",
|
|
||||||
"created": "2024-07-07 16:09:09.179Z",
|
|
||||||
"updated": "2024-07-22 20:13:31.324Z",
|
|
||||||
"name": "system_stats",
|
|
||||||
"type": "base",
|
|
||||||
"system": false,
|
|
||||||
"schema": [
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "h9sg148r",
|
|
||||||
"name": "system",
|
|
||||||
"type": "relation",
|
|
||||||
"required": true,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"collectionId": "2hz5ncl8tizk5nx",
|
|
||||||
"cascadeDelete": true,
|
|
||||||
"minSelect": null,
|
|
||||||
"maxSelect": 1,
|
|
||||||
"displayFields": null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "azftn0be",
|
|
||||||
"name": "stats",
|
|
||||||
"type": "json",
|
|
||||||
"required": true,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"maxSize": 2000000
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "m1ekhli3",
|
|
||||||
"name": "type",
|
|
||||||
"type": "select",
|
|
||||||
"required": true,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"maxSelect": 1,
|
|
||||||
"values": [
|
|
||||||
"1m",
|
|
||||||
"10m",
|
|
||||||
"20m",
|
|
||||||
"120m",
|
|
||||||
"480m"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"indexes": [
|
|
||||||
"CREATE INDEX ` + "`" + `idx_GxIee0j` + "`" + ` ON ` + "`" + `system_stats` + "`" + ` (` + "`" + `system` + "`" + `)"
|
|
||||||
],
|
|
||||||
"listRule": "@request.auth.id != \"\"",
|
|
||||||
"viewRule": null,
|
|
||||||
"createRule": null,
|
|
||||||
"updateRule": null,
|
|
||||||
"deleteRule": null,
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "juohu4jipgc13v7",
|
|
||||||
"created": "2024-07-07 16:09:57.976Z",
|
|
||||||
"updated": "2024-07-22 20:13:31.324Z",
|
|
||||||
"name": "container_stats",
|
|
||||||
"type": "base",
|
|
||||||
"system": false,
|
|
||||||
"schema": [
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "hutcu6ps",
|
|
||||||
"name": "system",
|
|
||||||
"type": "relation",
|
|
||||||
"required": true,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"collectionId": "2hz5ncl8tizk5nx",
|
|
||||||
"cascadeDelete": true,
|
|
||||||
"minSelect": null,
|
|
||||||
"maxSelect": 1,
|
|
||||||
"displayFields": null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "r39hhnil",
|
|
||||||
"name": "stats",
|
|
||||||
"type": "json",
|
|
||||||
"required": true,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"maxSize": 2000000
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "vo7iuj96",
|
|
||||||
"name": "type",
|
|
||||||
"type": "select",
|
|
||||||
"required": true,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"maxSelect": 1,
|
|
||||||
"values": [
|
|
||||||
"1m",
|
|
||||||
"10m",
|
|
||||||
"20m",
|
|
||||||
"120m",
|
|
||||||
"480m"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"indexes": [],
|
|
||||||
"listRule": "@request.auth.id != \"\"",
|
|
||||||
"viewRule": null,
|
|
||||||
"createRule": null,
|
|
||||||
"updateRule": null,
|
|
||||||
"deleteRule": null,
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "_pb_users_auth_",
|
|
||||||
"created": "2024-07-14 16:25:18.226Z",
|
|
||||||
"updated": "2024-07-28 17:02:08.311Z",
|
|
||||||
"name": "users",
|
|
||||||
"type": "auth",
|
|
||||||
"system": false,
|
|
||||||
"schema": [
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "qkbp58ae",
|
|
||||||
"name": "role",
|
|
||||||
"type": "select",
|
|
||||||
"required": false,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"maxSelect": 1,
|
|
||||||
"values": [
|
|
||||||
"user",
|
|
||||||
"admin",
|
|
||||||
"readonly"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "users_avatar",
|
|
||||||
"name": "avatar",
|
|
||||||
"type": "file",
|
|
||||||
"required": false,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"mimeTypes": [
|
|
||||||
"image/jpeg",
|
|
||||||
"image/png",
|
|
||||||
"image/svg+xml",
|
|
||||||
"image/gif",
|
|
||||||
"image/webp"
|
|
||||||
],
|
|
||||||
"thumbs": null,
|
|
||||||
"maxSelect": 1,
|
|
||||||
"maxSize": 5242880,
|
|
||||||
"protected": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"indexes": [],
|
|
||||||
"listRule": "id = @request.auth.id",
|
|
||||||
"viewRule": "id = @request.auth.id",
|
|
||||||
"createRule": null,
|
|
||||||
"updateRule": null,
|
|
||||||
"deleteRule": null,
|
|
||||||
"options": {
|
|
||||||
"allowEmailAuth": true,
|
|
||||||
"allowOAuth2Auth": true,
|
|
||||||
"allowUsernameAuth": false,
|
|
||||||
"exceptEmailDomains": null,
|
|
||||||
"manageRule": null,
|
|
||||||
"minPasswordLength": 8,
|
|
||||||
"onlyEmailDomains": null,
|
|
||||||
"onlyVerified": true,
|
|
||||||
"requireEmail": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "elngm8x1l60zi2v",
|
|
||||||
"created": "2024-07-15 01:16:04.044Z",
|
|
||||||
"updated": "2024-07-22 20:13:31.324Z",
|
|
||||||
"name": "alerts",
|
|
||||||
"type": "base",
|
|
||||||
"system": false,
|
|
||||||
"schema": [
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "hn5ly3vi",
|
|
||||||
"name": "user",
|
|
||||||
"type": "relation",
|
|
||||||
"required": true,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"collectionId": "_pb_users_auth_",
|
|
||||||
"cascadeDelete": true,
|
|
||||||
"minSelect": null,
|
|
||||||
"maxSelect": 1,
|
|
||||||
"displayFields": null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "g5sl3jdg",
|
|
||||||
"name": "system",
|
|
||||||
"type": "relation",
|
|
||||||
"required": true,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"collectionId": "2hz5ncl8tizk5nx",
|
|
||||||
"cascadeDelete": true,
|
|
||||||
"minSelect": null,
|
|
||||||
"maxSelect": 1,
|
|
||||||
"displayFields": null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "zj3ingrv",
|
|
||||||
"name": "name",
|
|
||||||
"type": "select",
|
|
||||||
"required": true,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"maxSelect": 1,
|
|
||||||
"values": [
|
|
||||||
"Status",
|
|
||||||
"CPU",
|
|
||||||
"Memory",
|
|
||||||
"Disk"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "o2ablxvn",
|
|
||||||
"name": "value",
|
|
||||||
"type": "number",
|
|
||||||
"required": false,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"min": null,
|
|
||||||
"max": null,
|
|
||||||
"noDecimal": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "6hgdf6hs",
|
|
||||||
"name": "triggered",
|
|
||||||
"type": "bool",
|
|
||||||
"required": false,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"indexes": [],
|
|
||||||
"listRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
|
||||||
"viewRule": "",
|
|
||||||
"createRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
|
||||||
"updateRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
|
||||||
"deleteRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
|
||||||
"options": {}
|
|
||||||
}
|
|
||||||
]`
|
|
||||||
|
|
||||||
collections := []*models.Collection{}
|
|
||||||
if err := json.Unmarshal([]byte(jsonData), &collections); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return daos.New(db).ImportCollections(collections, true, nil)
|
|
||||||
}, func(db dbx.Builder) error {
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
1258
beszel/migrations/1732489917_collections_snapshot.go
Normal file
@@ -1,19 +1,29 @@
|
|||||||
package migrations
|
package migrations
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/daos"
|
|
||||||
m "github.com/pocketbase/pocketbase/migrations"
|
m "github.com/pocketbase/pocketbase/migrations"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/security"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
TempAdminEmail = "_@b.b"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
m.Register(func(db dbx.Builder) error {
|
m.Register(func(app core.App) error {
|
||||||
dao := daos.New(db)
|
// initial settings
|
||||||
|
settings := app.Settings()
|
||||||
settings, _ := dao.FindSettings()
|
|
||||||
settings.Meta.AppName = "Beszel"
|
settings.Meta.AppName = "Beszel"
|
||||||
settings.Meta.HideControls = true
|
settings.Meta.HideControls = true
|
||||||
|
if err := app.Save(settings); err != nil {
|
||||||
return dao.SaveSettings(settings)
|
return err
|
||||||
|
}
|
||||||
|
// create superuser
|
||||||
|
collection, _ := app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
|
||||||
|
user := core.NewRecord(collection)
|
||||||
|
user.SetEmail(TempAdminEmail)
|
||||||
|
user.SetPassword(security.RandomString(12))
|
||||||
|
return app.Save(user)
|
||||||
}, nil)
|
}, nil)
|
||||||
}
|
}
|
||||||
|
|||||||
8
beszel/site/.prettierrc
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"useTabs": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": false,
|
||||||
|
"printWidth": 120
|
||||||
|
}
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
"style": "default",
|
"style": "default",
|
||||||
"rsc": false,
|
"rsc": false,
|
||||||
"tsx": true,
|
"tsx": true,
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"config": "tailwind.config.js",
|
"config": "tailwind.config.js",
|
||||||
"css": "src/index.css",
|
"css": "src/index.css",
|
||||||
"baseColor": "gray",
|
"baseColor": "gray",
|
||||||
"cssVariables": true,
|
"cssVariables": true,
|
||||||
"prefix": ""
|
"prefix": ""
|
||||||
},
|
},
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "@/components",
|
"components": "@/components",
|
||||||
"utils": "@/lib/utils"
|
"utils": "@/lib/utils"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,13 +3,11 @@ package site
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
|
"io/fs"
|
||||||
"github.com/labstack/echo/v5"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed all:dist
|
//go:embed all:dist
|
||||||
var assets embed.FS
|
var distDir embed.FS
|
||||||
|
|
||||||
var Dist = echo.MustSubFS(assets, "dist")
|
// DistDirFS contains the embedded dist directory files (without the "dist" prefix)
|
||||||
|
var DistDirFS, _ = fs.Sub(distDir, "dist")
|
||||||
var Static = echo.MustSubFS(assets, "dist/static")
|
|
||||||
|
|||||||
@@ -1,16 +1,10 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" dir="ltr">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Beszel</title>
|
<title>Beszel</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
||||||
<link
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap"
|
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
37
beszel/site/lingui.config.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { LinguiConfig } from "@lingui/conf"
|
||||||
|
|
||||||
|
const config: LinguiConfig = {
|
||||||
|
locales: [
|
||||||
|
"en",
|
||||||
|
"ar",
|
||||||
|
"cs",
|
||||||
|
"de",
|
||||||
|
"es",
|
||||||
|
"fa",
|
||||||
|
"fr",
|
||||||
|
"hr",
|
||||||
|
"it",
|
||||||
|
"ja",
|
||||||
|
"ko",
|
||||||
|
"nl",
|
||||||
|
"pl",
|
||||||
|
"pt",
|
||||||
|
"tr",
|
||||||
|
"ru",
|
||||||
|
"sv",
|
||||||
|
"uk",
|
||||||
|
"vi",
|
||||||
|
"zh-CN",
|
||||||
|
"zh-HK",
|
||||||
|
],
|
||||||
|
sourceLocale: "en",
|
||||||
|
compileNamespace: "ts",
|
||||||
|
catalogs: [
|
||||||
|
{
|
||||||
|
path: "<rootDir>/src/locales/{locale}/{locale}",
|
||||||
|
include: ["src"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
5096
beszel/site/package-lock.json
generated
@@ -1,53 +1,72 @@
|
|||||||
{
|
{
|
||||||
"name": "site",
|
"name": "beszel",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "lingui extract --overwrite && lingui compile && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"sync": "lingui extract --overwrite && lingui compile",
|
||||||
|
"sync_and_purge": "lingui extract --overwrite --clean && lingui compile"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@henrygd/queue": "^1.0.7",
|
||||||
|
"@lingui/detect-locale": "^4.14.1",
|
||||||
|
"@lingui/macro": "^4.14.1",
|
||||||
|
"@lingui/react": "^4.14.1",
|
||||||
"@nanostores/react": "^0.7.3",
|
"@nanostores/react": "^0.7.3",
|
||||||
"@nanostores/router": "^0.15.1",
|
"@nanostores/router": "^0.11.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||||
"@radix-ui/react-dialog": "^1.1.1",
|
"@radix-ui/react-checkbox": "^1.1.3",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
"@radix-ui/react-dialog": "^1.1.4",
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-direction": "^1.1.0",
|
||||||
"@radix-ui/react-select": "^2.1.1",
|
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||||
"@radix-ui/react-separator": "^1.1.0",
|
"@radix-ui/react-label": "^2.1.1",
|
||||||
"@radix-ui/react-slider": "^1.2.0",
|
"@radix-ui/react-select": "^2.1.4",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-separator": "^1.1.1",
|
||||||
"@radix-ui/react-switch": "^1.1.0",
|
"@radix-ui/react-slider": "^1.2.2",
|
||||||
"@radix-ui/react-toast": "^1.2.1",
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
"@radix-ui/react-tooltip": "^1.1.2",
|
"@radix-ui/react-switch": "^1.1.2",
|
||||||
"@tanstack/react-table": "^8.20.1",
|
"@radix-ui/react-tabs": "^1.1.2",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@radix-ui/react-toast": "^1.2.4",
|
||||||
"class-variance-authority": "^0.7.0",
|
"@radix-ui/react-tooltip": "^1.1.6",
|
||||||
|
"@tanstack/react-table": "^8.20.6",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.0.0",
|
"cmdk": "^1.0.4",
|
||||||
"d3-scale": "^4.0.2",
|
|
||||||
"d3-time": "^3.1.0",
|
"d3-time": "^3.1.0",
|
||||||
"lucide-react": "^0.407.0",
|
"lucide-react": "^0.452.0",
|
||||||
"nanostores": "^0.10.3",
|
"nanostores": "^0.11.3",
|
||||||
"pocketbase": "^0.21.4",
|
"pocketbase": "^0.22.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"recharts": "^2.13.0-alpha.4",
|
"recharts": "^2.15.0",
|
||||||
"tailwind-merge": "^2.4.0",
|
"tailwind-merge": "^2.5.5",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"use-is-in-viewport": "^1.0.9",
|
|
||||||
"valibot": "^0.36.0"
|
"valibot": "^0.36.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.1.6",
|
"@lingui/cli": "^4.14.1",
|
||||||
"@types/react": "^18.3.3",
|
"@lingui/swc-plugin": "^4.1.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@lingui/vite-plugin": "^4.14.1",
|
||||||
|
"@types/bun": "^1.1.14",
|
||||||
|
"@types/react": "^18.3.17",
|
||||||
|
"@types/react-dom": "^18.3.5",
|
||||||
|
"@vitejs/plugin-react-swc": "^3.7.2",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"postcss": "^8.4.40",
|
"postcss": "^8.4.49",
|
||||||
"tailwindcss": "^3.4.7",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5.5.4",
|
"tailwindcss-rtl": "^0.9.0",
|
||||||
"vite": "^5.3.5"
|
"typescript": "^5.7.2",
|
||||||
|
"vite": "^5.4.11"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"@nanostores/router": {
|
||||||
|
"nanostores": "^0.11.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/linux-arm64": "^0.21.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
beszel/site/public/static/InterVariable.woff2
Normal file
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12.2 6.9c-1 0-2.5-1-4-1-2 0-4 1.1-5 3-2 3.6-.5 9 1.5 12 1 1.5 2.3 3.2 3.8 3.1 1.6 0 2.1-1 4-1 1.8 0 2.3 1 4 1 1.6 0 2.6-1.5 3.6-3a13 13 0 0 0 1.7-3.4 5.3 5.3 0 0 1-.6-9.4 5.6 5.6 0 0 0-4.4-2.4C14.8 5.6 13 7 12.2 7zm3.3-3c.9-1 1.4-2.5 1.3-3.9-1.2 0-2.7.8-3.6 1.8A5 5 0 0 0 12 5.5c1.3.1 2.7-.7 3.5-1.7"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 378 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M.8 1.2a.8.8 0 0 0-.8 1l3.3 19.7c0 .5.5.9 1 .9h15.6a.8.8 0 0 0 .8-.7l3.3-20a.8.8 0 0 0-.8-.9zm13.7 14.3h-5l-1.3-7h7.5z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 196 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20.3 4.4a19.8 19.8 0 0 0-4.9-1.5L14.7 4C13 4 11.1 4 9.3 4.1L8.6 3a19.7 19.7 0 0 0-5 1.5C.6 9-.4 13.6.1 18.1c2 1.5 4 2.4 6 3h.1c.5-.6.9-1.3 1.2-2l-1.9-1V18l.4-.3c4 1.8 8.2 1.8 12.1 0h.1l.4.3v.1a12.3 12.3 0 0 1-2 1l1.3 2c2-.6 4-1.5 6-3h.1c.5-5.2-.8-9.7-3.6-13.7zM8 15.4c-1.2 0-2.1-1.2-2.1-2.5s1-2.4 2.1-2.4c1.2 0 2.2 1 2.2 2.4 0 1.3-1 2.4-2.2 2.4zm8 0c-1.2 0-2.2-1.2-2.2-2.5s1-2.4 2.2-2.4c1.2 0 2.2 1 2.2 2.4 0 1.3-1 2.4-2.2 2.4Z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 506 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M9.1 23.7v-8H6.6V12h2.5v-1.5c0-4.1 1.8-6 5.9-6h1.4a8.7 8.7 0 0 1 1.2.3V8a8.6 8.6 0 0 0-.7 0 26.8 26.8 0 0 0-.7 0c-.7 0-1.3 0-1.7.3a1.7 1.7 0 0 0-.7.6c-.2.4-.3 1-.3 1.7V12h3.9l-.4 2.1-.3 1.6h-3.2V24a12 12 0 1 0-4.4-.3Z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 295 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M4.2 4.6a4.2 4.2 0 0 0-2.9 1.1C-.4 7.3 0 9.7.1 10.1c0 .4.3 1.6 1.2 2.7C3 15 6.8 15 6.8 15S7.3 16 8 17c1 1.3 2 2.3 2.9 2.4H18s.4 0 1-.4c.6-.3 1-.9 1-.9s.6-.5 1.3-1.7l.5-1s2.1-4.6 2.1-9c0-1.2-.4-1.5-.4-1.5l-.4-.2s-4.5.3-6.8.3h-1.5v4.5l-.6-.3V5h-3.5l-6-.4h-.6zm.4 1.8s.3 2.3.7 3.6c.2 1.1 1 3 1 3l-1.7-.3c-1-.4-1.4-.8-1.4-.8s-.8-.5-1.1-1.5c-.7-1.7 0-2.7 0-2.7s.2-.9 1.4-1.1c.4-.2.9-.2 1-.2zM12.9 9l.5.1.9.4-.6 1.1a.7.7 0 0 0-.6.4.7.7 0 0 0 .1.7l-1 2a.7.7 0 0 0-.6.5.7.7 0 0 0 .3.7.7.7 0 0 0 1-.2.7.7 0 0 0-.2-.8l1-2a.7.7 0 0 0 .2 0 .7.7 0 0 0 .3 0 8.8 8.8 0 0 1 1 .4.8.8 0 0 1 .3.3l-.1.6c0 .3-.7 1.5-.7 1.5a.7.7 0 0 0-.7.5.7.7 0 1 0 1.2-.2l.2-.5.5-1.1c0-.1.2-.4.1-.8a1 1 0 0 0-.5-.7l-1-.6-.1-.2a.7.7 0 0 0-.2-.3l.5-1 3 1.4s.4.2.5.6v.6L16 16.8s-.2.5-.7.5a1 1 0 0 1-.4 0h-.2L10.4 15s-.4-.2-.5-.6l.1-.7 2-4.2s.3-.4.5-.5A.9.9 0 0 1 13 9z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 907 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm6 5.3c.4 0 .7.3.7.6v1.5a.6.6 0 0 1-.6.6H9.8C8.8 8 8 8.8 8 9.8v5.6c0 .3.3.6.6.6h5.6c1 0 1.8-.8 1.8-1.8V14a.6.6 0 0 0-.6-.6h-4.1a.6.6 0 0 1-.6-.6v-1.4a.6.6 0 0 1 .6-.6H18c.3 0 .6.2.6.6v3.4a4 4 0 0 1-4 4H5.9a.6.6 0 0 1-.6-.6V9.8a4.4 4.4 0 0 1 4.5-4.5H18Z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 406 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 .3a12 12 0 0 0-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.6-1.4-1.4-1.8-1.4-1.8-1-.7.1-.7.1-.7 1.2 0 1.9 1.2 1.9 1.2 1 1.8 2.8 1.3 3.5 1 0-.8.4-1.3.7-1.6-2.7-.3-5.5-1.3-5.5-6 0-1.2.5-2.3 1.3-3.1-.2-.4-.6-1.6 0-3.2 0 0 1-.3 3.4 1.2a11.5 11.5 0 0 1 6 0c2.3-1.5 3.3-1.2 3.3-1.2.6 1.6.2 2.8 0 3.2.9.8 1.3 1.9 1.3 3.2 0 4.6-2.8 5.6-5.5 5.9.5.4.9 1 .9 2.2v3.3c0 .3.1.7.8.6A12 12 0 0 0 12 .3"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 470 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M23.6 9.6 20.3 1a.9.9 0 0 0-.3-.4.9.9 0 0 0-1 0 .9.9 0 0 0-.3.5l-2.2 6.7h-9L5.3 1.1A.9.9 0 0 0 5 .6a.9.9 0 0 0-1 0 .9.9 0 0 0-.3.4L.4 9.5a6 6 0 0 0 2 7.1l5 3.8 2.5 1.8 1.5 1.1a1 1 0 0 0 1.2 0l1.5-1 2.5-2 5-3.7a6 6 0 0 0 2-7z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 302 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12.5 11v3.2h7.8a7 7 0 0 1-1.8 4.1 8 8 0 0 1-6 2.4c-4.8 0-8.6-3.9-8.6-8.7a8.6 8.6 0 0 1 14.5-6.4l2.3-2.3C18.7 1.4 16 0 12.5 0 5.9 0 .3 5.4.3 12S6 24 12.5 24a11 11 0 0 0 8.4-3.4c2.1-2.1 2.8-5.2 2.8-7.6 0-.8 0-1.5-.2-2h-11z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 299 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M7 0C5.8.2 5 .4 4.1.7 3.3 1 2.7 1.4 2 2c-.7.7-1 1.4-1.4 2.2C.3 4.9.1 5.8.1 7a84.6 84.6 0 0 0 .5 12.8c.4.8.8 1.4 1.4 2.1.7.7 1.4 1 2.2 1.4.7.3 1.6.5 2.9.5a85 85 0 0 0 12.8-.5c.8-.4 1.4-.8 2.1-1.4.7-.7 1-1.4 1.4-2.2.3-.7.5-1.6.5-2.9a85 85 0 0 0-.5-12.8C23 3.3 22.6 2.7 22 2c-.7-.7-1.4-1-2.2-1.4-.7-.3-1.6-.5-2.9-.5A85.5 85.5 0 0 0 7 0m.2 21.7c-1.2 0-1.8-.3-2.3-.4-.5-.2-1-.5-1.3-1-.5-.3-.7-.7-1-1.3-.1-.4-.3-1-.4-2.2a84.8 84.8 0 0 1 .4-12c.2-.5.5-1 1-1.3.3-.5.7-.7 1.3-1 .4-.1 1-.3 2.2-.4a84.4 84.4 0 0 1 12 .4c.5.3 1 .5 1.3 1 .5.3.7.7 1 1.3.1.4.3 1 .4 2.2a82.7 82.7 0 0 1-.4 12c-.2.5-.5 1-1 1.3-.3.5-.7.7-1.3 1-.4.1-1 .3-2.2.4a84.9 84.9 0 0 1-9.7 0M17 5.6A1.4 1.4 0 1 0 18.4 4 1.4 1.4 0 0 0 17 5.6M5.8 12a6.2 6.2 0 1 0 12.4 0 6.2 6.2 0 0 0-12.4 0M8 12a4 4 0 1 1 4 4 4 4 0 0 1-4-4"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 856 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M14.5.9 11 2.7v18.1c-4.1-.5-7.3-2.7-7.3-5.5 0-2.5 2.8-4.7 6.7-5.4V7.6C4.4 8.3 0 11.5 0 15.3c0 4 4.7 7.3 11 7.8l3.5-1.7V.9m.7 6.7V10c1.4.3 2.7.7 3.7 1.3l-2 1.1L24 14l-.5-5.2-1.9 1c-1.7-1-4-1.8-6.4-2z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 276 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M23 7.2c0-3-2.4-5.6-5.2-6.5-3.5-1.1-8.1-1-11.4.6-4 2-5.3 6-5.4 10.2C1 15 1.3 24 6.4 24c3.8 0 4.3-4.8 6-7.1 1.3-1.7 3-2.2 4.9-2.7a7.1 7.1 0 0 0 5.7-7Z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 227 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.7 0 12 0zm5.5 17.3c-.2.4-.6.5-1 .3-2.8-1.8-6.4-2.1-10.6-1.2-.4.2-.7-.1-.9-.5 0-.4.2-.8.6-.9 4.5-1 8.5-.6 11.6 1.3.4.2.5.7.3 1zM19 14c-.3.5-.9.6-1.3.3-3.2-2-8.2-2.5-12-1.3-.4 0-1-.2-1-.6-.2-.5 0-1 .5-1.2 4.4-1.3 9.8-.6 13.5 1.6.4.2.6.8.3 1.2zm0-3.3A19.9 19.9 0 0 0 5.3 9.3c-.6.2-1.2-.2-1.4-.7-.2-.6.2-1.2.7-1.4 4.3-1.3 11.3-1 15.7 1.6.6.3.7 1 .4 1.6-.3.4-1 .6-1.5.3z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 495 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="m15.4 18-2.1-4.2h-3l5 10.2 5.2-10.2h-3m-7-5.6 2.8 5.6h4.2L10.5 0l-7 13.8h4.1"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 154 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M11.6 4.7h1.7V10h-1.7zm4.7 0H18V10h-1.7zM6 0 1.7 4.3v15.4H7V24l4.2-4.3h3.5l7.7-7.7V0zm14.6 11.1L17 14.6h-3.4l-3 3v-3H7V1.7h13.7Z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 206 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M22.5 6c-.8.3-1.6.6-2.5.7.9-.5 1.6-1.4 1.9-2.4-.8.5-1.8.9-2.7 1a4.3 4.3 0 0 0-7.3 4C8.2 9 5 7.3 3 4.8a4.2 4.2 0 0 0 1.3 5.7c-.7 0-1.3-.2-2-.5 0 2.1 1.6 3.8 3.5 4.2a4.2 4.2 0 0 1-2 .1 4.3 4.3 0 0 0 4 3A8.5 8.5 0 0 1 2.7 19h-1A12.1 12.1 0 0 0 20.3 8.8v-.6c.8-.6 1.5-1.3 2-2.2"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 371 B |
@@ -1,4 +1,4 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -7,17 +7,20 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from '@/components/ui/dialog'
|
} from "@/components/ui/dialog"
|
||||||
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
|
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from "@/components/ui/label"
|
||||||
import { $publicKey, pb } from '@/lib/stores'
|
import { $publicKey, pb } from "@/lib/stores"
|
||||||
import { Copy, PlusIcon } from 'lucide-react'
|
import { Copy, PlusIcon } from "lucide-react"
|
||||||
import { useState, useRef, MutableRefObject } from 'react'
|
import { useState, useRef, MutableRefObject } from "react"
|
||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from "@nanostores/react"
|
||||||
import { cn, copyToClipboard, isReadOnlyUser } from '@/lib/utils'
|
import { cn, copyToClipboard, isReadOnlyUser } from "@/lib/utils"
|
||||||
import { navigate } from './router'
|
import { navigate } from "./router"
|
||||||
|
import { Trans } from "@lingui/macro"
|
||||||
|
import { i18n } from "@lingui/core"
|
||||||
|
|
||||||
export function AddSystemButton({ className }: { className?: string }) {
|
export function AddSystemButton({ className }: { className?: string }) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
@@ -33,21 +36,31 @@ export function AddSystemButton({ className }: { className?: string }) {
|
|||||||
network_mode: host
|
network_mode: host
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
# monitor other disks / partitions by mounting a folder in /extra-filesystems
|
||||||
|
# - /mnt/disk/.beszel:/extra-filesystems/sda1:ro
|
||||||
environment:
|
environment:
|
||||||
PORT: ${port}
|
PORT: ${port}
|
||||||
KEY: "${publicKey}"
|
KEY: "${publicKey}"`)
|
||||||
# FILESYSTEM: /dev/sda1 # set to the correct filesystem for disk I/O stats`)
|
}
|
||||||
|
|
||||||
|
function copyInstallCommand(port: string) {
|
||||||
|
let cmd = `curl -sL https://raw.githubusercontent.com/henrygd/beszel/main/supplemental/scripts/install-agent.sh -o install-agent.sh && chmod +x install-agent.sh && ./install-agent.sh -p ${port} -k "${publicKey}"`
|
||||||
|
// add china mirrors flag if zh-CN
|
||||||
|
if ((i18n.locale + navigator.language).includes("zh-CN")) {
|
||||||
|
cmd += ` --china-mirrors`
|
||||||
|
}
|
||||||
|
copyToClipboard(cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: SubmitEvent) {
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const formData = new FormData(e.target as HTMLFormElement)
|
const formData = new FormData(e.target as HTMLFormElement)
|
||||||
const data = Object.fromEntries(formData) as Record<string, any>
|
const data = Object.fromEntries(formData) as Record<string, any>
|
||||||
data.users = pb.authStore.model!.id
|
data.users = pb.authStore.record!.id
|
||||||
try {
|
try {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
await pb.collection('systems').create(data)
|
await pb.collection("systems").create(data)
|
||||||
navigate('/')
|
navigate("/")
|
||||||
// console.log(record)
|
// console.log(record)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e)
|
console.log(e)
|
||||||
@@ -59,88 +72,113 @@ export function AddSystemButton({ className }: { className?: string }) {
|
|||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn('flex gap-1 max-xs:h-[2.4rem]', className, isReadOnlyUser() && 'hidden')}
|
className={cn("flex gap-1 max-xs:h-[2.4rem]", className, isReadOnlyUser() && "hidden")}
|
||||||
>
|
>
|
||||||
<PlusIcon className="h-4 w-4 -ml-1" />
|
<PlusIcon className="h-4 w-4 -ms-1" />
|
||||||
Add <span className="hidden xs:inline">System</span>
|
<Trans>
|
||||||
|
Add <span className="hidden sm:inline">System</span>
|
||||||
|
</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="w-[90%] sm:max-w-[425px] rounded-lg">
|
<DialogContent className="w-[90%] sm:max-w-[440px] rounded-lg">
|
||||||
<DialogHeader>
|
<Tabs defaultValue="docker">
|
||||||
<DialogTitle className="mb-2">Add New System</DialogTitle>
|
<DialogHeader>
|
||||||
<DialogDescription>
|
<DialogTitle className="mb-2">
|
||||||
The agent must be running on the system to connect. Copy the{' '}
|
<Trans>Add New System</Trans>
|
||||||
<code className="bg-muted px-1 rounded-sm">docker-compose.yml</code> for the agent
|
</DialogTitle>
|
||||||
below.
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
</DialogDescription>
|
<TabsTrigger value="docker">Docker</TabsTrigger>
|
||||||
</DialogHeader>
|
<TabsTrigger value="binary">
|
||||||
<form onSubmit={handleSubmit as any}>
|
<Trans>Binary</Trans>
|
||||||
<div className="grid gap-3 mt-1 mb-4">
|
</TabsTrigger>
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
</TabsList>
|
||||||
<Label htmlFor="name" className="text-right">
|
</DialogHeader>
|
||||||
Name
|
{/* Docker */}
|
||||||
|
<TabsContent value="docker">
|
||||||
|
<DialogDescription className="mb-4 leading-normal">
|
||||||
|
<Trans>
|
||||||
|
The agent must be running on the system to connect. Copy the
|
||||||
|
<code className="bg-muted px-1 rounded-sm leading-3">docker-compose.yml</code> for the agent below.
|
||||||
|
</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</TabsContent>
|
||||||
|
{/* Binary */}
|
||||||
|
<TabsContent value="binary">
|
||||||
|
<DialogDescription className="mb-4 leading-normal">
|
||||||
|
<Trans>
|
||||||
|
The agent must be running on the system to connect. Copy the installation command for the agent below.
|
||||||
|
</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</TabsContent>
|
||||||
|
<form onSubmit={handleSubmit as any}>
|
||||||
|
<div className="grid xs:grid-cols-[auto_1fr] gap-y-3 gap-x-4 items-center mt-1 mb-4">
|
||||||
|
<Label htmlFor="name" className="xs:text-end">
|
||||||
|
<Trans>Name</Trans>
|
||||||
</Label>
|
</Label>
|
||||||
<Input id="name" name="name" className="col-span-3" required />
|
<Input id="name" name="name" className="" required />
|
||||||
</div>
|
<Label htmlFor="host" className="xs:text-end">
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<Trans>Host / IP</Trans>
|
||||||
<Label htmlFor="host" className="text-right">
|
|
||||||
Host / IP
|
|
||||||
</Label>
|
</Label>
|
||||||
<Input id="host" name="host" className="col-span-3" required />
|
<Input id="host" name="host" className="" required />
|
||||||
</div>
|
<Label htmlFor="port" className="xs:text-end">
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<Trans>Port</Trans>
|
||||||
<Label htmlFor="port" className="text-right">
|
|
||||||
Port
|
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input ref={port} name="port" id="port" defaultValue="45876" className="" required />
|
||||||
ref={port}
|
<Label htmlFor="pkey" className="xs:text-end whitespace-pre">
|
||||||
name="port"
|
<Trans comment="Use 'Key' if your language requires many more characters">Public Key</Trans>
|
||||||
id="port"
|
|
||||||
defaultValue="45876"
|
|
||||||
className="col-span-3"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4 relative">
|
|
||||||
<Label htmlFor="pkey" className="text-right whitespace-pre">
|
|
||||||
Public Key
|
|
||||||
</Label>
|
</Label>
|
||||||
<Input readOnly id="pkey" value={publicKey} className="col-span-3" required></Input>
|
<div className="relative">
|
||||||
<div
|
<Input readOnly id="pkey" value={publicKey} className="" required></Input>
|
||||||
className={
|
<div
|
||||||
'h-6 w-24 bg-gradient-to-r from-transparent to-background to-65% absolute right-1 pointer-events-none'
|
className={
|
||||||
}
|
"h-6 w-24 bg-gradient-to-r rtl:bg-gradient-to-l from-transparent to-background to-65% absolute top-2 end-1 pointer-events-none"
|
||||||
></div>
|
}
|
||||||
<TooltipProvider delayDuration={100}>
|
></div>
|
||||||
<Tooltip>
|
<TooltipProvider delayDuration={100}>
|
||||||
<TooltipTrigger asChild>
|
<Tooltip>
|
||||||
<Button
|
<TooltipTrigger asChild>
|
||||||
type="button"
|
<Button
|
||||||
variant={'link'}
|
type="button"
|
||||||
className="absolute right-0"
|
variant={"link"}
|
||||||
onClick={() => copyToClipboard(publicKey)}
|
className="absolute end-0 top-0"
|
||||||
>
|
onClick={() => copyToClipboard(publicKey)}
|
||||||
<Copy className="h-4 w-4 " />
|
>
|
||||||
</Button>
|
<Copy className="h-4 w-4 " />
|
||||||
</TooltipTrigger>
|
</Button>
|
||||||
<TooltipContent>
|
</TooltipTrigger>
|
||||||
<p>Click to copy</p>
|
<TooltipContent>
|
||||||
</TooltipContent>
|
<p>
|
||||||
</Tooltip>
|
<Trans>Click to copy</Trans>
|
||||||
</TooltipProvider>
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/* Docker */}
|
||||||
<DialogFooter className="flex justify-end gap-2">
|
<TabsContent value="docker">
|
||||||
<Button
|
<DialogFooter className="flex justify-end gap-2 sm:w-[calc(100%+20px)] sm:-ms-[20px]">
|
||||||
type="button"
|
<Button type="button" variant={"ghost"} onClick={() => copyDockerCompose(port.current.value)}>
|
||||||
variant={'ghost'}
|
<Trans>Copy</Trans> docker compose
|
||||||
onClick={() => copyDockerCompose(port.current.value)}
|
</Button>
|
||||||
>
|
<Button>
|
||||||
Copy docker compose
|
<Trans>Add system</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
<Button>Add system</Button>
|
</DialogFooter>
|
||||||
</DialogFooter>
|
</TabsContent>
|
||||||
</form>
|
{/* Binary */}
|
||||||
|
<TabsContent value="binary">
|
||||||
|
<DialogFooter className="flex justify-end gap-2 sm:w-[calc(100%+20px)] sm:-ms-[20px]">
|
||||||
|
<Button type="button" variant={"ghost"} onClick={() => copyInstallCommand(port.current.value)}>
|
||||||
|
<Trans>Copy Linux command</Trans>
|
||||||
|
</Button>
|
||||||
|
<Button>
|
||||||
|
<Trans>Add system</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</TabsContent>
|
||||||
|
</form>
|
||||||
|
</Tabs>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
|
|||||||
120
beszel/site/src/components/alerts/alert-button.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { memo, useState } from "react"
|
||||||
|
import { useStore } from "@nanostores/react"
|
||||||
|
import { $alerts, $systems } from "@/lib/stores"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { BellIcon, GlobeIcon, ServerIcon } from "lucide-react"
|
||||||
|
import { alertInfo, cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { AlertRecord, SystemRecord } from "@/types"
|
||||||
|
import { Link } from "../router"
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
|
import { Checkbox } from "../ui/checkbox"
|
||||||
|
import { SystemAlert, SystemAlertGlobal } from "./alerts-system"
|
||||||
|
import { Trans, t } from "@lingui/macro"
|
||||||
|
|
||||||
|
export default memo(function AlertsButton({ system }: { system: SystemRecord }) {
|
||||||
|
const alerts = useStore($alerts)
|
||||||
|
const [opened, setOpened] = useState(false)
|
||||||
|
|
||||||
|
const systemAlerts = alerts.filter((alert) => alert.system === system.id) as AlertRecord[]
|
||||||
|
const active = systemAlerts.length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger 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": active,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-h-full overflow-auto max-w-[35rem]">
|
||||||
|
{opened && <TheContent data={{ system, alerts, systemAlerts }} />}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function TheContent({
|
||||||
|
data: { system, alerts, systemAlerts },
|
||||||
|
}: {
|
||||||
|
data: { system: SystemRecord; alerts: AlertRecord[]; systemAlerts: AlertRecord[] }
|
||||||
|
}) {
|
||||||
|
const [overwriteExisting, setOverwriteExisting] = useState<boolean | "indeterminate">(false)
|
||||||
|
const systems = $systems.get()
|
||||||
|
|
||||||
|
const data = Object.keys(alertInfo).map((key) => {
|
||||||
|
const alert = alertInfo[key as keyof typeof alertInfo]
|
||||||
|
return {
|
||||||
|
key: key as keyof typeof alertInfo,
|
||||||
|
alert,
|
||||||
|
system,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-xl">
|
||||||
|
<Trans>Alerts</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>
|
||||||
|
See{" "}
|
||||||
|
<Link href="/settings/notifications" className="link">
|
||||||
|
notification settings
|
||||||
|
</Link>{" "}
|
||||||
|
to configure how you receive alerts.
|
||||||
|
</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Tabs defaultValue="system">
|
||||||
|
<TabsList className="mb-1 -mt-0.5">
|
||||||
|
<TabsTrigger value="system">
|
||||||
|
<ServerIcon className="me-2 h-3.5 w-3.5" />
|
||||||
|
{system.name}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="global">
|
||||||
|
<GlobeIcon className="me-1.5 h-3.5 w-3.5" />
|
||||||
|
<Trans>All Systems</Trans>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="system">
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{data.map((d) => (
|
||||||
|
<SystemAlert key={d.key} system={system} data={d} systemAlerts={systemAlerts} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="global">
|
||||||
|
<label
|
||||||
|
htmlFor="ovw"
|
||||||
|
className="mb-3 flex gap-2 items-center justify-center cursor-pointer border rounded-sm py-3 px-4 border-destructive text-destructive font-semibold text-sm"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id="ovw"
|
||||||
|
className="text-destructive border-destructive data-[state=checked]:bg-destructive"
|
||||||
|
checked={overwriteExisting}
|
||||||
|
onCheckedChange={setOverwriteExisting}
|
||||||
|
/>
|
||||||
|
<Trans>Overwrite existing alerts</Trans>
|
||||||
|
</label>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{data.map((d) => (
|
||||||
|
<SystemAlertGlobal key={d.key} data={d} overwrite={overwriteExisting} alerts={alerts} systems={systems} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
248
beszel/site/src/components/alerts/alerts-system.tsx
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import { pb } from "@/lib/stores"
|
||||||
|
import { alertInfo, cn } from "@/lib/utils"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { AlertInfo, AlertRecord, SystemRecord } from "@/types"
|
||||||
|
import { lazy, Suspense, useRef, useState } from "react"
|
||||||
|
import { toast } from "../ui/use-toast"
|
||||||
|
import { RecordOptions } from "pocketbase"
|
||||||
|
import { Trans, t, Plural } from "@lingui/macro"
|
||||||
|
|
||||||
|
interface AlertData {
|
||||||
|
checked?: boolean
|
||||||
|
val?: number
|
||||||
|
min?: number
|
||||||
|
updateAlert?: (checked: boolean, value: number, min: number) => void
|
||||||
|
key: keyof typeof alertInfo
|
||||||
|
alert: AlertInfo
|
||||||
|
system: SystemRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
const Slider = lazy(() => import("@/components/ui/slider"))
|
||||||
|
|
||||||
|
const failedUpdateToast = () =>
|
||||||
|
toast({
|
||||||
|
title: t`Failed to update alert`,
|
||||||
|
description: t`Please check logs for more details.`,
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
|
||||||
|
export function SystemAlert({
|
||||||
|
system,
|
||||||
|
systemAlerts,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
system: SystemRecord
|
||||||
|
systemAlerts: AlertRecord[]
|
||||||
|
data: AlertData
|
||||||
|
}) {
|
||||||
|
const alert = systemAlerts.find((alert) => alert.name === data.key)
|
||||||
|
|
||||||
|
data.updateAlert = async (checked: boolean, value: number, min: number) => {
|
||||||
|
try {
|
||||||
|
if (alert && !checked) {
|
||||||
|
await pb.collection("alerts").delete(alert.id)
|
||||||
|
} else if (alert && checked) {
|
||||||
|
await pb.collection("alerts").update(alert.id, { value, min, triggered: false })
|
||||||
|
} else if (checked) {
|
||||||
|
pb.collection("alerts").create({
|
||||||
|
system: system.id,
|
||||||
|
user: pb.authStore.record!.id,
|
||||||
|
name: data.key,
|
||||||
|
value: value,
|
||||||
|
min: min,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
failedUpdateToast()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alert) {
|
||||||
|
data.checked = true
|
||||||
|
data.val = alert.value
|
||||||
|
data.min = alert.min || 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AlertContent data={data} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SystemAlertGlobal({
|
||||||
|
data,
|
||||||
|
overwrite,
|
||||||
|
alerts,
|
||||||
|
systems,
|
||||||
|
}: {
|
||||||
|
data: AlertData
|
||||||
|
overwrite: boolean | "indeterminate"
|
||||||
|
alerts: AlertRecord[]
|
||||||
|
systems: SystemRecord[]
|
||||||
|
}) {
|
||||||
|
const systemsWithExistingAlerts = useRef<{ set: Set<string>; populatedSet: boolean }>({
|
||||||
|
set: new Set(),
|
||||||
|
populatedSet: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
data.checked = false
|
||||||
|
data.val = data.min = 0
|
||||||
|
|
||||||
|
data.updateAlert = async (checked: boolean, value: number, min: number) => {
|
||||||
|
const { set, populatedSet } = systemsWithExistingAlerts.current
|
||||||
|
|
||||||
|
// if overwrite checked, make sure all alerts will be overwritten
|
||||||
|
if (overwrite) {
|
||||||
|
set.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
const recordData: Partial<AlertRecord> = {
|
||||||
|
value,
|
||||||
|
min,
|
||||||
|
triggered: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
// we can only send 50 in one batch
|
||||||
|
let done = 0
|
||||||
|
|
||||||
|
while (done < systems.length) {
|
||||||
|
const batch = pb.createBatch()
|
||||||
|
let batchSize = 0
|
||||||
|
|
||||||
|
for (let i = done; i < Math.min(done + 50, systems.length); i++) {
|
||||||
|
const system = systems[i]
|
||||||
|
// if overwrite is false and system is in set (alert existed), skip
|
||||||
|
if (!overwrite && set.has(system.id)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// find matching existing alert
|
||||||
|
const existingAlert = alerts.find((alert) => alert.system === system.id && data.key === alert.name)
|
||||||
|
// if first run, add system to set (alert already existed when global panel was opened)
|
||||||
|
if (existingAlert && !populatedSet && !overwrite) {
|
||||||
|
set.add(system.id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
batchSize++
|
||||||
|
const requestOptions: RecordOptions = {
|
||||||
|
requestKey: system.id,
|
||||||
|
}
|
||||||
|
|
||||||
|
// checked - make sure alert is created or updated
|
||||||
|
if (checked) {
|
||||||
|
if (existingAlert) {
|
||||||
|
batch.collection("alerts").update(existingAlert.id, recordData, requestOptions)
|
||||||
|
} else {
|
||||||
|
batch.collection("alerts").create(
|
||||||
|
{
|
||||||
|
system: system.id,
|
||||||
|
user: pb.authStore.record!.id,
|
||||||
|
name: data.key,
|
||||||
|
...recordData,
|
||||||
|
},
|
||||||
|
requestOptions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (existingAlert) {
|
||||||
|
batch.collection("alerts").delete(existingAlert.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
batchSize && batch.send()
|
||||||
|
} catch (e) {
|
||||||
|
failedUpdateToast()
|
||||||
|
} finally {
|
||||||
|
done += 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
systemsWithExistingAlerts.current.populatedSet = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AlertContent data={data} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertContent({ data }: { data: AlertData }) {
|
||||||
|
const { key } = data
|
||||||
|
|
||||||
|
const hasSliders = !("single" in data.alert)
|
||||||
|
|
||||||
|
const [checked, setChecked] = useState(data.checked || false)
|
||||||
|
const [min, setMin] = useState(data.min || (hasSliders ? 10 : 0))
|
||||||
|
const [value, setValue] = useState(data.val || (hasSliders ? 80 : 0))
|
||||||
|
|
||||||
|
const showSliders = checked && hasSliders
|
||||||
|
|
||||||
|
const newMin = useRef(min)
|
||||||
|
const newValue = useRef(value)
|
||||||
|
|
||||||
|
const Icon = alertInfo[key].icon
|
||||||
|
|
||||||
|
const updateAlert = (c?: boolean) => data.updateAlert?.(c ?? checked, newValue.current, newMin.current)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-muted-foreground/15 hover:border-muted-foreground/20 transition-colors duration-100 group">
|
||||||
|
<label
|
||||||
|
htmlFor={`s${key}`}
|
||||||
|
className={cn("flex flex-row items-center justify-between gap-4 cursor-pointer p-4", {
|
||||||
|
"pb-0": showSliders,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div className="grid gap-1 select-none">
|
||||||
|
<p className="font-semibold flex gap-3 items-center">
|
||||||
|
<Icon className="h-4 w-4 opacity-85" /> {data.alert.name()}
|
||||||
|
</p>
|
||||||
|
{!showSliders && <span className="block text-sm text-muted-foreground">{data.alert.desc()}</span>}
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id={`s${key}`}
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setChecked(checked)
|
||||||
|
updateAlert(checked)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{showSliders && (
|
||||||
|
<div className="grid sm:grid-cols-2 mt-1.5 gap-5 px-4 pb-5 tabular-nums text-muted-foreground">
|
||||||
|
<Suspense fallback={<div className="h-10" />}>
|
||||||
|
<div>
|
||||||
|
<p id={`v${key}`} className="text-sm block h-8">
|
||||||
|
<Trans>
|
||||||
|
Average exceeds{" "}
|
||||||
|
<strong className="text-foreground">
|
||||||
|
{value}
|
||||||
|
{data.alert.unit}
|
||||||
|
</strong>
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Slider
|
||||||
|
aria-labelledby={`v${key}`}
|
||||||
|
defaultValue={[value]}
|
||||||
|
onValueCommit={(val) => (newValue.current = val[0]) && updateAlert()}
|
||||||
|
onValueChange={(val) => setValue(val[0])}
|
||||||
|
min={1}
|
||||||
|
max={alertInfo[key].max ?? 99}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p id={`t${key}`} className="text-sm block h-8">
|
||||||
|
<Trans>
|
||||||
|
For <strong className="text-foreground">{min}</strong>{" "}
|
||||||
|
<Plural value={min} one=" minute" other=" minutes" />
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Slider
|
||||||
|
aria-labelledby={`v${key}`}
|
||||||
|
defaultValue={[min]}
|
||||||
|
onValueCommit={(val) => (newMin.current = val[0]) && updateAlert()}
|
||||||
|
onValueChange={(val) => setMin(val[0])}
|
||||||
|
min={1}
|
||||||
|
max={60}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
143
beszel/site/src/components/charts/area-chart.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
||||||
|
|
||||||
|
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
||||||
|
import {
|
||||||
|
useYAxisWidth,
|
||||||
|
cn,
|
||||||
|
formatShortDate,
|
||||||
|
toFixedWithoutTrailingZeros,
|
||||||
|
decimalString,
|
||||||
|
chartMargin,
|
||||||
|
} from "@/lib/utils"
|
||||||
|
// import Spinner from '../spinner'
|
||||||
|
import { ChartData } from "@/types"
|
||||||
|
import { memo, useMemo } from "react"
|
||||||
|
import { t } from "@lingui/macro"
|
||||||
|
import { useLingui } from "@lingui/react"
|
||||||
|
|
||||||
|
/** [label, key, color, opacity] */
|
||||||
|
type DataKeys = [string, string, number, number]
|
||||||
|
|
||||||
|
const getNestedValue = (path: string, max = false, data: any): number | null => {
|
||||||
|
// fallback value (obj?.stats?.cpum ? 0 : null) should only come into play when viewing
|
||||||
|
// a max value which doesn't exist, or the value was zero and omitted from the stats object.
|
||||||
|
// so we check if cpum is present. if so, return 0 to make sure the zero value is displayed.
|
||||||
|
// if not, return null - there is no max data so do not display anything.
|
||||||
|
return `stats.${path}${max ? "m" : ""}`
|
||||||
|
.split(".")
|
||||||
|
.reduce((acc: any, key: string) => acc?.[key] ?? (data.stats?.cpum ? 0 : null), data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(function AreaChartDefault({
|
||||||
|
maxToggled = false,
|
||||||
|
unit = " MB/s",
|
||||||
|
chartName,
|
||||||
|
chartData,
|
||||||
|
max,
|
||||||
|
tickFormatter,
|
||||||
|
}: {
|
||||||
|
maxToggled?: boolean
|
||||||
|
unit?: string
|
||||||
|
chartName: string
|
||||||
|
chartData: ChartData
|
||||||
|
max?: number
|
||||||
|
tickFormatter?: (value: number) => string
|
||||||
|
}) {
|
||||||
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
|
const { i18n } = useLingui()
|
||||||
|
|
||||||
|
const { chartTime } = chartData
|
||||||
|
|
||||||
|
const showMax = chartTime !== "1h" && maxToggled
|
||||||
|
|
||||||
|
const dataKeys: DataKeys[] = useMemo(() => {
|
||||||
|
// [label, key, color, opacity]
|
||||||
|
if (chartName === "CPU Usage") {
|
||||||
|
return [[t`CPU Usage`, "cpu", 1, 0.4]]
|
||||||
|
} else if (chartName === "dio") {
|
||||||
|
return [
|
||||||
|
[t({ message: "Write", comment: "Disk write" }), "dw", 3, 0.3],
|
||||||
|
[t({ message: "Read", comment: "Disk read" }), "dr", 1, 0.3],
|
||||||
|
]
|
||||||
|
} else if (chartName === "bw") {
|
||||||
|
return [
|
||||||
|
[t({ message: "Sent", comment: "Network bytes sent (upload)" }), "ns", 5, 0.2],
|
||||||
|
[t({ message: "Received", comment: "Network bytes received (download)" }), "nr", 2, 0.2],
|
||||||
|
]
|
||||||
|
} else if (chartName.startsWith("efs")) {
|
||||||
|
return [
|
||||||
|
[t`Write`, `${chartName}.w`, 3, 0.3],
|
||||||
|
[t`Read`, `${chartName}.r`, 1, 0.3],
|
||||||
|
]
|
||||||
|
} else if (chartName.startsWith("g.")) {
|
||||||
|
return [chartName.includes("mu") ? [t`Used`, chartName, 2, 0.25] : [t`Usage`, chartName, 1, 0.4]]
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}, [chartName, i18n.locale])
|
||||||
|
|
||||||
|
// console.log('Rendered at', new Date())
|
||||||
|
|
||||||
|
if (chartData.systemStats.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ChartContainer
|
||||||
|
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
|
||||||
|
"opacity-100": yAxisWidth,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<YAxis
|
||||||
|
direction="ltr"
|
||||||
|
orientation={chartData.orientation}
|
||||||
|
className="tracking-tighter"
|
||||||
|
width={yAxisWidth}
|
||||||
|
domain={[0, max ?? "auto"]}
|
||||||
|
tickFormatter={(value) => {
|
||||||
|
let val: string
|
||||||
|
if (tickFormatter) {
|
||||||
|
val = tickFormatter(value)
|
||||||
|
} else {
|
||||||
|
val = toFixedWithoutTrailingZeros(value, 2) + unit
|
||||||
|
}
|
||||||
|
return updateYAxisWidth(val)
|
||||||
|
}}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
/>
|
||||||
|
{xAxis(chartData)}
|
||||||
|
<ChartTooltip
|
||||||
|
animationEasing="ease-out"
|
||||||
|
animationDuration={150}
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
|
contentFormatter={(item) => decimalString(item.value) + unit}
|
||||||
|
// indicator="line"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{dataKeys.map((key, i) => {
|
||||||
|
const color = `hsl(var(--chart-${key[2]}))`
|
||||||
|
return (
|
||||||
|
<Area
|
||||||
|
key={i}
|
||||||
|
dataKey={getNestedValue.bind(null, key[1], showMax)}
|
||||||
|
name={key[0]}
|
||||||
|
type="monotoneX"
|
||||||
|
fill={color}
|
||||||
|
fillOpacity={key[3]}
|
||||||
|
stroke={color}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{/* <ChartLegend content={<ChartLegendContent />} /> */}
|
||||||
|
</AreaChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
|
||||||
|
|
||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
|
|
||||||
import {
|
|
||||||
chartTimeData,
|
|
||||||
cn,
|
|
||||||
formatShortDate,
|
|
||||||
toFixedWithoutTrailingZeros,
|
|
||||||
useYaxisWidth,
|
|
||||||
} from '@/lib/utils'
|
|
||||||
// import Spinner from '../spinner'
|
|
||||||
import { useStore } from '@nanostores/react'
|
|
||||||
import { $chartTime } from '@/lib/stores'
|
|
||||||
import { SystemStatsRecord } from '@/types'
|
|
||||||
import { useMemo, useRef } from 'react'
|
|
||||||
|
|
||||||
export default function BandwidthChart({
|
|
||||||
ticks,
|
|
||||||
systemData,
|
|
||||||
}: {
|
|
||||||
ticks: number[]
|
|
||||||
systemData: SystemStatsRecord[]
|
|
||||||
}) {
|
|
||||||
const chartRef = useRef<HTMLDivElement>(null)
|
|
||||||
const yAxisWidth = useYaxisWidth(chartRef)
|
|
||||||
const chartTime = useStore($chartTime)
|
|
||||||
|
|
||||||
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={chartRef}>
|
|
||||||
{/* {!yAxisSet && <Spinner />} */}
|
|
||||||
<ChartContainer
|
|
||||||
config={{}}
|
|
||||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
|
||||||
'opacity-100': yAxisSet,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<AreaChart
|
|
||||||
accessibilityLayer
|
|
||||||
data={systemData}
|
|
||||||
margin={{
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
top: 10,
|
|
||||||
bottom: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CartesianGrid vertical={false} />
|
|
||||||
<YAxis
|
|
||||||
className="tracking-tighter"
|
|
||||||
width={yAxisWidth}
|
|
||||||
// domain={[0, (max: number) => (max <= 0.4 ? 0.4 : Math.ceil(max))]}
|
|
||||||
tickFormatter={(value) => toFixedWithoutTrailingZeros(value, 2)}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
unit={' MB/s'}
|
|
||||||
/>
|
|
||||||
<XAxis
|
|
||||||
dataKey="created"
|
|
||||||
domain={[ticks[0], ticks.at(-1)!]}
|
|
||||||
ticks={ticks}
|
|
||||||
type="number"
|
|
||||||
scale={'time'}
|
|
||||||
minTickGap={35}
|
|
||||||
tickMargin={8}
|
|
||||||
axisLine={false}
|
|
||||||
tickFormatter={chartTimeData[chartTime].format}
|
|
||||||
/>
|
|
||||||
<ChartTooltip
|
|
||||||
animationEasing="ease-out"
|
|
||||||
animationDuration={150}
|
|
||||||
content={
|
|
||||||
<ChartTooltipContent
|
|
||||||
unit=" MB/s"
|
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
|
||||||
indicator="line"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
dataKey="stats.ns"
|
|
||||||
name="Sent"
|
|
||||||
type="monotoneX"
|
|
||||||
fill="hsl(var(--chart-5))"
|
|
||||||
fillOpacity={0.2}
|
|
||||||
stroke="hsl(var(--chart-5))"
|
|
||||||
// animationDuration={1200}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
dataKey="stats.nr"
|
|
||||||
name="Received"
|
|
||||||
type="monotoneX"
|
|
||||||
fill="hsl(var(--chart-2))"
|
|
||||||
fillOpacity={0.2}
|
|
||||||
stroke="hsl(var(--chart-2))"
|
|
||||||
// animationDuration={1200}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
</AreaChart>
|
|
||||||
</ChartContainer>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,33 +1,23 @@
|
|||||||
import {
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
Select,
|
import { $chartTime } from "@/lib/stores"
|
||||||
SelectContent,
|
import { chartTimeData, cn } from "@/lib/utils"
|
||||||
SelectItem,
|
import { ChartTimes } from "@/types"
|
||||||
SelectTrigger,
|
import { useStore } from "@nanostores/react"
|
||||||
SelectValue,
|
import { HistoryIcon } from "lucide-react"
|
||||||
} from '@/components/ui/select'
|
|
||||||
import { $chartTime } from '@/lib/stores'
|
|
||||||
import { chartTimeData, cn } from '@/lib/utils'
|
|
||||||
import { ChartTimes } from '@/types'
|
|
||||||
import { useStore } from '@nanostores/react'
|
|
||||||
import { HistoryIcon } from 'lucide-react'
|
|
||||||
|
|
||||||
export default function ChartTimeSelect({ className }: { className?: string }) {
|
export default function ChartTimeSelect({ className }: { className?: string }) {
|
||||||
const chartTime = useStore($chartTime)
|
const chartTime = useStore($chartTime)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select defaultValue="1h" value={chartTime} onValueChange={(value: ChartTimes) => $chartTime.set(value)}>
|
||||||
defaultValue="1h"
|
<SelectTrigger className={cn(className, "relative ps-10 pe-5")}>
|
||||||
value={chartTime}
|
<HistoryIcon className="h-4 w-4 absolute start-4 top-1/2 -translate-y-1/2 opacity-85" />
|
||||||
onValueChange={(value: ChartTimes) => $chartTime.set(value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className={cn(className, 'relative pl-10 pr-5')}>
|
|
||||||
<HistoryIcon className="h-4 w-4 absolute left-4 top-1/2 -translate-y-1/2 opacity-80" />
|
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{Object.entries(chartTimeData).map(([value, { label }]) => (
|
{Object.entries(chartTimeData).map(([value, { label }]) => (
|
||||||
<SelectItem key={label} value={value}>
|
<SelectItem key={value} value={value}>
|
||||||
{label}
|
{label()}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
189
beszel/site/src/components/charts/container-chart.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
||||||
|
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
||||||
|
import { memo, useMemo } from "react"
|
||||||
|
import {
|
||||||
|
useYAxisWidth,
|
||||||
|
cn,
|
||||||
|
formatShortDate,
|
||||||
|
decimalString,
|
||||||
|
chartMargin,
|
||||||
|
toFixedFloat,
|
||||||
|
getSizeAndUnit,
|
||||||
|
toFixedWithoutTrailingZeros,
|
||||||
|
} from "@/lib/utils"
|
||||||
|
// import Spinner from '../spinner'
|
||||||
|
import { useStore } from "@nanostores/react"
|
||||||
|
import { $containerFilter } from "@/lib/stores"
|
||||||
|
import { ChartData } from "@/types"
|
||||||
|
import { Separator } from "../ui/separator"
|
||||||
|
|
||||||
|
export default memo(function ContainerChart({
|
||||||
|
dataKey,
|
||||||
|
chartData,
|
||||||
|
chartName,
|
||||||
|
unit = "%",
|
||||||
|
}: {
|
||||||
|
dataKey: string
|
||||||
|
chartData: ChartData
|
||||||
|
chartName: string
|
||||||
|
unit?: string
|
||||||
|
}) {
|
||||||
|
const filter = useStore($containerFilter)
|
||||||
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
|
|
||||||
|
const { containerData } = chartData
|
||||||
|
|
||||||
|
const isNetChart = chartName === "net"
|
||||||
|
|
||||||
|
const chartConfig = useMemo(() => {
|
||||||
|
let config = {} as Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
label: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
>
|
||||||
|
const totalUsage = {} as Record<string, number>
|
||||||
|
for (let stats of containerData) {
|
||||||
|
for (let key in stats) {
|
||||||
|
if (!key || key === "created") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!(key in totalUsage)) {
|
||||||
|
totalUsage[key] = 0
|
||||||
|
}
|
||||||
|
if (isNetChart) {
|
||||||
|
totalUsage[key] += (stats[key]?.nr ?? 0) + (stats[key]?.ns ?? 0)
|
||||||
|
} else {
|
||||||
|
// @ts-ignore
|
||||||
|
totalUsage[key] += stats[key]?.[dataKey] ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let keys = Object.keys(totalUsage)
|
||||||
|
keys.sort((a, b) => (totalUsage[a] > totalUsage[b] ? -1 : 1))
|
||||||
|
const length = keys.length
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
const key = keys[i]
|
||||||
|
const hue = ((i * 360) / length) % 360
|
||||||
|
config[key] = {
|
||||||
|
label: key,
|
||||||
|
color: `hsl(${hue}, 60%, 55%)`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config satisfies ChartConfig
|
||||||
|
}, [chartData])
|
||||||
|
|
||||||
|
const { toolTipFormatter, dataFunction, tickFormatter } = useMemo(() => {
|
||||||
|
const obj = {} as {
|
||||||
|
toolTipFormatter: (item: any, key: string) => React.ReactNode | string
|
||||||
|
dataFunction: (key: string, data: any) => number | null
|
||||||
|
tickFormatter: (value: any) => string
|
||||||
|
}
|
||||||
|
// tick formatter
|
||||||
|
if (chartName === "cpu") {
|
||||||
|
obj.tickFormatter = (value) => {
|
||||||
|
const val = toFixedWithoutTrailingZeros(value, 2) + unit
|
||||||
|
return updateYAxisWidth(val)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
obj.tickFormatter = (value) => {
|
||||||
|
const { v, u } = getSizeAndUnit(value, false)
|
||||||
|
return updateYAxisWidth(`${toFixedFloat(v, 2)}${u}${isNetChart ? "/s" : ""}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// tooltip formatter
|
||||||
|
if (isNetChart) {
|
||||||
|
obj.toolTipFormatter = (item: any, key: string) => {
|
||||||
|
try {
|
||||||
|
const sent = item?.payload?.[key]?.ns ?? 0
|
||||||
|
const received = item?.payload?.[key]?.nr ?? 0
|
||||||
|
return (
|
||||||
|
<span className="flex">
|
||||||
|
{decimalString(received)} MB/s
|
||||||
|
<span className="opacity-70 ms-0.5"> rx </span>
|
||||||
|
<Separator orientation="vertical" className="h-3 mx-1.5 bg-primary/40" />
|
||||||
|
{decimalString(sent)} MB/s
|
||||||
|
<span className="opacity-70 ms-0.5"> tx</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
obj.toolTipFormatter = (item: any) => decimalString(item.value) + unit
|
||||||
|
}
|
||||||
|
// data function
|
||||||
|
if (isNetChart) {
|
||||||
|
obj.dataFunction = (key: string, data: any) => (data[key] ? data[key].nr + data[key].ns : null)
|
||||||
|
} else {
|
||||||
|
obj.dataFunction = (key: string, data: any) => data[key]?.[dataKey] ?? null
|
||||||
|
}
|
||||||
|
return obj
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// console.log('rendered at', new Date())
|
||||||
|
|
||||||
|
if (containerData.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ChartContainer
|
||||||
|
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
|
||||||
|
"opacity-100": yAxisWidth,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<AreaChart
|
||||||
|
accessibilityLayer
|
||||||
|
// syncId={'cpu'}
|
||||||
|
data={containerData}
|
||||||
|
margin={chartMargin}
|
||||||
|
reverseStackOrder={true}
|
||||||
|
>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<YAxis
|
||||||
|
direction="ltr"
|
||||||
|
orientation={chartData.orientation}
|
||||||
|
className="tracking-tighter"
|
||||||
|
width={yAxisWidth}
|
||||||
|
tickFormatter={tickFormatter}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
/>
|
||||||
|
{xAxis(chartData)}
|
||||||
|
<ChartTooltip
|
||||||
|
animationEasing="ease-out"
|
||||||
|
animationDuration={150}
|
||||||
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
|
// @ts-ignore
|
||||||
|
itemSorter={(a, b) => b.value - a.value}
|
||||||
|
content={<ChartTooltipContent filter={filter} contentFormatter={toolTipFormatter} />}
|
||||||
|
/>
|
||||||
|
{Object.keys(chartConfig).map((key) => {
|
||||||
|
const filtered = filter && !key.includes(filter)
|
||||||
|
let fillOpacity = filtered ? 0.05 : 0.4
|
||||||
|
let strokeOpacity = filtered ? 0.1 : 1
|
||||||
|
return (
|
||||||
|
<Area
|
||||||
|
key={key}
|
||||||
|
isAnimationActive={false}
|
||||||
|
dataKey={dataFunction.bind(null, key)}
|
||||||
|
name={key}
|
||||||
|
type="monotoneX"
|
||||||
|
fill={chartConfig[key].color}
|
||||||
|
fillOpacity={fillOpacity}
|
||||||
|
stroke={chartConfig[key].color}
|
||||||
|
strokeOpacity={strokeOpacity}
|
||||||
|
activeDot={{ opacity: filtered ? 0 : 1 }}
|
||||||
|
stackId="a"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</AreaChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
|
||||||
import {
|
|
||||||
ChartConfig,
|
|
||||||
ChartContainer,
|
|
||||||
ChartTooltip,
|
|
||||||
ChartTooltipContent,
|
|
||||||
} from '@/components/ui/chart'
|
|
||||||
import { useMemo, useRef } from 'react'
|
|
||||||
import { chartTimeData, cn, formatShortDate, useYaxisWidth } from '@/lib/utils'
|
|
||||||
// import Spinner from '../spinner'
|
|
||||||
import { useStore } from '@nanostores/react'
|
|
||||||
import { $chartTime } from '@/lib/stores'
|
|
||||||
|
|
||||||
export default function ContainerCpuChart({
|
|
||||||
chartData,
|
|
||||||
ticks,
|
|
||||||
}: {
|
|
||||||
chartData: Record<string, number | string>[]
|
|
||||||
ticks: number[]
|
|
||||||
}) {
|
|
||||||
const chartRef = useRef<HTMLDivElement>(null)
|
|
||||||
const yAxisWidth = useYaxisWidth(chartRef)
|
|
||||||
const chartTime = useStore($chartTime)
|
|
||||||
|
|
||||||
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
|
||||||
|
|
||||||
const chartConfig = useMemo(() => {
|
|
||||||
let config = {} as Record<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
label: string
|
|
||||||
color: string
|
|
||||||
}
|
|
||||||
>
|
|
||||||
const totalUsage = {} as Record<string, number>
|
|
||||||
for (let stats of chartData) {
|
|
||||||
for (let key in stats) {
|
|
||||||
if (key === 'time') {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (!(key in totalUsage)) {
|
|
||||||
totalUsage[key] = 0
|
|
||||||
}
|
|
||||||
// @ts-ignore
|
|
||||||
totalUsage[key] += stats[key]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let keys = Object.keys(totalUsage)
|
|
||||||
keys.sort((a, b) => (totalUsage[a] > totalUsage[b] ? -1 : 1))
|
|
||||||
const length = keys.length
|
|
||||||
for (let i = 0; i < length; i++) {
|
|
||||||
const key = keys[i]
|
|
||||||
const hue = ((i * 360) / length) % 360
|
|
||||||
config[key] = {
|
|
||||||
label: key,
|
|
||||||
color: `hsl(${hue}, 60%, 55%)`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return config satisfies ChartConfig
|
|
||||||
}, [chartData])
|
|
||||||
|
|
||||||
// if (!chartData.length || !ticks.length) {
|
|
||||||
// return <Spinner />
|
|
||||||
// }
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={chartRef}>
|
|
||||||
{/* {!yAxisSet && <Spinner />} */}
|
|
||||||
<ChartContainer
|
|
||||||
config={{}}
|
|
||||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
|
||||||
'opacity-100': yAxisSet,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<AreaChart
|
|
||||||
accessibilityLayer
|
|
||||||
// syncId={'cpu'}
|
|
||||||
data={chartData}
|
|
||||||
margin={{
|
|
||||||
top: 10,
|
|
||||||
}}
|
|
||||||
reverseStackOrder={true}
|
|
||||||
>
|
|
||||||
<CartesianGrid vertical={false} />
|
|
||||||
<YAxis
|
|
||||||
className="tracking-tighter"
|
|
||||||
// domain={[0, (max: number) => Math.max(Math.ceil(max), 0.4)]}
|
|
||||||
width={yAxisWidth}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
unit={'%'}
|
|
||||||
tickFormatter={(x) => (x % 1 === 0 ? x : x.toFixed(1))}
|
|
||||||
/>
|
|
||||||
<XAxis
|
|
||||||
dataKey="time"
|
|
||||||
domain={[ticks[0], ticks.at(-1)!]}
|
|
||||||
ticks={ticks}
|
|
||||||
type="number"
|
|
||||||
scale={'time'}
|
|
||||||
minTickGap={35}
|
|
||||||
tickMargin={8}
|
|
||||||
axisLine={false}
|
|
||||||
tickFormatter={chartTimeData[chartTime].format}
|
|
||||||
/>
|
|
||||||
<ChartTooltip
|
|
||||||
// cursor={false}
|
|
||||||
animationEasing="ease-out"
|
|
||||||
animationDuration={150}
|
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.time)}
|
|
||||||
// @ts-ignore
|
|
||||||
itemSorter={(a, b) => b.value - a.value}
|
|
||||||
content={<ChartTooltipContent unit="%" indicator="line" />}
|
|
||||||
/>
|
|
||||||
{Object.keys(chartConfig).map((key) => (
|
|
||||||
<Area
|
|
||||||
key={key}
|
|
||||||
// isAnimationActive={chartData.length < 20}
|
|
||||||
isAnimationActive={false}
|
|
||||||
// animateNewValues={false}
|
|
||||||
// animationDuration={1200}
|
|
||||||
dataKey={key}
|
|
||||||
type="monotoneX"
|
|
||||||
fill={chartConfig[key].color}
|
|
||||||
fillOpacity={0.4}
|
|
||||||
stroke={chartConfig[key].color}
|
|
||||||
stackId="a"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</AreaChart>
|
|
||||||
</ChartContainer>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
|
||||||
import {
|
|
||||||
ChartConfig,
|
|
||||||
ChartContainer,
|
|
||||||
ChartTooltip,
|
|
||||||
ChartTooltipContent,
|
|
||||||
} from '@/components/ui/chart'
|
|
||||||
import { useMemo, useRef } from 'react'
|
|
||||||
import {
|
|
||||||
chartTimeData,
|
|
||||||
cn,
|
|
||||||
formatShortDate,
|
|
||||||
toFixedWithoutTrailingZeros,
|
|
||||||
useYaxisWidth,
|
|
||||||
} from '@/lib/utils'
|
|
||||||
// import Spinner from '../spinner'
|
|
||||||
import { useStore } from '@nanostores/react'
|
|
||||||
import { $chartTime } from '@/lib/stores'
|
|
||||||
|
|
||||||
export default function ContainerMemChart({
|
|
||||||
chartData,
|
|
||||||
ticks,
|
|
||||||
}: {
|
|
||||||
chartData: Record<string, number | string>[]
|
|
||||||
ticks: number[]
|
|
||||||
}) {
|
|
||||||
const chartTime = useStore($chartTime)
|
|
||||||
const chartRef = useRef<HTMLDivElement>(null)
|
|
||||||
const yAxisWidth = useYaxisWidth(chartRef)
|
|
||||||
|
|
||||||
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
|
||||||
|
|
||||||
const chartConfig = useMemo(() => {
|
|
||||||
let config = {} as Record<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
label: string
|
|
||||||
color: string
|
|
||||||
}
|
|
||||||
>
|
|
||||||
const totalUsage = {} as Record<string, number>
|
|
||||||
for (let stats of chartData) {
|
|
||||||
for (let key in stats) {
|
|
||||||
if (key === 'time') {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (!(key in totalUsage)) {
|
|
||||||
totalUsage[key] = 0
|
|
||||||
}
|
|
||||||
// @ts-ignore
|
|
||||||
totalUsage[key] += stats[key]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let keys = Object.keys(totalUsage)
|
|
||||||
keys.sort((a, b) => (totalUsage[a] > totalUsage[b] ? -1 : 1))
|
|
||||||
const length = keys.length
|
|
||||||
for (let i = 0; i < length; i++) {
|
|
||||||
const key = keys[i]
|
|
||||||
const hue = ((i * 360) / length) % 360
|
|
||||||
config[key] = {
|
|
||||||
label: key,
|
|
||||||
color: `hsl(${hue}, 60%, 55%)`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return config satisfies ChartConfig
|
|
||||||
}, [chartData])
|
|
||||||
|
|
||||||
// if (!chartData.length || !ticks.length) {
|
|
||||||
// return <Spinner />
|
|
||||||
// }
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={chartRef}>
|
|
||||||
{/* {!yAxisSet && <Spinner />} */}
|
|
||||||
<ChartContainer
|
|
||||||
config={{}}
|
|
||||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
|
||||||
'opacity-100': yAxisSet,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<AreaChart
|
|
||||||
accessibilityLayer
|
|
||||||
data={chartData}
|
|
||||||
reverseStackOrder={true}
|
|
||||||
margin={{
|
|
||||||
top: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CartesianGrid vertical={false} />
|
|
||||||
<YAxis
|
|
||||||
className="tracking-tighter"
|
|
||||||
// domain={[0, (max: number) => Math.ceil(max)]}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
unit={' GB'}
|
|
||||||
width={yAxisWidth}
|
|
||||||
tickFormatter={(value) => toFixedWithoutTrailingZeros(value / 1024, 2)}
|
|
||||||
/>
|
|
||||||
<XAxis
|
|
||||||
dataKey="time"
|
|
||||||
domain={[ticks[0], ticks.at(-1)!]}
|
|
||||||
ticks={ticks}
|
|
||||||
type="number"
|
|
||||||
scale={'time'}
|
|
||||||
minTickGap={35}
|
|
||||||
tickMargin={8}
|
|
||||||
axisLine={false}
|
|
||||||
tickFormatter={chartTimeData[chartTime].format}
|
|
||||||
/>
|
|
||||||
<ChartTooltip
|
|
||||||
// cursor={false}
|
|
||||||
animationEasing="ease-out"
|
|
||||||
animationDuration={150}
|
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.time)}
|
|
||||||
// @ts-ignore
|
|
||||||
itemSorter={(a, b) => b.value - a.value}
|
|
||||||
content={<ChartTooltipContent unit=" MB" indicator="line" />}
|
|
||||||
/>
|
|
||||||
{Object.keys(chartConfig).map((key) => (
|
|
||||||
<Area
|
|
||||||
key={key}
|
|
||||||
// animationDuration={1200}
|
|
||||||
isAnimationActive={false}
|
|
||||||
dataKey={key}
|
|
||||||
type="monotoneX"
|
|
||||||
fill={chartConfig[key].color}
|
|
||||||
fillOpacity={0.4}
|
|
||||||
stroke={chartConfig[key].color}
|
|
||||||
stackId="a"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</AreaChart>
|
|
||||||
</ChartContainer>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
|
||||||
import {
|
|
||||||
ChartConfig,
|
|
||||||
ChartContainer,
|
|
||||||
ChartTooltip,
|
|
||||||
ChartTooltipContent,
|
|
||||||
} from '@/components/ui/chart'
|
|
||||||
import { useMemo, useRef } from 'react'
|
|
||||||
import {
|
|
||||||
chartTimeData,
|
|
||||||
cn,
|
|
||||||
formatShortDate,
|
|
||||||
toFixedWithoutTrailingZeros,
|
|
||||||
useYaxisWidth,
|
|
||||||
} from '@/lib/utils'
|
|
||||||
// import Spinner from '../spinner'
|
|
||||||
import { useStore } from '@nanostores/react'
|
|
||||||
import { $chartTime } from '@/lib/stores'
|
|
||||||
import { Separator } from '@/components/ui/separator'
|
|
||||||
|
|
||||||
export default function ContainerCpuChart({
|
|
||||||
chartData,
|
|
||||||
ticks,
|
|
||||||
}: {
|
|
||||||
chartData: Record<string, number | number[]>[]
|
|
||||||
ticks: number[]
|
|
||||||
}) {
|
|
||||||
const chartTime = useStore($chartTime)
|
|
||||||
const chartRef = useRef<HTMLDivElement>(null)
|
|
||||||
const yAxisWidth = useYaxisWidth(chartRef)
|
|
||||||
|
|
||||||
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
|
||||||
|
|
||||||
const chartConfig = useMemo(() => {
|
|
||||||
let config = {} as Record<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
label: string
|
|
||||||
color: string
|
|
||||||
}
|
|
||||||
>
|
|
||||||
const totalUsage = {} as Record<string, number>
|
|
||||||
for (let stats of chartData) {
|
|
||||||
for (let key in stats) {
|
|
||||||
if (!Array.isArray(stats[key])) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (!(key in totalUsage)) {
|
|
||||||
totalUsage[key] = 0
|
|
||||||
}
|
|
||||||
totalUsage[key] += stats[key][2] ?? 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let keys = Object.keys(totalUsage)
|
|
||||||
keys.sort((a, b) => (totalUsage[a] > totalUsage[b] ? -1 : 1))
|
|
||||||
const length = keys.length
|
|
||||||
for (let i = 0; i < length; i++) {
|
|
||||||
const key = keys[i]
|
|
||||||
const hue = ((i * 360) / length) % 360
|
|
||||||
config[key] = {
|
|
||||||
label: key,
|
|
||||||
color: `hsl(${hue}, 60%, 55%)`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return config satisfies ChartConfig
|
|
||||||
}, [chartData])
|
|
||||||
|
|
||||||
// if (!chartData.length || !ticks.length) {
|
|
||||||
// return <Spinner />
|
|
||||||
// }
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={chartRef}>
|
|
||||||
{/* {!yAxisSet && <Spinner />} */}
|
|
||||||
<ChartContainer
|
|
||||||
config={{}}
|
|
||||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
|
||||||
'opacity-100': yAxisSet,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<AreaChart
|
|
||||||
accessibilityLayer
|
|
||||||
data={chartData}
|
|
||||||
margin={{
|
|
||||||
top: 10,
|
|
||||||
}}
|
|
||||||
reverseStackOrder={true}
|
|
||||||
>
|
|
||||||
<CartesianGrid vertical={false} />
|
|
||||||
<YAxis
|
|
||||||
className="tracking-tighter"
|
|
||||||
// domain={[0, (max: number) => Math.max(Math.ceil(max), 0.4)]}
|
|
||||||
width={yAxisWidth}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
unit={' MB/s'}
|
|
||||||
tickFormatter={(value) => toFixedWithoutTrailingZeros(value, 2)}
|
|
||||||
/>
|
|
||||||
<XAxis
|
|
||||||
dataKey="time"
|
|
||||||
domain={[ticks[0], ticks.at(-1)!]}
|
|
||||||
ticks={ticks}
|
|
||||||
type="number"
|
|
||||||
scale={'time'}
|
|
||||||
minTickGap={35}
|
|
||||||
tickMargin={8}
|
|
||||||
axisLine={false}
|
|
||||||
tickFormatter={chartTimeData[chartTime].format}
|
|
||||||
/>
|
|
||||||
<ChartTooltip
|
|
||||||
// cursor={false}
|
|
||||||
animationEasing="ease-out"
|
|
||||||
animationDuration={150}
|
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.time)}
|
|
||||||
// @ts-ignore
|
|
||||||
itemSorter={(a, b) => b.value - a.value}
|
|
||||||
content={
|
|
||||||
<ChartTooltipContent
|
|
||||||
indicator="line"
|
|
||||||
contentFormatter={(item, key) => {
|
|
||||||
try {
|
|
||||||
const sent = item?.payload?.[key][0] ?? 0
|
|
||||||
const received = item?.payload?.[key][1] ?? 0
|
|
||||||
return (
|
|
||||||
<span className="flex">
|
|
||||||
{received.toLocaleString()} MB/s
|
|
||||||
<span className="opacity-70 ml-0.5"> rx </span>
|
|
||||||
<Separator orientation="vertical" className="h-3 mx-1.5 bg-primary/40" />
|
|
||||||
{sent.toLocaleString()} MB/s<span className="opacity-70 ml-0.5"> tx</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
} catch (e) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{Object.keys(chartConfig).map((key) => (
|
|
||||||
<Area
|
|
||||||
key={key}
|
|
||||||
name={key}
|
|
||||||
// animationDuration={1200}
|
|
||||||
isAnimationActive={false}
|
|
||||||
dataKey={(data) => data?.[key]?.[2] ?? 0}
|
|
||||||
type="monotoneX"
|
|
||||||
fill={chartConfig[key].color}
|
|
||||||
fillOpacity={0.4}
|
|
||||||
stroke={chartConfig[key].color}
|
|
||||||
stackId="a"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</AreaChart>
|
|
||||||
</ChartContainer>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
|
||||||
|
|
||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
|
|
||||||
import { chartTimeData, cn, formatShortDate, useYaxisWidth } from '@/lib/utils'
|
|
||||||
// import Spinner from '../spinner'
|
|
||||||
import { useStore } from '@nanostores/react'
|
|
||||||
import { $chartTime } from '@/lib/stores'
|
|
||||||
import { SystemStatsRecord } from '@/types'
|
|
||||||
import { useMemo, useRef } from 'react'
|
|
||||||
|
|
||||||
export default function CpuChart({
|
|
||||||
ticks,
|
|
||||||
systemData,
|
|
||||||
}: {
|
|
||||||
ticks: number[]
|
|
||||||
systemData: SystemStatsRecord[]
|
|
||||||
}) {
|
|
||||||
const chartTime = useStore($chartTime)
|
|
||||||
const chartRef = useRef<HTMLDivElement>(null)
|
|
||||||
const yAxisWidth = useYaxisWidth(chartRef)
|
|
||||||
|
|
||||||
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={chartRef}>
|
|
||||||
<ChartContainer
|
|
||||||
config={{}}
|
|
||||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
|
||||||
'opacity-100': yAxisSet,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<AreaChart
|
|
||||||
accessibilityLayer
|
|
||||||
data={systemData}
|
|
||||||
margin={{ top: 10 }}
|
|
||||||
// syncId={'cpu'}
|
|
||||||
>
|
|
||||||
<CartesianGrid vertical={false} />
|
|
||||||
<YAxis
|
|
||||||
className="tracking-tighter"
|
|
||||||
// domain={[0, (max: number) => Math.ceil(max)]}
|
|
||||||
width={yAxisWidth}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
unit={'%'}
|
|
||||||
/>
|
|
||||||
<XAxis
|
|
||||||
dataKey="created"
|
|
||||||
domain={[ticks[0], ticks.at(-1)!]}
|
|
||||||
ticks={ticks}
|
|
||||||
type="number"
|
|
||||||
scale={'time'}
|
|
||||||
minTickGap={35}
|
|
||||||
tickMargin={8}
|
|
||||||
axisLine={false}
|
|
||||||
tickFormatter={chartTimeData[chartTime].format}
|
|
||||||
/>
|
|
||||||
<ChartTooltip
|
|
||||||
animationEasing="ease-out"
|
|
||||||
animationDuration={150}
|
|
||||||
content={
|
|
||||||
<ChartTooltipContent
|
|
||||||
unit="%"
|
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
|
||||||
indicator="line"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
dataKey="stats.cpu"
|
|
||||||
name="CPU Usage"
|
|
||||||
type="monotoneX"
|
|
||||||
fill="hsl(var(--chart-1))"
|
|
||||||
fillOpacity={0.4}
|
|
||||||
stroke="hsl(var(--chart-1))"
|
|
||||||
isAnimationActive={false}
|
|
||||||
// animationEasing="ease-out"
|
|
||||||
// animationDuration={1200}
|
|
||||||
// animateNewValues={true}
|
|
||||||
/>
|
|
||||||
</AreaChart>
|
|
||||||
</ChartContainer>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,97 +1,82 @@
|
|||||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
||||||
|
|
||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
|
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
||||||
import { chartTimeData, cn, formatShortDate, useYaxisWidth } from '@/lib/utils'
|
import {
|
||||||
import { useMemo, useRef } from 'react'
|
useYAxisWidth,
|
||||||
// import Spinner from '../spinner'
|
cn,
|
||||||
import { useStore } from '@nanostores/react'
|
formatShortDate,
|
||||||
import { $chartTime } from '@/lib/stores'
|
decimalString,
|
||||||
import { SystemStatsRecord } from '@/types'
|
toFixedFloat,
|
||||||
|
chartMargin,
|
||||||
|
getSizeAndUnit,
|
||||||
|
} from "@/lib/utils"
|
||||||
|
import { ChartData } from "@/types"
|
||||||
|
import { memo } from "react"
|
||||||
|
import { t } from "@lingui/macro"
|
||||||
|
import { useLingui } from "@lingui/react"
|
||||||
|
|
||||||
export default function DiskChart({
|
export default memo(function DiskChart({
|
||||||
ticks,
|
dataKey,
|
||||||
systemData,
|
diskSize,
|
||||||
|
chartData,
|
||||||
}: {
|
}: {
|
||||||
ticks: number[]
|
dataKey: string
|
||||||
systemData: SystemStatsRecord[]
|
diskSize: number
|
||||||
|
chartData: ChartData
|
||||||
}) {
|
}) {
|
||||||
const chartTime = useStore($chartTime)
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
const chartRef = useRef<HTMLDivElement>(null)
|
const { _ } = useLingui()
|
||||||
const yAxisWidth = useYaxisWidth(chartRef)
|
|
||||||
|
|
||||||
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
// round to nearest GB
|
||||||
|
if (diskSize >= 100) {
|
||||||
|
diskSize = Math.round(diskSize)
|
||||||
|
}
|
||||||
|
|
||||||
const diskSize = useMemo(() => {
|
if (chartData.systemStats.length === 0) {
|
||||||
return Math.round(systemData[0]?.stats.d)
|
return null
|
||||||
}, [systemData])
|
}
|
||||||
|
|
||||||
// const ticks = useMemo(() => {
|
|
||||||
// let ticks = [0]
|
|
||||||
// for (let i = 1; i < diskSize; i += diskSize / 5) {
|
|
||||||
// ticks.push(Math.trunc(i))
|
|
||||||
// }
|
|
||||||
// ticks.push(diskSize)
|
|
||||||
// return ticks
|
|
||||||
// }, [diskSize])
|
|
||||||
|
|
||||||
// if (!systemData.length || !ticks.length) {
|
|
||||||
// return <Spinner />
|
|
||||||
// }
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={chartRef}>
|
<div>
|
||||||
{/* {!yAxisSet && <Spinner />} */}
|
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
config={{}}
|
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
|
||||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
"opacity-100": yAxisWidth,
|
||||||
'opacity-100': yAxisSet,
|
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<AreaChart
|
<AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
|
||||||
accessibilityLayer
|
|
||||||
data={systemData}
|
|
||||||
margin={{
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
top: 10,
|
|
||||||
bottom: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CartesianGrid vertical={false} />
|
<CartesianGrid vertical={false} />
|
||||||
<YAxis
|
<YAxis
|
||||||
|
direction="ltr"
|
||||||
|
orientation={chartData.orientation}
|
||||||
className="tracking-tighter"
|
className="tracking-tighter"
|
||||||
width={yAxisWidth}
|
width={yAxisWidth}
|
||||||
domain={[0, diskSize]}
|
domain={[0, diskSize]}
|
||||||
tickCount={9}
|
tickCount={9}
|
||||||
|
minTickGap={6}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
unit={' GB'}
|
tickFormatter={(value) => {
|
||||||
/>
|
const { v, u } = getSizeAndUnit(value)
|
||||||
<XAxis
|
return updateYAxisWidth(toFixedFloat(v, 2) + u)
|
||||||
dataKey="created"
|
}}
|
||||||
domain={[ticks[0], ticks.at(-1)!]}
|
|
||||||
ticks={ticks}
|
|
||||||
type="number"
|
|
||||||
scale={'time'}
|
|
||||||
minTickGap={35}
|
|
||||||
tickMargin={8}
|
|
||||||
axisLine={false}
|
|
||||||
tickFormatter={chartTimeData[chartTime].format}
|
|
||||||
/>
|
/>
|
||||||
|
{xAxis(chartData)}
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
animationEasing="ease-out"
|
animationEasing="ease-out"
|
||||||
animationDuration={150}
|
animationDuration={150}
|
||||||
content={
|
content={
|
||||||
<ChartTooltipContent
|
<ChartTooltipContent
|
||||||
unit=" GB"
|
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
indicator="line"
|
contentFormatter={({ value }) => {
|
||||||
|
const { v, u } = getSizeAndUnit(value)
|
||||||
|
return decimalString(v) + u
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
dataKey="stats.du"
|
dataKey={dataKey}
|
||||||
name="Disk Usage"
|
name={_(t`Disk Usage`)}
|
||||||
type="monotoneX"
|
type="monotoneX"
|
||||||
fill="hsl(var(--chart-4))"
|
fill="hsl(var(--chart-4))"
|
||||||
fillOpacity={0.4}
|
fillOpacity={0.4}
|
||||||
@@ -103,4 +88,4 @@ export default function DiskChart({
|
|||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
|
||||||
|
|
||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
|
|
||||||
import {
|
|
||||||
chartTimeData,
|
|
||||||
cn,
|
|
||||||
formatShortDate,
|
|
||||||
toFixedWithoutTrailingZeros,
|
|
||||||
useYaxisWidth,
|
|
||||||
} from '@/lib/utils'
|
|
||||||
// import Spinner from '../spinner'
|
|
||||||
import { useStore } from '@nanostores/react'
|
|
||||||
import { $chartTime } from '@/lib/stores'
|
|
||||||
import { SystemStatsRecord } from '@/types'
|
|
||||||
import { useMemo, useRef } from 'react'
|
|
||||||
|
|
||||||
export default function DiskIoChart({
|
|
||||||
ticks,
|
|
||||||
systemData,
|
|
||||||
}: {
|
|
||||||
ticks: number[]
|
|
||||||
systemData: SystemStatsRecord[]
|
|
||||||
}) {
|
|
||||||
const chartTime = useStore($chartTime)
|
|
||||||
const chartRef = useRef<HTMLDivElement>(null)
|
|
||||||
const yAxisWidth = useYaxisWidth(chartRef)
|
|
||||||
|
|
||||||
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
|
||||||
|
|
||||||
// if (!systemData.length || !ticks.length) {
|
|
||||||
// return <Spinner />
|
|
||||||
// }
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={chartRef}>
|
|
||||||
{/* {!yAxisSet && <Spinner />} */}
|
|
||||||
<ChartContainer
|
|
||||||
config={{}}
|
|
||||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
|
||||||
'opacity-100': yAxisSet,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<AreaChart
|
|
||||||
accessibilityLayer
|
|
||||||
data={systemData}
|
|
||||||
margin={{
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
top: 10,
|
|
||||||
bottom: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CartesianGrid vertical={false} />
|
|
||||||
<YAxis
|
|
||||||
className="tracking-tighter"
|
|
||||||
width={yAxisWidth}
|
|
||||||
// domain={[0, (max: number) => (max <= 0.4 ? 0.4 : Math.ceil(max))]}
|
|
||||||
tickFormatter={(value) => toFixedWithoutTrailingZeros(value, 2)}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
unit={' MB/s'}
|
|
||||||
/>
|
|
||||||
<XAxis
|
|
||||||
dataKey="created"
|
|
||||||
domain={[ticks[0], ticks.at(-1)!]}
|
|
||||||
ticks={ticks}
|
|
||||||
type="number"
|
|
||||||
scale={'time'}
|
|
||||||
minTickGap={35}
|
|
||||||
tickMargin={8}
|
|
||||||
axisLine={false}
|
|
||||||
tickFormatter={chartTimeData[chartTime].format}
|
|
||||||
/>
|
|
||||||
<ChartTooltip
|
|
||||||
animationEasing="ease-out"
|
|
||||||
animationDuration={150}
|
|
||||||
content={
|
|
||||||
<ChartTooltipContent
|
|
||||||
unit=" MB/s"
|
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
|
||||||
indicator="line"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
dataKey="stats.dw"
|
|
||||||
name="Write"
|
|
||||||
type="monotoneX"
|
|
||||||
fill="hsl(var(--chart-3))"
|
|
||||||
fillOpacity={0.3}
|
|
||||||
stroke="hsl(var(--chart-3))"
|
|
||||||
// animationDuration={1200}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
dataKey="stats.dr"
|
|
||||||
name="Read"
|
|
||||||
type="monotoneX"
|
|
||||||
fill="hsl(var(--chart-1))"
|
|
||||||
fillOpacity={0.3}
|
|
||||||
stroke="hsl(var(--chart-1))"
|
|
||||||
// animationDuration={1200}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
</AreaChart>
|
|
||||||
</ChartContainer>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
112
beszel/site/src/components/charts/gpu-power-chart.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { CartesianGrid, Line, LineChart, YAxis } from "recharts"
|
||||||
|
|
||||||
|
import {
|
||||||
|
ChartContainer,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
xAxis,
|
||||||
|
} from "@/components/ui/chart"
|
||||||
|
import {
|
||||||
|
useYAxisWidth,
|
||||||
|
cn,
|
||||||
|
formatShortDate,
|
||||||
|
toFixedWithoutTrailingZeros,
|
||||||
|
decimalString,
|
||||||
|
chartMargin,
|
||||||
|
} from "@/lib/utils"
|
||||||
|
import { ChartData } from "@/types"
|
||||||
|
import { memo, useMemo } from "react"
|
||||||
|
|
||||||
|
export default memo(function GpuPowerChart({ chartData }: { chartData: ChartData }) {
|
||||||
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
|
|
||||||
|
if (chartData.systemStats.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format temperature data for chart and assign colors */
|
||||||
|
const newChartData = useMemo(() => {
|
||||||
|
const newChartData = { data: [], colors: {} } as {
|
||||||
|
data: Record<string, number | string>[]
|
||||||
|
colors: Record<string, string>
|
||||||
|
}
|
||||||
|
const powerSums = {} as Record<string, number>
|
||||||
|
for (let data of chartData.systemStats) {
|
||||||
|
let newData = { created: data.created } as Record<string, number | string>
|
||||||
|
|
||||||
|
for (let gpu of Object.values(data.stats?.g ?? {})) {
|
||||||
|
if (gpu.p) {
|
||||||
|
const name = gpu.n
|
||||||
|
newData[name] = gpu.p
|
||||||
|
powerSums[name] = (powerSums[name] ?? 0) + newData[name]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newChartData.data.push(newData)
|
||||||
|
}
|
||||||
|
const keys = Object.keys(powerSums).sort((a, b) => powerSums[b] - powerSums[a])
|
||||||
|
for (let key of keys) {
|
||||||
|
newChartData.colors[key] = `hsl(${((keys.indexOf(key) * 360) / keys.length) % 360}, 60%, 55%)`
|
||||||
|
}
|
||||||
|
return newChartData
|
||||||
|
}, [chartData])
|
||||||
|
|
||||||
|
const colors = Object.keys(newChartData.colors)
|
||||||
|
|
||||||
|
// console.log('rendered at', new Date())
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ChartContainer
|
||||||
|
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
|
||||||
|
"opacity-100": yAxisWidth,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<LineChart accessibilityLayer data={newChartData.data} margin={chartMargin}>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<YAxis
|
||||||
|
direction="ltr"
|
||||||
|
orientation={chartData.orientation}
|
||||||
|
className="tracking-tighter"
|
||||||
|
domain={[0, "auto"]}
|
||||||
|
width={yAxisWidth}
|
||||||
|
tickFormatter={(value) => {
|
||||||
|
const val = toFixedWithoutTrailingZeros(value, 2)
|
||||||
|
return updateYAxisWidth(val + "W")
|
||||||
|
}}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
/>
|
||||||
|
{xAxis(chartData)}
|
||||||
|
<ChartTooltip
|
||||||
|
animationEasing="ease-out"
|
||||||
|
animationDuration={150}
|
||||||
|
// @ts-ignore
|
||||||
|
itemSorter={(a, b) => b.value - a.value}
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
|
contentFormatter={(item) => decimalString(item.value) + "W"}
|
||||||
|
// indicator="line"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{colors.map((key) => (
|
||||||
|
<Line
|
||||||
|
key={key}
|
||||||
|
dataKey={key}
|
||||||
|
name={key}
|
||||||
|
type="monotoneX"
|
||||||
|
dot={false}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke={newChartData.colors[key]}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{colors.length > 1 && <ChartLegend content={<ChartLegendContent />} />}
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -1,49 +1,38 @@
|
|||||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
||||||
|
|
||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
|
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
||||||
import { chartTimeData, cn, formatShortDate, toFixedFloat, useYaxisWidth } from '@/lib/utils'
|
import { useYAxisWidth, cn, toFixedFloat, decimalString, formatShortDate, chartMargin } from "@/lib/utils"
|
||||||
import { useMemo, useRef } from 'react'
|
import { memo } from "react"
|
||||||
// import Spinner from '../spinner'
|
import { ChartData } from "@/types"
|
||||||
import { useStore } from '@nanostores/react'
|
import { t } from "@lingui/macro"
|
||||||
import { $chartTime } from '@/lib/stores'
|
import { useLingui } from "@lingui/react"
|
||||||
import { SystemStatsRecord } from '@/types'
|
|
||||||
|
|
||||||
export default function MemChart({
|
export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
|
||||||
ticks,
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
systemData,
|
const { _ } = useLingui()
|
||||||
}: {
|
|
||||||
ticks: number[]
|
|
||||||
systemData: SystemStatsRecord[]
|
|
||||||
}) {
|
|
||||||
const chartRef = useRef<HTMLDivElement>(null)
|
|
||||||
const yAxisWidth = useYaxisWidth(chartRef)
|
|
||||||
const chartTime = useStore($chartTime)
|
|
||||||
|
|
||||||
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
const totalMem = toFixedFloat(chartData.systemStats.at(-1)?.stats.m ?? 0, 1)
|
||||||
|
|
||||||
const totalMem = useMemo(() => {
|
// console.log('rendered at', new Date())
|
||||||
return toFixedFloat(systemData.at(-1)?.stats.m ?? 0, 1)
|
|
||||||
}, [systemData])
|
if (chartData.systemStats.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={chartRef}>
|
<div>
|
||||||
{/* {!yAxisSet && <Spinner />} */}
|
{/* {!yAxisSet && <Spinner />} */}
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
config={{}}
|
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
|
||||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
"opacity-100": yAxisWidth,
|
||||||
'opacity-100': yAxisSet,
|
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<AreaChart
|
<AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
|
||||||
accessibilityLayer
|
|
||||||
data={systemData}
|
|
||||||
margin={{
|
|
||||||
top: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CartesianGrid vertical={false} />
|
<CartesianGrid vertical={false} />
|
||||||
{totalMem && (
|
{totalMem && (
|
||||||
<YAxis
|
<YAxis
|
||||||
|
direction="ltr"
|
||||||
|
orientation={chartData.orientation}
|
||||||
// use "ticks" instead of domain / tickcount if need more control
|
// use "ticks" instead of domain / tickcount if need more control
|
||||||
domain={[0, totalMem]}
|
domain={[0, totalMem]}
|
||||||
tickCount={9}
|
tickCount={9}
|
||||||
@@ -51,37 +40,31 @@ export default function MemChart({
|
|||||||
width={yAxisWidth}
|
width={yAxisWidth}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
unit={' GB'}
|
tickFormatter={(value) => {
|
||||||
|
const val = toFixedFloat(value, 1)
|
||||||
|
return updateYAxisWidth(val + " GB")
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<XAxis
|
{xAxis(chartData)}
|
||||||
dataKey="created"
|
|
||||||
domain={[ticks[0], ticks.at(-1)!]}
|
|
||||||
ticks={ticks}
|
|
||||||
type="number"
|
|
||||||
scale={'time'}
|
|
||||||
minTickGap={35}
|
|
||||||
tickMargin={8}
|
|
||||||
axisLine={false}
|
|
||||||
tickFormatter={chartTimeData[chartTime].format}
|
|
||||||
/>
|
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
// cursor={false}
|
// cursor={false}
|
||||||
animationEasing="ease-out"
|
animationEasing="ease-out"
|
||||||
animationDuration={150}
|
animationDuration={150}
|
||||||
content={
|
content={
|
||||||
<ChartTooltipContent
|
<ChartTooltipContent
|
||||||
unit=" GB"
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
itemSorter={(a, b) => a.name.localeCompare(b.name)}
|
itemSorter={(a, b) => a.order - b.order}
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
indicator="line"
|
contentFormatter={(item) => decimalString(item.value) + " GB"}
|
||||||
|
// indicator="line"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
|
name={_(t`Used`)}
|
||||||
|
order={3}
|
||||||
dataKey="stats.mu"
|
dataKey="stats.mu"
|
||||||
name="Used"
|
|
||||||
type="monotoneX"
|
type="monotoneX"
|
||||||
fill="hsl(var(--chart-2))"
|
fill="hsl(var(--chart-2))"
|
||||||
fillOpacity={0.4}
|
fillOpacity={0.4}
|
||||||
@@ -89,14 +72,28 @@ export default function MemChart({
|
|||||||
stackId="1"
|
stackId="1"
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
|
{chartData.systemStats.at(-1)?.stats.mz && (
|
||||||
|
<Area
|
||||||
|
name="ZFS ARC"
|
||||||
|
order={2}
|
||||||
|
dataKey="stats.mz"
|
||||||
|
type="monotoneX"
|
||||||
|
fill="hsla(175 60% 45% / 0.8)"
|
||||||
|
fillOpacity={0.5}
|
||||||
|
stroke="hsla(175 60% 45% / 0.8)"
|
||||||
|
stackId="1"
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Area
|
<Area
|
||||||
|
name={_(t`Cache / Buffers`)}
|
||||||
|
order={1}
|
||||||
dataKey="stats.mb"
|
dataKey="stats.mb"
|
||||||
name="Cache / Buffers"
|
|
||||||
type="monotoneX"
|
type="monotoneX"
|
||||||
fill="hsl(var(--chart-2))"
|
fill="hsla(160 60% 45% / 0.5)"
|
||||||
fillOpacity={0.2}
|
fillOpacity={0.4}
|
||||||
strokeOpacity={0.3}
|
// strokeOpacity={1}
|
||||||
stroke="hsl(var(--chart-2))"
|
stroke="hsla(160 60% 45% / 0.5)"
|
||||||
stackId="1"
|
stackId="1"
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
@@ -104,4 +101,4 @@ export default function MemChart({
|
|||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -1,75 +1,59 @@
|
|||||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
||||||
|
|
||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
|
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
||||||
import {
|
import {
|
||||||
chartTimeData,
|
useYAxisWidth,
|
||||||
cn,
|
cn,
|
||||||
formatShortDate,
|
formatShortDate,
|
||||||
toFixedWithoutTrailingZeros,
|
toFixedWithoutTrailingZeros,
|
||||||
useYaxisWidth,
|
decimalString,
|
||||||
} from '@/lib/utils'
|
chartMargin,
|
||||||
// import Spinner from '../spinner'
|
} from "@/lib/utils"
|
||||||
import { useStore } from '@nanostores/react'
|
import { ChartData } from "@/types"
|
||||||
import { $chartTime } from '@/lib/stores'
|
import { memo } from "react"
|
||||||
import { SystemStatsRecord } from '@/types'
|
import { t } from "@lingui/macro"
|
||||||
import { useMemo, useRef } from 'react'
|
|
||||||
|
|
||||||
export default function SwapChart({
|
export default memo(function SwapChart({ chartData }: { chartData: ChartData }) {
|
||||||
ticks,
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
systemData,
|
|
||||||
}: {
|
|
||||||
ticks: number[]
|
|
||||||
systemData: SystemStatsRecord[]
|
|
||||||
}) {
|
|
||||||
const chartTime = useStore($chartTime)
|
|
||||||
const chartRef = useRef<HTMLDivElement>(null)
|
|
||||||
const yAxisWidth = useYaxisWidth(chartRef)
|
|
||||||
|
|
||||||
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
if (chartData.systemStats.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={chartRef}>
|
<div>
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
config={{}}
|
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
|
||||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
"opacity-100": yAxisWidth,
|
||||||
'opacity-100': yAxisSet,
|
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<AreaChart accessibilityLayer data={systemData} margin={{ top: 10 }}>
|
<AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
|
||||||
<CartesianGrid vertical={false} />
|
<CartesianGrid vertical={false} />
|
||||||
<YAxis
|
<YAxis
|
||||||
|
direction="ltr"
|
||||||
|
orientation={chartData.orientation}
|
||||||
className="tracking-tighter"
|
className="tracking-tighter"
|
||||||
domain={[0, () => toFixedWithoutTrailingZeros(systemData.at(-1)?.stats.s ?? 0.04, 2)]}
|
domain={[0, () => toFixedWithoutTrailingZeros(chartData.systemStats.at(-1)?.stats.s ?? 0.04, 2)]}
|
||||||
width={yAxisWidth}
|
width={yAxisWidth}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
unit={' GB'}
|
tickFormatter={(value) => updateYAxisWidth(value + " GB")}
|
||||||
/>
|
|
||||||
<XAxis
|
|
||||||
dataKey="created"
|
|
||||||
domain={[ticks[0], ticks.at(-1)!]}
|
|
||||||
ticks={ticks}
|
|
||||||
type="number"
|
|
||||||
scale={'time'}
|
|
||||||
minTickGap={35}
|
|
||||||
tickMargin={8}
|
|
||||||
axisLine={false}
|
|
||||||
tickFormatter={chartTimeData[chartTime].format}
|
|
||||||
/>
|
/>
|
||||||
|
{xAxis(chartData)}
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
animationEasing="ease-out"
|
animationEasing="ease-out"
|
||||||
animationDuration={150}
|
animationDuration={150}
|
||||||
content={
|
content={
|
||||||
<ChartTooltipContent
|
<ChartTooltipContent
|
||||||
unit=" GB"
|
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
indicator="line"
|
contentFormatter={(item) => decimalString(item.value) + " GB"}
|
||||||
|
// indicator="line"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
dataKey="stats.su"
|
dataKey="stats.su"
|
||||||
name="Swap Usage"
|
name={t`Used`}
|
||||||
type="monotoneX"
|
type="monotoneX"
|
||||||
fill="hsl(var(--chart-2))"
|
fill="hsl(var(--chart-2))"
|
||||||
fillOpacity={0.4}
|
fillOpacity={0.4}
|
||||||
@@ -80,4 +64,4 @@ export default function SwapChart({
|
|||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts'
|
import { CartesianGrid, Line, LineChart, YAxis } from "recharts"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ChartContainer,
|
ChartContainer,
|
||||||
@@ -6,38 +6,34 @@ import {
|
|||||||
ChartLegendContent,
|
ChartLegendContent,
|
||||||
ChartTooltip,
|
ChartTooltip,
|
||||||
ChartTooltipContent,
|
ChartTooltipContent,
|
||||||
} from '@/components/ui/chart'
|
xAxis,
|
||||||
|
} from "@/components/ui/chart"
|
||||||
import {
|
import {
|
||||||
chartTimeData,
|
useYAxisWidth,
|
||||||
cn,
|
cn,
|
||||||
formatShortDate,
|
formatShortDate,
|
||||||
toFixedWithoutTrailingZeros,
|
toFixedWithoutTrailingZeros,
|
||||||
useYaxisWidth,
|
decimalString,
|
||||||
} from '@/lib/utils'
|
chartMargin,
|
||||||
import { useStore } from '@nanostores/react'
|
} from "@/lib/utils"
|
||||||
import { $chartTime } from '@/lib/stores'
|
import { ChartData } from "@/types"
|
||||||
import { SystemStatsRecord } from '@/types'
|
import { memo, useMemo } from "react"
|
||||||
import { useMemo, useRef } from 'react'
|
|
||||||
|
|
||||||
export default function TemperatureChart({
|
export default memo(function TemperatureChart({ chartData }: { chartData: ChartData }) {
|
||||||
ticks,
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
systemData,
|
|
||||||
}: {
|
if (chartData.systemStats.length === 0) {
|
||||||
ticks: number[]
|
return null
|
||||||
systemData: SystemStatsRecord[]
|
}
|
||||||
}) {
|
|
||||||
const chartRef = useRef<HTMLDivElement>(null)
|
|
||||||
const yAxisWidth = useYaxisWidth(chartRef)
|
|
||||||
const chartTime = useStore($chartTime)
|
|
||||||
|
|
||||||
/** Format temperature data for chart and assign colors */
|
/** Format temperature data for chart and assign colors */
|
||||||
const newChartData = useMemo(() => {
|
const newChartData = useMemo(() => {
|
||||||
const chartData = { data: [], colors: {} } as {
|
const newChartData = { data: [], colors: {} } as {
|
||||||
data: Record<string, number | string>[]
|
data: Record<string, number | string>[]
|
||||||
colors: Record<string, string>
|
colors: Record<string, string>
|
||||||
}
|
}
|
||||||
const tempSums = {} as Record<string, number>
|
const tempSums = {} as Record<string, number>
|
||||||
for (let data of systemData) {
|
for (let data of chartData.systemStats) {
|
||||||
let newData = { created: data.created } as Record<string, number | string>
|
let newData = { created: data.created } as Record<string, number | string>
|
||||||
let keys = Object.keys(data.stats?.t ?? {})
|
let keys = Object.keys(data.stats?.t ?? {})
|
||||||
for (let i = 0; i < keys.length; i++) {
|
for (let i = 0; i < keys.length; i++) {
|
||||||
@@ -45,56 +41,42 @@ export default function TemperatureChart({
|
|||||||
newData[key] = data.stats.t![key]
|
newData[key] = data.stats.t![key]
|
||||||
tempSums[key] = (tempSums[key] ?? 0) + newData[key]
|
tempSums[key] = (tempSums[key] ?? 0) + newData[key]
|
||||||
}
|
}
|
||||||
chartData.data.push(newData)
|
newChartData.data.push(newData)
|
||||||
}
|
}
|
||||||
const keys = Object.keys(tempSums).sort((a, b) => tempSums[b] - tempSums[a])
|
const keys = Object.keys(tempSums).sort((a, b) => tempSums[b] - tempSums[a])
|
||||||
for (let key of keys) {
|
for (let key of keys) {
|
||||||
chartData.colors[key] = `hsl(${((keys.indexOf(key) * 360) / keys.length) % 360}, 60%, 55%)`
|
newChartData.colors[key] = `hsl(${((keys.indexOf(key) * 360) / keys.length) % 360}, 60%, 55%)`
|
||||||
}
|
}
|
||||||
return chartData
|
return newChartData
|
||||||
}, [systemData])
|
}, [chartData])
|
||||||
|
|
||||||
const yAxisSet = useMemo(() => yAxisWidth !== 180, [yAxisWidth])
|
const colors = Object.keys(newChartData.colors)
|
||||||
|
|
||||||
|
// console.log('rendered at', new Date())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={chartRef}>
|
<div>
|
||||||
{/* {!yAxisSet && <Spinner />} */}
|
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
config={{}}
|
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
|
||||||
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
|
"opacity-100": yAxisWidth,
|
||||||
'opacity-100': yAxisSet,
|
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<LineChart
|
<LineChart accessibilityLayer data={newChartData.data} margin={chartMargin}>
|
||||||
accessibilityLayer
|
|
||||||
data={newChartData.data}
|
|
||||||
margin={{
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
top: 10,
|
|
||||||
bottom: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CartesianGrid vertical={false} />
|
<CartesianGrid vertical={false} />
|
||||||
<YAxis
|
<YAxis
|
||||||
|
direction="ltr"
|
||||||
|
orientation={chartData.orientation}
|
||||||
className="tracking-tighter"
|
className="tracking-tighter"
|
||||||
|
domain={[0, "auto"]}
|
||||||
width={yAxisWidth}
|
width={yAxisWidth}
|
||||||
tickFormatter={(value) => toFixedWithoutTrailingZeros(value, 2)}
|
tickFormatter={(value) => {
|
||||||
|
const val = toFixedWithoutTrailingZeros(value, 2)
|
||||||
|
return updateYAxisWidth(val + " °C")
|
||||||
|
}}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
unit={' °C'}
|
|
||||||
/>
|
|
||||||
<XAxis
|
|
||||||
dataKey="created"
|
|
||||||
domain={[ticks[0], ticks.at(-1)!]}
|
|
||||||
ticks={ticks}
|
|
||||||
type="number"
|
|
||||||
scale={'time'}
|
|
||||||
minTickGap={35}
|
|
||||||
tickMargin={8}
|
|
||||||
axisLine={false}
|
|
||||||
tickFormatter={chartTimeData[chartTime].format}
|
|
||||||
/>
|
/>
|
||||||
|
{xAxis(chartData)}
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
animationEasing="ease-out"
|
animationEasing="ease-out"
|
||||||
animationDuration={150}
|
animationDuration={150}
|
||||||
@@ -102,13 +84,13 @@ export default function TemperatureChart({
|
|||||||
itemSorter={(a, b) => b.value - a.value}
|
itemSorter={(a, b) => b.value - a.value}
|
||||||
content={
|
content={
|
||||||
<ChartTooltipContent
|
<ChartTooltipContent
|
||||||
unit=" °C"
|
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
indicator="line"
|
contentFormatter={(item) => decimalString(item.value) + " °C"}
|
||||||
|
// indicator="line"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{Object.keys(newChartData.colors).map((key) => (
|
{colors.map((key) => (
|
||||||
<Line
|
<Line
|
||||||
key={key}
|
key={key}
|
||||||
dataKey={key}
|
dataKey={key}
|
||||||
@@ -120,9 +102,9 @@ export default function TemperatureChart({
|
|||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<ChartLegend content={<ChartLegendContent />} />
|
{colors.length < 12 && <ChartLegend content={<ChartLegendContent />} />}
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
|
BookIcon,
|
||||||
DatabaseBackupIcon,
|
DatabaseBackupIcon,
|
||||||
Github,
|
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LockKeyholeIcon,
|
|
||||||
LogsIcon,
|
LogsIcon,
|
||||||
MailIcon,
|
MailIcon,
|
||||||
Server,
|
Server,
|
||||||
|
SettingsIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
} from 'lucide-react'
|
} from "lucide-react"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CommandDialog,
|
CommandDialog,
|
||||||
@@ -18,61 +18,39 @@ import {
|
|||||||
CommandList,
|
CommandList,
|
||||||
CommandSeparator,
|
CommandSeparator,
|
||||||
CommandShortcut,
|
CommandShortcut,
|
||||||
} from '@/components/ui/command'
|
} from "@/components/ui/command"
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect } from "react"
|
||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from "@nanostores/react"
|
||||||
import { $systems } from '@/lib/stores'
|
import { $systems } from "@/lib/stores"
|
||||||
import { isAdmin } from '@/lib/utils'
|
import { isAdmin } from "@/lib/utils"
|
||||||
import { navigate } from './router'
|
import { navigate } from "./router"
|
||||||
|
import { Trans, t } from "@lingui/macro"
|
||||||
|
|
||||||
export default function CommandPalette() {
|
export default function CommandPalette({ open, setOpen }: { open: boolean; setOpen: (open: boolean) => void }) {
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const systems = useStore($systems)
|
const systems = useStore($systems)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const down = (e: KeyboardEvent) => {
|
const down = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setOpen((open) => !open)
|
setOpen(!open)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('keydown', down)
|
document.addEventListener("keydown", down)
|
||||||
return () => document.removeEventListener('keydown', down)
|
return () => document.removeEventListener("keydown", down)
|
||||||
}, [])
|
}, [open, setOpen])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommandDialog open={open} onOpenChange={setOpen}>
|
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||||
<CommandInput placeholder="Search for systems or settings..." />
|
<CommandInput placeholder={t`Search for systems or settings...`} />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>No results found.</CommandEmpty>
|
<CommandEmpty>
|
||||||
<CommandGroup heading="Suggestions">
|
<Trans>No results found.</Trans>
|
||||||
<CommandItem
|
</CommandEmpty>
|
||||||
keywords={['home']}
|
|
||||||
onSelect={() => {
|
|
||||||
navigate('/')
|
|
||||||
setOpen((open) => !open)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LayoutDashboard className="mr-2 h-4 w-4" />
|
|
||||||
<span>Dashboard</span>
|
|
||||||
<CommandShortcut>Page</CommandShortcut>
|
|
||||||
</CommandItem>
|
|
||||||
<CommandItem
|
|
||||||
keywords={['github']}
|
|
||||||
onSelect={() => {
|
|
||||||
window.location.href = 'https://github.com/henrygd/beszel/blob/main/readme.md'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Github className="mr-2 h-4 w-4" />
|
|
||||||
<span>Documentation</span>
|
|
||||||
<CommandShortcut>GitHub</CommandShortcut>
|
|
||||||
</CommandItem>
|
|
||||||
</CommandGroup>
|
|
||||||
{systems.length > 0 && (
|
{systems.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<CommandSeparator />
|
<CommandGroup>
|
||||||
<CommandGroup heading="Systems">
|
|
||||||
{systems.map((system) => (
|
{systems.map((system) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={system.id}
|
key={system.id}
|
||||||
@@ -81,70 +59,134 @@ export default function CommandPalette() {
|
|||||||
setOpen(false)
|
setOpen(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Server className="mr-2 h-4 w-4" />
|
<Server className="me-2 h-4 w-4" />
|
||||||
<span>{system.name}</span>
|
<span>{system.name}</span>
|
||||||
<CommandShortcut>{system.host}</CommandShortcut>
|
<CommandShortcut>{system.host}</CommandShortcut>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
<CommandSeparator className="mb-1.5" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<CommandGroup heading={t`Pages / Settings`}>
|
||||||
|
<CommandItem
|
||||||
|
keywords={["home"]}
|
||||||
|
onSelect={() => {
|
||||||
|
navigate("/")
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LayoutDashboard className="me-2 h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
<Trans>Dashboard</Trans>
|
||||||
|
</span>
|
||||||
|
<CommandShortcut>
|
||||||
|
<Trans>Page</Trans>
|
||||||
|
</CommandShortcut>
|
||||||
|
</CommandItem>
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => {
|
||||||
|
navigate("/settings/general")
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SettingsIcon className="me-2 h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
<Trans>Settings</Trans>
|
||||||
|
</span>
|
||||||
|
<CommandShortcut>
|
||||||
|
<Trans>Settings</Trans>
|
||||||
|
</CommandShortcut>
|
||||||
|
</CommandItem>
|
||||||
|
<CommandItem
|
||||||
|
keywords={["alerts"]}
|
||||||
|
onSelect={() => {
|
||||||
|
navigate("/settings/notifications")
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MailIcon className="me-2 h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
<Trans>Notifications</Trans>
|
||||||
|
</span>
|
||||||
|
<CommandShortcut>
|
||||||
|
<Trans>Settings</Trans>
|
||||||
|
</CommandShortcut>
|
||||||
|
</CommandItem>
|
||||||
|
<CommandItem
|
||||||
|
keywords={["help", "oauth", "oidc"]}
|
||||||
|
onSelect={() => {
|
||||||
|
window.location.href = "https://beszel.dev/guide/what-is-beszel"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BookIcon className="me-2 h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
<Trans>Documentation</Trans>
|
||||||
|
</span>
|
||||||
|
<CommandShortcut>beszel.dev</CommandShortcut>
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
{isAdmin() && (
|
{isAdmin() && (
|
||||||
<>
|
<>
|
||||||
<CommandSeparator />
|
<CommandSeparator className="mb-1.5" />
|
||||||
<CommandGroup heading="Admin">
|
<CommandGroup heading={t`Admin`}>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
keywords={['pocketbase']}
|
keywords={["pocketbase"]}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
window.open('/_/', '_blank')
|
window.open("/_/", "_blank")
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<UsersIcon className="mr-2 h-4 w-4" />
|
<UsersIcon className="me-2 h-4 w-4" />
|
||||||
<span>Users</span>
|
<span>
|
||||||
<CommandShortcut>Admin</CommandShortcut>
|
<Trans>Users</Trans>
|
||||||
|
</span>
|
||||||
|
<CommandShortcut>
|
||||||
|
<Trans>Admin</Trans>
|
||||||
|
</CommandShortcut>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
window.open('/_/#/logs', '_blank')
|
window.open("/_/#/logs", "_blank")
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LogsIcon className="mr-2 h-4 w-4" />
|
<LogsIcon className="me-2 h-4 w-4" />
|
||||||
<span>Logs</span>
|
<span>
|
||||||
<CommandShortcut>Admin</CommandShortcut>
|
<Trans>Logs</Trans>
|
||||||
|
</span>
|
||||||
|
<CommandShortcut>
|
||||||
|
<Trans>Admin</Trans>
|
||||||
|
</CommandShortcut>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
window.open('/_/#/settings/backups', '_blank')
|
window.open("/_/#/settings/backups", "_blank")
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DatabaseBackupIcon className="mr-2 h-4 w-4" />
|
<DatabaseBackupIcon className="me-2 h-4 w-4" />
|
||||||
<span>Database backups</span>
|
<span>
|
||||||
<CommandShortcut>Admin</CommandShortcut>
|
<Trans>Backups</Trans>
|
||||||
|
</span>
|
||||||
|
<CommandShortcut>
|
||||||
|
<Trans>Admin</Trans>
|
||||||
|
</CommandShortcut>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
keywords={['oauth', 'oicd']}
|
keywords={["email"]}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
window.open('/_/#/settings/auth-providers', '_blank')
|
window.open("/_/#/settings/mail", "_blank")
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LockKeyholeIcon className="mr-2 h-4 w-4" />
|
<MailIcon className="me-2 h-4 w-4" />
|
||||||
<span>Auth Providers</span>
|
<span>
|
||||||
<CommandShortcut>Admin</CommandShortcut>
|
<Trans>SMTP settings</Trans>
|
||||||
</CommandItem>
|
</span>
|
||||||
<CommandItem
|
<CommandShortcut>
|
||||||
keywords={['email']}
|
<Trans>Admin</Trans>
|
||||||
onSelect={() => {
|
</CommandShortcut>
|
||||||
setOpen(false)
|
|
||||||
window.open('/_/#/settings/mail', '_blank')
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MailIcon className="mr-2 h-4 w-4" />
|
|
||||||
<span>SMTP settings</span>
|
|
||||||
<CommandShortcut>Admin</CommandShortcut>
|
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
</>
|
</>
|
||||||
|
|||||||
51
beszel/site/src/components/copy-to-clipboard.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { useEffect, useMemo, useRef } from "react"
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"
|
||||||
|
import { Textarea } from "./ui/textarea"
|
||||||
|
import { $copyContent } from "@/lib/stores"
|
||||||
|
import { Trans } from "@lingui/macro"
|
||||||
|
|
||||||
|
export default function CopyToClipboard({ content }: { content: string }) {
|
||||||
|
return (
|
||||||
|
<Dialog defaultOpen={true}>
|
||||||
|
<DialogContent className="w-[90%] rounded-lg md:pt-4" style={{ maxWidth: 530 }}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Copy text</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="hidden xs:block">
|
||||||
|
<Trans>Automatic copy requires a secure context.</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<CopyTextarea content={content} />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CopyTextarea({ content }: { content: string }) {
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
return content.split("\n").length
|
||||||
|
}, [content])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (textareaRef.current) {
|
||||||
|
textareaRef.current.select()
|
||||||
|
}
|
||||||
|
}, [textareaRef])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => $copyContent.set("")
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
className="font-mono overflow-hidden whitespace-pre"
|
||||||
|
rows={rows}
|
||||||
|
value={content}
|
||||||
|
readOnly
|
||||||
|
ref={textareaRef}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
34
beszel/site/src/components/lang-toggle.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { LanguagesIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||||
|
import languages from "@/lib/languages"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useLingui } from "@lingui/react"
|
||||||
|
import { dynamicActivate } from "@/lib/i18n"
|
||||||
|
|
||||||
|
export function LangToggle() {
|
||||||
|
const { i18n } = useLingui()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant={"ghost"} size="icon" className="hidden 450:flex">
|
||||||
|
<LanguagesIcon className="absolute h-[1.2rem] w-[1.2rem] light:opacity-85" />
|
||||||
|
<span className="sr-only">Language</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="grid grid-cols-3">
|
||||||
|
{languages.map(({ lang, label, e }) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={lang}
|
||||||
|
className={cn("px-2.5 flex gap-2.5", lang === i18n.locale && "font-semibold")}
|
||||||
|
onClick={() => dynamicActivate(lang)}
|
||||||
|
>
|
||||||
|
<span>{e}</span> {label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,28 +1,20 @@
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from "@/lib/utils"
|
||||||
import { buttonVariants } from '@/components/ui/button'
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from "@/components/ui/label"
|
||||||
import { LoaderCircle, LockIcon, LogInIcon, MailIcon, UserIcon } from 'lucide-react'
|
import { LoaderCircle, LockIcon, LogInIcon, MailIcon } from "lucide-react"
|
||||||
import { $authenticated, pb } 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 {
|
import { Dialog, DialogContent, DialogTrigger, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
Dialog,
|
import { useCallback, useState } from "react"
|
||||||
DialogContent,
|
import { AuthMethodsList, OAuth2AuthConfig } from "pocketbase"
|
||||||
DialogTrigger,
|
import { Link } from "../router"
|
||||||
DialogHeader,
|
import { Trans, t } from "@lingui/macro"
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import { useCallback, useState } from 'react'
|
|
||||||
import { AuthMethodsList, OAuth2AuthConfig } from 'pocketbase'
|
|
||||||
import { Link } from '../router'
|
|
||||||
|
|
||||||
const honeypot = v.literal('')
|
const honeypot = v.literal("")
|
||||||
const emailSchema = v.pipe(v.string(), v.email('Invalid email address.'))
|
const emailSchema = v.pipe(v.string(), v.email(t`Invalid email address.`))
|
||||||
const passwordSchema = v.pipe(
|
const passwordSchema = v.pipe(v.string(), v.minLength(8, t`Password must be at least 8 characters.`))
|
||||||
v.string(),
|
|
||||||
v.minLength(10, 'Password must be at least 10 characters.')
|
|
||||||
)
|
|
||||||
|
|
||||||
const LoginSchema = v.looseObject({
|
const LoginSchema = v.looseObject({
|
||||||
name: honeypot,
|
name: honeypot,
|
||||||
@@ -32,14 +24,6 @@ const LoginSchema = v.looseObject({
|
|||||||
|
|
||||||
const RegisterSchema = v.looseObject({
|
const RegisterSchema = v.looseObject({
|
||||||
name: honeypot,
|
name: honeypot,
|
||||||
username: v.pipe(
|
|
||||||
v.string(),
|
|
||||||
v.regex(
|
|
||||||
/^(?=.*[a-zA-Z])[a-zA-Z0-9_-]+$/,
|
|
||||||
'Invalid username. You may use alphanumeric characters, underscores, and hyphens.'
|
|
||||||
),
|
|
||||||
v.minLength(3, 'Username must be at least 3 characters long.')
|
|
||||||
),
|
|
||||||
email: emailSchema,
|
email: emailSchema,
|
||||||
password: passwordSchema,
|
password: passwordSchema,
|
||||||
passwordConfirm: passwordSchema,
|
passwordConfirm: passwordSchema,
|
||||||
@@ -47,9 +31,9 @@ const RegisterSchema = v.looseObject({
|
|||||||
|
|
||||||
const showLoginFaliedToast = () => {
|
const showLoginFaliedToast = () => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Login attempt failed',
|
title: t`Login attempt failed`,
|
||||||
description: 'Please check your credentials and try again',
|
description: t`Please check your credentials and try again`,
|
||||||
variant: 'destructive',
|
variant: "destructive",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +55,8 @@ export function UserAuthForm({
|
|||||||
async (e: React.FormEvent<HTMLFormElement>) => {
|
async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
|
// store email for later use if mfa is enabled
|
||||||
|
let email = ""
|
||||||
try {
|
try {
|
||||||
const formData = new FormData(e.target as HTMLFormElement)
|
const formData = new FormData(e.target as HTMLFormElement)
|
||||||
const data = Object.fromEntries(formData) as Record<string, any>
|
const data = Object.fromEntries(formData) as Record<string, any>
|
||||||
@@ -86,35 +72,36 @@ export function UserAuthForm({
|
|||||||
setErrors(errors)
|
setErrors(errors)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const { email, password, passwordConfirm, username } = result.output
|
const { password, passwordConfirm } = result.output
|
||||||
|
email = result.output.email
|
||||||
if (isFirstRun) {
|
if (isFirstRun) {
|
||||||
// check that passwords match
|
// check that passwords match
|
||||||
if (password !== passwordConfirm) {
|
if (password !== passwordConfirm) {
|
||||||
let msg = 'Passwords do not match'
|
let msg = "Passwords do not match"
|
||||||
setErrors({ passwordConfirm: msg })
|
setErrors({ passwordConfirm: msg })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await pb.admins.create({
|
await pb.send("/api/beszel/create-user", {
|
||||||
email,
|
method: "POST",
|
||||||
password,
|
body: JSON.stringify({ email, password }),
|
||||||
passwordConfirm: password,
|
|
||||||
})
|
})
|
||||||
await pb.admins.authWithPassword(email, password)
|
await pb.collection("users").authWithPassword(email, password)
|
||||||
await pb.collection('users').create({
|
|
||||||
username,
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
passwordConfirm: password,
|
|
||||||
role: 'admin',
|
|
||||||
verified: true,
|
|
||||||
})
|
|
||||||
await pb.collection('users').authWithPassword(email, password)
|
|
||||||
} else {
|
} else {
|
||||||
await pb.collection('users').authWithPassword(email, password)
|
await pb.collection("users").authWithPassword(email, password)
|
||||||
}
|
}
|
||||||
$authenticated.set(true)
|
$authenticated.set(true)
|
||||||
} catch (e) {
|
} catch (err: any) {
|
||||||
showLoginFaliedToast()
|
showLoginFaliedToast()
|
||||||
|
// todo: implement MFA
|
||||||
|
// const mfaId = err.response?.mfaId
|
||||||
|
// if (!mfaId) {
|
||||||
|
// showLoginFaliedToast()
|
||||||
|
// throw err
|
||||||
|
// }
|
||||||
|
// the user needs to authenticate again with another auth method, for example OTP
|
||||||
|
// const result = await pb.collection("users").requestOTP(email)
|
||||||
|
// ... show a modal for users to check their email and to enter the received code ...
|
||||||
|
// await pb.collection("users").authWithOTP(result.otpId, "EMAIL_CODE", { mfaId: mfaId })
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
@@ -126,69 +113,48 @@ export function UserAuthForm({
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const oauthEnabled = authMethods.oauth2.enabled && authMethods.oauth2.providers.length > 0
|
||||||
|
const passwordEnabled = authMethods.password.enabled
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('grid gap-6', className)} {...props}>
|
<div className={cn("grid gap-6", className)} {...props}>
|
||||||
{authMethods.emailPassword && (
|
{passwordEnabled && (
|
||||||
<>
|
<>
|
||||||
<form onSubmit={handleSubmit} onChange={() => setErrors({})}>
|
<form onSubmit={handleSubmit} onChange={() => setErrors({})}>
|
||||||
<div className="grid gap-2.5">
|
<div className="grid gap-2.5">
|
||||||
{isFirstRun && (
|
|
||||||
<div className="grid gap-1 relative">
|
|
||||||
<UserIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Label className="sr-only" htmlFor="username">
|
|
||||||
Username
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
autoFocus={true}
|
|
||||||
id="username"
|
|
||||||
name="username"
|
|
||||||
required
|
|
||||||
placeholder="username"
|
|
||||||
type="username"
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoComplete="username"
|
|
||||||
autoCorrect="off"
|
|
||||||
disabled={isLoading || isOauthLoading}
|
|
||||||
className="pl-9"
|
|
||||||
/>
|
|
||||||
{errors?.username && (
|
|
||||||
<p className="px-1 text-xs text-red-600">{errors.username}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="grid gap-1 relative">
|
<div className="grid gap-1 relative">
|
||||||
<MailIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
<MailIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||||
<Label className="sr-only" htmlFor="email">
|
<Label className="sr-only" htmlFor="email">
|
||||||
Email
|
<Trans>Email</Trans>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
name="email"
|
name="email"
|
||||||
required
|
required
|
||||||
placeholder={isFirstRun ? 'email' : 'name@example.com'}
|
placeholder="name@example.com"
|
||||||
type="email"
|
type="email"
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
autoCorrect="off"
|
autoCorrect="off"
|
||||||
disabled={isLoading || isOauthLoading}
|
disabled={isLoading || isOauthLoading}
|
||||||
className="pl-9"
|
className="ps-9"
|
||||||
/>
|
/>
|
||||||
{errors?.email && <p className="px-1 text-xs text-red-600">{errors.email}</p>}
|
{errors?.email && <p className="px-1 text-xs text-red-600">{errors.email}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-1 relative">
|
<div className="grid gap-1 relative">
|
||||||
<LockIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
<LockIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||||
<Label className="sr-only" htmlFor="pass">
|
<Label className="sr-only" htmlFor="pass">
|
||||||
Password
|
<Trans>Password</Trans>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="pass"
|
id="pass"
|
||||||
name="password"
|
name="password"
|
||||||
placeholder="password"
|
placeholder={t`Password`}
|
||||||
required
|
required
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
disabled={isLoading || isOauthLoading}
|
disabled={isLoading || isOauthLoading}
|
||||||
className="pl-9"
|
className="ps-9 placeholder:lowercase"
|
||||||
/>
|
/>
|
||||||
{errors?.password && <p className="px-1 text-xs text-red-600">{errors.password}</p>}
|
{errors?.password && <p className="px-1 text-xs text-red-600">{errors.password}</p>}
|
||||||
</div>
|
</div>
|
||||||
@@ -196,61 +162,61 @@ export function UserAuthForm({
|
|||||||
<div className="grid gap-1 relative">
|
<div className="grid gap-1 relative">
|
||||||
<LockIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
<LockIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||||
<Label className="sr-only" htmlFor="pass2">
|
<Label className="sr-only" htmlFor="pass2">
|
||||||
Confirm password
|
<Trans>Confirm password</Trans>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="pass2"
|
id="pass2"
|
||||||
name="passwordConfirm"
|
name="passwordConfirm"
|
||||||
placeholder="confirm password"
|
placeholder={t`Confirm password`}
|
||||||
required
|
required
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
disabled={isLoading || isOauthLoading}
|
disabled={isLoading || isOauthLoading}
|
||||||
className="pl-9"
|
className="ps-9 placeholder:lowercase"
|
||||||
/>
|
/>
|
||||||
{errors?.passwordConfirm && (
|
{errors?.passwordConfirm && <p className="px-1 text-xs text-red-600">{errors.passwordConfirm}</p>}
|
||||||
<p className="px-1 text-xs text-red-600">{errors.passwordConfirm}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="sr-only">
|
<div className="sr-only">
|
||||||
{/* honeypot */}
|
{/* honeypot */}
|
||||||
<label htmlFor="name"></label>
|
<label htmlFor="name"></label>
|
||||||
<input id="name" type="text" name="name" tabIndex={-1} />
|
<input id="name" type="text" name="name" tabIndex={-1} autoComplete="off" />
|
||||||
</div>
|
</div>
|
||||||
<button className={cn(buttonVariants())} disabled={isLoading}>
|
<button className={cn(buttonVariants())} disabled={isLoading}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
|
<LoaderCircle className="me-2 h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<LogInIcon className="mr-2 h-4 w-4" />
|
<LogInIcon className="me-2 h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
{isFirstRun ? 'Create account' : 'Sign in'}
|
{isFirstRun ? t`Create account` : t`Sign in`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{(isFirstRun || authMethods.authProviders.length > 0) && (
|
{(isFirstRun || oauthEnabled) && (
|
||||||
// only show 'continue with' during onboarding or if we have auth providers
|
// only show 'continue with' during onboarding or if we have auth providers
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute inset-0 flex items-center">
|
<div className="absolute inset-0 flex items-center">
|
||||||
<span className="w-full border-t" />
|
<span className="w-full border-t" />
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex justify-center text-xs uppercase">
|
<div className="relative flex justify-center text-xs uppercase">
|
||||||
<span className="bg-background px-2 text-muted-foreground">Or continue with</span>
|
<span className="bg-background px-2 text-muted-foreground">
|
||||||
|
<Trans>Or continue with</Trans>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{authMethods.authProviders.length > 0 && (
|
{oauthEnabled && (
|
||||||
<div className="grid gap-2 -mt-1">
|
<div className="grid gap-2 -mt-1">
|
||||||
{authMethods.authProviders.map((provider) => (
|
{authMethods.oauth2.providers.map((provider) => (
|
||||||
<button
|
<button
|
||||||
key={provider.name}
|
key={provider.name}
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(buttonVariants({ variant: 'outline' }), {
|
className={cn(buttonVariants({ variant: "outline" }), {
|
||||||
'justify-self-center': !authMethods.emailPassword,
|
"justify-self-center": !passwordEnabled,
|
||||||
'px-5': !authMethods.emailPassword,
|
"px-5": !passwordEnabled,
|
||||||
})}
|
})}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsOauthLoading(true)
|
setIsOauthLoading(true)
|
||||||
@@ -263,9 +229,9 @@ export function UserAuthForm({
|
|||||||
if (!authWindow) {
|
if (!authWindow) {
|
||||||
setIsOauthLoading(false)
|
setIsOauthLoading(false)
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: t`Error`,
|
||||||
description: 'Please enable pop-ups for this site',
|
description: t`Please enable pop-ups for this site`,
|
||||||
variant: 'destructive',
|
variant: "destructive",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -273,7 +239,7 @@ export function UserAuthForm({
|
|||||||
authWindow.location.href = url
|
authWindow.location.href = url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pb.collection('users')
|
pb.collection("users")
|
||||||
.authWithOAuth2(oAuthOpts)
|
.authWithOAuth2(oAuthOpts)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
$authenticated.set(pb.authStore.isValid)
|
$authenticated.set(pb.authStore.isValid)
|
||||||
@@ -286,14 +252,14 @@ export function UserAuthForm({
|
|||||||
disabled={isLoading || isOauthLoading}
|
disabled={isLoading || isOauthLoading}
|
||||||
>
|
>
|
||||||
{isOauthLoading ? (
|
{isOauthLoading ? (
|
||||||
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
|
<LoaderCircle className="me-2 h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<img
|
<img
|
||||||
className="mr-2 h-4 w-4 dark:invert"
|
className="me-2 h-4 w-4 dark:brightness-0 dark:invert"
|
||||||
src={`/static/${provider.name}.svg`}
|
src={`/_/images/oauth2/${provider.name}.svg`}
|
||||||
alt=""
|
alt=""
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
e.currentTarget.src = '/static/lock.svg'
|
e.currentTarget.src = "/static/lock.svg"
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -303,42 +269,48 @@ export function UserAuthForm({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!authMethods.authProviders.length && isFirstRun && (
|
{!oauthEnabled && isFirstRun && (
|
||||||
// only show GitHub button / dialog during onboarding
|
// only show GitHub button / dialog during onboarding
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<button type="button" className={cn(buttonVariants({ variant: 'outline' }))}>
|
<button type="button" className={cn(buttonVariants({ variant: "outline" }))}>
|
||||||
<img className="mr-2 h-4 w-4 dark:invert" src="/static/github.svg" alt="" />
|
<img className="me-2 h-4 w-4 dark:invert" src="/_/images/oauth2/github.svg" alt="" />
|
||||||
<span className="translate-y-[1px]">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%" }}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>OAuth 2 / OIDC support</DialogTitle>
|
<DialogTitle>
|
||||||
|
<Trans>OAuth 2 / OIDC support</Trans>
|
||||||
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="text-primary/70 text-[0.95em] contents">
|
<div className="text-primary/70 text-[0.95em] contents">
|
||||||
<p>Beszel supports OpenID Connect and many OAuth2 authentication providers.</p>
|
|
||||||
<p>
|
<p>
|
||||||
Please view the{' '}
|
<Trans>Beszel supports OpenID Connect and many OAuth2 authentication providers.</Trans>
|
||||||
<a
|
</p>
|
||||||
href="https://github.com/henrygd/beszel/blob/main/readme.md#oauth--oidc-integration"
|
<p>
|
||||||
className={cn(buttonVariants({ variant: 'link' }), 'p-0 h-auto')}
|
<Trans>
|
||||||
>
|
Please see{" "}
|
||||||
GitHub README
|
<a
|
||||||
</a>{' '}
|
href="https://beszel.dev/guide/oauth"
|
||||||
for instructions.
|
className={cn(buttonVariants({ variant: "link" }), "p-0 h-auto")}
|
||||||
|
>
|
||||||
|
the documentation
|
||||||
|
</a>{" "}
|
||||||
|
for instructions.
|
||||||
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{authMethods.emailPassword && !isFirstRun && (
|
{passwordEnabled && !isFirstRun && (
|
||||||
<Link
|
<Link
|
||||||
href="/forgot-password"
|
href="/forgot-password"
|
||||||
className="text-sm mx-auto hover:text-brand underline underline-offset-4 opacity-70 hover:opacity-100 transition-opacity"
|
className="text-sm mx-auto hover:text-brand underline underline-offset-4 opacity-70 hover:opacity-100 transition-opacity"
|
||||||
>
|
>
|
||||||
Forgot password?
|
<Trans>Forgot password?</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,25 +1,26 @@
|
|||||||
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"
|
||||||
import { useCallback, useState } from 'react'
|
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 { 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 { t, Trans } from "@lingui/macro"
|
||||||
|
|
||||||
const showLoginFaliedToast = () => {
|
const showLoginFaliedToast = () => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Login attempt failed',
|
title: t`Login attempt failed`,
|
||||||
description: 'Please check your credentials and try again',
|
description: t`Please check your credentials and try again`,
|
||||||
variant: 'destructive',
|
variant: "destructive",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ForgotPassword() {
|
export default function ForgotPassword() {
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(false)
|
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState("")
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
async (e: React.FormEvent<HTMLFormElement>) => {
|
async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
@@ -27,16 +28,16 @@ export default function ForgotPassword() {
|
|||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
// console.log(email)
|
// console.log(email)
|
||||||
await pb.collection('users').requestPasswordReset(email)
|
await pb.collection("users").requestPasswordReset(email)
|
||||||
toast({
|
toast({
|
||||||
title: 'Password reset request received',
|
title: t`Password reset request received`,
|
||||||
description: `Check ${email} for a reset link.`,
|
description: t`Check ${email} for a reset link.`,
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showLoginFaliedToast()
|
showLoginFaliedToast()
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
setEmail('')
|
setEmail("")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[email]
|
[email]
|
||||||
@@ -49,7 +50,7 @@ export default function ForgotPassword() {
|
|||||||
<div className="grid gap-1 relative">
|
<div className="grid gap-1 relative">
|
||||||
<MailIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
<MailIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||||
<Label className="sr-only" htmlFor="email">
|
<Label className="sr-only" htmlFor="email">
|
||||||
Email
|
<Trans>Email</Trans>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
value={email}
|
value={email}
|
||||||
@@ -63,37 +64,40 @@ export default function ForgotPassword() {
|
|||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
autoCorrect="off"
|
autoCorrect="off"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="pl-9"
|
className="ps-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button className={cn(buttonVariants())} disabled={isLoading}>
|
<button className={cn(buttonVariants())} disabled={isLoading}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
|
<LoaderCircle className="me-2 h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<SendHorizonalIcon className="mr-2 h-4 w-4" />
|
<SendHorizonalIcon className="me-2 h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
Reset password
|
<Trans>Reset Password</Trans>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<button className="text-sm mx-auto hover:text-brand underline underline-offset-4 opacity-70 hover:opacity-100 transition-opacity">
|
<button className="text-sm mx-auto hover:text-brand underline underline-offset-4 opacity-70 hover:opacity-100 transition-opacity">
|
||||||
Command line instructions
|
<Trans>Command line instructions</Trans>
|
||||||
</button>
|
</button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-w-[33em]">
|
<DialogContent className="max-w-[33em]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Command line instructions</DialogTitle>
|
<DialogTitle>
|
||||||
|
<Trans>Command line instructions</Trans>
|
||||||
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<p className="text-primary/70 text-[0.95em] leading-relaxed">
|
<p className="text-primary/70 text-[0.95em] leading-relaxed">
|
||||||
If you've lost the password to your admin account, you may reset it using the following
|
<Trans>
|
||||||
command.
|
If you've lost the password to your admin account, you may reset it using the following command.
|
||||||
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-primary/70 text-[0.95em] leading-relaxed">
|
<p className="text-primary/70 text-[0.95em] leading-relaxed">
|
||||||
Then log into the backend and reset your user account password in the users table.
|
<Trans>Then log into the backend and reset your user account password in the users table.</Trans>
|
||||||
</p>
|
</p>
|
||||||
<code className="bg-muted rounded-sm py-0.5 px-2.5 mr-auto text-sm">
|
<code className="bg-muted rounded-sm py-0.5 px-2.5 me-auto text-sm">
|
||||||
beszel admin update youremail@example.com newpassword
|
beszel admin update youremail@example.com newpassword
|
||||||
</code>
|
</code>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -1,27 +1,30 @@
|
|||||||
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 { 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 { t } from "@lingui/macro"
|
||||||
|
import { useTheme } from "../theme-provider"
|
||||||
|
|
||||||
export default function () {
|
export default function () {
|
||||||
const page = useStore($router)
|
const page = useStore($router)
|
||||||
const [isFirstRun, setFirstRun] = useState(false)
|
const [isFirstRun, setFirstRun] = useState(false)
|
||||||
const [authMethods, setAuthMethods] = useState<AuthMethodsList>()
|
const [authMethods, setAuthMethods] = useState<AuthMethodsList>()
|
||||||
|
const { theme } = useTheme()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = 'Login / Beszel'
|
document.title = t`Login` + " / Beszel"
|
||||||
|
|
||||||
pb.send('/api/beszel/first-run', {}).then(({ firstRun }) => {
|
pb.send("/api/beszel/first-run", {}).then(({ firstRun }) => {
|
||||||
setFirstRun(firstRun)
|
setFirstRun(firstRun)
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
pb.collection('users')
|
pb.collection("users")
|
||||||
.listAuthMethods()
|
.listAuthMethods()
|
||||||
.then((methods) => {
|
.then((methods) => {
|
||||||
setAuthMethods(methods)
|
setAuthMethods(methods)
|
||||||
@@ -30,11 +33,11 @@ export default function () {
|
|||||||
|
|
||||||
const subtitle = useMemo(() => {
|
const subtitle = useMemo(() => {
|
||||||
if (isFirstRun) {
|
if (isFirstRun) {
|
||||||
return 'Please create an admin account'
|
return t`Please create an admin account`
|
||||||
} else if (page?.path === '/forgot-password') {
|
} else if (page?.path === "/forgot-password") {
|
||||||
return 'Enter email address to reset password'
|
return t`Enter email address to reset password`
|
||||||
} else {
|
} else {
|
||||||
return 'Please sign in to your account'
|
return t`Please sign in to your account`
|
||||||
}
|
}
|
||||||
}, [isFirstRun, page])
|
}, [isFirstRun, page])
|
||||||
|
|
||||||
@@ -43,8 +46,12 @@ export default function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen grid items-center py-12">
|
<div className="min-h-svh grid items-center py-12">
|
||||||
<div className="grid gap-5 w-full px-4 mx-auto" style={{ maxWidth: '22em' }}>
|
<div
|
||||||
|
className="grid gap-5 w-full px-4 mx-auto"
|
||||||
|
// @ts-ignore
|
||||||
|
style={{ maxWidth: "22em", "--border": theme == "light" ? "30 8% 80%" : "220 3% 20%" }}
|
||||||
|
>
|
||||||
<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" />
|
||||||
@@ -52,7 +59,7 @@ export default function () {
|
|||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">{subtitle}</p>
|
<p className="text-sm text-muted-foreground">{subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
{page?.path === '/forgot-password' ? (
|
{page?.path === "/forgot-password" ? (
|
||||||
<ForgotPassword />
|
<ForgotPassword />
|
||||||
) : (
|
) : (
|
||||||
<UserAuthForm isFirstRun={isFirstRun} authMethods={authMethods} />
|
<UserAuthForm isFirstRun={isFirstRun} authMethods={authMethods} />
|
||||||
|
|||||||
@@ -2,7 +2,16 @@ export function Logo({ className }: { className?: string }) {
|
|||||||
return (
|
return (
|
||||||
// Righteous
|
// Righteous
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 285 75" className={className}>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 285 75" className={className}>
|
||||||
<path d="M146.4 73.1h-30.5V59.8h30.5a3.2 3.2 0 0 0 2.3-1 3.2 3.2 0 0 0 1-2.3q0-.8-.3-1.3a1.5 1.5 0 0 0-.7-.6 4.7 4.7 0 0 0-1-.3l-1.3-.1h-13.9q-3.4 0-6.5-1.3-3-1.3-5.2-3.6a16.9 16.9 0 0 1-3.6-5.3 16.3 16.3 0 0 1-1.3-6.5 16.4 16.4 0 0 1 1.3-6.4q1.3-3.1 3.6-5.4 2.2-2.2 5.2-3.5a16.3 16.3 0 0 1 6.5-1.3h27v13.3h-27a3.2 3.2 0 0 0-2.3 1 3.2 3.2 0 0 0-1 2.3 3.3 3.3 0 0 0 1 2.4 3.3 3.3 0 0 0 1.2.8 3.2 3.2 0 0 0 1.1.2h13.9a18.1 18.1 0 0 1 6 1 17.3 17.3 0 0 1 .4.2q3 1.1 5.3 3.2a15.1 15.1 0 0 1 3.6 4.9 14.7 14.7 0 0 1 1.3 5.4 17.2 17.2 0 0 1 0 .9 16 16 0 0 1-1 5.8 15.4 15.4 0 0 1-.3.7 17.3 17.3 0 0 1-3.6 5.2 16.4 16.4 0 0 1-5.3 3.6 16.2 16.2 0 0 1-6.4 1.3Zm64.5-13.3v13.3h-43.6l22-39h-22V21h43.6l-22 39h22ZM35 73.1H0v-70h35q4.4 0 8.2 1.6a21.4 21.4 0 0 1 6.6 4.6q2.9 2.8 4.5 6.6 1.7 3.8 1.7 8.2a15.4 15.4 0 0 1-.3 3.2 17.6 17.6 0 0 1-.2.8 19.4 19.4 0 0 1-1.5 4 17 17 0 0 1-2.4 3.4 13.5 13.5 0 0 1-2.6 2.3 12.5 12.5 0 0 1-.4.3q1.7 1 3 2.5 1.4 1.6 2.4 3.5a18.3 18.3 0 0 1 1.5 4A17.4 17.4 0 0 1 56 51a15.3 15.3 0 0 1 0 1.1q0 4.3-1.7 8.2a21.4 21.4 0 0 1-4.5 6.6q-2.8 2.9-6.6 4.5-3.8 1.7-8.2 1.7Zm76-43L86 60.4l1.5.3a16.7 16.7 0 0 0 1.6 0q2 0 3.8-.4 1.8-.6 3.4-1.6 1.6-1 2.8-2.4a12.8 12.8 0 0 0 2-3.2l9.8 9.8q-1.9 2.6-4.3 4.7a27 27 0 0 1-5.2 3.6 26.1 26.1 0 0 1-6 2.2 26.8 26.8 0 0 1-6.3.8 26.4 26.4 0 0 1-10.4-2 26.2 26.2 0 0 1-8.5-5.8 26.7 26.7 0 0 1-5.5-8.3 30.4 30.4 0 0 1-.2-.4q-2.1-5-2.1-11.1a31.9 31.9 0 0 1 .7-7 27 27 0 0 1 1.4-4.3 27 27 0 0 1 3.8-6.6 24.5 24.5 0 0 1 2-2.2 26 26 0 0 1 8.4-5.6 27 27 0 0 1 10.4-2 26.3 26.3 0 0 1 6.4.8 26.9 26.9 0 0 1 6 2.2q2.7 1.5 5.2 3.6 2.4 2.1 4.3 4.8Zm152.3 0-25 30.2 1.5.3a16.7 16.7 0 0 0 1.6 0q2 0 3.8-.4 1.8-.6 3.4-1.6 1.5-1 2.8-2.4a12.8 12.8 0 0 0 2-3.2l9.8 9.8q-1.9 2.6-4.3 4.7a27 27 0 0 1-5.2 3.6 26.1 26.1 0 0 1-6 2.2 26.8 26.8 0 0 1-6.3.8 26.4 26.4 0 0 1-10.4-2 26.2 26.2 0 0 1-8.5-5.8A26.7 26.7 0 0 1 217 58a30.4 30.4 0 0 1-.2-.4q-2.1-5-2.1-11.1a31.9 31.9 0 0 1 .7-7 27 27 0 0 1 1.4-4.3 27 27 0 0 1 3.8-6.6 24.5 24.5 0 0 1 2-2.2 26 26 0 0 1 8.4-5.6 27 27 0 0 1 10.4-2 26.3 26.3 0 0 1 6.4.8 26.9 26.9 0 0 1 6 2.2q2.7 1.5 5.2 3.6 2.4 2.1 4.3 4.8ZM283.4 0v73.1H270V0h13.4ZM14 17v14.1h21a7 7 0 0 0 2.3-.4 6.6 6.6 0 0 0 .4-.1Q39 30 40 29a6.9 6.9 0 0 0 1.5-2.3q.5-1.3.5-2.7a7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.5q-.6-1.2-1.5-2.2a7 7 0 0 0-2.3-1.5 6.9 6.9 0 0 0-2.5-.5 7.9 7.9 0 0 0-.2 0H14Zm0 28.1v14h21a7 7 0 0 0 2.3-.4 6.6 6.6 0 0 0 .4-.2Q39 58 40 57.1a7 7 0 0 0 1.5-2.3 6.9 6.9 0 0 0 .5-2.5 7.9 7.9 0 0 0 0-.2 7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 48 40 47a7 7 0 0 0-2.3-1.4 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Zm63.3 8.3 15.5-20.6a8 8 0 0 0-1.4-.4 7 7 0 0 0-.4 0 17.2 17.2 0 0 0-1.6-.1 19.2 19.2 0 0 0-.3 0 13.3 13.3 0 0 0-5.1 1q-2.5 1-4.2 2.8a13.1 13.1 0 0 0-2.5 3.6 15.5 15.5 0 0 0-.3.9 14.7 14.7 0 0 0-1 3.5 18.7 18.7 0 0 0 0 2.4 17.6 17.6 0 0 0 0 .7v.8a29.4 29.4 0 0 0 0 .1 19.2 19.2 0 0 0 .2 2 20.2 20.2 0 0 0 .4 1.6 18.6 18.6 0 0 0 0 .2 7.5 7.5 0 0 0 .4.9 6 6 0 0 0 .3.6Zm152.3 0L245 32.8a8 8 0 0 0-1.4-.4 7 7 0 0 0-.4 0 17.2 17.2 0 0 0-1.6-.1 19.2 19.2 0 0 0-.3 0 13.3 13.3 0 0 0-5.1 1q-2.5 1-4.2 2.8a13.1 13.1 0 0 0-2.5 3.6 15.5 15.5 0 0 0-.4.9 14.7 14.7 0 0 0-.8 3.5 18.7 18.7 0 0 0-.2 2.4 17.6 17.6 0 0 0 0 .7v.8a29.4 29.4 0 0 0 .1.1 19.2 19.2 0 0 0 .2 2 20.2 20.2 0 0 0 .4 1.6 18.6 18.6 0 0 0 0 .2 7.5 7.5 0 0 0 .4.9 6 6 0 0 0 .3.6Z" />
|
{/* <defs>
|
||||||
|
<linearGradient id="gradient" x1="0%" y1="20%" x2="100%" y2="120%">
|
||||||
|
<stop offset="0%" style={{ stopColor: "#747bff" }} />
|
||||||
|
<stop offset="100%" style={{ stopColor: "#24eb5c" }} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs> */}
|
||||||
|
<path
|
||||||
|
// fill="url(#gradient)"
|
||||||
|
d="M146.4 73.1h-30.5V59.8h30.5a3.2 3.2 0 0 0 2.3-1 3.2 3.2 0 0 0 1-2.3q0-.8-.3-1.3a1.5 1.5 0 0 0-.7-.6 4.7 4.7 0 0 0-1-.3l-1.3-.1h-13.9q-3.4 0-6.5-1.3-3-1.3-5.2-3.6a16.9 16.9 0 0 1-3.6-5.3 16.3 16.3 0 0 1-1.3-6.5 16.4 16.4 0 0 1 1.3-6.4q1.3-3.1 3.6-5.4 2.2-2.2 5.2-3.5a16.3 16.3 0 0 1 6.5-1.3h27v13.3h-27a3.2 3.2 0 0 0-2.3 1 3.2 3.2 0 0 0-1 2.3 3.3 3.3 0 0 0 1 2.4 3.3 3.3 0 0 0 1.2.8 3.2 3.2 0 0 0 1.1.2h13.9a18.1 18.1 0 0 1 6 1 17.3 17.3 0 0 1 .4.2q3 1.1 5.3 3.2a15.1 15.1 0 0 1 3.6 4.9 14.7 14.7 0 0 1 1.3 5.4 17.2 17.2 0 0 1 0 .9 16 16 0 0 1-1 5.8 15.4 15.4 0 0 1-.3.7 17.3 17.3 0 0 1-3.6 5.2 16.4 16.4 0 0 1-5.3 3.6 16.2 16.2 0 0 1-6.4 1.3Zm64.5-13.3v13.3h-43.6l22-39h-22V21h43.6l-22 39h22ZM35 73.1H0v-70h35q4.4 0 8.2 1.6a21.4 21.4 0 0 1 6.6 4.6q2.9 2.8 4.5 6.6 1.7 3.8 1.7 8.2a15.4 15.4 0 0 1-.3 3.2 17.6 17.6 0 0 1-.2.8 19.4 19.4 0 0 1-1.5 4 17 17 0 0 1-2.4 3.4 13.5 13.5 0 0 1-2.6 2.3 12.5 12.5 0 0 1-.4.3q1.7 1 3 2.5 1.4 1.6 2.4 3.5a18.3 18.3 0 0 1 1.5 4A17.4 17.4 0 0 1 56 51a15.3 15.3 0 0 1 0 1.1q0 4.3-1.7 8.2a21.4 21.4 0 0 1-4.5 6.6q-2.8 2.9-6.6 4.5-3.8 1.7-8.2 1.7Zm76-43L86 60.4l1.5.3a16.7 16.7 0 0 0 1.6 0q2 0 3.8-.4 1.8-.6 3.4-1.6 1.6-1 2.8-2.4a12.8 12.8 0 0 0 2-3.2l9.8 9.8q-1.9 2.6-4.3 4.7a27 27 0 0 1-5.2 3.6 26.1 26.1 0 0 1-6 2.2 26.8 26.8 0 0 1-6.3.8 26.4 26.4 0 0 1-10.4-2 26.2 26.2 0 0 1-8.5-5.8 26.7 26.7 0 0 1-5.5-8.3 30.4 30.4 0 0 1-.2-.4q-2.1-5-2.1-11.1a31.9 31.9 0 0 1 .7-7 27 27 0 0 1 1.4-4.3 27 27 0 0 1 3.8-6.6 24.5 24.5 0 0 1 2-2.2 26 26 0 0 1 8.4-5.6 27 27 0 0 1 10.4-2 26.3 26.3 0 0 1 6.4.8 26.9 26.9 0 0 1 6 2.2q2.7 1.5 5.2 3.6 2.4 2.1 4.3 4.8Zm152.3 0-25 30.2 1.5.3a16.7 16.7 0 0 0 1.6 0q2 0 3.8-.4 1.8-.6 3.4-1.6 1.5-1 2.8-2.4a12.8 12.8 0 0 0 2-3.2l9.8 9.8q-1.9 2.6-4.3 4.7a27 27 0 0 1-5.2 3.6 26.1 26.1 0 0 1-6 2.2 26.8 26.8 0 0 1-6.3.8 26.4 26.4 0 0 1-10.4-2 26.2 26.2 0 0 1-8.5-5.8A26.7 26.7 0 0 1 217 58a30.4 30.4 0 0 1-.2-.4q-2.1-5-2.1-11.1a31.9 31.9 0 0 1 .7-7 27 27 0 0 1 1.4-4.3 27 27 0 0 1 3.8-6.6 24.5 24.5 0 0 1 2-2.2 26 26 0 0 1 8.4-5.6 27 27 0 0 1 10.4-2 26.3 26.3 0 0 1 6.4.8 26.9 26.9 0 0 1 6 2.2q2.7 1.5 5.2 3.6 2.4 2.1 4.3 4.8ZM283.4 0v73.1H270V0h13.4ZM14 17v14.1h21a7 7 0 0 0 2.3-.4 6.6 6.6 0 0 0 .4-.1Q39 30 40 29a6.9 6.9 0 0 0 1.5-2.3q.5-1.3.5-2.7a7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.5q-.6-1.2-1.5-2.2a7 7 0 0 0-2.3-1.5 6.9 6.9 0 0 0-2.5-.5 7.9 7.9 0 0 0-.2 0H14Zm0 28.1v14h21a7 7 0 0 0 2.3-.4 6.6 6.6 0 0 0 .4-.2Q39 58 40 57.1a7 7 0 0 0 1.5-2.3 6.9 6.9 0 0 0 .5-2.5 7.9 7.9 0 0 0 0-.2 7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 48 40 47a7 7 0 0 0-2.3-1.4 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Zm63.3 8.3 15.5-20.6a8 8 0 0 0-1.4-.4 7 7 0 0 0-.4 0 17.2 17.2 0 0 0-1.6-.1 19.2 19.2 0 0 0-.3 0 13.3 13.3 0 0 0-5.1 1q-2.5 1-4.2 2.8a13.1 13.1 0 0 0-2.5 3.6 15.5 15.5 0 0 0-.3.9 14.7 14.7 0 0 0-1 3.5 18.7 18.7 0 0 0 0 2.4 17.6 17.6 0 0 0 0 .7v.8a29.4 29.4 0 0 0 0 .1 19.2 19.2 0 0 0 .2 2 20.2 20.2 0 0 0 .4 1.6 18.6 18.6 0 0 0 0 .2 7.5 7.5 0 0 0 .4.9 6 6 0 0 0 .3.6Zm152.3 0L245 32.8a8 8 0 0 0-1.4-.4 7 7 0 0 0-.4 0 17.2 17.2 0 0 0-1.6-.1 19.2 19.2 0 0 0-.3 0 13.3 13.3 0 0 0-5.1 1q-2.5 1-4.2 2.8a13.1 13.1 0 0 0-2.5 3.6 15.5 15.5 0 0 0-.4.9 14.7 14.7 0 0 0-.8 3.5 18.7 18.7 0 0 0-.2 2.4 17.6 17.6 0 0 0 0 .7v.8a29.4 29.4 0 0 0 .1.1 19.2 19.2 0 0 0 .2 2 20.2 20.2 0 0 0 .4 1.6 18.6 18.6 0 0 0 0 .2 7.5 7.5 0 0 0 .4.9 6 6 0 0 0 .3.6Z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,54 @@
|
|||||||
import { MoonStarIcon, Sun } from 'lucide-react'
|
import { LaptopIcon, MoonStarIcon, SunIcon } from "lucide-react"
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||||
DropdownMenu,
|
import { useTheme } from "@/components/theme-provider"
|
||||||
DropdownMenuContent,
|
import { cn } from "@/lib/utils"
|
||||||
DropdownMenuItem,
|
import { t, Trans } from "@lingui/macro"
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu'
|
|
||||||
import { useTheme } from '@/components/theme-provider'
|
|
||||||
|
|
||||||
export function ModeToggle() {
|
export function ModeToggle() {
|
||||||
const { setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{
|
||||||
|
theme: "light",
|
||||||
|
Icon: SunIcon,
|
||||||
|
label: <Trans comment="Light theme">Light</Trans>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
theme: "dark",
|
||||||
|
Icon: MoonStarIcon,
|
||||||
|
label: <Trans comment="Dark theme">Dark</Trans>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
theme: "system",
|
||||||
|
Icon: LaptopIcon,
|
||||||
|
label: <Trans comment="System theme">System</Trans>,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant={'ghost'} size="icon">
|
<Button variant={"ghost"} size="icon" aria-label={t`Toggle theme`}>
|
||||||
<Sun className="h-[1.2rem] w-[1.2rem] dark:opacity-0" />
|
<SunIcon className="h-[1.2rem] w-[1.2rem] dark:opacity-0" />
|
||||||
<MoonStarIcon className="absolute h-[1.2rem] w-[1.2rem] opacity-0 dark:opacity-100" />
|
<MoonStarIcon className="absolute h-[1.2rem] w-[1.2rem] opacity-0 dark:opacity-100" />
|
||||||
<span className="sr-only">Toggle theme</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
<DropdownMenuItem onClick={() => setTheme('light')}>Light</DropdownMenuItem>
|
{options.map((opt) => {
|
||||||
<DropdownMenuItem onClick={() => setTheme('dark')}>Dark</DropdownMenuItem>
|
const selected = opt.theme === theme
|
||||||
<DropdownMenuItem onClick={() => setTheme('system')}>System</DropdownMenuItem>
|
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>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)
|
)
|
||||||
|
|||||||
145
beszel/site/src/components/navbar.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { useState, lazy, Suspense } from "react"
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
DatabaseBackupIcon,
|
||||||
|
LogOutIcon,
|
||||||
|
LogsIcon,
|
||||||
|
SearchIcon,
|
||||||
|
ServerIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
UserIcon,
|
||||||
|
UsersIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { Link } from "./router"
|
||||||
|
import { LangToggle } from "./lang-toggle"
|
||||||
|
import { ModeToggle } from "./mode-toggle"
|
||||||
|
import { Logo } from "./logo"
|
||||||
|
import { pb } from "@/lib/stores"
|
||||||
|
import { cn, isReadOnlyUser, isAdmin } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import { AddSystemButton } from "./add-system"
|
||||||
|
import { Trans } from "@lingui/macro"
|
||||||
|
|
||||||
|
const CommandPalette = lazy(() => import("./command-palette"))
|
||||||
|
|
||||||
|
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0
|
||||||
|
|
||||||
|
export default function Navbar() {
|
||||||
|
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">
|
||||||
|
<Link href="/" aria-label="Home" className="p-2 ps-0 me-3">
|
||||||
|
<Logo className="h-[1.1rem] md:h-5 fill-foreground" />
|
||||||
|
</Link>
|
||||||
|
<SearchButton />
|
||||||
|
|
||||||
|
<div className="flex items-center ms-auto">
|
||||||
|
<LangToggle />
|
||||||
|
<ModeToggle />
|
||||||
|
<Link
|
||||||
|
href="/settings/general"
|
||||||
|
aria-label="Settings"
|
||||||
|
className={cn("", buttonVariants({ variant: "ghost", size: "icon" }))}
|
||||||
|
>
|
||||||
|
<SettingsIcon className="h-[1.2rem] w-[1.2rem]" />
|
||||||
|
</Link>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button aria-label="User Actions" className={cn("", buttonVariants({ variant: "ghost", size: "icon" }))}>
|
||||||
|
<UserIcon className="h-[1.2rem] w-[1.2rem]" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align={isReadOnlyUser() ? "end" : "center"} className="min-w-44">
|
||||||
|
<DropdownMenuLabel>{pb.authStore.record?.email}</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
{isAdmin() && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<a href="/_/" target="_blank">
|
||||||
|
<UsersIcon className="me-2.5 h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
<Trans>Users</Trans>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<a href="/_/#/collections?collectionId=2hz5ncl8tizk5nx" target="_blank">
|
||||||
|
<ServerIcon className="me-2.5 h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
<Trans>Systems</Trans>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<a href="/_/#/logs" target="_blank">
|
||||||
|
<LogsIcon className="me-2.5 h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
<Trans>Logs</Trans>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<a href="/_/#/settings/backups" target="_blank">
|
||||||
|
<DatabaseBackupIcon className="me-2.5 h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
<Trans>Backups</Trans>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
<DropdownMenuItem onSelect={() => pb.authStore.clear()}>
|
||||||
|
<LogOutIcon className="me-2.5 h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
<Trans>Log Out</Trans>
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<AddSystemButton className="ms-2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchButton() {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
const Kbd = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground opacity-100">
|
||||||
|
{children}
|
||||||
|
</kbd>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="hidden md:block text-sm text-muted-foreground px-4"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center">
|
||||||
|
<SearchIcon className="me-1.5 h-4 w-4" />
|
||||||
|
<Trans>Search</Trans>
|
||||||
|
<span className="flex items-center ms-3.5">
|
||||||
|
<Kbd>{isMac ? "⌘" : "Ctrl"}</Kbd>
|
||||||
|
<Kbd>K</Kbd>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
<Suspense>
|
||||||
|
<CommandPalette open={open} setOpen={setOpen} />
|
||||||
|
</Suspense>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { createRouter } from '@nanostores/router'
|
import { createRouter } from "@nanostores/router"
|
||||||
|
|
||||||
export const $router = createRouter(
|
export const $router = createRouter(
|
||||||
{
|
{
|
||||||
home: '/',
|
home: "/",
|
||||||
server: '/system/:name',
|
server: "/system/:name",
|
||||||
'forgot-password': '/forgot-password',
|
settings: "/settings/:name?",
|
||||||
|
forgot_password: "/forgot-password",
|
||||||
},
|
},
|
||||||
{ links: false }
|
{ links: false }
|
||||||
)
|
)
|
||||||
@@ -16,7 +17,7 @@ export const navigate = (urlString: string) => {
|
|||||||
|
|
||||||
function onClick(e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) {
|
function onClick(e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
$router.open(new URL((e.target as HTMLAnchorElement).href).pathname)
|
$router.open(new URL((e.currentTarget as HTMLAnchorElement).href).pathname)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Link = (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
export const Link = (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||||
|
|||||||
@@ -1,68 +1,106 @@
|
|||||||
import { Suspense, lazy, useEffect, useState } from 'react'
|
import { Suspense, lazy, useEffect, useMemo } from "react"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"
|
||||||
import { $alerts, $hubVersion, $systems, pb } from '@/lib/stores'
|
import { $alerts, $hubVersion, $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 { updateRecordList, updateSystemList } from '@/lib/utils'
|
import { alertInfo, updateRecordList, updateSystemList } from "@/lib/utils"
|
||||||
import { AlertRecord, SystemRecord } from '@/types'
|
import { AlertRecord, SystemRecord } from "@/types"
|
||||||
import { Input } from '../ui/input'
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||||
|
import { Link } from "../router"
|
||||||
|
import { Plural, t, Trans } from "@lingui/macro"
|
||||||
|
|
||||||
const SystemsTable = lazy(() => import('../systems-table/systems-table'))
|
const SystemsTable = lazy(() => import("../systems-table/systems-table"))
|
||||||
|
|
||||||
export default function () {
|
export default function Home() {
|
||||||
const hubVersion = useStore($hubVersion)
|
const hubVersion = useStore($hubVersion)
|
||||||
const [filter, setFilter] = useState<string>()
|
|
||||||
|
const alerts = useStore($alerts)
|
||||||
|
const systems = useStore($systems)
|
||||||
|
|
||||||
|
const activeAlerts = useMemo(() => {
|
||||||
|
const activeAlerts = alerts.filter((alert) => {
|
||||||
|
const active = alert.triggered && alert.name in alertInfo
|
||||||
|
if (!active) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
alert.sysname = systems.find((system) => system.id === alert.system)?.name
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return activeAlerts
|
||||||
|
}, [alerts])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = 'Dashboard / Beszel'
|
document.title = t`Dashboard` + " / Beszel"
|
||||||
|
|
||||||
// make sure we have the latest list of systems
|
// make sure we have the latest list of systems
|
||||||
updateSystemList()
|
updateSystemList()
|
||||||
|
|
||||||
// subscribe to real time updates for systems / alerts
|
// subscribe to real time updates for systems / alerts
|
||||||
pb.collection<SystemRecord>('systems').subscribe('*', (e) => {
|
pb.collection<SystemRecord>("systems").subscribe("*", (e) => {
|
||||||
updateRecordList(e, $systems)
|
updateRecordList(e, $systems)
|
||||||
})
|
})
|
||||||
pb.collection<AlertRecord>('alerts').subscribe('*', (e) => {
|
// todo: add toast if new triggered alert comes in
|
||||||
|
pb.collection<AlertRecord>("alerts").subscribe("*", (e) => {
|
||||||
updateRecordList(e, $alerts)
|
updateRecordList(e, $alerts)
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
pb.collection('systems').unsubscribe('*')
|
pb.collection("systems").unsubscribe("*")
|
||||||
pb.collection('alerts').unsubscribe('*')
|
// pb.collection('alerts').unsubscribe('*')
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card>
|
{/* show active alerts */}
|
||||||
<CardHeader className="pb-5 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
|
{activeAlerts.length > 0 && (
|
||||||
<div className="grid md:flex gap-3 w-full items-end">
|
<Card className="mb-4">
|
||||||
|
<CardHeader className="pb-4 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
|
||||||
<div className="px-2 sm:px-1">
|
<div className="px-2 sm:px-1">
|
||||||
<CardTitle className="mb-2.5">All Systems</CardTitle>
|
<CardTitle>
|
||||||
<CardDescription>
|
<Trans>Active Alerts</Trans>
|
||||||
Updated in real time. Press{' '}
|
</CardTitle>
|
||||||
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-0.5 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground opacity-100">
|
|
||||||
<span className="text-xs">⌘</span>K
|
|
||||||
</kbd>{' '}
|
|
||||||
to open the command palette.
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
</div>
|
||||||
<Input
|
</CardHeader>
|
||||||
placeholder="Filter..."
|
<CardContent className="max-sm:p-2">
|
||||||
onChange={(e) => setFilter(e.target.value)}
|
{activeAlerts.length > 0 && (
|
||||||
className="w-full md:w-56 lg:w-80 ml-auto pl-4"
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-3">
|
||||||
/>
|
{activeAlerts.map((alert) => {
|
||||||
</div>
|
const info = alertInfo[alert.name as keyof typeof alertInfo]
|
||||||
</CardHeader>
|
return (
|
||||||
<CardContent className="max-sm:p-2">
|
<Alert
|
||||||
<Suspense>
|
key={alert.id}
|
||||||
<SystemsTable filter={filter} />
|
className="hover:-translate-y-[1px] duration-200 bg-transparent border-foreground/10 hover:shadow-md shadow-black"
|
||||||
</Suspense>
|
>
|
||||||
</CardContent>
|
<info.icon className="h-4 w-4" />
|
||||||
</Card>
|
<AlertTitle>
|
||||||
|
{alert.sysname} {info.name().toLowerCase().replace("cpu", "CPU")}
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<Trans>
|
||||||
|
Exceeds {alert.value}
|
||||||
|
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
|
||||||
|
</Trans>
|
||||||
|
</AlertDescription>
|
||||||
|
<Link
|
||||||
|
href={`/system/${encodeURIComponent(alert.sysname!)}`}
|
||||||
|
className="absolute inset-0 w-full h-full"
|
||||||
|
aria-label="View system"
|
||||||
|
></Link>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
<Suspense>
|
||||||
|
<SystemsTable />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
{hubVersion && (
|
{hubVersion && (
|
||||||
<div className="flex gap-1.5 justify-end items-center pr-3 sm:pr-6 mt-3.5 text-xs opacity-80">
|
<div className="flex gap-1.5 justify-end items-center pe-3 sm:pe-6 mt-3.5 text-xs opacity-80">
|
||||||
<a
|
<a
|
||||||
href="https://github.com/henrygd/beszel"
|
href="https://github.com/henrygd/beszel"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|||||||
97
beszel/site/src/components/routes/settings/config-yaml.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { isAdmin } from "@/lib/utils"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { redirectPage } from "@nanostores/router"
|
||||||
|
import { $router } from "@/components/router"
|
||||||
|
import { AlertCircleIcon, FileSlidersIcon, LoaderCircleIcon } from "lucide-react"
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||||
|
import { pb } from "@/lib/stores"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { toast } from "@/components/ui/use-toast"
|
||||||
|
import clsx from "clsx"
|
||||||
|
import { Trans, t } from "@lingui/macro"
|
||||||
|
|
||||||
|
export default function ConfigYaml() {
|
||||||
|
const [configContent, setConfigContent] = useState<string>("")
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
const ButtonIcon = isLoading ? LoaderCircleIcon : FileSlidersIcon
|
||||||
|
|
||||||
|
async function fetchConfig() {
|
||||||
|
try {
|
||||||
|
setIsLoading(true)
|
||||||
|
const { config } = await pb.send<{ config: string }>("/api/beszel/config-yaml", {})
|
||||||
|
setConfigContent(config)
|
||||||
|
} catch (error: any) {
|
||||||
|
toast({
|
||||||
|
title: t`Error`,
|
||||||
|
description: error.message,
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAdmin()) {
|
||||||
|
redirectPage($router, "settings", { name: "general" })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-medium mb-2">
|
||||||
|
<Trans>YAML Configuration</Trans>
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
<Trans>Export your current systems configuration.</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Separator className="my-4" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed my-1">
|
||||||
|
<Trans>
|
||||||
|
Systems may be managed in a <code className="bg-muted rounded-sm px-1 text-primary">config.yml</code> file
|
||||||
|
inside your data directory.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
<Trans>
|
||||||
|
On each restart, systems in the database will be updated to match the systems defined in the file.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
<Alert className="my-4 border-destructive text-destructive w-auto table md:pe-6">
|
||||||
|
<AlertCircleIcon className="h-4 w-4 stroke-destructive" />
|
||||||
|
<AlertTitle>
|
||||||
|
<Trans>Caution - potential data loss</Trans>
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<p>
|
||||||
|
<Trans>
|
||||||
|
Existing systems not defined in <code>config.yml</code> will be deleted. Please make regular backups.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
{configContent && (
|
||||||
|
<Textarea
|
||||||
|
dir="ltr"
|
||||||
|
autoFocus
|
||||||
|
defaultValue={configContent}
|
||||||
|
spellCheck="false"
|
||||||
|
rows={Math.min(25, configContent.split("\n").length)}
|
||||||
|
className="font-mono whitespace-pre"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Separator className="my-5" />
|
||||||
|
<Button type="button" className="mt-2 flex items-center gap-1" onClick={fetchConfig} disabled={isLoading}>
|
||||||
|
<ButtonIcon className={clsx("h-4 w-4 me-0.5", isLoading && "animate-spin")} />
|
||||||
|
<Trans>Export configuration</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
111
beszel/site/src/components/routes/settings/general.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { chartTimeData } from "@/lib/utils"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import { LanguagesIcon, LoaderCircleIcon, SaveIcon } from "lucide-react"
|
||||||
|
import { UserSettings } from "@/types"
|
||||||
|
import { saveSettings } from "./layout"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Trans } from "@lingui/macro"
|
||||||
|
import languages from "@/lib/languages"
|
||||||
|
import { dynamicActivate } from "@/lib/i18n"
|
||||||
|
import { useLingui } from "@lingui/react"
|
||||||
|
// import { setLang } from "@/lib/i18n"
|
||||||
|
|
||||||
|
export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) {
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const { i18n } = useLingui()
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsLoading(true)
|
||||||
|
const formData = new FormData(e.target as HTMLFormElement)
|
||||||
|
const data = Object.fromEntries(formData) as Partial<UserSettings>
|
||||||
|
await saveSettings(data)
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-medium mb-2">
|
||||||
|
<Trans>General</Trans>
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
<Trans>Change general application options.</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Separator className="my-4" />
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="mb-1 text-lg font-medium flex items-center gap-2">
|
||||||
|
<LanguagesIcon className="h-4 w-4" />
|
||||||
|
<Trans>Language</Trans>
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
<Trans>
|
||||||
|
Want to help us make our translations even better? Check out{" "}
|
||||||
|
<a href="https://crowdin.com/project/beszel" className="link" target="_blank" rel="noopener noreferrer">
|
||||||
|
Crowdin
|
||||||
|
</a>{" "}
|
||||||
|
for more details.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Label className="block" htmlFor="lang">
|
||||||
|
<Trans>Preferred Language</Trans>
|
||||||
|
</Label>
|
||||||
|
<Select value={i18n.locale} onValueChange={(lang: string) => dynamicActivate(lang)}>
|
||||||
|
<SelectTrigger id="lang">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{languages.map((lang) => (
|
||||||
|
<SelectItem key={lang.lang} value={lang.lang}>
|
||||||
|
<span className="me-2.5">{lang.e}</span>
|
||||||
|
{lang.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="mb-1 text-lg font-medium">
|
||||||
|
<Trans>Chart options</Trans>
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
<Trans>Adjust display options for charts.</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Label className="block" htmlFor="chartTime">
|
||||||
|
<Trans>Default time period</Trans>
|
||||||
|
</Label>
|
||||||
|
<Select name="chartTime" key={userSettings.chartTime} defaultValue={userSettings.chartTime}>
|
||||||
|
<SelectTrigger id="chartTime">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Object.entries(chartTimeData).map(([value, { label }]) => (
|
||||||
|
<SelectItem key={value} value={value}>
|
||||||
|
{label()}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[0.8rem] text-muted-foreground">
|
||||||
|
<Trans>Sets the default time range for charts when a system is viewed.</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<Button type="submit" className="flex items-center gap-1.5 disabled:opacity-100" disabled={isLoading}>
|
||||||
|
{isLoading ? <LoaderCircleIcon className="h-4 w-4 animate-spin" /> : <SaveIcon className="h-4 w-4" />}
|
||||||
|
<Trans>Save Settings</Trans>
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
115
beszel/site/src/components/routes/settings/layout.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { useEffect } from "react"
|
||||||
|
import { Separator } from "../../ui/separator"
|
||||||
|
import { SidebarNav } from "./sidebar-nav.tsx"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card.tsx"
|
||||||
|
import { useStore } from "@nanostores/react"
|
||||||
|
import { $router } from "@/components/router.tsx"
|
||||||
|
import { redirectPage } from "@nanostores/router"
|
||||||
|
import { BellIcon, FileSlidersIcon, SettingsIcon } from "lucide-react"
|
||||||
|
import { $userSettings, pb } from "@/lib/stores.ts"
|
||||||
|
import { toast } from "@/components/ui/use-toast.ts"
|
||||||
|
import { UserSettings } from "@/types.js"
|
||||||
|
import General from "./general.tsx"
|
||||||
|
import Notifications from "./notifications.tsx"
|
||||||
|
import ConfigYaml from "./config-yaml.tsx"
|
||||||
|
import { Trans, t } from "@lingui/macro"
|
||||||
|
import { useLingui } from "@lingui/react"
|
||||||
|
|
||||||
|
export async function saveSettings(newSettings: Partial<UserSettings>) {
|
||||||
|
try {
|
||||||
|
// get fresh copy of settings
|
||||||
|
const req = await pb.collection("user_settings").getFirstListItem("", {
|
||||||
|
fields: "id,settings",
|
||||||
|
})
|
||||||
|
// update user settings
|
||||||
|
const updatedSettings = await pb.collection("user_settings").update(req.id, {
|
||||||
|
settings: {
|
||||||
|
...req.settings,
|
||||||
|
...newSettings,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
$userSettings.set(updatedSettings.settings)
|
||||||
|
toast({
|
||||||
|
title: t`Settings saved`,
|
||||||
|
description: t`Your user settings have been updated.`,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
// console.error('update settings', e)
|
||||||
|
toast({
|
||||||
|
title: t`Failed to save settings`,
|
||||||
|
description: t`Check logs for more details.`,
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsLayout() {
|
||||||
|
const { _ } = useLingui()
|
||||||
|
|
||||||
|
const sidebarNavItems = [
|
||||||
|
{
|
||||||
|
title: _(t({ message: `General`, comment: "Context: General settings" })),
|
||||||
|
href: "/settings/general",
|
||||||
|
icon: SettingsIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t`Notifications`,
|
||||||
|
href: "/settings/notifications",
|
||||||
|
icon: BellIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t`YAML Config`,
|
||||||
|
href: "/settings/config",
|
||||||
|
icon: FileSlidersIcon,
|
||||||
|
admin: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const page = useStore($router)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = t`Settings` + " / Beszel"
|
||||||
|
// redirect to account page if no page is specified
|
||||||
|
if (page?.path === "/settings") {
|
||||||
|
redirectPage($router, "settings", { name: "general" })
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="pt-5 px-4 pb-8 sm:pt-6 sm:px-7">
|
||||||
|
<CardHeader className="p-0">
|
||||||
|
<CardTitle className="mb-1">
|
||||||
|
<Trans>Settings</Trans>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
<Trans>Manage display and notification preferences.</Trans>
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Separator className="hidden md:block my-5" />
|
||||||
|
<div className="flex flex-col gap-3.5 md:flex-row md:gap-5 lg:gap-10">
|
||||||
|
<aside className="md:w-48 w-full">
|
||||||
|
<SidebarNav items={sidebarNavItems} />
|
||||||
|
</aside>
|
||||||
|
<div className="flex-1">
|
||||||
|
{/* @ts-ignore */}
|
||||||
|
<SettingsContent name={page?.params?.name ?? "general"} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsContent({ name }: { name: string }) {
|
||||||
|
const userSettings = useStore($userSettings)
|
||||||
|
|
||||||
|
switch (name) {
|
||||||
|
case "general":
|
||||||
|
return <General userSettings={userSettings} />
|
||||||
|
case "notifications":
|
||||||
|
return <Notifications userSettings={userSettings} />
|
||||||
|
case "config":
|
||||||
|
return <ConfigYaml />
|
||||||
|
}
|
||||||
|
}
|
||||||
225
beszel/site/src/components/routes/settings/notifications.tsx
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { pb } from "@/lib/stores"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import { Card } from "@/components/ui/card"
|
||||||
|
import { BellIcon, LoaderCircleIcon, PlusIcon, SaveIcon, Trash2Icon } from "lucide-react"
|
||||||
|
import { ChangeEventHandler, useEffect, useState } from "react"
|
||||||
|
import { toast } from "@/components/ui/use-toast"
|
||||||
|
import { InputTags } from "@/components/ui/input-tags"
|
||||||
|
import { UserSettings } from "@/types"
|
||||||
|
import { saveSettings } from "./layout"
|
||||||
|
import * as v from "valibot"
|
||||||
|
import { isAdmin } from "@/lib/utils"
|
||||||
|
import { Trans, t } from "@lingui/macro"
|
||||||
|
|
||||||
|
interface ShoutrrrUrlCardProps {
|
||||||
|
url: string
|
||||||
|
onUrlChange: ChangeEventHandler<HTMLInputElement>
|
||||||
|
onRemove: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const NotificationSchema = v.object({
|
||||||
|
emails: v.array(v.pipe(v.string(), v.email())),
|
||||||
|
webhooks: v.array(v.pipe(v.string(), v.url())),
|
||||||
|
})
|
||||||
|
|
||||||
|
const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSettings }) => {
|
||||||
|
const [webhooks, setWebhooks] = useState(userSettings.webhooks ?? [])
|
||||||
|
const [emails, setEmails] = useState<string[]>(userSettings.emails ?? [])
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
// update values when userSettings changes
|
||||||
|
useEffect(() => {
|
||||||
|
setWebhooks(userSettings.webhooks ?? [])
|
||||||
|
setEmails(userSettings.emails ?? [])
|
||||||
|
}, [userSettings])
|
||||||
|
|
||||||
|
function addWebhook() {
|
||||||
|
setWebhooks([...webhooks, ""])
|
||||||
|
// focus on the new input
|
||||||
|
queueMicrotask(() => {
|
||||||
|
const inputs = document.querySelectorAll("#webhooks input") as NodeListOf<HTMLInputElement>
|
||||||
|
inputs[inputs.length - 1]?.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const removeWebhook = (index: number) => setWebhooks(webhooks.filter((_, i) => i !== index))
|
||||||
|
|
||||||
|
function updateWebhook(index: number, value: string) {
|
||||||
|
const newWebhooks = [...webhooks]
|
||||||
|
newWebhooks[index] = value
|
||||||
|
setWebhooks(newWebhooks)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateSettings() {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const parsedData = v.parse(NotificationSchema, { emails, webhooks })
|
||||||
|
await saveSettings(parsedData)
|
||||||
|
} catch (e: any) {
|
||||||
|
toast({
|
||||||
|
title: t`Failed to save settings`,
|
||||||
|
description: e.message,
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-medium mb-2">
|
||||||
|
<Trans>Notifications</Trans>
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
<Trans>Configure how you receive alert notifications.</Trans>
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1.5 leading-relaxed">
|
||||||
|
<Trans>
|
||||||
|
Looking instead for where to create alerts? Click the bell <BellIcon className="inline h-4 w-4" /> icons in
|
||||||
|
the systems table.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Separator className="my-4" />
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="mb-1 text-lg font-medium">
|
||||||
|
<Trans>Email notifications</Trans>
|
||||||
|
</h3>
|
||||||
|
{isAdmin() && (
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
<Trans>
|
||||||
|
Please{" "}
|
||||||
|
<a href="/_/#/settings/mail" className="link" target="_blank">
|
||||||
|
configure an SMTP server
|
||||||
|
</a>{" "}
|
||||||
|
to ensure alerts are delivered.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Label className="block" htmlFor="email">
|
||||||
|
<Trans>To email(s)</Trans>
|
||||||
|
</Label>
|
||||||
|
<InputTags
|
||||||
|
value={emails}
|
||||||
|
onChange={setEmails}
|
||||||
|
placeholder={t`Enter email address...`}
|
||||||
|
className="w-full"
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
/>
|
||||||
|
<p className="text-[0.8rem] text-muted-foreground">
|
||||||
|
<Trans>Save address using enter key or comma. Leave blank to disable email notifications.</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-1 text-lg font-medium">
|
||||||
|
<Trans>Webhook / Push notifications</Trans>
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
<Trans>
|
||||||
|
Beszel uses{" "}
|
||||||
|
<a href="https://containrrr.dev/shoutrrr/services/overview/" target="_blank" className="link">
|
||||||
|
Shoutrrr
|
||||||
|
</a>{" "}
|
||||||
|
to integrate with popular notification services.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{webhooks.length > 0 && (
|
||||||
|
<div className="grid gap-2.5" id="webhooks">
|
||||||
|
{webhooks.map((webhook, index) => (
|
||||||
|
<ShoutrrrUrlCard
|
||||||
|
key={index}
|
||||||
|
url={webhook}
|
||||||
|
onUrlChange={(e: React.ChangeEvent<HTMLInputElement>) => updateWebhook(index, e.target.value)}
|
||||||
|
onRemove={() => removeWebhook(index)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mt-2 flex items-center gap-1"
|
||||||
|
onClick={addWebhook}
|
||||||
|
>
|
||||||
|
<PlusIcon className="h-4 w-4 -ms-0.5" />
|
||||||
|
<Trans>Add URL</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-1.5 disabled:opacity-100"
|
||||||
|
onClick={updateSettings}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? <LoaderCircleIcon className="h-4 w-4 animate-spin" /> : <SaveIcon className="h-4 w-4" />}
|
||||||
|
<Trans>Save Settings</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ShoutrrrUrlCard = ({ url, onUrlChange, onRemove }: ShoutrrrUrlCardProps) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
const sendTestNotification = async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
const res = await pb.send("/api/beszel/send-test-notification", { url })
|
||||||
|
if ("err" in res && !res.err) {
|
||||||
|
toast({
|
||||||
|
title: t`Test notification sent`,
|
||||||
|
description: t`Check your notification service`,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: t`Error`,
|
||||||
|
description: res.err ?? t`Failed to send test notification`,
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-muted/40 p-2 md:p-3">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Input
|
||||||
|
type="url"
|
||||||
|
className="light:bg-card"
|
||||||
|
required
|
||||||
|
placeholder="generic://webhook.site/xxxxxx"
|
||||||
|
value={url}
|
||||||
|
onChange={onUrlChange}
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="outline" disabled={isLoading || url === ""} onClick={sendTestNotification}>
|
||||||
|
{isLoading ? (
|
||||||
|
<LoaderCircleIcon className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
<Trans>
|
||||||
|
Test <span className="hidden sm:inline">URL</span>
|
||||||
|
</Trans>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" size="icon" className="shrink-0" aria-label="Delete" onClick={onRemove}>
|
||||||
|
<Trash2Icon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SettingsNotificationsPage
|
||||||
71
beszel/site/src/components/routes/settings/sidebar-nav.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { cn, isAdmin } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from "../../ui/button"
|
||||||
|
import { $router, Link, navigate } from "../../router"
|
||||||
|
import { useStore } from "@nanostores/react"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
|
||||||
|
interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
|
||||||
|
items: {
|
||||||
|
href: string
|
||||||
|
title: string
|
||||||
|
icon?: React.FC<React.SVGProps<SVGSVGElement>>
|
||||||
|
admin?: boolean
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
|
||||||
|
const page = useStore($router)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Mobile View */}
|
||||||
|
<div className="md:hidden">
|
||||||
|
<Select onValueChange={(value: string) => navigate(value)} value={page?.path}>
|
||||||
|
<SelectTrigger className="w-full my-3.5">
|
||||||
|
<SelectValue placeholder="Select page" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{items.map((item) => {
|
||||||
|
if (item.admin && !isAdmin()) return null
|
||||||
|
return (
|
||||||
|
<SelectItem key={item.href} value={item.href}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{item.icon && <item.icon className="h-4 w-4" />}
|
||||||
|
{item.title}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Separator />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop View */}
|
||||||
|
<nav className={cn("hidden md:grid gap-1", className)} {...props}>
|
||||||
|
{items.map((item) => {
|
||||||
|
if (item.admin && !isAdmin()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: "ghost" }),
|
||||||
|
"flex items-center gap-3",
|
||||||
|
page?.path === item.href ? "bg-muted hover:bg-muted" : "hover:bg-muted/50",
|
||||||
|
"justify-start"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.icon && <item.icon className="h-4 w-4" />}
|
||||||
|
{item.title}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,62 +1,140 @@
|
|||||||
import { $systems, pb, $chartTime } from '@/lib/stores'
|
import { $systems, pb, $chartTime, $containerFilter, $userSettings, $direction } from "@/lib/stores"
|
||||||
import { ContainerStatsRecord, SystemRecord, SystemStatsRecord } from '@/types'
|
import { ChartData, ChartTimes, ContainerStatsRecord, GPUData, SystemRecord, SystemStatsRecord } from "@/types"
|
||||||
import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import React, { lazy, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } 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"
|
||||||
import { ClockArrowUp, CpuIcon, GlobeIcon } from 'lucide-react'
|
import { ClockArrowUp, CpuIcon, GlobeIcon, LayoutGridIcon, MonitorIcon, XIcon } from "lucide-react"
|
||||||
import ChartTimeSelect from '../charts/chart-time-select'
|
import ChartTimeSelect from "../charts/chart-time-select"
|
||||||
import { chartTimeData, cn, getPbTimestamp, useClampedIsInViewport } from '@/lib/utils'
|
import { chartTimeData, cn, getPbTimestamp, getSizeAndUnit, toFixedFloat, useLocalStorage } from "@/lib/utils"
|
||||||
import { Separator } from '../ui/separator'
|
import { Separator } from "../ui/separator"
|
||||||
import { scaleTime } from 'd3-scale'
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
|
import { Button } from "../ui/button"
|
||||||
|
import { Input } from "../ui/input"
|
||||||
|
import { ChartAverage, ChartMax, Rows, TuxIcon } from "../ui/icons"
|
||||||
|
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"
|
||||||
|
import { timeTicks } from "d3-time"
|
||||||
|
import { Plural, Trans, t } from "@lingui/macro"
|
||||||
|
import { useLingui } from "@lingui/react"
|
||||||
|
|
||||||
const CpuChart = lazy(() => import('../charts/cpu-chart'))
|
const AreaChartDefault = lazy(() => import("../charts/area-chart"))
|
||||||
const ContainerCpuChart = lazy(() => import('../charts/container-cpu-chart'))
|
const ContainerChart = lazy(() => import("../charts/container-chart"))
|
||||||
const MemChart = lazy(() => import('../charts/mem-chart'))
|
const MemChart = lazy(() => import("../charts/mem-chart"))
|
||||||
const ContainerMemChart = lazy(() => import('../charts/container-mem-chart'))
|
const DiskChart = lazy(() => import("../charts/disk-chart"))
|
||||||
const DiskChart = lazy(() => import('../charts/disk-chart'))
|
const SwapChart = lazy(() => import("../charts/swap-chart"))
|
||||||
const DiskIoChart = lazy(() => import('../charts/disk-io-chart'))
|
const TemperatureChart = lazy(() => import("../charts/temperature-chart"))
|
||||||
const BandwidthChart = lazy(() => import('../charts/bandwidth-chart'))
|
const GpuPowerChart = lazy(() => import("../charts/gpu-power-chart"))
|
||||||
const ContainerNetChart = lazy(() => import('../charts/container-net-chart'))
|
|
||||||
const SwapChart = lazy(() => import('../charts/swap-chart'))
|
const cache = new Map<string, any>()
|
||||||
const TemperatureChart = lazy(() => import('../charts/temperature-chart'))
|
|
||||||
|
// create ticks and domain for charts
|
||||||
|
function getTimeData(chartTime: ChartTimes, lastCreated: number) {
|
||||||
|
const cached = cache.get("td")
|
||||||
|
if (cached && cached.chartTime === chartTime) {
|
||||||
|
if (!lastCreated || cached.time >= lastCreated) {
|
||||||
|
return cached.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const startTime = chartTimeData[chartTime].getOffset(now)
|
||||||
|
const ticks = timeTicks(startTime, now, chartTimeData[chartTime].ticks ?? 12).map((date) => date.getTime())
|
||||||
|
const data = {
|
||||||
|
ticks,
|
||||||
|
domain: [chartTimeData[chartTime].getOffset(now).getTime(), now.getTime()],
|
||||||
|
}
|
||||||
|
cache.set("td", { time: now.getTime(), data, chartTime })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// add empty values between records to make gaps if interval is too large
|
||||||
|
function addEmptyValues<T extends SystemStatsRecord | ContainerStatsRecord>(
|
||||||
|
prevRecords: T[],
|
||||||
|
newRecords: T[],
|
||||||
|
expectedInterval: number
|
||||||
|
) {
|
||||||
|
const modifiedRecords: T[] = []
|
||||||
|
let prevTime = (prevRecords.at(-1)?.created ?? 0) as number
|
||||||
|
for (let i = 0; i < newRecords.length; i++) {
|
||||||
|
const record = newRecords[i]
|
||||||
|
record.created = new Date(record.created).getTime()
|
||||||
|
if (prevTime) {
|
||||||
|
const interval = record.created - prevTime
|
||||||
|
// if interval is too large, add a null record
|
||||||
|
if (interval > expectedInterval / 2 + expectedInterval) {
|
||||||
|
// @ts-ignore
|
||||||
|
modifiedRecords.push({ created: null, stats: null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prevTime = record.created
|
||||||
|
modifiedRecords.push(record)
|
||||||
|
}
|
||||||
|
return modifiedRecords
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStats<T>(collection: string, system: SystemRecord, chartTime: ChartTimes): Promise<T[]> {
|
||||||
|
const lastCached = cache.get(`${system.id}_${chartTime}_${collection}`)?.at(-1)?.created as number
|
||||||
|
return await pb.collection<T>(collection).getFullList({
|
||||||
|
filter: pb.filter("system={:id} && created > {:created} && type={:type}", {
|
||||||
|
id: system.id,
|
||||||
|
created: getPbTimestamp(chartTime, lastCached ? new Date(lastCached + 1000) : undefined),
|
||||||
|
type: chartTimeData[chartTime].type,
|
||||||
|
}),
|
||||||
|
fields: "created,stats",
|
||||||
|
sort: "created",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function dockerOrPodman(str: string, system: SystemRecord) {
|
||||||
|
if (system.info.p) {
|
||||||
|
str = str.replace("docker", "podman").replace("Docker", "Podman")
|
||||||
|
}
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
|
||||||
export default function SystemDetail({ name }: { name: string }) {
|
export default function SystemDetail({ name }: { name: string }) {
|
||||||
|
const direction = useStore($direction)
|
||||||
|
const { _ } = useLingui()
|
||||||
const systems = useStore($systems)
|
const systems = useStore($systems)
|
||||||
const chartTime = useStore($chartTime)
|
const chartTime = useStore($chartTime)
|
||||||
const [ticks, setTicks] = useState([] as number[])
|
/** Max CPU toggle value */
|
||||||
|
const cpuMaxStore = useState(false)
|
||||||
|
const bandwidthMaxStore = useState(false)
|
||||||
|
const diskIoMaxStore = useState(false)
|
||||||
|
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 [hasDockerStats, setHasDocker] = useState(false)
|
const [containerData, setContainerData] = useState([] as ChartData["containerData"])
|
||||||
const [dockerCpuChartData, setDockerCpuChartData] = useState<Record<string, number | string>[]>(
|
const netCardRef = useRef<HTMLDivElement>(null)
|
||||||
[]
|
const [containerFilterBar, setContainerFilterBar] = useState(null as null | JSX.Element)
|
||||||
)
|
const [bottomSpacing, setBottomSpacing] = useState(0)
|
||||||
const [dockerMemChartData, setDockerMemChartData] = useState<Record<string, number | string>[]>(
|
const [chartLoading, setChartLoading] = useState(true)
|
||||||
[]
|
const isLongerChart = chartTime !== "1h"
|
||||||
)
|
|
||||||
const [dockerNetChartData, setDockerNetChartData] = useState<Record<string, number | number[]>[]>(
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = `${name} / Beszel`
|
document.title = `${name} / Beszel`
|
||||||
return () => {
|
return () => {
|
||||||
resetCharts()
|
$chartTime.set($userSettings.get().chartTime)
|
||||||
$chartTime.set('1h')
|
// resetCharts()
|
||||||
setHasDocker(false)
|
setSystemStats([])
|
||||||
|
setContainerData([])
|
||||||
|
setContainerFilterBar(null)
|
||||||
|
$containerFilter.set("")
|
||||||
|
cpuMaxStore[1](false)
|
||||||
|
bandwidthMaxStore[1](false)
|
||||||
|
diskIoMaxStore[1](false)
|
||||||
}
|
}
|
||||||
}, [name])
|
}, [name])
|
||||||
|
|
||||||
function resetCharts() {
|
// function resetCharts() {
|
||||||
setSystemStats([])
|
// setSystemStats([])
|
||||||
setDockerCpuChartData([])
|
// setContainerData([])
|
||||||
setDockerMemChartData([])
|
// }
|
||||||
setDockerNetChartData([])
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(resetCharts, [chartTime])
|
// useEffect(resetCharts, [chartTime])
|
||||||
|
|
||||||
|
// find matching system
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (system.id && system.name === name) {
|
if (system.id && system.name === name) {
|
||||||
return
|
return
|
||||||
@@ -72,246 +150,485 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
if (!system.id) {
|
if (!system.id) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pb.collection<SystemRecord>('systems').subscribe(system.id, (e) => {
|
pb.collection<SystemRecord>("systems").subscribe(system.id, (e) => {
|
||||||
setSystem(e.record)
|
setSystem(e.record)
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
pb.collection('systems').unsubscribe(system.id)
|
pb.collection("systems").unsubscribe(system.id)
|
||||||
}
|
}
|
||||||
}, [system])
|
}, [system.id])
|
||||||
|
|
||||||
async function getStats<T>(collection: string): Promise<T[]> {
|
const chartData: ChartData = useMemo(() => {
|
||||||
return await pb.collection<T>(collection).getFullList({
|
const lastCreated = Math.max(
|
||||||
filter: pb.filter('system={:id} && created > {:created} && type={:type}', {
|
(systemStats.at(-1)?.created as number) ?? 0,
|
||||||
id: system.id,
|
(containerData.at(-1)?.created as number) ?? 0
|
||||||
created: getPbTimestamp(chartTime),
|
)
|
||||||
type: chartTimeData[chartTime].type,
|
return {
|
||||||
}),
|
systemStats,
|
||||||
fields: 'created,stats',
|
containerData,
|
||||||
sort: 'created',
|
chartTime,
|
||||||
})
|
orientation: direction === "rtl" ? "right" : "left",
|
||||||
}
|
...getTimeData(chartTime, lastCreated),
|
||||||
|
|
||||||
// add empty values between records to make gaps if interval is too large
|
|
||||||
function addEmptyValues<T extends SystemStatsRecord | ContainerStatsRecord>(
|
|
||||||
records: T[],
|
|
||||||
expectedInterval: number
|
|
||||||
) {
|
|
||||||
const modifiedRecords: T[] = []
|
|
||||||
let prevTime = 0
|
|
||||||
for (let i = 0; i < records.length; i++) {
|
|
||||||
const record = records[i]
|
|
||||||
record.created = new Date(record.created).getTime()
|
|
||||||
if (prevTime) {
|
|
||||||
const interval = record.created - prevTime
|
|
||||||
// if interval is too large, add a null record
|
|
||||||
if (interval - interval * 0.5 > expectedInterval) {
|
|
||||||
// @ts-ignore
|
|
||||||
modifiedRecords.push({ created: null, stats: null })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prevTime = record.created
|
|
||||||
modifiedRecords.push(record)
|
|
||||||
}
|
}
|
||||||
return modifiedRecords
|
}, [systemStats, containerData, direction])
|
||||||
}
|
|
||||||
|
|
||||||
// get stats
|
// get stats
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!system.id || !chartTime) {
|
if (!system.id || !chartTime) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// loading: true
|
||||||
|
setChartLoading(true)
|
||||||
Promise.allSettled([
|
Promise.allSettled([
|
||||||
getStats<SystemStatsRecord>('system_stats'),
|
getStats<SystemStatsRecord>("system_stats", system, chartTime),
|
||||||
getStats<ContainerStatsRecord>('container_stats'),
|
getStats<ContainerStatsRecord>("container_stats", system, chartTime),
|
||||||
]).then(([systemStats, containerStats]) => {
|
]).then(([systemStats, containerStats]) => {
|
||||||
const expectedInterval = chartTimeData[chartTime].expectedInterval
|
// loading: false
|
||||||
if (containerStats.status === 'fulfilled' && containerStats.value.length) {
|
setChartLoading(false)
|
||||||
makeContainerData(addEmptyValues(containerStats.value, expectedInterval))
|
|
||||||
setHasDocker(true)
|
const { expectedInterval } = chartTimeData[chartTime]
|
||||||
|
// make new system stats
|
||||||
|
const ss_cache_key = `${system.id}_${chartTime}_system_stats`
|
||||||
|
let systemData = (cache.get(ss_cache_key) || []) as SystemStatsRecord[]
|
||||||
|
if (systemStats.status === "fulfilled" && systemStats.value.length) {
|
||||||
|
systemData = systemData.concat(addEmptyValues(systemData, systemStats.value, expectedInterval))
|
||||||
|
if (systemData.length > 120) {
|
||||||
|
systemData = systemData.slice(-100)
|
||||||
|
}
|
||||||
|
cache.set(ss_cache_key, systemData)
|
||||||
}
|
}
|
||||||
if (systemStats.status === 'fulfilled') {
|
setSystemStats(systemData)
|
||||||
setSystemStats(addEmptyValues(systemStats.value, expectedInterval))
|
// make new container stats
|
||||||
|
const cs_cache_key = `${system.id}_${chartTime}_container_stats`
|
||||||
|
let containerData = (cache.get(cs_cache_key) || []) as ContainerStatsRecord[]
|
||||||
|
if (containerStats.status === "fulfilled" && containerStats.value.length) {
|
||||||
|
containerData = containerData.concat(addEmptyValues(containerData, containerStats.value, expectedInterval))
|
||||||
|
if (containerData.length > 120) {
|
||||||
|
containerData = containerData.slice(-100)
|
||||||
|
}
|
||||||
|
cache.set(cs_cache_key, containerData)
|
||||||
}
|
}
|
||||||
|
if (containerData.length) {
|
||||||
|
!containerFilterBar && setContainerFilterBar(<ContainerFilterBar />)
|
||||||
|
} else if (containerFilterBar) {
|
||||||
|
setContainerFilterBar(null)
|
||||||
|
}
|
||||||
|
makeContainerData(containerData)
|
||||||
})
|
})
|
||||||
}, [system, chartTime])
|
}, [system, chartTime])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!systemStats.length) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const now = new Date()
|
|
||||||
const startTime = chartTimeData[chartTime].getOffset(now)
|
|
||||||
const scale = scaleTime([startTime.getTime(), now], [0, systemStats.length])
|
|
||||||
setTicks(scale.ticks(chartTimeData[chartTime].ticks).map((d) => d.getTime()))
|
|
||||||
}, [chartTime, systemStats])
|
|
||||||
|
|
||||||
// make container stats for charts
|
// make container stats for charts
|
||||||
const makeContainerData = useCallback((containers: ContainerStatsRecord[]) => {
|
const makeContainerData = useCallback((containers: ContainerStatsRecord[]) => {
|
||||||
// console.log('containers', containers)
|
const containerData = [] as ChartData["containerData"]
|
||||||
const dockerCpuData = []
|
|
||||||
const dockerMemData = []
|
|
||||||
const dockerNetData = []
|
|
||||||
for (let { created, stats } of containers) {
|
for (let { created, stats } of containers) {
|
||||||
if (!created) {
|
if (!created) {
|
||||||
let nullData = { time: null } as unknown
|
// @ts-ignore add null value for gaps
|
||||||
dockerCpuData.push(nullData as Record<string, number | string>)
|
containerData.push({ created: null })
|
||||||
dockerMemData.push(nullData as Record<string, number | string>)
|
|
||||||
dockerNetData.push(nullData as Record<string, number | number[]>)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const time = new Date(created).getTime()
|
created = new Date(created).getTime()
|
||||||
let cpuData = { time } as Record<string, number | string>
|
// @ts-ignore not dealing with this rn
|
||||||
let memData = { time } as Record<string, number | string>
|
let containerStats: ChartData["containerData"][0] = { created }
|
||||||
let netData = { time } as Record<string, number | number[]>
|
|
||||||
for (let container of stats) {
|
for (let container of stats) {
|
||||||
cpuData[container.n] = container.c
|
containerStats[container.n] = container
|
||||||
memData[container.n] = container.m
|
|
||||||
netData[container.n] = [container.ns, container.nr, container.ns + container.nr] // sent, received, total
|
|
||||||
}
|
}
|
||||||
dockerCpuData.push(cpuData)
|
containerData.push(containerStats)
|
||||||
dockerMemData.push(memData)
|
|
||||||
dockerNetData.push(netData)
|
|
||||||
}
|
}
|
||||||
setDockerCpuChartData(dockerCpuData)
|
setContainerData(containerData)
|
||||||
setDockerMemChartData(dockerMemData)
|
|
||||||
setDockerNetChartData(dockerNetData)
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const uptime = useMemo(() => {
|
// values for system info bar
|
||||||
let uptime = system.info?.u || 0
|
const systemInfo = useMemo(() => {
|
||||||
if (uptime < 172800) {
|
if (!system.info) {
|
||||||
return `${Math.trunc(uptime / 3600)} hours`
|
return []
|
||||||
}
|
}
|
||||||
return `${Math.trunc(system.info?.u / 86400)} days`
|
let uptime: React.ReactNode
|
||||||
}, [system.info?.u])
|
if (system.info.u < 172800) {
|
||||||
|
const hours = Math.trunc(system.info.u / 3600)
|
||||||
|
uptime = <Plural value={hours} one="# hour" other="# hours" />
|
||||||
|
} else {
|
||||||
|
uptime = <Plural value={Math.trunc(system.info?.u / 86400)} one="# day" other="# days" />
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ value: system.host, Icon: GlobeIcon },
|
||||||
|
{
|
||||||
|
value: system.info.h,
|
||||||
|
Icon: MonitorIcon,
|
||||||
|
label: "Hostname",
|
||||||
|
// hide if hostname is same as host or name
|
||||||
|
hide: system.info.h === system.host || system.info.h === system.name,
|
||||||
|
},
|
||||||
|
{ value: uptime, Icon: ClockArrowUp, label: t`Uptime` },
|
||||||
|
{ value: system.info.k, Icon: TuxIcon, label: t({ comment: "Linux kernel", message: "Kernel" }) },
|
||||||
|
{
|
||||||
|
value: `${system.info.m} (${system.info.c}c${system.info.t ? `/${system.info.t}t` : ""})`,
|
||||||
|
Icon: CpuIcon,
|
||||||
|
hide: !system.info.m,
|
||||||
|
},
|
||||||
|
] as {
|
||||||
|
value: string | number | undefined
|
||||||
|
label?: string
|
||||||
|
Icon: any
|
||||||
|
hide?: boolean
|
||||||
|
}[]
|
||||||
|
}, [system.info])
|
||||||
|
|
||||||
|
/** Space for tooltip if more than 12 containers */
|
||||||
|
useEffect(() => {
|
||||||
|
if (!netCardRef.current || !containerData.length) {
|
||||||
|
setBottomSpacing(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const tooltipHeight = (Object.keys(containerData[0]).length - 11) * 17.8 - 40
|
||||||
|
const wrapperEl = document.getElementById("chartwrap") as HTMLDivElement
|
||||||
|
const wrapperRect = wrapperEl.getBoundingClientRect()
|
||||||
|
const chartRect = netCardRef.current.getBoundingClientRect()
|
||||||
|
const distanceToBottom = wrapperRect.bottom - chartRect.bottom
|
||||||
|
setBottomSpacing(tooltipHeight - distanceToBottom)
|
||||||
|
}, [netCardRef, containerData])
|
||||||
|
|
||||||
if (!system.id) {
|
if (!system.id) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// if no data, show empty message
|
||||||
|
const dataEmpty = !chartLoading && chartData.systemStats.length === 0
|
||||||
|
const hasGpuData = Object.keys(systemStats.at(-1)?.stats.g ?? {}).length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid lg:grid-cols-2 gap-4 mb-10">
|
<>
|
||||||
<Card className="col-span-full">
|
<div id="chartwrap" className="grid gap-4 mb-10 overflow-x-clip">
|
||||||
<div className="grid lg:flex items-center gap-4 px-4 sm:px-6 pt-3 sm:pt-4 pb-5">
|
{/* system info */}
|
||||||
<div>
|
<Card>
|
||||||
<h1 className="text-[1.6rem] font-semibold mb-1.5">{system.name}</h1>
|
<div className="grid xl:flex gap-4 px-4 sm:px-6 pt-3 sm:pt-4 pb-5">
|
||||||
<div className="flex flex-wrap items-center gap-3 gap-y-2 text-sm opacity-90">
|
<div>
|
||||||
<div className="capitalize flex gap-2 items-center">
|
<h1 className="text-[1.6rem] font-semibold mb-1.5">{system.name}</h1>
|
||||||
<span className={cn('relative flex h-3 w-3')}>
|
<div className="flex flex-wrap items-center gap-3 gap-y-2 text-sm opacity-90">
|
||||||
{system.status === 'up' && (
|
<div className="capitalize flex gap-2 items-center">
|
||||||
|
<span className={cn("relative flex h-3 w-3")}>
|
||||||
|
{system.status === "up" && (
|
||||||
|
<span
|
||||||
|
className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
|
||||||
|
style={{ animationDuration: "1.5s" }}
|
||||||
|
></span>
|
||||||
|
)}
|
||||||
<span
|
<span
|
||||||
className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
|
className={cn("relative inline-flex rounded-full h-3 w-3", {
|
||||||
style={{ animationDuration: '1.5s' }}
|
"bg-green-500": system.status === "up",
|
||||||
|
"bg-red-500": system.status === "down",
|
||||||
|
"bg-primary/40": system.status === "paused",
|
||||||
|
"bg-yellow-500": system.status === "pending",
|
||||||
|
})}
|
||||||
></span>
|
></span>
|
||||||
)}
|
</span>
|
||||||
<span
|
{system.status}
|
||||||
className={cn('relative inline-flex rounded-full h-3 w-3', {
|
</div>
|
||||||
'bg-green-500': system.status === 'up',
|
{systemInfo.map(({ value, label, Icon, hide }, i) => {
|
||||||
'bg-red-500': system.status === 'down',
|
if (hide || !value) {
|
||||||
'bg-primary/40': system.status === 'paused',
|
return null
|
||||||
'bg-yellow-500': system.status === 'pending',
|
}
|
||||||
})}
|
const content = (
|
||||||
></span>
|
<div className="flex gap-1.5 items-center">
|
||||||
</span>
|
<Icon className="h-4 w-4" /> {value}
|
||||||
{system.status}
|
</div>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<div key={i} className="contents">
|
||||||
|
<Separator orientation="vertical" className="h-4 bg-primary/30" />
|
||||||
|
{label ? (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip delayDuration={150}>
|
||||||
|
<TooltipTrigger asChild>{content}</TooltipTrigger>
|
||||||
|
<TooltipContent>{label}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
) : (
|
||||||
|
content
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<Separator orientation="vertical" className="h-4 bg-primary/30" />
|
</div>
|
||||||
<div className="flex gap-1.5">
|
<div className="xl:ms-auto flex items-center gap-2 max-sm:-mb-1">
|
||||||
<GlobeIcon className="h-4 w-4 mt-[1px]" /> {system.host}
|
<ChartTimeSelect className="w-full xl:w-40" />
|
||||||
</div>
|
<TooltipProvider delayDuration={100}>
|
||||||
{system.info?.u && (
|
<Tooltip>
|
||||||
<TooltipProvider>
|
<TooltipTrigger asChild>
|
||||||
<Tooltip>
|
<Button
|
||||||
<Separator orientation="vertical" className="h-4 bg-primary/30" />
|
aria-label={t`Toggle grid`}
|
||||||
<TooltipTrigger asChild>
|
variant="outline"
|
||||||
<div className="flex gap-1.5">
|
size="icon"
|
||||||
<ClockArrowUp className="h-4 w-4 mt-[1px]" /> {uptime}
|
className="hidden xl:flex p-0 text-primary"
|
||||||
</div>
|
onClick={() => setGrid(!grid)}
|
||||||
</TooltipTrigger>
|
>
|
||||||
<TooltipContent>Uptime</TooltipContent>
|
{grid ? (
|
||||||
</Tooltip>
|
<LayoutGridIcon className="h-[1.2rem] w-[1.2rem] opacity-85" />
|
||||||
</TooltipProvider>
|
) : (
|
||||||
)}
|
<Rows className="h-[1.3rem] w-[1.3rem] opacity-85" />
|
||||||
{system.info?.m && (
|
)}
|
||||||
<>
|
</Button>
|
||||||
<Separator orientation="vertical" className="h-4 bg-primary/30" />
|
</TooltipTrigger>
|
||||||
<div className="flex gap-1.5">
|
<TooltipContent>{t`Toggle grid`}</TooltipContent>
|
||||||
<CpuIcon className="h-4 w-4 mt-[1px]" />
|
</Tooltip>
|
||||||
{system.info.m} ({system.info.c}c / {system.info.t}t)
|
</TooltipProvider>
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ChartTimeSelect className="w-full lg:w-40 xl:w-52 ml-auto max-sm:-mb-1" />
|
</Card>
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<ChartCard title="Total CPU Usage" description="Average system-wide CPU utilization">
|
{/* main charts */}
|
||||||
<CpuChart ticks={ticks} systemData={systemStats} />
|
<div className="grid xl:grid-cols-2 gap-4">
|
||||||
</ChartCard>
|
|
||||||
|
|
||||||
{hasDockerStats && (
|
|
||||||
<ChartCard title="Docker CPU Usage" description="CPU utilization of docker containers">
|
|
||||||
<ContainerCpuChart chartData={dockerCpuChartData} ticks={ticks} />
|
|
||||||
</ChartCard>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ChartCard title="Total Memory Usage" description="Precise utilization at the recorded time">
|
|
||||||
<MemChart ticks={ticks} systemData={systemStats} />
|
|
||||||
</ChartCard>
|
|
||||||
|
|
||||||
{hasDockerStats && (
|
|
||||||
<ChartCard title="Docker Memory Usage" description="Memory usage of docker containers">
|
|
||||||
<ContainerMemChart chartData={dockerMemChartData} ticks={ticks} />
|
|
||||||
</ChartCard>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(systemStats.at(-1)?.stats.s ?? 0) > 0 && (
|
|
||||||
<ChartCard title="Swap Usage" description="Swap space used by the system">
|
|
||||||
<SwapChart ticks={ticks} systemData={systemStats} />
|
|
||||||
</ChartCard>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{systemStats.at(-1)?.stats.t && (
|
|
||||||
<ChartCard title="Temperature" description="Temperatures of system sensors">
|
|
||||||
<TemperatureChart ticks={ticks} systemData={systemStats} />
|
|
||||||
</ChartCard>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ChartCard title="Disk Usage" description="Space usage of root partition">
|
|
||||||
<DiskChart ticks={ticks} systemData={systemStats} />
|
|
||||||
</ChartCard>
|
|
||||||
|
|
||||||
<ChartCard title="Disk I/O" description="Throughput of root filesystem">
|
|
||||||
<DiskIoChart ticks={ticks} systemData={systemStats} />
|
|
||||||
</ChartCard>
|
|
||||||
|
|
||||||
<ChartCard title="Bandwidth" description="Network traffic of public interfaces">
|
|
||||||
<BandwidthChart ticks={ticks} systemData={systemStats} />
|
|
||||||
</ChartCard>
|
|
||||||
|
|
||||||
{hasDockerStats && dockerNetChartData.length > 0 && (
|
|
||||||
<>
|
|
||||||
<ChartCard
|
<ChartCard
|
||||||
title="Docker Network I/O"
|
empty={dataEmpty}
|
||||||
description="Includes traffic between internal services"
|
grid={grid}
|
||||||
|
title={_(t`CPU Usage`)}
|
||||||
|
description={t`Average system-wide CPU utilization`}
|
||||||
|
cornerEl={isLongerChart ? <SelectAvgMax store={cpuMaxStore} /> : null}
|
||||||
>
|
>
|
||||||
<ContainerNetChart chartData={dockerNetChartData} ticks={ticks} />
|
<AreaChartDefault chartData={chartData} chartName="CPU Usage" maxToggled={cpuMaxStore[0]} unit="%" />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
{/* add space for tooltip if more than 12 containers */}
|
|
||||||
{Object.keys(dockerNetChartData[0]).length > 12 && (
|
{containerFilterBar && (
|
||||||
<span
|
<ChartCard
|
||||||
className="block"
|
empty={dataEmpty}
|
||||||
style={{
|
grid={grid}
|
||||||
height: (Object.keys(dockerNetChartData[0]).length - 13) * 18,
|
title={dockerOrPodman(t`Docker CPU Usage`, system)}
|
||||||
}}
|
description={t`Average CPU utilization of containers`}
|
||||||
/>
|
cornerEl={containerFilterBar}
|
||||||
|
>
|
||||||
|
<ContainerChart chartData={chartData} dataKey="c" chartName="cpu" />
|
||||||
|
</ChartCard>
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
|
<ChartCard
|
||||||
|
empty={dataEmpty}
|
||||||
|
grid={grid}
|
||||||
|
title={t`Memory Usage`}
|
||||||
|
description={t`Precise utilization at the recorded time`}
|
||||||
|
>
|
||||||
|
<MemChart chartData={chartData} />
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
{containerFilterBar && (
|
||||||
|
<ChartCard
|
||||||
|
empty={dataEmpty}
|
||||||
|
grid={grid}
|
||||||
|
title={dockerOrPodman(t`Docker Memory Usage`, system)}
|
||||||
|
description={dockerOrPodman(t`Memory usage of docker containers`, system)}
|
||||||
|
cornerEl={containerFilterBar}
|
||||||
|
>
|
||||||
|
<ContainerChart chartData={chartData} chartName="mem" dataKey="m" unit=" MB" />
|
||||||
|
</ChartCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ChartCard empty={dataEmpty} grid={grid} title={t`Disk Usage`} description={t`Usage of root partition`}>
|
||||||
|
<DiskChart chartData={chartData} dataKey="stats.du" diskSize={systemStats.at(-1)?.stats.d ?? NaN} />
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
<ChartCard
|
||||||
|
empty={dataEmpty}
|
||||||
|
grid={grid}
|
||||||
|
title={t`Disk I/O`}
|
||||||
|
description={t`Throughput of root filesystem`}
|
||||||
|
cornerEl={isLongerChart ? <SelectAvgMax store={diskIoMaxStore} /> : null}
|
||||||
|
>
|
||||||
|
<AreaChartDefault chartData={chartData} maxToggled={diskIoMaxStore[0]} chartName="dio" />
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
<ChartCard
|
||||||
|
empty={dataEmpty}
|
||||||
|
grid={grid}
|
||||||
|
title={t`Bandwidth`}
|
||||||
|
cornerEl={isLongerChart ? <SelectAvgMax store={bandwidthMaxStore} /> : null}
|
||||||
|
description={t`Network traffic of public interfaces`}
|
||||||
|
>
|
||||||
|
<AreaChartDefault chartData={chartData} maxToggled={bandwidthMaxStore[0]} chartName="bw" />
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
{containerFilterBar && containerData.length > 0 && (
|
||||||
|
<div
|
||||||
|
ref={netCardRef}
|
||||||
|
className={cn({
|
||||||
|
"col-span-full": !grid,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ChartCard
|
||||||
|
empty={dataEmpty}
|
||||||
|
title={dockerOrPodman(t`Docker Network I/O`, system)}
|
||||||
|
description={dockerOrPodman(t`Network traffic of docker containers`, system)}
|
||||||
|
cornerEl={containerFilterBar}
|
||||||
|
>
|
||||||
|
{/* @ts-ignore */}
|
||||||
|
<ContainerChart chartData={chartData} chartName="net" dataKey="n" />
|
||||||
|
</ChartCard>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Swap chart */}
|
||||||
|
{(systemStats.at(-1)?.stats.su ?? 0) > 0 && (
|
||||||
|
<ChartCard
|
||||||
|
empty={dataEmpty}
|
||||||
|
grid={grid}
|
||||||
|
title={t`Swap Usage`}
|
||||||
|
description={t`Swap space used by the system`}
|
||||||
|
>
|
||||||
|
<SwapChart chartData={chartData} />
|
||||||
|
</ChartCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Temperature chart */}
|
||||||
|
{systemStats.at(-1)?.stats.t && (
|
||||||
|
<ChartCard
|
||||||
|
empty={dataEmpty}
|
||||||
|
grid={grid}
|
||||||
|
title={t`Temperature`}
|
||||||
|
description={t`Temperatures of system sensors`}
|
||||||
|
>
|
||||||
|
<TemperatureChart chartData={chartData} />
|
||||||
|
</ChartCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* GPU power draw chart */}
|
||||||
|
{hasGpuData && (
|
||||||
|
<ChartCard
|
||||||
|
empty={dataEmpty}
|
||||||
|
grid={grid}
|
||||||
|
title={t`GPU Power Draw`}
|
||||||
|
description={t`Average power consumption of GPUs`}
|
||||||
|
>
|
||||||
|
<GpuPowerChart chartData={chartData} />
|
||||||
|
</ChartCard>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* GPU charts */}
|
||||||
|
{hasGpuData && (
|
||||||
|
<div className="grid xl:grid-cols-2 gap-4">
|
||||||
|
{Object.keys(systemStats.at(-1)?.stats.g ?? {}).map((id) => {
|
||||||
|
const gpu = systemStats.at(-1)?.stats.g?.[id] as GPUData
|
||||||
|
return (
|
||||||
|
<div key={id} className="contents">
|
||||||
|
<ChartCard
|
||||||
|
empty={dataEmpty}
|
||||||
|
grid={grid}
|
||||||
|
title={`${gpu.n} ${t`Usage`}`}
|
||||||
|
description={t`Average utilization of ${gpu.n}`}
|
||||||
|
>
|
||||||
|
<AreaChartDefault chartData={chartData} chartName={`g.${id}.u`} unit="%" />
|
||||||
|
</ChartCard>
|
||||||
|
<ChartCard
|
||||||
|
empty={dataEmpty}
|
||||||
|
grid={grid}
|
||||||
|
title={`${gpu.n} VRAM`}
|
||||||
|
description={t`Precise utilization at the recorded time`}
|
||||||
|
>
|
||||||
|
<AreaChartDefault
|
||||||
|
chartData={chartData}
|
||||||
|
chartName={`g.${id}.mu`}
|
||||||
|
unit=" MB"
|
||||||
|
max={gpu.mt}
|
||||||
|
tickFormatter={(value) => {
|
||||||
|
const { v, u } = getSizeAndUnit(value, false)
|
||||||
|
return toFixedFloat(v, 1) + u
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* extra filesystem charts */}
|
||||||
|
{Object.keys(systemStats.at(-1)?.stats.efs ?? {}).length > 0 && (
|
||||||
|
<div className="grid xl:grid-cols-2 gap-4">
|
||||||
|
{Object.keys(systemStats.at(-1)?.stats.efs ?? {}).map((extraFsName) => {
|
||||||
|
return (
|
||||||
|
<div key={extraFsName} className="contents">
|
||||||
|
<ChartCard
|
||||||
|
empty={dataEmpty}
|
||||||
|
grid={grid}
|
||||||
|
title={`${extraFsName} ${t`Usage`}`}
|
||||||
|
description={t`Disk usage of ${extraFsName}`}
|
||||||
|
>
|
||||||
|
<DiskChart
|
||||||
|
chartData={chartData}
|
||||||
|
dataKey={`stats.efs.${extraFsName}.du`}
|
||||||
|
diskSize={systemStats.at(-1)?.stats.efs?.[extraFsName].d ?? NaN}
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
<ChartCard
|
||||||
|
empty={dataEmpty}
|
||||||
|
grid={grid}
|
||||||
|
title={`${extraFsName} I/O`}
|
||||||
|
description={t`Throughput of ${extraFsName}`}
|
||||||
|
cornerEl={isLongerChart ? <SelectAvgMax store={diskIoMaxStore} /> : null}
|
||||||
|
>
|
||||||
|
<AreaChartDefault
|
||||||
|
chartData={chartData}
|
||||||
|
maxToggled={diskIoMaxStore[0]}
|
||||||
|
chartName={`efs.${extraFsName}`}
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* add space for tooltip if more than 12 containers */}
|
||||||
|
{bottomSpacing > 0 && <span className="block" style={{ height: bottomSpacing }} />}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContainerFilterBar() {
|
||||||
|
const containerFilter = useStore($containerFilter)
|
||||||
|
const { _ } = useLingui()
|
||||||
|
|
||||||
|
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
$containerFilter.set(e.target.value)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Input placeholder={_(t`Filter...`)} className="ps-4 pe-8" value={containerFilter} onChange={handleChange} />
|
||||||
|
{containerFilter && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Clear"
|
||||||
|
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
|
||||||
|
onClick={() => $containerFilter.set("")}
|
||||||
|
>
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectAvgMax({ store }: { store: [boolean, React.Dispatch<React.SetStateAction<boolean>>] }) {
|
||||||
|
const [max, setMax] = store
|
||||||
|
const Icon = max ? ChartMax : ChartAverage
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select value={max ? "max" : "avg"} onValueChange={(e) => setMax(e === "max")}>
|
||||||
|
<SelectTrigger className="relative ps-10 pe-5">
|
||||||
|
<Icon className="h-4 w-4 absolute start-4 top-1/2 -translate-y-1/2 opacity-85" />
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem key="avg" value="avg">
|
||||||
|
<Trans>Average</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem key="max" value="max">
|
||||||
|
<Trans comment="Chart select field. Please try to keep this short.">Max 1 min</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,26 +636,36 @@ function ChartCard({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
children,
|
children,
|
||||||
|
grid,
|
||||||
|
empty,
|
||||||
|
cornerEl,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
|
grid?: boolean
|
||||||
|
empty?: boolean
|
||||||
|
cornerEl?: JSX.Element | null
|
||||||
}) {
|
}) {
|
||||||
const target = useRef<HTMLDivElement>(null)
|
const { isIntersecting, ref } = useIntersectionObserver()
|
||||||
const [isInViewport, wrappedTargetRef] = useClampedIsInViewport({ target: target })
|
|
||||||
return (
|
return (
|
||||||
<Card className="pb-2 sm:pb-4 even:last-of-type:col-span-full" ref={wrappedTargetRef}>
|
<Card className={cn("pb-2 sm:pb-4 odd:last-of-type:col-span-full", { "col-span-full": !grid })} ref={ref}>
|
||||||
<CardHeader className="pb-5 pt-4 relative space-y-1 max-sm:py-3 max-sm:px-4">
|
<CardHeader className="pb-5 pt-4 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>
|
||||||
{/* <div className="w-full pt-1 sm:w-40 hidden sm:block absolute top-1.5 right-3.5">
|
{cornerEl && <div className="relative py-1 block sm:w-44 sm:absolute sm:top-2.5 sm:end-3.5">{cornerEl}</div>}
|
||||||
<ChartTimeSelect />
|
|
||||||
</div> */}
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pl-0 w-[calc(100%-1.6em)] h-52 relative">
|
<div className="ps-0 w-[calc(100%-1.5em)] h-48 md:h-52 relative group">
|
||||||
{<Spinner />}
|
{
|
||||||
{isInViewport && <Suspense>{children}</Suspense>}
|
<Spinner
|
||||||
</CardContent>
|
msg={empty ? t`Waiting for enough records to display` : undefined}
|
||||||
|
// className="group-has-[.opacity-100]:opacity-0 transition-opacity"
|
||||||
|
className="group-has-[.opacity-100]:invisible duration-100"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
{isIntersecting && children}
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { LoaderCircleIcon } from 'lucide-react'
|
import { cn } from "@/lib/utils"
|
||||||
|
import { LoaderCircleIcon } from "lucide-react"
|
||||||
|
|
||||||
export default function () {
|
export default function ({ msg, className }: { msg?: string; className?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid place-content-center h-full absolute inset-0">
|
<div className={cn(className, "flex flex-col items-center justify-center h-full absolute inset-0")}>
|
||||||
<LoaderCircleIcon className="animate-spin h-10 w-10 opacity-60" />
|
{msg ? (
|
||||||
|
<p className={"opacity-60 mb-2 text-center text-sm px-4"}>{msg}</p>
|
||||||
|
) : (
|
||||||
|
<LoaderCircleIcon className="animate-spin h-10 w-10 opacity-60" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,29 +6,27 @@ import {
|
|||||||
SortingState,
|
SortingState,
|
||||||
getSortedRowModel,
|
getSortedRowModel,
|
||||||
flexRender,
|
flexRender,
|
||||||
|
VisibilityState,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
Column,
|
Column,
|
||||||
} from '@tanstack/react-table'
|
} from "@tanstack/react-table"
|
||||||
|
|
||||||
import {
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@/components/ui/table'
|
|
||||||
|
|
||||||
import { Button, buttonVariants } from '@/components/ui/button'
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -40,227 +38,203 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from '@/components/ui/alert-dialog'
|
} from "@/components/ui/alert-dialog"
|
||||||
|
|
||||||
import { SystemRecord } from '@/types'
|
import { SystemRecord } from "@/types"
|
||||||
import {
|
import {
|
||||||
MoreHorizontal,
|
MoreHorizontalIcon,
|
||||||
ArrowUpDown,
|
ArrowUpDownIcon,
|
||||||
Server,
|
MemoryStickIcon,
|
||||||
Cpu,
|
|
||||||
MemoryStick,
|
|
||||||
HardDrive,
|
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
PauseCircleIcon,
|
PauseCircleIcon,
|
||||||
PlayCircleIcon,
|
PlayCircleIcon,
|
||||||
Trash2Icon,
|
Trash2Icon,
|
||||||
WifiIcon,
|
WifiIcon,
|
||||||
} from 'lucide-react'
|
HardDriveIcon,
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
ServerIcon,
|
||||||
import { $hubVersion, $systems, pb } from '@/lib/stores'
|
CpuIcon,
|
||||||
import { useStore } from '@nanostores/react'
|
LayoutGridIcon,
|
||||||
import { cn, copyToClipboard, isReadOnlyUser } from '@/lib/utils'
|
LayoutListIcon,
|
||||||
import AlertsButton from '../table-alerts'
|
ArrowDownIcon,
|
||||||
import { navigate } from '../router'
|
ArrowUpIcon,
|
||||||
|
Settings2Icon,
|
||||||
|
EyeIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import { $hubVersion, $systems, pb } from "@/lib/stores"
|
||||||
|
import { useStore } from "@nanostores/react"
|
||||||
|
import { cn, copyToClipboard, decimalString, isReadOnlyUser, useLocalStorage } from "@/lib/utils"
|
||||||
|
import AlertsButton from "../alerts/alert-button"
|
||||||
|
import { Link, navigate } from "../router"
|
||||||
|
import { EthernetIcon } from "../ui/icons"
|
||||||
|
import { Trans, t } from "@lingui/macro"
|
||||||
|
import { useLingui } from "@lingui/react"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
|
||||||
|
import { Input } from "../ui/input"
|
||||||
|
import { ClassValue } from "clsx"
|
||||||
|
|
||||||
|
type ViewMode = "table" | "grid"
|
||||||
|
|
||||||
function CellFormatter(info: CellContext<SystemRecord, unknown>) {
|
function CellFormatter(info: CellContext<SystemRecord, unknown>) {
|
||||||
const val = info.getValue() as number
|
const val = info.getValue() as number
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-1 items-center tabular-nums tracking-tight">
|
<div className="flex gap-2 items-center tabular-nums tracking-tight">
|
||||||
<span className="min-w-[3.5em]">{val.toFixed(1)}%</span>
|
<span className="min-w-[3.5em]">{decimalString(val, 1)}%</span>
|
||||||
<span className="grow min-w-10 block bg-muted h-[1em] relative rounded-sm overflow-hidden">
|
<span className="grow min-w-10 block bg-muted h-[1em] relative rounded-sm overflow-hidden">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute inset-0 w-full h-full origin-left',
|
"absolute inset-0 w-full h-full origin-left",
|
||||||
(val < 60 && 'bg-green-500') || (val < 90 && 'bg-yellow-500') || 'bg-red-600'
|
(val < 65 && "bg-green-500") || (val < 90 && "bg-yellow-500") || "bg-red-600"
|
||||||
)}
|
)}
|
||||||
style={{ transform: `scalex(${val}%)` }}
|
style={{
|
||||||
|
transform: `scalex(${val / 100})`,
|
||||||
|
}}
|
||||||
></span>
|
></span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortableHeader(
|
function sortableHeader(column: Column<SystemRecord, unknown>, hideSortIcon = false) {
|
||||||
column: Column<SystemRecord, unknown>,
|
|
||||||
name: string,
|
|
||||||
Icon: any,
|
|
||||||
hideSortIcon = false
|
|
||||||
) {
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-9 px-3"
|
className="h-9 px-3 flex"
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
>
|
>
|
||||||
<Icon className="mr-2 h-4 w-4" />
|
{/* @ts-ignore */}
|
||||||
{name}
|
{column.columnDef?.icon && <column.columnDef.icon className="me-2 size-4" />}
|
||||||
{!hideSortIcon && <ArrowUpDown className="ml-2 h-4 w-4" />}
|
{column.id}
|
||||||
|
{!hideSortIcon && <ArrowUpDownIcon className="ms-2 size-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SystemsTable({ filter }: { filter?: string }) {
|
export default function SystemsTable() {
|
||||||
const data = useStore($systems)
|
const data = useStore($systems)
|
||||||
const hubVersion = useStore($hubVersion)
|
const hubVersion = useStore($hubVersion)
|
||||||
const [sorting, setSorting] = useState<SortingState>([])
|
const [filter, setFilter] = useState<string>()
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([{ id: t`System`, desc: false }])
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||||
|
const [columnVisibility, setColumnVisibility] = useLocalStorage<VisibilityState>("cols", {})
|
||||||
|
const [viewMode, setViewMode] = useLocalStorage<ViewMode>("viewMode", window.innerWidth > 1024 ? "table" : "grid")
|
||||||
|
const { i18n } = useLingui()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (filter !== undefined) {
|
if (filter !== undefined) {
|
||||||
table.getColumn('name')?.setFilterValue(filter)
|
table.getColumn(t`System`)?.setFilterValue(filter)
|
||||||
}
|
}
|
||||||
}, [filter])
|
}, [filter])
|
||||||
|
|
||||||
const columns: ColumnDef<SystemRecord>[] = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
// size: 200,
|
// size: 200,
|
||||||
size: 200,
|
size: 200,
|
||||||
minSize: 0,
|
minSize: 0,
|
||||||
accessorKey: 'name',
|
accessorKey: "name",
|
||||||
cell: (info) => {
|
id: t`System`,
|
||||||
const { status } = info.row.original
|
enableHiding: false,
|
||||||
|
icon: ServerIcon,
|
||||||
|
cell: (info) => (
|
||||||
|
<span className="flex gap-0.5 items-center text-base md:pe-5">
|
||||||
|
<IndicatorDot system={info.row.original} />
|
||||||
|
<Button
|
||||||
|
data-nolink
|
||||||
|
variant={"ghost"}
|
||||||
|
className="text-primary/90 h-7 px-1.5 gap-1.5"
|
||||||
|
onClick={() => copyToClipboard(info.getValue() as string)}
|
||||||
|
>
|
||||||
|
{info.getValue() as string}
|
||||||
|
<CopyIcon className="h-2.5 w-2.5" />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
header: ({ column }) => sortableHeader(column),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "info.cpu",
|
||||||
|
id: t`CPU`,
|
||||||
|
invertSorting: true,
|
||||||
|
cell: CellFormatter,
|
||||||
|
icon: CpuIcon,
|
||||||
|
header: ({ column }) => sortableHeader(column),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "info.mp",
|
||||||
|
id: t`Memory`,
|
||||||
|
invertSorting: true,
|
||||||
|
cell: CellFormatter,
|
||||||
|
icon: MemoryStickIcon,
|
||||||
|
header: ({ column }) => sortableHeader(column),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "info.dp",
|
||||||
|
id: t`Disk`,
|
||||||
|
invertSorting: true,
|
||||||
|
cell: CellFormatter,
|
||||||
|
icon: HardDriveIcon,
|
||||||
|
header: ({ column }) => sortableHeader(column),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorFn: (originalRow) => originalRow.info.b || 0,
|
||||||
|
id: t`Net`,
|
||||||
|
invertSorting: true,
|
||||||
|
size: 115,
|
||||||
|
icon: EthernetIcon,
|
||||||
|
header: ({ column }) => sortableHeader(column),
|
||||||
|
cell(info) {
|
||||||
|
const val = info.getValue() as number
|
||||||
return (
|
return (
|
||||||
<span className="flex gap-0.5 items-center text-base md:pr-5">
|
<span
|
||||||
<span
|
className={cn("tabular-nums whitespace-nowrap", {
|
||||||
className={cn('w-2 h-2 left-0 rounded-full', {
|
"ps-1": viewMode === "table",
|
||||||
'bg-green-500': status === 'up',
|
})}
|
||||||
'bg-red-500': status === 'down',
|
>
|
||||||
'bg-primary/40': status === 'paused',
|
{decimalString(val, val >= 100 ? 1 : 2)} MB/s
|
||||||
'bg-yellow-500': status === 'pending',
|
|
||||||
})}
|
|
||||||
style={{ marginBottom: '-1px' }}
|
|
||||||
></span>
|
|
||||||
<Button
|
|
||||||
data-nolink
|
|
||||||
variant={'ghost'}
|
|
||||||
className="text-foreground/90 h-7 px-1.5 gap-1.5"
|
|
||||||
onClick={() => copyToClipboard(info.getValue() as string)}
|
|
||||||
>
|
|
||||||
{info.getValue() as string}
|
|
||||||
<CopyIcon className="h-2.5 w-2.5" />
|
|
||||||
</Button>
|
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
header: ({ column }) => sortableHeader(column, 'System', Server),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'info.cpu',
|
accessorKey: "info.v",
|
||||||
cell: CellFormatter,
|
id: t`Agent`,
|
||||||
header: ({ column }) => sortableHeader(column, 'CPU', Cpu),
|
invertSorting: true,
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'info.mp',
|
|
||||||
cell: CellFormatter,
|
|
||||||
header: ({ column }) => sortableHeader(column, 'Memory', MemoryStick),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'info.dp',
|
|
||||||
cell: CellFormatter,
|
|
||||||
header: ({ column }) => sortableHeader(column, 'Disk', HardDrive),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'info.v',
|
|
||||||
size: 50,
|
size: 50,
|
||||||
cell: (info) => {
|
icon: WifiIcon,
|
||||||
|
header: ({ column }) => sortableHeader(column, true),
|
||||||
|
cell(info) {
|
||||||
const version = info.getValue() as string
|
const version = info.getValue() as string
|
||||||
if (!version || !hubVersion) {
|
if (!version || !hubVersion) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span className="flex gap-2 items-center md:pr-5 tabular-nums pl-1">
|
<span
|
||||||
<span
|
className={cn("flex gap-2 items-center md:pe-5 tabular-nums", {
|
||||||
className={cn(
|
"ps-1": viewMode === "table",
|
||||||
'w-2 h-2 left-0 rounded-full',
|
})}
|
||||||
version === hubVersion ? 'bg-green-500' : 'bg-yellow-500'
|
>
|
||||||
)}
|
<IndicatorDot
|
||||||
style={{ marginBottom: '-1px' }}
|
system={info.row.original}
|
||||||
></span>
|
className={version === hubVersion ? "bg-green-500" : "bg-yellow-500"}
|
||||||
|
/>
|
||||||
<span>{info.getValue() as string}</span>
|
<span>{info.getValue() as string}</span>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
header: ({ column }) => sortableHeader(column, 'Agent', WifiIcon, true),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: t({ message: "Actions", comment: "Table column" }),
|
||||||
size: 120,
|
size: 120,
|
||||||
// minSize: 0,
|
cell: ({ row }) => (
|
||||||
cell: ({ row }) => {
|
<div className="flex justify-end items-center gap-1">
|
||||||
const { id, name, status, host } = row.original
|
<AlertsButton system={row.original} />
|
||||||
return (
|
<ActionsButton system={row.original} />
|
||||||
<div className={'flex justify-end items-center gap-1'}>
|
</div>
|
||||||
<AlertsButton system={row.original} />
|
),
|
||||||
<AlertDialog>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size={'icon'} data-nolink>
|
|
||||||
<span className="sr-only">Open menu</span>
|
|
||||||
<MoreHorizontal className="w-5" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem
|
|
||||||
className={cn(isReadOnlyUser() && 'hidden')}
|
|
||||||
onClick={() => {
|
|
||||||
pb.collection('systems').update(id, {
|
|
||||||
status: status === 'paused' ? 'pending' : 'paused',
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{status === 'paused' ? (
|
|
||||||
<>
|
|
||||||
<PlayCircleIcon className="mr-2.5 h-4 w-4" />
|
|
||||||
Resume
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<PauseCircleIcon className="mr-2.5 h-4 w-4" />
|
|
||||||
Pause
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => copyToClipboard(host)}>
|
|
||||||
<CopyIcon className="mr-2.5 h-4 w-4" />
|
|
||||||
Copy host
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator className={cn(isReadOnlyUser() && 'hidden')} />
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<DropdownMenuItem className={cn(isReadOnlyUser() && 'hidden')}>
|
|
||||||
<Trash2Icon className="mr-2.5 h-4 w-4" />
|
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Are you sure you want to delete {name}?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
This action cannot be undone. This will permanently delete all current records
|
|
||||||
for <code className={'bg-muted rounded-sm px-1'}>{name}</code> from the
|
|
||||||
database.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
className={cn(buttonVariants({ variant: 'destructive' }))}
|
|
||||||
onClick={() => pb.collection('systems').delete(id)}
|
|
||||||
>
|
|
||||||
Continue
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
]
|
] as ColumnDef<SystemRecord>[]
|
||||||
}, [hubVersion])
|
}, [hubVersion, i18n.locale])
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data,
|
||||||
@@ -270,9 +244,11 @@ export default function SystemsTable({ filter }: { filter?: string }) {
|
|||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
onColumnFiltersChange: setColumnFilters,
|
onColumnFiltersChange: setColumnFilters,
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
state: {
|
state: {
|
||||||
sorting,
|
sorting,
|
||||||
columnFilters,
|
columnFilters,
|
||||||
|
columnVisibility,
|
||||||
},
|
},
|
||||||
defaultColumn: {
|
defaultColumn: {
|
||||||
minSize: 0,
|
minSize: 0,
|
||||||
@@ -282,64 +258,336 @@ export default function SystemsTable({ filter }: { filter?: string }) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border overflow-hidden">
|
<Card>
|
||||||
<Table>
|
<CardHeader className="pb-5 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
|
||||||
<TableHeader className="bg-muted/40">
|
<div className="grid md:flex gap-5 w-full items-end">
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
<div className="px-2 sm:px-1">
|
||||||
<TableRow key={headerGroup.id}>
|
<CardTitle className="mb-2.5">
|
||||||
{headerGroup.headers.map((header) => {
|
<Trans>All Systems</Trans>
|
||||||
return (
|
</CardTitle>
|
||||||
<TableHead className="px-2" key={header.id}>
|
<CardDescription>
|
||||||
{header.isPlaceholder
|
<Trans>Updated in real time. Click on a system to view information.</Trans>
|
||||||
? null
|
</CardDescription>
|
||||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
</div>
|
||||||
</TableHead>
|
<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" />
|
||||||
})}
|
<DropdownMenu>
|
||||||
</TableRow>
|
<DropdownMenuTrigger asChild>
|
||||||
))}
|
<Button variant="outline">
|
||||||
</TableHeader>
|
<Settings2Icon className="me-1.5 size-4 opacity-80" />
|
||||||
<TableBody>
|
<Trans>View</Trans>
|
||||||
{table.getRowModel().rows?.length ? (
|
</Button>
|
||||||
table.getRowModel().rows.map((row) => (
|
</DropdownMenuTrigger>
|
||||||
<TableRow
|
<DropdownMenuContent align="end" className="h-72 md:h-auto min-w-48 md:min-w-auto overflow-y-auto">
|
||||||
key={row.original.id}
|
<div className="grid grid-cols-1 md:grid-cols-3 divide-y md:divide-s md:divide-y-0">
|
||||||
data-state={row.getIsSelected() && 'selected'}
|
<div>
|
||||||
className={cn('cursor-pointer transition-opacity', {
|
<DropdownMenuLabel className="pt-2 px-3.5 flex items-center gap-2">
|
||||||
'opacity-50': row.original.status === 'paused',
|
<LayoutGridIcon className="size-4" />
|
||||||
})}
|
<Trans>Layout</Trans>
|
||||||
onClick={(e) => {
|
</DropdownMenuLabel>
|
||||||
const target = e.target as HTMLElement
|
<DropdownMenuSeparator />
|
||||||
if (!target.closest('[data-nolink]') && e.currentTarget.contains(target)) {
|
<DropdownMenuRadioGroup
|
||||||
navigate(`/system/${encodeURIComponent(row.original.name)}`)
|
className="px-1 pb-1"
|
||||||
}
|
value={viewMode}
|
||||||
}}
|
onValueChange={(view) => setViewMode(view as ViewMode)}
|
||||||
>
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
<DropdownMenuRadioItem value="table" onSelect={(e) => e.preventDefault()} className="gap-2">
|
||||||
<TableCell
|
<LayoutListIcon className="size-4" />
|
||||||
key={cell.id}
|
<Trans>Table</Trans>
|
||||||
style={{
|
</DropdownMenuRadioItem>
|
||||||
width:
|
<DropdownMenuRadioItem value="grid" onSelect={(e) => e.preventDefault()} className="gap-2">
|
||||||
cell.column.getSize() === Number.MAX_SAFE_INTEGER
|
<LayoutGridIcon className="size-4" />
|
||||||
? 'auto'
|
<Trans>Grid</Trans>
|
||||||
: cell.column.getSize(),
|
</DropdownMenuRadioItem>
|
||||||
}}
|
</DropdownMenuRadioGroup>
|
||||||
className={'overflow-hidden relative py-2.5'}
|
</div>
|
||||||
>
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
<div>
|
||||||
</TableCell>
|
<DropdownMenuLabel className="pt-2 px-3.5 flex items-center gap-2">
|
||||||
|
<ArrowUpDownIcon className="size-4" />
|
||||||
|
<Trans>Sort By</Trans>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<div className="px-1 pb-1">
|
||||||
|
{table.getAllColumns().map((column) => {
|
||||||
|
if (column.id === t`Actions` || !column.getCanSort()) return null
|
||||||
|
let Icon = <span className="w-6"></span>
|
||||||
|
// if current sort column, show sort direction
|
||||||
|
if (sorting[0]?.id === column.id) {
|
||||||
|
if (sorting[0]?.desc) {
|
||||||
|
Icon = <ArrowUpIcon className="me-2 size-4" />
|
||||||
|
} else {
|
||||||
|
Icon = <ArrowDownIcon className="me-2 size-4" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setSorting([{ id: column.id, desc: sorting[0]?.id === column.id && !sorting[0]?.desc }])
|
||||||
|
}}
|
||||||
|
key={column.id}
|
||||||
|
>
|
||||||
|
{Icon}
|
||||||
|
{column.id}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<DropdownMenuLabel className="pt-2 px-3.5 flex items-center gap-2">
|
||||||
|
<EyeIcon className="size-4" />
|
||||||
|
<Trans>Visible Fields</Trans>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<div className="px-1.5 pb-1">
|
||||||
|
{table
|
||||||
|
.getAllColumns()
|
||||||
|
.filter((column) => column.getCanHide())
|
||||||
|
.map((column) => {
|
||||||
|
return (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={column.id}
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
checked={column.getIsVisible()}
|
||||||
|
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||||
|
>
|
||||||
|
{column.id}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<div className="p-6 pt-0 max-sm:py-3 max-sm:px-2">
|
||||||
|
{viewMode === "table" ? (
|
||||||
|
// table layout
|
||||||
|
<div className="rounded-md border overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
return (
|
||||||
|
<TableHead className="px-2" key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</TableHead>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableHeader>
|
||||||
))
|
<TableBody>
|
||||||
) : (
|
{table.getRowModel().rows?.length ? (
|
||||||
<TableRow>
|
table.getRowModel().rows.map((row) => (
|
||||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
<TableRow
|
||||||
No systems found
|
key={row.original.id}
|
||||||
</TableCell>
|
data-state={row.getIsSelected() && "selected"}
|
||||||
</TableRow>
|
className={cn("cursor-pointer transition-opacity", {
|
||||||
)}
|
"opacity-50": row.original.status === "paused",
|
||||||
</TableBody>
|
})}
|
||||||
</Table>
|
onClick={(e) => {
|
||||||
</div>
|
const target = e.target as HTMLElement
|
||||||
|
if (!target.closest("[data-nolink]") && e.currentTarget.contains(target)) {
|
||||||
|
navigate(`/system/${encodeURIComponent(row.original.name)}`)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell
|
||||||
|
key={cell.id}
|
||||||
|
style={{
|
||||||
|
width: cell.column.getSize() === Number.MAX_SAFE_INTEGER ? "auto" : cell.column.getSize(),
|
||||||
|
}}
|
||||||
|
className={cn("overflow-hidden relative", data.length > 10 ? "py-2" : "py-2.5")}
|
||||||
|
>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||||
|
<Trans>No systems found.</Trans>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// grid layout
|
||||||
|
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => {
|
||||||
|
const system = row.original
|
||||||
|
const { status } = system
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={system.id}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer hover:shadow-md transition-all bg-transparent w-full dark:border-border duration-200 relative",
|
||||||
|
{
|
||||||
|
"opacity-50": status === "paused",
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CardHeader className="py-1 ps-5 pe-3 bg-muted/30 border-b border-border/60">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<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">
|
||||||
|
<IndicatorDot system={system} />
|
||||||
|
<CardTitle className="text-[.95em]/normal tracking-normal truncate text-primary/90">
|
||||||
|
{system.name}
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardTitle>
|
||||||
|
{table.getColumn(t`Actions`)?.getIsVisible() && (
|
||||||
|
<div className="flex gap-1 flex-shrink-0 relative z-10">
|
||||||
|
<AlertsButton system={system} />
|
||||||
|
<ActionsButton system={system} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2.5 text-sm px-5 pt-3.5 pb-4">
|
||||||
|
{table.getAllColumns().map((column) => {
|
||||||
|
if (!column.getIsVisible() || column.id === t`System` || column.id === t`Actions`) return null
|
||||||
|
const cell = row.getAllCells().find((cell) => cell.column.id === column.id)
|
||||||
|
if (!cell) return null
|
||||||
|
return (
|
||||||
|
<div key={column.id} className="flex items-center gap-3">
|
||||||
|
{/* @ts-ignore */}
|
||||||
|
{column.columnDef?.icon && (
|
||||||
|
// @ts-ignore
|
||||||
|
<column.columnDef.icon className="size-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3 flex-1">
|
||||||
|
<span className="text-muted-foreground min-w-16">{column.id}:</span>
|
||||||
|
<div className="flex-1">{flexRender(cell.column.columnDef.cell, cell.getContext())}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
|
<Link
|
||||||
|
href={`/system/${encodeURIComponent(row.original.name)}`}
|
||||||
|
className="inset-0 absolute w-full h-full"
|
||||||
|
>
|
||||||
|
<span className="sr-only">{row.original.name}</span>
|
||||||
|
</Link>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="col-span-full text-center py-8">
|
||||||
|
<Trans>No systems found.</Trans>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function IndicatorDot({ system, className }: { system: SystemRecord; className?: ClassValue }) {
|
||||||
|
className ||= {
|
||||||
|
"bg-green-500": system.status === "up",
|
||||||
|
"bg-red-500": system.status === "down",
|
||||||
|
"bg-primary/40": system.status === "paused",
|
||||||
|
"bg-yellow-500": system.status === "pending",
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn("flex-shrink-0 size-2 rounded-full", className)}
|
||||||
|
// style={{ marginBottom: "-1px" }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionsButton({ system }: { system: SystemRecord }) {
|
||||||
|
// const [opened, setOpened] = useState(false)
|
||||||
|
const { id, status, host, name } = system
|
||||||
|
return (
|
||||||
|
<AlertDialog>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size={"icon"} data-nolink>
|
||||||
|
<span className="sr-only">
|
||||||
|
<Trans>Open menu</Trans>
|
||||||
|
</span>
|
||||||
|
<MoreHorizontalIcon className="w-5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
className={cn(isReadOnlyUser() && "hidden")}
|
||||||
|
onClick={() => {
|
||||||
|
pb.collection("systems").update(id, {
|
||||||
|
status: status === "paused" ? "pending" : "paused",
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status === "paused" ? (
|
||||||
|
<>
|
||||||
|
<PlayCircleIcon className="me-2.5 size-4" />
|
||||||
|
<Trans>Resume</Trans>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<PauseCircleIcon className="me-2.5 size-4" />
|
||||||
|
<Trans>Pause</Trans>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => copyToClipboard(host)}>
|
||||||
|
<CopyIcon className="me-2.5 size-4" />
|
||||||
|
<Trans>Copy host</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator className={cn(isReadOnlyUser() && "hidden")} />
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<DropdownMenuItem className={cn(isReadOnlyUser() && "hidden")}>
|
||||||
|
<Trash2Icon className="me-2.5 size-4" />
|
||||||
|
<Trans>Delete</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
<Trans>Are you sure you want to delete {name}?</Trans>
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
<Trans>
|
||||||
|
This action cannot be undone. This will permanently delete all current records for {name} from the
|
||||||
|
database.
|
||||||
|
</Trans>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className={cn(buttonVariants({ variant: "destructive" }))}
|
||||||
|
onClick={() => pb.collection("systems").delete(id)}
|
||||||
|
>
|
||||||
|
<Trans>Continue</Trans>
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,234 +0,0 @@
|
|||||||
import { $alerts, pb } from '@/lib/stores'
|
|
||||||
import { useStore } from '@nanostores/react'
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogTrigger,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import { BellIcon } from 'lucide-react'
|
|
||||||
import { cn, isAdmin } from '@/lib/utils'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Switch } from '@/components/ui/switch'
|
|
||||||
import { AlertRecord, SystemRecord } from '@/types'
|
|
||||||
import { lazy, Suspense, useMemo, useState } from 'react'
|
|
||||||
import { toast } from './ui/use-toast'
|
|
||||||
|
|
||||||
const Slider = lazy(() => import('./ui/slider'))
|
|
||||||
|
|
||||||
const failedUpdateToast = () =>
|
|
||||||
toast({
|
|
||||||
title: 'Failed to update alert',
|
|
||||||
description: 'Please check logs for more details.',
|
|
||||||
variant: 'destructive',
|
|
||||||
})
|
|
||||||
|
|
||||||
export default function AlertsButton({ system }: { system: SystemRecord }) {
|
|
||||||
const alerts = useStore($alerts)
|
|
||||||
|
|
||||||
const active = useMemo(() => {
|
|
||||||
return alerts.find((alert) => alert.system === system.id)
|
|
||||||
}, [alerts, system])
|
|
||||||
|
|
||||||
const systemAlerts = useMemo(() => {
|
|
||||||
return alerts.filter((alert) => alert.system === system.id) as AlertRecord[]
|
|
||||||
}, [alerts, system])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button variant="ghost" size={'icon'} aria-label="Alerts" data-nolink>
|
|
||||||
<BellIcon
|
|
||||||
className={cn('h-[1.2em] w-[1.2em] pointer-events-none', {
|
|
||||||
'fill-foreground': active,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="max-h-full overflow-auto">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="mb-1">Alerts for {system.name}</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{isAdmin() && (
|
|
||||||
<span>
|
|
||||||
Please{' '}
|
|
||||||
<a
|
|
||||||
href="/_/#/settings/mail"
|
|
||||||
className="font-medium text-primary opacity-80 hover:opacity-100 duration-100"
|
|
||||||
>
|
|
||||||
configure an SMTP server
|
|
||||||
</a>{' '}
|
|
||||||
to ensure alerts are delivered.{' '}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="grid gap-3">
|
|
||||||
<AlertStatus system={system} alerts={systemAlerts} />
|
|
||||||
<AlertWithSlider
|
|
||||||
system={system}
|
|
||||||
alerts={systemAlerts}
|
|
||||||
name="CPU"
|
|
||||||
title="CPU Usage"
|
|
||||||
description="Triggers when CPU usage exceeds a threshold."
|
|
||||||
/>
|
|
||||||
<AlertWithSlider
|
|
||||||
system={system}
|
|
||||||
alerts={systemAlerts}
|
|
||||||
name="Memory"
|
|
||||||
title="Memory Usage"
|
|
||||||
description="Triggers when memory usage exceeds a threshold."
|
|
||||||
/>
|
|
||||||
<AlertWithSlider
|
|
||||||
system={system}
|
|
||||||
alerts={systemAlerts}
|
|
||||||
name="Disk"
|
|
||||||
title="Disk Usage"
|
|
||||||
description="Triggers when disk usage exceeds a threshold."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AlertStatus({ system, alerts }: { system: SystemRecord; alerts: AlertRecord[] }) {
|
|
||||||
const [pendingChange, setPendingChange] = useState(false)
|
|
||||||
|
|
||||||
const alert = useMemo(() => {
|
|
||||||
return alerts.find((alert) => alert.name === 'Status')
|
|
||||||
}, [alerts])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
htmlFor="alert-status"
|
|
||||||
className="flex flex-row items-center justify-between gap-4 rounded-lg border p-4 cursor-pointer"
|
|
||||||
>
|
|
||||||
<div className="grid gap-1 select-none">
|
|
||||||
<p className="font-semibold">System Status</p>
|
|
||||||
<span className="block text-sm text-foreground opacity-80">
|
|
||||||
Triggers when status switches between up and down.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
id="alert-status"
|
|
||||||
className={cn('transition-opacity', pendingChange && 'opacity-40')}
|
|
||||||
checked={!!alert}
|
|
||||||
value={!!alert ? 'on' : 'off'}
|
|
||||||
onCheckedChange={async (active) => {
|
|
||||||
if (pendingChange) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setPendingChange(true)
|
|
||||||
try {
|
|
||||||
if (!active && alert) {
|
|
||||||
await pb.collection('alerts').delete(alert.id)
|
|
||||||
} else if (active) {
|
|
||||||
pb.collection('alerts').create({
|
|
||||||
system: system.id,
|
|
||||||
user: pb.authStore.model!.id,
|
|
||||||
name: 'Status',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
failedUpdateToast()
|
|
||||||
} finally {
|
|
||||||
setPendingChange(false)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AlertWithSlider({
|
|
||||||
system,
|
|
||||||
alerts,
|
|
||||||
name,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
}: {
|
|
||||||
system: SystemRecord
|
|
||||||
alerts: AlertRecord[]
|
|
||||||
name: string
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
}) {
|
|
||||||
const [pendingChange, setPendingChange] = useState(false)
|
|
||||||
const [liveValue, setLiveValue] = useState(50)
|
|
||||||
|
|
||||||
const alert = useMemo(() => {
|
|
||||||
const alert = alerts.find((alert) => alert.name === name)
|
|
||||||
if (alert) {
|
|
||||||
setLiveValue(alert.value)
|
|
||||||
}
|
|
||||||
return alert
|
|
||||||
}, [alerts])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border">
|
|
||||||
<label
|
|
||||||
htmlFor={`alert-${name}`}
|
|
||||||
className={cn('flex flex-row items-center justify-between gap-4 cursor-pointer p-4', {
|
|
||||||
'pb-0': !!alert,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<div className="grid gap-1 select-none">
|
|
||||||
<p className="font-semibold">{title}</p>
|
|
||||||
<span className="block text-sm text-foreground opacity-80">{description}</span>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
id={`alert-${name}`}
|
|
||||||
className={cn('transition-opacity', pendingChange && 'opacity-40')}
|
|
||||||
checked={!!alert}
|
|
||||||
value={!!alert ? 'on' : 'off'}
|
|
||||||
onCheckedChange={async (active) => {
|
|
||||||
if (pendingChange) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setPendingChange(true)
|
|
||||||
try {
|
|
||||||
if (!active && alert) {
|
|
||||||
await pb.collection('alerts').delete(alert.id)
|
|
||||||
} else if (active) {
|
|
||||||
pb.collection('alerts').create({
|
|
||||||
system: system.id,
|
|
||||||
user: pb.authStore.model!.id,
|
|
||||||
name,
|
|
||||||
value: liveValue,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
failedUpdateToast()
|
|
||||||
} finally {
|
|
||||||
setPendingChange(false)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{alert && (
|
|
||||||
<div className="flex mt-2 mb-3 gap-3 px-4">
|
|
||||||
<Suspense>
|
|
||||||
<Slider
|
|
||||||
defaultValue={[liveValue]}
|
|
||||||
onValueCommit={(val) => {
|
|
||||||
pb.collection('alerts').update(alert.id, {
|
|
||||||
value: val[0],
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
onValueChange={(val) => {
|
|
||||||
setLiveValue(val[0])
|
|
||||||
}}
|
|
||||||
min={10}
|
|
||||||
max={99}
|
|
||||||
// step={1}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
<span className="tabular-nums tracking-tighter text-[.92em]">{liveValue}%</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createContext, useContext, useEffect, useState } from 'react'
|
import { createContext, useContext, useEffect, useState } from "react"
|
||||||
|
|
||||||
type Theme = 'dark' | 'light' | 'system'
|
type Theme = "dark" | "light" | "system"
|
||||||
|
|
||||||
type ThemeProviderProps = {
|
type ThemeProviderProps = {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
@@ -14,7 +14,7 @@ type ThemeProviderState = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const initialState: ThemeProviderState = {
|
const initialState: ThemeProviderState = {
|
||||||
theme: 'system',
|
theme: "system",
|
||||||
setTheme: () => null,
|
setTheme: () => null,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,23 +22,19 @@ const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
|
|||||||
|
|
||||||
export function ThemeProvider({
|
export function ThemeProvider({
|
||||||
children,
|
children,
|
||||||
defaultTheme = 'system',
|
defaultTheme = "system",
|
||||||
storageKey = 'ui-theme',
|
storageKey = "ui-theme",
|
||||||
...props
|
...props
|
||||||
}: ThemeProviderProps) {
|
}: ThemeProviderProps) {
|
||||||
const [theme, setTheme] = useState<Theme>(
|
const [theme, setTheme] = useState<Theme>(() => (localStorage.getItem(storageKey) as Theme) || defaultTheme)
|
||||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = window.document.documentElement
|
const root = window.document.documentElement
|
||||||
|
|
||||||
root.classList.remove('light', 'dark')
|
root.classList.remove("light", "dark")
|
||||||
|
|
||||||
if (theme === 'system') {
|
if (theme === "system") {
|
||||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
|
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
|
||||||
? 'dark'
|
|
||||||
: 'light'
|
|
||||||
|
|
||||||
root.classList.add(systemTheme)
|
root.classList.add(systemTheme)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import * as React from 'react'
|
import * as React from "react"
|
||||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from "@/lib/utils"
|
||||||
import { buttonVariants } from '@/components/ui/button'
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
const AlertDialog = AlertDialogPrimitive.Root
|
const AlertDialog = AlertDialogPrimitive.Root
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ const AlertDialogOverlay = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<AlertDialogPrimitive.Overlay
|
<AlertDialogPrimitive.Overlay
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in 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 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -34,7 +34,7 @@ const AlertDialogContent = React.forwardRef<
|
|||||||
<AlertDialogPrimitive.Content
|
<AlertDialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-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,27 +44,20 @@ const AlertDialogContent = React.forwardRef<
|
|||||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||||
|
|
||||||
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
|
<div className={cn("flex flex-col space-y-2 text-center sm:text-start", className)} {...props} />
|
||||||
)
|
)
|
||||||
AlertDialogHeader.displayName = 'AlertDialogHeader'
|
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||||
|
|
||||||
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
<div
|
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-2", className)} {...props} />
|
||||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
)
|
||||||
AlertDialogFooter.displayName = 'AlertDialogFooter'
|
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||||
|
|
||||||
const AlertDialogTitle = React.forwardRef<
|
const AlertDialogTitle = React.forwardRef<
|
||||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<AlertDialogPrimitive.Title
|
<AlertDialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
|
||||||
ref={ref}
|
|
||||||
className={cn('text-lg font-semibold', className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
))
|
||||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||||
|
|
||||||
@@ -72,11 +65,7 @@ const AlertDialogDescription = React.forwardRef<
|
|||||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<AlertDialogPrimitive.Description
|
<AlertDialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||||
ref={ref}
|
|
||||||
className={cn('text-sm text-muted-foreground', className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
))
|
||||||
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName
|
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName
|
||||||
|
|
||||||
@@ -94,7 +83,7 @@ const AlertDialogCancel = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<AlertDialogPrimitive.Cancel
|
<AlertDialogPrimitive.Cancel
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
|
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
|
|||||||
54
beszel/site/src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
// import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// const alertVariants = cva(
|
||||||
|
// "relative w-full rounded-lg border p-4 [&>svg~*]:ps-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||||
|
// {
|
||||||
|
// variants: {
|
||||||
|
// variant: {
|
||||||
|
// default: "bg-background text-foreground",
|
||||||
|
// destructive:
|
||||||
|
// "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// defaultVariants: {
|
||||||
|
// variant: "default",
|
||||||
|
// },
|
||||||
|
// }
|
||||||
|
// )
|
||||||
|
|
||||||
|
const Alert = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
// React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||||
|
// >(({ className, variant, ...props }, ref) => (
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="alert"
|
||||||
|
className={cn(
|
||||||
|
"relative w-full rounded-lg border p-4 [&>svg~*]:ps-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground bg-background text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Alert.displayName = "Alert"
|
||||||
|
|
||||||
|
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<h5 ref={ref} className={cn("mb-1 -mt-0.5 font-medium leading-tight tracking-tight", className)} {...props} />
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AlertTitle.displayName = "AlertTitle"
|
||||||
|
|
||||||
|
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AlertDescription.displayName = "AlertDescription"
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription }
|
||||||
29
beszel/site/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
|
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||||
|
outline: "text-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
@@ -1,31 +1,31 @@
|
|||||||
import * as React from 'react'
|
import * as React from "react"
|
||||||
import { Slot } from '@radix-ui/react-slot'
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
import { cva, type VariantProps } from 'class-variance-authority'
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-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: {
|
||||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
outline: "border bg-background hover:bg-accent/70 dark:hover:bg-accent/50 hover:text-accent-foreground",
|
||||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
link: 'text-primary underline-offset-4 hover:underline',
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: 'h-10 px-4 py-2',
|
default: "h-10 px-4 py-2",
|
||||||
sm: 'h-9 rounded-md px-3',
|
sm: "h-9 rounded-md px-3",
|
||||||
lg: 'h-11 rounded-md px-8',
|
lg: "h-11 rounded-md px-8",
|
||||||
icon: 'h-10 w-10',
|
icon: "h-10 w-10",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: 'default',
|
variant: "default",
|
||||||
size: 'default',
|
size: "default",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -38,12 +38,10 @@ export interface ButtonProps
|
|||||||
|
|
||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
const Comp = asChild ? Slot : 'button'
|
const Comp = asChild ? Slot : "button"
|
||||||
return (
|
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||||
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
Button.displayName = 'Button'
|
Button.displayName = "Button"
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
export { Button, buttonVariants }
|
||||||
|
|||||||
@@ -2,78 +2,44 @@ import * as React from "react"
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const Card = React.forwardRef<
|
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||||
HTMLDivElement,
|
<div
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
ref={ref}
|
||||||
>(({ className, ...props }, ref) => (
|
className={cn("rounded-lg border border-border/60 bg-card text-card-foreground shadow-sm", className)}
|
||||||
<div
|
{...props}
|
||||||
ref={ref}
|
/>
|
||||||
className={cn(
|
|
||||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
))
|
||||||
Card.displayName = "Card"
|
Card.displayName = "Card"
|
||||||
|
|
||||||
const CardHeader = React.forwardRef<
|
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
HTMLDivElement,
|
({ className, ...props }, ref) => (
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
<div ref={ref} className={cn("flex flex-col space-y-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"
|
||||||
|
|
||||||
const CardTitle = React.forwardRef<
|
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||||
HTMLParagraphElement,
|
({ className, ...props }, ref) => (
|
||||||
React.HTMLAttributes<HTMLHeadingElement>
|
<h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} />
|
||||||
>(({ className, ...props }, ref) => (
|
)
|
||||||
<h3
|
)
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"text-2xl font-semibold leading-none tracking-tight",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
CardTitle.displayName = "CardTitle"
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
const CardDescription = React.forwardRef<
|
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||||
HTMLParagraphElement,
|
({ className, ...props }, ref) => (
|
||||||
React.HTMLAttributes<HTMLParagraphElement>
|
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||||
>(({ className, ...props }, ref) => (
|
)
|
||||||
<p
|
)
|
||||||
ref={ref}
|
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
CardDescription.displayName = "CardDescription"
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
const CardContent = React.forwardRef<
|
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
HTMLDivElement,
|
({ className, ...props }, ref) => <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
)
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
|
||||||
))
|
|
||||||
CardContent.displayName = "CardContent"
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
const CardFooter = React.forwardRef<
|
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
HTMLDivElement,
|
({ className, ...props }, ref) => <div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
)
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={cn("flex items-center p-6 pt-0", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
CardFooter.displayName = "CardFooter"
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
|
|||||||