浏览代码

feat: add proxmox widget

Александр Тумайкин 3 月之前
父节点
当前提交
18e08cc03f
共有 5 个文件被更改,包括 445 次插入0 次删除
  1. 36 0
      docs/configuration.md
  2. 二进制
      docs/images/proxmox-preview.png
  3. 135 0
      internal/glance/templates/proxmox.html
  4. 272 0
      internal/glance/widget-proxmox.go
  5. 2 0
      internal/glance/widget.go

+ 36 - 0
docs/configuration.md

@@ -39,6 +39,7 @@
   - [Twitch Top Games](#twitch-top-games)
   - [Twitch Top Games](#twitch-top-games)
   - [iframe](#iframe)
   - [iframe](#iframe)
   - [HTML](#html)
   - [HTML](#html)
+  - [Proxmox](#proxmox)
 
 
 
 
 ## Preconfigured page
 ## Preconfigured page
@@ -2395,3 +2396,38 @@ Example:
 ```
 ```
 
 
 Note the use of `|` after `source:`, this allows you to insert a multi-line string.
 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.

二进制
docs/images/proxmox-preview.png


+ 135 - 0
internal/glance/templates/proxmox.html

@@ -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 - 0
internal/glance/widget-proxmox.go

@@ -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
+}

+ 2 - 0
internal/glance/widget.go

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