1
0
mirror of https://github.com/tw93/Mole.git synced 2026-03-22 21:55:08 +00:00
Files
Mole/cmd/status/metrics_disk.go
tw93 9de661b5df fix(status): prefer internal disks over external in disk listing
When multiple disks are connected, the status command was sorting
only by size, causing external disks to appear first when they are
larger than the internal disk. This resulted in showing incorrect
free space (external disk size) instead of the internal disk.

The sort now prioritizes internal disks before sorting by size,
ensuring the internal disk always appears first.

Fixes #466
2026-02-16 19:08:25 +08:00

220 lines
4.6 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"runtime"
"sort"
"strings"
"time"
"github.com/shirou/gopsutil/v4/disk"
)
var skipDiskMounts = map[string]bool{
"/System/Volumes/VM": true,
"/System/Volumes/Preboot": true,
"/System/Volumes/Update": true,
"/System/Volumes/xarts": true,
"/System/Volumes/Hardware": true,
"/System/Volumes/Data": true,
"/dev": true,
}
func collectDisks() ([]DiskStatus, error) {
partitions, err := disk.Partitions(false)
if err != nil {
return nil, err
}
var (
disks []DiskStatus
seenDevice = make(map[string]bool)
seenVolume = make(map[string]bool)
)
for _, part := range partitions {
if strings.HasPrefix(part.Device, "/dev/loop") {
continue
}
if skipDiskMounts[part.Mountpoint] {
continue
}
if strings.HasPrefix(part.Mountpoint, "/System/Volumes/") {
continue
}
// Skip /private mounts.
if strings.HasPrefix(part.Mountpoint, "/private/") {
continue
}
baseDevice := baseDeviceName(part.Device)
if baseDevice == "" {
baseDevice = part.Device
}
if seenDevice[baseDevice] {
continue
}
usage, err := disk.Usage(part.Mountpoint)
if err != nil || usage.Total == 0 {
continue
}
// Skip <1GB volumes.
if usage.Total < 1<<30 {
continue
}
// Use size-based dedupe key for shared pools.
volKey := fmt.Sprintf("%s:%d", part.Fstype, usage.Total)
if seenVolume[volKey] {
continue
}
disks = append(disks, DiskStatus{
Mount: part.Mountpoint,
Device: part.Device,
Used: usage.Used,
Total: usage.Total,
UsedPercent: usage.UsedPercent,
Fstype: part.Fstype,
})
seenDevice[baseDevice] = true
seenVolume[volKey] = true
}
annotateDiskTypes(disks)
sort.Slice(disks, func(i, j int) bool {
// First, prefer internal disks over external
if disks[i].External != disks[j].External {
return !disks[i].External
}
// Then sort by size (largest first)
return disks[i].Total > disks[j].Total
})
if len(disks) > 3 {
disks = disks[:3]
}
return disks, nil
}
var (
// External disk cache.
lastDiskCacheAt time.Time
diskTypeCache = make(map[string]bool)
diskCacheTTL = 2 * time.Minute
)
func annotateDiskTypes(disks []DiskStatus) {
if len(disks) == 0 || runtime.GOOS != "darwin" || !commandExists("diskutil") {
return
}
now := time.Now()
// Clear stale cache.
if now.Sub(lastDiskCacheAt) > diskCacheTTL {
diskTypeCache = make(map[string]bool)
lastDiskCacheAt = now
}
for i := range disks {
base := baseDeviceName(disks[i].Device)
if base == "" {
base = disks[i].Device
}
if val, ok := diskTypeCache[base]; ok {
disks[i].External = val
continue
}
external, err := isExternalDisk(base)
if err != nil {
external = strings.HasPrefix(disks[i].Mount, "/Volumes/")
}
disks[i].External = external
diskTypeCache[base] = external
}
}
func baseDeviceName(device string) string {
device = strings.TrimPrefix(device, "/dev/")
if !strings.HasPrefix(device, "disk") {
return device
}
for i := 4; i < len(device); i++ {
if device[i] == 's' {
return device[:i]
}
}
return device
}
func isExternalDisk(device string) (bool, error) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
out, err := runCmd(ctx, "diskutil", "info", device)
if err != nil {
return false, err
}
var (
found bool
external bool
)
for line := range strings.Lines(out) {
trim := strings.TrimSpace(line)
if strings.HasPrefix(trim, "Internal:") {
found = true
external = strings.Contains(trim, "No")
break
}
if strings.HasPrefix(trim, "Device Location:") {
found = true
external = strings.Contains(trim, "External")
}
}
if !found {
return false, errors.New("diskutil info missing Internal field")
}
return external, nil
}
func (c *Collector) collectDiskIO(now time.Time) DiskIOStatus {
counters, err := disk.IOCounters()
if err != nil || len(counters) == 0 {
return DiskIOStatus{}
}
var total disk.IOCountersStat
for _, v := range counters {
total.ReadBytes += v.ReadBytes
total.WriteBytes += v.WriteBytes
}
if c.lastDiskAt.IsZero() {
c.prevDiskIO = total
c.lastDiskAt = now
return DiskIOStatus{}
}
elapsed := now.Sub(c.lastDiskAt).Seconds()
if elapsed <= 0 {
elapsed = 1
}
readRate := float64(total.ReadBytes-c.prevDiskIO.ReadBytes) / 1024 / 1024 / elapsed
writeRate := float64(total.WriteBytes-c.prevDiskIO.WriteBytes) / 1024 / 1024 / elapsed
c.prevDiskIO = total
c.lastDiskAt = now
if readRate < 0 {
readRate = 0
}
if writeRate < 0 {
writeRate = 0
}
return DiskIOStatus{ReadRate: readRate, WriteRate: writeRate}
}