Pārlūkot izejas kodu

Add server-stats widget

Svilen Markov 4 mēneši atpakaļ
vecāks
revīzija
37f35281b4

+ 8 - 0
go.mod

@@ -5,6 +5,7 @@ go 1.23.1
 require (
 	github.com/fsnotify/fsnotify v1.8.0
 	github.com/mmcdole/gofeed v1.3.0
+	github.com/shirou/gopsutil/v4 v4.24.11
 	github.com/tidwall/gjson v1.18.0
 	golang.org/x/text v0.21.0
 	gopkg.in/yaml.v3 v3.0.1
@@ -13,12 +14,19 @@ require (
 require (
 	github.com/PuerkitoBio/goquery v1.10.0 // indirect
 	github.com/andybalholm/cascadia v1.3.3 // indirect
+	github.com/ebitengine/purego v0.8.1 // indirect
+	github.com/go-ole/go-ole v1.2.6 // indirect
 	github.com/json-iterator/go v1.1.12 // indirect
+	github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
 	github.com/mmcdole/goxpp v1.1.1 // indirect
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
 	github.com/modern-go/reflect2 v1.0.2 // indirect
+	github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
 	github.com/tidwall/match v1.1.1 // indirect
 	github.com/tidwall/pretty v1.2.1 // indirect
+	github.com/tklauser/go-sysconf v0.3.14 // indirect
+	github.com/tklauser/numcpus v0.8.0 // indirect
+	github.com/yusufpapurcu/wmi v1.2.4 // indirect
 	golang.org/x/net v0.33.0 // indirect
 	golang.org/x/sys v0.28.0 // indirect
 )

+ 23 - 13
go.sum

@@ -1,18 +1,24 @@
 github.com/PuerkitoBio/goquery v1.10.0 h1:6fiXdLuUvYs2OJSvNRqlNPoBm6YABE226xrbavY5Wv4=
 github.com/PuerkitoBio/goquery v1.10.0/go.mod h1:TjZZl68Q3eGHNBA8CWaxAN7rOU1EbDz3CWuolcO5Yu4=
-github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
-github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
 github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
 github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
 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/ebitengine/purego v0.8.1 h1:sdRKd6plj7KYW33EH5As6YKfe8m9zbN9JMrOjNVF/BE=
+github.com/ebitengine/purego v0.8.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
 github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
 github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
+github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
+github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
+github.com/google/go-cmp v0.5.6/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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
 github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
+github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
 github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
 github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE=
 github.com/mmcdole/goxpp v1.1.1 h1:RGIX+D6iQRIunGHrKqnA2+700XMCnNv0bAOOv5MUhx8=
@@ -24,10 +30,14 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
 github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
 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-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
+github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
+github.com/shirou/gopsutil/v4 v4.24.11 h1:WaU9xqGFKvFfsUv94SXcUPD7rCkU0vr/asVdQOBZNj8=
+github.com/shirou/gopsutil/v4 v4.24.11/go.mod h1:s4D/wg+ag4rG0WO7AiTj2BeYCRhym0vM7DHbZRxnIT8=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
-github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
-github.com/stretchr/testify v1.8.1/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/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
 github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
 github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
@@ -35,7 +45,13 @@ github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JT
 github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
 github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
 github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+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/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=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
@@ -51,13 +67,10 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
 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.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
-golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
 golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
 golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
 golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
 golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
-golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
-golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
 golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
 golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -68,25 +81,23 @@ golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sys v0.0.0-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-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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
-golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
 golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
 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.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
-golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
 golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
 golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
 golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
@@ -100,8 +111,6 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
 golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
-golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
-golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
 golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
 golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -111,6 +120,7 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
 golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
 golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
 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=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

+ 149 - 1
internal/glance/static/main.css

@@ -37,7 +37,7 @@
     --color-popover-background: hsl(var(--bgh), calc(var(--bgs) + 3%), calc(var(--bgl) + 3%));
     --color-popover-border: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 12%)));
     --color-progress-border: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 10% * var(--cm))));
-    --color-progress-value: hsl(var(--bgh), calc(var(--bgs) * var(--tsm)), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 27% * var(--cm))));
+    --color-progress-value: hsl(var(--bgh), calc(var(--bgs) * var(--tsm)), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 30% * var(--cm))));
     --color-graph-gridlines: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 6% * var(--cm))));
 
     --ths: var(--bgh), calc(var(--bgs) * var(--tsm));
@@ -796,6 +796,20 @@ details[open] .summary::after {
     gap: 1rem;
 }
 
+.widget-beta-icon {
+    width: 1.6rem;
+    height: 1.6rem;
+    flex-shrink: 0;
+    transition: transform .45s, opacity .45s, stroke .45s;
+    opacity: 0.7;
+}
+
+.widget-beta-icon:hover, .widget-header .popover-active > .widget-beta-icon {
+    fill: var(--color-text-highlight);
+    transform: translateY(-10%) scale(1.3);
+    opacity: 1;
+}
+
 .widget + .widget {
     margin-top: var(--widget-gap);
 }
@@ -1484,6 +1498,137 @@ details[open] .summary::after {
     height: 2rem;
 }
 
+.widget-type-server-info {
+    position: relative;
+}
+
+.server + .server {
+    margin-top: 3rem;
+}
+
+.server {
+    gap: 1rem;
+    display: flex;
+    flex-direction: column;
+}
+
+.server-info {
+    align-items: center;
+    display: flex;
+    justify-content: space-between;
+    gap: 1.5rem;
+    flex-shrink: 1;
+    min-width: 0;
+}
+
+.server-details {
+    min-width: 0;
+}
+
+.server-icon {
+    height: 3rem;
+    width: 3rem;
+}
+
+.server-spicy-cpu-icon {
+    height: 1em;
+    align-self: center;
+    margin-left: 0.4em;
+    margin-bottom: 0.2rem;
+}
+
+.server-stats {
+    display: flex;
+    gap: 1.5rem;
+    margin-top: 0.5rem;
+}
+
+.server-stat-unavailable {
+    opacity: 0.5;
+}
+
+.progress-bar {
+    border: 1px solid var(--color-progress-border);
+    border-radius: var(--border-radius);
+    display: flex;
+    flex-direction: column;
+    gap: 2px;
+    padding: 2px;
+    height: 1.5rem;
+    margin-inline: -3px; /* naughty, but oh so beautiful */
+}
+
+.progress-bar-combined {
+    height: 3rem;
+}
+
+.popover-active > .progress-bar {
+    transition: border-color .3s;
+    border-color: var(--color-text-subdue);
+}
+
+.progress-value {
+    --half-border-radius: calc(var(--border-radius) / 2);
+    border-radius: 0 var(--half-border-radius) var(--half-border-radius) 0;
+    background: var(--color-progress-value);
+    width: calc(var(--percent) * 1%);
+    min-width: 1px;
+    flex: 1;
+}
+
+.progress-value:first-child {
+    border-top-left-radius: var(--half-border-radius);
+}
+
+.progress-value:last-child {
+    border-bottom-left-radius: var(--half-border-radius);
+}
+
+.progress-value-notice {
+    background: linear-gradient(to right, var(--color-progress-value) 65%, var(--color-negative));
+}
+
+.value-separator {
+    min-width: 2rem;
+    margin-inline: 0.8rem;
+    flex: 1;
+    height: calc(1em * 1.1);
+    border-bottom: 1px dotted var(--color-text-subdue);
+}
+
+@container widget (min-width: 650px) {
+    .server {
+        gap: 2rem;
+        flex-direction: row;
+        align-items: center;
+    }
+
+    .server + .server {
+        margin-top: 1rem;
+    }
+
+    .server-info {
+        flex-direction: row-reverse;
+        justify-content: unset;
+        margin-right: auto;
+        z-index: 1;
+    }
+
+    .server-stats {
+        flex-direction: row;
+        justify-content: right;
+        min-width: 450px;
+        margin-top: 0;
+        gap: 2rem;
+        padding-bottom: 0.8rem;
+        z-index: 1;
+    }
+
+    .server-stats > * {
+        max-width: 200px;
+    }
+}
+
 .thumbnail {
     filter: grayscale(0.2) contrast(0.9);
     opacity: 0.8;
@@ -1881,6 +2026,7 @@ details[open] .summary::after {
 .text-center        { text-align: center; }
 .text-elevate       { margin-top: -0.2em; }
 .text-compact       { word-spacing: -0.18em; }
+.text-very-compact  { word-spacing: -0.35em; }
 .rtl                { direction: rtl; }
 .shrink             { flex-shrink: 1; }
 .shrink-0           { flex-shrink: 0; }
@@ -1891,6 +2037,7 @@ details[open] .summary::after {
 .overflow-hidden    { overflow: hidden; }
 .relative           { position: relative; }
 .flex               { display: flex; }
+.flex-1             { flex: 1; }
 .flex-wrap          { flex-wrap: wrap; }
 .flex-nowrap        { flex-wrap: nowrap; }
 .justify-between    { justify-content: space-between; }
@@ -1903,6 +2050,7 @@ details[open] .summary::after {
 .flex-column        { flex-direction: column; }
 .items-center       { align-items: center; }
 .items-start        { align-items: start; }
+.items-end          { align-items: end; }
 .gap-5              { gap: 0.5rem; }
 .gap-7              { gap: 0.7rem; }
 .gap-10             { gap: 1rem; }

+ 24 - 2
internal/glance/templates.go

@@ -1,10 +1,10 @@
 package glance
 
 import (
+	"fmt"
 	"html/template"
 	"math"
 	"strconv"
-	"time"
 
 	"golang.org/x/text/language"
 	"golang.org/x/text/message"
@@ -27,9 +27,31 @@ var globalTemplateFunctions = template.FuncMap{
 	"formatPrice": func(price float64) string {
 		return intl.Sprintf("%.2f", price)
 	},
-	"dynamicRelativeTimeAttrs": func(t time.Time) template.HTMLAttr {
+	"dynamicRelativeTimeAttrs": func(t interface{ Unix() int64 }) template.HTMLAttr {
 		return template.HTMLAttr(`data-dynamic-relative-time="` + strconv.FormatInt(t.Unix(), 10) + `"`)
 	},
+	"formatServerMegabytes": func(mb uint64) template.HTML {
+		var value string
+		var label string
+
+		if mb < 1_000 {
+			value = strconv.FormatUint(mb, 10)
+			label = "MB"
+		} else if mb < 1_000_000 {
+			if mb < 10_000 {
+				value = fmt.Sprintf("%.1f", float64(mb)/1_000)
+			} else {
+				value = strconv.FormatUint(mb/1_000, 10)
+			}
+
+			label = "GB"
+		} else {
+			value = fmt.Sprintf("%.1f", float64(mb)/1_000_000)
+			label = "TB"
+		}
+
+		return template.HTML(value + ` <span class="color-base size-h5">` + label + `</span>`)
+	},
 }
 
 func mustParseTemplate(primary string, dependencies ...string) *template.Template {

+ 140 - 0
internal/glance/templates/server-stats.html

@@ -0,0 +1,140 @@
+{{ template "widget-base.html" . }}
+
+{{- define "widget-content" }}
+{{- range .Servers }}
+<div class="server">
+    <div class="server-info">
+        <div class="server-details">
+            <div class="server-name color-highlight size-h3">{{ if .Name }}{{ .Name }}{{ else }}{{ .Info.Hostname }}{{ end }}</div>
+            <div>
+                {{- if .IsReachable }}
+                    {{ if .Info.HostInfoIsAvailable }}<span {{ dynamicRelativeTimeAttrs .Info.BootTime }}></span>{{ else }}unknown{{ end }} uptime
+                {{- else }}
+                    unreachable
+                {{- end }}
+            </div>
+        </div>
+        <div class="shrink-0"{{ if .IsReachable }} data-popover-type="html" data-popover-margin="0.2rem" data-popover-max-width="400px"{{ end }}>
+            {{- if .IsReachable }}
+            <div data-popover-html>
+                <div class="size-h5 text-compact">PLATFORM</div>
+                <div class="color-highlight">{{ if .Info.HostInfoIsAvailable }}{{ .Info.Platform }}{{ else }}Unknown{{ end }}</div>
+            </div>
+            {{- end }}
+            <svg class="server-icon" stroke="var(--color-{{ if .IsReachable }}positive{{ else }}negative{{ end }})" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
+                <path stroke-linecap="round" stroke-linejoin="round" d="M21.75 17.25v-.228a4.5 4.5 0 0 0-.12-1.03l-2.268-9.64a3.375 3.375 0 0 0-3.285-2.602H7.923a3.375 3.375 0 0 0-3.285 2.602l-2.268 9.64a4.5 4.5 0 0 0-.12 1.03v.228m19.5 0a3 3 0 0 1-3 3H5.25a3 3 0 0 1-3-3m19.5 0a3 3 0 0 0-3-3H5.25a3 3 0 0 0-3 3m16.5 0h.008v.008h-.008v-.008Zm-3 0h.008v.008h-.008v-.008Z" />
+            </svg>
+        </div>
+    </div>
+    <div class="server-stats">
+        <div class="flex-1{{ if not .Info.CPU.LoadIsAvailable }} server-stat-unavailable{{ end }}">
+            <div class="flex items-end size-h5">
+                <div>CPU</div>
+                {{- if and .Info.CPU.TemperatureIsAvailable (ge .Info.CPU.TemperatureC 80) }}
+                <svg class="server-spicy-cpu-icon" fill="var(--color-negative)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" >
+                    <path fill-rule="evenodd" d="M8.074.945A4.993 4.993 0 0 0 6 5v.032c.004.6.114 1.176.311 1.709.16.428-.204.91-.61.7a5.023 5.023 0 0 1-1.868-1.677c-.202-.304-.648-.363-.848-.058a6 6 0 1 0 8.017-1.901l-.004-.007a4.98 4.98 0 0 1-2.18-2.574c-.116-.31-.477-.472-.744-.28Zm.78 6.178a3.001 3.001 0 1 1-3.473 4.341c-.205-.365.215-.694.62-.59a4.008 4.008 0 0 0 1.873.03c.288-.065.413-.386.321-.666A3.997 3.997 0 0 1 8 8.999c0-.585.126-1.14.351-1.641a.42.42 0 0 1 .503-.235Z" clip-rule="evenodd" />
+                </svg>
+                {{- end }}
+                <div class="color-highlight margin-left-auto text-very-compact">{{ if .Info.CPU.LoadIsAvailable }}{{ .Info.CPU.Load1Percent }} <span class="color-base">%</span>{{ else }}n/a{{ end }}</div>
+            </div>
+            <div{{ if .Info.CPU.LoadIsAvailable }} data-popover-type="html"{{ end }}>
+                {{- if .Info.CPU.LoadIsAvailable }}
+                <div data-popover-html>
+                    <div class="flex">
+                        <div class="size-h5">1M AVG</div>
+                        <div class="value-separator"></div>
+                        <div class="color-highlight text-very-compact">{{ .Info.CPU.Load1Percent }} <span class="color-base size-h5">%</span></div>
+                    </div>
+                    <div class="flex margin-top-3">
+                        <div class="size-h5">15M AVG</div>
+                        <div class="value-separator"></div>
+                        <div class="color-highlight text-very-compact">{{ .Info.CPU.Load15Percent }} <span class="color-base size-h5">%</span></div>
+                    </div>
+                    {{- if .Info.CPU.TemperatureIsAvailable }}
+                    <div class="flex margin-top-3">
+                        <div class="size-h5">TEMP C</div>
+                        <div class="value-separator"></div>
+                        <div class="color-highlight text-very-compact">{{ .Info.CPU.TemperatureC }} <span class="color-base size-h5">°</span></div>
+                    </div>
+                    {{- end }}
+                </div>
+                {{- end }}
+                <div class="progress-bar progress-bar-combined">
+                    {{- if .Info.CPU.LoadIsAvailable }}
+                    <div class="progress-value{{ if ge .Info.CPU.Load1Percent 85 }} progress-value-notice{{ end }}" style="--percent: {{ .Info.CPU.Load1Percent }}"></div>
+                    <div class="progress-value{{ if ge .Info.CPU.Load15Percent 85 }} progress-value-notice{{ end }}" style="--percent: {{ .Info.CPU.Load15Percent }}"></div>
+                    {{- end }}
+                </div>
+            </div>
+        </div>
+        <div class="flex-1{{ if not .Info.Memory.IsAvailable }} server-stat-unavailable{{ end }}">
+            <div class="flex justify-between items-end size-h5">
+                <div>RAM</div>
+                <div class="color-highlight text-very-compact">{{ if .Info.Memory.IsAvailable }}{{ .Info.Memory.UsedPercent }} <span class="color-base">%</span>{{ else }}n/a{{ end }}</div>
+            </div>
+            <div{{ if .Info.Memory.IsAvailable }} data-popover-type="html"{{ end }}>
+                {{- if .Info.Memory.IsAvailable }}
+                <div data-popover-html>
+                    <div class="flex">
+                        <div class="size-h5">RAM</div>
+                        <div class="value-separator"></div>
+                        <div class="color-highlight text-very-compact">
+                            {{ .Info.Memory.UsedMB | formatServerMegabytes }} <span class="color-base size-h5">/</span> {{ .Info.Memory.TotalMB | formatServerMegabytes }}
+                        </div>
+                    </div>
+                    {{- if and (not .HideSwap) .Info.Memory.SwapIsAvailable }}
+                    <div class="flex margin-top-3">
+                        <div class="size-h5">SWAP</div>
+                        <div class="value-separator"></div>
+                        <div class="color-highlight text-very-compact">
+                            {{ .Info.Memory.SwapUsedMB | formatServerMegabytes }} <span class="color-base size-h5">/</span> {{ .Info.Memory.SwapTotalMB | formatServerMegabytes }}
+                        </div>
+                    </div>
+                    {{- end }}
+                </div>
+                {{- end }}
+                <div class="progress-bar progress-bar-combined">
+                    {{- if .Info.Memory.IsAvailable }}
+                    <div class="progress-value{{ if ge .Info.Memory.UsedPercent 85 }} progress-value-notice{{ end }}" style="--percent: {{ .Info.Memory.UsedPercent }}"></div>
+                    {{- if and (not .HideSwap) .Info.Memory.SwapIsAvailable }}
+                    <div class="progress-value{{ if ge .Info.Memory.SwapUsedPercent 85 }} progress-value-notice{{ end }}" style="--percent: {{ .Info.Memory.SwapUsedPercent }}"></div>
+                    {{- end }}
+                    {{- end }}
+                </div>
+            </div>
+        </div>
+        <div class="flex-1{{ if not .Info.Mountpoints }} server-stat-unavailable{{ end }}">
+            <div class="flex justify-between items-end size-h5">
+                <div>DISK</div>
+                <div class="color-highlight text-very-compact">{{ if .Info.Mountpoints }}{{ (index .Info.Mountpoints 0).UsedPercent }} <span class="color-base">%</span>{{ else }}n/a{{ end }}</div>
+            </div>
+            <div{{ if .Info.Mountpoints }} data-popover-type="html"{{ end }}>
+                {{- if .Info.Mountpoints }}
+                <div data-popover-html>
+                    <ul class="list list-gap-2">
+                        {{- range .Info.Mountpoints }}
+                        <li class="flex">
+                            <div class="size-h5">{{ if .Name }}{{ .Name }}{{ else }}{{ .Path }}{{ end }}</div>
+                            <div class="value-separator"></div>
+                            <div class="color-highlight text-very-compact">
+                                {{ .UsedMB | formatServerMegabytes }} <span class="color-base size-h5">/</span> {{ .TotalMB | formatServerMegabytes }}
+                            </div>
+                        </li>
+                        {{- end }}
+                    </ul>
+                </div>
+                {{- end }}
+                <div class="progress-bar progress-bar-combined">
+                    {{- if .Info.Mountpoints }}
+                    <div class="progress-value{{ if ge ((index .Info.Mountpoints 0).UsedPercent) 85 }} progress-value-notice{{ end }}" style="--percent: {{ (index .Info.Mountpoints 0).UsedPercent }}"></div>
+                    {{- if ge (len .Info.Mountpoints) 2 }}
+                    <div class="progress-value{{ if ge ((index .Info.Mountpoints 1).UsedPercent) 85 }} progress-value-notice{{ end }}" style="--percent: {{ (index .Info.Mountpoints 1).UsedPercent }}"></div>
+                    {{- end }}
+                    {{- end }}
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
+{{- end }}
+{{- end }}

+ 26 - 10
internal/glance/templates/widget-base.html

@@ -1,18 +1,34 @@
 <div class="widget widget-type-{{ .GetType }}{{ if ne "" .CSSClass }} {{ .CSSClass }}{{ end }}">
-    {{ if not .HideHeader}}
+    {{- if not .HideHeader}}
     <div class="widget-header">
-        {{ if ne "" .TitleURL }}<a href="{{ .TitleURL | safeURL }}" target="_blank" rel="noreferrer" class="uppercase">{{ .Title }}</a>{{ else }}<div class="uppercase">{{ .Title }}</div>{{ end }}
-        {{ if and .Error .ContentAvailable }}
+        {{- if ne "" .TitleURL }}
+        <a href="{{ .TitleURL | safeURL }}" target="_blank" rel="noreferrer" class="uppercase">{{ .Title }}</a>
+        {{- else }}
+        <div class="uppercase">{{ .Title }}</div>
+        {{- end }}
+        {{- if .IsWIP }}
+        <div data-popover-type="html" data-popover-position="above">
+            <div data-popover-html>
+                <p class="size-h5">WORK IN PROGRESS</p>
+                <p class="margin-block-10 color-paragraph">This widget is still in development, certain features may not work as expected or may change drastically.</p>
+                <a class="color-primary visited-indicator" href="https://github.com/glanceapp/glance/issues" target="_blank" rel="noreferrer">Report issue</a>
+            </div>
+            <svg class="widget-beta-icon cursor-help" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
+                <path fill-rule="evenodd" d="M19 5.5a4.5 4.5 0 0 1-4.791 4.49c-.873-.055-1.808.128-2.368.8l-6.024 7.23a2.724 2.724 0 1 1-3.837-3.837L9.21 8.16c.672-.56.855-1.495.8-2.368a4.5 4.5 0 0 1 5.873-4.575c.324.105.39.51.15.752L13.34 4.66a.455.455 0 0 0-.11.494 3.01 3.01 0 0 0 1.617 1.617c.17.07.363.02.493-.111l2.692-2.692c.241-.241.647-.174.752.15.14.435.216.9.216 1.382ZM4 17a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
+            </svg>
+        </div>
+        {{- end }}
+        {{- if and .Error .ContentAvailable }}
         <div class="notice-icon notice-icon-major" title="{{ .Error }}"></div>
-        {{ else if .Notice }}
+        {{- else if .Notice }}
         <div class="notice-icon notice-icon-minor" title="{{ .Notice }}"></div>
-        {{ end }}
+        {{- end }}
     </div>
-    {{ end }}
+    {{- end }}
     <div class="widget-content{{ if .ContentAvailable }} {{ block "widget-content-classes" . }}{{ end }}{{ end }}">
-        {{ if .ContentAvailable }}
-            {{ block "widget-content" . }}{{ end }}
-        {{ else }}
+        {{- if .ContentAvailable }}
+        {{ block "widget-content" . }}{{ end }}
+        {{- else }}
             <div class="widget-error-header">
                 <div class="color-negative size-h3">ERROR</div>
                 <svg class="widget-error-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
@@ -20,6 +36,6 @@
                 </svg>
             </div>
             <p class="break-all">{{ if .Error }}{{ .Error }}{{ else }}No error information provided{{ end }}</p>
-        {{ end}}
+        {{- end}}
     </div>
 </div>

+ 117 - 0
internal/glance/widget-server-stats.go

@@ -0,0 +1,117 @@
+package glance
+
+import (
+	"context"
+	"html/template"
+	"log/slog"
+	"net/http"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/glanceapp/glance/pkg/sysinfo"
+)
+
+var serverStatsWidgetTemplate = mustParseTemplate("server-stats.html", "widget-base.html")
+
+type serverStatsWidget struct {
+	widgetBase `yaml:",inline"`
+	Servers    []serverStatsRequest `yaml:"servers"`
+}
+
+func (widget *serverStatsWidget) initialize() error {
+	widget.withTitle("Server Stats").withCacheDuration(15 * time.Second)
+	widget.widgetBase.WIP = true
+
+	if len(widget.Servers) == 0 {
+		widget.Servers = []serverStatsRequest{{Type: "local"}}
+	}
+
+	for i := range widget.Servers {
+		widget.Servers[i].URL = strings.TrimRight(widget.Servers[i].URL, "/")
+
+		if widget.Servers[i].Timeout == 0 {
+			widget.Servers[i].Timeout = durationField(3 * time.Second)
+		}
+	}
+
+	return nil
+}
+
+func (widget *serverStatsWidget) update(context.Context) {
+	// Refactor later, most of it may change depending on feedback
+	var wg sync.WaitGroup
+
+	for i := range widget.Servers {
+		serv := &widget.Servers[i]
+
+		if serv.Type == "local" {
+			info, errs := sysinfo.Collect(serv.SystemInfoRequest)
+
+			if len(errs) > 0 {
+				for i := range errs {
+					slog.Warn("Getting system info: " + errs[i].Error())
+				}
+			}
+
+			serv.IsReachable = true
+			serv.Info = info
+		} else {
+			wg.Add(1)
+			go func() {
+				defer wg.Done()
+				info, err := fetchRemoteServerInfo(serv)
+				if err != nil {
+					slog.Warn("Getting remote system info: " + err.Error())
+					serv.IsReachable = false
+					serv.Info = &sysinfo.SystemInfo{
+						Hostname: "Unnamed server #" + strconv.Itoa(i+1),
+					}
+				} else {
+					serv.IsReachable = true
+					serv.Info = info
+				}
+			}()
+		}
+	}
+
+	wg.Wait()
+	widget.withError(nil).scheduleNextUpdate()
+}
+
+func (widget *serverStatsWidget) Render() template.HTML {
+	return widget.renderTemplate(widget, serverStatsWidgetTemplate)
+}
+
+type serverStatsRequest struct {
+	*sysinfo.SystemInfoRequest `yaml:",inline"`
+	Info                       *sysinfo.SystemInfo `yaml:"-"`
+	IsReachable                bool                `yaml:"-"`
+	StatusText                 string              `yaml:"-"`
+	Name                       string              `yaml:"name"`
+	HideSwap                   bool                `yaml:"hide-swap"`
+	Type                       string              `yaml:"type"`
+	URL                        string              `yaml:"url"`
+	Token                      string              `yaml:"token"`
+	Timeout                    durationField       `yaml:"timeout"`
+	// Support for other agents
+	// Provider                   string              `yaml:"provider"`
+}
+
+func fetchRemoteServerInfo(infoReq *serverStatsRequest) (*sysinfo.SystemInfo, error) {
+	ctx, cancel := context.WithTimeout(context.Background(), time.Duration(infoReq.Timeout))
+	defer cancel()
+
+	request, _ := http.NewRequestWithContext(ctx, "GET", infoReq.URL+"/api/sysinfo/all", nil)
+	if infoReq.Token != "" {
+		request.Header.Set("Authorization", "Bearer "+infoReq.Token)
+	}
+
+	info, err := decodeJsonFromRequest[*sysinfo.SystemInfo](defaultHTTPClient, request)
+	if err != nil {
+		return nil, err
+	}
+
+	return info, nil
+}

+ 7 - 0
internal/glance/widget.go

@@ -73,6 +73,8 @@ func newWidget(widgetType string) (widget, error) {
 		w = &customAPIWidget{}
 	case "docker-containers":
 		w = &dockerContainersWidget{}
+	case "server-stats":
+		w = &serverStatsWidget{}
 	default:
 		return nil, fmt.Errorf("unknown widget type: %s", widgetType)
 	}
@@ -147,6 +149,7 @@ type widgetBase struct {
 	CSSClass            string           `yaml:"css-class"`
 	CustomCacheDuration durationField    `yaml:"cache"`
 	ContentAvailable    bool             `yaml:"-"`
+	WIP                 bool             `yaml:"-"`
 	Error               error            `yaml:"-"`
 	Notice              error            `yaml:"-"`
 	templateBuffer      bytes.Buffer     `yaml:"-"`
@@ -173,6 +176,10 @@ func (w *widgetBase) requiresUpdate(now *time.Time) bool {
 	return now.After(w.nextUpdate)
 }
 
+func (w *widgetBase) IsWIP() bool {
+	return w.WIP
+}
+
 func (w *widgetBase) update(ctx context.Context) {
 
 }

+ 252 - 0
pkg/sysinfo/sysinfo.go

@@ -0,0 +1,252 @@
+package sysinfo
+
+import (
+	"fmt"
+	"math"
+	"runtime"
+	"sort"
+	"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/load"
+	"github.com/shirou/gopsutil/v4/mem"
+	"github.com/shirou/gopsutil/v4/sensors"
+)
+
+type timestampJSON struct {
+	time.Time
+}
+
+func (t timestampJSON) MarshalJSON() ([]byte, error) {
+	return []byte(strconv.FormatInt(t.Unix(), 10)), nil
+}
+
+func (t *timestampJSON) UnmarshalJSON(data []byte) error {
+	i, err := strconv.ParseInt(string(data), 10, 64)
+	if err != nil {
+		return err
+	}
+
+	t.Time = time.Unix(i, 0)
+	return nil
+}
+
+type SystemInfo struct {
+	HostInfoIsAvailable bool          `json:"host_info_is_available"`
+	BootTime            timestampJSON `json:"boot_time"`
+	Hostname            string        `json:"hostname"`
+	Platform            string        `json:"platform"`
+
+	CPU struct {
+		LoadIsAvailable bool  `json:"load_is_available"`
+		Load1Percent    uint8 `json:"load1_percent"`
+		Load15Percent   uint8 `json:"load15_percent"`
+
+		TemperatureIsAvailable bool  `json:"temperature_is_available"`
+		TemperatureC           uint8 `json:"temperature_c"`
+	} `json:"cpu"`
+
+	Memory struct {
+		IsAvailable bool   `json:"memory_is_available"`
+		TotalMB     uint64 `json:"total_mb"`
+		UsedMB      uint64 `json:"used_mb"`
+		UsedPercent uint8  `json:"used_percent"`
+
+		SwapIsAvailable bool   `json:"swap_is_available"`
+		SwapTotalMB     uint64 `json:"swap_total_mb"`
+		SwapUsedMB      uint64 `json:"swap_used_mb"`
+		SwapUsedPercent uint8  `json:"swap_used_percent"`
+	} `json:"memory"`
+
+	Mountpoints []MountpointInfo `json:"mountpoints"`
+}
+
+type MountpointInfo struct {
+	Path        string `json:"path"`
+	Name        string `json:"name"`
+	TotalMB     uint64 `json:"total_mb"`
+	UsedMB      uint64 `json:"used_mb"`
+	UsedPercent uint8  `json:"used_percent"`
+}
+
+type SystemInfoRequest struct {
+	CPUTempSensor string                       `yaml:"cpu-temp-sensor"`
+	Mountpoints   map[string]MointpointRequest `yaml:"mountpoints"`
+}
+
+type MointpointRequest struct {
+	Name string `yaml:"name"`
+	Hide bool   `yaml:"hide"`
+}
+
+// Currently caches hostname indefinitely which isn't ideal
+// Potential issue with caching boot time as it may not initially get reported correctly:
+// https://github.com/shirou/gopsutil/issues/842#issuecomment-1908972344
+var cachedHostInfo = struct {
+	available bool
+	hostname  string
+	platform  string
+	bootTime  timestampJSON
+}{}
+
+func Collect(req *SystemInfoRequest) (*SystemInfo, []error) {
+	if req == nil {
+		req = &SystemInfoRequest{}
+	}
+
+	var errs []error
+
+	addErr := func(err error) {
+		errs = append(errs, err)
+	}
+
+	info := &SystemInfo{
+		Mountpoints: []MountpointInfo{},
+	}
+
+	applyCachedHostInfo := func() {
+		info.HostInfoIsAvailable = true
+		info.BootTime = cachedHostInfo.bootTime
+		info.Hostname = cachedHostInfo.hostname
+		info.Platform = cachedHostInfo.platform
+	}
+
+	if cachedHostInfo.available {
+		applyCachedHostInfo()
+	} else {
+		hostInfo, err := host.Info()
+		if err == nil {
+			cachedHostInfo.available = true
+			cachedHostInfo.bootTime = timestampJSON{time.Unix(int64(hostInfo.BootTime), 0)}
+			cachedHostInfo.hostname = hostInfo.Hostname
+			cachedHostInfo.platform = hostInfo.Platform
+
+			applyCachedHostInfo()
+		} else {
+			addErr(fmt.Errorf("getting host info: %v", err))
+		}
+	}
+
+	coreCount, err := cpu.Counts(true)
+	if err == nil {
+		loadAvg, err := load.Avg()
+		if err == nil {
+			info.CPU.LoadIsAvailable = true
+			if runtime.GOOS == "windows" {
+				// The numbers returned here seem unreliable on Windows. Even with the CPU pegged
+				// at close to 50% for multiple minutes, load1 is sometimes way under or way over
+				// with no clear pattern. Dividing by core count gives numbers that are way too
+				// low so that's likely not necessary as it is with unix.
+				info.CPU.Load1Percent = uint8(math.Min(loadAvg.Load1*100, 100))
+				info.CPU.Load15Percent = uint8(math.Min(loadAvg.Load15*100, 100))
+			} else {
+				info.CPU.Load1Percent = uint8(math.Min((loadAvg.Load1/float64(coreCount))*100, 100))
+				info.CPU.Load15Percent = uint8(math.Min((loadAvg.Load15/float64(coreCount))*100, 100))
+			}
+		} else {
+			addErr(fmt.Errorf("getting load avg: %v", err))
+		}
+	} else {
+		addErr(fmt.Errorf("getting core count: %v", err))
+	}
+
+	memory, err := mem.VirtualMemory()
+	if err == nil {
+		info.Memory.IsAvailable = true
+		info.Memory.TotalMB = memory.Total / 1024 / 1024
+		info.Memory.UsedMB = memory.Used / 1024 / 1024
+		info.Memory.UsedPercent = uint8(math.Min(memory.UsedPercent, 100))
+	} else {
+		addErr(fmt.Errorf("getting memory info: %v", err))
+	}
+
+	swapMemory, err := mem.SwapMemory()
+	if err == nil {
+		info.Memory.SwapIsAvailable = true
+		info.Memory.SwapTotalMB = swapMemory.Total / 1024 / 1024
+		info.Memory.SwapUsedMB = swapMemory.Used / 1024 / 1024
+		info.Memory.SwapUsedPercent = uint8(math.Min(swapMemory.UsedPercent, 100))
+	} else {
+		addErr(fmt.Errorf("getting swap memory info: %v", err))
+	}
+
+	// currently disabled on Windows because it requires elevated privilidges, otherwise
+	// keeps returning a single sensor with key "ACPI\\ThermalZone\\TZ00_0" which
+	// doesn't seem to be the CPU sensor or correspond to anything useful when
+	// compared against the temperatures Libre Hardware Monitor reports
+	if runtime.GOOS != "windows" {
+		sensorReadings, err := sensors.SensorsTemperatures()
+		if err == nil {
+			if req.CPUTempSensor != "" {
+				for i := range sensorReadings {
+					if sensorReadings[i].SensorKey == req.CPUTempSensor {
+						info.CPU.TemperatureIsAvailable = true
+						info.CPU.TemperatureC = uint8(sensorReadings[i].Temperature)
+						break
+					}
+				}
+
+				if !info.CPU.TemperatureIsAvailable {
+					addErr(fmt.Errorf("CPU temperature sensor %s not found", req.CPUTempSensor))
+				}
+			} else if cpuTempSensor := inferCPUTempSensor(sensorReadings); cpuTempSensor != nil {
+				info.CPU.TemperatureIsAvailable = true
+				info.CPU.TemperatureC = uint8(cpuTempSensor.Temperature)
+			}
+		} else {
+			addErr(fmt.Errorf("getting sensor readings: %v", err))
+		}
+	}
+
+	filesystems, err := disk.Partitions(false)
+	if err == nil {
+		for _, fs := range filesystems {
+			mpReq, ok := req.Mountpoints[fs.Mountpoint]
+			if ok && mpReq.Hide {
+				continue
+			}
+
+			usage, err := disk.Usage(fs.Mountpoint)
+			if err == nil {
+				mpInfo := MountpointInfo{
+					Path:        fs.Mountpoint,
+					Name:        mpReq.Name,
+					TotalMB:     usage.Total / 1024 / 1024,
+					UsedMB:      usage.Used / 1024 / 1024,
+					UsedPercent: uint8(math.Min(usage.UsedPercent, 100)),
+				}
+
+				info.Mountpoints = append(info.Mountpoints, mpInfo)
+			} else {
+				addErr(fmt.Errorf("getting filesystem usage for %s: %v", fs.Mountpoint, err))
+			}
+		}
+	} else {
+		addErr(fmt.Errorf("getting filesystems: %v", err))
+	}
+
+	sort.Slice(info.Mountpoints, func(a, b int) bool {
+		return info.Mountpoints[a].UsedPercent > info.Mountpoints[b].UsedPercent
+	})
+
+	return info, errs
+}
+
+func inferCPUTempSensor(sensors []sensors.TemperatureStat) *sensors.TemperatureStat {
+	for i := range sensors {
+		switch sensors[i].SensorKey {
+		case
+			"coretemp_package_id_0", // intel / linux
+			"coretemp",              // intel / linux
+			"k10temp",               // amd / linux
+			"zenpower",              // amd / linux
+			"cpu_thermal":           // raspberry pi / linux
+			return &sensors[i]
+		}
+	}
+
+	return nil
+}