mirror of
https://github.com/tw93/Mole.git
synced 2026-03-22 21:55:08 +00:00
fix(status): correct external disk capacity totals
This commit is contained in:
@@ -347,7 +347,7 @@ func (c *Collector) Collect() (MetricsSnapshot, error) {
|
|||||||
}, mergeErr
|
}, mergeErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func runCmd(ctx context.Context, name string, args ...string) (string, error) {
|
var runCmd = func(ctx context.Context, name string, args ...string) (string, error) {
|
||||||
cmd := exec.CommandContext(ctx, name, args...)
|
cmd := exec.CommandContext(ctx, name, args...)
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -356,7 +356,7 @@ func runCmd(ctx context.Context, name string, args ...string) (string, error) {
|
|||||||
return string(output), nil
|
return string(output), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func commandExists(name string) bool {
|
var commandExists = func(name string) bool {
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,26 +67,30 @@ func collectDisks() ([]DiskStatus, error) {
|
|||||||
if err != nil || usage.Total == 0 {
|
if err != nil || usage.Total == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
total := usage.Total
|
||||||
|
if runtime.GOOS == "darwin" {
|
||||||
|
total = correctDiskTotalBytes(part.Mountpoint, total)
|
||||||
|
}
|
||||||
// Skip <1GB volumes.
|
// Skip <1GB volumes.
|
||||||
if usage.Total < 1<<30 {
|
if total < 1<<30 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Use size-based dedupe key for shared pools.
|
// Use size-based dedupe key for shared pools.
|
||||||
volKey := fmt.Sprintf("%s:%d", part.Fstype, usage.Total)
|
volKey := fmt.Sprintf("%s:%d", part.Fstype, total)
|
||||||
if seenVolume[volKey] {
|
if seenVolume[volKey] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
used := usage.Used
|
used := usage.Used
|
||||||
usedPercent := usage.UsedPercent
|
usedPercent := usage.UsedPercent
|
||||||
if runtime.GOOS == "darwin" && strings.ToLower(part.Fstype) == "apfs" {
|
if runtime.GOOS == "darwin" && strings.ToLower(part.Fstype) == "apfs" {
|
||||||
used, usedPercent = correctAPFSDiskUsage(part.Mountpoint, usage.Total, usage.Used)
|
used, usedPercent = correctAPFSDiskUsage(part.Mountpoint, total, usage.Used)
|
||||||
}
|
}
|
||||||
|
|
||||||
disks = append(disks, DiskStatus{
|
disks = append(disks, DiskStatus{
|
||||||
Mount: part.Mountpoint,
|
Mount: part.Mountpoint,
|
||||||
Device: part.Device,
|
Device: part.Device,
|
||||||
Used: used,
|
Used: used,
|
||||||
Total: usage.Total,
|
Total: total,
|
||||||
UsedPercent: usedPercent,
|
UsedPercent: usedPercent,
|
||||||
Fstype: part.Fstype,
|
Fstype: part.Fstype,
|
||||||
})
|
})
|
||||||
@@ -228,6 +232,39 @@ func isExternalDisk(device string) (bool, error) {
|
|||||||
return external, nil
|
return external, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// correctDiskTotalBytes uses diskutil's plist output when macOS reports a
|
||||||
|
// meaningfully different disk size than gopsutil. This fixes external APFS
|
||||||
|
// volumes that can show doubled capacities through statfs/gopsutil.
|
||||||
|
func correctDiskTotalBytes(mountpoint string, rawTotal uint64) uint64 {
|
||||||
|
if rawTotal == 0 || !commandExists("diskutil") {
|
||||||
|
return rawTotal
|
||||||
|
}
|
||||||
|
|
||||||
|
diskutilTotal, err := getDiskutilTotalBytes(mountpoint)
|
||||||
|
if err != nil || diskutilTotal == 0 {
|
||||||
|
return rawTotal
|
||||||
|
}
|
||||||
|
|
||||||
|
if uint64AbsDiff(rawTotal, diskutilTotal) > 1<<30 {
|
||||||
|
return diskutilTotal
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawTotal
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDiskutilTotalBytes(mountpoint string) (uint64, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
out, err := runCmd(ctx, "diskutil", "info", "-plist", mountpoint)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer TotalSize, but keep older/plainer keys as fallbacks.
|
||||||
|
return extractPlistUint(out, "TotalSize", "DiskSize", "Size")
|
||||||
|
}
|
||||||
|
|
||||||
// correctAPFSDiskUsage returns Finder-accurate used bytes and percent for an
|
// correctAPFSDiskUsage returns Finder-accurate used bytes and percent for an
|
||||||
// APFS volume, accounting for purgeable caches and APFS local snapshots that
|
// APFS volume, accounting for purgeable caches and APFS local snapshots that
|
||||||
// statfs incorrectly counts as "used". Uses a three-tier fallback:
|
// statfs incorrectly counts as "used". Uses a three-tier fallback:
|
||||||
@@ -274,27 +311,7 @@ func getAPFSContainerFreeBytes(mountpoint string) (uint64, error) {
|
|||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = "<key>APFSContainerFree</key>"
|
return extractPlistUint(out, "APFSContainerFree")
|
||||||
_, rest, found := strings.Cut(out, key)
|
|
||||||
if !found {
|
|
||||||
return 0, fmt.Errorf("APFSContainerFree not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, rest, found = strings.Cut(rest, "<integer>")
|
|
||||||
if !found {
|
|
||||||
return 0, fmt.Errorf("APFSContainerFree value not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
value, _, found := strings.Cut(rest, "</integer>")
|
|
||||||
if !found {
|
|
||||||
return 0, fmt.Errorf("APFSContainerFree end tag not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
val, err := strconv.ParseUint(strings.TrimSpace(value), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("failed to parse APFSContainerFree: %v", err)
|
|
||||||
}
|
|
||||||
return val, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getFinderStartupDiskFreeBytes queries Finder via osascript for the startup
|
// getFinderStartupDiskFreeBytes queries Finder via osascript for the startup
|
||||||
@@ -336,6 +353,41 @@ func getFinderStartupDiskFreeBytes() (free, total uint64, err error) {
|
|||||||
return finderDiskFree, finderDiskTotal, nil
|
return finderDiskFree, finderDiskTotal, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func extractPlistUint(plist string, keys ...string) (uint64, error) {
|
||||||
|
for _, key := range keys {
|
||||||
|
marker := "<key>" + key + "</key>"
|
||||||
|
_, rest, found := strings.Cut(plist, marker)
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
_, rest, found = strings.Cut(rest, "<integer>")
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
value, _, found := strings.Cut(rest, "</integer>")
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := strconv.ParseUint(strings.TrimSpace(value), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to parse %s: %v", key, err)
|
||||||
|
}
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, fmt.Errorf("%s not found", strings.Join(keys, "/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func uint64AbsDiff(a, b uint64) uint64 {
|
||||||
|
if a > b {
|
||||||
|
return a - b
|
||||||
|
}
|
||||||
|
return b - a
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Collector) collectDiskIO(now time.Time) DiskIOStatus {
|
func (c *Collector) collectDiskIO(now time.Time) DiskIOStatus {
|
||||||
counters, err := disk.IOCounters()
|
counters, err := disk.IOCounters()
|
||||||
if err != nil || len(counters) == 0 {
|
if err != nil || len(counters) == 0 {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/disk"
|
"github.com/shirou/gopsutil/v4/disk"
|
||||||
@@ -58,3 +60,91 @@ func TestShouldSkipDiskPartition(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractPlistUint(t *testing.T) {
|
||||||
|
t.Run("prefers first matching key", func(t *testing.T) {
|
||||||
|
raw := `<plist><dict>
|
||||||
|
<key>TotalSize</key><integer>1099511627776</integer>
|
||||||
|
<key>DiskSize</key><integer>2199023255552</integer>
|
||||||
|
</dict></plist>`
|
||||||
|
|
||||||
|
got, err := extractPlistUint(raw, "TotalSize", "DiskSize")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("extractPlistUint() error = %v", err)
|
||||||
|
}
|
||||||
|
if got != 1099511627776 {
|
||||||
|
t.Fatalf("extractPlistUint() = %d, want %d", got, uint64(1099511627776))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("falls back to later keys", func(t *testing.T) {
|
||||||
|
raw := `<plist><dict><key>DiskSize</key><integer>1099511627776</integer></dict></plist>`
|
||||||
|
|
||||||
|
got, err := extractPlistUint(raw, "TotalSize", "DiskSize", "Size")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("extractPlistUint() error = %v", err)
|
||||||
|
}
|
||||||
|
if got != 1099511627776 {
|
||||||
|
t.Fatalf("extractPlistUint() = %d, want %d", got, uint64(1099511627776))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns error for malformed integer", func(t *testing.T) {
|
||||||
|
raw := `<plist><dict><key>TotalSize</key><integer>oops</integer></dict></plist>`
|
||||||
|
|
||||||
|
if _, err := extractPlistUint(raw, "TotalSize"); err == nil {
|
||||||
|
t.Fatalf("extractPlistUint() expected parse error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCorrectDiskTotalBytes(t *testing.T) {
|
||||||
|
origRunCmd := runCmd
|
||||||
|
origCommandExists := commandExists
|
||||||
|
t.Cleanup(func() {
|
||||||
|
runCmd = origRunCmd
|
||||||
|
commandExists = origCommandExists
|
||||||
|
})
|
||||||
|
|
||||||
|
commandExists = func(name string) bool {
|
||||||
|
return name == "diskutil"
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("uses diskutil total when meaningfully different", func(t *testing.T) {
|
||||||
|
runCmd = func(ctx context.Context, name string, args ...string) (string, error) {
|
||||||
|
if name != "diskutil" {
|
||||||
|
return "", errors.New("unexpected command")
|
||||||
|
}
|
||||||
|
return `<plist><dict><key>TotalSize</key><integer>1099511627776</integer></dict></plist>`, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
got := correctDiskTotalBytes("/Volumes/Backup", 2199023255552)
|
||||||
|
if got != 1099511627776 {
|
||||||
|
t.Fatalf("correctDiskTotalBytes() = %d, want %d", got, uint64(1099511627776))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("keeps raw total for small differences", func(t *testing.T) {
|
||||||
|
runCmd = func(ctx context.Context, name string, args ...string) (string, error) {
|
||||||
|
return `<plist><dict><key>TotalSize</key><integer>1000500000000</integer></dict></plist>`, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawTotal = 1000000000000
|
||||||
|
got := correctDiskTotalBytes("/Volumes/FastSSD", rawTotal)
|
||||||
|
if got != rawTotal {
|
||||||
|
t.Fatalf("correctDiskTotalBytes() = %d, want %d", got, uint64(rawTotal))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("keeps raw total when diskutil fails", func(t *testing.T) {
|
||||||
|
runCmd = func(ctx context.Context, name string, args ...string) (string, error) {
|
||||||
|
return "", errors.New("diskutil failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawTotal = 1099511627776
|
||||||
|
got := correctDiskTotalBytes("/Volumes/FastSSD", rawTotal)
|
||||||
|
if got != rawTotal {
|
||||||
|
t.Fatalf("correctDiskTotalBytes() = %d, want %d", got, uint64(rawTotal))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user