This commit is contained in:
Alexandr Tumaykin 2025-03-15 05:45:49 -05:00 committed by GitHub
commit 08f70954f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 445 additions and 0 deletions

View file

@ -39,6 +39,7 @@
- [Twitch Top Games](#twitch-top-games)
- [iframe](#iframe)
- [HTML](#html)
- [Proxmox](#proxmox)
## Preconfigured page
@ -2395,3 +2396,38 @@ Example:
```
Note the use of `|` after `source:`, this allows you to insert a multi-line string.
### Proxmox
Display a Proxmox server stats.
Example:
```yaml
- type: proxmox
url: https://proxmox.local
token: root@pam!glance
secret: 5f9b473d-0519-42f6-bdb9-44b4cbca6283
```
Preview:
![](images/proxmox-preview.png)
#### Properties
| Name | Type | Required | Default |
| ---- |--------|----------| ------ |
| url | string | yes | |
| token | string | yes | |
| secret | string | yes | |
| hide-swap | boolean | no | false |
###### `url`
The URL and port of the server to fetch the statistics from.
###### `token`
The authentication token to use when fetching the statistics.
##### `secret`
The authentication secret to use when fetching the statistics.
###### `hide-swap`
Whether to hide the swap usage.

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View file

@ -0,0 +1,135 @@
{{ template "widget-base.html" . }}
{{- define "widget-content" }}
{{- range .Nodes }}
<div class="server">
<div class="server-info">
<div class="server-details">
<div class="server-name color-highlight size-h3">{{ if .Name }}{{ .Name }}{{ else }}{{ .Hostname }}{{ end }}</div>
<div>
{{- if .IsReachable }}
<span {{ dynamicRelativeTimeAttrs .BootTime }}></span> 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">{{ .Platform }}</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 .CPU.LoadIsAvailable }} server-stat-unavailable{{ end }}">
<div class="flex items-end size-h5">
<div>CPU</div>
{{- if and .CPU.TemperatureIsAvailable (ge .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 .CPU.LoadIsAvailable }}{{ .CPU.Load1Percent }} <span class="color-base">%</span>{{ else }}n/a{{ end }}</div>
</div>
<div{{ if .CPU.LoadIsAvailable }} data-popover-type="html"{{ end }}>
{{- if .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">{{ .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">{{ .CPU.Load15Percent }} <span class="color-base size-h5">%</span></div>
</div>
{{- if .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">{{ .CPU.TemperatureC }} <span class="color-base size-h5">°</span></div>
</div>
{{- end }}
</div>
{{- end }}
<div class="progress-bar progress-bar-combined">
{{- if .CPU.LoadIsAvailable }}
<div class="progress-value{{ if ge .CPU.Load1Percent 85 }} progress-value-notice{{ end }}" style="--percent: {{ .CPU.Load1Percent }}"></div>
<div class="progress-value{{ if ge .CPU.Load15Percent 85 }} progress-value-notice{{ end }}" style="--percent: {{ .CPU.Load15Percent }}"></div>
{{- end }}
</div>
</div>
</div>
<div class="flex-1{{ if not .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 .Memory.IsAvailable }}{{ .Memory.UsedPercent }} <span class="color-base">%</span>{{ else }}n/a{{ end }}</div>
</div>
<div{{ if .Memory.IsAvailable }} data-popover-type="html"{{ end }}>
{{- if .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">
{{ .Memory.UsedMB | formatServerMegabytes }} <span class="color-base size-h5">/</span> {{ .Memory.TotalMB | formatServerMegabytes }}
</div>
</div>
{{- if and (not .HideSwap) .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">
{{ .Memory.SwapUsedMB | formatServerMegabytes }} <span class="color-base size-h5">/</span> {{ .Memory.SwapTotalMB | formatServerMegabytes }}
</div>
</div>
{{- end }}
</div>
{{- end }}
<div class="progress-bar progress-bar-combined">
{{- if .Memory.IsAvailable }}
<div class="progress-value{{ if ge .Memory.UsedPercent 85 }} progress-value-notice{{ end }}" style="--percent: {{ .Memory.UsedPercent }}"></div>
{{- if and (not .HideSwap) .Memory.SwapIsAvailable }}
<div class="progress-value{{ if ge .Memory.SwapUsedPercent 85 }} progress-value-notice{{ end }}" style="--percent: {{ .Memory.SwapUsedPercent }}"></div>
{{- end }}
{{- end }}
</div>
</div>
</div>
<div class="flex-1">
<div class="flex justify-between items-end size-h5">
<div>DISK</div>
<div class="color-highlight text-very-compact">{{ .Disk.UsedPercent }} <span class="color-base">%</span></div>
</div>
<div{{ if .Mountpoints }} data-popover-type="html"{{ end }}>
{{- if .Mountpoints }}
<div data-popover-html>
<ul class="list list-gap-2">
{{- range .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">
<div class="progress-value{{ if ge .Disk.UsedPercent 85 }} progress-value-notice{{ end }}" style="--percent: {{ .Disk.UsedPercent }}"></div>
</div>
</div>
</div>
</div>
</div>
{{- end }}
{{- end }}

View file

@ -0,0 +1,272 @@
package glance
import (
"context"
"errors"
"html/template"
"math"
"net/http"
"sort"
"strconv"
"time"
)
var proxmoxStatsWidgetTemplate = mustParseTemplate("proxmox.html", "widget-base.html")
type proxmoxWidget struct {
widgetBase `yaml:",inline"`
URL string `yaml:"url"`
Token string `yaml:"token"`
Secret string `yaml:"secret"`
HideSwap bool `yaml:"hide-swap"`
Nodes []proxmoxNodeStats `yaml:"-"`
}
type proxmoxNodeStats struct {
Name string
IsReachable bool
HideSwap bool
BootTime time.Time
Hostname string
Platform string
CPU struct {
LoadIsAvailable bool
Load1Percent uint8
Load15Percent uint8
TemperatureIsAvailable bool
TemperatureC uint8
}
Memory struct {
IsAvailable bool
TotalMB uint64
UsedMB uint64
UsedPercent uint8
SwapIsAvailable bool
SwapTotalMB uint64
SwapUsedMB uint64
SwapUsedPercent uint8
}
Disk nodeStorageInfo
Mountpoints []nodeStorageInfo
}
type nodeStorageInfo struct {
Path string
Name string
TotalMB uint64
UsedMB uint64
UsedPercent uint8
}
type singleResponse[T any] struct {
Data T `json:"data"`
}
type multipleResponse[T any] struct {
Data []T `json:"data"`
}
type proxmoxClusterResource struct {
CPU float64 `json:"cpu"`
CgroupMode int `json:"cgroup-mode"`
Content string `json:"content"`
Disk uint64 `json:"disk"`
DiskRead int64 `json:"diskread"`
DiskWrite int64 `json:"diskwrite"`
ID string `json:"id"`
Level string `json:"level"`
MaxCPU int `json:"maxcpu"`
MaxDisk uint64 `json:"maxdisk"`
MaxMem int64 `json:"maxmem"`
Mem int64 `json:"mem"`
Name string `json:"name"`
NetIn int64 `json:"netin"`
NetOut int64 `json:"netout"`
Node string `json:"node"`
PluginType string `json:"plugintype"`
SDN string `json:"sdn"`
Shared int `json:"shared"`
Status string `json:"status"`
Storage string `json:"storage"`
Template int `json:"template"`
Type string `json:"type"`
Uptime int64 `json:"uptime"`
VMID int `json:"vmid"`
}
type proxmoxNodeStatus 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"`
}
func (widget *proxmoxWidget) initialize() error {
widget.withTitle("Proxmox Stats").withCacheDuration(15 * time.Second)
if widget.URL == "" {
return errors.New("URL is required")
}
return nil
}
func (widget *proxmoxWidget) update(context.Context) {
resources, err := fetchProxmoxClusterResources(widget)
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
}
nodes := make([]proxmoxNodeStats, 0)
for _, resource := range resources {
if resource.Type != "node" {
continue
}
var node proxmoxNodeStats
node.Name = resource.Node
node.BootTime = time.Unix(time.Now().Unix()-resource.Uptime, 0)
node.HideSwap = widget.HideSwap
status, err := fetchProxmoxNodeStatus(widget, resource.Node)
if err != nil {
continue
}
node.IsReachable = true
node.Platform = status.PveVersion
if len(status.LoadAvg) == 3 {
node.CPU.LoadIsAvailable = true
load1, _ := strconv.ParseFloat(status.LoadAvg[0], 64)
node.CPU.Load1Percent = uint8(math.Min(load1*100/float64(status.CpuInfo.Cores), 100))
load15, _ := strconv.ParseFloat(status.LoadAvg[2], 64)
node.CPU.Load15Percent = uint8(math.Min(load15*100/float64(status.CpuInfo.Cores), 100))
}
node.Memory.IsAvailable = true
node.Memory.TotalMB = status.Memory.Total / 1024 / 1024
node.Memory.UsedMB = status.Memory.Used / 1024 / 1024
if node.Memory.TotalMB > 0 {
node.Memory.UsedPercent = uint8(math.Min(float64(node.Memory.UsedMB)/float64(node.Memory.TotalMB)*100, 100))
}
node.Memory.SwapIsAvailable = true
node.Memory.SwapTotalMB = status.Swap.Total / 1024 / 1024
node.Memory.SwapUsedMB = status.Swap.Used / 1024 / 1024
if node.Memory.SwapTotalMB > 0 {
node.Memory.SwapUsedPercent = uint8(math.Min(float64(node.Memory.SwapUsedMB)/float64(node.Memory.SwapTotalMB)*100, 100))
}
node.Disk = nodeStorageInfo{
TotalMB: resource.MaxDisk / 1024 / 1024,
UsedMB: resource.Disk / 1024 / 1024,
}
node.Disk.UsedPercent = uint8(math.Min(float64(node.Disk.UsedMB)/float64(node.Disk.TotalMB)*100, 100))
for _, storage := range resources {
if storage.Type != "storage" || storage.Node != node.Name {
continue
}
storageInfo := nodeStorageInfo{
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))
}
node.Mountpoints = append(node.Mountpoints, storageInfo)
}
nodes = append(nodes, node)
}
sort.Slice(nodes, func(i, j int) bool {
return nodes[i].Name < nodes[j].Name
})
widget.Nodes = nodes
}
func (widget *proxmoxWidget) Render() template.HTML {
return widget.renderTemplate(widget, proxmoxStatsWidgetTemplate)
}
func fetchProxmoxClusterResources(w *proxmoxWidget) ([]proxmoxClusterResource, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
request, _ := http.NewRequestWithContext(ctx, "GET", w.URL+"/api2/json/cluster/resources", nil)
request.Header.Set("Authorization", "PVEAPIToken="+w.Token+"="+w.Secret)
result, err := decodeJsonFromRequest[multipleResponse[proxmoxClusterResource]](defaultHTTPClient, request)
if err != nil {
return nil, err
}
return result.Data, nil
}
func fetchProxmoxNodeStatus(w *proxmoxWidget, node string) (*proxmoxNodeStatus, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
request, _ := http.NewRequestWithContext(ctx, "GET", w.URL+"/api2/json/nodes/"+node+"/status", nil)
request.Header.Set("Authorization", "PVEAPIToken="+w.Token+"="+w.Secret)
var result singleResponse[proxmoxNodeStatus]
result, err := decodeJsonFromRequest[singleResponse[proxmoxNodeStatus]](defaultHTTPClient, request)
if err != nil {
return nil, err
}
return &result.Data, nil
}

View file

@ -75,6 +75,8 @@ func newWidget(widgetType string) (widget, error) {
w = &dockerContainersWidget{}
case "server-stats":
w = &serverStatsWidget{}
case "proxmox":
w = &proxmoxWidget{}
default:
return nil, fmt.Errorf("unknown widget type: %s", widgetType)
}