Files
deploy-felhom-compose/controller/internal/system/mounts_linux.go
T
admin 69698a89e8 v0.10.0: Phase B — Storage Management UI Polish & Health Severity Fix
- 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>
2026-02-17 09:48:51 +01:00

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