Files
ITWorx Pulse release export bd774932d5
Public source validation / validate (push) Failing after 3m8s
Publish ITWorx Pulse source
2026-09-03 02:09:19 +02:00

199 lines
6.2 KiB
Go

package unraid
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/itworx/pulse/internal/array"
"github.com/itworx/pulse/internal/disk"
)
// ArraySource adapts the documented read-only array query. It intentionally maps only
// documented fields; unavailable SMART/pool/share detail is represented by the domain
// as unavailable rather than guessed from names or host paths.
type ArraySource struct {
Client *Client
Now func() time.Time
}
type arrayDiskDocument struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Size json.RawMessage `json:"size"`
Status string `json:"status"`
FSSize json.RawMessage `json:"fsSize"`
FSUsed json.RawMessage `json:"fsUsed"`
FSFree json.RawMessage `json:"fsFree"`
FSType string `json:"fsType"`
Temp json.RawMessage `json:"temp"`
Spinning *bool `json:"isSpinning"`
}
type arrayDocument struct {
Array struct {
State string `json:"state"`
Disks []arrayDiskDocument `json:"disks"`
Parities []arrayDiskDocument `json:"parities"`
Caches []arrayDiskDocument `json:"caches"`
} `json:"array"`
}
func (s ArraySource) document(ctx context.Context) (arrayDocument, time.Time, error) {
if s.Client == nil {
return arrayDocument{}, time.Time{}, errors.New("Unraid client is required")
}
payload, err := s.Client.Query(ctx, "array")
if err != nil {
return arrayDocument{}, time.Time{}, fmt.Errorf("query Unraid array: %w", err)
}
var response arrayDocument
if err := json.Unmarshal(payload, &response); err != nil {
return arrayDocument{}, time.Time{}, errors.New("decode Unraid array response")
}
if len(response.Array.Disks)+len(response.Array.Parities)+len(response.Array.Caches) > 64 {
return arrayDocument{}, time.Time{}, errors.New("Unraid disk response exceeds bounds")
}
now := time.Now().UTC()
if s.Now != nil {
now = s.Now().UTC()
}
return response, now, nil
}
func (s ArraySource) Snapshot(ctx context.Context) (array.RawSnapshot, error) {
doc, now, err := s.document(ctx)
if err != nil {
return array.RawSnapshot{}, err
}
members := make([]array.RawMember, 0, len(doc.Array.Disks)+len(doc.Array.Parities))
for _, item := range append(doc.Array.Parities, doc.Array.Disks...) {
size, err := rawUint(item.Size)
if err != nil || strings.TrimSpace(item.Name) == "" {
return array.RawSnapshot{}, errors.New("Unraid disk identity or size is invalid")
}
members = append(members, array.RawMember{ID: identity(item.ID, item.Name), Name: item.Name, Role: role(item.Type), State: diskState(item.Status), CapacityBytes: kilobytes(size)})
}
return array.RawSnapshot{Source: array.Source{ID: "unraid", Type: "unraid"}, State: arrayState(doc.Array.State), Members: members, ObservedAt: now, ReceivedAt: now}, nil
}
// DiskSource shares the one bounded array document and exposes its documented disk
// fields without inventing SMART values.
type DiskSource struct {
Client *Client
Now func() time.Time
}
func (s DiskSource) Snapshot(ctx context.Context) (disk.RawSnapshot, error) {
doc, now, err := (ArraySource{Client: s.Client, Now: s.Now}).document(ctx)
if err != nil {
return disk.RawSnapshot{}, err
}
items := make([]disk.RawDisk, 0, len(doc.Array.Disks)+len(doc.Array.Parities)+len(doc.Array.Caches))
for _, item := range append(append(doc.Array.Parities, doc.Array.Disks...), doc.Array.Caches...) {
size, err := rawUint(item.FSSize)
if err != nil || size == 0 {
size, err = rawUint(item.Size)
}
if err != nil || strings.TrimSpace(item.Name) == "" {
return disk.RawSnapshot{}, errors.New("Unraid disk identity or size is invalid")
}
used, err := rawUint(item.FSUsed)
if err != nil && len(item.FSUsed) != 0 && string(item.FSUsed) != "null" {
return disk.RawSnapshot{}, errors.New("Unraid disk usage is invalid")
}
raw := disk.RawDisk{ID: identity(item.ID, item.Name), Name: item.Name, Role: role(item.Type), SizeBytes: kilobytes(size), UsedBytes: kilobytes(used), State: diskState(item.Status), Filesystem: item.FSType}
if item.Temp != nil && string(item.Temp) != "null" {
temp, err := rawUint(item.Temp)
if err != nil {
return disk.RawSnapshot{}, errors.New("Unraid disk temperature is invalid")
}
raw.Temperature = &disk.RawTemperature{Available: true, Celsius: float64(temp), ObservedAt: now}
}
if item.Spinning != nil {
raw.Spin = &disk.RawSpin{Supported: true, State: spinState(*item.Spinning)}
}
items = append(items, raw)
}
return disk.RawSnapshot{Source: disk.Source{ID: "unraid", Type: "unraid"}, Disks: items, ObservedAt: now, ReceivedAt: now}, nil
}
func rawUint(raw json.RawMessage) (uint64, error) {
if len(raw) == 0 || string(raw) == "null" {
return 0, errors.New("missing unsigned integer")
}
var number json.Number
if err := json.Unmarshal(raw, &number); err == nil {
return strconv.ParseUint(number.String(), 10, 64)
}
var text string
if err := json.Unmarshal(raw, &text); err != nil {
return 0, err
}
return strconv.ParseUint(text, 10, 64)
}
func kilobytes(value uint64) uint64 {
const unit = uint64(1024)
if value > ^uint64(0)/unit {
return ^uint64(0)
}
return value * unit
}
func identity(id, name string) string {
if strings.TrimSpace(id) != "" {
return strings.ToLower(strings.TrimSpace(id))
}
return strings.ToLower(strings.TrimSpace(name))
}
func role(value string) string {
switch strings.ToUpper(strings.TrimSpace(value)) {
case "PARITY":
return "parity"
case "CACHE":
return "cache"
default:
return "data"
}
}
func arrayState(value string) string {
switch strings.ToUpper(strings.TrimSpace(value)) {
case "STARTED":
return array.StateOperational
case "RECON_DISK", "DISABLE_DISK", "SWAP_DSBL":
return array.StateDegraded
case "TOO_MANY_MISSING_DISKS", "NO_DATA_DISKS":
return array.StateMissing
default:
return array.StateUnknown
}
}
func diskState(value string) string {
switch strings.ToUpper(strings.TrimSpace(value)) {
case "DISK_OK":
return disk.StateOnline
case "DISK_NP_MISSING", "DISK_NP":
return disk.StateMissing
case "DISK_DSBL", "DISK_NP_DSBL", "DISK_DSBL_NEW":
return disk.StateDisabled
default:
return disk.StateUnknown
}
}
func spinState(spinning bool) string {
if spinning {
return "spinning"
}
return "stopped"
}