Compare commits

..

7 Commits

Author SHA1 Message Date
henrygd
86ea23fe39 refactor install-agent.sh for freebsd
- Updated paths for FreeBSD installation, changing default directories from /opt/beszel-agent to /usr/local/etc/beszel-agent and /usr/local/sbin.
- Introduced conditional path setting based on the operating system.
- Updated cron job creation to use /etc/cron.d
2025-09-08 19:46:39 -04:00
henrygd
a284dd74dd add time format (12h, 24h) settings (#424)
- Some biome formatting :/
- Changed chartTime update to use listenkeys
2025-09-08 17:12:43 -04:00
Alexander Mnich
6a0075291c [FIX] OpenWRT auto update (#1155)
* switch to root crontab for OpenWRT auto update

* fix: OpenWRT auto update restart condition
2025-09-08 16:50:39 -04:00
henrygd
f542bc70a1 add biome and apply selected formatting / fixes 2025-09-08 15:22:04 -04:00
henrygd
270e59d9ea add support for otp and mfa 2025-09-07 20:46:34 -04:00
henrygd
0d97a604f8 rename main.go to beszel.go 2025-09-07 17:34:56 -04:00
henrygd
f6078fc232 fix govulncheck workflow 2025-09-07 16:52:46 -04:00
55 changed files with 814 additions and 477 deletions

View File

@@ -29,5 +29,5 @@ jobs:
run: go install golang.org/x/vuln/cmd/govulncheck@latest
shell: bash
- name: Run govulncheck
run: govulncheck -C ./beszel -show verbose ./...
run: govulncheck -show verbose ./...
shell: bash

View File

38
src/site/biome.json Normal file
View File

@@ -0,0 +1,38 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.3/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
"useIgnoreFile": false
},
"files": {
"ignoreUnknown": false
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
"indentWidth": 2,
"lineWidth": 120
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"semicolons": "asNeeded",
"trailingCommas": "es5"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}

Binary file not shown.

View File

@@ -35,6 +35,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"d3-time": "^3.1.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.452.0",
"nanostores": "^0.11.4",
"pocketbase": "^0.26.2",
@@ -67,7 +68,7 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
@@ -81,7 +82,7 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.27.1",
@@ -96,7 +97,7 @@
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz",
"integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -106,7 +107,7 @@
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.3.tgz",
"integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.2.0",
@@ -137,7 +138,7 @@
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz",
"integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.3",
@@ -154,7 +155,7 @@
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz",
"integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/compat-data": "^7.27.2",
@@ -171,7 +172,7 @@
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -181,7 +182,7 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
"integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.27.1",
@@ -195,7 +196,7 @@
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz",
"integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.27.1",
@@ -213,7 +214,7 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -223,7 +224,7 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -233,7 +234,7 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -243,7 +244,7 @@
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.3.tgz",
"integrity": "sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.27.2",
@@ -257,7 +258,7 @@
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.3.tgz",
"integrity": "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.28.2"
@@ -285,7 +286,7 @@
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
"integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
@@ -300,7 +301,7 @@
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.3.tgz",
"integrity": "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
@@ -319,7 +320,7 @@
"version": "7.28.2",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz",
"integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
@@ -855,7 +856,7 @@
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
"integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@sinclair/typebox": "^0.27.8"
@@ -868,7 +869,7 @@
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz",
"integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/schemas": "^29.6.3",
@@ -886,7 +887,7 @@
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -908,7 +909,7 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@@ -918,14 +919,14 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.30",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz",
"integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -946,7 +947,7 @@
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/@lingui/babel-plugin-lingui-macro/-/babel-plugin-lingui-macro-5.4.1.tgz",
"integrity": "sha512-9IO+PDvdneY8OCI8zvI1oDXpzryTMtyRv7uq9O0U1mFCvIPVd5dWQKQDu/CpgpYAc2+JG/izn5PNl9xzPc6ckw==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.20.12",
@@ -1166,7 +1167,7 @@
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/@lingui/conf/-/conf-5.4.1.tgz",
"integrity": "sha512-aDkj/bMSr/mCL8Nr1TS52v0GLCuVa4YqtRz+WvUCFZw/ovVInX0hKq1TClx/bSlhu60FzB/CbclxFMBw8aLVUg==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.13",
@@ -2585,7 +2586,7 @@
"version": "0.27.8",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
"integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/@swc/core": {
@@ -3255,14 +3256,14 @@
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
"integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/@types/istanbul-lib-report": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz",
"integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@types/istanbul-lib-coverage": "*"
@@ -3272,7 +3273,7 @@
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz",
"integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@types/istanbul-lib-report": "*"
@@ -3282,7 +3283,7 @@
"version": "24.3.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz",
"integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.10.0"
@@ -3292,7 +3293,7 @@
"version": "19.1.11",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.11.tgz",
"integrity": "sha512-lr3jdBw/BGj49Eps7EvqlUaoeA0xpj3pc0RoJkHpYaCHkVK7i28dKyImLQb3JVlqs3aYSXf7qYuWOW/fgZnTXQ==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@@ -3302,7 +3303,7 @@
"version": "19.1.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.7.tgz",
"integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==",
"devOptional": true,
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.0.0"
@@ -3312,7 +3313,7 @@
"version": "17.0.33",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
"integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@types/yargs-parser": "*"
@@ -3322,7 +3323,7 @@
"version": "21.0.3",
"resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz",
"integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/@vitejs/plugin-react-swc": {
@@ -3359,7 +3360,7 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -3402,7 +3403,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"devOptional": true,
"dev": true,
"license": "Python-2.0"
},
"node_modules/aria-hidden": {
@@ -3497,7 +3498,7 @@
"version": "4.25.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz",
"integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==",
"devOptional": true,
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -3568,7 +3569,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -3578,7 +3579,7 @@
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
"integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -3591,7 +3592,7 @@
"version": "1.0.30001727",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz",
"integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==",
"devOptional": true,
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -3612,7 +3613,7 @@
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
@@ -3724,7 +3725,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -3737,7 +3738,7 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/colors": {
@@ -3754,14 +3755,14 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/cosmiconfig": {
"version": "8.3.6",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz",
"integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"import-fresh": "^3.3.0",
@@ -3941,7 +3942,7 @@
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -4011,7 +4012,7 @@
"version": "1.5.182",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.182.tgz",
"integrity": "sha512-Lv65Btwv9W4J9pyODI6EWpdnhfvrve/us5h1WspW8B2Fb0366REPtY3hX7ounk1CkV/TBjWCEvCBBbYbmV0qCA==",
"devOptional": true,
"dev": true,
"license": "ISC"
},
"node_modules/emoji-regex": {
@@ -4039,7 +4040,7 @@
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
"integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"is-arrayish": "^0.2.1"
@@ -4108,7 +4109,7 @@
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -4196,7 +4197,7 @@
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -4222,7 +4223,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -4253,7 +4254,7 @@
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
"integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"parent-module": "^1.0.0",
@@ -4273,6 +4274,16 @@
"dev": true,
"license": "ISC"
},
"node_modules/input-otp": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz",
"integrity": "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
@@ -4286,7 +4297,7 @@
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
"integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/is-binary-path": {
@@ -4379,7 +4390,7 @@
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz",
"integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
@@ -4389,7 +4400,7 @@
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz",
"integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/types": "^29.6.3",
@@ -4407,7 +4418,7 @@
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"devOptional": true,
"dev": true,
"license": "MIT",
"bin": {
"jiti": "bin/jiti.js"
@@ -4429,7 +4440,7 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -4442,7 +4453,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
"devOptional": true,
"dev": true,
"license": "MIT",
"bin": {
"jsesc": "bin/jsesc"
@@ -4455,14 +4466,14 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"devOptional": true,
"dev": true,
"license": "MIT",
"bin": {
"json5": "lib/cli.js"
@@ -4475,7 +4486,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
"integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -4724,7 +4735,7 @@
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/lodash": {
@@ -4773,7 +4784,7 @@
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
"devOptional": true,
"dev": true,
"license": "ISC",
"dependencies": {
"yallist": "^3.0.2"
@@ -4884,7 +4895,7 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@@ -4925,7 +4936,7 @@
"version": "2.0.19",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
"integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/normalize-path": {
@@ -5021,7 +5032,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
"integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"callsites": "^3.0.0"
@@ -5034,7 +5045,7 @@
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
"integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.0.0",
@@ -5063,7 +5074,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -5073,7 +5084,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"devOptional": true,
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
@@ -5135,7 +5146,7 @@
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
"integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/schemas": "^29.6.3",
@@ -5150,7 +5161,7 @@
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -5396,7 +5407,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
@@ -5494,7 +5505,7 @@
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"devOptional": true,
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -5677,7 +5688,7 @@
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
@@ -5811,7 +5822,7 @@
"version": "5.9.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -5825,14 +5836,14 @@
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/update-browserslist-db": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
"integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
"devOptional": true,
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -6164,7 +6175,7 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"devOptional": true,
"dev": true,
"license": "ISC"
}
}

View File

@@ -8,7 +8,11 @@
"build": "lingui extract --overwrite && lingui compile && vite build",
"preview": "vite preview",
"sync": "lingui extract --overwrite && lingui compile",
"sync_and_purge": "lingui extract --overwrite --clean && lingui compile"
"sync_and_purge": "lingui extract --overwrite --clean && lingui compile",
"format": "biome format --write .",
"lint": "biome lint .",
"check": "biome check .",
"check:fix": "biome check --fix ."
},
"dependencies": {
"@henrygd/queue": "^1.0.7",
@@ -38,6 +42,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"d3-time": "^3.1.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.452.0",
"nanostores": "^0.11.4",
"pocketbase": "^0.26.2",
@@ -48,6 +53,7 @@
"valibot": "^0.42.1"
},
"devDependencies": {
"@biomejs/biome": "2.2.3",
"@lingui/cli": "^5.4.1",
"@lingui/swc-plugin": "^5.6.1",
"@lingui/vite-plugin": "^5.4.1",

View File

@@ -4,7 +4,7 @@ import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { LoaderCircle, LockIcon, LogInIcon, MailIcon } from "lucide-react"
import { KeyIcon, LoaderCircle, LockIcon, LogInIcon, MailIcon } from "lucide-react"
import { $authenticated } from "@/lib/stores"
import * as v from "valibot"
import { toast } from "../ui/use-toast"
@@ -14,6 +14,7 @@ import { AuthMethodsList, AuthProviderInfo, OAuth2AuthConfig } from "pocketbase"
import { $router, Link, prependBasePath } from "../router"
import { getPagePath } from "@nanostores/router"
import { pb } from "@/lib/api"
import { OtpInputForm } from "./otp-forms"
const honeypot = v.literal("")
const emailSchema = v.pipe(v.string(), v.email(t`Invalid email address.`))
@@ -36,10 +37,11 @@ const RegisterSchema = v.looseObject({
passwordConfirm: passwordSchema,
})
const showLoginFaliedToast = () => {
export const showLoginFaliedToast = (description?: string) => {
description ||= t`Please check your credentials and try again`
toast({
title: t`Login attempt failed`,
description: t`Please check your credentials and try again`,
description,
variant: "destructive",
})
}
@@ -65,6 +67,8 @@ export function UserAuthForm({
const [isLoading, setIsLoading] = useState<boolean>(false)
const [isOauthLoading, setIsOauthLoading] = useState<boolean>(false)
const [errors, setErrors] = useState<Record<string, string | undefined>>({})
const [mfaId, setMfaId] = useState<string | undefined>()
const [otpId, setOtpId] = useState<string | undefined>()
const handleSubmit = useCallback(
async (e: React.FormEvent<HTMLFormElement>) => {
@@ -106,17 +110,19 @@ export function UserAuthForm({
}
$authenticated.set(true)
} catch (err: any) {
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 })
const mfaId = err?.response?.mfaId
if (!mfaId) {
showLoginFaliedToast()
throw err
}
setMfaId(mfaId)
try {
const { otpId } = await pb.collection("users").requestOTP(email)
setOtpId(otpId)
} catch (err) {
console.log({ err })
showLoginFaliedToast()
}
} finally {
setIsLoading(false)
}
@@ -131,6 +137,8 @@ export function UserAuthForm({
const authProviders = authMethods.oauth2.providers ?? []
const oauthEnabled = authMethods.oauth2.enabled && authProviders.length > 0
const passwordEnabled = authMethods.password.enabled
const otpEnabled = authMethods.otp.enabled
const mfaEnabled = authMethods.mfa.enabled
function loginWithOauth(provider: AuthProviderInfo, forcePopup = false) {
setIsOauthLoading(true)
@@ -173,6 +181,10 @@ export function UserAuthForm({
}
}, [])
if (otpId && mfaId) {
return <OtpInputForm otpId={otpId} mfaId={mfaId} />
}
return (
<div className={cn("grid gap-6", className)} {...props}>
{passwordEnabled && (
@@ -249,7 +261,7 @@ export function UserAuthForm({
</button>
</div>
</form>
{(isFirstRun || oauthEnabled) && (
{(isFirstRun || oauthEnabled || (otpEnabled && !mfaEnabled)) && (
// only show 'continue with' during onboarding or if we have auth providers
<div className="relative">
<div className="absolute inset-0 flex items-center">
@@ -264,6 +276,15 @@ export function UserAuthForm({
)}
</>
)}
{/* hide OTP button if MFA is enabled (it will be used as MFA) */}
{otpEnabled && !mfaEnabled && (
<div className="grid gap-2 -mt-1">
<Link href="/request-otp" type="button" className={cn(buttonVariants({ variant: "outline" }), "flex gap-2")}>
<KeyIcon className="size-4" />
<Trans>One-time password</Trans>
</Link>
</div>
)}
{oauthEnabled && (
<div className="grid gap-2 -mt-1">
{authMethods.oauth2.providers.map((provider) => (

View File

@@ -9,6 +9,7 @@ import { AuthMethodsList } from "pocketbase"
import { useTheme } from "../theme-provider"
import { pb } from "@/lib/api"
import { ModeToggle } from "../mode-toggle"
import { OtpRequestForm } from "./otp-forms"
export default function () {
const page = useStore($router)
@@ -37,6 +38,8 @@ export default function () {
return t`Please create an admin account`
} else if (page?.route === "forgot_password") {
return t`Enter email address to reset password`
} else if (page?.route === "request_otp") {
return t`Request a one-time password`
} else {
return t`Please sign in to your account`
}
@@ -51,7 +54,7 @@ export default function () {
<div
className="grid gap-5 w-full px-4 mx-auto"
// @ts-ignore
style={{ maxWidth: "22em", "--border": theme == "light" ? "hsl(30, 8%, 70%)" : "hsl(220, 3%, 25%)" }}
style={{ maxWidth: "21.5em", "--border": theme == "light" ? "hsl(30, 8%, 70%)" : "hsl(220, 3%, 25%)" }}
>
<div className="absolute top-3 right-3">
<ModeToggle />
@@ -65,6 +68,8 @@ export default function () {
</div>
{page?.route === "forgot_password" ? (
<ForgotPassword />
) : page?.route === "request_otp" ? (
<OtpRequestForm />
) : (
<UserAuthForm isFirstRun={isFirstRun} authMethods={authMethods} />
)}

View File

@@ -0,0 +1,106 @@
import { useCallback, useState } from "react"
import { pb } from "@/lib/api"
import { $authenticated } from "@/lib/stores"
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/otp"
import { Trans } from "@lingui/react/macro"
import { showLoginFaliedToast } from "./auth-form"
import { cn } from "@/lib/utils"
import { MailIcon, LoaderCircle, SendHorizonalIcon } from "lucide-react"
import { Label } from "../ui/label"
import { buttonVariants } from "../ui/button"
import { Input } from "../ui/input"
import { $router } from "../router"
export function OtpInputForm({ otpId, mfaId }: { otpId: string; mfaId: string }) {
const [value, setValue] = useState("")
if (value.length === 6) {
pb.collection("users")
.authWithOTP(otpId, value, { mfaId })
.then(() => {
$router.open("/")
$authenticated.set(true)
})
.catch((err) => {
showLoginFaliedToast(err.message)
})
}
return (
<div className="grid gap-3 items-center justify-center">
<InputOTP maxLength={6} value={value} onChange={setValue} autoFocus>
<InputOTPGroup>
{Array.from({ length: 6 }).map((_, i) => (
<InputOTPSlot key={i} index={i} />
))}
</InputOTPGroup>
</InputOTP>
<div className="text-center text-sm text-muted-foreground">
<Trans>Enter your one-time password.</Trans>
</div>
</div>
)
}
export function OtpRequestForm() {
const [isLoading, setIsLoading] = useState<boolean>(false)
const [email, setEmail] = useState("")
const [otpId, setOtpId] = useState<string | undefined>()
const handleSubmit = useCallback(
async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setIsLoading(true)
try {
// console.log(email)
const { otpId } = await pb.collection("users").requestOTP(email)
setOtpId(otpId)
} catch (e: any) {
showLoginFaliedToast(e?.message)
} finally {
setIsLoading(false)
setEmail("")
}
},
[email]
)
if (otpId) {
return <OtpInputForm otpId={otpId} mfaId={""} />
}
return (
<form onSubmit={handleSubmit}>
<div className="grid gap-3">
<div className="grid gap-1 relative">
<MailIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Label className="sr-only" htmlFor="email">
<Trans>Email</Trans>
</Label>
<Input
value={email}
onChange={(e) => setEmail(e.target.value)}
id="email"
name="email"
required
placeholder="name@example.com"
type="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
disabled={isLoading}
className="ps-9"
/>
</div>
<button className={cn(buttonVariants())} disabled={isLoading}>
{isLoading ? (
<LoaderCircle className="me-2 h-4 w-4 animate-spin" />
) : (
<SendHorizonalIcon className="me-2 h-4 w-4" />
)}
<Trans>Request OTP</Trans>
</button>
</div>
</form>
)
}

View File

@@ -5,6 +5,7 @@ const routes = {
system: `/system/:name`,
settings: `/settings/:name?`,
forgot_password: `/forgot-password`,
request_otp: `/request-otp`,
} as const
/**

View File

@@ -1,22 +1,22 @@
import { Suspense, memo, useEffect, useMemo } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"
import { $alerts, $allSystemsById } from "@/lib/stores"
import { useStore } from "@nanostores/react"
import { GithubIcon } from "lucide-react"
import { Separator } from "../ui/separator"
import { AlertRecord } from "@/types"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { $router, Link } from "../router"
import { Plural, Trans, useLingui } from "@lingui/react/macro"
import { useStore } from "@nanostores/react"
import { getPagePath } from "@nanostores/router"
import { alertInfo } from "@/lib/alerts"
import { GithubIcon } from "lucide-react"
import { memo, Suspense, useEffect, useMemo } from "react"
import { $router, Link } from "@/components/router"
import SystemsTable from "@/components/systems-table/systems-table"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator"
import { alertInfo } from "@/lib/alerts"
import { $alerts, $allSystemsById } from "@/lib/stores"
import type { AlertRecord } from "@/types"
export default memo(function () {
export default memo(() => {
const { t } = useLingui()
useEffect(() => {
document.title = t`Dashboard` + " / Beszel"
document.title = `${t`Dashboard`} / Beszel`
}, [t])
return useMemo(
@@ -32,6 +32,7 @@ export default memo(function () {
href="https://github.com/henrygd/beszel"
target="_blank"
className="flex items-center gap-0.5 text-muted-foreground hover:text-foreground duration-75"
rel="noopener"
>
<GithubIcon className="h-3 w-3" /> GitHub
</a>
@@ -40,6 +41,7 @@ export default memo(function () {
href="https://github.com/henrygd/beszel/releases"
target="_blank"
className="text-muted-foreground hover:text-foreground duration-75"
rel="noopener"
>
Beszel {globalThis.BESZEL.HUB_VERSION}
</a>
@@ -71,6 +73,7 @@ const ActiveAlerts = () => {
return { activeAlerts, alertsKey }
}, [alerts])
// biome-ignore lint/correctness/useExhaustiveDependencies: alertsKey is inclusive
return useMemo(() => {
if (activeAlerts.length === 0) {
return null

View File

@@ -1,26 +1,16 @@
import { pb } from "@/lib/api"
import { cn, formatDuration, formatShortDate } from "@/lib/utils"
import { alertInfo } from "@/lib/alerts"
import { AlertsHistoryRecord } from "@/types"
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro"
import {
type ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
getFilteredRowModel,
type SortingState,
useReactTable,
flexRender,
ColumnFiltersState,
SortingState,
VisibilityState,
type VisibilityState,
} from "@tanstack/react-table"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { alertsHistoryColumns } from "../../alerts-history-columns"
import { Checkbox } from "@/components/ui/checkbox"
import { memo, useEffect, useState } from "react"
import { Label } from "@/components/ui/label"
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
import {
ChevronLeftIcon,
ChevronRightIcon,
@@ -29,9 +19,7 @@ import {
DownloadIcon,
Trash2Icon,
} from "lucide-react"
import { Trans } from "@lingui/react/macro"
import { t } from "@lingui/core/macro"
import { useToast } from "@/components/ui/use-toast"
import { memo, useEffect, useState } from "react"
import {
AlertDialog,
AlertDialogAction,
@@ -43,6 +31,18 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button, buttonVariants } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { useToast } from "@/components/ui/use-toast"
import { alertInfo } from "@/lib/alerts"
import { pb } from "@/lib/api"
import { cn, formatDuration, formatShortDate } from "@/lib/utils"
import type { AlertsHistoryRecord } from "@/types"
import { alertsHistoryColumns } from "../../alerts-history-columns"
const SectionIntro = memo(() => {
return (

View File

@@ -1,15 +1,15 @@
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro"
import { Separator } from "@/components/ui/separator"
import { Button } from "@/components/ui/button"
import { redirectPage } from "@nanostores/router"
import { $router } from "@/components/router"
import clsx from "clsx"
import { AlertCircleIcon, FileSlidersIcon, LoaderCircleIcon } from "lucide-react"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { useState } from "react"
import { $router } from "@/components/router"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { Textarea } from "@/components/ui/textarea"
import { toast } from "@/components/ui/use-toast"
import clsx from "clsx"
import { isAdmin, pb } from "@/lib/api"
export default function ConfigYaml() {

View File

@@ -1,18 +1,18 @@
import { Trans } from "@lingui/react/macro"
/** biome-ignore-all lint/correctness/useUniqueElementIds: component is only rendered once */
import { Trans, useLingui } from "@lingui/react/macro"
import { LanguagesIcon, LoaderCircleIcon, SaveIcon } from "lucide-react"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
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 languages from "@/lib/languages"
import { HourFormat, Unit } from "@/lib/enums"
import { dynamicActivate } from "@/lib/i18n"
import { useLingui } from "@lingui/react/macro"
import { Input } from "@/components/ui/input"
import { Unit } from "@/lib/enums"
import languages from "@/lib/languages"
import { chartTimeData, currentHour12 } from "@/lib/utils"
import type { UserSettings } from "@/types"
import { saveSettings } from "./layout"
export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) {
const [isLoading, setIsLoading] = useState(false)
@@ -82,24 +82,46 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
<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 className="grid sm:grid-cols-3 gap-4">
<div className="grid gap-2">
<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>
</div>
<div className="grid gap-2">
<Label className="block" htmlFor="hourFormat">
<Trans>Time format</Trans>
</Label>
<Select
name="hourFormat"
key={userSettings.hourFormat}
defaultValue={userSettings.hourFormat ?? (currentHour12() ? HourFormat["12h"] : HourFormat["24h"])}
>
<SelectTrigger id="hourFormat">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.keys(HourFormat).map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<Separator />
<div className="grid gap-2">

View File

@@ -1,18 +1,17 @@
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro"
import { Trans, useLingui } from "@lingui/react/macro"
import { useStore } from "@nanostores/react"
import { getPagePath, redirectPage } from "@nanostores/router"
import { AlertOctagonIcon, BellIcon, FileSlidersIcon, FingerprintIcon, SettingsIcon } from "lucide-react"
import { lazy, useEffect } from "react"
import { $router } from "@/components/router.tsx"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card.tsx"
import { toast } from "@/components/ui/use-toast.ts"
import { pb } from "@/lib/api"
import { $userSettings } from "@/lib/stores.ts"
import type { UserSettings } from "@/types"
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 { getPagePath, redirectPage } from "@nanostores/router"
import { BellIcon, FileSlidersIcon, FingerprintIcon, SettingsIcon, AlertOctagonIcon } from "lucide-react"
import { $userSettings } from "@/lib/stores.ts"
import { toast } from "@/components/ui/use-toast.ts"
import { UserSettings } from "@/types"
import { useLingui } from "@lingui/react/macro"
import { pb } from "@/lib/api"
const generalSettingsImport = () => import("./general.tsx")
const notificationsSettingsImport = () => import("./notifications.tsx")
@@ -93,9 +92,10 @@ export default function SettingsLayout() {
const page = useStore($router)
// biome-ignore lint/correctness/useExhaustiveDependencies: no dependencies
useEffect(() => {
document.title = t`Settings` + " / Beszel"
// @ts-ignore redirect to account page if no page is specified
document.title = `${t`Settings`} / Beszel`
// @ts-expect-error redirect to account page if no page is specified
if (!page?.params?.name) {
redirectPage($router, "settings", { name: "general" })
}

View File

@@ -1,19 +1,19 @@
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
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 { type ChangeEventHandler, useEffect, useState } from "react"
import * as v from "valibot"
import { prependBasePath } from "@/components/router"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { InputTags } from "@/components/ui/input-tags"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { toast } from "@/components/ui/use-toast"
import { isAdmin, pb } from "@/lib/api"
import type { UserSettings } from "@/types"
import { saveSettings } from "./layout"
interface ShoutrrrUrlCardProps {
url: string
@@ -127,7 +127,7 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>
Beszel uses{" "}
<a href="https://beszel.dev/guide/notifications" target="_blank" className="link">
<a href="https://beszel.dev/guide/notifications" target="_blank" className="link" rel="noopener">
Shoutrrr
</a>{" "}
to integrate with popular notification services.

View File

@@ -1,11 +1,11 @@
import React from "react"
import { cn } from "@/lib/utils"
import { isAdmin, isReadOnlyUser } from "@/lib/api"
import { buttonVariants } from "../../ui/button"
import { $router, Link, navigate } from "../../router"
import { useStore } from "@nanostores/react"
import type React from "react"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
import { isAdmin, isReadOnlyUser } from "@/lib/api"
import { cn } from "@/lib/utils"
import { $router, Link, navigate } from "../../router"
import { buttonVariants } from "../../ui/button"
interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
items: {

View File

@@ -1,9 +1,6 @@
import { Trans, useLingui } from "@lingui/react/macro"
import { t } from "@lingui/core/macro"
import { $publicKey } from "@/lib/stores"
import { memo, useEffect, useMemo, useState } from "react"
import { Table, TableCell, TableHead, TableBody, TableRow, TableHeader } from "@/components/ui/table"
import { FingerprintRecord } from "@/types"
import { Trans, useLingui } from "@lingui/react/macro"
import { redirectPage } from "@nanostores/router"
import {
CopyIcon,
FingerprintIcon,
@@ -13,9 +10,17 @@ import {
ServerIcon,
Trash2Icon,
} from "lucide-react"
import { toast } from "@/components/ui/use-toast"
import { cn, copyToClipboard, generateToken, getHubURL, tokenMap } from "@/lib/utils"
import { isReadOnlyUser, pb } from "@/lib/api"
import { memo, useEffect, useMemo, useState } from "react"
import {
copyDockerCompose,
copyDockerRun,
copyLinuxCommand,
copyWindowsCommand,
type DropdownItem,
InstallDropdown,
} from "@/components/install-dropdowns"
import { $router } from "@/components/router"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
@@ -23,20 +28,15 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Button } from "@/components/ui/button"
import { AppleIcon, DockerIcon, TuxIcon, WindowsIcon } from "@/components/ui/icons"
import { Separator } from "@/components/ui/separator"
import { Switch } from "@/components/ui/switch"
import {
copyDockerCompose,
copyDockerRun,
copyLinuxCommand,
copyWindowsCommand,
DropdownItem,
InstallDropdown,
} from "@/components/install-dropdowns"
import { AppleIcon, DockerIcon, TuxIcon, WindowsIcon } from "@/components/ui/icons"
import { redirectPage } from "@nanostores/router"
import { $router } from "@/components/router"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/use-toast"
import { isReadOnlyUser, pb } from "@/lib/api"
import { $publicKey } from "@/lib/stores"
import { cn, copyToClipboard, generateToken, getHubURL, tokenMap } from "@/lib/utils"
import type { FingerprintRecord } from "@/types"
const pbFingerprintOptions = {
expand: "system",

View File

@@ -1,24 +1,34 @@
import { t } from "@lingui/core/macro"
import { Plural, Trans } from "@lingui/react/macro"
import { Plural, Trans, useLingui } from "@lingui/react/macro"
import { useStore } from "@nanostores/react"
import { getPagePath } from "@nanostores/router"
import { timeTicks } from "d3-time"
import { ClockArrowUp, CpuIcon, GlobeIcon, LayoutGridIcon, MonitorIcon, XIcon } from "lucide-react"
import { subscribeKeys } from "nanostores"
import React, { type JSX, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
import AreaChartDefault from "@/components/charts/area-chart"
import ContainerChart from "@/components/charts/container-chart"
import DiskChart from "@/components/charts/disk-chart"
import GpuPowerChart from "@/components/charts/gpu-power-chart"
import { useContainerChartConfigs } from "@/components/charts/hooks"
import LoadAverageChart from "@/components/charts/load-average-chart"
import MemChart from "@/components/charts/mem-chart"
import SwapChart from "@/components/charts/swap-chart"
import TemperatureChart from "@/components/charts/temperature-chart"
import { getPbTimestamp, pb } from "@/lib/api"
import { ChartType, Os, SystemStatus, Unit } from "@/lib/enums"
import { batteryStateTranslations } from "@/lib/i18n"
import {
$systems,
$allSystemsByName,
$chartTime,
$containerFilter,
$userSettings,
$direction,
$maxValues,
$systems,
$temperatureFilter,
$allSystemsByName,
$userSettings,
} from "@/lib/stores"
import { ChartData, ChartTimes, ContainerStatsRecord, GPUData, SystemRecord, SystemStatsRecord } from "@/types"
import { useContainerChartConfigs } from "@/components/charts/hooks"
import { ChartType, Unit, Os, SystemStatus } from "@/lib/enums"
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, type JSX } from "react"
import { Card, CardHeader, CardTitle, CardDescription } from "../ui/card"
import { useStore } from "@nanostores/react"
import Spinner from "../spinner"
import { ClockArrowUp, CpuIcon, GlobeIcon, LayoutGridIcon, MonitorIcon, XIcon } from "lucide-react"
import ChartTimeSelect from "../charts/chart-time-select"
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
import {
chartTimeData,
cn,
@@ -30,34 +40,33 @@ import {
toFixedFloat,
useBrowserStorage,
} from "@/lib/utils"
import { getPbTimestamp, pb } from "@/lib/api"
import type { ChartData, ChartTimes, ContainerStatsRecord, GPUData, SystemRecord, SystemStatsRecord } from "@/types"
import ChartTimeSelect from "../charts/chart-time-select"
import { $router, navigate } from "../router"
import Spinner from "../spinner"
import { Button } from "../ui/button"
import { Card, CardDescription, CardHeader, CardTitle } from "../ui/card"
import { AppleIcon, ChartAverage, ChartMax, FreeBsdIcon, Rows, TuxIcon, WindowsIcon } from "../ui/icons"
import { Input } from "../ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"
import { Separator } from "../ui/separator"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"
import { Button } from "../ui/button"
import { Input } from "../ui/input"
import { ChartAverage, ChartMax, Rows, TuxIcon, WindowsIcon, AppleIcon, FreeBsdIcon } 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 { useLingui } from "@lingui/react/macro"
import { $router, navigate } from "../router"
import { getPagePath } from "@nanostores/router"
import { batteryStateTranslations } from "@/lib/i18n"
import AreaChartDefault from "@/components/charts/area-chart"
import ContainerChart from "@/components/charts/container-chart"
import MemChart from "@/components/charts/mem-chart"
import DiskChart from "@/components/charts/disk-chart"
import SwapChart from "@/components/charts/swap-chart"
import TemperatureChart from "@/components/charts/temperature-chart"
import GpuPowerChart from "@/components/charts/gpu-power-chart"
import LoadAverageChart from "@/components/charts/load-average-chart"
import { subscribeKeys } from "nanostores"
const cache = new Map<string, any>()
type ChartTimeData = {
time: number
data: {
ticks: number[]
domain: number[]
}
chartTime: ChartTimes
}
const cache = new Map<string, ChartTimeData | SystemStatsRecord[] | ContainerStatsRecord[]>()
// create ticks and domain for charts
function getTimeData(chartTime: ChartTimes, lastCreated: number) {
const cached = cache.get("td")
const cached = cache.get("td") as ChartTimeData | undefined
if (cached && cached.chartTime === chartTime) {
if (!lastCreated || cached.time >= lastCreated) {
return cached.data
@@ -79,7 +88,7 @@ function getTimeData(chartTime: ChartTimes, lastCreated: number) {
function addEmptyValues<T extends SystemStatsRecord | ContainerStatsRecord>(
prevRecords: T[],
newRecords: T[],
expectedInterval: number
expectedInterval: number,
) {
const modifiedRecords: T[] = []
let prevTime = (prevRecords.at(-1)?.created ?? 0) as number
@@ -90,7 +99,7 @@ function addEmptyValues<T extends SystemStatsRecord | ContainerStatsRecord>(
const interval = record.created - prevTime
// if interval is too large, add a null record
if (interval > expectedInterval / 2 + expectedInterval) {
// @ts-ignore
// @ts-expect-error
modifiedRecords.push({ created: null, stats: null })
}
}
@@ -100,8 +109,9 @@ function addEmptyValues<T extends SystemStatsRecord | ContainerStatsRecord>(
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
async function getStats<T extends SystemStatsRecord | ContainerStatsRecord>(collection: string, system: SystemRecord, chartTime: ChartTimes): Promise<T[]> {
const cachedStats = cache.get(`${system.id}_${chartTime}_${collection}`) as T[] | undefined
const lastCached = cachedStats?.at(-1)?.created as number
return await pb.collection<T>(collection).getFullList({
filter: pb.filter("system={:id} && created > {:created} && type={:type}", {
id: system.id,
@@ -137,6 +147,7 @@ export default memo(function SystemDetail({ name }: { name: string }) {
const [chartLoading, setChartLoading] = useState(true)
const isLongerChart = chartTime !== "1h"
const userSettings = $userSettings.get()
const chartWrapRef = useRef<HTMLDivElement>(null)
useEffect(() => {
document.title = `${name} / Beszel`
@@ -160,10 +171,11 @@ export default memo(function SystemDetail({ name }: { name: string }) {
})
}, [name])
// biome-ignore lint/correctness/useExhaustiveDependencies: not necessary
const chartData: ChartData = useMemo(() => {
const lastCreated = Math.max(
(systemStats.at(-1)?.created as number) ?? 0,
(containerData.at(-1)?.created as number) ?? 0
(containerData.at(-1)?.created as number) ?? 0,
)
return {
systemStats,
@@ -178,8 +190,29 @@ export default memo(function SystemDetail({ name }: { name: string }) {
// Share chart config computation for all container charts
const containerChartConfigs = useContainerChartConfigs(containerData)
// make container stats for charts
const makeContainerData = useCallback((containers: ContainerStatsRecord[]) => {
const containerData = [] as ChartData["containerData"]
for (let { created, stats } of containers) {
if (!created) {
// @ts-expect-error add null value for gaps
containerData.push({ created: null })
continue
}
created = new Date(created).getTime()
// @ts-expect-error not dealing with this rn
const containerStats: ChartData["containerData"][0] = { created }
for (const container of stats) {
containerStats[container.n] = container
}
containerData.push(containerStats)
}
setContainerData(containerData)
}, [])
// get stats
useEffect(() => {
// biome-ignore lint/correctness/useExhaustiveDependencies: not necessary
useEffect(() => {
if (!system.id || !chartTime) {
return
}
@@ -223,25 +256,6 @@ export default memo(function SystemDetail({ name }: { name: string }) {
})
}, [system, chartTime])
// make container stats for charts
const makeContainerData = useCallback((containers: ContainerStatsRecord[]) => {
const containerData = [] as ChartData["containerData"]
for (let { created, stats } of containers) {
if (!created) {
// @ts-ignore add null value for gaps
containerData.push({ created: null })
continue
}
created = new Date(created).getTime()
// @ts-ignore not dealing with this rn
let containerStats: ChartData["containerData"][0] = { created }
for (let container of stats) {
containerStats[container.n] = container
}
containerData.push(containerStats)
}
setContainerData(containerData)
}, [])
// values for system info bar
const systemInfo = useMemo(() => {
@@ -303,10 +317,10 @@ export default memo(function SystemDetail({ name }: { name: string }) {
] as {
value: string | number | undefined
label?: string
Icon: any
Icon: React.ElementType
hide?: boolean
}[]
}, [system.info, t])
}, [system, t])
/** Space for tooltip if more than 12 containers */
useEffect(() => {
@@ -315,12 +329,12 @@ export default memo(function SystemDetail({ name }: { name: string }) {
return
}
const tooltipHeight = (Object.keys(containerData[0]).length - 11) * 17.8 - 40
const wrapperEl = document.getElementById("chartwrap") as HTMLDivElement
const wrapperEl = chartWrapRef.current as HTMLDivElement
const wrapperRect = wrapperEl.getBoundingClientRect()
const chartRect = netCardRef.current.getBoundingClientRect()
const distanceToBottom = wrapperRect.bottom - chartRect.bottom
setBottomSpacing(tooltipHeight - distanceToBottom)
}, [netCardRef, containerData])
}, [containerData])
// keyboard navigation between systems
useEffect(() => {
@@ -343,15 +357,17 @@ export default memo(function SystemDetail({ name }: { name: string }) {
}
switch (e.key) {
case "ArrowLeft":
case "h":
case "h": {
const prevIndex = (currentIndex - 1 + systems.length) % systems.length
persistChartTime.current = true
return navigate(getPagePath($router, "system", { name: systems[prevIndex].name }))
}
case "ArrowRight":
case "l":
case "l": {
const nextIndex = (currentIndex + 1) % systems.length
persistChartTime.current = true
return navigate(getPagePath($router, "system", { name: systems[nextIndex].name }))
}
}
}
return listen(document, "keyup", handleKeyUp)
@@ -380,7 +396,7 @@ export default memo(function SystemDetail({ name }: { name: string }) {
return (
<>
<div id="chartwrap" className="grid gap-4 mb-14 overflow-x-clip">
<div ref={chartWrapRef} className="grid gap-4 mb-14 overflow-x-clip">
{/* system info */}
<Card>
<div className="grid xl:flex gap-4 px-4 sm:px-6 pt-3 sm:pt-4 pb-5">
@@ -406,7 +422,7 @@ export default memo(function SystemDetail({ name }: { name: string }) {
</span>
{translatedStatus}
</div>
{systemInfo.map(({ value, label, Icon, hide }, i) => {
{systemInfo.map(({ value, label, Icon, hide }) => {
if (hide || !value) {
return null
}
@@ -416,7 +432,7 @@ export default memo(function SystemDetail({ name }: { name: string }) {
</div>
)
return (
<div key={i} className="contents">
<div key={value} className="contents">
<Separator orientation="vertical" className="h-4 bg-primary/30" />
{label ? (
<TooltipProvider>
@@ -479,8 +495,8 @@ export default memo(function SystemDetail({ name }: { name: string }) {
opacity: 0.4,
},
]}
tickFormatter={(val) => toFixedFloat(val, 2) + "%"}
contentFormatter={({ value }) => decimalString(value) + "%"}
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
contentFormatter={({ value }) => `${decimalString(value)}%`}
/>
</ChartCard>
@@ -558,11 +574,11 @@ export default memo(function SystemDetail({ name }: { name: string }) {
]}
tickFormatter={(val) => {
const { value, unit } = formatBytes(val, true, userSettings.unitDisk, true)
return toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
}}
contentFormatter={({ value }) => {
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, true)
return decimalString(convertedValue, convertedValue >= 100 ? 1 : 2) + " " + unit
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
}}
/>
</ChartCard>
@@ -603,12 +619,12 @@ export default memo(function SystemDetail({ name }: { name: string }) {
},
]}
tickFormatter={(val) => {
let { value, unit } = formatBytes(val, true, userSettings.unitNet, false)
return toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit
const { value, unit } = formatBytes(val, true, userSettings.unitNet, false)
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
}}
contentFormatter={(data) => {
const { value, unit } = formatBytes(data.value, true, userSettings.unitNet, false)
return decimalString(value, value >= 100 ? 1 : 2) + " " + unit
return `${decimalString(value, value >= 100 ? 1 : 2)} ${unit}`
}}
/>
</ChartCard>
@@ -682,7 +698,7 @@ export default memo(function SystemDetail({ name }: { name: string }) {
description={`${t({
message: "Current state",
comment: "Context: Battery state",
})}: ${batteryStateTranslations[systemStats.at(-1)?.stats.bat![1] ?? 0]()}`}
})}: ${batteryStateTranslations[systemStats.at(-1)?.stats.bat?.[1] ?? 0]()}`}
>
<AreaChartDefault
chartData={chartData}
@@ -738,8 +754,8 @@ export default memo(function SystemDetail({ name }: { name: string }) {
opacity: 0.35,
},
]}
tickFormatter={(val) => toFixedFloat(val, 2) + "%"}
contentFormatter={({ value }) => decimalString(value) + "%"}
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
contentFormatter={({ value }) => `${decimalString(value)}%`}
/>
</ChartCard>
<ChartCard
@@ -761,11 +777,11 @@ export default memo(function SystemDetail({ name }: { name: string }) {
max={gpu.mt}
tickFormatter={(val) => {
const { value, unit } = formatBytes(val, false, Unit.Bytes, true)
return toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
}}
contentFormatter={({ value }) => {
const { value: convertedValue, unit } = formatBytes(value, false, Unit.Bytes, true)
return decimalString(convertedValue) + " " + unit
return `${decimalString(convertedValue)} ${unit}`
}}
/>
</ChartCard>
@@ -819,11 +835,11 @@ export default memo(function SystemDetail({ name }: { name: string }) {
maxToggled={maxValues}
tickFormatter={(val) => {
const { value, unit } = formatBytes(val, true, userSettings.unitDisk, true)
return toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
}}
contentFormatter={({ value }) => {
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, true)
return decimalString(convertedValue, convertedValue >= 100 ? 1 : 2) + " " + unit
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
}}
/>
</ChartCard>
@@ -846,7 +862,7 @@ function FilterBar({ store = $containerFilter }: { store?: typeof $containerFilt
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
store.set(e.target.value)
}, [])
}, [store])
return (
<>

View File

@@ -1,8 +1,7 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import * as React from "react"
import { buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils"
const AlertDialog = AlertDialogPrimitive.Root

View File

@@ -1,5 +1,5 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import type * as React from "react"
import { cn } from "@/lib/utils"

View File

@@ -1,6 +1,6 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react"
import { cn } from "@/lib/utils"

View File

@@ -1,10 +1,8 @@
import type { JSX } from "react"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { chartTimeData, cn } from "@/lib/utils"
import { ChartData } from "@/types"
import type { JSX } from "react"
import type { ChartData } from "@/types"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
@@ -134,7 +132,7 @@ const ChartTooltipContent = React.forwardRef<
payload = payload?.filter((item) => (item.name as string)?.toLowerCase().includes(filter.toLowerCase()))
}
if (itemSorter) {
// @ts-ignore
// @ts-expect-error
payload?.sort(itemSorter)
}
}, [itemSorter, payload])
@@ -331,7 +329,7 @@ function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key:
}
let cachedAxis: JSX.Element
const xAxis = function ({ domain, ticks, chartTime }: ChartData) {
const xAxis = ({ domain, ticks, chartTime }: ChartData) => {
if (cachedAxis && domain[0] === cachedAxis.props.domain[0]) {
return cachedAxis
}

View File

@@ -1,8 +1,8 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"

View File

@@ -1,5 +1,5 @@
import * as React from "react"
import { ChevronDownIcon, HourglassIcon } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import { Button } from "./button"
@@ -17,11 +17,7 @@ export function Collapsible({ title, children, description, defaultOpen = false,
return (
<div className={cn("border rounded-lg", className)}>
<Button
variant="ghost"
className="w-full justify-between p-4 font-semibold"
onClick={() => setIsOpen(!isOpen)}
>
<Button variant="ghost" className="w-full justify-between p-4 font-semibold" onClick={() => setIsOpen(!isOpen)}>
<div className="flex items-center gap-2">
{icon}
{title}
@@ -32,18 +28,12 @@ export function Collapsible({ title, children, description, defaultOpen = false,
})}
/>
</Button>
{description && (
<div className="px-4 pb-2 text-sm text-muted-foreground">
{description}
</div>
)}
{description && <div className="px-4 pb-2 text-sm text-muted-foreground">{description}</div>}
{isOpen && (
<div className="px-4 pb-4">
<div className="grid gap-3">
{children}
</div>
<div className="grid gap-3">{children}</div>
</div>
)}
</div>
)
}
}

View File

@@ -1,9 +1,8 @@
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import type * as React from "react"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { cn } from "@/lib/utils"
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
return (

View File

@@ -1,6 +1,6 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"

View File

@@ -1,6 +1,6 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"

View File

@@ -1,4 +1,4 @@
import { SVGProps } from "react"
import type { SVGProps } from "react"
// linux-logo-bold from https://github.com/phosphor-icons/core (MIT license)
export function TuxIcon(props: SVGProps<SVGSVGElement>) {

View File

@@ -1,9 +1,9 @@
import { copyToClipboard } from "@/lib/utils"
import { Input } from "./input"
import { Trans } from "@lingui/react/macro"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip"
import { CopyIcon } from "lucide-react"
import { copyToClipboard } from "@/lib/utils"
import { Button } from "./button"
import { Input } from "./input"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip"
export function InputCopy({ value, id, name }: { value: string; id: string; name: string }) {
return (

View File

@@ -1,9 +1,9 @@
import { XIcon } from "lucide-react"
import * as React from "react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
import { type InputProps } from "./input"
import { cn } from "@/lib/utils"
import type { InputProps } from "./input"
type InputTagsProps = Omit<InputProps, "value" | "onChange"> & {
value: string[]

View File

@@ -1,4 +1,4 @@
import * as React from "react"
import type * as React from "react"
import { cn } from "@/lib/utils"

View File

@@ -1,6 +1,6 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react"
import { cn } from "@/lib/utils"

View File

@@ -0,0 +1,66 @@
import { OTPInput, OTPInputContext } from "input-otp"
import { MinusIcon } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn("flex items-center gap-2 has-disabled:opacity-50", containerClassName)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
)
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="input-otp-group" className={cn("flex items-center", className)} {...props} />
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number
}) {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
)
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
)
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

View File

@@ -1,6 +1,6 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
@@ -78,8 +78,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width)"
position === "popper" && "h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width)"
)}
>
{children}

View File

@@ -1,5 +1,5 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import * as React from "react"
import { cn } from "@/lib/utils"

View File

@@ -1,6 +1,6 @@
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import type * as React from "react"
import { cn } from "@/lib/utils"

View File

@@ -1,5 +1,5 @@
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import * as React from "react"
import { cn } from "@/lib/utils"

View File

@@ -1,5 +1,5 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import * as React from "react"
import { cn } from "@/lib/utils"

View File

@@ -1,5 +1,5 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import * as React from "react"
import { cn } from "@/lib/utils"

View File

@@ -1,7 +1,7 @@
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"

View File

@@ -6,18 +6,16 @@ export function Toaster() {
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>}
</div>
{action}
<ToastClose />
</Toast>
)
})}
{toasts.map(({ id, title, description, action, ...props }) => (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>}
</div>
{action}
<ToastClose />
</Toast>
))}
<ToastViewport />
</ToastProvider>
)

View File

@@ -1,5 +1,5 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import type * as React from "react"
import { cn } from "@/lib/utils"

View File

@@ -103,7 +103,7 @@ export const reducer = (state: State, action: Action): State => {
? {
...t,
open: false,
}
}
: t
),
}

View File

@@ -1,9 +1,9 @@
import type { AlertInfo, AlertRecord } from "@/types"
import type { RecordSubscription } from "pocketbase"
import { $alerts } from "@/lib/stores"
import { EthernetIcon } from "@/components/ui/icons"
import { ServerIcon, CpuIcon, MemoryStickIcon, HardDriveIcon, ThermometerIcon, HourglassIcon } from "lucide-react"
import { t } from "@lingui/core/macro"
import { CpuIcon, HardDriveIcon, HourglassIcon, MemoryStickIcon, ServerIcon, ThermometerIcon } from "lucide-react"
import type { RecordSubscription } from "pocketbase"
import { EthernetIcon } from "@/components/ui/icons"
import { $alerts } from "@/lib/stores"
import type { AlertInfo, AlertRecord } from "@/types"
import { pb } from "./api"
/** Alert info for each alert type */
@@ -14,7 +14,7 @@ export const alertInfo: Record<string, AlertInfo> = {
icon: ServerIcon,
desc: () => t`Triggers when status switches between up and down`,
/** "for x minutes" is appended to desc when only one value */
singleDesc: () => t`System` + " " + t`Down`,
singleDesc: () => `${t`System`} ${t`Down`}`,
},
CPU: {
name: () => t`CPU Usage`,
@@ -127,7 +127,7 @@ export const alertManager = (() => {
return (data: RecordSubscription<AlertRecord>) => {
const { record } = data
batch.set(`${record.system}${record.name}`, data)
clearTimeout(timeout!)
clearTimeout(timeout)
timeout = setTimeout(() => {
const groups = { create: [], update: [], delete: [] } as Record<string, AlertRecord[]>
for (const { action, record } of batch.values()) {

View File

@@ -1,10 +1,10 @@
import { ChartTimes, UserSettings } from "@/types"
import { $alerts, $allSystemsByName, $userSettings } from "./stores"
import { toast } from "@/components/ui/use-toast"
import { t } from "@lingui/core/macro"
import { chartTimeData } from "./utils"
import PocketBase from "pocketbase"
import { basePath } from "@/components/router"
import { toast } from "@/components/ui/use-toast"
import type { ChartTimes, UserSettings } from "@/types"
import { $alerts, $allSystemsByName, $userSettings } from "./stores"
import { chartTimeData } from "./utils"
/** PocketBase JS Client */
export const pb = new PocketBase(basePath)
@@ -46,7 +46,7 @@ export async function updateUserSettings() {
}
// create user settings if error fetching existing
try {
const createdSettings = await pb.collection("user_settings").create({ user: pb.authStore.record!.id })
const createdSettings = await pb.collection("user_settings").create({ user: pb.authStore.record?.id })
$userSettings.set(createdSettings.settings)
} catch (e) {
console.error("create settings", e)

View File

@@ -46,3 +46,10 @@ export enum BatteryState {
Discharging,
Idle,
}
/** Time format */
export enum HourFormat {
// Default = "Default",
"12h" = "12h",
"24h" = "24h",
}

View File

@@ -1,11 +1,11 @@
import { $direction } from "./stores"
import { i18n } from "@lingui/core"
import type { Messages } from "@lingui/core"
import { i18n } from "@lingui/core"
import { t } from "@lingui/core/macro"
import { detect, fromNavigator, fromStorage } from "@lingui/detect-locale"
import languages from "@/lib/languages"
import { detect, fromStorage, fromNavigator } from "@lingui/detect-locale"
import { messages as enMessages } from "@/locales/en/en"
import { BatteryState } from "./enums"
import { t } from "@lingui/core/macro"
import { $direction } from "./stores"
// activates locale
function activateLocale(locale: string, messages: Messages = enMessages) {
@@ -18,7 +18,7 @@ function activateLocale(locale: string, messages: Messages = enMessages) {
// dynamically loads translations for the given locale
export async function dynamicActivate(locale: string) {
if (locale == "en") {
if (locale === "en") {
activateLocale(locale)
} else {
try {

View File

@@ -1,7 +1,7 @@
import { atom, computed, map, ReadableAtom } from "nanostores"
import { AlertMap, ChartTimes, SystemRecord, UserSettings } from "@/types"
import { Unit } from "./enums"
import { atom, computed, listenKeys, map, type ReadableAtom } from "nanostores"
import type { AlertMap, ChartTimes, SystemRecord, UserSettings } from "@/types"
import { pb } from "./api"
import { Unit } from "./enums"
/** Store if user is authenticated */
export const $authenticated = atom(pb.authStore.isValid)
@@ -50,7 +50,7 @@ export const $userSettings = map<UserSettings>({
unitTemp: Unit.Celsius,
})
// update chart time on change
$userSettings.subscribe((value) => $chartTime.set(value.chartTime))
listenKeys($userSettings, ["chartTime"], ({ chartTime }) => $chartTime.set(chartTime))
/** Container chart filter */
export const $containerFilter = atom("")

View File

@@ -1,15 +1,15 @@
import { SystemRecord } from "@/types"
import { PreinitializedMapStore } from "nanostores"
import type { PreinitializedMapStore } from "nanostores"
import { pb, verifyAuth } from "@/lib/api"
import {
$allSystemsByName,
$upSystems,
$downSystems,
$pausedSystems,
$allSystemsById,
$allSystemsByName,
$downSystems,
$longestSystemNameLen,
$pausedSystems,
$upSystems,
} from "@/lib/stores"
import { updateFavicon, FAVICON_DEFAULT, FAVICON_GREEN, FAVICON_RED } from "@/lib/utils"
import { FAVICON_DEFAULT, FAVICON_GREEN, FAVICON_RED, updateFavicon } from "@/lib/utils"
import type { SystemRecord } from "@/types"
import { SystemStatus } from "./enums"
const COLLECTION = pb.collection<SystemRecord>("systems")

0
src/site/src/lib/time.ts Normal file
View File

View File

@@ -1,13 +1,14 @@
import { t } from "@lingui/core/macro"
import { toast } from "@/components/ui/use-toast"
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
import { $copyContent, $userSettings } from "./stores"
import type { ChartTimeData, FingerprintRecord, SemVer, SystemRecord } from "@/types"
import { timeDay, timeHour } from "d3-time"
import { useEffect, useState } from "react"
import { MeterState, Unit } from "./enums"
import { twMerge } from "tailwind-merge"
import { prependBasePath } from "@/components/router"
import { toast } from "@/components/ui/use-toast"
import type { ChartTimeData, FingerprintRecord, SemVer, SystemRecord } from "@/types"
import { HourFormat, MeterState, Unit } from "./enums"
import { $copyContent, $userSettings } from "./stores"
import { listenKeys } from "nanostores"
export const FAVICON_DEFAULT = "favicon.svg"
export const FAVICON_GREEN = "favicon-green.svg"
@@ -31,29 +32,52 @@ export async function copyToClipboard(content: string) {
duration,
description: t`Copied to clipboard`,
})
} catch (e: any) {
} catch (e) {
$copyContent.set(content)
}
}
const hourWithMinutesFormatter = new Intl.DateTimeFormat(undefined, {
hour: "numeric",
minute: "numeric",
})
// Create formatters directly without intermediate containers
const createHourWithMinutesFormatter = (hour12?: boolean) =>
new Intl.DateTimeFormat(undefined, {
hour: "numeric",
minute: "numeric",
hour12,
})
const createShortDateFormatter = (hour12?: boolean) =>
new Intl.DateTimeFormat(undefined, {
day: "numeric",
month: "short",
hour: "numeric",
minute: "numeric",
hour12,
})
// Initialize formatters with default values
let hourWithMinutesFormatter = createHourWithMinutesFormatter()
let shortDateFormatter = createShortDateFormatter()
export const currentHour12 = () => shortDateFormatter.resolvedOptions().hour12
export const hourWithMinutes = (timestamp: string) => {
return hourWithMinutesFormatter.format(new Date(timestamp))
}
const shortDateFormatter = new Intl.DateTimeFormat(undefined, {
day: "numeric",
month: "short",
hour: "numeric",
minute: "numeric",
})
export const formatShortDate = (timestamp: string) => {
return shortDateFormatter.format(new Date(timestamp))
}
// Update the time formatters if user changes hourFormat
listenKeys($userSettings, ["hourFormat"], ({ hourFormat }) => {
if (!hourFormat) return
const newHour12 = hourFormat === HourFormat["12h"]
if (currentHour12() !== newHour12) {
hourWithMinutesFormatter = createHourWithMinutesFormatter(newHour12)
shortDateFormatter = createShortDateFormatter(newHour12)
}
})
const dayFormatter = new Intl.DateTimeFormat(undefined, {
day: "numeric",
month: "short",
@@ -113,7 +137,7 @@ export function toFixedFloat(num: number, digits: number) {
return parseFloat((digits === 0 ? Math.ceil(num) : num).toFixed(digits))
}
let decimalFormatters: Map<number, Intl.NumberFormat> = new Map()
const decimalFormatters: Map<number, Intl.NumberFormat> = new Map()
/** Format number to x decimal places, maintaining trailing zeros */
export function decimalString(num: number, digits = 2) {
if (digits === 0) {
@@ -131,7 +155,7 @@ export function decimalString(num: number, digits = 2) {
}
/** Get value from local or session storage */
function getStorageValue(key: string, defaultValue: any, storageInterface: Storage = localStorage) {
function getStorageValue(key: string, defaultValue: unknown, storageInterface: Storage = localStorage) {
const saved = storageInterface?.getItem(key)
return saved ? JSON.parse(saved) : defaultValue
}
@@ -142,6 +166,7 @@ export function useBrowserStorage<T>(key: string, defaultValue: T, storageInterf
const [value, setValue] = useState(() => {
return getStorageValue(key, defaultValue, storageInterface)
})
// biome-ignore lint/correctness/useExhaustiveDependencies: storageInterface won't change
useEffect(() => {
storageInterface?.setItem(key, JSON.stringify(value))
}, [key, value])
@@ -155,7 +180,7 @@ export function formatTemperature(celsius: number, unit?: Unit): { value: number
unit = $userSettings.get().unitTemp || Unit.Celsius
}
// need loose equality check due to form data being strings
if (unit == Unit.Fahrenheit) {
if (unit === Unit.Fahrenheit) {
return {
value: celsius * 1.8 + 32,
unit: "°F",
@@ -178,7 +203,7 @@ export function formatBytes(
if (isMegabytes) size *= 1024 * 1024
// need loose equality check due to form data being strings
if (unit == Unit.Bits) {
if (unit === Unit.Bits) {
const bits = size * 8
const suffix = perSecond ? "ps" : ""
if (bits < 1000) return { value: bits, unit: `b${suffix}` }
@@ -314,6 +339,7 @@ export function getMeterState(value: number): MeterState {
return value >= colorCrit ? MeterState.Crit : value >= colorWarn ? MeterState.Warn : MeterState.Good
}
// biome-ignore lint/suspicious/noExplicitAny: any is used to allow any function to be passed in
export function debounce<T extends (...args: any[]) => any>(func: T, wait: number): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout>
return (...args: Parameters<T>) => {
@@ -323,8 +349,10 @@ export function debounce<T extends (...args: any[]) => any>(func: T, wait: numbe
}
// Cache for runOnce
// biome-ignore lint/complexity/noBannedTypes: Function is used to allow any function to be passed in
const runOnceCache = new WeakMap<Function, { done: boolean; result: unknown }>()
/** Run a function only once */
// biome-ignore lint/suspicious/noExplicitAny: any is used to allow any function to be passed in
export function runOnce<T extends (...args: any[]) => any>(fn: T): T {
return ((...args: Parameters<T>) => {
let state = runOnceCache.get(fn)

View File

@@ -1,21 +1,21 @@
import "./index.css"
// import { Suspense, lazy, useEffect, StrictMode } from "react"
import { Suspense, lazy, memo, useEffect } from "react"
import ReactDOM from "react-dom/client"
import { ThemeProvider } from "./components/theme-provider.tsx"
import { DirectionProvider } from "@radix-ui/react-direction"
import { $authenticated, $publicKey, $copyContent, $direction } from "./lib/stores.ts"
import { pb, updateUserSettings } from "./lib/api.ts"
import * as systemsManager from "./lib/systemsManager.ts"
import { useStore } from "@nanostores/react"
import { Toaster } from "./components/ui/toaster.tsx"
import { $router } from "./components/router.tsx"
import Navbar from "./components/navbar.tsx"
import { I18nProvider } from "@lingui/react"
import { i18n } from "@lingui/core"
import { getLocale, dynamicActivate } from "./lib/i18n"
import { alertManager } from "./lib/alerts"
import Settings from "./components/routes/settings/layout.tsx"
import { I18nProvider } from "@lingui/react"
import { useStore } from "@nanostores/react"
import { DirectionProvider } from "@radix-ui/react-direction"
// import { Suspense, lazy, useEffect, StrictMode } from "react"
import { lazy, memo, Suspense, useEffect } from "react"
import ReactDOM from "react-dom/client"
import Navbar from "@/components/navbar.tsx"
import { $router } from "@/components/router.tsx"
import Settings from "@/components/routes/settings/layout.tsx"
import { ThemeProvider } from "@/components/theme-provider.tsx"
import { Toaster } from "@/components/ui/toaster.tsx"
import { alertManager } from "@/lib/alerts"
import { pb, updateUserSettings } from "@/lib/api.ts"
import { dynamicActivate, getLocale } from "@/lib/i18n"
import { $authenticated, $copyContent, $direction, $publicKey } from "@/lib/stores.ts"
import * as systemsManager from "@/lib/systemsManager.ts"
const LoginPage = lazy(() => import("@/components/login/login.tsx"))
const Home = lazy(() => import("@/components/routes/home.tsx"))
@@ -114,7 +114,7 @@ const I18nApp = () => {
)
}
ReactDOM.createRoot(document.getElementById("app")!).render(
ReactDOM.createRoot(document.getElementById("app") as HTMLElement).render(
// strict mode in dev mounts / unmounts components twice
// and breaks the clipboard dialog
//<StrictMode>

View File

@@ -1,5 +1,5 @@
import { RecordModel } from "pocketbase"
import { Unit, Os, BatteryState } from "./lib/enums"
import type { RecordModel } from "pocketbase"
import type { Unit, Os, BatteryState, HourFormat } from "./lib/enums"
// global window properties
declare global {
@@ -238,6 +238,7 @@ export interface UserSettings {
unitDisk?: Unit
colorWarn?: number
colorCrit?: number
hourFormat?: HourFormat
}
type ChartDataContainer = {
@@ -262,17 +263,17 @@ export interface ChartData {
chartTime: ChartTimes
}
interface AlertInfo {
name: () => string
unit: string
icon: any
desc: () => string
max?: number
min?: number
step?: number
start?: number
/** Single value description (when there's only one value, like status) */
singleDesc?: () => string
}
// interface AlertInfo {
// name: () => string
// unit: string
// icon: any
// desc: () => string
// max?: number
// min?: number
// step?: number
// start?: number
// /** Single value description (when there's only one value, like status) */
// singleDesc?: () => string
// }
export type AlertMap = Record<string, Map<string, AlertRecord>>

View File

@@ -12,6 +12,7 @@ is_freebsd() {
[ "$(uname -s)" = "FreeBSD" ]
}
# If SELinux is enabled, set the context of the binary
set_selinux_context() {
# Check if SELinux is enabled and in enforcing or permissive mode
@@ -23,8 +24,8 @@ set_selinux_context() {
# First try to set persistent context if semanage is available
if command -v semanage >/dev/null 2>&1; then
echo "Attempting to set persistent SELinux context..."
if semanage fcontext -a -t bin_t "/opt/beszel-agent/beszel-agent" >/dev/null 2>&1; then
restorecon -v /opt/beszel-agent/beszel-agent >/dev/null 2>&1
if semanage fcontext -a -t bin_t "$BIN_PATH" >/dev/null 2>&1; then
restorecon -v "$BIN_PATH" >/dev/null 2>&1
else
echo "Warning: Failed to set persistent context, falling back to temporary context."
fi
@@ -33,8 +34,8 @@ set_selinux_context() {
# Fall back to chcon if semanage failed or isn't available
if command -v chcon >/dev/null 2>&1; then
# Set context for both the directory and binary
chcon -t bin_t /opt/beszel-agent/beszel-agent || echo "Warning: Failed to set SELinux context for binary."
chcon -R -t bin_t /opt/beszel-agent || echo "Warning: Failed to set SELinux context for directory."
chcon -t bin_t "$BIN_PATH" || echo "Warning: Failed to set SELinux context for binary."
chcon -R -t bin_t "$AGENT_DIR" || echo "Warning: Failed to set SELinux context for directory."
else
if [ "$SELINUX_MODE" = "Enforcing" ]; then
echo "Warning: SELinux is in enforcing mode but chcon command not found. The service may fail to start."
@@ -53,7 +54,7 @@ cleanup_selinux_context() {
echo "Cleaning up SELinux contexts..."
# Remove persistent context if semanage is available
if command -v semanage >/dev/null 2>&1; then
semanage fcontext -d "/opt/beszel-agent/beszel-agent" 2>/dev/null || true
semanage fcontext -d "$BIN_PATH" 2>/dev/null || true
fi
fi
}
@@ -85,11 +86,11 @@ generate_freebsd_rc_service() {
# beszel_agent_enable (bool): Set to YES to enable Beszel Agent
# Default: YES
# beszel_agent_env_file (str): Beszel Agent env configuration file
# Default: /opt/beszel-agent/env
# Default: /usr/local/etc/beszel-agent/env
# beszel_agent_user (str): Beszel Agent daemon user
# Default: beszel
# beszel_agent_bin (str): Path to the beszel-agent binary
# Default: /opt/beszel-agent/beszel-agent
# Default: /usr/local/sbin/beszel-agent
# beszel_agent_flags (str): Extra flags passed to beszel-agent command invocation
# Default:
@@ -102,8 +103,8 @@ load_rc_config $name
: ${beszel_agent_enable:="YES"}
: ${beszel_agent_user:="beszel"}
: ${beszel_agent_flags:=""}
: ${beszel_agent_env_file:="/opt/beszel-agent/env"}
: ${beszel_agent_bin:="/opt/beszel-agent/beszel-agent"}
: ${beszel_agent_env_file:="/usr/local/etc/beszel-agent/env"}
: ${beszel_agent_bin:="/usr/local/sbin/beszel-agent"}
logfile="/var/log/${name}.log"
pidfile="/var/run/${name}.pid"
@@ -297,6 +298,17 @@ while [ $# -gt 0 ]; do
shift
done
# Set paths based on operating system
if is_freebsd; then
AGENT_DIR="/usr/local/etc/beszel-agent"
BIN_DIR="/usr/local/sbin"
BIN_PATH="/usr/local/sbin/beszel-agent"
else
AGENT_DIR="/opt/beszel-agent"
BIN_DIR="/opt/beszel-agent"
BIN_PATH="/opt/beszel-agent/beszel-agent"
fi
# Uninstall process
if [ "$UNINSTALL" = true ]; then
# Clean up SELinux contexts before removing files
@@ -329,7 +341,12 @@ if [ "$UNINSTALL" = true ]; then
# Remove the update service if it exists
echo "Removing the daily update service..."
# Remove legacy beszel account based crontab file
rm -f /etc/crontabs/beszel
# Install root crontab job
if crontab -u root -l 2>/dev/null | grep -q "beszel-agent.*update"; then
crontab -u root -l 2>/dev/null | grep -v "beszel-agent.*update" | crontab -u root -
fi
elif is_freebsd; then
echo "Stopping and disabling the agent service..."
@@ -341,17 +358,17 @@ if [ "$UNINSTALL" = true ]; then
# Remove the daily update cron job if it exists
echo "Removing the daily update cron job..."
if crontab -u root -l 2>/dev/null | grep -q "beszel-agent.*update"; then
crontab -u root -l 2>/dev/null | grep -v "beszel-agent.*update" | crontab -u root -
fi
rm -f /etc/cron.d/beszel-agent
# Remove log files
echo "Removing log files..."
rm -f /var/log/beszel-agent.log
# Remove env file
# Remove env file and directories
echo "Removing environment configuration file..."
rm -f /opt/beszel-agent/env
rm -f "$AGENT_DIR/env"
rm -f "$BIN_PATH"
rmdir "$AGENT_DIR" 2>/dev/null || true
else
echo "Stopping and disabling the agent service..."
@@ -372,7 +389,7 @@ if [ "$UNINSTALL" = true ]; then
fi
echo "Removing the Beszel Agent directory..."
rm -rf /opt/beszel-agent
rm -rf "$AGENT_DIR"
echo "Removing the dedicated user for the agent service..."
killall beszel-agent 2>/dev/null
@@ -503,7 +520,7 @@ elif is_openwrt; then
elif is_freebsd; then
if ! id -u beszel >/dev/null 2>&1; then
pw user add beszel -u 2000 -d /nonexistent -s /usr/sbin/nologin -c "beszel user"
pw user add beszel -d /nonexistent -s /usr/sbin/nologin -c "beszel user"
fi
# Add the user to the wheel group to allow self-updates
if pw group show wheel >/dev/null 2>&1; then
@@ -523,11 +540,16 @@ else
fi
# Create the directory for the Beszel Agent
if [ ! -d "/opt/beszel-agent" ]; then
if [ ! -d "$AGENT_DIR" ]; then
echo "Creating the directory for the Beszel Agent..."
mkdir -p /opt/beszel-agent
chown beszel:beszel /opt/beszel-agent
chmod 755 /opt/beszel-agent
mkdir -p "$AGENT_DIR"
chown beszel:beszel "$AGENT_DIR"
chmod 755 "$AGENT_DIR"
fi
if [ ! -d "$BIN_DIR" ]; then
mkdir -p "$BIN_DIR"
fi
# Download and install the Beszel Agent
@@ -579,9 +601,9 @@ if ! tar -xzf "$FILE_NAME" beszel-agent; then
exit 1
fi
mv beszel-agent /opt/beszel-agent/beszel-agent
chown beszel:beszel /opt/beszel-agent/beszel-agent
chmod 755 /opt/beszel-agent/beszel-agent
mv beszel-agent "$BIN_PATH"
chown beszel:beszel "$BIN_PATH"
chmod 755 "$BIN_PATH"
# Set SELinux context if needed
set_selinux_context
@@ -613,7 +635,7 @@ if is_alpine; then
name="beszel-agent"
description="Beszel Agent Service"
command="/opt/beszel-agent/beszel-agent"
command="$BIN_PATH"
command_user="beszel"
command_background="yes"
pidfile="/run/\${RC_SVCNAME}.pid"
@@ -668,7 +690,7 @@ EOF
# Create cron job to run beszel-agent update command daily at midnight
if ! crontab -u root -l 2>/dev/null | grep -q "beszel-agent.*update"; then
(crontab -u root -l 2>/dev/null; echo "12 0 * * * /opt/beszel-agent/beszel-agent update >/dev/null 2>&1") | crontab -u root -
(crontab -u root -l 2>/dev/null; echo "12 0 * * * $BIN_PATH update >/dev/null 2>&1") | crontab -u root -
fi
printf "\nDaily updates have been enabled via cron job.\n"
@@ -692,7 +714,7 @@ START=99
start_service() {
procd_open_instance
procd_set_param command /opt/beszel-agent/beszel-agent
procd_set_param command $BIN_PATH
procd_set_param user beszel
procd_set_param pidfile /var/run/beszel-agent.pid
procd_set_param env PORT="$PORT" KEY="$KEY" TOKEN="$TOKEN" HUB_URL="$HUB_URL"
@@ -716,7 +738,7 @@ EXTRA_HELP=" update Update the Beszel agent
restart Restart the Beszel agent"
update() {
if /opt/beszel-agent/beszel-agent update | grep -q "Successfully updated"; then
if $BIN_PATH update | grep -q "Update completed successfully"; then
/etc/init.d/beszel-agent restart
fi
}
@@ -745,9 +767,9 @@ EOF
[Yy]*)
echo "Setting up daily automatic updates for beszel-agent..."
cat >/etc/crontabs/beszel <<EOF
12 0 * * * /etc/init.d/beszel-agent update
EOF
if ! crontab -u root -l 2>/dev/null | grep -q "beszel-agent.*update"; then
(crontab -u root -l 2>/dev/null; echo "12 0 * * * /etc/init.d/beszel-agent update") | crontab -u root -
fi
/etc/init.d/cron restart
@@ -767,14 +789,14 @@ elif is_freebsd; then
# Create environment configuration file with proper permissions
echo "Creating environment configuration file..."
cat >/opt/beszel-agent/env <<EOF
cat >"$AGENT_DIR/env" <<EOF
LISTEN=$PORT
KEY="$KEY"
TOKEN=$TOKEN
HUB_URL=$HUB_URL
EOF
chmod 640 /opt/beszel-agent/env
chown root:beszel /opt/beszel-agent/env
chmod 640 "$AGENT_DIR/env"
chown root:beszel "$AGENT_DIR/env"
# Create the rc service file
generate_freebsd_rc_service > /usr/local/etc/rc.d/beszel-agent
@@ -808,12 +830,13 @@ EOF
[Yy]*)
echo "Setting up daily automatic updates for beszel-agent..."
# Create cron job to run beszel-agent update command daily
if ! crontab -u root -l 2>/dev/null | grep -q "beszel-agent.*update"; then
(crontab -u root -l 2>/dev/null; echo "12 0 * * * /opt/beszel-agent/beszel-agent update >/dev/null 2>&1") | crontab -u root -
fi
printf "\nDaily updates have been enabled via cron job.\n"
# Create cron job in /etc/cron.d
cat >/etc/cron.d/beszel-agent <<EOF
# Beszel Agent daily update job
12 0 * * * root $BIN_PATH update >/dev/null 2>&1
EOF
chmod 644 /etc/cron.d/beszel-agent
printf "\nDaily updates have been enabled via /etc/cron.d.\n"
;;
esac
@@ -843,7 +866,7 @@ Environment="KEY=$KEY"
Environment="TOKEN=$TOKEN"
Environment="HUB_URL=$HUB_URL"
# Environment="EXTRA_FILESYSTEMS=sdb"
ExecStart=/opt/beszel-agent/beszel-agent
ExecStart=$BIN_PATH
User=beszel
Restart=on-failure
RestartSec=5
@@ -898,7 +921,7 @@ Wants=beszel-agent.service
[Service]
Type=oneshot
ExecStart=/opt/beszel-agent/beszel-agent update
ExecStart=$BIN_PATH update
EOF
# Create systemd timer for the daily update