feat: add proxmox widget
This commit is contained in:
parent
774b0c104b
commit
18e08cc03f
5 changed files with 445 additions and 0 deletions
|
@ -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:
|
||||

|
||||
|
||||
#### 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.
|
BIN
docs/images/proxmox-preview.png
Normal file
BIN
docs/images/proxmox-preview.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 46 KiB |
135
internal/glance/templates/proxmox.html
Normal file
135
internal/glance/templates/proxmox.html
Normal 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 }}
|
272
internal/glance/widget-proxmox.go
Normal file
272
internal/glance/widget-proxmox.go
Normal 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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue