Compare commits

..

231 Commits

Author SHA1 Message Date
Henry Dollman
22e9ede766 release 0.5.0 2024-09-29 16:37:09 -04:00
Henry Dollman
9ab359d3cf add SENSORS env var 2024-09-29 16:36:32 -04:00
Henry Dollman
5447ccad47 make sure temp chart starts at 0 degrees 2024-09-29 16:33:23 -04:00
Henry Dollman
3e51d79c37 update memory chart colors 2024-09-29 15:14:55 -04:00
Henry Dollman
0996d60224 update go deps 2024-09-29 14:22:21 -04:00
Henry Dollman
7a5ec067f5 refactor records package 2024-09-29 13:36:03 -04:00
Henry Dollman
98563d643d refactor alerts and move managers to hub struct
- resource alerts called from updateSystem to avoid needing to pull from db after update
2024-09-29 13:35:38 -04:00
Henry Dollman
268e364bd4 update MemoryStats type 2024-09-29 12:36:19 -04:00
Henry Dollman
dd84a9fd35 remove semaphore and limit docker host connections to 10 2024-09-29 12:30:30 -04:00
Henry Dollman
2f4e537f72 change containerStatsMutex to a RWMutex 2024-09-28 19:13:24 -04:00
Henry Dollman
9637363cf3 combine container.Stats and container.PrevContainerStats 2024-09-28 18:51:46 -04:00
Henry Dollman
73d0dd25ec agent refactoring - create agent/docker.go, agent/system.go 2024-09-28 17:49:04 -04:00
Henry Dollman
2ecf5572ba remove addr, pubKey fields from agent struct 2024-09-28 16:48:55 -04:00
Henry Dollman
5e97167ee0 Fetch kernel, hostname, cpu at start rather than every run 2024-09-28 16:38:52 -04:00
Henry Dollman
1a4862ecd9 remove containerStats mutex and add stats by index 2024-09-27 19:06:37 -04:00
Henry Dollman
6235d15fa2 tweak colors in memory chart 2024-09-27 19:03:46 -04:00
Henry Dollman
4694642674 add apiContainerList variable to reduce memory allocations 2024-09-27 16:10:31 -04:00
Henry Dollman
56c0b86025 rename variables for clarity 2024-09-27 14:56:53 -04:00
Henry Dollman
82e3f3c7c1 Fix temperature sensors not reporting if any sensor lacks valid data (#167) 2024-09-27 13:10:13 -04:00
Henry Dollman
38a9c535b8 change x axis time format for 1 week chart and add key 2024-09-27 13:08:48 -04:00
Henry Dollman
34c83e7c17 hide temp sensors chart legend if more than 11 sensors 2024-09-27 13:08:11 -04:00
Henry Dollman
fe5732d75a replace icon and remove unneeded usememo in system.tsx 2024-09-27 13:06:31 -04:00
Henry Dollman
cc32b50d82 add agent.debug and comments 2024-09-27 12:17:19 -04:00
Henry Dollman
764e043e83 add slog and LOG_LEVEL to agent 2024-09-26 20:07:35 -04:00
Henry Dollman
cec9339f6d allow monitoring remote mounts (#178) and handle I/O edge case (#183) 2024-09-26 18:01:52 -04:00
Henry Dollman
f96f04f876 change swap chart placement 2024-09-26 16:55:31 -04:00
Henry Dollman
06b1c2200b reorganize agent package 2024-09-26 15:08:26 -04:00
Henry Dollman
e88e2bf3dc agent binary - show correct cores in lxc 2024-09-26 15:00:48 -04:00
Henry Dollman
8621a45383 remove unnecessary buffer pool 2024-09-26 14:57:01 -04:00
Henry Dollman
f2ddee9216 Fix typo in extra disk I/O chart (closes #187) 2024-09-26 10:50:23 -04:00
Henry Dollman
f350d61ee2 add CSP env var to set a custom Content-Security-Policy header value 2024-09-24 15:22:47 -04:00
Henry Dollman
2d670c585d optimize deletion of old records 2024-09-23 17:17:30 -04:00
Henry Dollman
55d1c00903 use same disk I/O chart component for root and extra fs 2024-09-23 13:56:15 -04:00
Henry Dollman
78a9086b55 use same disk usage chart component for root and extra fs 2024-09-23 13:42:44 -04:00
Henry Dollman
4ee169fea5 display TB in disk usage charts if value >= 1 TB 2024-09-23 13:34:40 -04:00
Henry Dollman
a286bed54c update SystemInfo ts type 2024-09-18 13:02:53 -04:00
Henry Dollman
314cee081a system info refactoring 2024-09-18 12:47:06 -04:00
Henry Dollman
e287124632 show swap chart only if swap is in use 2024-09-18 12:46:15 -04:00
Stavros
9cccefd3fa feat: add kernel version text (#170) 2024-09-17 13:43:58 -04:00
Henry Dollman
ec95f63806 limit fields when fetching server list 2024-09-16 18:40:11 -04:00
Henry Dollman
812fe20df7 fix: update settings when visiting a settings page directly 2024-09-16 16:51:23 -04:00
Henry Dollman
ddfcbc546b release 0.4.0 2024-09-16 14:05:00 -04:00
Henry Dollman
c74d5496af update go deps 2024-09-16 14:04:48 -04:00
Henry Dollman
060846d70a update command palette items 2024-09-16 13:38:03 -04:00
Henry Dollman
e03e2b8d67 fix wrapping of y axis chart labels 2024-09-16 13:14:23 -04:00
Henry Dollman
c46879694d allow FILESYSTEM env var to override root usage stats 2024-09-15 18:16:36 -04:00
Henry Dollman
61a68e5be1 refactor findMaxReadsDevice to use disk.IOCounters 2024-09-15 16:29:55 -04:00
Henry Dollman
bd43a2a2c2 focus on input when new webhook added 2024-09-15 11:31:44 -04:00
Henry Dollman
3aeca6af2f add SYS_SENSORS env var 2024-09-14 18:46:16 -04:00
Henry Dollman
3e95269a7c add hostname to system info 2024-09-14 18:09:38 -04:00
Henry Dollman
53b02dd55f change extra disk charts to follow grid layout selection 2024-09-14 17:40:47 -04:00
Henry Dollman
43ba9d5c6a change NIC env var to NICS to support multiple interfaces 2024-09-14 17:30:42 -04:00
Henry Dollman
1cb4a711c3 add NIC env var 2024-09-14 16:47:12 -04:00
Henry Dollman
aef99c3bd9 update package comments 2024-09-14 16:46:48 -04:00
Henry Dollman
138cbc13d6 rename package user to users 2024-09-14 16:44:53 -04:00
Henry Dollman
62d5ae8236 clean up / small refactoring 2024-09-14 15:46:42 -04:00
Henry Dollman
8ce605d65e create UserManager 2024-09-14 15:45:57 -04:00
Henry Dollman
c8743201a2 allow creation of 10m record if nine 1m records 2024-09-14 15:27:42 -04:00
Henry Dollman
f16e22e521 updates to settings page and alerts 2024-09-13 18:19:12 -04:00
Henry Dollman
9710d0d2f1 shoutrrr alerts / settings page 2024-09-12 19:39:27 -04:00
Henry Dollman
2889d151ea alert / settings page updates 2024-09-11 17:47:36 -04:00
Henry Dollman
ce6e887d1b progress on settings / alerts 2024-09-11 15:50:15 -04:00
Henry Dollman
b4cf5bb1c0 alerts updates 2024-09-10 19:38:32 -04:00
Henry Dollman
9bc7773607 further progress on settings / alerts 2024-09-10 19:03:08 -04:00
Henry Dollman
3362a3d1cf progress on settings page 2024-09-09 20:00:09 -04:00
Henry Dollman
3b13fadde2 rm shoutrrr config info from readme 2024-09-09 13:37:09 -04:00
parnic
99d79f7d2d Add support for alerts via shoutrrr (#145)
* Add support for alerts via shoutrrr

This provides users the ability to use a wide variety of notification platforms instead of just email. If there's a problem sending a notification via shoutrrr, an error is logged and email is attempted as a fallback.

Since this uses Viper, users can set a notification type and URL via either a config file or environment variable. In the beszel_data folder (where the sqlite dbs reside), create an alerts.env file to set values that way.

Values:
* NOTIFICATION_TYPE
  * If this is `shoutrrr`, then the shoutrrr library is used. Any other value, including not being set, uses the fallback email behavior.
* NOTIFICATION_URL
  * If NOTIFICATION_TYPE is shoutrrr, this is the URL given to shoutrrr to send the alert. See list of supported services: https://containrrr.dev/shoutrrr/services/overview/

Note: there's currently a bug in viper v1.18.2+ where environment variable overrides aren't functioning when no config file exists, so this library should remain pinned to 1.18.1 until that's fixed. See: https://github.com/spf13/viper/issues/1895

* Update documentation

* Log shoutrrr URL instead of unused "to" var
2024-09-09 13:34:43 -04:00
hank
1fb23ff673 Create SECURITY.md 2024-09-04 16:53:13 -04:00
Henry Dollman
29529d1a84 add EXTRA_FILESYSTEMS to systemd script and example 2024-09-04 16:22:01 -04:00
Bart
9f84629b92 Enhanced README.md (#156)
* Enhanced README.md

* Changed back to 'Sentence case'

* Changed text to reflect comment
2024-09-04 15:29:14 -04:00
Henry Dollman
d2284c3fed release 0.3.0 2024-09-03 18:37:02 -04:00
Henry Dollman
eb420bef3a update go deps 2024-09-03 18:31:01 -04:00
Henry Dollman
9cf6c167b0 don't reset container filter when changing chart time 2024-09-03 18:26:28 -04:00
Henry Dollman
fbc7f79660 add instructions for monitoring additional disks / partitions 2024-09-03 18:23:09 -04:00
Henry Dollman
37170f2bdb refactor network io stats collection (ref #150) 2024-09-03 18:10:45 -04:00
Henry Dollman
af4c05e692 standardize chart values to two decimals 2024-09-03 16:49:12 -04:00
Henry Dollman
202a506485 add dialog for copy to clipboard fallback (fixes #152) 2024-09-02 19:37:44 -04:00
Henry Dollman
aa3866c8ed better alignment for readonly user account menu 2024-09-02 17:32:59 -04:00
Henry Dollman
f9c0d0b89d don't cache mail client (fixes #149) 2024-09-02 16:34:53 -04:00
Henry Dollman
ec5b1a833d extra fs charts and filter bar for container charts 2024-09-02 16:13:07 -04:00
Henry Dollman
1cfda8fb9f add icons to theme menu 2024-09-02 15:56:57 -04:00
Henry Dollman
2168db6ebd remove toggle component 2024-09-02 15:56:14 -04:00
Henry Dollman
e64ef49e97 update js deps / exclude package-lock.json 2024-09-02 15:49:41 -04:00
Henry Dollman
54e0240dd8 remove external fonts 2024-09-02 12:12:52 -04:00
Henry Dollman
05f52ad15a update readme 2024-09-01 19:17:17 -04:00
Henry Dollman
8ffb3a0cc8 update agent docker-compose.yml 2024-09-01 18:50:38 -04:00
Henry Dollman
953d7cac1e use GOGC=75 for docker images 2024-09-01 18:25:40 -04:00
Henry Dollman
1cfd3cdd30 add support for multiple disks 2024-09-01 18:23:57 -04:00
Henry Dollman
b4a3cb9ce6 add timeout to ssh session creation to avoid hanging 2024-08-30 19:06:16 -04:00
Henry Dollman
7a6fbc8346 add toggle for chart grid layout 2024-08-25 18:19:47 -04:00
Henry Dollman
76cffb16de formatting 2024-08-25 18:19:03 -04:00
Henry Dollman
13f7d016e6 fix blurry command pallette on some setups (closes #137) 2024-08-25 16:20:58 -04:00
Henry Dollman
7a8dccfc97 use css color-scheme instead of custom scrollbar styles 2024-08-25 16:19:31 -04:00
Stavros
68824935e9 feat: style scrollbar (#135)
* feat: style scrollbar

* refactor(index.css): increase the border radius a bit

* chore: revert prettier styling
2024-08-25 13:05:55 -04:00
Henry Dollman
42e2e3463e release 0.2.0 2024-08-22 15:43:44 -04:00
Henry Dollman
2ef54bae36 add linux/arm/v7 docker images 2024-08-22 15:29:14 -04:00
Henry Dollman
6766a8120a update go dependencies + hub dockerfile 2024-08-22 15:07:22 -04:00
Henry Dollman
9fcf1e7164 use grid layout for charts 2024-08-22 14:10:03 -04:00
Henry Dollman
130c9bd696 add temperature chart 2024-08-21 22:14:56 -04:00
Henry Dollman
88db920ebe add icon to time select 2024-08-21 15:36:46 -04:00
Henry Dollman
5278805d79 web ui design updates 2024-08-21 15:06:33 -04:00
Henry Dollman
55863f849c get rid of updatedSystem store 2024-08-21 12:50:12 -04:00
Henry Dollman
0590facc89 rerender table if hub version loaded after first render 2024-08-20 18:02:29 -04:00
Henry Dollman
3ff339ba1d change agent check for invalid data to use memory usage rather than networks 2024-08-20 17:40:27 -04:00
Henry Dollman
e92a1d8a4d log sent alert emails in pocketbase 2024-08-20 17:11:16 -04:00
Henry Dollman
2590f5612c add null values to create gaps in chart if expected interval is exceeded. closes #100 2024-08-20 17:10:32 -04:00
Henry Dollman
112685bdf4 Merge branch 'theRealBassist-fix-container-lifetime-check' 2024-08-20 14:23:50 -04:00
Henry Dollman
7e1455161d update container status check 2024-08-20 14:23:24 -04:00
Henry Dollman
be98b77634 Merge branch 'fix-container-lifetime-check' of https://github.com/theRealBassist/beszel into theRealBassist-fix-container-lifetime-check 2024-08-20 14:12:17 -04:00
Henry Dollman
cb75923f9f Merge branch 'theRealBassist-add-agent-version-info' 2024-08-20 14:04:37 -04:00
Henry Dollman
87ab4961fd move gh button + update agent display in table 2024-08-20 14:04:09 -04:00
Henry Dollman
d053f16058 add agent version to getkey route 2024-08-20 14:00:15 -04:00
theRealBassist
19272c05bf Adds a version listing in the hub for each agent. 2024-08-19 17:45:15 -04:00
theRealBassist
e56c5f30e0 Fixes edge cases where container health message does not have expected suffix 2024-08-19 15:29:50 -04:00
Henry Dollman
7cf7b706c1 add check to catch containers in restart loop. closes #103 2024-08-19 13:01:01 -04:00
Henry Dollman
58fe9f723a fix: uri encode system url. closes #102 2024-08-19 12:13:18 -04:00
Henry Dollman
a337ece52e Merge branch 'TOomaAh-feature/change-file-structure' 2024-08-18 18:33:17 -04:00
Henry Dollman
61723a24e0 tweak opacity of non-stacked area charts 2024-08-18 18:32:15 -04:00
Henry Dollman
b7934931cf refactor hub requestJsonFromAgent 2024-08-18 18:30:44 -04:00
Henry Dollman
0566433aa1 move longer records creation to a scheduled job 2024-08-18 18:23:17 -04:00
Henry Dollman
b5607025f7 slight improvements to agent memory usage 2024-08-18 17:45:39 -04:00
Henry Dollman
683dc74cbf update workflows 2024-08-14 14:56:01 -04:00
Henry Dollman
c7e67a9b63 refactor: agent and entities 2024-08-14 14:14:41 -04:00
Henry Dollman
083da9598e refactor: hub 2024-08-14 11:28:43 -04:00
Henry Dollman
f8d2161489 refactor: alerts / emails 2024-08-12 19:13:42 -04:00
Henry Dollman
d0d4e546d9 update dockerfiles and docker workflow 2024-08-11 16:21:02 -04:00
Henry Dollman
2ac5d797d2 update readme / .gitignore 2024-08-11 16:20:29 -04:00
Henry Dollman
4105567051 remove cobra dependency for agent 2024-08-11 15:54:27 -04:00
Henry Dollman
c06eabefe0 add docker compose examples for hub / agent to supplemental 2024-08-11 15:22:30 -04:00
Henry Dollman
9d2192f323 update migrations / gitignore 2024-08-11 15:14:58 -04:00
Henry Dollman
dd55e74ec9 update goreleaser config 2024-08-11 13:57:09 -04:00
Henry Dollman
9da1e5751a move application code into beszel folder 2024-08-11 13:41:57 -04:00
TOomaAh
ea71492d13 fix migrations 2024-08-11 02:18:24 +02:00
THOMAS B
b51770a703 Merge branch 'henrygd:main' into feature/change-file-structure 2024-08-10 11:21:02 +02:00
Henry Dollman
39596b8da8 add -p flag to hub installer and run on 0.0.0.0 2024-08-09 12:17:22 -04:00
TOomaAh
932d126117 remove global variable 2024-08-09 17:58:09 +02:00
TOomaAh
034a5c21eb change agent file structure 2024-08-09 17:52:02 +02:00
TOomaAh
d840178cc0 change hub file structure 2024-08-07 23:59:02 +02:00
Henry Dollman
8a04a9bed6 release 0.1.2 2024-08-07 16:19:27 -04:00
Henry Dollman
3f692ce528 close idle connections on timeout 2024-08-07 16:12:18 -04:00
Henry Dollman
876fb6e02e refresh auth status if no systems are found 2024-08-07 15:07:22 -04:00
Henry Dollman
2eb691661c improve y axis annoyances on charts 2024-08-06 20:23:59 -04:00
Henry Dollman
f4332d69d5 adjust y axis width 2024-08-06 18:50:12 -04:00
Henry Dollman
b958e84572 fix: down systems jamming the system update queue 2024-08-06 18:15:12 -04:00
Henry Dollman
7ce6f76315 use precise number for max mem in memory chart 2024-08-06 17:36:11 -04:00
Henry Dollman
cdd10a3011 add swap usage chart 2024-08-06 16:46:25 -04:00
Henry Dollman
dcdee1d943 update deps for agent 2024-08-06 16:25:41 -04:00
Henry Dollman
f4e82ecd59 update readme 2024-08-06 15:13:06 -04:00
Henry Dollman
b8a2d0f32f use txDao in deleteOldRecords for deletion only 2024-08-06 15:09:46 -04:00
Henry Dollman
f13f0b2f8a make sure deletion of container stats is thread safe 2024-08-06 14:44:31 -04:00
Henry Dollman
fdf0ce22dc improve chart scaling + add space below docker net chart for tooltip 2024-08-05 19:17:54 -04:00
Henry Dollman
f36b0a4528 add freebsd and mips64 binaries 2024-08-05 19:04:52 -04:00
Henry Dollman
a73a01fe37 measure docker network stats per second 2024-08-05 18:58:00 -04:00
Henry Dollman
c6b9f1ab77 use promise.allsettled to stop docker chart from populating later 2024-08-04 21:51:11 -04:00
Henry Dollman
8ef30e0733 lazy load charts and disable chart animations 2024-08-04 20:14:13 -04:00
Henry Dollman
e3ed07a999 adapt y axis width in recharts 2024-08-04 16:35:12 -04:00
Henry Dollman
b05184a654 mobile style tweaks 2024-08-04 13:58:18 -04:00
Henry Dollman
2a3b228668 add docker container net stats 2024-08-04 13:26:17 -04:00
Henry Dollman
c3e3d483b0 update js deps 2024-08-04 13:19:19 -04:00
Henry Dollman
b0c6151664 simplify system chart data 2024-08-02 19:55:38 -04:00
Henry Dollman
c9196def32 update install-agent.sh to add beszel user to docker group 2024-08-02 14:59:26 -04:00
Henry Dollman
59cbaf3009 fix FromAsCasing warning 2024-08-02 13:06:49 -04:00
Henry Dollman
bc3f7257c0 update systemd guide 2024-08-01 17:49:17 -04:00
Henry Dollman
4ae65f061c Merge branch 'delta-whiplash-main' 2024-08-01 17:44:00 -04:00
Henry Dollman
0f9aa11255 update docs for systemd / reorganize supplemental directory 2024-08-01 17:40:15 -04:00
Henry Dollman
092f09b084 update linux install scripts to work with other distros 2024-08-01 16:00:19 -04:00
DeltaWhiplash
4841b95a8d Update the Readme for new install scripts 2024-08-01 15:50:38 +02:00
DeltaWhiplash
0ab9ba0614 add dependencies install for the hub script installer 2024-08-01 15:40:06 +02:00
DeltaWhiplash
d809704ab3 Add Automated hub install script for debian/ubuntu 2024-08-01 15:36:56 +02:00
DeltaWhiplash
8d71e95d0b Add Automated agent install script for debian/ubuntu 2024-08-01 15:27:51 +02:00
Henry Dollman
e204bcf9ce add support for docker socket proxy 2024-07-31 19:14:51 -04:00
Henry Dollman
e26e9fce03 move systemd instructions to the supplemental directory 2024-07-31 17:37:38 -04:00
Henry Dollman
4dd201de0d use more specific methods to retrieve record fields 2024-07-31 16:52:26 -04:00
Henry Dollman
de7e07963d improve efficiency of hourly cleanup operation 2024-07-31 16:26:41 -04:00
Henry Dollman
ac6f50c40c update readme and add same-system docker example 2024-07-31 15:59:10 -04:00
hank
4c680a2ab9 update readme - add path to update commands 2024-07-28 22:30:31 -04:00
Henry Dollman
c2cfe8cad6 release 0.1.1 2024-07-28 18:11:15 -04:00
Henry Dollman
9a43ee8f1d Allow address in agent's PORT env var 2024-07-28 17:53:41 -04:00
Henry Dollman
556434f043 fix agent losing track of container after restart 2024-07-28 17:33:07 -04:00
Henry Dollman
f2ff27aaa2 remove unnecessary time.Sleep in getServerConnection 2024-07-28 14:30:31 -04:00
Henry Dollman
93dce463d9 update site dependencies 2024-07-28 14:19:02 -04:00
Henry Dollman
bb23673547 default values for system / update collections snapshot 2024-07-28 13:16:04 -04:00
Henry Dollman
517f949a30 uniform x axis on charts 2024-07-28 12:48:46 -04:00
Henry Dollman
f54faa6bd6 make user role optional and default to 'user' 2024-07-28 11:40:38 -04:00
Henry Dollman
c4e62bd099 only show GitHub button / dialog during onboarding 2024-07-28 11:09:48 -04:00
Henry Dollman
d3033ed72e improve error handling in getSystemStats 2024-07-27 21:45:26 -04:00
Henry Dollman
d0f51e5ca9 skip network interfaces if they have no traffic 2024-07-27 21:13:37 -04:00
Henry Dollman
935dca8679 upgrade go deps and readme 2024-07-27 20:59:17 -04:00
Henry Dollman
184445f089 possible fix for ios safari auth popup blockage 2024-07-27 20:56:23 -04:00
Henry Dollman
3dafb8ddd5 add compiling info and tutoriel en français to readme 2024-07-27 18:37:02 -04:00
Henry Dollman
463681e145 add automatic binary releases for 32 bit arm 2024-07-25 21:08:32 -04:00
Henry Dollman
26b307a629 update binary install commands and instructions 2024-07-25 21:07:23 -04:00
Henry Dollman
fe82632804 Merge branch 'MFYDev-main' 2024-07-25 16:20:00 -04:00
Fanyang Meng
94697658f2 correct typo 2024-07-25 15:20:17 -04:00
Fanyang Meng
9a1bfdd24b Update readme.md 2024-07-25 15:11:30 -04:00
Henry Dollman
b668da17f6 change hub compose ports from 127.0.0.1:8090:8090 to 8090:8090 2024-07-25 12:48:33 -04:00
Henry Dollman
26dbb1968a version 0.1.0 2024-07-24 15:34:37 -04:00
Henry Dollman
ee57e84cb8 fallback prompt for copy button in insecure contexts 2024-07-24 15:32:20 -04:00
Henry Dollman
345dbeb757 0.0.1 2024-07-24 10:53:49 -04:00
Henry Dollman
29f5d3ae62 update readme 2024-07-24 10:52:55 -04:00
Henry Dollman
d4b0887153 update forgot password cli instructions 2024-07-24 10:32:25 -04:00
Henry Dollman
06e4dd10e0 0.0.1-alpha.9 2024-07-23 22:41:33 -04:00
Henry Dollman
af4d5137d6 lower 55 sec system update check to 50 sec 2024-07-23 22:41:05 -04:00
Henry Dollman
5e255f8f69 use semaphore to limit concurrency in agent
subtract mem cache from container stats
2024-07-23 22:40:39 -04:00
Henry Dollman
76cfaaa179 0.0.1-alpha.8 2024-07-23 20:09:50 -04:00
Henry Dollman
b89bec31b5 add check for no records 2024-07-23 20:09:09 -04:00
Henry Dollman
0355d9c654 refactoring agent 2024-07-23 20:07:46 -04:00
Henry Dollman
41df7b7392 update agent to use short container id 2024-07-23 19:51:35 -04:00
Henry Dollman
52c77dd361 readme / site style updates 2024-07-23 18:51:49 -04:00
Henry Dollman
ae0f5c938f 0.0.1-alpha.7 2024-07-23 15:47:45 -04:00
Henry Dollman
78dc269538 update logic for batch updating servers 2024-07-23 15:47:15 -04:00
Henry Dollman
f6967eab35 update gitignore / readme 2024-07-23 15:43:26 -04:00
Henry Dollman
e787b6ea1b update docker compose to make docker sock read only 2024-07-23 15:21:03 -04:00
Henry Dollman
844b95dfd0 get container stats synchronously 2024-07-23 15:19:04 -04:00
Henry Dollman
c5776541a0 style / chart axis updates 2024-07-23 14:48:33 -04:00
Henry Dollman
5ba7568acf update readme 2024-07-22 19:01:05 -04:00
Henry Dollman
14c7e2db8f 0.0.1-alpha.6 2024-07-22 16:52:11 -04:00
Henry Dollman
51ed130b53 update bun install github action to not save lockfile 2024-07-22 16:26:08 -04:00
Henry Dollman
b23034a2a8 0.0.1-alpha.5 2024-07-22 16:16:08 -04:00
Henry Dollman
c060e294f9 alerts for cpu, memory, and disk 2024-07-22 16:14:55 -04:00
Henry Dollman
b1d994a0ff verify auth if error fetching systems 2024-07-22 11:05:14 -04:00
Henry Dollman
8f4659b356 docker updates 2024-07-22 10:40:54 -04:00
Henry Dollman
a0bb97f3e8 remove fallback pub key 2024-07-22 10:40:24 -04:00
Henry Dollman
b81c09c358 change server verbiage to system 2024-07-21 22:37:56 -04:00
Henry Dollman
67cc6cf0bb add license 2024-07-21 22:30:53 -04:00
Henry Dollman
be4a583126 0.0.1-alpha.3 2024-07-21 20:59:43 -04:00
Henry Dollman
75f1cb619b update hub dockerfile 2024-07-21 20:30:32 -04:00
Henry Dollman
09806e8688 fix update scripts 2024-07-21 20:03:14 -04:00
Henry Dollman
bb295ca297 0.0.1-alpha.2 2024-07-21 19:20:42 -04:00
Henry Dollman
765a4c707b update the update scripts 2024-07-21 19:20:20 -04:00
Henry Dollman
683032f919 add action for docker images 2024-07-21 19:19:58 -04:00
158 changed files with 7299 additions and 3825 deletions

75
.github/workflows/docker-images.yml vendored Normal file
View File

@@ -0,0 +1,75 @@
name: Make docker images
on:
push:
tags:
- '*'
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- image: henrygd/beszel
context: ./beszel
dockerfile: ./beszel/dockerfile_Hub
- image: henrygd/beszel-agent
context: ./beszel
dockerfile: ./beszel/dockerfile_Agent
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install --no-save --cwd ./beszel/site
- name: Build site
run: bun run --cwd ./beszel/site build
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker metadata
id: metadata
uses: docker/metadata-action@v5
with:
images: ${{ matrix.image }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
# https://github.com/docker/login-action
- name: Login to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: '${{ matrix.context }}'
file: ${{ matrix.dockerfile }}
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: ${{ github.ref_type == 'tag' }}
tags: ${{ steps.metadata.outputs.tags }}
labels: ${{ steps.metadata.outputs.labels }}
cache-from: type=gha
cache-to: type=gha

View File

@@ -21,10 +21,10 @@ jobs:
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install --cwd ./hub/site
run: bun install --no-save --cwd ./beszel/site
- name: Build site
run: bun run --cwd ./hub/site build
run: bun run --cwd ./beszel/site build
- name: Set up Go
uses: actions/setup-go@v5
@@ -34,17 +34,7 @@ jobs:
- name: GoReleaser beszel
uses: goreleaser/goreleaser-action@v6
with:
workdir: ./hub
distribution: goreleaser
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.TOKEN }}
- name: GoReleaser beszel-agent
uses: goreleaser/goreleaser-action@v6
with:
workdir: ./agent
workdir: ./beszel
distribution: goreleaser
version: latest
args: release --clean

9
.gitignore vendored
View File

@@ -3,7 +3,12 @@ pb_data
data
temp
.vscode
beszel
beszel-agent
beszel_data
dist
beszel_data*
dist
*.exe
beszel/cmd/hub/hub
beszel/cmd/agent/agent
node_modules
package-lock.json

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 henrygd
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

9
SECURITY.md Normal file
View File

@@ -0,0 +1,9 @@
# Security Policy
## Reporting a Vulnerability
If you find a vulnerability in the latest version, please email me directly at hank@henrygd.me, or [submit a private advisory](https://github.com/henrygd/beszel/security/advisories/new).
If you submit an advisory, open an empty issue as well to let me know that you did (or email me), as I'm not sure if I get notifications for that.
If the issue is low severity (use best judgement) you may open an issue for it instead of contacting me directly.

View File

@@ -1,36 +0,0 @@
# version: 1
project_name: beszel-agent
before:
hooks:
- go mod tidy
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
- darwin
goarch:
- amd64
- arm64
archives:
- format: tar.gz
name_template: >-
{{ .ProjectName }}_
{{- .Os }}_
{{- .Arch }}
# use zip for windows archives
format_overrides:
- goos: windows
format: zip
changelog:
disable: true
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'

View File

@@ -1,12 +0,0 @@
services:
beszel-agent:
image: 'henrygd/beszel-agent'
container_name: 'beszel-agent'
restart: unless-stopped
network_mode: host
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
- PORT=45876
- KEY="ssh-ed25519 YOUR_PUBLIC_KEY"
# - FILESYSTEM=/dev/sda1 # set to the correct filesystem for disk I/O stats

View File

@@ -1,20 +0,0 @@
FROM --platform=$BUILDPLATFORM golang:alpine as builder
WORKDIR /app
# Download Go modules
COPY go.mod go.sum ./
RUN go mod download
COPY *.go ./
# Build
ARG TARGETOS TARGETARCH
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent .
# ? -------------------------
FROM scratch
COPY --from=builder /agent /agent
ENTRYPOINT ["/agent"]

View File

@@ -1,32 +0,0 @@
module beszel-agent
go 1.22.4
require (
github.com/blang/semver v3.5.1+incompatible
github.com/gliderlabs/ssh v0.3.7
github.com/rhysd/go-github-selfupdate v1.2.3
github.com/shirou/gopsutil/v4 v4.24.6
)
require (
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/golang/protobuf v1.3.2 // indirect
github.com/google/go-github/v30 v30.1.0 // indirect
github.com/google/go-querystring v1.0.0 // indirect
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect
github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/tcnksm/go-gitconfig v0.1.2 // indirect
github.com/tklauser/go-sysconf v0.3.14 // indirect
github.com/tklauser/numcpus v0.8.0 // indirect
github.com/ulikunitz/xz v0.5.9 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/crypto v0.25.0 // indirect
golang.org/x/net v0.21.0 // indirect
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288 // indirect
golang.org/x/sys v0.22.0 // indirect
google.golang.org/appengine v1.3.0 // indirect
)

View File

@@ -1,97 +0,0 @@
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE=
github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-github/v30 v30.1.0 h1:VLDx+UolQICEOKu2m4uAoMti1SxuEBAl7RSEG16L+Oo=
github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQFEufcolZ95JfU8=
github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7VjIE6z8dIvMsI4/s+1qr5EL+zoIGev1BQj1eoJ8=
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae h1:dIZY4ULFcto4tAFlj1FYZl8ztUZ13bdq+PLY+NOfbyI=
github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.2 h1:3mYCb7aPxS/RU7TI1y4rkEn1oKmPRjNJLNEXgw7MH2I=
github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/rhysd/go-github-selfupdate v1.2.3 h1:iaa+J202f+Nc+A8zi75uccC8Wg3omaM7HDeimXA22Ag=
github.com/rhysd/go-github-selfupdate v1.2.3/go.mod h1:mp/N8zj6jFfBQy/XMYoWsmfzxazpPAODuqarmPDe2Rg=
github.com/shirou/gopsutil/v4 v4.24.6 h1:9qqCSYF2pgOU+t+NgJtp7Co5+5mHF/HyKBUckySQL64=
github.com/shirou/gopsutil/v4 v4.24.6/go.mod h1:aoebb2vxetJ/yIDZISmduFvVNPHqXQ9SEJwRXxkf0RA=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tcnksm/go-gitconfig v0.1.2 h1:iiDhRitByXAEyjgBqsKi9QU4o2TNtv9kPP3RgPgXBPw=
github.com/tcnksm/go-gitconfig v0.1.2/go.mod h1:/8EhP4H7oJZdIPyT+/UIsG87kTzrzM4UsLGSItWYCpE=
github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU=
github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY=
github.com/tklauser/numcpus v0.8.0 h1:Mx4Wwe/FjZLeQsK/6kt2EOepwwSl7SmJrK5bV/dXYgY=
github.com/tklauser/numcpus v0.8.0/go.mod h1:ZJZlAY+dmR4eut8epnzf0u/VwodKmryxR8txiloSqBE=
github.com/ulikunitz/xz v0.5.9 h1:RsKRIA2MO8x56wkkcd3LbtcE/uMszhb6DpRf+3uwa3I=
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288 h1:JIqe8uIcRBHXDQVvZtHwp80ai3Lw3IJAeJEs55Dc1W0=
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk=
golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.3.0 h1:FBSsiFRMz3LBeXIomRnVzrQwSDj4ibvcRexLG0LZGQk=
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,397 +0,0 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"log"
"math"
"net"
"net/http"
"os"
"strings"
"sync"
"time"
sshServer "github.com/gliderlabs/ssh"
"github.com/shirou/gopsutil/v4/cpu"
"github.com/shirou/gopsutil/v4/disk"
"github.com/shirou/gopsutil/v4/host"
"github.com/shirou/gopsutil/v4/mem"
psutilNet "github.com/shirou/gopsutil/v4/net"
)
var Version = "0.0.1-alpha.0"
var containerCpuMap = make(map[string][2]uint64)
var containerCpuMutex = &sync.Mutex{}
var diskIoStats = DiskIoStats{
Read: 0,
Write: 0,
Time: time.Now(),
Filesystem: "",
}
var netIoStats = NetIoStats{
BytesRecv: 0,
BytesSent: 0,
Time: time.Now(),
Name: "",
}
// client for docker engine api
var client = &http.Client{
Timeout: time.Second * 5,
Transport: &http.Transport{
Dial: func(proto, addr string) (net.Conn, error) {
return net.Dial("unix", "/var/run/docker.sock")
},
ForceAttemptHTTP2: false,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 100,
IdleConnTimeout: 90 * time.Second,
DisableKeepAlives: false,
},
}
type SystemData struct {
Stats SystemStats `json:"stats"`
Info SystemInfo `json:"info"`
Containers []ContainerStats `json:"container"`
}
type SystemInfo struct {
Cores int `json:"c"`
Threads int `json:"t"`
CpuModel string `json:"m"`
// Os string `json:"o"`
Uptime uint64 `json:"u"`
Cpu float64 `json:"cpu"`
MemPct float64 `json:"mp"`
DiskPct float64 `json:"dp"`
}
type SystemStats struct {
Cpu float64 `json:"cpu"`
Mem float64 `json:"m"`
MemUsed float64 `json:"mu"`
MemPct float64 `json:"mp"`
MemBuffCache float64 `json:"mb"`
Disk float64 `json:"d"`
DiskUsed float64 `json:"du"`
DiskPct float64 `json:"dp"`
DiskRead float64 `json:"dr"`
DiskWrite float64 `json:"dw"`
NetworkSent float64 `json:"ns"`
NetworkRecv float64 `json:"nr"`
}
type ContainerStats struct {
Name string `json:"n"`
Cpu float64 `json:"c"`
Mem float64 `json:"m"`
// MemPct float64 `json:"mp"`
}
func getSystemStats() (SystemInfo, SystemStats) {
c, _ := cpu.Percent(0, false)
v, _ := mem.VirtualMemory()
d, _ := disk.Usage("/")
cpuPct := twoDecimals(c[0])
memPct := twoDecimals(v.UsedPercent)
diskPct := twoDecimals(d.UsedPercent)
systemStats := SystemStats{
Cpu: cpuPct,
Mem: bytesToGigabytes(v.Total),
MemUsed: bytesToGigabytes(v.Used),
MemBuffCache: bytesToGigabytes(v.Total - v.Free - v.Used),
MemPct: memPct,
Disk: bytesToGigabytes(d.Total),
DiskUsed: bytesToGigabytes(d.Used),
DiskPct: diskPct,
}
systemInfo := SystemInfo{
Cpu: cpuPct,
MemPct: memPct,
DiskPct: diskPct,
}
// add disk stats
if io, err := disk.IOCounters(diskIoStats.Filesystem); err == nil {
for _, d := range io {
// add to systemStats
secondsElapsed := time.Since(diskIoStats.Time).Seconds()
readPerSecond := float64(d.ReadBytes-diskIoStats.Read) / secondsElapsed
systemStats.DiskRead = bytesToMegabytes(readPerSecond)
writePerSecond := float64(d.WriteBytes-diskIoStats.Write) / secondsElapsed
systemStats.DiskWrite = bytesToMegabytes(writePerSecond)
// update diskIoStats
diskIoStats.Time = time.Now()
diskIoStats.Read = d.ReadBytes
diskIoStats.Write = d.WriteBytes
}
}
// add network stats
if netIO, err := psutilNet.IOCounters(true); err == nil {
bytesSent := uint64(0)
bytesRecv := uint64(0)
for _, v := range netIO {
if skipNetworkInterface(v.Name) {
continue
}
// log.Printf("%+v: %+v recv, %+v sent\n", v.Name, v.BytesRecv, v.BytesSent)
bytesSent += v.BytesSent
bytesRecv += v.BytesRecv
}
// add to systemStats
secondsElapsed := time.Since(netIoStats.Time).Seconds()
sentPerSecond := float64(bytesSent-netIoStats.BytesSent) / secondsElapsed
recvPerSecond := float64(bytesRecv-netIoStats.BytesRecv) / secondsElapsed
systemStats.NetworkSent = bytesToMegabytes(sentPerSecond)
systemStats.NetworkRecv = bytesToMegabytes(recvPerSecond)
// update netIoStats
netIoStats.BytesSent = bytesSent
netIoStats.BytesRecv = bytesRecv
netIoStats.Time = time.Now()
}
// add host stats
if info, err := host.Info(); err == nil {
systemInfo.Uptime = info.Uptime
// systemInfo.Os = info.OS
}
// add cpu stats
if info, err := cpu.Info(); err == nil {
systemInfo.CpuModel = info[0].ModelName
}
if cores, err := cpu.Counts(false); err == nil {
systemInfo.Cores = cores
}
if threads, err := cpu.Counts(true); err == nil {
systemInfo.Threads = threads
}
return systemInfo, systemStats
}
func getDockerStats() ([]ContainerStats, error) {
resp, err := client.Get("http://localhost/containers/json")
if err != nil {
return []ContainerStats{}, err
}
defer resp.Body.Close()
var containers []Container
if err := json.NewDecoder(resp.Body).Decode(&containers); err != nil {
panic(err)
}
var wg sync.WaitGroup
var containerStats []ContainerStats
for _, ctr := range containers {
wg.Add(1)
go func() {
defer wg.Done()
cstats, err := getContainerStats(ctr)
if err != nil {
log.Printf("Error getting container stats: %+v\n", err)
return
}
containerStats = append(containerStats, cstats)
}()
}
// clean up old containers from map
validNames := make(map[string]struct{}, len(containers))
for _, ctr := range containers {
validNames[ctr.Names[0][1:]] = struct{}{}
}
for name := range containerCpuMap {
if _, exists := validNames[name]; !exists {
delete(containerCpuMap, name)
}
}
wg.Wait()
return containerStats, nil
}
func getContainerStats(ctr Container) (ContainerStats, error) {
resp, err := client.Get("http://localhost/containers/" + ctr.ID + "/stats?stream=0&one-shot=1")
if err != nil {
return ContainerStats{}, err
}
defer resp.Body.Close()
var statsJson CStats
if err := json.NewDecoder(resp.Body).Decode(&statsJson); err != nil {
panic(err)
}
name := ctr.Names[0][1:]
// memory
usedMemory := statsJson.MemoryStats.Usage - statsJson.MemoryStats.Cache
// pctMemory := float64(usedMemory) / float64(statsJson.MemoryStats.Limit) * 100
// cpu
// add default values to containerCpu if it doesn't exist
containerCpuMutex.Lock()
defer containerCpuMutex.Unlock()
if _, ok := containerCpuMap[name]; !ok {
containerCpuMap[name] = [2]uint64{0, 0}
}
cpuDelta := statsJson.CPUStats.CPUUsage.TotalUsage - containerCpuMap[name][0]
systemDelta := statsJson.CPUStats.SystemUsage - containerCpuMap[name][1]
cpuPct := float64(cpuDelta) / float64(systemDelta) * 100
if cpuPct > 100 {
return ContainerStats{}, errors.New("cpu pct is greater than 100")
}
containerCpuMap[name] = [2]uint64{statsJson.CPUStats.CPUUsage.TotalUsage, statsJson.CPUStats.SystemUsage}
cStats := ContainerStats{
Name: name,
Cpu: twoDecimals(cpuPct),
Mem: bytesToMegabytes(float64(usedMemory)),
// MemPct: twoDecimals(pctMemory),
}
return cStats, nil
}
func gatherStats() SystemData {
systemInfo, systemStats := getSystemStats()
stats := SystemData{
Stats: systemStats,
Info: systemInfo,
Containers: []ContainerStats{},
}
containerStats, err := getDockerStats()
if err == nil {
stats.Containers = containerStats
}
// fmt.Printf("%+v\n", stats)
return stats
}
func startServer(port string, pubKey []byte) {
sshServer.Handle(func(s sshServer.Session) {
stats := gatherStats()
var jsonStats []byte
jsonStats, _ = json.Marshal(stats)
io.WriteString(s, string(jsonStats))
s.Exit(0)
})
log.Printf("Starting SSH server on port %s", port)
if err := sshServer.ListenAndServe(":"+port, nil, sshServer.NoPty(),
sshServer.PublicKeyAuth(func(ctx sshServer.Context, key sshServer.PublicKey) bool {
data := []byte(pubKey)
allowed, _, _, _, _ := sshServer.ParseAuthorizedKey(data)
return sshServer.KeysEqual(key, allowed)
}),
); err != nil {
log.Fatal(err)
}
}
func main() {
// handle flags / subcommands
if len(os.Args) > 1 {
switch os.Args[1] {
case "-v":
fmt.Println("beszel-agent", Version)
case "update":
updateBeszel()
}
os.Exit(0)
}
var pubKey []byte
if pubKeyEnv, exists := os.LookupEnv("KEY"); exists {
pubKey = []byte(pubKeyEnv)
} else {
pubKey = []byte("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJgPK8kpPOwPFIq6BIa7Bu/xwrjt5VRQCz3az3Glt4jp")
// log.Fatal("KEY environment variable is not set")
}
if filesystem, exists := os.LookupEnv("FILESYSTEM"); exists {
diskIoStats.Filesystem = filesystem
} else {
diskIoStats.Filesystem = findDefaultFilesystem()
}
initializeDiskIoStats()
initializeNetIoStats()
if port, exists := os.LookupEnv("PORT"); exists {
startServer(port, pubKey)
} else {
startServer("45876", pubKey)
}
}
func bytesToMegabytes(b float64) float64 {
return twoDecimals(b / 1048576)
}
func bytesToGigabytes(b uint64) float64 {
return twoDecimals(float64(b) / 1073741824)
}
func twoDecimals(value float64) float64 {
return math.Round(value*100) / 100
}
func findDefaultFilesystem() string {
if partitions, err := disk.Partitions(false); err == nil {
for _, v := range partitions {
if v.Mountpoint == "/" {
log.Printf("Using filesystem: %+v\n", v.Device)
return v.Device
}
}
}
return ""
}
func skipNetworkInterface(name string) bool {
return strings.HasPrefix(name, "lo") || strings.HasPrefix(name, "docker") || strings.HasPrefix(name, "br-") || strings.HasPrefix(name, "veth")
}
func initializeDiskIoStats() {
if io, err := disk.IOCounters(diskIoStats.Filesystem); err == nil {
for _, d := range io {
diskIoStats.Time = time.Now()
diskIoStats.Read = d.ReadBytes
diskIoStats.Write = d.WriteBytes
}
}
}
func initializeNetIoStats() {
if netIO, err := psutilNet.IOCounters(true); err == nil {
bytesSent := uint64(0)
bytesRecv := uint64(0)
for _, v := range netIO {
if skipNetworkInterface(v.Name) {
continue
}
log.Printf("Found network interface: %+v (%+v recv, %+v sent)\n", v.Name, v.BytesRecv, v.BytesSent)
bytesSent += v.BytesSent
bytesRecv += v.BytesRecv
}
netIoStats.BytesSent = bytesSent
netIoStats.BytesRecv = bytesRecv
netIoStats.Time = time.Now()
}
}

View File

@@ -1,116 +0,0 @@
package main
import "time"
type Container struct {
ID string `json:"Id"`
Names []string
Image string
ImageID string
Command string
Created int64
// Ports []Port
SizeRw int64 `json:",omitempty"`
SizeRootFs int64 `json:",omitempty"`
Labels map[string]string
State string
Status string
HostConfig struct {
NetworkMode string `json:",omitempty"`
Annotations map[string]string `json:",omitempty"`
}
// NetworkSettings *SummaryNetworkSettings
// Mounts []MountPoint
}
type CStats struct {
// Common stats
Read time.Time `json:"read"`
PreRead time.Time `json:"preread"`
// Linux specific stats, not populated on Windows.
// PidsStats PidsStats `json:"pids_stats,omitempty"`
// BlkioStats BlkioStats `json:"blkio_stats,omitempty"`
// Windows specific stats, not populated on Linux.
NumProcs uint32 `json:"num_procs"`
// StorageStats StorageStats `json:"storage_stats,omitempty"`
// Shared stats
CPUStats CPUStats `json:"cpu_stats,omitempty"`
PreCPUStats CPUStats `json:"precpu_stats,omitempty"` // "Pre"="Previous"
MemoryStats MemoryStats `json:"memory_stats,omitempty"`
}
type CPUStats struct {
// CPU Usage. Linux and Windows.
CPUUsage CPUUsage `json:"cpu_usage"`
// System Usage. Linux only.
SystemUsage uint64 `json:"system_cpu_usage,omitempty"`
// Online CPUs. Linux only.
OnlineCPUs uint32 `json:"online_cpus,omitempty"`
// Throttling Data. Linux only.
// ThrottlingData ThrottlingData `json:"throttling_data,omitempty"`
}
type CPUUsage struct {
// Total CPU time consumed.
// Units: nanoseconds (Linux)
// Units: 100's of nanoseconds (Windows)
TotalUsage uint64 `json:"total_usage"`
// Total CPU time consumed per core (Linux). Not used on Windows.
// Units: nanoseconds.
PercpuUsage []uint64 `json:"percpu_usage,omitempty"`
// Time spent by tasks of the cgroup in kernel mode (Linux).
// Time spent by all container processes in kernel mode (Windows).
// Units: nanoseconds (Linux).
// Units: 100's of nanoseconds (Windows). Not populated for Hyper-V Containers.
UsageInKernelmode uint64 `json:"usage_in_kernelmode"`
// Time spent by tasks of the cgroup in user mode (Linux).
// Time spent by all container processes in user mode (Windows).
// Units: nanoseconds (Linux).
// Units: 100's of nanoseconds (Windows). Not populated for Hyper-V Containers
UsageInUsermode uint64 `json:"usage_in_usermode"`
}
type MemoryStats struct {
// current res_counter usage for memory
Usage uint64 `json:"usage,omitempty"`
Cache uint64 `json:"cache,omitempty"`
// maximum usage ever recorded.
MaxUsage uint64 `json:"max_usage,omitempty"`
// TODO(vishh): Export these as stronger types.
// all the stats exported via memory.stat.
Stats map[string]uint64 `json:"stats,omitempty"`
// number of times memory usage hits limits.
Failcnt uint64 `json:"failcnt,omitempty"`
Limit uint64 `json:"limit,omitempty"`
// committed bytes
Commit uint64 `json:"commitbytes,omitempty"`
// peak committed bytes
CommitPeak uint64 `json:"commitpeakbytes,omitempty"`
// private working set
PrivateWorkingSet uint64 `json:"privateworkingset,omitempty"`
}
type DiskIoStats struct {
Read uint64
Write uint64
Time time.Time
Filesystem string
}
type NetIoStats struct {
BytesRecv uint64
BytesSent uint64
Time time.Time
Name string
}

View File

@@ -1,52 +0,0 @@
package main
import (
"fmt"
"log"
"os"
"strings"
"github.com/blang/semver"
"github.com/rhysd/go-github-selfupdate/selfupdate"
)
func updateBeszel() {
var latest *selfupdate.Release
var found bool
var err error
currentVersion := semver.MustParse(Version)
fmt.Println("beszel-agent", currentVersion)
fmt.Println("Checking for updates...")
latest, found, err = selfupdate.DetectLatest("henrygd/beszel")
if err != nil {
fmt.Println("Error checking for updates:", err)
os.Exit(1)
}
if !found {
fmt.Println("No updates found")
os.Exit(0)
}
fmt.Println("Latest version", "v", latest.Version)
if latest.Version.LTE(currentVersion) {
fmt.Println("You are up to date")
return
}
var binaryPath string
fmt.Printf("Updating from %s to %s...", currentVersion, latest.Version)
binaryPath, err = os.Executable()
if err != nil {
fmt.Println("Error getting binary path:", err)
os.Exit(1)
}
err = selfupdate.UpdateTo(latest.AssetURL, binaryPath)
if err != nil {
fmt.Println("Please try rerunning with sudo. Error:", err)
os.Exit(1)
}
log.Printf("Successfully updated: %s -> %s\n\n%s", currentVersion, latest.Version, strings.TrimSpace(latest.ReleaseNotes))
}

69
beszel/.goreleaser.yml Normal file
View File

@@ -0,0 +1,69 @@
version: 2
project_name: beszel
before:
hooks:
- go mod tidy
builds:
- id: beszel
binary: beszel
main: cmd/hub/hub.go
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
goarch:
- amd64
- arm64
- arm
- id: beszel-agent
binary: beszel-agent
main: cmd/agent/agent.go
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- freebsd
goarch:
- amd64
- arm64
- arm
- mips64
ignore:
- goos: freebsd
goarch: arm
archives:
- id: beszel
format: tar.gz
builds:
- beszel-agent
name_template: >-
{{ .Binary }}_
{{- .Os }}_
{{- .Arch }}
- id: beszel-agent
format: tar.gz
builds:
- beszel
name_template: >-
{{ .Binary }}_
{{- .Os }}_
{{- .Arch }}
# use zip for windows archives
# format_overrides:
# - goos: windows
# format: zip
changelog:
disable: true
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'

42
beszel/cmd/agent/agent.go Normal file
View File

@@ -0,0 +1,42 @@
package main
import (
"beszel"
"beszel/internal/agent"
"beszel/internal/update"
"fmt"
"log"
"os"
"strings"
)
func main() {
// handle flags / subcommands
if len(os.Args) > 1 {
switch os.Args[1] {
case "-v":
fmt.Println(beszel.AppName+"-agent", beszel.Version)
case "update":
update.UpdateBeszelAgent()
}
os.Exit(0)
}
var pubKey []byte
if pubKeyEnv, exists := os.LookupEnv("KEY"); exists {
pubKey = []byte(pubKeyEnv)
} else {
log.Fatal("KEY environment variable is not set")
}
addr := ":45876"
if portEnvVar, exists := os.LookupEnv("PORT"); exists {
// allow passing an address in the form of "127.0.0.1:45876"
if !strings.Contains(portEnvVar, ":") {
portEnvVar = ":" + portEnvVar
}
addr = portEnvVar
}
agent.NewAgent().Run(pubKey, addr)
}

29
beszel/cmd/hub/hub.go Normal file
View File

@@ -0,0 +1,29 @@
package main
import (
"beszel"
"beszel/internal/hub"
"beszel/internal/update"
_ "beszel/migrations"
"github.com/pocketbase/pocketbase"
"github.com/spf13/cobra"
)
func main() {
app := pocketbase.NewWithConfig(pocketbase.Config{
DefaultDataDir: beszel.AppName + "_data",
})
app.RootCmd.Version = beszel.Version
app.RootCmd.Use = beszel.AppName
app.RootCmd.Short = ""
// add update command
app.RootCmd.AddCommand(&cobra.Command{
Use: "update",
Short: "Update " + beszel.AppName + " to the latest version",
Run: func(_ *cobra.Command, _ []string) { update.UpdateBeszel() },
})
hub.NewHub(app).Run()
}

20
beszel/dockerfile_Agent Normal file
View File

@@ -0,0 +1,20 @@
FROM --platform=$BUILDPLATFORM golang:alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
# RUN go mod download
COPY *.go ./
COPY cmd ./cmd
COPY internal ./internal
# Build
ARG TARGETOS TARGETARCH
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./cmd/agent
# ? -------------------------
FROM scratch
COPY --from=builder /agent /agent
ENTRYPOINT ["/agent"]

36
beszel/dockerfile_Hub Normal file
View File

@@ -0,0 +1,36 @@
FROM --platform=$BUILDPLATFORM golang:alpine AS builder
WORKDIR /app
# Download Go modules
COPY go.mod go.sum ./
RUN go mod download
# Copy source files
COPY *.go ./
COPY cmd ./cmd
COPY internal ./internal
COPY migrations ./migrations
COPY site/dist ./site/dist
COPY site/*.go ./site
RUN apk add --no-cache \
unzip \
ca-certificates
RUN update-ca-certificates
# Build
ARG TARGETOS TARGETARCH
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /beszel ./cmd/hub
# ? -------------------------
FROM scratch
COPY --from=builder /beszel /
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8090
ENTRYPOINT [ "/beszel" ]
CMD ["serve", "--http=0.0.0.0:8090"]

103
beszel/go.mod Normal file
View File

@@ -0,0 +1,103 @@
module beszel
go 1.22.4
require (
github.com/blang/semver v3.5.1+incompatible
github.com/containrrr/shoutrrr v0.8.0
github.com/gliderlabs/ssh v0.3.7
github.com/goccy/go-json v0.10.3
github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61
github.com/pocketbase/dbx v1.10.1
github.com/pocketbase/pocketbase v0.22.21
github.com/rhysd/go-github-selfupdate v1.2.3
github.com/shirou/gopsutil/v4 v4.24.8
github.com/spf13/cobra v1.8.1
golang.org/x/crypto v0.27.0
)
require (
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go-v2 v1.31.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.5 // indirect
github.com/aws/aws-sdk-go-v2/config v1.27.39 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.37 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.14 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.25 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.18 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.18 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.18 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.5 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.20 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.20 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.18 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.63.3 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.23.3 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.27.3 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.31.3 // indirect
github.com/aws/smithy-go v1.21.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/disintegration/imaging v1.6.2 // indirect
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.17.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.5 // indirect
github.com/ganigeorgiev/fexpr v0.4.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/go-github/v30 v30.1.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/gax-go/v2 v2.13.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.23 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/spf13/cast v1.7.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/tcnksm/go-gitconfig v0.1.2 // indirect
github.com/tklauser/go-sysconf v0.3.14 // indirect
github.com/tklauser/numcpus v0.8.0 // indirect
github.com/ulikunitz/xz v0.5.12 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opencensus.io v0.24.0 // indirect
gocloud.dev v0.39.0 // indirect
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
golang.org/x/image v0.20.0 // indirect
golang.org/x/net v0.29.0 // indirect
golang.org/x/oauth2 v0.23.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/term v0.24.0 // indirect
golang.org/x/text v0.18.0 // indirect
golang.org/x/time v0.6.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
google.golang.org/api v0.199.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240924160255-9d4c2d233b61 // indirect
google.golang.org/grpc v1.67.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // indirect
modernc.org/libc v1.61.0 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/sqlite v1.33.1 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)

View File

@@ -1,17 +1,17 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14=
cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU=
cloud.google.com/go/auth v0.6.1 h1:T0Zw1XM5c1GlpN2HYr2s+m3vr1p2wy+8VN+Z1FKxW38=
cloud.google.com/go/auth v0.6.1/go.mod h1:eFHG7zDzbXHKmjJddFG/rBlcGp6t25SwRUiEQSlO4x4=
cloud.google.com/go/auth/oauth2adapt v0.2.2 h1:+TTV8aXpjeChS9M+aTtN/TjdQnzJvmzKFt//oWu7HX4=
cloud.google.com/go/auth/oauth2adapt v0.2.2/go.mod h1:wcYjgpZI9+Yu7LyYBg4pqSiaRkfEK3GQcpb7C/uyF1Q=
cloud.google.com/go/compute v1.25.0 h1:H1/4SqSUhjPFE7L5ddzHOfY2bCAvjwNRZPNl6Ni5oYU=
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
cloud.google.com/go/iam v1.1.6 h1:bEa06k05IO4f4uJonbB5iAgKTPpABy1ayxaIZV/GHVc=
cloud.google.com/go/iam v1.1.6/go.mod h1:O0zxdPeGBoFdWW3HWmBxJsk0pfvNM/p/qa82rWOGTwI=
cloud.google.com/go/storage v1.39.1 h1:MvraqHKhogCOTXTlct/9C3K3+Uy2jBmFYb3/Sp6dVtY=
cloud.google.com/go/storage v1.39.1/go.mod h1:xK6xZmxZmo+fyP7+DEF6FhNc24/JAe95OLyOHCXFH1o=
cloud.google.com/go v0.115.1 h1:Jo0SM9cQnSkYfp44+v+NQXHpcHqlnRJk2qxh6yvxxxQ=
cloud.google.com/go v0.115.1/go.mod h1:DuujITeaufu3gL68/lOFIirVNJwQeyf5UXyi+Wbgknc=
cloud.google.com/go/auth v0.9.5 h1:4CTn43Eynw40aFVr3GpPqsQponx2jv0BQpjvajsbbzw=
cloud.google.com/go/auth v0.9.5/go.mod h1:Xo0n7n66eHyOWWCnitop6870Ilwo3PiZyodVkkH1xWM=
cloud.google.com/go/auth/oauth2adapt v0.2.4 h1:0GWE/FUsXhf6C+jAkWgYm7X9tK8cuEIfy19DBn6B6bY=
cloud.google.com/go/auth/oauth2adapt v0.2.4/go.mod h1:jC/jOpwFP6JBxhB3P5Rr0a9HLMC/Pe3eaL4NmdvqPtc=
cloud.google.com/go/compute v1.14.0 h1:hfm2+FfxVmnRlh6LpB7cg1ZNU+5edAHmW679JePztk0=
cloud.google.com/go/compute/metadata v0.5.2 h1:UxK4uu/Tn+I3p2dYWTfiX4wva7aYlKixAHn3fyqngqo=
cloud.google.com/go/compute/metadata v0.5.2/go.mod h1:C66sj2AluDcIqakBq/M8lw8/ybHgOZqin2obFxa/E5k=
cloud.google.com/go/iam v1.1.13 h1:7zWBXG9ERbMLrzQBRhFliAV+kjcRToDTgQT3CTwYyv4=
cloud.google.com/go/iam v1.1.13/go.mod h1:K8mY0uSXwEXS30KrnVb+j54LB/ntfZu1dr+4zFMNbus=
cloud.google.com/go/storage v1.43.0 h1:CcxnSohZwizt4LCzQHWvBf1/kvtHUn7gk9QERXPyXFs=
cloud.google.com/go/storage v1.43.0/go.mod h1:ajvxEa7WmZS1PxvKRq4bq0tFT3vMd502JwstCcYv0Q0=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
@@ -19,60 +19,65 @@ github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1r
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/aws/aws-sdk-go v1.51.11 h1:El5VypsMIz7sFwAAj/j06JX9UGs4KAbAIEaZ57bNY4s=
github.com/aws/aws-sdk-go v1.51.11/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk=
github.com/aws/aws-sdk-go-v2 v1.30.1 h1:4y/5Dvfrhd1MxRDD77SrfsDaj8kUkkljU7XE83NPV+o=
github.com/aws/aws-sdk-go-v2 v1.30.1/go.mod h1:nIQjQVp5sfpQcTc9mPSr1B0PaWK5ByX9MOoDadSN4lc=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 h1:tW1/Rkad38LA15X4UQtjXZXNKsCgkshC3EbmcUmghTg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3/go.mod h1:UbnqO+zjqk3uIt9yCACHJ9IVNhyhOCnYk8yA19SAWrM=
github.com/aws/aws-sdk-go-v2/config v1.27.23 h1:Cr/gJEa9NAS7CDAjbnB7tHYb3aLZI2gVggfmSAasDac=
github.com/aws/aws-sdk-go-v2/config v1.27.23/go.mod h1:WMMYHqLCFu5LH05mFOF5tsq1PGEMfKbu083VKqLCd0o=
github.com/aws/aws-sdk-go-v2/credentials v1.17.23 h1:G1CfmLVoO2TdQ8z9dW+JBc/r8+MqyPQhXCafNZcXVZo=
github.com/aws/aws-sdk-go-v2/credentials v1.17.23/go.mod h1:V/DvSURn6kKgcuKEk4qwSwb/fZ2d++FFARtWSbXnLqY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.9 h1:Aznqksmd6Rfv2HQN9cpqIV/lQRMaIpJkLLaJ1ZI76no=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.9/go.mod h1:WQr3MY7AxGNxaqAtsDWn+fBxmd4XvLkzeqQ8P1VM0/w=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.4 h1:6eKRM6fgeXG4krRO9XKz755vuRhT5UyB9M1W6vjA3JU=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.4/go.mod h1:h0TjcRi+nTob6fksqubKOe+Hra8uqfgmN+vuw4xRwWE=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.13 h1:5SAoZ4jYpGH4721ZNoS1znQrhOfZinOhc4XuTXx/nVc=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.13/go.mod h1:+rdA6ZLpaSeM7tSg/B0IEDinCIBJGmW8rKDFkYpP04g=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.13 h1:WIijqeaAO7TYFLbhsZmi2rgLEAtWOC1LhxCAVTJlSKw=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.13/go.mod h1:i+kbfa76PQbWw/ULoWnp51EYVWH4ENln76fLQE3lXT8=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.13 h1:THZJJ6TU/FOiM7DZFnisYV9d49oxXWUzsVIMTuf3VNU=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.13/go.mod h1:VISUTg6n+uBaYIWPBaIG0jk7mbBxm7DUqBtU2cUDDWI=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 h1:dT3MqvGhSoaIhRseqw2I0yH81l7wiR2vjs57O51EAm8=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3/go.mod h1:GlAeCkHwugxdHaueRr4nhPuY+WW+gR8UjlcqzPr1SPI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.15 h1:2jyRZ9rVIMisyQRnhSS/SqlckveoxXneIumECVFP91Y=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.15/go.mod h1:bDRG3m382v1KJBk1cKz7wIajg87/61EiiymEyfLvAe0=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.15 h1:I9zMeF107l0rJrpnHpjEiiTSCKYAIw8mALiXcPsGBiA=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.15/go.mod h1:9xWJ3Q/S6Ojusz1UIkfycgD1mGirJfLLKqq3LPT7WN8=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.13 h1:Eq2THzHt6P41mpjS2sUzz/3dJYFRqdWZ+vQaEMm98EM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.13/go.mod h1:FgwTca6puegxgCInYwGjmd4tB9195Dd6LCuA+8MjpWw=
github.com/aws/aws-sdk-go-v2/service/s3 v1.58.0 h1:4rhV0Hn+bf8IAIUphRX1moBcEvKJipCPmswMCl6Q5mw=
github.com/aws/aws-sdk-go-v2/service/s3 v1.58.0/go.mod h1:hdV0NTYd0RwV4FvNKhKUNbPLZoq9CTr/lke+3I7aCAI=
github.com/aws/aws-sdk-go-v2/service/sso v1.22.1 h1:p1GahKIjyMDZtiKoIn0/jAj/TkMzfzndDv5+zi2Mhgc=
github.com/aws/aws-sdk-go-v2/service/sso v1.22.1/go.mod h1:/vWdhoIoYA5hYoPZ6fm7Sv4d8701PiG5VKe8/pPJL60=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.1 h1:lCEv9f8f+zJ8kcFeAjRZsekLd/x5SAm96Cva+VbUdo8=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.1/go.mod h1:xyFHA4zGxgYkdD73VeezHt3vSKEG9EmFnGwoKlP00u4=
github.com/aws/aws-sdk-go-v2/service/sts v1.30.1 h1:+woJ607dllHJQtsnJLi52ycuqHMwlW+Wqm2Ppsfp4nQ=
github.com/aws/aws-sdk-go-v2/service/sts v1.30.1/go.mod h1:jiNR3JqT15Dm+QWq2SRgh0x0bCNSRP2L25+CqPNpJlQ=
github.com/aws/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE=
github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/aws/aws-sdk-go-v2 v1.31.0 h1:3V05LbxTSItI5kUqNwhJrrrY1BAXxXt0sN0l72QmG5U=
github.com/aws/aws-sdk-go-v2 v1.31.0/go.mod h1:ztolYtaEUtdpf9Wftr31CJfLVjOnD/CVRkKOOYgF8hA=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.5 h1:xDAuZTn4IMm8o1LnBZvmrL8JA1io4o3YWNXgohbf20g=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.5/go.mod h1:wYSv6iDS621sEFLfKvpPE2ugjTuGlAG7iROg0hLOkfc=
github.com/aws/aws-sdk-go-v2/config v1.27.39 h1:FCylu78eTGzW1ynHcongXK9YHtoXD5AiiUqq3YfJYjU=
github.com/aws/aws-sdk-go-v2/config v1.27.39/go.mod h1:wczj2hbyskP4LjMKBEZwPRO1shXY+GsQleab+ZXT2ik=
github.com/aws/aws-sdk-go-v2/credentials v1.17.37 h1:G2aOH01yW8X373JK419THj5QVqu9vKEwxSEsGxihoW0=
github.com/aws/aws-sdk-go-v2/credentials v1.17.37/go.mod h1:0ecCjlb7htYCptRD45lXJ6aJDQac6D2NlKGpZqyTG6A=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.14 h1:C/d03NAmh8C4BZXhuRNboF/DqhBkBCeDiJDcaqIT5pA=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.14/go.mod h1:7I0Ju7p9mCIdlrfS+JCgqcYD0VXz/N4yozsox+0o078=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.25 h1:HkpHeZMM39sGtMHVYG1buAg93vhj5d7F81y6G0OAbGc=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.25/go.mod h1:j3Vz04ZjaWA6kygOsZRpmWe4CyGqfqq2u3unDTU0QGA=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.18 h1:kYQ3H1u0ANr9KEKlGs/jTLrBFPo8P8NaH/w7A01NeeM=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.18/go.mod h1:r506HmK5JDUh9+Mw4CfGJGSSoqIiLCndAuqXuhbv67Y=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.18 h1:Z7IdFUONvTcvS7YuhtVxN99v2cCoHRXOS4mTr0B/pUc=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.18/go.mod h1:DkKMmksZVVyat+Y+r1dEOgJEfUeA7UngIHWeKsi0yNc=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.18 h1:OWYvKL53l1rbsUmW7bQyJVsYU/Ii3bbAAQIIFNbM0Tk=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.18/go.mod h1:CUx0G1v3wG6l01tUB+j7Y8kclA8NSqK4ef0YG79a4cg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.5 h1:QFASJGfT8wMXtuP3D5CRmMjARHv9ZmzFUMJznHDOY3w=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.5/go.mod h1:QdZ3OmoIjSX+8D1OPAzPxDfjXASbBMDsz9qvtyIhtik=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.20 h1:rTWjG6AvWekO2B1LHeM3ktU7MqyX9rzWQ7hgzneZW7E=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.20/go.mod h1:RGW2DDpVc8hu6Y6yG8G5CHVmVOAn1oV8rNKOHRJyswg=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.20 h1:Xbwbmk44URTiHNx6PNo0ujDE6ERlsCKJD3u1zfnzAPg=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.20/go.mod h1:oAfOFzUB14ltPZj1rWwRc3d/6OgD76R8KlvU3EqM9Fg=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.18 h1:eb+tFOIl9ZsUe2259/BKPeniKuz4/02zZFH/i4Nf8Rg=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.18/go.mod h1:GVCC2IJNJTmdlyEsSmofEy7EfJncP7DNnXDzRjJ5Keg=
github.com/aws/aws-sdk-go-v2/service/s3 v1.63.3 h1:3zt8qqznMuAZWDTDpcwv9Xr11M/lVj2FsRR7oYBt0OA=
github.com/aws/aws-sdk-go-v2/service/s3 v1.63.3/go.mod h1:NLTqRLe3pUNu3nTEHI6XlHLKYmc8fbHUdMxAB6+s41Q=
github.com/aws/aws-sdk-go-v2/service/sso v1.23.3 h1:rs4JCczF805+FDv2tRhZ1NU0RB2H6ryAvsWPanAr72Y=
github.com/aws/aws-sdk-go-v2/service/sso v1.23.3/go.mod h1:XRlMvmad0ZNL+75C5FYdMvbbLkd6qiqz6foR1nA1PXY=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.27.3 h1:S7EPdMVZod8BGKQQPTBK+FcX9g7bKR7c4+HxWqHP7Vg=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.27.3/go.mod h1:FnvDM4sfa+isJ3kDXIzAB9GAwVSzFzSy97uZ3IsHo4E=
github.com/aws/aws-sdk-go-v2/service/sts v1.31.3 h1:VzudTFrDCIDakXtemR7l6Qzt2+JYsVqo2MxBPt5k8T8=
github.com/aws/aws-sdk-go-v2/service/sts v1.31.3/go.mod h1:yMWe0F+XG0DkRZK5ODZhG7BEFYhLXi2dqGsv6tX0cgI=
github.com/aws/smithy-go v1.21.0 h1:H7L8dtDRk0P1Qm6y0ji7MCYMQObJ5R9CRpyPhRUkLYA=
github.com/aws/smithy-go v1.21.0/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/containrrr/shoutrrr v0.8.0 h1:mfG2ATzIS7NR2Ec6XL+xyoHzN97H8WPjir8aYzJUSec=
github.com/containrrr/shoutrrr v0.8.0/go.mod h1:ioyQAyu1LJY6sILuNyKaQaw+9Ttik5QePU8atnAdO2o=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
@@ -90,19 +95,26 @@ github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSw
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I=
github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s=
github.com/gabriel-vasile/mimetype v1.4.5 h1:J7wGKdGu33ocBOhGy0z653k/lFKLFDPJMG8Gql0kxn4=
github.com/gabriel-vasile/mimetype v1.4.5/go.mod h1:ibHel+/kbxn9x2407k1izTA1S81ku1z/DlgOW2QE0M4=
github.com/ganigeorgiev/fexpr v0.4.1 h1:hpUgbUEEWIZhSDBtf4M9aUNfQQ0BZkGRaMePy7Gcx5k=
github.com/ganigeorgiev/fexpr v0.4.1/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE=
github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.8.0 h1:UtktXaU2Nb64z/pLiGIxY4431SJ4/dR5cjMmlVHgnT4=
github.com/go-sql-driver/mysql v1.8.0/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
@@ -129,26 +141,28 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-github/v30 v30.1.0 h1:VLDx+UolQICEOKu2m4uAoMti1SxuEBAl7RSEG16L+Oo=
github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQFEufcolZ95JfU8=
github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/pprof v0.0.0-20240625030939-27f56978b8b0 h1:e+8XbKB6IMn8A4OAyZccO4pYfB3s7bt6azNIPE7AnPg=
github.com/google/pprof v0.0.0-20240625030939-27f56978b8b0/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5 h1:5iH8iuqE5apketRbSFBy+X1V0o+l+8NF1avt4HWl7cA=
github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI=
github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA=
github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
github.com/googleapis/gax-go/v2 v2.12.5 h1:8gw9KZK8TiVKB6q3zHY3SBzLnrGp6HQjyfYBYGmXdxA=
github.com/googleapis/gax-go/v2 v2.12.5/go.mod h1:BUDKcWo+RaKq5SC9vVYL0wLADa3VcfswbOMMRmB9H3E=
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
github.com/googleapis/gax-go/v2 v2.13.0 h1:yitjD5f7jQHhyDsnhKEBU52NdvvdSeGzlAnDPT0hH1s=
github.com/googleapis/gax-go/v2 v2.13.0/go.mod h1:Z/fvTZXF8/uw7Xu5GuslPw+bplx6SS338j1Is2S+B7A=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
@@ -158,10 +172,10 @@ github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7V
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc=
github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@@ -173,6 +187,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61 h1:FwuzbVh87iLiUQj1+uQUsuw9x5t9m5n5g7rG7o4svW4=
github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61/go.mod h1:paQfF1YtHe+GrGg5fOgjsjoCX/UKDr9bc1DoWpZfns8=
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0=
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
@@ -180,22 +196,29 @@ github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/onsi/ginkgo v1.6.0 h1:Ix8l273rp3QzYgXSR+c8d1fTG7UPgYkOSELPhiY/YGw=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.2 h1:3mYCb7aPxS/RU7TI1y4rkEn1oKmPRjNJLNEXgw7MH2I=
github.com/onsi/ginkgo/v2 v2.9.2 h1:BA2GMJOtfGAfagzYtrAlufIP0lq6QERkFmHLMLPwFSU=
github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxeMeDuts=
github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.10.1 h1:cw+vsyfCJD8YObOVeqb93YErnlxwYMkNZ4rwN0G0AaA=
github.com/pocketbase/dbx v1.10.1/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/pocketbase v0.22.16 h1:NMpz8s4ASqWGuxzfcpIax1z0WwRzGTSkNjTaisBG5Eo=
github.com/pocketbase/pocketbase v0.22.16/go.mod h1:tsEEQ2xXydNUeDUDkgSQDBlIuF0gkhE2tcYZThLCSHg=
github.com/pocketbase/pocketbase v0.22.21 h1:DGPCxn6co8VuTV0mton4NFO/ON49XiFMszRr+Mysy48=
github.com/pocketbase/pocketbase v0.22.21/go.mod h1:Cw5E4uoGhKItBIE2lJL3NfmiUr9Syk2xaNJ2G7Dssow=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
@@ -204,8 +227,14 @@ github.com/rhysd/go-github-selfupdate v1.2.3/go.mod h1:mp/N8zj6jFfBQy/XMYoWsmfzx
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/shirou/gopsutil/v4 v4.24.8 h1:pVQjIenQkIhqO81mwTaXjTzOMT7d3TZkf43PlVFHENI=
github.com/shirou/gopsutil/v4 v4.24.8/go.mod h1:wE0OrJtj4dG+hYkxqDH3QiBICdKSf04/npcvLLc/oRg=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
@@ -218,47 +247,56 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tcnksm/go-gitconfig v0.1.2 h1:iiDhRitByXAEyjgBqsKi9QU4o2TNtv9kPP3RgPgXBPw=
github.com/tcnksm/go-gitconfig v0.1.2/go.mod h1:/8EhP4H7oJZdIPyT+/UIsG87kTzrzM4UsLGSItWYCpE=
github.com/ulikunitz/xz v0.5.9 h1:RsKRIA2MO8x56wkkcd3LbtcE/uMszhb6DpRf+3uwa3I=
github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU=
github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY=
github.com/tklauser/numcpus v0.8.0 h1:Mx4Wwe/FjZLeQsK/6kt2EOepwwSl7SmJrK5bV/dXYgY=
github.com/tklauser/numcpus v0.8.0/go.mod h1:ZJZlAY+dmR4eut8epnzf0u/VwodKmryxR8txiloSqBE=
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=
go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=
go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI=
go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco=
go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI=
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
gocloud.dev v0.37.0 h1:XF1rN6R0qZI/9DYjN16Uy0durAmSlf58DHOcb28GPro=
gocloud.dev v0.37.0/go.mod h1:7/O4kqdInCNsc6LqgmuFnS0GRew4XNNYWpA44yQnwco=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
gocloud.dev v0.39.0 h1:EYABYGhAalPUaMrbSKOr5lejxoxvXj99nE8XFtsDgds=
gocloud.dev v0.39.0/go.mod h1:drz+VyYNBvrMTW0KZiBAYEdl8lbNZx+OQ7oQvdrFmSQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/image v0.20.0 h1:7cVCUjQwfL18gyBJOmYvptfSHS8Fb3YUDtfLIZ7Nbpw=
golang.org/x/image v0.20.0/go.mod h1:0a88To4CYVBAHp5FXJm8o7QbUl37Vd85ply1vyD8auM=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -270,48 +308,51 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM=
golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -319,14 +360,14 @@ golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE=
golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
google.golang.org/api v0.187.0 h1:Mxs7VATVC2v7CY+7Xwm4ndkX71hpElcvx0D1Ji/p1eo=
google.golang.org/api v0.187.0/go.mod h1:KIHlTc4x7N7gKKuVsdmfBXN13yEEWXWFURWY6SBp2gk=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
google.golang.org/api v0.199.0 h1:aWUXClp+VFJmqE0JPvpZOK3LDQMyFKYIow4etYd9qxs=
google.golang.org/api v0.199.0/go.mod h1:ohG4qSztDJmZdjK/Ar6MhbAmb/Rpi4JHOqagsh90K28=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -334,19 +375,19 @@ google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCID
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20240624140628-dc46fd24d27d h1:PksQg4dV6Sem3/HkBX+Ltq8T0ke0PKIRBNBatoDTVls=
google.golang.org/genproto v0.0.0-20240624140628-dc46fd24d27d/go.mod h1:s7iA721uChleev562UJO2OYB0PPT9CMFjV+Ce7VJH5M=
google.golang.org/genproto/googleapis/api v0.0.0-20240610135401-a8a62080eff3 h1:QW9+G6Fir4VcRXVH8x3LilNAb6cxBGLa6+GM4hRwexE=
google.golang.org/genproto/googleapis/api v0.0.0-20240610135401-a8a62080eff3/go.mod h1:kdrSS/OiLkPrNUpzD4aHgCq2rVuC/YRxok32HXZ4vRE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 h1:BwIjyKYGsK9dMCBOorzRri8MQwmi7mT9rGHsCEinZkA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY=
google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1 h1:BulPr26Jqjnd4eYDVe+YvyR7Yc2vJGkO5/0UxD0/jZU=
google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:hL97c3SYopEHblzpxRL4lSs523++l8DYxGM1FQiYmb4=
google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142 h1:wKguEg1hsxI2/L3hUYrpo1RVi48K+uTyzKqprwLXsb8=
google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142/go.mod h1:d6be+8HhtEtucleCbxpPW9PA9XwISACu8nvpPqF0BVo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240924160255-9d4c2d233b61 h1:N9BgCIAUvn/M+p4NJccWPWb3BWh88+zyL0ll9HgbEeM=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240924160255-9d4c2d233b61/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc=
google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ=
google.golang.org/grpc v1.67.0 h1:IdH9y6PF5MPSdAntIcpjQ+tXO41pcQsfZV2RxtQgVcw=
google.golang.org/grpc v1.67.0/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -364,25 +405,23 @@ gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMy
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
modernc.org/cc/v4 v4.21.2 h1:dycHFB/jDc3IyacKipCNSDrjIC0Lm1hyoWOZTRR20Lk=
modernc.org/cc/v4 v4.21.2/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.17.10 h1:6wrtRozgrhCxieCeJh85QsxkX/2FFrT9hdaWPlbn4Zo=
modernc.org/ccgo/v4 v4.17.10/go.mod h1:0NBHgsqTTpm9cA5z2ccErvGZmtntSM9qD2kFAs6pjXM=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.21.0 h1:kKPI3dF7RIag8YcToh5ZwDcVMIv6VGa0ED5cvh0LMW4=
modernc.org/ccgo/v4 v4.21.0/go.mod h1:h6kt6H/A2+ew/3MW/p6KEoQmrq/i3pr0J/SiwiaF/g0=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b h1:BnN1t+pb1cy61zbvSUV7SeI0PwosMhlAEi/vBY4qxp8=
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.52.1 h1:uau0VoiT5hnR+SpoWekCKbLqm7v6dhRL3hI+NQhgN3M=
modernc.org/libc v1.52.1/go.mod h1:HR4nVzFDSDizP620zcMCgjb1/8xk2lg5p/8yjfGv1IQ=
modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M=
modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a h1:CfbpOLEo2IwNzJdMvE8aiRbPMxoTpgAJeyePh0SmO8M=
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.61.0 h1:eGFcvWpqlnoGwzZeZe3PWJkkKbM/3SUGyk1DVZQ0TpE=
modernc.org/libc v1.61.0/go.mod h1:DvxVX89wtGTu+r72MLGhygpfi3aUGgZRdAYGCAVVud0=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
@@ -391,8 +430,8 @@ modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.30.1 h1:YFhPVfu2iIgUf9kuA1CR7iiHdcEEsI2i+yjRYHscyxk=
modernc.org/sqlite v1.30.1/go.mod h1:DUmsiWQDaAvU4abhc/N+djlom/L2o8f7gZ95RCvyoLU=
modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM=
modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -0,0 +1,101 @@
// Package agent handles the agent's SSH server and system stats collection.
package agent
import (
"beszel/internal/entities/container"
"beszel/internal/entities/system"
"context"
"log/slog"
"net/http"
"os"
"strings"
"sync"
"github.com/shirou/gopsutil/v4/common"
)
type Agent struct {
hostname string // Hostname of the system
kernelVersion string // Kernel version of the system
cpuModel string // CPU model of the system
cores int // Number of cores of the system
threads int // Number of threads of the system
debug bool // true if LOG_LEVEL is set to debug
fsNames []string // List of filesystem device names being monitored
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
netInterfaces map[string]struct{} // Stores all valid network interfaces
netIoStats system.NetIoStats // Keeps track of bandwidth usage
containerStatsMap map[string]*container.Stats // Keeps track of container stats
containerStatsMutex sync.RWMutex // Mutex to prevent concurrent access to prevContainerStatsMap
dockerClient *http.Client // HTTP client to query docker api
apiContainerList *[]container.ApiInfo // List of containers from docker host
sensorsContext context.Context // Sensors context to override sys location
sensorsWhitelist map[string]struct{} // List of sensors to monitor
}
func NewAgent() *Agent {
return &Agent{
containerStatsMap: make(map[string]*container.Stats),
containerStatsMutex: sync.RWMutex{},
netIoStats: system.NetIoStats{},
dockerClient: newDockerClient(),
sensorsContext: context.Background(),
}
}
func (a *Agent) Run(pubKey []byte, addr string) {
// Set up slog with a log level determined by the LOG_LEVEL env var
if logLevelStr, exists := os.LookupEnv("LOG_LEVEL"); exists {
switch strings.ToLower(logLevelStr) {
case "debug":
a.debug = true
slog.SetLogLoggerLevel(slog.LevelDebug)
case "warn":
slog.SetLogLoggerLevel(slog.LevelWarn)
case "error":
slog.SetLogLoggerLevel(slog.LevelError)
}
}
// Set sensors context (allows overriding sys location for sensors)
if sysSensors, exists := os.LookupEnv("SYS_SENSORS"); exists {
slog.Info("SYS_SENSORS", "path", sysSensors)
a.sensorsContext = context.WithValue(a.sensorsContext,
common.EnvKey, common.EnvMap{common.HostSysEnvKey: sysSensors},
)
}
// Set sensors whitelist
if sensors, exists := os.LookupEnv("SENSORS"); exists {
a.sensorsWhitelist = make(map[string]struct{})
for _, sensor := range strings.Split(sensors, ",") {
a.sensorsWhitelist[sensor] = struct{}{}
}
}
a.initializeSystemInfo()
a.initializeDiskInfo()
a.initializeNetIoStats()
a.startServer(pubKey, addr)
}
func (a *Agent) gatherStats() system.CombinedData {
systemInfo, SystemStats := a.getSystemStats()
systemData := system.CombinedData{
Stats: SystemStats,
Info: systemInfo,
}
// add docker stats
if containerStats, err := a.getDockerStats(); err == nil {
systemData.Containers = containerStats
}
// add extra filesystems
systemData.Stats.ExtraFs = make(map[string]*system.FsStats)
for name, stats := range a.fsStats {
if !stats.Root && stats.DiskTotal > 0 {
systemData.Stats.ExtraFs[name] = stats
}
}
return systemData
}

View File

@@ -0,0 +1,169 @@
package agent
import (
"beszel/internal/entities/system"
"log/slog"
"time"
"os"
"path/filepath"
"strings"
"github.com/shirou/gopsutil/v4/disk"
)
// Sets up the filesystems to monitor for disk usage and I/O.
func (a *Agent) initializeDiskInfo() {
filesystem := os.Getenv("FILESYSTEM")
efPath := "/extra-filesystems"
hasRoot := false
// Create map for disk stats
a.fsStats = make(map[string]*system.FsStats)
partitions, err := disk.Partitions(false)
if err != nil {
slog.Error("Error getting disk partitions", "err", err)
}
slog.Debug("Disk", "partitions", partitions)
// ioContext := context.WithValue(a.sensorsContext,
// common.EnvKey, common.EnvMap{common.HostProcEnvKey: "/tmp/testproc"},
// )
// diskIoCounters, err := disk.IOCountersWithContext(ioContext)
diskIoCounters, err := disk.IOCounters()
if err != nil {
slog.Error("Error getting diskstats", "err", err)
}
slog.Debug("Disk I/O", "diskstats", diskIoCounters)
// Helper function to add a filesystem to fsStats if it doesn't exist
addFsStat := func(device, mountpoint string, root bool) {
key := filepath.Base(device)
if _, exists := a.fsStats[key]; !exists {
if root {
slog.Info("Detected root device", "name", key)
// check if root device is in /proc/diskstats, use fallback if not
if _, exists := diskIoCounters[key]; !exists {
slog.Warn("Device not found in diskstats", "name", key)
key = findFallbackIoDevice(filesystem, diskIoCounters)
slog.Info("Using I/O fallback", "name", key)
}
}
a.fsStats[key] = &system.FsStats{Root: root, Mountpoint: mountpoint}
}
}
// Use FILESYSTEM env var to find root filesystem
if filesystem != "" {
for _, p := range partitions {
if strings.HasSuffix(p.Device, filesystem) || p.Mountpoint == filesystem {
addFsStat(p.Device, p.Mountpoint, true)
hasRoot = true
break
}
}
if !hasRoot {
slog.Warn("Partition details not found", "filesystem", filesystem)
}
}
// Add EXTRA_FILESYSTEMS env var values to fsStats
if extraFilesystems, exists := os.LookupEnv("EXTRA_FILESYSTEMS"); exists {
for _, fs := range strings.Split(extraFilesystems, ",") {
found := false
for _, p := range partitions {
if strings.HasSuffix(p.Device, fs) || p.Mountpoint == fs {
addFsStat(p.Device, p.Mountpoint, false)
found = true
break
}
}
// if not in partitions, test if we can get disk usage
if !found {
if _, err := disk.Usage(fs); err == nil {
addFsStat(filepath.Base(fs), fs, false)
} else {
slog.Error("Invalid filesystem", "name", fs, "err", err)
}
}
}
}
// Process partitions for various mount points
for _, p := range partitions {
// fmt.Println(p.Device, p.Mountpoint)
// Binary root fallback or docker root fallback
if !hasRoot && (p.Mountpoint == "/" || (p.Mountpoint == "/etc/hosts" && strings.HasPrefix(p.Device, "/dev") && !strings.Contains(p.Device, "mapper"))) {
addFsStat(p.Device, "/", true)
hasRoot = true
}
// Check if device is in /extra-filesystems
if strings.HasPrefix(p.Mountpoint, efPath) {
addFsStat(p.Device, p.Mountpoint, false)
}
}
// Check all folders in /extra-filesystems and add them if not already present
if folders, err := os.ReadDir(efPath); err == nil {
existingMountpoints := make(map[string]bool)
for _, stats := range a.fsStats {
existingMountpoints[stats.Mountpoint] = true
}
for _, folder := range folders {
if folder.IsDir() {
mountpoint := filepath.Join(efPath, folder.Name())
slog.Debug("/extra-filesystems", "mountpoint", mountpoint)
if !existingMountpoints[mountpoint] {
a.fsStats[folder.Name()] = &system.FsStats{Mountpoint: mountpoint}
}
}
}
}
// If no root filesystem set, use fallback
if !hasRoot {
rootDevice := findFallbackIoDevice(filepath.Base(filesystem), diskIoCounters)
slog.Info("Root disk", "mountpoint", "/", "io", rootDevice)
a.fsStats[rootDevice] = &system.FsStats{Root: true, Mountpoint: "/"}
}
a.initializeDiskIoStats(diskIoCounters)
}
// Returns the device with the most reads in /proc/diskstats,
// or the device specified by the filesystem argument if it exists
func findFallbackIoDevice(filesystem string, diskIoCounters map[string]disk.IOCountersStat) string {
var maxReadBytes uint64
maxReadDevice := "/"
for _, d := range diskIoCounters {
if d.Name == filesystem {
return d.Name
}
if d.ReadBytes > maxReadBytes {
maxReadBytes = d.ReadBytes
maxReadDevice = d.Name
}
}
return maxReadDevice
}
// Sets start values for disk I/O stats.
func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersStat) {
for device, stats := range a.fsStats {
// skip if not in diskIoCounters
d, exists := diskIoCounters[device]
if !exists {
slog.Warn("Device not found in diskstats", "name", device)
continue
}
// populate initial values
stats.Time = time.Now()
stats.TotalRead = d.ReadBytes
stats.TotalWrite = d.WriteBytes
// add to list of valid io device names
a.fsNames = append(a.fsNames, device)
}
}

View File

@@ -0,0 +1,211 @@
package agent
import (
"beszel/internal/entities/container"
"context"
"encoding/json"
"fmt"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
)
// Returns stats for all running containers
func (a *Agent) getDockerStats() ([]*container.Stats, error) {
resp, err := a.dockerClient.Get("http://localhost/containers/json")
if err != nil {
a.closeIdleConnections(err)
return nil, err
}
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(&a.apiContainerList); err != nil {
slog.Error("Error decoding containers", "err", err)
return nil, err
}
containersLength := len(*a.apiContainerList)
containerStats := make([]*container.Stats, containersLength)
// store valid ids to clean up old container ids from map
validIds := make(map[string]struct{}, containersLength)
var wg sync.WaitGroup
for i, ctr := range *a.apiContainerList {
ctr.IdShort = ctr.Id[:12]
validIds[ctr.IdShort] = struct{}{}
// check if container is less than 1 minute old (possible restart)
// note: can't use Created field because it's not updated on restart
if strings.Contains(ctr.Status, "second") {
// if so, remove old container data
a.deleteContainerStatsSync(ctr.IdShort)
}
wg.Add(1)
go func() {
defer wg.Done()
stats, err := a.getContainerStats(ctr)
if err != nil {
// close idle connections if error is a network timeout
isTimeout := a.closeIdleConnections(err)
// delete container from map if not a timeout
if !isTimeout {
a.deleteContainerStatsSync(ctr.IdShort)
}
// retry once
stats, err = a.getContainerStats(ctr)
if err != nil {
slog.Error("Error getting container stats", "err", err)
}
}
containerStats[i] = stats
}()
}
wg.Wait()
// remove old / invalid container stats
for id := range a.containerStatsMap {
if _, exists := validIds[id]; !exists {
delete(a.containerStatsMap, id)
}
}
return containerStats, nil
}
// Returns stats for individual container
func (a *Agent) getContainerStats(ctr container.ApiInfo) (*container.Stats, error) {
name := ctr.Names[0][1:]
resp, err := a.dockerClient.Get("http://localhost/containers/" + ctr.IdShort + "/stats?stream=0&one-shot=1")
if err != nil {
return &container.Stats{Name: name}, err
}
defer resp.Body.Close()
a.containerStatsMutex.Lock()
defer a.containerStatsMutex.Unlock()
// add empty values if they doesn't exist in map
stats, initialized := a.containerStatsMap[ctr.IdShort]
if !initialized {
stats = &container.Stats{Name: name}
a.containerStatsMap[ctr.IdShort] = stats
}
// reset current stats
stats.Cpu = 0
stats.Mem = 0
stats.NetworkSent = 0
stats.NetworkRecv = 0
// docker host container stats response
var res container.ApiStats
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
return stats, err
}
// check if container has valid data, otherwise may be in restart loop (#103)
if res.MemoryStats.Usage == 0 {
return stats, fmt.Errorf("%s - no memory stats - see https://github.com/henrygd/beszel/issues/144", name)
}
// memory (https://docs.docker.com/reference/cli/docker/container/stats/)
memCache := res.MemoryStats.Stats.InactiveFile
if memCache == 0 {
memCache = res.MemoryStats.Stats.Cache
}
usedMemory := res.MemoryStats.Usage - memCache
// cpu
cpuDelta := res.CPUStats.CPUUsage.TotalUsage - stats.PrevCpu[0]
systemDelta := res.CPUStats.SystemUsage - stats.PrevCpu[1]
cpuPct := float64(cpuDelta) / float64(systemDelta) * 100
if cpuPct > 100 {
return stats, fmt.Errorf("%s cpu pct greater than 100: %+v", name, cpuPct)
}
stats.PrevCpu = [2]uint64{res.CPUStats.CPUUsage.TotalUsage, res.CPUStats.SystemUsage}
// network
var total_sent, total_recv uint64
for _, v := range res.Networks {
total_sent += v.TxBytes
total_recv += v.RxBytes
}
var sent_delta, recv_delta float64
// prevent first run from sending all prev sent/recv bytes
if initialized {
secondsElapsed := time.Since(stats.PrevNet.Time).Seconds()
sent_delta = float64(total_sent-stats.PrevNet.Sent) / secondsElapsed
recv_delta = float64(total_recv-stats.PrevNet.Recv) / secondsElapsed
}
stats.PrevNet.Sent = total_sent
stats.PrevNet.Recv = total_recv
stats.PrevNet.Time = time.Now()
stats.Cpu = twoDecimals(cpuPct)
stats.Mem = bytesToMegabytes(float64(usedMemory))
stats.NetworkSent = bytesToMegabytes(sent_delta)
stats.NetworkRecv = bytesToMegabytes(recv_delta)
return stats, nil
}
// Creates a new http client for docker api
func newDockerClient() *http.Client {
dockerHost := "unix:///var/run/docker.sock"
if dockerHostEnv, exists := os.LookupEnv("DOCKER_HOST"); exists {
slog.Info("DOCKER_HOST", "host", dockerHostEnv)
dockerHost = dockerHostEnv
}
parsedURL, err := url.Parse(dockerHost)
if err != nil {
slog.Error("Error parsing DOCKER_HOST", "err", err)
os.Exit(1)
}
transport := &http.Transport{
ForceAttemptHTTP2: false,
IdleConnTimeout: 90 * time.Second,
DisableCompression: true,
MaxConnsPerHost: 10,
MaxIdleConnsPerHost: 10,
DisableKeepAlives: false,
}
switch parsedURL.Scheme {
case "unix":
transport.DialContext = func(ctx context.Context, proto, addr string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", parsedURL.Path)
}
case "tcp", "http", "https":
transport.DialContext = func(ctx context.Context, proto, addr string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "tcp", parsedURL.Host)
}
default:
slog.Error("Invalid DOCKER_HOST", "scheme", parsedURL.Scheme)
os.Exit(1)
}
return &http.Client{
Timeout: time.Second,
Transport: transport,
}
}
// Closes idle connections on timeouts to prevent reuse of stale connections
func (a *Agent) closeIdleConnections(err error) (isTimeout bool) {
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
slog.Warn("Closing idle connections", "err", err)
a.dockerClient.Transport.(*http.Transport).CloseIdleConnections()
return true
}
return false
}

View File

@@ -0,0 +1,67 @@
package agent
import (
"log/slog"
"os"
"strings"
"time"
psutilNet "github.com/shirou/gopsutil/v4/net"
)
func (a *Agent) initializeNetIoStats() {
// reset valid network interfaces
a.netInterfaces = make(map[string]struct{}, 0)
// map of network interface names passed in via NICS env var
var nicsMap map[string]struct{}
nics, nicsEnvExists := os.LookupEnv("NICS")
if nicsEnvExists {
nicsMap = make(map[string]struct{}, 0)
for _, nic := range strings.Split(nics, ",") {
nicsMap[nic] = struct{}{}
}
}
// reset network I/O stats
a.netIoStats.BytesSent = 0
a.netIoStats.BytesRecv = 0
// get intial network I/O stats
if netIO, err := psutilNet.IOCounters(true); err == nil {
a.netIoStats.Time = time.Now()
for _, v := range netIO {
switch {
// skip if nics exists and the interface is not in the list
case nicsEnvExists:
if _, nameInNics := nicsMap[v.Name]; !nameInNics {
continue
}
// otherwise run the interface name through the skipNetworkInterface function
default:
if a.skipNetworkInterface(v) {
continue
}
}
slog.Info("Detected network interface", "name", v.Name, "sent", v.BytesSent, "recv", v.BytesRecv)
a.netIoStats.BytesSent += v.BytesSent
a.netIoStats.BytesRecv += v.BytesRecv
// store as a valid network interface
a.netInterfaces[v.Name] = struct{}{}
}
}
}
func (a *Agent) skipNetworkInterface(v psutilNet.IOCountersStat) bool {
switch {
case strings.HasPrefix(v.Name, "lo"),
strings.HasPrefix(v.Name, "docker"),
strings.HasPrefix(v.Name, "br-"),
strings.HasPrefix(v.Name, "veth"),
v.BytesRecv == 0,
v.BytesSent == 0:
return true
default:
return false
}
}

View File

@@ -0,0 +1,35 @@
package agent
import (
"encoding/json"
"log/slog"
"os"
sshServer "github.com/gliderlabs/ssh"
)
func (a *Agent) startServer(pubKey []byte, addr string) {
sshServer.Handle(a.handleSession)
slog.Info("Starting SSH server", "address", addr)
if err := sshServer.ListenAndServe(addr, nil, sshServer.NoPty(),
sshServer.PublicKeyAuth(func(ctx sshServer.Context, key sshServer.PublicKey) bool {
allowed, _, _, _, _ := sshServer.ParseAuthorizedKey(pubKey)
return sshServer.KeysEqual(key, allowed)
}),
); err != nil {
slog.Error("Error starting SSH server", "err", err)
os.Exit(1)
}
}
func (a *Agent) handleSession(s sshServer.Session) {
stats := a.gatherStats()
slog.Debug("Sending stats", "data", stats)
if err := json.NewEncoder(s).Encode(stats); err != nil {
slog.Error("Error encoding stats", "err", err)
s.Exit(1)
return
}
s.Exit(0)
}

View File

@@ -0,0 +1,191 @@
package agent
import (
"beszel"
"beszel/internal/entities/system"
"log/slog"
"os"
"strconv"
"time"
"github.com/shirou/gopsutil/v4/cpu"
"github.com/shirou/gopsutil/v4/disk"
"github.com/shirou/gopsutil/v4/host"
"github.com/shirou/gopsutil/v4/mem"
psutilNet "github.com/shirou/gopsutil/v4/net"
"github.com/shirou/gopsutil/v4/sensors"
)
// Sets initial / non-changing values about the host system
func (a *Agent) initializeSystemInfo() {
a.kernelVersion, _ = host.KernelVersion()
a.hostname, _ = os.Hostname()
// add cpu stats
if info, err := cpu.Info(); err == nil && len(info) > 0 {
a.cpuModel = info[0].ModelName
}
a.cores, _ = cpu.Counts(false)
if threads, err := cpu.Counts(true); err == nil {
if threads > 0 && threads < a.cores {
// in lxc logical cores reflects container limits, so use that as cores if lower
a.cores = threads
} else {
a.threads = threads
}
}
}
// Returns current info, stats about the host system
func (a *Agent) getSystemStats() (system.Info, system.Stats) {
systemStats := system.Stats{}
// cpu percent
cpuPct, err := cpu.Percent(0, false)
if err != nil {
slog.Error("Error getting cpu percent", "err", err)
} else if len(cpuPct) > 0 {
systemStats.Cpu = twoDecimals(cpuPct[0])
}
// memory
if v, err := mem.VirtualMemory(); err == nil {
systemStats.Mem = bytesToGigabytes(v.Total)
systemStats.MemUsed = bytesToGigabytes(v.Used)
systemStats.MemBuffCache = bytesToGigabytes(v.Total - v.Free - v.Used)
systemStats.MemPct = twoDecimals(v.UsedPercent)
systemStats.Swap = bytesToGigabytes(v.SwapTotal)
systemStats.SwapUsed = bytesToGigabytes(v.SwapTotal - v.SwapFree)
}
// disk usage
for _, stats := range a.fsStats {
if d, err := disk.Usage(stats.Mountpoint); err == nil {
stats.DiskTotal = bytesToGigabytes(d.Total)
stats.DiskUsed = bytesToGigabytes(d.Used)
if stats.Root {
systemStats.DiskTotal = bytesToGigabytes(d.Total)
systemStats.DiskUsed = bytesToGigabytes(d.Used)
systemStats.DiskPct = twoDecimals(d.UsedPercent)
}
} else {
// reset stats if error (likely unmounted)
slog.Error("Error getting disk stats", "name", stats.Mountpoint, "err", err)
stats.DiskTotal = 0
stats.DiskUsed = 0
stats.TotalRead = 0
stats.TotalWrite = 0
}
}
// disk i/o
if ioCounters, err := disk.IOCounters(a.fsNames...); err == nil {
for _, d := range ioCounters {
stats := a.fsStats[d.Name]
if stats == nil {
continue
}
secondsElapsed := time.Since(stats.Time).Seconds()
readPerSecond := float64(d.ReadBytes-stats.TotalRead) / secondsElapsed
writePerSecond := float64(d.WriteBytes-stats.TotalWrite) / secondsElapsed
stats.Time = time.Now()
stats.DiskReadPs = bytesToMegabytes(readPerSecond)
stats.DiskWritePs = bytesToMegabytes(writePerSecond)
stats.TotalRead = d.ReadBytes
stats.TotalWrite = d.WriteBytes
// if root filesystem, update system stats
if stats.Root {
systemStats.DiskReadPs = stats.DiskReadPs
systemStats.DiskWritePs = stats.DiskWritePs
}
}
}
// network stats
if netIO, err := psutilNet.IOCounters(true); err == nil {
secondsElapsed := time.Since(a.netIoStats.Time).Seconds()
a.netIoStats.Time = time.Now()
bytesSent := uint64(0)
bytesRecv := uint64(0)
// sum all bytes sent and received
for _, v := range netIO {
// skip if not in valid network interfaces list
if _, exists := a.netInterfaces[v.Name]; !exists {
continue
}
bytesSent += v.BytesSent
bytesRecv += v.BytesRecv
}
// add to systemStats
sentPerSecond := float64(bytesSent-a.netIoStats.BytesSent) / secondsElapsed
recvPerSecond := float64(bytesRecv-a.netIoStats.BytesRecv) / secondsElapsed
networkSentPs := bytesToMegabytes(sentPerSecond)
networkRecvPs := bytesToMegabytes(recvPerSecond)
// add check for issue (#150) where sent is a massive number
if networkSentPs > 10_000 || networkRecvPs > 10_000 {
slog.Warn("Invalid network stats. Resetting.", "sent", networkSentPs, "recv", networkRecvPs)
for _, v := range netIO {
if _, exists := a.netInterfaces[v.Name]; !exists {
continue
}
slog.Info(v.Name, "recv", v.BytesRecv, "sent", v.BytesSent)
}
// reset network I/O stats
a.initializeNetIoStats()
} else {
systemStats.NetworkSent = networkSentPs
systemStats.NetworkRecv = networkRecvPs
// update netIoStats
a.netIoStats.BytesSent = bytesSent
a.netIoStats.BytesRecv = bytesRecv
}
}
// temperatures
temps, err := sensors.TemperaturesWithContext(a.sensorsContext)
if err != nil && a.debug {
err.(*sensors.Warnings).Verbose = true
slog.Debug("Sensor error", "errs", err)
}
if len(temps) > 0 {
slog.Debug("Temperatures", "data", temps)
systemStats.Temperatures = make(map[string]float64, len(temps))
for i, sensor := range temps {
// skip if temperature is 0
if sensor.Temperature == 0 {
continue
}
if _, ok := systemStats.Temperatures[sensor.SensorKey]; ok {
// if key already exists, append int to key
systemStats.Temperatures[sensor.SensorKey+"_"+strconv.Itoa(i)] = twoDecimals(sensor.Temperature)
} else {
systemStats.Temperatures[sensor.SensorKey] = twoDecimals(sensor.Temperature)
}
}
// remove sensors from systemStats if whitelist exists and sensor is not in whitelist
// (do this here instead of in initial loop so we have correct keys if int was appended)
if a.sensorsWhitelist != nil {
for key := range systemStats.Temperatures {
if _, nameInWhitelist := a.sensorsWhitelist[key]; !nameInWhitelist {
delete(systemStats.Temperatures, key)
}
}
}
}
systemInfo := system.Info{
Cpu: systemStats.Cpu,
MemPct: systemStats.MemPct,
DiskPct: systemStats.DiskPct,
AgentVersion: beszel.Version,
Hostname: a.hostname,
KernelVersion: a.kernelVersion,
CpuModel: a.cpuModel,
Cores: a.cores,
Threads: a.threads,
}
systemInfo.Uptime, _ = host.Uptime()
return systemInfo, systemStats
}

View File

@@ -0,0 +1,22 @@
package agent
import "math"
// delete container stats from map using mutex
func (a *Agent) deleteContainerStatsSync(id string) {
a.containerStatsMutex.Lock()
defer a.containerStatsMutex.Unlock()
delete(a.containerStatsMap, id)
}
func bytesToMegabytes(b float64) float64 {
return twoDecimals(b / 1048576)
}
func bytesToGigabytes(b uint64) float64 {
return twoDecimals(float64(b) / 1073741824)
}
func twoDecimals(value float64) float64 {
return math.Round(value*100) / 100
}

View File

@@ -0,0 +1,288 @@
// Package alerts handles alert management and delivery.
package alerts
import (
"beszel/internal/entities/system"
"fmt"
"net/mail"
"net/url"
"github.com/containrrr/shoutrrr"
"github.com/labstack/echo/v5"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/mailer"
)
type AlertManager struct {
app *pocketbase.PocketBase
}
type AlertData struct {
UserID string
Title string
Message string
Link string
LinkText string
}
type UserNotificationSettings struct {
Emails []string `json:"emails"`
Webhooks []string `json:"webhooks"`
}
func NewAlertManager(app *pocketbase.PocketBase) *AlertManager {
return &AlertManager{
app: app,
}
}
func (am *AlertManager) HandleSystemInfoAlerts(systemRecord *models.Record, systemInfo system.Info) {
alertRecords, err := am.app.Dao().FindRecordsByExpr("alerts",
dbx.NewExp("system={:system}", dbx.Params{"system": systemRecord.GetId()}),
)
if err != nil || len(alertRecords) == 0 {
// log.Println("no alerts found for system")
return
}
// log.Println("found alerts", len(alertRecords))
for _, alertRecord := range alertRecords {
name := alertRecord.GetString("name")
switch name {
case "CPU", "Memory", "Disk":
if name == "CPU" {
am.handleSlidingValueAlert(systemRecord, alertRecord, name, systemInfo.Cpu)
} else if name == "Memory" {
am.handleSlidingValueAlert(systemRecord, alertRecord, name, systemInfo.MemPct)
} else if name == "Disk" {
am.handleSlidingValueAlert(systemRecord, alertRecord, name, systemInfo.DiskPct)
}
}
}
}
func (am *AlertManager) handleSlidingValueAlert(systemRecord *models.Record, alertRecord *models.Record, name string, curValue float64) {
triggered := alertRecord.GetBool("triggered")
threshold := alertRecord.GetFloat("value")
// fmt.Println(name, curValue, "threshold", threshold, "triggered", triggered)
var subject string
var body string
var systemName string
if !triggered && curValue > threshold {
alertRecord.Set("triggered", true)
systemName = systemRecord.GetString("name")
subject = fmt.Sprintf("%s usage above threshold on %s", name, systemName)
body = fmt.Sprintf("%s usage on %s is %.1f%%.", name, systemName, curValue)
} else if triggered && curValue <= threshold {
alertRecord.Set("triggered", false)
systemName = systemRecord.GetString("name")
subject = fmt.Sprintf("%s usage below threshold on %s", name, systemName)
body = fmt.Sprintf("%s usage on %s is below threshold at %.1f%%.", name, systemName, curValue)
} else {
// fmt.Println(name, "not triggered")
return
}
if err := am.app.Dao().SaveRecord(alertRecord); err != nil {
// app.Logger().Error("failed to save alert record", "err", err.Error())
return
}
// expand the user relation and send the alert
if errs := am.app.Dao().ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
// app.Logger().Error("failed to expand user relation", "errs", errs)
return
}
if user := alertRecord.ExpandedOne("user"); user != nil {
am.sendAlert(AlertData{
UserID: user.GetId(),
Title: subject,
Message: body,
Link: am.app.Settings().Meta.AppUrl + "/system/" + url.QueryEscape(systemName),
LinkText: "View " + systemName,
})
}
}
func (am *AlertManager) HandleStatusAlerts(newStatus string, oldSystemRecord *models.Record) error {
var alertStatus string
switch newStatus {
case "up":
if oldSystemRecord.GetString("status") == "down" {
alertStatus = "up"
}
case "down":
if oldSystemRecord.GetString("status") == "up" {
alertStatus = "down"
}
}
if alertStatus == "" {
return nil
}
// check if use
alertRecords, err := am.app.Dao().FindRecordsByExpr("alerts",
dbx.HashExp{
"system": oldSystemRecord.GetId(),
"name": "Status",
},
)
if err != nil || len(alertRecords) == 0 {
// log.Println("no alerts found for system")
return nil
}
for _, alertRecord := range alertRecords {
// expand the user relation
if errs := am.app.Dao().ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
return fmt.Errorf("failed to expand: %v", errs)
}
user := alertRecord.ExpandedOne("user")
if user == nil {
return nil
}
emoji := "\U0001F534"
if alertStatus == "up" {
emoji = "\u2705"
}
// send alert
systemName := oldSystemRecord.GetString("name")
am.sendAlert(AlertData{
UserID: user.GetId(),
Title: fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji),
Message: fmt.Sprintf("Connection to %s is %s", systemName, alertStatus),
Link: am.app.Settings().Meta.AppUrl + "/system/" + url.QueryEscape(systemName),
LinkText: "View " + systemName,
})
}
return nil
}
func (am *AlertManager) sendAlert(data AlertData) {
// get user settings
record, err := am.app.Dao().FindFirstRecordByFilter(
"user_settings", "user={:user}",
dbx.Params{"user": data.UserID},
)
if err != nil {
am.app.Logger().Error("Failed to get user settings", "err", err.Error())
return
}
// unmarshal user settings
userAlertSettings := UserNotificationSettings{
Emails: []string{},
Webhooks: []string{},
}
if err := record.UnmarshalJSONField("settings", &userAlertSettings); err != nil {
am.app.Logger().Error("Failed to unmarshal user settings", "err", err.Error())
}
// send alerts via webhooks
for _, webhook := range userAlertSettings.Webhooks {
if err := am.SendShoutrrrAlert(webhook, data.Title, data.Message, data.Link, data.LinkText); err != nil {
am.app.Logger().Error("Failed to send shoutrrr alert", "err", err.Error())
}
}
// send alerts via email
if len(userAlertSettings.Emails) == 0 {
// log.Println("No email addresses found")
return
}
addresses := []mail.Address{}
for _, email := range userAlertSettings.Emails {
addresses = append(addresses, mail.Address{Address: email})
}
message := mailer.Message{
To: addresses,
Subject: data.Title,
Text: data.Message + fmt.Sprintf("\n\n%s", data.Link),
From: mail.Address{
Address: am.app.Settings().Meta.SenderAddress,
Name: am.app.Settings().Meta.SenderName,
},
}
if err := am.app.NewMailClient().Send(&message); err != nil {
am.app.Logger().Error("Failed to send alert: ", "err", err.Error())
} else {
am.app.Logger().Info("Sent email alert", "to", message.To, "subj", message.Subject)
}
}
// SendShoutrrrAlert sends an alert via a Shoutrrr URL
func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link, linkText string) error {
// services that support title param
supportsTitle := []string{"bark", "discord", "gotify", "ifttt", "join", "matrix", "ntfy", "opsgenie", "pushbullet", "pushover", "slack", "teams", "telegram", "zulip"}
// Parse the URL
parsedURL, err := url.Parse(notificationUrl)
if err != nil {
return fmt.Errorf("error parsing URL: %v", err)
}
scheme := parsedURL.Scheme
queryParams := parsedURL.Query()
// Add title
if sliceContains(supportsTitle, scheme) {
queryParams.Add("title", title)
} else if scheme == "mattermost" {
// use markdown title for mattermost
message = "##### " + title + "\n\n" + message
} else if scheme == "generic" && queryParams.Has("template") {
// add title as property if using generic with template json
titleKey := queryParams.Get("titlekey")
if titleKey == "" {
titleKey = "title"
}
queryParams.Add("$"+titleKey, title)
} else {
// otherwise just add title to message
message = title + "\n\n" + message
}
// Add link
if scheme == "ntfy" {
// if ntfy, add link to actions
queryParams.Add("Actions", fmt.Sprintf("view, %s, %s", linkText, link))
} else {
// else add link directly to the message
message += "\n\n" + link
}
// Encode the modified query parameters back into the URL
parsedURL.RawQuery = queryParams.Encode()
// log.Println("URL after modification:", parsedURL.String())
err = shoutrrr.Send(parsedURL.String(), message)
if err == nil {
am.app.Logger().Info("Sent shoutrrr alert", "title", title)
} else {
am.app.Logger().Error("Error sending shoutrrr alert", "err", err.Error())
return err
}
return nil
}
// Contains checks if a string is present in a slice of strings
func sliceContains(slice []string, item string) bool {
for _, v := range slice {
if v == item {
return true
}
}
return false
}
func (am *AlertManager) SendTestNotification(c echo.Context) error {
requestData := apis.RequestInfo(c)
if requestData.AuthRecord == nil {
return apis.NewForbiddenError("Forbidden", nil)
}
url := c.QueryParam("url")
// log.Println("url", url)
if url == "" {
return c.JSON(200, map[string]string{"err": "URL is required"})
}
err := am.SendShoutrrrAlert(url, "Test Alert", "This is a notification from Beszel.", am.app.Settings().Meta.AppUrl, "View Beszel")
if err != nil {
return c.JSON(200, map[string]string{"err": err.Error()})
}
return c.JSON(200, map[string]bool{"err": false})
}

View File

@@ -0,0 +1,134 @@
package container
import "time"
// Docker container info from /containers/json
type ApiInfo struct {
Id string
IdShort string
Names []string
Status string
// Image string
// ImageID string
// Command string
// Created int64
// Ports []Port
// SizeRw int64 `json:",omitempty"`
// SizeRootFs int64 `json:",omitempty"`
// Labels map[string]string
// State string
// HostConfig struct {
// NetworkMode string `json:",omitempty"`
// Annotations map[string]string `json:",omitempty"`
// }
// NetworkSettings *SummaryNetworkSettings
// Mounts []MountPoint
}
// Docker container resources from /containers/{id}/stats
type ApiStats struct {
// Common stats
// Read time.Time `json:"read"`
// PreRead time.Time `json:"preread"`
// Linux specific stats, not populated on Windows.
// PidsStats PidsStats `json:"pids_stats,omitempty"`
// BlkioStats BlkioStats `json:"blkio_stats,omitempty"`
// Windows specific stats, not populated on Linux.
// NumProcs uint32 `json:"num_procs"`
// StorageStats StorageStats `json:"storage_stats,omitempty"`
// Networks request version >=1.21
Networks map[string]NetworkStats
// Shared stats
CPUStats CPUStats `json:"cpu_stats,omitempty"`
// PreCPUStats CPUStats `json:"precpu_stats,omitempty"` // "Pre"="Previous"
MemoryStats MemoryStats `json:"memory_stats,omitempty"`
}
type CPUStats struct {
// CPU Usage. Linux and Windows.
CPUUsage CPUUsage `json:"cpu_usage"`
// System Usage. Linux only.
SystemUsage uint64 `json:"system_cpu_usage,omitempty"`
// Online CPUs. Linux only.
// OnlineCPUs uint32 `json:"online_cpus,omitempty"`
// Throttling Data. Linux only.
// ThrottlingData ThrottlingData `json:"throttling_data,omitempty"`
}
type CPUUsage struct {
// Total CPU time consumed.
// Units: nanoseconds (Linux)
// Units: 100's of nanoseconds (Windows)
TotalUsage uint64 `json:"total_usage"`
// Total CPU time consumed per core (Linux). Not used on Windows.
// Units: nanoseconds.
// PercpuUsage []uint64 `json:"percpu_usage,omitempty"`
// Time spent by tasks of the cgroup in kernel mode (Linux).
// Time spent by all container processes in kernel mode (Windows).
// Units: nanoseconds (Linux).
// Units: 100's of nanoseconds (Windows). Not populated for Hyper-V Containers.
// UsageInKernelmode uint64 `json:"usage_in_kernelmode"`
// Time spent by tasks of the cgroup in user mode (Linux).
// Time spent by all container processes in user mode (Windows).
// Units: nanoseconds (Linux).
// Units: 100's of nanoseconds (Windows). Not populated for Hyper-V Containers
// UsageInUsermode uint64 `json:"usage_in_usermode"`
}
type MemoryStats struct {
// current res_counter usage for memory
Usage uint64 `json:"usage,omitempty"`
// all the stats exported via memory.stat.
Stats MemoryStatsStats `json:"stats,omitempty"`
// maximum usage ever recorded.
// MaxUsage uint64 `json:"max_usage,omitempty"`
// TODO(vishh): Export these as stronger types.
// number of times memory usage hits limits.
// Failcnt uint64 `json:"failcnt,omitempty"`
// Limit uint64 `json:"limit,omitempty"`
// // committed bytes
// Commit uint64 `json:"commitbytes,omitempty"`
// // peak committed bytes
// CommitPeak uint64 `json:"commitpeakbytes,omitempty"`
// // private working set
// PrivateWorkingSet uint64 `json:"privateworkingset,omitempty"`
}
type MemoryStatsStats struct {
Cache uint64 `json:"cache,omitempty"`
InactiveFile uint64 `json:"inactive_file,omitempty"`
}
type NetworkStats struct {
// Bytes received. Windows and Linux.
RxBytes uint64 `json:"rx_bytes"`
// Bytes sent. Windows and Linux.
TxBytes uint64 `json:"tx_bytes"`
}
type prevNetStats struct {
Sent uint64
Recv uint64
Time time.Time
}
// Docker container stats
type Stats struct {
Name string `json:"n"`
Cpu float64 `json:"c"`
Mem float64 `json:"m"`
NetworkSent float64 `json:"ns"`
NetworkRecv float64 `json:"nr"`
PrevCpu [2]uint64 `json:"-"`
PrevNet prevNetStats `json:"-"`
}

View File

@@ -0,0 +1,65 @@
package system
import (
"beszel/internal/entities/container"
"time"
)
type Stats struct {
Cpu float64 `json:"cpu"`
Mem float64 `json:"m"`
MemUsed float64 `json:"mu"`
MemPct float64 `json:"mp"`
MemBuffCache float64 `json:"mb"`
Swap float64 `json:"s,omitempty"`
SwapUsed float64 `json:"su,omitempty"`
DiskTotal float64 `json:"d"`
DiskUsed float64 `json:"du"`
DiskPct float64 `json:"dp"`
DiskReadPs float64 `json:"dr"`
DiskWritePs float64 `json:"dw"`
NetworkSent float64 `json:"ns"`
NetworkRecv float64 `json:"nr"`
Temperatures map[string]float64 `json:"t,omitempty"`
ExtraFs map[string]*FsStats `json:"efs,omitempty"`
}
type FsStats struct {
Time time.Time `json:"-"`
Root bool `json:"-"`
Mountpoint string `json:"-"`
DiskTotal float64 `json:"d"`
DiskUsed float64 `json:"du"`
TotalRead uint64 `json:"-"`
TotalWrite uint64 `json:"-"`
DiskWritePs float64 `json:"w"`
DiskReadPs float64 `json:"r"`
}
type NetIoStats struct {
BytesRecv uint64
BytesSent uint64
Time time.Time
Name string
}
type Info struct {
Hostname string `json:"h"`
KernelVersion string `json:"k,omitempty"`
Cores int `json:"c"`
Threads int `json:"t,omitempty"`
CpuModel string `json:"m"`
// Os string `json:"o"`
Uptime uint64 `json:"u"`
Cpu float64 `json:"cpu"`
MemPct float64 `json:"mp"`
DiskPct float64 `json:"dp"`
AgentVersion string `json:"v"`
}
// Final data structure to return to the hub
type CombinedData struct {
Stats Stats `json:"stats"`
Info Info `json:"info"`
Containers []*container.Stats `json:"container"`
}

489
beszel/internal/hub/hub.go Normal file
View File

@@ -0,0 +1,489 @@
// Package hub handles updating systems and serving the web UI.
package hub
import (
"beszel"
"beszel/internal/alerts"
"beszel/internal/entities/system"
"beszel/internal/records"
"beszel/internal/users"
"beszel/site"
"context"
"crypto/ed25519"
"encoding/pem"
"fmt"
"log"
"net/http"
"net/http/httputil"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/goccy/go-json"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/plugins/migratecmd"
"github.com/pocketbase/pocketbase/tools/cron"
"golang.org/x/crypto/ssh"
)
type Hub struct {
app *pocketbase.PocketBase
connectionLock *sync.Mutex
systemConnections map[string]*ssh.Client
sshClientConfig *ssh.ClientConfig
pubKey string
am *alerts.AlertManager
um *users.UserManager
rm *records.RecordManager
}
func NewHub(app *pocketbase.PocketBase) *Hub {
return &Hub{
app: app,
connectionLock: &sync.Mutex{},
systemConnections: make(map[string]*ssh.Client),
am: alerts.NewAlertManager(app),
um: users.NewUserManager(app),
rm: records.NewRecordManager(app),
}
}
func (h *Hub) Run() {
// rm := records.NewRecordManager(h.app)
// am := alerts.NewAlertManager(h.app)
// um := users.NewUserManager(h.app)
// loosely check if it was executed using "go run"
isGoRun := strings.HasPrefix(os.Args[0], os.TempDir())
// // enable auto creation of migration files when making collection changes in the Admin UI
migratecmd.MustRegister(h.app, h.app.RootCmd, migratecmd.Config{
// (the isGoRun check is to enable it only during development)
Automigrate: isGoRun,
Dir: "../../migrations",
})
// initial setup
h.app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
// create ssh client config
err := h.createSSHClientConfig()
if err != nil {
log.Fatal(err)
}
// set auth settings
usersCollection, err := h.app.Dao().FindCollectionByNameOrId("users")
if err != nil {
return err
}
usersAuthOptions := usersCollection.AuthOptions()
usersAuthOptions.AllowUsernameAuth = false
if os.Getenv("DISABLE_PASSWORD_AUTH") == "true" {
usersAuthOptions.AllowEmailAuth = false
} else {
usersAuthOptions.AllowEmailAuth = true
}
usersCollection.SetOptions(usersAuthOptions)
if err := h.app.Dao().SaveCollection(usersCollection); err != nil {
return err
}
return nil
})
// serve web ui
h.app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
switch isGoRun {
case true:
proxy := httputil.NewSingleHostReverseProxy(&url.URL{
Scheme: "http",
Host: "localhost:5173",
})
e.Router.Any("/*", echo.WrapHandler(proxy))
default:
csp, cspExists := os.LookupEnv("CSP")
e.Router.Any("/*", func(c echo.Context) error {
if cspExists {
c.Response().Header().Del("X-Frame-Options")
c.Response().Header().Set("Content-Security-Policy", csp)
}
indexFallback := !strings.HasPrefix(c.Request().URL.Path, "/static/")
return apis.StaticDirectoryHandler(site.Dist, indexFallback)(c)
})
}
return nil
})
// set up scheduled jobs / ticker for system updates
h.app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
// 15 second ticker for system updates
go h.startSystemUpdateTicker()
// set up cron jobs
scheduler := cron.New()
// delete old records once every hour
scheduler.MustAdd("delete old records", "8 * * * *", h.rm.DeleteOldRecords)
// create longer records every 10 minutes
scheduler.MustAdd("create longer records", "*/10 * * * *", h.rm.CreateLongerRecords)
scheduler.Start()
return nil
})
// custom api routes
h.app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
// returns public key
e.Router.GET("/api/beszel/getkey", func(c echo.Context) error {
requestData := apis.RequestInfo(c)
if requestData.AuthRecord == nil {
return apis.NewForbiddenError("Forbidden", nil)
}
return c.JSON(http.StatusOK, map[string]string{"key": h.pubKey, "v": beszel.Version})
})
// check if first time setup on login page
e.Router.GET("/api/beszel/first-run", func(c echo.Context) error {
adminNum, err := h.app.Dao().TotalAdmins()
if err != nil {
return err
}
return c.JSON(http.StatusOK, map[string]bool{"firstRun": adminNum == 0})
})
// send test notification
e.Router.GET("/api/beszel/send-test-notification", h.am.SendTestNotification)
return nil
})
// system creation defaults
h.app.OnModelBeforeCreate("systems").Add(func(e *core.ModelEvent) error {
record := e.Model.(*models.Record)
record.Set("info", system.Info{})
record.Set("status", "pending")
return nil
})
// immediately create connection for new systems
h.app.OnModelAfterCreate("systems").Add(func(e *core.ModelEvent) error {
go h.updateSystem(e.Model.(*models.Record))
return nil
})
// handle default values for user / user_settings creation
h.app.OnModelBeforeCreate("users").Add(h.um.InitializeUserRole)
h.app.OnModelBeforeCreate("user_settings").Add(h.um.InitializeUserSettings)
// do things after a systems record is updated
h.app.OnModelAfterUpdate("systems").Add(func(e *core.ModelEvent) error {
newRecord := e.Model.(*models.Record)
oldRecord := newRecord.OriginalCopy()
newStatus := newRecord.GetString("status")
// if system is disconnected and connection exists, remove it
if newStatus == "down" || newStatus == "paused" {
h.deleteSystemConnection(newRecord)
}
// if system is set to pending (unpause), try to connect immediately
if newStatus == "pending" {
go h.updateSystem(newRecord)
} else {
h.am.HandleStatusAlerts(newStatus, oldRecord)
}
return nil
})
// do things after a systems record is deleted
h.app.OnModelAfterDelete("systems").Add(func(e *core.ModelEvent) error {
// if system connection exists, close it
h.deleteSystemConnection(e.Model.(*models.Record))
return nil
})
if err := h.app.Start(); err != nil {
log.Fatal(err)
}
}
func (h *Hub) startSystemUpdateTicker() {
ticker := time.NewTicker(15 * time.Second)
for range ticker.C {
h.updateSystems()
}
}
func (h *Hub) updateSystems() {
records, err := h.app.Dao().FindRecordsByFilter(
"2hz5ncl8tizk5nx", // systems collection
"status != 'paused'", // filter
"updated", // sort
-1, // limit
0, // offset
)
// log.Println("records", len(records))
if err != nil || len(records) == 0 {
// h.app.Logger().Error("Failed to query systems")
return
}
fiftySecondsAgo := time.Now().UTC().Add(-50 * time.Second)
batchSize := len(records)/4 + 1
done := 0
for _, record := range records {
// break if batch size reached or if the system was updated less than 50 seconds ago
if done >= batchSize || record.GetDateTime("updated").Time().After(fiftySecondsAgo) {
break
}
// don't increment for down systems to avoid them jamming the queue
// because they're always first when sorted by least recently updated
if record.GetString("status") != "down" {
done++
}
go h.updateSystem(record)
}
}
func (h *Hub) updateSystem(record *models.Record) {
var client *ssh.Client
var err error
// check if system connection data exists
if _, ok := h.systemConnections[record.Id]; ok {
client = h.systemConnections[record.Id]
} else {
// create system connection
client, err = h.createSystemConnection(record)
if err != nil {
if record.GetString("status") != "down" {
h.app.Logger().Error("Failed to connect:", "err", err.Error(), "system", record.GetString("host"), "port", record.GetString("port"))
h.updateSystemStatus(record, "down")
}
return
}
h.connectionLock.Lock()
h.systemConnections[record.Id] = client
h.connectionLock.Unlock()
}
// get system stats from agent
var systemData system.CombinedData
if err := h.requestJsonFromAgent(client, &systemData); err != nil {
if err.Error() == "bad client" {
// if previous connection was closed, try again
h.app.Logger().Error("Existing SSH connection closed. Retrying...", "host", record.GetString("host"), "port", record.GetString("port"))
h.deleteSystemConnection(record)
h.updateSystem(record)
return
}
h.app.Logger().Error("Failed to get system stats: ", "err", err.Error())
h.updateSystemStatus(record, "down")
return
}
// update system record
record.Set("status", "up")
record.Set("info", systemData.Info)
if err := h.app.Dao().SaveRecord(record); err != nil {
h.app.Logger().Error("Failed to update record: ", "err", err.Error())
}
// add new system_stats record
system_stats, _ := h.app.Dao().FindCollectionByNameOrId("system_stats")
systemStatsRecord := models.NewRecord(system_stats)
systemStatsRecord.Set("system", record.Id)
systemStatsRecord.Set("stats", systemData.Stats)
systemStatsRecord.Set("type", "1m")
if err := h.app.Dao().SaveRecord(systemStatsRecord); err != nil {
h.app.Logger().Error("Failed to save record: ", "err", err.Error())
}
// add new container_stats record
if len(systemData.Containers) > 0 {
container_stats, _ := h.app.Dao().FindCollectionByNameOrId("container_stats")
containerStatsRecord := models.NewRecord(container_stats)
containerStatsRecord.Set("system", record.Id)
containerStatsRecord.Set("stats", systemData.Containers)
containerStatsRecord.Set("type", "1m")
if err := h.app.Dao().SaveRecord(containerStatsRecord); err != nil {
h.app.Logger().Error("Failed to save record: ", "err", err.Error())
}
}
// system info alerts (todo: temp alerts, extra fs alerts)
h.am.HandleSystemInfoAlerts(record, systemData.Info)
}
// set system to specified status and save record
func (h *Hub) updateSystemStatus(record *models.Record, status string) {
if record.GetString("status") != status {
record.Set("status", status)
if err := h.app.Dao().SaveRecord(record); err != nil {
h.app.Logger().Error("Failed to update record: ", "err", err.Error())
}
}
}
func (h *Hub) deleteSystemConnection(record *models.Record) {
if _, ok := h.systemConnections[record.Id]; ok {
if h.systemConnections[record.Id] != nil {
h.systemConnections[record.Id].Close()
}
h.connectionLock.Lock()
defer h.connectionLock.Unlock()
delete(h.systemConnections, record.Id)
}
}
func (h *Hub) createSystemConnection(record *models.Record) (*ssh.Client, error) {
client, err := ssh.Dial("tcp", fmt.Sprintf("%s:%s", record.GetString("host"), record.GetString("port")), h.sshClientConfig)
if err != nil {
return nil, err
}
return client, nil
}
func (h *Hub) createSSHClientConfig() error {
key, err := h.getSSHKey()
if err != nil {
h.app.Logger().Error("Failed to get SSH key: ", "err", err.Error())
return err
}
// Create the Signer for this private key.
signer, err := ssh.ParsePrivateKey(key)
if err != nil {
return err
}
h.sshClientConfig = &ssh.ClientConfig{
User: "u",
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 5 * time.Second,
}
return nil
}
// Fetches system stats from the agent and decodes the json data into the provided struct
func (h *Hub) requestJsonFromAgent(client *ssh.Client, systemData *system.CombinedData) error {
session, err := newSessionWithTimeout(client, 5*time.Second)
if err != nil {
return fmt.Errorf("bad client")
}
defer session.Close()
stdout, err := session.StdoutPipe()
if err != nil {
return err
}
if err := session.Shell(); err != nil {
return err
}
if err := json.NewDecoder(stdout).Decode(systemData); err != nil {
return err
}
// wait for the session to complete
if err := session.Wait(); err != nil {
return err
}
return nil
}
// Adds timeout to SSH session creation to avoid hanging in case of network issues
func newSessionWithTimeout(client *ssh.Client, timeout time.Duration) (*ssh.Session, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
// use goroutine to create the session
sessionChan := make(chan *ssh.Session, 1)
errChan := make(chan error, 1)
go func() {
if session, err := client.NewSession(); err != nil {
errChan <- err
} else {
sessionChan <- session
}
}()
select {
case session := <-sessionChan:
return session, nil
case err := <-errChan:
return nil, err
case <-ctx.Done():
return nil, fmt.Errorf("session creation timed out")
}
}
func (h *Hub) getSSHKey() ([]byte, error) {
dataDir := h.app.DataDir()
// check if the key pair already exists
existingKey, err := os.ReadFile(dataDir + "/id_ed25519")
if err == nil {
if pubKey, err := os.ReadFile(h.app.DataDir() + "/id_ed25519.pub"); err == nil {
h.pubKey = strings.TrimSuffix(string(pubKey), "\n")
}
// return existing private key
return existingKey, nil
}
// Generate the Ed25519 key pair
pubKey, privKey, err := ed25519.GenerateKey(nil)
if err != nil {
// h.app.Logger().Error("Error generating key pair:", "err", err.Error())
return nil, err
}
// Get the private key in OpenSSH format
privKeyBytes, err := ssh.MarshalPrivateKey(privKey, "")
if err != nil {
// h.app.Logger().Error("Error marshaling private key:", "err", err.Error())
return nil, err
}
// Save the private key to a file
privateFile, err := os.Create(dataDir + "/id_ed25519")
if err != nil {
// h.app.Logger().Error("Error creating private key file:", "err", err.Error())
return nil, err
}
defer privateFile.Close()
if err := pem.Encode(privateFile, privKeyBytes); err != nil {
// h.app.Logger().Error("Error writing private key to file:", "err", err.Error())
return nil, err
}
// Generate the public key in OpenSSH format
publicKey, err := ssh.NewPublicKey(pubKey)
if err != nil {
return nil, err
}
pubKeyBytes := ssh.MarshalAuthorizedKey(publicKey)
h.pubKey = strings.TrimSuffix(string(pubKeyBytes), "\n")
// Save the public key to a file
publicFile, err := os.Create(dataDir + "/id_ed25519.pub")
if err != nil {
return nil, err
}
defer publicFile.Close()
if _, err := publicFile.Write(pubKeyBytes); err != nil {
return nil, err
}
h.app.Logger().Info("ed25519 SSH key pair generated successfully.")
h.app.Logger().Info("Private key saved to: " + dataDir + "/id_ed25519")
h.app.Logger().Info("Public key saved to: " + dataDir + "/id_ed25519.pub")
existingKey, err = os.ReadFile(dataDir + "/id_ed25519")
if err == nil {
return existingKey, nil
}
return nil, err
}

View File

@@ -0,0 +1,309 @@
// Package records handles creating longer records and deleting old records.
package records
import (
"beszel/internal/entities/container"
"beszel/internal/entities/system"
"log"
"math"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/types"
)
type RecordManager struct {
app *pocketbase.PocketBase
}
type LongerRecordData struct {
shorterType string
longerType string
longerTimeDuration time.Duration
minShorterRecords int
}
type RecordDeletionData struct {
recordType string
retention time.Duration
}
func NewRecordManager(app *pocketbase.PocketBase) *RecordManager {
return &RecordManager{app}
}
// Create longer records by averaging shorter records
func (rm *RecordManager) CreateLongerRecords() {
// start := time.Now()
recordData := []LongerRecordData{
{
shorterType: "1m",
// change to 9 from 10 to allow edge case timing or short pauses
minShorterRecords: 9,
longerType: "10m",
longerTimeDuration: -10 * time.Minute,
},
{
shorterType: "10m",
minShorterRecords: 2,
longerType: "20m",
longerTimeDuration: -20 * time.Minute,
},
{
shorterType: "20m",
minShorterRecords: 6,
longerType: "120m",
longerTimeDuration: -120 * time.Minute,
},
{
shorterType: "120m",
minShorterRecords: 4,
longerType: "480m",
longerTimeDuration: -480 * time.Minute,
},
}
// wrap the operations in a transaction
rm.app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
activeSystems, err := txDao.FindRecordsByExpr("systems", dbx.NewExp("status = 'up'"))
if err != nil {
log.Println("failed to get active systems", "err", err.Error())
return err
}
collections := map[string]*models.Collection{}
for _, collectionName := range []string{"system_stats", "container_stats"} {
collection, _ := txDao.FindCollectionByNameOrId(collectionName)
collections[collectionName] = collection
}
// loop through all active systems, time periods, and collections
for _, system := range activeSystems {
// log.Println("processing system", system.GetString("name"))
for _, recordData := range recordData {
// log.Println("processing longer record type", recordData.longerType)
// add one minute padding for longer records because they are created slightly later than the job start time
longerRecordPeriod := time.Now().UTC().Add(recordData.longerTimeDuration + time.Minute)
// shorter records are created independently of longer records, so we shouldn't need to add padding
shorterRecordPeriod := time.Now().UTC().Add(recordData.longerTimeDuration)
// loop through both collections
for _, collection := range collections {
// check creation time of last longer record if not 10m, since 10m is created every run
if recordData.longerType != "10m" {
lastLongerRecord, err := txDao.FindFirstRecordByFilter(
collection.Id,
"type = {:type} && system = {:system} && created > {:created}",
dbx.Params{"type": recordData.longerType, "system": system.Id, "created": longerRecordPeriod},
)
// continue if longer record exists
if err == nil || lastLongerRecord != nil {
// log.Println("longer record found. continuing")
continue
}
}
// get shorter records from the past x minutes
allShorterRecords, err := txDao.FindRecordsByExpr(
collection.Id,
dbx.NewExp(
"type = {:type} AND system = {:system} AND created > {:created}",
dbx.Params{"type": recordData.shorterType, "system": system.Id, "created": shorterRecordPeriod},
),
)
// continue if not enough shorter records
if err != nil || len(allShorterRecords) < recordData.minShorterRecords {
// log.Println("not enough shorter records. continue.", len(allShorterRecords), recordData.expectedShorterRecords)
continue
}
// average the shorter records and create longer record
var stats interface{}
switch collection.Name {
case "system_stats":
stats = rm.AverageSystemStats(allShorterRecords)
case "container_stats":
stats = rm.AverageContainerStats(allShorterRecords)
}
longerRecord := models.NewRecord(collection)
longerRecord.Set("system", system.Id)
longerRecord.Set("stats", stats)
longerRecord.Set("type", recordData.longerType)
if err := txDao.SaveRecord(longerRecord); err != nil {
log.Println("failed to save longer record", "err", err.Error())
}
}
}
}
return nil
})
// log.Println("finished creating longer records", "time (ms)", time.Since(start).Milliseconds())
}
// Calculate the average stats of a list of system_stats records without reflect
func (rm *RecordManager) AverageSystemStats(records []*models.Record) system.Stats {
sum := system.Stats{
Temperatures: make(map[string]float64),
ExtraFs: make(map[string]*system.FsStats),
}
count := float64(len(records))
// use different counter for temps in case some records don't have them
tempCount := float64(0)
var stats system.Stats
for _, record := range records {
record.UnmarshalJSONField("stats", &stats)
sum.Cpu += stats.Cpu
sum.Mem += stats.Mem
sum.MemUsed += stats.MemUsed
sum.MemPct += stats.MemPct
sum.MemBuffCache += stats.MemBuffCache
sum.Swap += stats.Swap
sum.SwapUsed += stats.SwapUsed
sum.DiskTotal += stats.DiskTotal
sum.DiskUsed += stats.DiskUsed
sum.DiskPct += stats.DiskPct
sum.DiskReadPs += stats.DiskReadPs
sum.DiskWritePs += stats.DiskWritePs
sum.NetworkSent += stats.NetworkSent
sum.NetworkRecv += stats.NetworkRecv
// add temps to sum
if stats.Temperatures != nil {
tempCount++
for key, value := range stats.Temperatures {
if _, ok := sum.Temperatures[key]; !ok {
sum.Temperatures[key] = 0
}
sum.Temperatures[key] += value
}
}
// add extra fs to sum
if stats.ExtraFs != nil {
for key, value := range stats.ExtraFs {
if _, ok := sum.ExtraFs[key]; !ok {
sum.ExtraFs[key] = &system.FsStats{}
}
sum.ExtraFs[key].DiskTotal += value.DiskTotal
sum.ExtraFs[key].DiskUsed += value.DiskUsed
sum.ExtraFs[key].DiskWritePs += value.DiskWritePs
sum.ExtraFs[key].DiskReadPs += value.DiskReadPs
}
}
}
stats = system.Stats{
Cpu: twoDecimals(sum.Cpu / count),
Mem: twoDecimals(sum.Mem / count),
MemUsed: twoDecimals(sum.MemUsed / count),
MemPct: twoDecimals(sum.MemPct / count),
MemBuffCache: twoDecimals(sum.MemBuffCache / count),
Swap: twoDecimals(sum.Swap / count),
SwapUsed: twoDecimals(sum.SwapUsed / count),
DiskTotal: twoDecimals(sum.DiskTotal / count),
DiskUsed: twoDecimals(sum.DiskUsed / count),
DiskPct: twoDecimals(sum.DiskPct / count),
DiskReadPs: twoDecimals(sum.DiskReadPs / count),
DiskWritePs: twoDecimals(sum.DiskWritePs / count),
NetworkSent: twoDecimals(sum.NetworkSent / count),
NetworkRecv: twoDecimals(sum.NetworkRecv / count),
}
if len(sum.Temperatures) != 0 {
stats.Temperatures = make(map[string]float64)
for key, value := range sum.Temperatures {
stats.Temperatures[key] = twoDecimals(value / tempCount)
}
}
if len(sum.ExtraFs) != 0 {
stats.ExtraFs = make(map[string]*system.FsStats)
for key, value := range sum.ExtraFs {
stats.ExtraFs[key] = &system.FsStats{
DiskTotal: twoDecimals(value.DiskTotal / count),
DiskUsed: twoDecimals(value.DiskUsed / count),
DiskWritePs: twoDecimals(value.DiskWritePs / count),
DiskReadPs: twoDecimals(value.DiskReadPs / count),
}
}
}
return stats
}
// Calculate the average stats of a list of container_stats records
func (rm *RecordManager) AverageContainerStats(records []*models.Record) []container.Stats {
sums := make(map[string]*container.Stats)
count := float64(len(records))
var containerStats []container.Stats
for _, record := range records {
record.UnmarshalJSONField("stats", &containerStats)
for _, stat := range containerStats {
if _, ok := sums[stat.Name]; !ok {
sums[stat.Name] = &container.Stats{Name: stat.Name}
}
sums[stat.Name].Cpu += stat.Cpu
sums[stat.Name].Mem += stat.Mem
sums[stat.Name].NetworkSent += stat.NetworkSent
sums[stat.Name].NetworkRecv += stat.NetworkRecv
}
}
result := make([]container.Stats, 0, len(sums))
for _, value := range sums {
result = append(result, container.Stats{
Name: value.Name,
Cpu: twoDecimals(value.Cpu / count),
Mem: twoDecimals(value.Mem / count),
NetworkSent: twoDecimals(value.NetworkSent / count),
NetworkRecv: twoDecimals(value.NetworkRecv / count),
})
}
return result
}
// Deletes records older than what is displayed in the UI
func (rm *RecordManager) DeleteOldRecords() {
collections := []string{"system_stats", "container_stats"}
recordData := []RecordDeletionData{
{
recordType: "1m",
retention: time.Hour,
},
{
recordType: "10m",
retention: 12 * time.Hour,
},
{
recordType: "20m",
retention: 24 * time.Hour,
},
{
recordType: "120m",
retention: 7 * 24 * time.Hour,
},
{
recordType: "480m",
retention: 30 * 24 * time.Hour,
},
}
db := rm.app.Dao().NonconcurrentDB()
for _, recordData := range recordData {
for _, collectionSlug := range collections {
formattedDate := time.Now().UTC().Add(-recordData.retention).Format(types.DefaultDateLayout)
expr := dbx.NewExp("[[created]] < {:date} AND [[type]] = {:type}", dbx.Params{"date": formattedDate, "type": recordData.recordType})
_, err := db.Delete(collectionSlug, expr).Execute()
if err != nil {
rm.app.Logger().Error("Failed to delete records", "err", err.Error())
}
}
}
}
/* Round float to two decimals */
func twoDecimals(value float64) float64 {
return math.Round(value*100) / 100
}

View File

@@ -0,0 +1,100 @@
// Package update handles updating beszel and beszel-agent.
package update
import (
"beszel"
"fmt"
"os"
"strings"
"github.com/blang/semver"
"github.com/rhysd/go-github-selfupdate/selfupdate"
)
func UpdateBeszel() {
var latest *selfupdate.Release
var found bool
var err error
currentVersion := semver.MustParse(beszel.Version)
fmt.Println("beszel", currentVersion)
fmt.Println("Checking for updates...")
updater, _ := selfupdate.NewUpdater(selfupdate.Config{
Filters: []string{"beszel_"},
})
latest, found, err = updater.DetectLatest("henrygd/beszel")
if err != nil {
fmt.Println("Error checking for updates:", err)
os.Exit(1)
}
if !found {
fmt.Println("No updates found")
os.Exit(0)
}
fmt.Println("Latest version:", latest.Version)
if latest.Version.LTE(currentVersion) {
fmt.Println("You are up to date")
return
}
var binaryPath string
fmt.Printf("Updating from %s to %s...\n", currentVersion, latest.Version)
binaryPath, err = os.Executable()
if err != nil {
fmt.Println("Error getting binary path:", err)
os.Exit(1)
}
err = selfupdate.UpdateTo(latest.AssetURL, binaryPath)
if err != nil {
fmt.Println("Please try rerunning with sudo. Error:", err)
os.Exit(1)
}
fmt.Printf("Successfully updated to %s\n\n%s\n", latest.Version, strings.TrimSpace(latest.ReleaseNotes))
}
func UpdateBeszelAgent() {
var latest *selfupdate.Release
var found bool
var err error
currentVersion := semver.MustParse(beszel.Version)
fmt.Println("beszel-agent", currentVersion)
fmt.Println("Checking for updates...")
updater, _ := selfupdate.NewUpdater(selfupdate.Config{
Filters: []string{"beszel-agent"},
})
latest, found, err = updater.DetectLatest("henrygd/beszel")
if err != nil {
fmt.Println("Error checking for updates:", err)
os.Exit(1)
}
if !found {
fmt.Println("No updates found")
os.Exit(0)
}
fmt.Println("Latest version:", latest.Version)
if latest.Version.LTE(currentVersion) {
fmt.Println("You are up to date")
return
}
var binaryPath string
fmt.Printf("Updating from %s to %s...\n", currentVersion, latest.Version)
binaryPath, err = os.Executable()
if err != nil {
fmt.Println("Error getting binary path:", err)
os.Exit(1)
}
err = selfupdate.UpdateTo(latest.AssetURL, binaryPath)
if err != nil {
fmt.Println("Please try rerunning with sudo. Error:", err)
os.Exit(1)
}
fmt.Printf("Successfully updated to %s\n\n%s\n", latest.Version, strings.TrimSpace(latest.ReleaseNotes))
}

View File

@@ -0,0 +1,65 @@
// Package users handles user-related custom functionality.
package users
import (
"log"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models"
)
type UserManager struct {
app *pocketbase.PocketBase
}
type UserSettings struct {
ChartTime string `json:"chartTime"`
NotificationEmails []string `json:"emails"`
NotificationWebhooks []string `json:"webhooks"`
// Language string `json:"lang"`
}
func NewUserManager(app *pocketbase.PocketBase) *UserManager {
return &UserManager{
app: app,
}
}
func (um *UserManager) InitializeUserRole(e *core.ModelEvent) error {
user := e.Model.(*models.Record)
if user.GetString("role") == "" {
user.Set("role", "user")
}
return nil
}
func (um *UserManager) InitializeUserSettings(e *core.ModelEvent) error {
record := e.Model.(*models.Record)
// intialize settings with defaults
settings := UserSettings{
// Language: "en",
ChartTime: "1h",
NotificationEmails: []string{},
NotificationWebhooks: []string{},
}
record.UnmarshalJSONField("settings", &settings)
if len(settings.NotificationEmails) == 0 {
// get user email from auth record
if errs := um.app.Dao().ExpandRecord(record, []string{"user"}, nil); len(errs) == 0 {
// app.Logger().Error("failed to expand user relation", "errs", errs)
if user := record.ExpandedOne("user"); user != nil {
settings.NotificationEmails = []string{user.GetString("email")}
} else {
log.Println("Failed to get user email from auth record")
}
} else {
log.Println("failed to expand user relation", "errs", errs)
}
}
// if len(settings.NotificationWebhooks) == 0 {
// settings.NotificationWebhooks = []string{""}
// }
record.Set("settings", settings)
return nil
}

View File

@@ -15,7 +15,7 @@ func init() {
{
"id": "2hz5ncl8tizk5nx",
"created": "2024-07-07 16:08:20.979Z",
"updated": "2024-07-17 15:27:00.429Z",
"updated": "2024-07-28 17:14:24.492Z",
"name": "systems",
"type": "base",
"system": false,
@@ -39,7 +39,7 @@ func init() {
"id": "waj7seaf",
"name": "status",
"type": "select",
"required": true,
"required": false,
"presentable": false,
"unique": false,
"options": {
@@ -85,7 +85,7 @@ func init() {
"id": "qoq64ntl",
"name": "info",
"type": "json",
"required": true,
"required": false,
"presentable": false,
"unique": false,
"options": {
@@ -102,7 +102,7 @@ func init() {
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": false,
"cascadeDelete": true,
"minSelect": null,
"maxSelect": null,
"displayFields": null
@@ -120,7 +120,7 @@ func init() {
{
"id": "ej9oowivz8b2mht",
"created": "2024-07-07 16:09:09.179Z",
"updated": "2024-07-18 15:56:45.302Z",
"updated": "2024-07-28 17:14:24.492Z",
"name": "system_stats",
"type": "base",
"system": false,
@@ -186,7 +186,7 @@ func init() {
{
"id": "juohu4jipgc13v7",
"created": "2024-07-07 16:09:57.976Z",
"updated": "2024-07-18 15:57:50.933Z",
"updated": "2024-07-28 17:14:24.492Z",
"name": "container_stats",
"type": "base",
"system": false,
@@ -250,7 +250,7 @@ func init() {
{
"id": "_pb_users_auth_",
"created": "2024-07-14 16:25:18.226Z",
"updated": "2024-07-20 00:55:02.071Z",
"updated": "2024-09-12 23:19:36.280Z",
"name": "users",
"type": "auth",
"system": false,
@@ -260,7 +260,7 @@ func init() {
"id": "qkbp58ae",
"name": "role",
"type": "select",
"required": true,
"required": false,
"presentable": false,
"unique": false,
"options": {
@@ -304,7 +304,7 @@ func init() {
"options": {
"allowEmailAuth": true,
"allowOAuth2Auth": true,
"allowUsernameAuth": true,
"allowUsernameAuth": false,
"exceptEmailDomains": null,
"manageRule": null,
"minPasswordLength": 8,
@@ -316,7 +316,7 @@ func init() {
{
"id": "elngm8x1l60zi2v",
"created": "2024-07-15 01:16:04.044Z",
"updated": "2024-07-15 22:44:12.297Z",
"updated": "2024-07-28 17:14:24.492Z",
"name": "alerts",
"type": "base",
"system": false,
@@ -364,18 +364,92 @@ func init() {
"options": {
"maxSelect": 1,
"values": [
"status"
"Status",
"CPU",
"Memory",
"Disk"
]
}
},
{
"system": false,
"id": "o2ablxvn",
"name": "value",
"type": "number",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"noDecimal": false
}
},
{
"system": false,
"id": "6hgdf6hs",
"name": "triggered",
"type": "bool",
"required": false,
"presentable": false,
"unique": false,
"options": {}
}
],
"indexes": [],
"listRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"viewRule": "",
"createRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"updateRule": null,
"updateRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"deleteRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"options": {}
},
{
"id": "4afacsdnlu8q8r2",
"created": "2024-09-12 17:42:55.324Z",
"updated": "2024-09-12 21:19:59.114Z",
"name": "user_settings",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "d5vztyxa",
"name": "user",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "xcx4qgqq",
"name": "settings",
"type": "json",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSize": 2000000
}
}
],
"indexes": [
"CREATE UNIQUE INDEX ` + "`" + `idx_30Lwgf2` + "`" + ` ON ` + "`" + `user_settings` + "`" + ` (` + "`" + `user` + "`" + `)"
],
"listRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"viewRule": null,
"createRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"updateRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"deleteRule": null,
"options": {}
}
]`

BIN
beszel/site/bun.lockb Executable file

Binary file not shown.

View File

@@ -11,5 +11,3 @@ import (
var assets embed.FS
var Dist = echo.MustSubFS(assets, "dist")
var Static = echo.MustSubFS(assets, "dist/static")

View File

@@ -5,12 +5,6 @@
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Beszel</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="app"></div>

53
beszel/site/package.json Normal file
View File

@@ -0,0 +1,53 @@
{
"name": "site",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@nanostores/react": "^0.7.3",
"@nanostores/router": "^0.15.1",
"@radix-ui/react-alert-dialog": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slider": "^1.2.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-tooltip": "^1.1.2",
"@tanstack/react-table": "^8.20.5",
"@vitejs/plugin-react": "^4.3.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"d3-scale": "^4.0.2",
"d3-time": "^3.1.0",
"lucide-react": "^0.407.0",
"nanostores": "^0.10.3",
"pocketbase": "^0.21.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"recharts": "^2.13.0-alpha.4",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"use-is-in-viewport": "^1.0.9",
"valibot": "^0.36.0"
},
"devDependencies": {
"@types/bun": "^1.1.8",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.44",
"tailwindcss": "^3.4.10",
"typescript": "^5.5.4",
"vite": "^5.4.2"
}
}

Binary file not shown.

View File

Before

Width:  |  Height:  |  Size: 378 B

After

Width:  |  Height:  |  Size: 378 B

View File

Before

Width:  |  Height:  |  Size: 196 B

After

Width:  |  Height:  |  Size: 196 B

View File

Before

Width:  |  Height:  |  Size: 506 B

After

Width:  |  Height:  |  Size: 506 B

View File

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 295 B

View File

Before

Width:  |  Height:  |  Size: 906 B

After

Width:  |  Height:  |  Size: 906 B

View File

Before

Width:  |  Height:  |  Size: 906 B

After

Width:  |  Height:  |  Size: 906 B

View File

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 903 B

View File

Before

Width:  |  Height:  |  Size: 907 B

After

Width:  |  Height:  |  Size: 907 B

View File

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 406 B

View File

Before

Width:  |  Height:  |  Size: 470 B

After

Width:  |  Height:  |  Size: 470 B

View File

Before

Width:  |  Height:  |  Size: 302 B

After

Width:  |  Height:  |  Size: 302 B

View File

Before

Width:  |  Height:  |  Size: 299 B

After

Width:  |  Height:  |  Size: 299 B

View File

Before

Width:  |  Height:  |  Size: 856 B

After

Width:  |  Height:  |  Size: 856 B

View File

Before

Width:  |  Height:  |  Size: 257 B

After

Width:  |  Height:  |  Size: 257 B

View File

Before

Width:  |  Height:  |  Size: 276 B

After

Width:  |  Height:  |  Size: 276 B

View File

Before

Width:  |  Height:  |  Size: 227 B

After

Width:  |  Height:  |  Size: 227 B

View File

Before

Width:  |  Height:  |  Size: 495 B

After

Width:  |  Height:  |  Size: 495 B

View File

Before

Width:  |  Height:  |  Size: 154 B

After

Width:  |  Height:  |  Size: 154 B

View File

Before

Width:  |  Height:  |  Size: 206 B

After

Width:  |  Height:  |  Size: 206 B

View File

Before

Width:  |  Height:  |  Size: 371 B

After

Width:  |  Height:  |  Size: 371 B

View File

@@ -13,13 +13,13 @@ import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@/comp
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { $publicKey, pb } from '@/lib/stores'
import { Copy, Plus } from 'lucide-react'
import { useState, useRef, MutableRefObject, useEffect } from 'react'
import { Copy, PlusIcon } from 'lucide-react'
import { useState, useRef, MutableRefObject } from 'react'
import { useStore } from '@nanostores/react'
import { copyToClipboard } from '@/lib/utils'
import { SystemStats } from '@/types'
import { cn, copyToClipboard, isReadOnlyUser } from '@/lib/utils'
import { navigate } from './router'
export function AddServerButton() {
export function AddSystemButton({ className }: { className?: string }) {
const [open, setOpen] = useState(false)
const port = useRef() as MutableRefObject<HTMLInputElement>
const publicKey = useStore($publicKey)
@@ -27,49 +27,29 @@ export function AddServerButton() {
function copyDockerCompose(port: string) {
copyToClipboard(`services:
beszel-agent:
image: 'henrygd/beszel-agent'
container_name: 'beszel-agent'
image: "henrygd/beszel-agent"
container_name: "beszel-agent"
restart: unless-stopped
network_mode: host
volumes:
- /var/run/docker.sock:/var/run/docker.sock
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
# monitor other disks / partitions by mounting a folder in /extra-filesystems
# - /mnt/disk1/.beszel:/extra-filesystems/disk1:ro
environment:
- PORT=${port}
- KEY="${publicKey}"
# - FILESYSTEM=/dev/sda1 # set to the correct filesystem for disk I/O stats`)
PORT: ${port}
KEY: "${publicKey}"
# FILESYSTEM: /dev/sda1 # override the root partition / device for disk I/O stats`)
}
useEffect(() => {
if (publicKey || !open) {
return
}
// get public key
pb.send('/api/beszel/getkey', {}).then(({ key }) => {
$publicKey.set(key)
})
}, [open])
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
const formData = new FormData(e.target as HTMLFormElement)
const data = Object.fromEntries(formData) as Record<string, any>
data.status = 'pending'
data.users = pb.authStore.model!.id
data.info = {
cpu: 0,
m: 0,
mu: 0,
mp: 0,
mb: 0,
d: 0,
du: 0,
dp: 0,
dr: 0,
dw: 0,
} as SystemStats
try {
setOpen(false)
await pb.collection('systems').create(data)
navigate('/')
// console.log(record)
} catch (e) {
console.log(e)
@@ -79,16 +59,19 @@ export function AddServerButton() {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="flex gap-1">
<Plus className="h-4 w-4 mr-auto" />
Add <span className="hidden sm:inline">System</span>
<Button
variant="outline"
className={cn('flex gap-1 max-xs:h-[2.4rem]', className, isReadOnlyUser() && 'hidden')}
>
<PlusIcon className="h-4 w-4 -ml-1" />
Add <span className="hidden xs:inline">System</span>
</Button>
</DialogTrigger>
<DialogContent className="w-[90%] sm:max-w-[425px] rounded-lg">
<DialogHeader>
<DialogTitle className="mb-2">Add New System</DialogTitle>
<DialogDescription>
The agent must be running on the server to connect. Copy the{' '}
The agent must be running on the system to connect. Copy the{' '}
<code className="bg-muted px-1 rounded-sm">docker-compose.yml</code> for the agent
below.
</DialogDescription>

View File

@@ -0,0 +1,105 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
import {
useYAxisWidth,
chartTimeData,
cn,
formatShortDate,
toFixedWithoutTrailingZeros,
twoDecimalString,
} from '@/lib/utils'
// import Spinner from '../spinner'
import { useStore } from '@nanostores/react'
import { $chartTime } from '@/lib/stores'
import { SystemStatsRecord } from '@/types'
export default function BandwidthChart({
ticks,
systemData,
}: {
ticks: number[]
systemData: SystemStatsRecord[]
}) {
const chartTime = useStore($chartTime)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
return (
<div>
{/* {!yAxisSet && <Spinner />} */}
<ChartContainer
config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisWidth,
})}
>
<AreaChart
accessibilityLayer
data={systemData}
margin={{
left: 0,
right: 0,
top: 10,
bottom: 0,
}}
>
<CartesianGrid vertical={false} />
<YAxis
className="tracking-tighter"
width={yAxisWidth}
// domain={[0, (max: number) => (max <= 0.4 ? 0.4 : Math.ceil(max))]}
tickFormatter={(value) => {
const val = toFixedWithoutTrailingZeros(value, 2) + ' MB/s'
return updateYAxisWidth(val)
}}
tickLine={false}
axisLine={false}
// unit={' MB/s'}
/>
<XAxis
dataKey="created"
domain={[ticks[0], ticks.at(-1)!]}
ticks={ticks}
type="number"
scale={'time'}
minTickGap={35}
tickMargin={8}
axisLine={false}
tickFormatter={chartTimeData[chartTime].format}
/>
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => twoDecimalString(item.value) + ' MB/s'}
indicator="line"
/>
}
/>
<Area
dataKey="stats.ns"
name="Sent"
type="monotoneX"
fill="hsl(var(--chart-5))"
fillOpacity={0.2}
stroke="hsl(var(--chart-5))"
// animationDuration={1200}
isAnimationActive={false}
/>
<Area
dataKey="stats.nr"
name="Received"
type="monotoneX"
fill="hsl(var(--chart-2))"
fillOpacity={0.2}
stroke="hsl(var(--chart-2))"
// animationDuration={1200}
isAnimationActive={false}
/>
</AreaChart>
</ChartContainer>
</div>
)
}

View File

@@ -9,6 +9,7 @@ import { $chartTime } from '@/lib/stores'
import { chartTimeData, cn } from '@/lib/utils'
import { ChartTimes } from '@/types'
import { useStore } from '@nanostores/react'
import { HistoryIcon } from 'lucide-react'
export default function ChartTimeSelect({ className }: { className?: string }) {
const chartTime = useStore($chartTime)
@@ -19,7 +20,8 @@ export default function ChartTimeSelect({ className }: { className?: string }) {
value={chartTime}
onValueChange={(value: ChartTimes) => $chartTime.set(value)}
>
<SelectTrigger className={cn(className, 'px-5')}>
<SelectTrigger className={cn(className, 'relative pl-10 pr-5')}>
<HistoryIcon className="h-4 w-4 absolute left-4 top-1/2 -translate-y-1/2 opacity-85" />
<SelectValue />
</SelectTrigger>
<SelectContent>

View File

@@ -0,0 +1,143 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart'
import { useMemo } from 'react'
import { useYAxisWidth, chartTimeData, cn, formatShortDate, twoDecimalString } from '@/lib/utils'
// import Spinner from '../spinner'
import { useStore } from '@nanostores/react'
import { $chartTime, $containerFilter } from '@/lib/stores'
export default function ContainerCpuChart({
chartData,
ticks,
}: {
chartData: Record<string, number | string>[]
ticks: number[]
}) {
const chartTime = useStore($chartTime)
const filter = useStore($containerFilter)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const chartConfig = useMemo(() => {
let config = {} as Record<
string,
{
label: string
color: string
}
>
const totalUsage = {} as Record<string, number>
for (let stats of chartData) {
for (let key in stats) {
if (key === 'time') {
continue
}
if (!(key in totalUsage)) {
totalUsage[key] = 0
}
// @ts-ignore
totalUsage[key] += stats[key]
}
}
let keys = Object.keys(totalUsage)
keys.sort((a, b) => (totalUsage[a] > totalUsage[b] ? -1 : 1))
const length = keys.length
for (let i = 0; i < length; i++) {
const key = keys[i]
const hue = ((i * 360) / length) % 360
config[key] = {
label: key,
color: `hsl(${hue}, 60%, 55%)`,
}
}
return config satisfies ChartConfig
}, [chartData])
// if (!chartData.length || !ticks.length) {
// return <Spinner />
// }
return (
<div>
{/* {!yAxisSet && <Spinner />} */}
<ChartContainer
config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisWidth,
})}
>
<AreaChart
accessibilityLayer
// syncId={'cpu'}
data={chartData}
margin={{
top: 10,
}}
reverseStackOrder={true}
>
<CartesianGrid vertical={false} />
<YAxis
className="tracking-tighter"
// domain={[0, (max: number) => Math.max(Math.ceil(max), 0.4)]}
width={yAxisWidth}
tickLine={false}
axisLine={false}
tickFormatter={(x) => {
const val = (x % 1 === 0 ? x : x.toFixed(1)) + '%'
return updateYAxisWidth(val)
}}
/>
<XAxis
dataKey="time"
domain={[ticks[0], ticks.at(-1)!]}
ticks={ticks}
type="number"
scale={'time'}
minTickGap={35}
tickMargin={8}
axisLine={false}
tickFormatter={chartTimeData[chartTime].format}
/>
<ChartTooltip
// cursor={false}
animationEasing="ease-out"
animationDuration={150}
labelFormatter={(_, data) => formatShortDate(data[0].payload.time)}
// @ts-ignore
itemSorter={(a, b) => b.value - a.value}
content={
<ChartTooltipContent
filter={filter}
contentFormatter={(item) => twoDecimalString(item.value) + '%'}
indicator="line"
/>
}
/>
{Object.keys(chartConfig).map((key) => {
const filtered = filter && !key.includes(filter)
let fillOpacity = filtered ? 0.05 : 0.4
let strokeOpacity = filtered ? 0.1 : 1
return (
<Area
key={key}
isAnimationActive={false}
dataKey={key}
type="monotoneX"
fill={chartConfig[key].color}
fillOpacity={fillOpacity}
stroke={chartConfig[key].color}
strokeOpacity={strokeOpacity}
activeDot={{ opacity: filtered ? 0 : 1 }}
stackId="a"
/>
)
})}
</AreaChart>
</ChartContainer>
</div>
)
}

View File

@@ -0,0 +1,149 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart'
import { useMemo } from 'react'
import {
useYAxisWidth,
chartTimeData,
cn,
formatShortDate,
toFixedWithoutTrailingZeros,
twoDecimalString,
} from '@/lib/utils'
// import Spinner from '../spinner'
import { useStore } from '@nanostores/react'
import { $chartTime, $containerFilter } from '@/lib/stores'
export default function ContainerMemChart({
chartData,
ticks,
}: {
chartData: Record<string, number | string>[]
ticks: number[]
}) {
const chartTime = useStore($chartTime)
const filter = useStore($containerFilter)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const chartConfig = useMemo(() => {
let config = {} as Record<
string,
{
label: string
color: string
}
>
const totalUsage = {} as Record<string, number>
for (let stats of chartData) {
for (let key in stats) {
if (key === 'time') {
continue
}
if (!(key in totalUsage)) {
totalUsage[key] = 0
}
// @ts-ignore
totalUsage[key] += stats[key]
}
}
let keys = Object.keys(totalUsage)
keys.sort((a, b) => (totalUsage[a] > totalUsage[b] ? -1 : 1))
const length = keys.length
for (let i = 0; i < length; i++) {
const key = keys[i]
const hue = ((i * 360) / length) % 360
config[key] = {
label: key,
color: `hsl(${hue}, 60%, 55%)`,
}
}
return config satisfies ChartConfig
}, [chartData])
// if (!chartData.length || !ticks.length) {
// return <Spinner />
// }
return (
<div>
{/* {!yAxisSet && <Spinner />} */}
<ChartContainer
config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisWidth,
})}
>
<AreaChart
accessibilityLayer
data={chartData}
reverseStackOrder={true}
margin={{
top: 10,
}}
>
<CartesianGrid vertical={false} />
<YAxis
className="tracking-tighter"
// domain={[0, (max: number) => Math.ceil(max)]}
tickLine={false}
axisLine={false}
width={yAxisWidth}
tickFormatter={(value) => {
const val = toFixedWithoutTrailingZeros(value / 1024, 2) + ' GB'
return updateYAxisWidth(val)
}}
/>
<XAxis
dataKey="time"
domain={[ticks[0], ticks.at(-1)!]}
ticks={ticks}
type="number"
scale={'time'}
minTickGap={35}
tickMargin={8}
axisLine={false}
tickFormatter={chartTimeData[chartTime].format}
/>
<ChartTooltip
// cursor={false}
animationEasing="ease-out"
animationDuration={150}
labelFormatter={(_, data) => formatShortDate(data[0].payload.time)}
// @ts-ignore
itemSorter={(a, b) => b.value - a.value}
content={
<ChartTooltipContent
filter={filter}
contentFormatter={(item) => twoDecimalString(item.value) + ' MB'}
indicator="line"
/>
}
/>
{Object.keys(chartConfig).map((key) => {
const filtered = filter && !key.includes(filter)
let fillOpacity = filtered ? 0.05 : 0.4
let strokeOpacity = filtered ? 0.1 : 1
return (
<Area
key={key}
isAnimationActive={false}
dataKey={key}
type="monotoneX"
fill={chartConfig[key].color}
strokeOpacity={strokeOpacity}
fillOpacity={fillOpacity}
stroke={chartConfig[key].color}
activeDot={filtered ? false : {}}
stackId="a"
/>
)
})}
</AreaChart>
</ChartContainer>
</div>
)
}

View File

@@ -0,0 +1,166 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart'
import { useMemo } from 'react'
import {
useYAxisWidth,
chartTimeData,
cn,
formatShortDate,
toFixedWithoutTrailingZeros,
twoDecimalString,
} from '@/lib/utils'
// import Spinner from '../spinner'
import { useStore } from '@nanostores/react'
import { $chartTime, $containerFilter } from '@/lib/stores'
import { Separator } from '@/components/ui/separator'
export default function ContainerCpuChart({
chartData,
ticks,
}: {
chartData: Record<string, number | number[]>[]
ticks: number[]
}) {
const chartTime = useStore($chartTime)
const filter = useStore($containerFilter)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const chartConfig = useMemo(() => {
let config = {} as Record<
string,
{
label: string
color: string
}
>
const totalUsage = {} as Record<string, number>
for (let stats of chartData) {
for (let key in stats) {
if (!Array.isArray(stats[key])) {
continue
}
if (!(key in totalUsage)) {
totalUsage[key] = 0
}
totalUsage[key] += stats[key][2] ?? 0
}
}
let keys = Object.keys(totalUsage)
keys.sort((a, b) => (totalUsage[a] > totalUsage[b] ? -1 : 1))
const length = keys.length
for (let i = 0; i < length; i++) {
const key = keys[i]
const hue = ((i * 360) / length) % 360
config[key] = {
label: key,
color: `hsl(${hue}, 60%, 55%)`,
}
}
return config satisfies ChartConfig
}, [chartData])
// if (!chartData.length || !ticks.length) {
// return <Spinner />
// }
return (
<div>
{/* {!yAxisSet && <Spinner />} */}
<ChartContainer
config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisWidth,
})}
>
<AreaChart
accessibilityLayer
data={chartData}
margin={{
top: 10,
}}
reverseStackOrder={true}
>
<CartesianGrid vertical={false} />
<YAxis
className="tracking-tighter"
// domain={[0, (max: number) => Math.max(Math.ceil(max), 0.4)]}
width={yAxisWidth}
tickLine={false}
axisLine={false}
tickFormatter={(value) => {
const val = toFixedWithoutTrailingZeros(value, 2) + ' MB/s'
return updateYAxisWidth(val)
}}
/>
<XAxis
dataKey="time"
domain={[ticks[0], ticks.at(-1)!]}
ticks={ticks}
type="number"
scale={'time'}
minTickGap={35}
tickMargin={8}
axisLine={false}
tickFormatter={chartTimeData[chartTime].format}
/>
<ChartTooltip
// cursor={false}
animationEasing="ease-out"
animationDuration={150}
labelFormatter={(_, data) => formatShortDate(data[0].payload.time)}
// @ts-ignore
itemSorter={(a, b) => b.value - a.value}
content={
<ChartTooltipContent
filter={filter}
indicator="line"
contentFormatter={(item, key) => {
try {
const sent = item?.payload?.[key][0] ?? 0
const received = item?.payload?.[key][1] ?? 0
return (
<span className="flex">
{twoDecimalString(received)} MB/s
<span className="opacity-70 ml-0.5"> rx </span>
<Separator orientation="vertical" className="h-3 mx-1.5 bg-primary/40" />
{twoDecimalString(sent)} MB/s<span className="opacity-70 ml-0.5"> tx</span>
</span>
)
} catch (e) {
return null
}
}}
/>
}
/>
{Object.keys(chartConfig).map((key) => {
const filtered = filter && !key.includes(filter)
let fillOpacity = filtered ? 0.05 : 0.4
let strokeOpacity = filtered ? 0.1 : 1
return (
<Area
key={key}
name={key}
// animationDuration={1200}
isAnimationActive={false}
dataKey={(data) => data?.[key]?.[2] ?? 0}
type="monotoneX"
fill={chartConfig[key].color}
fillOpacity={fillOpacity}
stroke={chartConfig[key].color}
strokeOpacity={strokeOpacity}
activeDot={{ opacity: filtered ? 0 : 1 }}
stackId="a"
/>
)
})}
</AreaChart>
</ChartContainer>
</div>
)
}

View File

@@ -0,0 +1,81 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
import { useYAxisWidth, chartTimeData, cn, formatShortDate, twoDecimalString } from '@/lib/utils'
// import Spinner from '../spinner'
import { useStore } from '@nanostores/react'
import { $chartTime } from '@/lib/stores'
import { SystemStatsRecord } from '@/types'
export default function CpuChart({
ticks,
systemData,
}: {
ticks: number[]
systemData: SystemStatsRecord[]
}) {
const chartTime = useStore($chartTime)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
return (
<div>
<ChartContainer
config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisWidth,
})}
>
<AreaChart
accessibilityLayer
data={systemData}
margin={{ top: 10 }}
// syncId={'cpu'}
>
<CartesianGrid vertical={false} />
<YAxis
className="tracking-tighter"
// domain={[0, (max: number) => Math.ceil(max)]}
width={yAxisWidth}
tickLine={false}
axisLine={false}
tickFormatter={(value) => updateYAxisWidth(value + '%')}
/>
<XAxis
dataKey="created"
domain={[ticks[0], ticks.at(-1)!]}
ticks={ticks}
type="number"
scale={'time'}
minTickGap={35}
tickMargin={8}
axisLine={false}
tickFormatter={chartTimeData[chartTime].format}
/>
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => twoDecimalString(item.value) + '%'}
indicator="line"
/>
}
/>
<Area
dataKey="stats.cpu"
name="CPU Usage"
type="monotoneX"
fill="hsl(var(--chart-1))"
fillOpacity={0.4}
stroke="hsl(var(--chart-1))"
isAnimationActive={false}
// animationEasing="ease-out"
// animationDuration={1200}
// animateNewValues={true}
/>
</AreaChart>
</ChartContainer>
</div>
)
}

View File

@@ -0,0 +1,104 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
import {
useYAxisWidth,
chartTimeData,
cn,
formatShortDate,
twoDecimalString,
toFixedFloat,
getSizeVal,
getSizeUnit,
} from '@/lib/utils'
// import { useMemo } from 'react'
// import Spinner from '../spinner'
import { useStore } from '@nanostores/react'
import { $chartTime } from '@/lib/stores'
import { SystemStatsRecord } from '@/types'
export default function DiskChart({
ticks,
systemData,
dataKey,
diskSize,
}: {
ticks: number[]
systemData: SystemStatsRecord[]
dataKey: string
diskSize: number
}) {
const chartTime = useStore($chartTime)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
return (
<div>
{/* {!yAxisSet && <Spinner />} */}
<ChartContainer
config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisWidth,
})}
>
<AreaChart
accessibilityLayer
data={systemData}
margin={{
left: 0,
right: 0,
top: 10,
bottom: 0,
}}
>
<CartesianGrid vertical={false} />
<YAxis
className="tracking-tighter"
width={yAxisWidth}
domain={[0, diskSize]}
tickCount={9}
minTickGap={6}
tickLine={false}
axisLine={false}
tickFormatter={(value) =>
updateYAxisWidth(toFixedFloat(getSizeVal(value), 2) + getSizeUnit(value))
}
/>
<XAxis
dataKey="created"
domain={[ticks[0], ticks.at(-1)!]}
ticks={ticks}
type="number"
scale={'time'}
minTickGap={35}
tickMargin={8}
axisLine={false}
tickFormatter={chartTimeData[chartTime].format}
/>
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={({ value }) =>
twoDecimalString(getSizeVal(value)) + getSizeUnit(value)
}
indicator="line"
/>
}
/>
<Area
dataKey={dataKey}
name="Disk Usage"
type="monotoneX"
fill="hsl(var(--chart-4))"
fillOpacity={0.4}
stroke="hsl(var(--chart-4))"
// animationDuration={1200}
isAnimationActive={false}
/>
</AreaChart>
</ChartContainer>
</div>
)
}

View File

@@ -0,0 +1,102 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
import {
useYAxisWidth,
chartTimeData,
cn,
formatShortDate,
toFixedWithoutTrailingZeros,
twoDecimalString,
} from '@/lib/utils'
// import Spinner from '../spinner'
import { useStore } from '@nanostores/react'
import { $chartTime } from '@/lib/stores'
import { SystemStatsRecord } from '@/types'
export default function DiskIoChart({
ticks,
systemData,
dataKeys,
}: {
ticks: number[]
systemData: SystemStatsRecord[]
dataKeys: string[]
}) {
const chartTime = useStore($chartTime)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
return (
<div>
{/* {!yAxisSet && <Spinner />} */}
<ChartContainer
config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisWidth,
})}
>
<AreaChart
accessibilityLayer
data={systemData}
margin={{
left: 0,
right: 0,
top: 10,
bottom: 0,
}}
>
<CartesianGrid vertical={false} />
<YAxis
className="tracking-tighter"
width={yAxisWidth}
// domain={[0, (max: number) => (max <= 0.4 ? 0.4 : Math.ceil(max))]}
tickFormatter={(value) => {
const val = toFixedWithoutTrailingZeros(value, 2) + ' MB/s'
return updateYAxisWidth(val)
}}
tickLine={false}
axisLine={false}
/>
<XAxis
dataKey="created"
domain={[ticks[0], ticks.at(-1)!]}
ticks={ticks}
type="number"
scale={'time'}
minTickGap={35}
tickMargin={8}
axisLine={false}
tickFormatter={chartTimeData[chartTime].format}
/>
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => twoDecimalString(item.value) + ' MB/s'}
indicator="line"
/>
}
/>
{dataKeys.map((dataKey, i) => {
const action = i ? 'Read' : 'Write'
const color = i ? 'hsl(var(--chart-1))' : 'hsl(var(--chart-3))'
return (
<Area
key={i}
dataKey={dataKey}
name={action}
type="monotoneX"
fill={color}
fillOpacity={0.3}
stroke={color}
isAnimationActive={false}
/>
)
})}
</AreaChart>
</ChartContainer>
</div>
)
}

View File

@@ -0,0 +1,114 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
import {
useYAxisWidth,
chartTimeData,
cn,
formatShortDate,
toFixedFloat,
twoDecimalString,
} from '@/lib/utils'
import { useMemo } from 'react'
// import Spinner from '../spinner'
import { useStore } from '@nanostores/react'
import { $chartTime } from '@/lib/stores'
import { SystemStatsRecord } from '@/types'
export default function MemChart({
ticks,
systemData,
}: {
ticks: number[]
systemData: SystemStatsRecord[]
}) {
const chartTime = useStore($chartTime)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const totalMem = useMemo(() => {
return toFixedFloat(systemData.at(-1)?.stats.m ?? 0, 1)
}, [systemData])
return (
<div>
{/* {!yAxisSet && <Spinner />} */}
<ChartContainer
config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisWidth,
})}
>
<AreaChart
accessibilityLayer
data={systemData}
margin={{
top: 10,
}}
>
<CartesianGrid vertical={false} />
{totalMem && (
<YAxis
// use "ticks" instead of domain / tickcount if need more control
domain={[0, totalMem]}
tickCount={9}
className="tracking-tighter"
width={yAxisWidth}
tickLine={false}
axisLine={false}
tickFormatter={(value) => {
const val = toFixedFloat(value, 1)
return updateYAxisWidth(val + ' GB')
}}
/>
)}
<XAxis
dataKey="created"
domain={[ticks[0], ticks.at(-1)!]}
ticks={ticks}
type="number"
scale={'time'}
minTickGap={35}
tickMargin={8}
axisLine={false}
tickFormatter={chartTimeData[chartTime].format}
/>
<ChartTooltip
// cursor={false}
animationEasing="ease-out"
animationDuration={150}
content={
<ChartTooltipContent
// @ts-ignore
itemSorter={(a, b) => a.name.localeCompare(b.name)}
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => twoDecimalString(item.value) + ' GB'}
indicator="line"
/>
}
/>
<Area
dataKey="stats.mu"
name="Used"
type="monotoneX"
fill="hsl(var(--chart-2))"
fillOpacity={0.4}
stroke="hsl(var(--chart-2))"
stackId="1"
isAnimationActive={false}
/>
<Area
dataKey="stats.mb"
name="Cache / Buffers"
type="monotoneX"
fill="hsla(160 60% 45% / 0.5)"
fillOpacity={0.4}
// strokeOpacity={1}
stroke="hsla(160 60% 45% / 0.5)"
stackId="1"
isAnimationActive={false}
/>
</AreaChart>
</ChartContainer>
</div>
)
}

View File

@@ -0,0 +1,80 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'
import {
useYAxisWidth,
chartTimeData,
cn,
formatShortDate,
toFixedWithoutTrailingZeros,
twoDecimalString,
} from '@/lib/utils'
// import Spinner from '../spinner'
import { useStore } from '@nanostores/react'
import { $chartTime } from '@/lib/stores'
import { SystemStatsRecord } from '@/types'
export default function SwapChart({
ticks,
systemData,
}: {
ticks: number[]
systemData: SystemStatsRecord[]
}) {
const chartTime = useStore($chartTime)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
return (
<div>
<ChartContainer
config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisWidth,
})}
>
<AreaChart accessibilityLayer data={systemData} margin={{ top: 10 }}>
<CartesianGrid vertical={false} />
<YAxis
className="tracking-tighter"
domain={[0, () => toFixedWithoutTrailingZeros(systemData.at(-1)?.stats.s ?? 0.04, 2)]}
width={yAxisWidth}
tickLine={false}
axisLine={false}
tickFormatter={(value) => updateYAxisWidth(value + ' GB')}
/>
<XAxis
dataKey="created"
domain={[ticks[0], ticks.at(-1)!]}
ticks={ticks}
type="number"
scale={'time'}
minTickGap={35}
tickMargin={8}
axisLine={false}
tickFormatter={chartTimeData[chartTime].format}
/>
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => twoDecimalString(item.value) + ' GB'}
indicator="line"
/>
}
/>
<Area
dataKey="stats.su"
name="Swap Usage"
type="monotoneX"
fill="hsl(var(--chart-2))"
fillOpacity={0.4}
stroke="hsl(var(--chart-2))"
isAnimationActive={false}
/>
</AreaChart>
</ChartContainer>
</div>
)
}

View File

@@ -0,0 +1,131 @@
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts'
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart'
import {
useYAxisWidth,
chartTimeData,
cn,
formatShortDate,
toFixedWithoutTrailingZeros,
twoDecimalString,
} from '@/lib/utils'
import { useStore } from '@nanostores/react'
import { $chartTime } from '@/lib/stores'
import { SystemStatsRecord } from '@/types'
import { useMemo } from 'react'
export default function TemperatureChart({
ticks,
systemData,
}: {
ticks: number[]
systemData: SystemStatsRecord[]
}) {
const chartTime = useStore($chartTime)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
/** Format temperature data for chart and assign colors */
const newChartData = useMemo(() => {
const chartData = { data: [], colors: {} } as {
data: Record<string, number | string>[]
colors: Record<string, string>
}
const tempSums = {} as Record<string, number>
for (let data of systemData) {
let newData = { created: data.created } as Record<string, number | string>
let keys = Object.keys(data.stats?.t ?? {})
for (let i = 0; i < keys.length; i++) {
let key = keys[i]
newData[key] = data.stats.t![key]
tempSums[key] = (tempSums[key] ?? 0) + newData[key]
}
chartData.data.push(newData)
}
const keys = Object.keys(tempSums).sort((a, b) => tempSums[b] - tempSums[a])
for (let key of keys) {
chartData.colors[key] = `hsl(${((keys.indexOf(key) * 360) / keys.length) % 360}, 60%, 55%)`
}
return chartData
}, [systemData])
const colors = Object.keys(newChartData.colors)
return (
<div>
{/* {!yAxisSet && <Spinner />} */}
<ChartContainer
config={{}}
className={cn('h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity', {
'opacity-100': yAxisWidth,
})}
>
<LineChart
accessibilityLayer
data={newChartData.data}
margin={{
left: 0,
right: 0,
top: 10,
bottom: 0,
}}
>
<CartesianGrid vertical={false} />
<YAxis
className="tracking-tighter"
domain={[0, 'auto']}
width={yAxisWidth}
tickFormatter={(value) => {
const val = toFixedWithoutTrailingZeros(value, 2)
return updateYAxisWidth(val + ' °C')
}}
tickLine={false}
axisLine={false}
/>
<XAxis
dataKey="created"
domain={[ticks[0], ticks.at(-1)!]}
ticks={ticks}
type="number"
scale={'time'}
minTickGap={35}
tickMargin={8}
axisLine={false}
tickFormatter={chartTimeData[chartTime].format}
/>
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
// @ts-ignore
itemSorter={(a, b) => b.value - a.value}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => twoDecimalString(item.value) + ' °C'}
indicator="line"
/>
}
/>
{colors.map((key) => (
<Line
key={key}
dataKey={key}
name={key}
type="monotoneX"
dot={false}
strokeWidth={1.5}
stroke={newChartData.colors[key]}
isAnimationActive={false}
/>
))}
{colors.length < 12 && <ChartLegend content={<ChartLegendContent />} />}
</LineChart>
</ChartContainer>
</div>
)
}

View File

@@ -1,7 +1,4 @@
'use client'
import {
Database,
DatabaseBackupIcon,
Github,
LayoutDashboard,
@@ -9,6 +6,7 @@ import {
LogsIcon,
MailIcon,
Server,
SettingsIcon,
UsersIcon,
} from 'lucide-react'
@@ -30,7 +28,7 @@ import { navigate } from './router'
export default function CommandPalette() {
const [open, setOpen] = useState(false)
const servers = useStore($systems)
const systems = useStore($systems)
useEffect(() => {
const down = (e: KeyboardEvent) => {
@@ -46,10 +44,30 @@ export default function CommandPalette() {
return (
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder="Type a command or search..." />
<CommandInput placeholder="Search for systems or settings..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Suggestions">
{systems.length > 0 && (
<>
<CommandGroup>
{systems.map((system) => (
<CommandItem
key={system.id}
onSelect={() => {
navigate(`/system/${encodeURIComponent(system.name)}`)
setOpen(false)
}}
>
<Server className="mr-2 h-4 w-4" />
<span>{system.name}</span>
<CommandShortcut>{system.host}</CommandShortcut>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator className="mb-1.5" />
</>
)}
<CommandGroup heading="Pages / Settings">
<CommandItem
keywords={['home']}
onSelect={() => {
@@ -61,6 +79,27 @@ export default function CommandPalette() {
<span>Dashboard</span>
<CommandShortcut>Page</CommandShortcut>
</CommandItem>
<CommandItem
onSelect={() => {
navigate('/settings/general')
setOpen((open) => !open)
}}
>
<SettingsIcon className="mr-2 h-4 w-4" />
<span>Settings</span>
<CommandShortcut>Settings</CommandShortcut>
</CommandItem>
<CommandItem
keywords={['alerts']}
onSelect={() => {
navigate('/settings/notifications')
setOpen((open) => !open)
}}
>
<MailIcon className="mr-2 h-4 w-4" />
<span>Notification settings</span>
<CommandShortcut>Settings</CommandShortcut>
</CommandItem>
<CommandItem
keywords={['github']}
onSelect={() => {
@@ -72,29 +111,14 @@ export default function CommandPalette() {
<CommandShortcut>GitHub</CommandShortcut>
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Servers">
{servers.map((server) => (
<CommandItem
key={server.id}
onSelect={() => {
navigate(`/server/${server.name}`)
setOpen((open) => !open)
}}
>
<Server className="mr-2 h-4 w-4" />
<span>{server.name}</span>
<CommandShortcut>{server.host}</CommandShortcut>
</CommandItem>
))}
</CommandGroup>
{isAdmin() && (
<>
<CommandSeparator />
<CommandSeparator className="mb-1.5" />
<CommandGroup heading="Admin">
<CommandItem
keywords={['pocketbase']}
onSelect={() => {
setOpen(false)
window.open('/_/', '_blank')
}}
>
@@ -104,6 +128,7 @@ export default function CommandPalette() {
</CommandItem>
<CommandItem
onSelect={() => {
setOpen(false)
window.open('/_/#/logs', '_blank')
}}
>
@@ -113,6 +138,7 @@ export default function CommandPalette() {
</CommandItem>
<CommandItem
onSelect={() => {
setOpen(false)
window.open('/_/#/settings/backups', '_blank')
}}
>
@@ -123,6 +149,7 @@ export default function CommandPalette() {
<CommandItem
keywords={['oauth', 'oicd']}
onSelect={() => {
setOpen(false)
window.open('/_/#/settings/auth-providers', '_blank')
}}
>
@@ -133,6 +160,7 @@ export default function CommandPalette() {
<CommandItem
keywords={['email']}
onSelect={() => {
setOpen(false)
window.open('/_/#/settings/mail', '_blank')
}}
>

View File

@@ -0,0 +1,49 @@
import { useEffect, useMemo, useRef } from 'react'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog'
import { Textarea } from './ui/textarea'
import { $copyContent } from '@/lib/stores'
export default function CopyToClipboard({ content }: { content: string }) {
return (
<Dialog defaultOpen={true}>
<DialogContent className="w-[90%] rounded-lg" style={{ maxWidth: 530 }}>
<DialogHeader>
<DialogTitle>Could not copy to clipboard</DialogTitle>
<DialogDescription>Please copy the text manually.</DialogDescription>
</DialogHeader>
<CopyTextarea content={content} />
<p className="text-sm text-muted-foreground">
Clipboard API requires a secure context (https, localhost, or *.localhost)
</p>
</DialogContent>
</Dialog>
)
}
function CopyTextarea({ content }: { content: string }) {
const textareaRef = useRef<HTMLTextAreaElement>(null)
const rows = useMemo(() => {
return content.split('\n').length
}, [content])
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.select()
}
}, [textareaRef])
useEffect(() => {
return () => $copyContent.set('')
}, [])
return (
<Textarea
className="font-mono overflow-hidden whitespace-pre"
rows={rows}
value={content}
readOnly
ref={textareaRef}
/>
)
}

View File

@@ -13,8 +13,8 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { useState } from 'react'
import { AuthMethodsList } from 'pocketbase'
import { useCallback, useState } from 'react'
import { AuthMethodsList, OAuth2AuthConfig } from 'pocketbase'
import { Link } from '../router'
const honeypot = v.literal('')
@@ -64,60 +64,63 @@ export function UserAuthForm({
authMethods: AuthMethodsList
}) {
const [isLoading, setIsLoading] = useState<boolean>(false)
const [isGitHubLoading, setIsOauthLoading] = useState<boolean>(false)
const [isOauthLoading, setIsOauthLoading] = useState<boolean>(false)
const [errors, setErrors] = useState<Record<string, string | undefined>>({})
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setIsLoading(true)
try {
const formData = new FormData(e.target as HTMLFormElement)
const data = Object.fromEntries(formData) as Record<string, any>
const Schema = isFirstRun ? RegisterSchema : LoginSchema
const result = v.safeParse(Schema, data)
if (!result.success) {
console.log(result)
let errors = {}
for (const issue of result.issues) {
// @ts-ignore
errors[issue.path[0].key] = issue.message
}
setErrors(errors)
return
}
const { email, password, passwordConfirm, username } = result.output
if (isFirstRun) {
// check that passwords match
if (password !== passwordConfirm) {
let msg = 'Passwords do not match'
setErrors({ passwordConfirm: msg })
const handleSubmit = useCallback(
async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setIsLoading(true)
try {
const formData = new FormData(e.target as HTMLFormElement)
const data = Object.fromEntries(formData) as Record<string, any>
const Schema = isFirstRun ? RegisterSchema : LoginSchema
const result = v.safeParse(Schema, data)
if (!result.success) {
console.log(result)
let errors = {}
for (const issue of result.issues) {
// @ts-ignore
errors[issue.path[0].key] = issue.message
}
setErrors(errors)
return
}
await pb.admins.create({
email,
password,
passwordConfirm: password,
})
await pb.admins.authWithPassword(email, password)
await pb.collection('users').create({
username,
email,
password,
passwordConfirm: password,
role: 'admin',
verified: true,
})
await pb.collection('users').authWithPassword(email, password)
} else {
await pb.collection('users').authWithPassword(email, password)
const { email, password, passwordConfirm, username } = result.output
if (isFirstRun) {
// check that passwords match
if (password !== passwordConfirm) {
let msg = 'Passwords do not match'
setErrors({ passwordConfirm: msg })
return
}
await pb.admins.create({
email,
password,
passwordConfirm: password,
})
await pb.admins.authWithPassword(email, password)
await pb.collection('users').create({
username,
email,
password,
passwordConfirm: password,
role: 'admin',
verified: true,
})
await pb.collection('users').authWithPassword(email, password)
} else {
await pb.collection('users').authWithPassword(email, password)
}
$authenticated.set(true)
} catch (e) {
showLoginFaliedToast()
} finally {
setIsLoading(false)
}
$authenticated.set(true)
} catch (e) {
showLoginFaliedToast()
} finally {
setIsLoading(false)
}
}
},
[isFirstRun]
)
if (!authMethods) {
return null
@@ -145,7 +148,7 @@ export function UserAuthForm({
autoCapitalize="none"
autoComplete="username"
autoCorrect="off"
disabled={isLoading || isGitHubLoading}
disabled={isLoading || isOauthLoading}
className="pl-9"
/>
{errors?.username && (
@@ -167,7 +170,7 @@ export function UserAuthForm({
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
disabled={isLoading || isGitHubLoading}
disabled={isLoading || isOauthLoading}
className="pl-9"
/>
{errors?.email && <p className="px-1 text-xs text-red-600">{errors.email}</p>}
@@ -184,7 +187,7 @@ export function UserAuthForm({
required
type="password"
autoComplete="current-password"
disabled={isLoading || isGitHubLoading}
disabled={isLoading || isOauthLoading}
className="pl-9"
/>
{errors?.password && <p className="px-1 text-xs text-red-600">{errors.password}</p>}
@@ -202,7 +205,7 @@ export function UserAuthForm({
required
type="password"
autoComplete="current-password"
disabled={isLoading || isGitHubLoading}
disabled={isLoading || isOauthLoading}
className="pl-9"
/>
{errors?.passwordConfirm && (
@@ -225,14 +228,17 @@ export function UserAuthForm({
</button>
</div>
</form>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
{(isFirstRun || authMethods.authProviders.length > 0) && (
// only show 'continue with' during onboarding or if we have auth providers
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">Or continue with</span>
</div>
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">Or continue with</span>
</div>
</div>
)}
</>
)}
@@ -246,20 +252,40 @@ export function UserAuthForm({
'justify-self-center': !authMethods.emailPassword,
'px-5': !authMethods.emailPassword,
})}
onClick={async () => {
onClick={() => {
setIsOauthLoading(true)
try {
await pb.collection('users').authWithOAuth2({ provider: provider.name })
$authenticated.set(pb.authStore.isValid)
} catch (e) {
showLoginFaliedToast()
} finally {
setIsOauthLoading(false)
const oAuthOpts: OAuth2AuthConfig = {
provider: provider.name,
}
// https://github.com/pocketbase/pocketbase/discussions/2429#discussioncomment-5943061
if (navigator.userAgent.match(/iPhone|iPad|iPod/i)) {
const authWindow = window.open()
if (!authWindow) {
setIsOauthLoading(false)
toast({
title: 'Error',
description: 'Please enable pop-ups for this site',
variant: 'destructive',
})
return
}
oAuthOpts.urlCallback = (url) => {
authWindow.location.href = url
}
}
pb.collection('users')
.authWithOAuth2(oAuthOpts)
.then(() => {
$authenticated.set(pb.authStore.isValid)
})
.catch(showLoginFaliedToast)
.finally(() => {
setIsOauthLoading(false)
})
}}
disabled={isLoading || isGitHubLoading}
disabled={isLoading || isOauthLoading}
>
{isGitHubLoading ? (
{isOauthLoading ? (
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
) : (
<img
@@ -277,11 +303,12 @@ export function UserAuthForm({
</div>
)}
{!authMethods.authProviders.length && (
{!authMethods.authProviders.length && isFirstRun && (
// only show GitHub button / dialog during onboarding
<Dialog>
<DialogTrigger asChild>
<button type="button" className={cn(buttonVariants({ variant: 'outline' }))}>
<img className="mr-2 h-4 w-4 dark:invert" src="/icons/github.svg" alt="" />
<img className="mr-2 h-4 w-4 dark:invert" src="/static/github.svg" alt="" />
<span className="translate-y-[1px]">GitHub</span>
</button>
</DialogTrigger>

View File

@@ -86,9 +86,12 @@ export default function ForgotPassword() {
<DialogHeader>
<DialogTitle>Command line instructions</DialogTitle>
</DialogHeader>
<p className="text-primary/70 text-[0.95em]">
Use the following command to reset
your password:
<p className="text-primary/70 text-[0.95em] leading-relaxed">
If you've lost the password to your admin account, you may reset it using the following
command.
</p>
<p className="text-primary/70 text-[0.95em] leading-relaxed">
Then log into the backend and reset your user account password in the users table.
</p>
<code className="bg-muted rounded-sm py-0.5 px-2.5 mr-auto text-sm">
beszel admin update youremail@example.com newpassword

View File

@@ -1,4 +1,4 @@
import { MoonStarIcon, Sun } from 'lucide-react'
import { LaptopIcon, MoonStarIcon, SunIcon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
@@ -16,15 +16,24 @@ export function ModeToggle() {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant={'ghost'} size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] dark:opacity-0" />
<SunIcon className="h-[1.2rem] w-[1.2rem] dark:opacity-0" />
<MoonStarIcon className="absolute h-[1.2rem] w-[1.2rem] opacity-0 dark:opacity-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => setTheme('light')}>Light</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>Dark</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>System</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('light')}>
<SunIcon className="mr-2.5 h-4 w-4" />
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>
<MoonStarIcon className="mr-2.5 h-4 w-4" />
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>
<LaptopIcon className="mr-2.5 h-4 w-4" />
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)

View File

@@ -3,8 +3,8 @@ import { createRouter } from '@nanostores/router'
export const $router = createRouter(
{
home: '/',
server: '/server/:name',
'forgot-password': '/forgot-password',
server: '/system/:name',
settings: '/settings/:name?',
},
{ links: false }
)
@@ -16,7 +16,7 @@ export const navigate = (urlString: string) => {
function onClick(e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) {
e.preventDefault()
$router.open(new URL((e.target as HTMLAnchorElement).href).pathname)
$router.open(new URL((e.currentTarget as HTMLAnchorElement).href).pathname)
}
export const Link = (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {

View File

@@ -0,0 +1,85 @@
import { Suspense, lazy, useEffect, useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card'
import { $alerts, $hubVersion, $systems, pb } from '@/lib/stores'
import { useStore } from '@nanostores/react'
import { GithubIcon } from 'lucide-react'
import { Separator } from '../ui/separator'
import { updateRecordList, updateSystemList } from '@/lib/utils'
import { AlertRecord, SystemRecord } from '@/types'
import { Input } from '../ui/input'
const SystemsTable = lazy(() => import('../systems-table/systems-table'))
export default function () {
const hubVersion = useStore($hubVersion)
const [filter, setFilter] = useState<string>()
useEffect(() => {
document.title = 'Dashboard / Beszel'
// make sure we have the latest list of systems
updateSystemList()
// subscribe to real time updates for systems / alerts
pb.collection<SystemRecord>('systems').subscribe('*', (e) => {
updateRecordList(e, $systems)
})
pb.collection<AlertRecord>('alerts').subscribe('*', (e) => {
updateRecordList(e, $alerts)
})
return () => {
pb.collection('systems').unsubscribe('*')
pb.collection('alerts').unsubscribe('*')
}
}, [])
return (
<>
<Card>
<CardHeader className="pb-5 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
<div className="grid md:flex gap-3 w-full items-end">
<div className="px-2 sm:px-1">
<CardTitle className="mb-2.5">All Systems</CardTitle>
<CardDescription>
Updated in real time. Press{' '}
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-0.5 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground opacity-100">
<span className="text-xs"></span>K
</kbd>{' '}
to open the command palette.
</CardDescription>
</div>
<Input
placeholder="Filter..."
onChange={(e) => setFilter(e.target.value)}
className="w-full md:w-56 lg:w-80 ml-auto px-4"
/>
</div>
</CardHeader>
<CardContent className="max-sm:p-2">
<Suspense>
<SystemsTable filter={filter} />
</Suspense>
</CardContent>
</Card>
{hubVersion && (
<div className="flex gap-1.5 justify-end items-center pr-3 sm:pr-6 mt-3.5 text-xs opacity-80">
<a
href="https://github.com/henrygd/beszel"
target="_blank"
className="flex items-center gap-0.5 text-muted-foreground hover:text-foreground duration-75"
>
<GithubIcon className="h-3 w-3" /> GitHub
</a>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
<a
href="https://github.com/henrygd/beszel/releases"
target="_blank"
className="text-muted-foreground hover:text-foreground duration-75"
>
Beszel {hubVersion}
</a>
</div>
)}
</>
)
}

View File

@@ -0,0 +1,110 @@
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { chartTimeData } from '@/lib/utils'
import { Separator } from '@/components/ui/separator'
import { LoaderCircleIcon, SaveIcon } from 'lucide-react'
import { UserSettings } from '@/types'
import { saveSettings } from './layout'
import { useState } from 'react'
// import { Input } from '@/components/ui/input'
export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) {
const [isLoading, setIsLoading] = useState(false)
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setIsLoading(true)
const formData = new FormData(e.target as HTMLFormElement)
const data = Object.fromEntries(formData) as Partial<UserSettings>
await saveSettings(data)
setIsLoading(false)
}
return (
<div>
<div>
<h3 className="text-xl font-medium mb-2">General</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Change general application options.
</p>
</div>
<Separator className="my-4" />
<form onSubmit={handleSubmit} className="space-y-5">
{/* <Separator />
<div className="space-y-2">
<div className="mb-4">
<h3 className="mb-1 text-lg font-medium">Language</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Internationalization will be added in a future release. Please see the{' '}
<a href="#" className="link" target="_blank">
discussion on GitHub
</a>{' '}
for more details.
</p>
</div>
<Label className="block" htmlFor="lang">
Preferred language
</Label>
<Select defaultValue="en">
<SelectTrigger id="lang">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="en">English</SelectItem>
</SelectContent>
</Select>
</div> */}
<div className="space-y-2">
<div className="mb-4">
<h3 className="mb-1 text-lg font-medium">Chart options</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Adjust display options for charts.
</p>
</div>
<Label className="block" htmlFor="chartTime">
Default time period
</Label>
<Select
name="chartTime"
key={userSettings.chartTime}
defaultValue={userSettings.chartTime}
>
<SelectTrigger id="chartTime">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(chartTimeData).map(([value, { label }]) => (
<SelectItem key={label} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[0.8rem] text-muted-foreground">
Sets the default time range for charts when a system is viewed.
</p>
</div>
<Separator />
<Button
type="submit"
className="flex items-center gap-1.5 disabled:opacity-100"
disabled={isLoading}
>
{isLoading ? (
<LoaderCircleIcon className="h-4 w-4 animate-spin" />
) : (
<SaveIcon className="h-4 w-4" />
)}
Save settings
</Button>
</form>
</div>
)
}

View File

@@ -0,0 +1,98 @@
import { useEffect } from 'react'
import { Separator } from '../../ui/separator'
import { SidebarNav } from './sidebar-nav.tsx'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.tsx'
import { useStore } from '@nanostores/react'
import { $router } from '@/components/router.tsx'
import { redirectPage } from '@nanostores/router'
import { BellIcon, SettingsIcon } from 'lucide-react'
import { $userSettings, pb } from '@/lib/stores.ts'
import { toast } from '@/components/ui/use-toast.ts'
import { UserSettings } from '@/types.js'
import General from './general.tsx'
import Notifications from './notifications.tsx'
const sidebarNavItems = [
{
title: 'General',
href: '/settings/general',
icon: SettingsIcon,
},
{
title: 'Notifications',
href: '/settings/notifications',
icon: BellIcon,
},
]
export async function saveSettings(newSettings: Partial<UserSettings>) {
try {
// get fresh copy of settings
const req = await pb.collection('user_settings').getFirstListItem('', {
fields: 'id,settings',
})
// update user settings
const updatedSettings = await pb.collection('user_settings').update(req.id, {
settings: {
...req.settings,
...newSettings,
},
})
$userSettings.set(updatedSettings.settings)
toast({
title: 'Settings saved',
description: 'Your user settings have been updated.',
})
} catch (e) {
// console.error('update settings', e)
toast({
title: 'Failed to save settings',
description: 'Check logs for more details.',
variant: 'destructive',
})
}
}
export default function SettingsLayout() {
const page = useStore($router)
useEffect(() => {
document.title = 'Settings / Beszel'
// redirect to account page if no page is specified
if (page?.path === '/settings') {
redirectPage($router, 'settings', { name: 'general' })
}
}, [])
return (
<Card className="pt-5 px-4 pb-8 sm:pt-6 sm:px-7">
<CardHeader className="p-0">
<CardTitle className="mb-1">Settings</CardTitle>
<CardDescription>Manage display and notification preferences.</CardDescription>
</CardHeader>
<CardContent className="p-0">
<Separator className="hidden md:block my-5" />
<div className="flex flex-col gap-3.5 md:flex-row md:gap-5 lg:gap-10">
<aside className="md:w-48 w-full">
<SidebarNav items={sidebarNavItems} />
</aside>
<div className="flex-1">
{/* @ts-ignore */}
<SettingsContent name={page?.params?.name ?? 'general'} />
</div>
</div>
</CardContent>
</Card>
)
}
function SettingsContent({ name }: { name: string }) {
const userSettings = useStore($userSettings)
switch (name) {
case 'general':
return <General userSettings={userSettings} />
case 'notifications':
return <Notifications userSettings={userSettings} />
}
}

View File

@@ -0,0 +1,233 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { pb } from '@/lib/stores'
import { Separator } from '@/components/ui/separator'
import { Card } from '@/components/ui/card'
import { BellIcon, LoaderCircleIcon, PlusIcon, SaveIcon, Trash2Icon } from 'lucide-react'
import { ChangeEventHandler, useEffect, useState } from 'react'
import { toast } from '@/components/ui/use-toast'
import { InputTags } from '@/components/ui/input-tags'
import { UserSettings } from '@/types'
import { saveSettings } from './layout'
import * as v from 'valibot'
import { isAdmin } from '@/lib/utils'
interface ShoutrrrUrlCardProps {
url: string
onUrlChange: ChangeEventHandler<HTMLInputElement>
onRemove: () => void
}
const NotificationSchema = v.object({
emails: v.array(v.pipe(v.string(), v.email())),
webhooks: v.array(v.pipe(v.string(), v.url())),
})
const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSettings }) => {
const [webhooks, setWebhooks] = useState(userSettings.webhooks ?? [])
const [emails, setEmails] = useState<string[]>(userSettings.emails ?? [])
const [isLoading, setIsLoading] = useState(false)
// update values when userSettings changes
useEffect(() => {
setWebhooks(userSettings.webhooks ?? [])
setEmails(userSettings.emails ?? [])
}, [userSettings])
function addWebhook() {
setWebhooks([...webhooks, ''])
// focus on the new input
queueMicrotask(() => {
const inputs = document.querySelectorAll('#webhooks input') as NodeListOf<HTMLInputElement>
inputs[inputs.length - 1]?.focus()
})
}
const removeWebhook = (index: number) => setWebhooks(webhooks.filter((_, i) => i !== index))
function updateWebhook(index: number, value: string) {
const newWebhooks = [...webhooks]
newWebhooks[index] = value
setWebhooks(newWebhooks)
}
async function updateSettings() {
setIsLoading(true)
try {
const parsedData = v.parse(NotificationSchema, { emails, webhooks })
await saveSettings(parsedData)
} catch (e: any) {
toast({
title: 'Failed to save settings',
description: e.message,
variant: 'destructive',
})
}
setIsLoading(false)
}
return (
<div>
<div>
<h3 className="text-xl font-medium mb-2">Notifications</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Configure how you receive alert notifications.
</p>
<p className="text-sm text-muted-foreground mt-1.5 leading-relaxed">
Looking instead for where to create alerts? Click the bell{' '}
<BellIcon className="inline h-4 w-4" /> icons in the systems table.
</p>
</div>
<Separator className="my-4" />
<div className="space-y-5">
<div className="space-y-2">
<div className="mb-4">
<h3 className="mb-1 text-lg font-medium">Email notifications</h3>
{isAdmin() && (
<p className="text-sm text-muted-foreground leading-relaxed">
Please{' '}
<a href="/_/#/settings/mail" className="link" target="_blank">
configure an SMTP server
</a>{' '}
to ensure alerts are delivered.{' '}
</p>
)}
</div>
<Label className="block" htmlFor="email">
To email(s)
</Label>
<InputTags
value={emails}
onChange={setEmails}
placeholder="Enter email address..."
className="w-full"
type="email"
id="email"
/>
<p className="text-[0.8rem] text-muted-foreground">
Save address using enter key or comma. Leave blank to disable email notifications.
</p>
</div>
<Separator />
<div className="space-y-3">
<div>
<h3 className="mb-1 text-lg font-medium">Webhook / Push notifications</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Beszel uses{' '}
<a
href="https://containrrr.dev/shoutrrr/services/overview/"
target="_blank"
className="link"
>
Shoutrrr
</a>{' '}
to integrate with popular notification services.
</p>
</div>
{webhooks.length > 0 && (
<div className="grid gap-2.5" id="webhooks">
{webhooks.map((webhook, index) => (
<ShoutrrrUrlCard
key={index}
url={webhook}
onUrlChange={(e: React.ChangeEvent<HTMLInputElement>) =>
updateWebhook(index, e.target.value)
}
onRemove={() => removeWebhook(index)}
/>
))}
</div>
)}
<Button
type="button"
variant="outline"
size="sm"
className="mt-2 flex items-center gap-1"
onClick={addWebhook}
>
<PlusIcon className="h-4 w-4 -ml-0.5" />
Add URL
</Button>
</div>
<Separator />
<Button
type="button"
className="flex items-center gap-1.5 disabled:opacity-100"
onClick={updateSettings}
disabled={isLoading}
>
{isLoading ? (
<LoaderCircleIcon className="h-4 w-4 animate-spin" />
) : (
<SaveIcon className="h-4 w-4" />
)}
Save settings
</Button>
</div>
</div>
)
}
const ShoutrrrUrlCard = ({ url, onUrlChange, onRemove }: ShoutrrrUrlCardProps) => {
const [isLoading, setIsLoading] = useState(false)
const sendTestNotification = async () => {
setIsLoading(true)
const res = await pb.send('/api/beszel/send-test-notification', { url })
if ('err' in res && !res.err) {
toast({
title: 'Test notification sent',
description: 'Check your notification service',
})
} else {
toast({
title: 'Error',
description: res.err ?? 'Failed to send test notification',
variant: 'destructive',
})
}
setIsLoading(false)
}
return (
<Card className="bg-muted/30 p-2 md:p-3">
<div className="flex items-center gap-1">
<Input
type="url"
className="light:bg-card"
required
placeholder="generic://webhook.site/xxxxxx"
value={url}
onChange={onUrlChange}
/>
<Button
type="button"
variant="outline"
className="w-20 md:w-28"
disabled={isLoading || url === ''}
onClick={sendTestNotification}
>
{isLoading ? (
<LoaderCircleIcon className="h-4 w-4 animate-spin" />
) : (
<span>
Test <span className="hidden md:inline">URL</span>
</span>
)}
</Button>
<Button
type="button"
variant="outline"
size="icon"
className="shrink-0"
aria-label="Delete"
onClick={onRemove}
>
<Trash2Icon className="h-4 w-4" />
</Button>
</div>
</Card>
)
}
export default SettingsNotificationsPage

View File

@@ -0,0 +1,68 @@
import React from 'react'
import { cn } from '@/lib/utils'
import { buttonVariants } from '../../ui/button'
import { $router, Link, navigate } from '../../router'
import { useStore } from '@nanostores/react'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
items: {
href: string
title: string
icon?: React.FC<React.SVGProps<SVGSVGElement>>
}[]
}
export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
const page = useStore($router)
return (
<>
{/* Mobile View */}
<div className="md:hidden">
<Select onValueChange={(value: string) => navigate(value)} value={page?.path}>
<SelectTrigger className="w-full my-3.5">
<SelectValue placeholder="Select a page" />
</SelectTrigger>
<SelectContent>
{items.map((item) => (
<SelectItem key={item.href} value={item.href}>
<span className="flex items-center gap-2">
{item.icon && <item.icon className="h-4 w-4" />}
{item.title}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<Separator />
</div>
{/* Desktop View */}
<nav className={cn('hidden md:grid gap-1', className)} {...props}>
{items.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
buttonVariants({ variant: 'ghost' }),
'flex items-center gap-3',
page?.path === item.href ? 'bg-muted hover:bg-muted' : 'hover:bg-muted/50',
'justify-start'
)}
>
{item.icon && <item.icon className="h-4 w-4" />}
{item.title}
</Link>
))}
</nav>
</>
)
}

View File

@@ -0,0 +1,526 @@
import { $systems, pb, $chartTime, $containerFilter, $userSettings } from '@/lib/stores'
import { ContainerStatsRecord, SystemRecord, SystemStatsRecord } from '@/types'
import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Card, CardHeader, CardTitle, CardDescription, CardContent } 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 {
chartTimeData,
cn,
getPbTimestamp,
useClampedIsInViewport,
useLocalStorage,
} from '@/lib/utils'
import { Separator } from '../ui/separator'
import { scaleTime } from 'd3-scale'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
import { Button, buttonVariants } from '../ui/button'
import { Input } from '../ui/input'
import { Rows, TuxIcon } from '../ui/icons'
const CpuChart = lazy(() => import('../charts/cpu-chart'))
const ContainerCpuChart = lazy(() => import('../charts/container-cpu-chart'))
const MemChart = lazy(() => import('../charts/mem-chart'))
const ContainerMemChart = lazy(() => import('../charts/container-mem-chart'))
const DiskChart = lazy(() => import('../charts/disk-chart'))
const DiskIoChart = lazy(() => import('../charts/disk-io-chart'))
const BandwidthChart = lazy(() => import('../charts/bandwidth-chart'))
const ContainerNetChart = lazy(() => import('../charts/container-net-chart'))
const SwapChart = lazy(() => import('../charts/swap-chart'))
const TemperatureChart = lazy(() => import('../charts/temperature-chart'))
export default function SystemDetail({ name }: { name: string }) {
const systems = useStore($systems)
const chartTime = useStore($chartTime)
const [grid, setGrid] = useLocalStorage('grid', true)
const [ticks, setTicks] = useState([] as number[])
const [system, setSystem] = useState({} as SystemRecord)
const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[])
const netCardRef = useRef<HTMLDivElement>(null)
const [dockerCpuChartData, setDockerCpuChartData] = useState<Record<string, number | string>[]>(
[]
)
const [dockerMemChartData, setDockerMemChartData] = useState<Record<string, number | string>[]>(
[]
)
const [dockerNetChartData, setDockerNetChartData] = useState<Record<string, number | number[]>[]>(
[]
)
const hasDockerStats = dockerCpuChartData.length > 0
useEffect(() => {
document.title = `${name} / Beszel`
return () => {
resetCharts()
$chartTime.set($userSettings.get().chartTime)
$containerFilter.set('')
// setHasDocker(false)
}
}, [name])
function resetCharts() {
setSystemStats([])
setDockerCpuChartData([])
setDockerMemChartData([])
setDockerNetChartData([])
}
useEffect(resetCharts, [chartTime])
useEffect(() => {
if (system.id && system.name === name) {
return
}
const matchingSystem = systems.find((s) => s.name === name) as SystemRecord
if (matchingSystem) {
setSystem(matchingSystem)
}
}, [name, system, systems])
// update system when new data is available
useEffect(() => {
if (!system.id) {
return
}
pb.collection<SystemRecord>('systems').subscribe(system.id, (e) => {
setSystem(e.record)
})
return () => {
pb.collection('systems').unsubscribe(system.id)
}
}, [system])
async function getStats<T>(collection: string): Promise<T[]> {
return await pb.collection<T>(collection).getFullList({
filter: pb.filter('system={:id} && created > {:created} && type={:type}', {
id: system.id,
created: getPbTimestamp(chartTime),
type: chartTimeData[chartTime].type,
}),
fields: 'created,stats',
sort: 'created',
})
}
// add empty values between records to make gaps if interval is too large
function addEmptyValues<T extends SystemStatsRecord | ContainerStatsRecord>(
records: T[],
expectedInterval: number
) {
const modifiedRecords: T[] = []
let prevTime = 0
for (let i = 0; i < records.length; i++) {
const record = records[i]
record.created = new Date(record.created).getTime()
if (prevTime) {
const interval = record.created - prevTime
// if interval is too large, add a null record
if (interval > expectedInterval / 2 + expectedInterval) {
// @ts-ignore
modifiedRecords.push({ created: null, stats: null })
}
}
prevTime = record.created
modifiedRecords.push(record)
}
return modifiedRecords
}
// get stats
useEffect(() => {
if (!system.id || !chartTime) {
return
}
Promise.allSettled([
getStats<SystemStatsRecord>('system_stats'),
getStats<ContainerStatsRecord>('container_stats'),
]).then(([systemStats, containerStats]) => {
const expectedInterval = chartTimeData[chartTime].expectedInterval
if (containerStats.status === 'fulfilled' && containerStats.value.length) {
makeContainerData(addEmptyValues(containerStats.value, expectedInterval))
}
if (systemStats.status === 'fulfilled') {
setSystemStats(addEmptyValues(systemStats.value, expectedInterval))
}
})
}, [system, chartTime])
useEffect(() => {
if (!systemStats.length) {
return
}
const now = new Date()
const startTime = chartTimeData[chartTime].getOffset(now)
const scale = scaleTime([startTime.getTime(), now], [0, systemStats.length])
setTicks(scale.ticks(chartTimeData[chartTime].ticks).map((d) => d.getTime()))
}, [chartTime, systemStats])
// make container stats for charts
const makeContainerData = useCallback((containers: ContainerStatsRecord[]) => {
// console.log('containers', containers)
const dockerCpuData = []
const dockerMemData = []
const dockerNetData = []
for (let { created, stats } of containers) {
if (!created) {
let nullData = { time: null } as unknown
dockerCpuData.push(nullData as Record<string, number | string>)
dockerMemData.push(nullData as Record<string, number | string>)
dockerNetData.push(nullData as Record<string, number | number[]>)
continue
}
const time = new Date(created).getTime()
let cpuData = { time } as Record<string, number | string>
let memData = { time } as Record<string, number | string>
let netData = { time } as Record<string, number | number[]>
for (let container of stats) {
cpuData[container.n] = container.c
memData[container.n] = container.m
netData[container.n] = [container.ns, container.nr, container.ns + container.nr] // sent, received, total
}
dockerCpuData.push(cpuData)
dockerMemData.push(memData)
dockerNetData.push(netData)
}
setDockerCpuChartData(dockerCpuData)
setDockerMemChartData(dockerMemData)
setDockerNetChartData(dockerNetData)
}, [])
// values for system info bar
const systemInfo = useMemo(() => {
if (!system.info) {
return []
}
let uptime: number | string = system.info.u
if (system.info.u < 172800) {
uptime = `${Math.trunc(uptime / 3600)} hours`
} else {
uptime = `${Math.trunc(system.info?.u / 86400)} days`
}
return [
{ value: system.host, Icon: GlobeIcon },
{
value: system.info.h,
Icon: MonitorIcon,
label: 'Hostname',
// hide if hostname is same as host or name
hide: system.info.h === system.host || system.info.h === system.name,
},
{ value: uptime, Icon: ClockArrowUp, label: 'Uptime' },
{ value: system.info.k, Icon: TuxIcon, label: 'Kernel' },
{
value: `${system.info.m} (${system.info.c}c${system.info.t ? `/${system.info.t}t` : ''})`,
Icon: CpuIcon,
hide: !system.info.m,
},
] as {
value: string | number | undefined
label?: string
Icon: any
hide?: boolean
}[]
}, [system.info])
/** Space for tooltip if more than 12 containers */
const bottomSpacing = useMemo(() => {
if (!netCardRef.current || !dockerNetChartData.length) {
return 0
}
const tooltipHeight = (Object.keys(dockerNetChartData[0]).length - 11) * 17.8 - 40
const wrapperEl = document.getElementById('chartwrap') as HTMLDivElement
const wrapperRect = wrapperEl.getBoundingClientRect()
const chartRect = netCardRef.current.getBoundingClientRect()
const distanceToBottom = wrapperRect.bottom - chartRect.bottom
return tooltipHeight - distanceToBottom
}, [netCardRef.current, dockerNetChartData])
if (!system.id) {
return null
}
return (
<>
<div id="chartwrap" className="grid gap-4 mb-10">
{/* system info */}
<Card>
<div className="grid lg:flex items-center gap-4 px-4 sm:px-6 pt-3 sm:pt-4 pb-5">
<div>
<h1 className="text-[1.6rem] font-semibold mb-1.5">{system.name}</h1>
<div className="flex flex-wrap items-center gap-3 gap-y-2 text-sm opacity-90">
<div className="capitalize flex gap-2 items-center">
<span className={cn('relative flex h-3 w-3')}>
{system.status === 'up' && (
<span
className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
style={{ animationDuration: '1.5s' }}
></span>
)}
<span
className={cn('relative inline-flex rounded-full h-3 w-3', {
'bg-green-500': system.status === 'up',
'bg-red-500': system.status === 'down',
'bg-primary/40': system.status === 'paused',
'bg-yellow-500': system.status === 'pending',
})}
></span>
</span>
{system.status}
</div>
{systemInfo.map(({ value, label, Icon, hide }, i) => {
if (hide || !value) {
return null
}
const content = (
<div className="flex gap-1.5 items-center">
<Icon className="h-4 w-4" /> {value}
</div>
)
return (
<div key={i} className="contents">
<Separator orientation="vertical" className="h-4 bg-primary/30" />
{label ? (
<TooltipProvider>
<Tooltip delayDuration={150}>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
content
)}
</div>
)
})}
</div>
</div>
<div className="lg:ml-auto flex items-center gap-2 max-sm:-mb-1">
<ChartTimeSelect className="w-full lg:w-40" />
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Toggle grid"
className={cn(
buttonVariants({ variant: 'outline', size: 'icon' }),
'hidden lg:flex p-0 text-primary'
)}
onClick={() => setGrid(!grid)}
>
{grid ? (
<LayoutGridIcon className="h-[1.2rem] w-[1.2rem] opacity-85" />
) : (
<Rows className="h-[1.3rem] w-[1.3rem] opacity-85" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Toggle grid</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</Card>
{/* main charts */}
<div className="grid lg:grid-cols-2 gap-4">
<ChartCard
grid={grid}
title="Total CPU Usage"
description="Average system-wide CPU utilization"
>
<CpuChart ticks={ticks} systemData={systemStats} />
</ChartCard>
{hasDockerStats && (
<ChartCard
grid={grid}
title="Docker CPU Usage"
description="CPU utilization of docker containers"
isContainerChart={true}
>
<ContainerCpuChart chartData={dockerCpuChartData} ticks={ticks} />
</ChartCard>
)}
<ChartCard
grid={grid}
title="Total Memory Usage"
description="Precise utilization at the recorded time"
>
<MemChart ticks={ticks} systemData={systemStats} />
</ChartCard>
{hasDockerStats && (
<ChartCard
grid={grid}
title="Docker Memory Usage"
description="Memory usage of docker containers"
isContainerChart={true}
>
<ContainerMemChart chartData={dockerMemChartData} ticks={ticks} />
</ChartCard>
)}
<ChartCard grid={grid} title="Disk Space" description="Usage of root partition">
<DiskChart
ticks={ticks}
systemData={systemStats}
dataKey="stats.du"
diskSize={Math.round(systemStats.at(-1)?.stats.d ?? NaN)}
/>
</ChartCard>
<ChartCard grid={grid} title="Disk I/O" description="Throughput of root filesystem">
<DiskIoChart
ticks={ticks}
systemData={systemStats}
dataKeys={['stats.dw', 'stats.dr']}
/>
</ChartCard>
<ChartCard
grid={grid}
title="Bandwidth"
description="Network traffic of public interfaces"
>
<BandwidthChart ticks={ticks} systemData={systemStats} />
</ChartCard>
{hasDockerStats && dockerNetChartData.length > 0 && (
<div
ref={netCardRef}
className={cn({
'col-span-full': !grid,
})}
>
<ChartCard
title="Docker Network I/O"
description="Includes traffic between internal services"
isContainerChart={true}
>
<ContainerNetChart chartData={dockerNetChartData} ticks={ticks} />
</ChartCard>
</div>
)}
{(systemStats.at(-1)?.stats.su ?? 0) > 0 && (
<ChartCard grid={grid} title="Swap Usage" description="Swap space used by the system">
<SwapChart ticks={ticks} systemData={systemStats} />
</ChartCard>
)}
{systemStats.at(-1)?.stats.t && (
<ChartCard grid={grid} title="Temperature" description="Temperatures of system sensors">
<TemperatureChart ticks={ticks} systemData={systemStats} />
</ChartCard>
)}
</div>
{/* extra filesystem charts */}
{Object.keys(systemStats.at(-1)?.stats.efs ?? {}).length > 0 && (
<div className="grid lg:grid-cols-2 gap-4">
{Object.keys(systemStats.at(-1)?.stats.efs ?? {}).map((extraFsName) => {
return (
<div key={extraFsName} className="contents">
<ChartCard
grid={grid}
title={`${extraFsName} Usage`}
description={`Disk usage of ${extraFsName}`}
>
<DiskChart
ticks={ticks}
systemData={systemStats}
dataKey={`stats.efs.${extraFsName}.du`}
diskSize={Math.round(systemStats.at(-1)?.stats.efs?.[extraFsName].d ?? NaN)}
/>
</ChartCard>
<ChartCard
grid={grid}
title={`${extraFsName} I/O`}
description={`Throughput of ${extraFsName}`}
>
<DiskIoChart
ticks={ticks}
systemData={systemStats}
dataKeys={[`stats.efs.${extraFsName}.w`, `stats.efs.${extraFsName}.r`]}
/>
</ChartCard>
</div>
)
})}
</div>
)}
</div>
{/* add space for tooltip if more than 12 containers */}
{bottomSpacing > 0 && <span className="block" style={{ height: bottomSpacing }} />}
</>
)
}
function ContainerFilterBar() {
const containerFilter = useStore($containerFilter)
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
$containerFilter.set(e.target.value)
}, []) // Use an empty dependency array to prevent re-creation
return (
<div className="relative py-1 block sm:w-44 sm:absolute sm:top-2.5 sm:right-3.5">
<Input
placeholder="Filter..."
className="pl-4 pr-8"
value={containerFilter}
onChange={handleChange}
/>
{containerFilter && (
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Clear"
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
onClick={() => $containerFilter.set('')}
>
<XIcon className="h-4 w-4" />
</Button>
)}
</div>
)
}
function ChartCard({
title,
description,
children,
grid,
isContainerChart,
}: {
title: string
description: string
children: React.ReactNode
grid?: boolean
isContainerChart?: boolean
}) {
const target = useRef<HTMLDivElement>(null)
const [isInViewport, wrappedTargetRef] = useClampedIsInViewport({ target: target })
return (
<Card
className={cn('pb-2 sm:pb-4 odd:last-of-type:col-span-full', { 'col-span-full': !grid })}
ref={wrappedTargetRef}
>
<CardHeader className="pb-5 pt-4 relative space-y-1 max-sm:py-3 max-sm:px-4">
<CardTitle className="text-xl sm:text-2xl">{title}</CardTitle>
<CardDescription>{description}</CardDescription>
{isContainerChart && <ContainerFilterBar />}
</CardHeader>
<CardContent className="pl-0 w-[calc(100%-1.6em)] h-52 relative">
{<Spinner />}
{isInViewport && <Suspense>{children}</Suspense>}
</CardContent>
</Card>
)
}

View File

@@ -21,7 +21,6 @@ import {
} from '@/components/ui/table'
import { Button, buttonVariants } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
DropdownMenu,
@@ -55,11 +54,11 @@ import {
PauseCircleIcon,
PlayCircleIcon,
Trash2Icon,
WifiIcon,
} from 'lucide-react'
import { useMemo, useState } from 'react'
import { $systems, pb } from '@/lib/stores'
import { useEffect, useMemo, useState } from 'react'
import { $hubVersion, $systems, pb } from '@/lib/stores'
import { useStore } from '@nanostores/react'
import { AddServerButton } from '../add-server'
import { cn, copyToClipboard, isReadOnlyUser } from '@/lib/utils'
import AlertsButton from '../table-alerts'
import { navigate } from '../router'
@@ -73,7 +72,7 @@ function CellFormatter(info: CellContext<SystemRecord, unknown>) {
<span
className={cn(
'absolute inset-0 w-full h-full origin-left',
(val < 60 && 'bg-green-500') || (val < 90 && 'bg-yellow-500') || 'bg-red-600'
(val < 65 && 'bg-green-500') || (val < 90 && 'bg-yellow-500') || 'bg-red-600'
)}
style={{ transform: `scalex(${val}%)` }}
></span>
@@ -82,7 +81,12 @@ function CellFormatter(info: CellContext<SystemRecord, unknown>) {
)
}
function sortableHeader(column: Column<SystemRecord, unknown>, name: string, Icon: any) {
function sortableHeader(
column: Column<SystemRecord, unknown>,
name: string,
Icon: any,
hideSortIcon = false
) {
return (
<Button
variant="ghost"
@@ -91,16 +95,23 @@ function sortableHeader(column: Column<SystemRecord, unknown>, name: string, Ico
>
<Icon className="mr-2 h-4 w-4" />
{name}
<ArrowUpDown className="ml-2 h-4 w-4" />
{!hideSortIcon && <ArrowUpDown className="ml-2 h-4 w-4" />}
</Button>
)
}
export default function SystemsTable() {
export default function SystemsTable({ filter }: { filter?: string }) {
const data = useStore($systems)
const hubVersion = useStore($hubVersion)
const [sorting, setSorting] = useState<SortingState>([])
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
useEffect(() => {
if (filter !== undefined) {
table.getColumn('name')?.setFilterValue(filter)
}
}, [filter])
const columns: ColumnDef<SystemRecord>[] = useMemo(() => {
return [
{
@@ -124,7 +135,7 @@ export default function SystemsTable() {
<Button
data-nolink
variant={'ghost'}
className="text-foreground/90 h-7 px-1.5 gap-1.5"
className="text-primary/90 h-7 px-1.5 gap-1.5"
onClick={() => copyToClipboard(info.getValue() as string)}
>
{info.getValue() as string}
@@ -133,7 +144,7 @@ export default function SystemsTable() {
</span>
)
},
header: ({ column }) => sortableHeader(column, 'Server', Server),
header: ({ column }) => sortableHeader(column, 'System', Server),
},
{
accessorKey: 'info.cpu',
@@ -150,6 +161,29 @@ export default function SystemsTable() {
cell: CellFormatter,
header: ({ column }) => sortableHeader(column, 'Disk', HardDrive),
},
{
accessorKey: 'info.v',
size: 50,
cell: (info) => {
const version = info.getValue() as string
if (!version || !hubVersion) {
return null
}
return (
<span className="flex gap-2 items-center md:pr-5 tabular-nums pl-1">
<span
className={cn(
'w-2 h-2 left-0 rounded-full',
version === hubVersion ? 'bg-green-500' : 'bg-yellow-500'
)}
style={{ marginBottom: '-1px' }}
></span>
<span>{info.getValue() as string}</span>
</span>
)
},
header: ({ column }) => sortableHeader(column, 'Agent', WifiIcon, true),
},
{
id: 'actions',
size: 120,
@@ -226,7 +260,7 @@ export default function SystemsTable() {
},
},
]
}, [])
}, [hubVersion])
const table = useReactTable({
data,
@@ -248,80 +282,64 @@ export default function SystemsTable() {
})
return (
<>
<div className="w-full">
<div className="flex items-center mb-4 gap-2">
<Input
// @ts-ignore
placeholder="Filter..."
value={(table.getColumn('name')?.getFilterValue() as string) ?? ''}
onChange={(event) => table.getColumn('name')?.setFilterValue(event.target.value)}
className="max-w-sm"
/>
<div className={cn('ml-auto flex gap-2', isReadOnlyUser() && 'hidden')}>
<AddServerButton />
</div>
</div>
<div className="rounded-md border overflow-hidden">
<Table>
<TableHeader className="bg-muted/40">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead className="px-2" key={header.id}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.original.id}
data-state={row.getIsSelected() && 'selected'}
className={cn('cursor-pointer transition-opacity', {
'opacity-50': row.original.status === 'paused',
})}
onClick={(e) => {
const target = e.target as HTMLElement
if (!target.closest('[data-nolink]') && e.currentTarget.contains(target)) {
navigate(`/server/${row.original.name}`)
}
<div className="rounded-md border overflow-hidden">
<Table>
<TableHeader className="bg-muted/40">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead className="px-2" key={header.id}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.original.id}
data-state={row.getIsSelected() && 'selected'}
className={cn('cursor-pointer transition-opacity', {
'opacity-50': row.original.status === 'paused',
})}
onClick={(e) => {
const target = e.target as HTMLElement
if (!target.closest('[data-nolink]') && e.currentTarget.contains(target)) {
navigate(`/system/${encodeURIComponent(row.original.name)}`)
}
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{
width:
cell.column.getSize() === Number.MAX_SAFE_INTEGER
? 'auto'
: cell.column.getSize(),
}}
className={cn('overflow-hidden relative', data.length > 10 ? 'py-2' : 'py-2.5')}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{
width:
cell.column.getSize() === Number.MAX_SAFE_INTEGER
? 'auto'
: cell.column.getSize(),
}}
className={'overflow-hidden relative py-3'}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No servers found
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
</>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No systems found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
)
}

View File

@@ -0,0 +1,228 @@
import { $alerts, pb } from '@/lib/stores'
import { useStore } from '@nanostores/react'
import {
Dialog,
DialogTrigger,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { BellIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { AlertRecord, SystemRecord } from '@/types'
import { lazy, Suspense, useMemo, useState } from 'react'
import { toast } from './ui/use-toast'
import { Link } from './router'
const Slider = lazy(() => import('./ui/slider'))
const failedUpdateToast = () =>
toast({
title: 'Failed to update alert',
description: 'Please check logs for more details.',
variant: 'destructive',
})
export default function AlertsButton({ system }: { system: SystemRecord }) {
const alerts = useStore($alerts)
const active = useMemo(() => {
return alerts.find((alert) => alert.system === system.id)
}, [alerts, system])
const systemAlerts = useMemo(() => {
return alerts.filter((alert) => alert.system === system.id) as AlertRecord[]
}, [alerts, system])
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size={'icon'} aria-label="Alerts" data-nolink>
<BellIcon
className={cn('h-[1.2em] w-[1.2em] pointer-events-none', {
'fill-foreground': active,
})}
/>
</Button>
</DialogTrigger>
<DialogContent className="max-h-full overflow-auto">
<DialogHeader>
<DialogTitle className="text-xl">{system.name} alerts</DialogTitle>
<DialogDescription className="mb-1">
See{' '}
<Link href="/settings/notifications" className="link">
notification settings
</Link>{' '}
to configure how you receive alerts.
</DialogDescription>
</DialogHeader>
<div className="grid gap-3">
<AlertStatus system={system} alerts={systemAlerts} />
<AlertWithSlider
system={system}
alerts={systemAlerts}
name="CPU"
title="CPU Usage"
description="Triggers when CPU usage exceeds a threshold."
/>
<AlertWithSlider
system={system}
alerts={systemAlerts}
name="Memory"
title="Memory Usage"
description="Triggers when memory usage exceeds a threshold."
/>
<AlertWithSlider
system={system}
alerts={systemAlerts}
name="Disk"
title="Disk Usage"
description="Triggers when root usage exceeds a threshold."
/>
</div>
</DialogContent>
</Dialog>
)
}
function AlertStatus({ system, alerts }: { system: SystemRecord; alerts: AlertRecord[] }) {
const [pendingChange, setPendingChange] = useState(false)
const alert = useMemo(() => {
return alerts.find((alert) => alert.name === 'Status')
}, [alerts])
return (
<label
htmlFor="alert-status"
className="flex flex-row items-center justify-between gap-4 rounded-lg border p-4 cursor-pointer"
>
<div className="grid gap-1 select-none">
<p className="font-semibold">System Status</p>
<span className="block text-sm text-foreground opacity-80">
Triggers when status switches between up and down.
</span>
</div>
<Switch
id="alert-status"
className={cn('transition-opacity', pendingChange && 'opacity-40')}
checked={!!alert}
value={!!alert ? 'on' : 'off'}
onCheckedChange={async (active) => {
if (pendingChange) {
return
}
setPendingChange(true)
try {
if (!active && alert) {
await pb.collection('alerts').delete(alert.id)
} else if (active) {
pb.collection('alerts').create({
system: system.id,
user: pb.authStore.model!.id,
name: 'Status',
})
}
} catch (e) {
failedUpdateToast()
} finally {
setPendingChange(false)
}
}}
/>
</label>
)
}
function AlertWithSlider({
system,
alerts,
name,
title,
description,
}: {
system: SystemRecord
alerts: AlertRecord[]
name: string
title: string
description: string
}) {
const [pendingChange, setPendingChange] = useState(false)
const [liveValue, setLiveValue] = useState(50)
const alert = useMemo(() => {
const alert = alerts.find((alert) => alert.name === name)
if (alert) {
setLiveValue(alert.value)
}
return alert
}, [alerts])
return (
<div className="rounded-lg border">
<label
htmlFor={`alert-${name}`}
className={cn('flex flex-row items-center justify-between gap-4 cursor-pointer p-4', {
'pb-0': !!alert,
})}
>
<div className="grid gap-1 select-none">
<p className="font-semibold">{title}</p>
<span className="block text-sm text-foreground opacity-80">{description}</span>
</div>
<Switch
id={`alert-${name}`}
className={cn('transition-opacity', pendingChange && 'opacity-40')}
checked={!!alert}
value={!!alert ? 'on' : 'off'}
onCheckedChange={async (active) => {
if (pendingChange) {
return
}
setPendingChange(true)
try {
if (!active && alert) {
await pb.collection('alerts').delete(alert.id)
} else if (active) {
pb.collection('alerts').create({
system: system.id,
user: pb.authStore.model!.id,
name,
value: liveValue,
})
}
} catch (e) {
failedUpdateToast()
} finally {
setPendingChange(false)
}
}}
/>
</label>
{alert && (
<div className="flex mt-2 mb-3 gap-3 px-4">
<Suspense>
<Slider
defaultValue={[liveValue]}
onValueCommit={(val) => {
pb.collection('alerts').update(alert.id, {
value: val[0],
})
}}
onValueChange={(val) => {
setLiveValue(val[0])
}}
min={10}
max={99}
// step={1}
/>
</Suspense>
<span className="tabular-nums tracking-tighter text-[.92em]">{liveValue}%</span>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -100,6 +100,8 @@ const ChartTooltipContent = React.forwardRef<
nameKey?: string
labelKey?: string
unit?: string
filter?: string
contentFormatter?: (item: any, key: string) => React.ReactNode | string
}
>(
(
@@ -118,13 +120,18 @@ const ChartTooltipContent = React.forwardRef<
nameKey,
labelKey,
unit,
filter,
itemSorter,
contentFormatter: content = undefined,
},
ref
) => {
const { config } = useChart()
React.useMemo(() => {
if (filter) {
payload = payload?.filter((item) => (item.name as string)?.includes(filter))
}
if (itemSorter) {
// @ts-ignore
payload?.sort(itemSorter)
@@ -180,7 +187,7 @@ const ChartTooltipContent = React.forwardRef<
return (
<div
key={item.dataKey}
key={item?.name || item.dataKey}
className={cn(
'flex w-full items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground',
indicator === 'dot' && 'items-center'
@@ -227,8 +234,10 @@ const ChartTooltipContent = React.forwardRef<
</span>
</div>
{item.value !== undefined && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString() + (unit ? unit : '')}
<span className="font-medium tabular-nums text-foreground">
{content && typeof content === 'function'
? content(item, key)
: item.value.toLocaleString() + (unit ? unit : '')}
</span>
)}
</div>
@@ -254,7 +263,7 @@ const ChartLegendContent = React.forwardRef<
nameKey?: string
}
>(({ className, hideIcon = false, payload, verticalAlign = 'bottom', nameKey }, ref) => {
const { config } = useChart()
// const { config } = useChart()
if (!payload?.length) {
return null
@@ -264,33 +273,35 @@ const ChartLegendContent = React.forwardRef<
<div
ref={ref}
className={cn(
'flex items-center justify-center gap-4',
'flex items-center justify-center gap-4 gap-y-1 flex-wrap',
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || 'value'}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
// const key = `${nameKey || item.dataKey || 'value'}`
// const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
'flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground'
// 'flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground text-muted-foreground'
'flex items-center gap-1.5 text-muted-foreground'
)}
>
{itemConfig?.icon && !hideIcon ? (
{/* {itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
) : ( */}
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
{item.value}
{/* )} */}
{/* {itemConfig?.label} */}
</div>
)
})}

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