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" }