69698a89e8
- Health severity fix: mount-point check downgraded from issue (FAIL) to warning (WARN) - All storage health messages translated to Hungarian - Success flash messages for all storage operations - Edit storage path labels (inline edit UI + backend) - App details per storage path on settings page (expandable list with names + sizes) - Storage badge on stacks page showing which storage each app uses - Deploy dropdown with free space display and low-space warning (<20%) - Filesystem & disk info on settings page (ext4/btrfs, device, model via findmnt) - Backup page storage context with per-app storage label badges Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
143 lines
3.6 KiB
Go
143 lines
3.6 KiB
Go
//go:build linux
|
|
|
|
package system
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
)
|
|
|
|
// IsMountPoint checks if a path is on a different device than its parent.
|
|
// Returns true if the path is a mount point (different device ID from parent).
|
|
func IsMountPoint(path string) bool {
|
|
var pathStat, parentStat syscall.Stat_t
|
|
if err := syscall.Stat(path, &pathStat); err != nil {
|
|
return false
|
|
}
|
|
parent := filepath.Dir(path)
|
|
if err := syscall.Stat(parent, &parentStat); err != nil {
|
|
return false
|
|
}
|
|
return pathStat.Dev != parentStat.Dev
|
|
}
|
|
|
|
// IsWritable checks if the given path is writable by attempting to create+remove a temp file.
|
|
func IsWritable(path string) bool {
|
|
testFile := filepath.Join(path, ".felhom-write-test")
|
|
f, err := os.Create(testFile)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
f.Close()
|
|
os.Remove(testFile)
|
|
return true
|
|
}
|
|
|
|
// PathsOverlap returns true if one path is a parent or child of the other.
|
|
func PathsOverlap(a, b string) bool {
|
|
a = filepath.Clean(a)
|
|
b = filepath.Clean(b)
|
|
if a == b {
|
|
return true
|
|
}
|
|
aSep := a + string(os.PathSeparator)
|
|
bSep := b + string(os.PathSeparator)
|
|
return strings.HasPrefix(aSep, bSep) || strings.HasPrefix(bSep, aSep)
|
|
}
|
|
|
|
// DiskUsageInfo holds disk usage statistics for a path.
|
|
type DiskUsageInfo struct {
|
|
TotalGB float64
|
|
UsedGB float64
|
|
AvailGB float64
|
|
UsedPercent float64
|
|
TotalHuman string
|
|
UsedHuman string
|
|
}
|
|
|
|
// GetDiskUsage returns disk usage info for a path, or nil on error.
|
|
func GetDiskUsage(path string) *DiskUsageInfo {
|
|
var stat syscall.Statfs_t
|
|
if err := syscall.Statfs(path, &stat); err != nil {
|
|
return nil
|
|
}
|
|
|
|
bsize := uint64(stat.Bsize)
|
|
total := stat.Blocks * bsize
|
|
avail := stat.Bavail * bsize
|
|
used := total - (stat.Bfree * bsize)
|
|
|
|
const gb = 1024 * 1024 * 1024
|
|
info := &DiskUsageInfo{
|
|
TotalGB: float64(total) / float64(gb),
|
|
UsedGB: float64(used) / float64(gb),
|
|
AvailGB: float64(avail) / float64(gb),
|
|
}
|
|
if total > 0 {
|
|
info.UsedPercent = float64(used) / float64(total) * 100
|
|
}
|
|
info.TotalHuman = formatGB(info.TotalGB)
|
|
info.UsedHuman = formatGB(info.UsedGB)
|
|
return info
|
|
}
|
|
|
|
func formatGB(gb float64) string {
|
|
if gb >= 1000 {
|
|
return fmt.Sprintf("%.1f TB", gb/1024)
|
|
}
|
|
return fmt.Sprintf("%.1f GB", gb)
|
|
}
|
|
|
|
// FSInfo holds filesystem type, device, and disk model info.
|
|
type FSInfo struct {
|
|
FSType string // "ext4", "btrfs"
|
|
Device string // "/dev/sda1"
|
|
Model string // "WD Elements 25A2" (best-effort from sysfs)
|
|
}
|
|
|
|
// GetFSInfo returns filesystem info for a path using findmnt, or nil on error.
|
|
func GetFSInfo(path string) *FSInfo {
|
|
out, err := exec.Command("findmnt", "-n", "-o", "SOURCE,FSTYPE", "--target", path).Output()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
fields := strings.Fields(strings.TrimSpace(string(out)))
|
|
if len(fields) < 2 {
|
|
return nil
|
|
}
|
|
info := &FSInfo{
|
|
Device: fields[0],
|
|
FSType: fields[1],
|
|
}
|
|
// Try to get disk model from sysfs
|
|
info.Model = diskModel(info.Device)
|
|
return info
|
|
}
|
|
|
|
// diskModel reads the disk model from /sys/block/<dev>/device/model.
|
|
func diskModel(device string) string {
|
|
// /dev/sda1 → sda, /dev/nvme0n1p1 → nvme0n1
|
|
base := filepath.Base(device)
|
|
// Strip partition number: sda1 → sda, nvme0n1p1 → nvme0n1
|
|
disk := base
|
|
if strings.HasPrefix(base, "nvme") {
|
|
// nvme0n1p1 → find last 'p' followed by digits
|
|
if idx := strings.LastIndex(base, "p"); idx > 4 {
|
|
disk = base[:idx]
|
|
}
|
|
} else {
|
|
// sda1 → sda: strip trailing digits
|
|
disk = strings.TrimRight(base, "0123456789")
|
|
}
|
|
modelPath := "/sys/block/" + disk + "/device/model"
|
|
data, err := os.ReadFile(modelPath)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(string(data))
|
|
}
|