package storage import ( "encoding/json" "strconv" "gitea.dooplex.hu/admin/felhom-agent/internal/hub" ) // smartctlJSON is the lenient subset of `smartctl -a -j` output we read. Pointers detect // presence so an absent section (e.g. NVMe fields on a SATA disk, or no SMART at all on a // USB bridge) decodes cleanly to nil and we degrade to UNKNOWN. type smartctlJSON struct { SmartStatus *struct { Passed bool `json:"passed"` } `json:"smart_status"` Temperature *struct { Current *int `json:"current"` } `json:"temperature"` PowerOnTime *struct { Hours *int `json:"hours"` } `json:"power_on_time"` // SATA/ATA attribute table. ATA *struct { Table []struct { ID int `json:"id"` Raw struct { Value int64 `json:"value"` } `json:"raw"` } `json:"table"` } `json:"ata_smart_attributes"` // NVMe health log. NVMe *struct { CriticalWarning *int `json:"critical_warning"` MediaErrors *int64 `json:"media_errors"` PercentageUsed *int `json:"percentage_used"` Temperature *int `json:"temperature"` } `json:"nvme_smart_health_information_log"` } // SATA attribute IDs we surface. const ( ataReallocatedSectorCt = 5 ataCurrentPending = 197 ataOfflineUncorrect = 198 ) // parseSMART maps smartctl JSON to a hub.SmartSummary, handling SATA + NVMe and degrading // to UNKNOWN when health is not reported. A device populates only its own attribute set. func parseSMART(raw []byte) hub.SmartSummary { s := hub.SmartSummary{Health: hub.SmartUnknown} if len(raw) == 0 { return s } var j smartctlJSON if err := json.Unmarshal(raw, &j); err != nil { return s // unparseable → UNKNOWN (never an error to the report) } if j.SmartStatus != nil { if j.SmartStatus.Passed { s.Health = hub.SmartPassed } else { s.Health = hub.SmartFailing } } if j.Temperature != nil && j.Temperature.Current != nil { s.TemperatureC = j.Temperature.Current } if j.PowerOnTime != nil && j.PowerOnTime.Hours != nil { s.PowerOnHours = j.PowerOnTime.Hours } // SATA attributes. if j.ATA != nil { for _, a := range j.ATA.Table { switch a.ID { case ataReallocatedSectorCt: s.ReallocatedSectors = intPtr(int(a.Raw.Value)) case ataCurrentPending: s.PendingSectors = intPtr(int(a.Raw.Value)) case ataOfflineUncorrect: s.OfflineUncorrectable = intPtr(int(a.Raw.Value)) } } } // NVMe attributes. if j.NVMe != nil { s.CriticalWarning = j.NVMe.CriticalWarning if j.NVMe.MediaErrors != nil { s.MediaErrors = intPtr(int(*j.NVMe.MediaErrors)) } s.PercentageUsed = j.NVMe.PercentageUsed // NVMe reports temperature in its own log when the top-level block is absent. if s.TemperatureC == nil && j.NVMe.Temperature != nil { s.TemperatureC = j.NVMe.Temperature } } return s } // lvsReport is the lenient subset of `lvs --reportformat json` output. type lvsReport struct { Report []struct { LV []struct { LVName string `json:"lv_name"` DataPercent string `json:"data_percent"` MetadataPercent string `json:"metadata_percent"` } `json:"lv"` } `json:"report"` } // parseThinPoolMetadata extracts the metadata-used fraction (0..1) from lvs JSON. lvs // reports percentages as decimal strings (e.g. "10.50"); an empty string means "not a thin // pool / not applicable" → ok=false. func parseThinPoolMetadata(raw []byte) (float64, bool) { if len(raw) == 0 { return 0, false } var r lvsReport if err := json.Unmarshal(raw, &r); err != nil { return 0, false } for _, rep := range r.Report { for _, lv := range rep.LV { if lv.MetadataPercent == "" { continue } pct, err := strconv.ParseFloat(lv.MetadataPercent, 64) if err != nil { continue } return pct / 100, true } } return 0, false } func intPtr(v int) *int { return &v }