This commit is contained in:
Andrejs Baranovskis 2025-03-15 14:31:16 +01:00 committed by GitHub
commit e88a9fdba6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 347 additions and 18 deletions

View file

@ -4,12 +4,14 @@ import (
"context"
"html/template"
"log/slog"
"math"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/glanceapp/glance/pkg/proxmox"
"github.com/glanceapp/glance/pkg/sysinfo"
)
@ -20,6 +22,44 @@ type serverStatsWidget struct {
Servers []serverStatsRequest `yaml:"servers"`
}
type serverStats struct {
HostInfoIsAvailable bool `json:"host_info_is_available"`
BootTime time.Time `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 []serverStorageInfo `json:"mountpoints"`
}
type serverStorageInfo struct {
Path string `json:"path"`
Name string `json:"name"`
TotalMB uint64 `json:"total_mb"`
UsedMB uint64 `json:"used_mb"`
UsedPercent uint8 `json:"used_percent"`
}
func (widget *serverStatsWidget) initialize() error {
widget.withTitle("Server Stats").withCacheDuration(15 * time.Second)
widget.widgetBase.WIP = true
@ -56,21 +96,70 @@ func (widget *serverStatsWidget) update(context.Context) {
}
serv.IsReachable = true
serv.Info = info
serv.Info = &serverStats{
HostInfoIsAvailable: info.HostInfoIsAvailable,
BootTime: info.BootTime.Time,
Hostname: info.Hostname,
Platform: info.Platform,
CPU: info.CPU,
Memory: info.Memory,
}
for _, mountPoint := range info.Mountpoints {
serv.Info.Mountpoints = append(serv.Info.Mountpoints, serverStorageInfo{
Path: mountPoint.Path,
Name: mountPoint.Name,
TotalMB: mountPoint.TotalMB,
UsedMB: mountPoint.UsedMB,
UsedPercent: mountPoint.UsedPercent,
})
}
} 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),
if serv.Type == "proxmox" {
info, err := fetchProxmoxServerInfo(serv)
if err != nil {
slog.Warn("Getting remote system info: " + err.Error())
serv.IsReachable = false
serv.Info = &serverStats{
Hostname: "Unnamed server #" + strconv.Itoa(i+1),
}
} else {
serv.IsReachable = true
serv.Info = info
}
} else {
serv.IsReachable = true
serv.Info = info
info, err := fetchRemoteServerInfo(serv)
if err != nil {
slog.Warn("Getting remote system info: " + err.Error())
serv.IsReachable = false
serv.Info = &serverStats{
Hostname: "Unnamed server #" + strconv.Itoa(i+1),
}
} else {
serv.IsReachable = true
serv.Info = &serverStats{
HostInfoIsAvailable: info.HostInfoIsAvailable,
BootTime: info.BootTime.Time,
Hostname: info.Hostname,
Platform: info.Platform,
CPU: info.CPU,
Memory: info.Memory,
}
for _, mountPoint := range info.Mountpoints {
serv.Info.Mountpoints = append(serv.Info.Mountpoints, serverStorageInfo{
Path: mountPoint.Path,
Name: mountPoint.Name,
TotalMB: mountPoint.TotalMB,
UsedMB: mountPoint.UsedMB,
UsedPercent: mountPoint.UsedPercent,
})
}
}
}
}()
}
@ -86,15 +175,17 @@ func (widget *serverStatsWidget) Render() template.HTML {
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"`
Info *serverStats `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"`
Username string `yaml:"username"`
Password string `yaml:"password"`
Timeout durationField `yaml:"timeout"`
// Support for other agents
// Provider string `yaml:"provider"`
}
@ -115,3 +206,81 @@ func fetchRemoteServerInfo(infoReq *serverStatsRequest) (*sysinfo.SystemInfo, er
return info, nil
}
func fetchProxmoxServerInfo(infoReq *serverStatsRequest) (*serverStats, error) {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(infoReq.Timeout))
defer cancel()
cli := proxmox.New(infoReq.URL, infoReq.Username, infoReq.Token, infoReq.Password)
resources, err := cli.GetClusterResources(ctx)
if err != nil {
return nil, err
}
// TODO: Add support for multiple nodes
for _, node := range resources {
if node.Type != "node" {
continue
}
status, err := cli.GetNodeStatus(ctx, node.Node)
if err != nil {
// TODO: Log me!
continue
}
var info serverStats
info.Platform = status.PveVersion
info.BootTime = time.Unix(time.Now().Unix()-node.Uptime, 0)
info.HostInfoIsAvailable = true
if len(status.LoadAvg) == 3 {
info.CPU.LoadIsAvailable = true
load1, _ := strconv.ParseFloat(status.LoadAvg[0], 64)
info.CPU.Load1Percent = uint8(math.Min(load1*100/float64(status.CpuInfo.Cores), 100))
load15, _ := strconv.ParseFloat(status.LoadAvg[2], 64)
info.CPU.Load15Percent = uint8(math.Min(load15*100/float64(status.CpuInfo.Cores), 100))
}
info.Memory.IsAvailable = true
info.Memory.TotalMB = status.Memory.Total / 1024 / 1024
info.Memory.UsedMB = status.Memory.Used / 1024 / 1024
if info.Memory.TotalMB > 0 {
info.Memory.UsedPercent = uint8(math.Min(float64(info.Memory.UsedMB)/float64(info.Memory.TotalMB)*100, 100))
}
info.Memory.SwapIsAvailable = true
info.Memory.SwapTotalMB = status.Swap.Total / 1024 / 1024
info.Memory.SwapUsedMB = status.Swap.Used / 1024 / 1024
if info.Memory.SwapTotalMB > 0 {
info.Memory.SwapUsedPercent = uint8(math.Min(float64(info.Memory.SwapUsedMB)/float64(info.Memory.SwapTotalMB)*100, 100))
}
for _, storage := range resources {
if storage.Type != "storage" || storage.Node != node.Node {
continue
}
storageInfo := serverStorageInfo{
Path: storage.ID,
Name: storage.Storage,
TotalMB: storage.MaxDisk / 1024 / 1024,
UsedMB: storage.Disk / 1024 / 1024,
}
if storageInfo.TotalMB > 0 {
storageInfo.UsedPercent = uint8(math.Min(float64(storageInfo.UsedMB)/float64(storageInfo.TotalMB)*100, 100))
}
info.Mountpoints = append(info.Mountpoints, storageInfo)
}
return &info, nil
}
return nil, nil
}

83
pkg/proxmox/proxmox.go Normal file
View file

@ -0,0 +1,83 @@
package proxmox
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
type Proxmox struct {
URL string
Username string
TokenID string
Secret string
Timeout time.Duration
}
func New(URL string, userName string, tokenID string, secret string) *Proxmox {
return &Proxmox{
URL: URL,
Username: userName,
TokenID: tokenID,
Secret: secret,
Timeout: 15 * time.Second,
}
}
func (p *Proxmox) setAuthorizationHeader(req *http.Request) {
req.Header.Set("Authorization", "PVEAPIToken="+p.Username+"@pam!"+p.TokenID+"="+p.Secret)
}
func (p *Proxmox) GetClusterResources(ctx context.Context) ([]ClusterResource, error) {
client := &http.Client{
Timeout: p.Timeout,
}
request, _ := http.NewRequestWithContext(ctx, "GET", p.URL+"/api2/json/cluster/resources", nil)
p.setAuthorizationHeader(request)
response, err := client.Do(request)
if err != nil {
return nil, fmt.Errorf("sending request to cluster resources: %w", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return nil, fmt.Errorf("non-200 cluster resources response status: %s", response.Status)
}
var result multipleResponse[ClusterResource]
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decoding cluster resources response: %w", err)
}
return result.Data, nil
}
func (p *Proxmox) GetNodeStatus(ctx context.Context, node string) (*NodeStatus, error) {
client := &http.Client{
Timeout: p.Timeout,
}
request, _ := http.NewRequestWithContext(ctx, "GET", p.URL+"/api2/json/nodes/"+node+"/status", nil)
p.setAuthorizationHeader(request)
response, err := client.Do(request)
if err != nil {
return nil, fmt.Errorf("sending request to node status: %w", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return nil, fmt.Errorf("non-200 node status response status: %s", response.Status)
}
var result singleResponse[NodeStatus]
if err = json.NewDecoder(response.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decoding node status response: %w", err)
}
return &result.Data, nil
}

77
pkg/proxmox/types.go Normal file
View file

@ -0,0 +1,77 @@
package proxmox
type singleResponse[T any] struct {
Data T `json:"data"`
}
type multipleResponse[T any] struct {
Data []T `json:"data"`
}
type ClusterResource struct {
NetOut int64 `json:"netout"`
Name string `json:"name"`
Status string `json:"status"`
NetIn int64 `json:"netin"`
MaxCPU int `json:"maxcpu"`
DiskWrite int64 `json:"diskwrite"`
Template int `json:"template"`
CPU float64 `json:"cpu"`
Uptime int64 `json:"uptime"`
Mem int64 `json:"mem"`
DiskRead int64 `json:"diskread"`
MaxMem int64 `json:"maxmem"`
MaxDisk uint64 `json:"maxdisk"`
ID string `json:"id"`
VMID int `json:"vmid"`
Type string `json:"type"`
Node string `json:"node"`
Disk uint64 `json:"disk"`
Level string `json:"level"`
CgroupMode int `json:"cgroup-mode"`
PluginType string `json:"plugintype"`
Content string `json:"content"`
Shared int `json:"shared"`
Storage string `json:"storage"`
SDN string `json:"sdn"`
}
type NodeStatus struct {
PveVersion string `json:"pveversion"`
Kversion string `json:"kversion"`
Wait float64 `json:"wait"`
Uptime int64 `json:"uptime"`
LoadAvg []string `json:"loadavg"`
Cpu float64 `json:"cpu"`
Idle int `json:"idle"`
Swap struct {
Free uint64 `json:"free"`
Used uint64 `json:"used"`
Total uint64 `json:"total"`
} `json:"swap"`
CpuInfo struct {
Model string `json:"model"`
Cpus int `json:"cpus"`
Hvm string `json:"hvm"`
UserHz int `json:"user_hz"`
Flags string `json:"flags"`
Cores int `json:"cores"`
Sockets int `json:"sockets"`
Mhz string `json:"mhz"`
} `json:"cpuinfo"`
Memory struct {
Free uint64 `json:"free"`
Used uint64 `json:"used"`
Total uint64 `json:"total"`
} `json:"memory"`
RootFs struct {
Available int64 `json:"avail"`
Total int64 `json:"total"`
Used int64 `json:"used"`
Free int64 `json:"free"`
} `json:"rootfs"`
}