Pulse/pkg/proxmox/zfs.go
2025-10-14 20:57:43 +00:00

230 lines
6 KiB
Go

package proxmox
import (
"context"
"fmt"
"strings"
"github.com/rs/zerolog/log"
)
// GetZFSPoolsWithDetails gets both the list and detailed info for all ZFS pools on a node
// This combines the list and detail endpoints to get complete information
func (c *Client) GetZFSPoolsWithDetails(ctx context.Context, node string) ([]ZFSPoolInfo, error) {
// First get the list of pools
pools, err := c.GetZFSPoolStatus(ctx, node)
if err != nil {
return nil, fmt.Errorf("failed to list ZFS pools: %w", err)
}
// Now get details for each pool
var poolInfos []ZFSPoolInfo
for _, pool := range pools {
info := ZFSPoolInfo{
Name: pool.Name,
Health: pool.Health,
Size: pool.Size,
Alloc: pool.Alloc,
Free: pool.Free,
Frag: pool.Frag,
Dedup: pool.Dedup,
}
// Try to get detailed info, but don't fail if it's not available
detail, err := c.GetZFSPoolDetail(ctx, node, pool.Name)
if err != nil {
log.Debug().
Err(err).
Str("node", node).
Str("pool", pool.Name).
Msg("Could not get ZFS pool details, using basic info only")
// Continue with basic info
} else {
info.State = detail.State
info.Status = detail.Status
info.Scan = detail.Scan
info.Errors = detail.Errors
info.Devices = detail.Children
}
poolInfos = append(poolInfos, info)
}
return poolInfos, nil
}
// ZFSPoolInfo combines list and detail info for a complete picture
type ZFSPoolInfo struct {
// From list endpoint
Name string `json:"name"`
Health string `json:"health"`
Size uint64 `json:"size"`
Alloc uint64 `json:"alloc"`
Free uint64 `json:"free"`
Frag int `json:"frag"`
Dedup float64 `json:"dedup"`
// From detail endpoint (may be empty if not available)
State string `json:"state,omitempty"`
Status string `json:"status,omitempty"`
Scan string `json:"scan,omitempty"`
Errors string `json:"errors,omitempty"`
Devices []ZFSPoolDevice `json:"devices,omitempty"`
}
// ConvertToModelZFSPool converts the combined pool info to our model
func (p *ZFSPoolInfo) ConvertToModelZFSPool() *ZFSPool {
if p == nil {
return nil
}
// Use State if available, otherwise fall back to Health
state := p.State
if state == "" {
state = p.Health
}
pool := &ZFSPool{
Name: p.Name,
State: state,
Health: p.Health,
Status: p.Status,
Scan: p.Scan,
Errors: p.Errors,
}
// Extract error counts from devices if available
pool.Devices = make([]ZFSDevice, 0)
for _, dev := range p.Devices {
pool.Devices = append(pool.Devices, convertDeviceRecursive(dev, "")...)
}
// Calculate total errors from all devices
for _, dev := range pool.Devices {
pool.ReadErrors += dev.ReadErrors
pool.WriteErrors += dev.WriteErrors
pool.ChecksumErrors += dev.ChecksumErrors
}
return pool
}
// ZFSPool represents complete ZFS pool information for monitoring
type ZFSPool struct {
Name string `json:"name"`
State string `json:"state"`
Health string `json:"health"`
Status string `json:"status"`
Scan string `json:"scan"`
Errors string `json:"errors"`
ReadErrors int64 `json:"readErrors"`
WriteErrors int64 `json:"writeErrors"`
ChecksumErrors int64 `json:"checksumErrors"`
Devices []ZFSDevice `json:"devices"`
}
// ZFSDevice represents a device in the pool (flattened from tree structure)
type ZFSDevice struct {
Name string `json:"name"`
Type string `json:"type"`
State string `json:"state"`
ReadErrors int64 `json:"readErrors"`
WriteErrors int64 `json:"writeErrors"`
ChecksumErrors int64 `json:"checksumErrors"`
IsLeaf bool `json:"isLeaf"`
Message string `json:"message,omitempty"`
}
// convertDeviceRecursive flattens the device tree into a list
func convertDeviceRecursive(dev ZFSPoolDevice, parentRole string) []ZFSDevice {
var devices []ZFSDevice
name := strings.TrimSpace(dev.Name)
lowerName := strings.ToLower(name)
role := parentRole
if role == "" {
switch {
case lowerName == "logs" || lowerName == "log" || strings.HasPrefix(lowerName, "slog"):
role = "log"
case lowerName == "cache" || strings.HasPrefix(lowerName, "l2arc"):
role = "cache"
case lowerName == "spares" || strings.HasPrefix(lowerName, "spare"):
role = "spare"
}
}
state := strings.ToUpper(strings.TrimSpace(dev.State))
if state == "" {
state = "UNKNOWN"
}
isVdev := dev.Leaf == 0 && len(dev.Children) > 0
deviceType := "disk"
if isVdev {
deviceType = "vdev"
}
switch {
case isVdev && (lowerName == "mirror" || strings.HasPrefix(lowerName, "mirror")):
deviceType = "mirror"
case isVdev && strings.HasPrefix(lowerName, "raidz"):
deviceType = lowerName // raidz, raidz2, raidz3
case isVdev && role == "log":
deviceType = "log"
case isVdev && role == "cache":
deviceType = "cache"
case isVdev && role == "spare":
deviceType = "spare"
case role == "log" && dev.Leaf == 1:
deviceType = "log"
case role == "cache" && dev.Leaf == 1:
deviceType = "cache"
}
isSpare := lowerName == "spares" || strings.HasPrefix(lowerName, "spare")
if isSpare {
if dev.Leaf == 1 {
deviceType = "spare"
} else {
deviceType = "spare-group"
}
}
healthyStates := map[string]bool{
"ONLINE": true,
"SPARE": true,
"AVAIL": true,
"INUSE": true,
}
if state == "UNKNOWN" {
if role == "log" || role == "cache" || role == "spare" || isSpare {
healthyStates[state] = true
}
}
// Add this device if it has errors or is not healthy (but skip healthy spares)
if !healthyStates[state] || dev.Read > 0 || dev.Write > 0 || dev.Cksum > 0 {
message := strings.TrimSpace(dev.Msg)
devices = append(devices, ZFSDevice{
Name: name,
Type: deviceType,
State: state,
ReadErrors: dev.Read,
WriteErrors: dev.Write,
ChecksumErrors: dev.Cksum,
IsLeaf: dev.Leaf == 1,
Message: message,
})
}
// Process children
for _, child := range dev.Children {
devices = append(devices, convertDeviceRecursive(child, role)...)
}
return devices
}