Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Prototyping proxmox server stats 🎉 #375

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
205 changes: 187 additions & 18 deletions internal/glance/widget-server-stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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
Expand Down Expand Up @@ -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,
})
}
}
}
}()
}
Expand All @@ -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"`
}
Expand All @@ -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 changes: 83 additions & 0 deletions pkg/proxmox/proxmox.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading