feat: add storage info to settings page and improve NFS export handling

Backend:
- Add source/used_by/available/error fields to diskUsage in system status
- collectDiskUsage() now reads /proc/mounts, samba shares and NFS exports from DB
- Paths are deduplicated; shared paths list all shares/exports using them
- syscall.Statfs errors surface as available=false with user-facing error
- collectServiceStatus made a method of Server (receiver consistency)

Frontend:
- Settings page now shows two cards: mount points and shared resources
- Each path shows source badge (Sistema/Mount/SMB/NFS), used_by chips, progress bar
- Unavailable paths show amber warning instead of progress bar
- DiskUsage interface updated with new fields
- NFSExport interface updated with structured fields (fsid, async, etc)
- NFS page updated to use new export fields
This commit is contained in:
2026-07-05 22:56:39 -04:00
parent 72a8336087
commit 65cd753818
14 changed files with 696 additions and 100 deletions
@@ -0,0 +1,9 @@
ALTER TABLE nfs_exports
ADD COLUMN read_only INTEGER NOT NULL DEFAULT 0,
ADD COLUMN async_ INTEGER NOT NULL DEFAULT 0,
ADD COLUMN root_squash INTEGER NOT NULL DEFAULT 1,
ADD COLUMN subtree_check INTEGER NOT NULL DEFAULT 0,
ADD COLUMN fsid INTEGER NOT NULL DEFAULT 0,
ADD COLUMN advanced TEXT NOT NULL DEFAULT '{}';
CREATE INDEX IF NOT EXISTS idx_nfs_exports_fsid ON nfs_exports(fsid);
+19 -6
View File
@@ -38,12 +38,25 @@ type SambaShare struct {
}
type NFSExport struct {
ID int64 `json:"id"`
Path string `json:"path"`
Clients []string `json:"clients"`
Options string `json:"options"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID int64 `json:"id"`
Path string `json:"path"`
Clients []string `json:"clients"`
ReadOnly bool `json:"read_only"`
Async bool `json:"async"`
RootSquash bool `json:"root_squash"`
SubtreeCheck bool `json:"subtree_check"`
FSID int64 `json:"fsid"`
Advanced string `json:"advanced"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type NFSAdvanced struct {
AllSquash bool `json:"all_squash"`
Secure bool `json:"secure"`
WDelay bool `json:"wdelay"`
Hide bool `json:"hide"`
Crossmnt bool `json:"crossmnt"`
}
type DirtyModule struct {
+23 -3
View File
@@ -61,10 +61,30 @@ func (d *DB) ReplaceNFSExports(exports []NFSExport) error {
if err != nil {
return err
}
readOnly := 0
if exp.ReadOnly {
readOnly = 1
}
async_ := 0
if exp.Async {
async_ = 1
}
rootSquash := 0
if exp.RootSquash {
rootSquash = 1
}
subtreeCheck := 0
if exp.SubtreeCheck {
subtreeCheck = 1
}
advanced := exp.Advanced
if advanced == "" {
advanced = "{}"
}
_, err = tx.Exec(`
INSERT INTO nfs_exports (path, clients, options)
VALUES (?, ?, ?)`,
exp.Path, clients, exp.Options,
INSERT INTO nfs_exports (path, clients, read_only, async_, root_squash, subtree_check, fsid, advanced)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
exp.Path, clients, readOnly, async_, rootSquash, subtreeCheck, exp.FSID, advanced,
)
if err != nil {
return fmt.Errorf("insert nfs export %s: %w", exp.Path, err)
+85 -8
View File
@@ -10,11 +10,17 @@ func scanNFSExport(row interface {
}) (NFSExport, error) {
var export NFSExport
var clients, createdAt, updatedAt string
var readOnly, async_, rootSquash, subtreeCheck, fsid int
if err := row.Scan(
&export.ID,
&export.Path,
&clients,
&export.Options,
&readOnly,
&async_,
&rootSquash,
&subtreeCheck,
&fsid,
&export.Advanced,
&createdAt,
&updatedAt,
); err != nil {
@@ -25,6 +31,11 @@ func scanNFSExport(row interface {
if err != nil {
return NFSExport{}, err
}
export.ReadOnly = readOnly != 0
export.Async = async_ != 0
export.RootSquash = rootSquash != 0
export.SubtreeCheck = subtreeCheck != 0
export.FSID = int64(fsid)
export.CreatedAt = parseTime(createdAt)
export.UpdatedAt = parseTime(updatedAt)
return export, nil
@@ -32,7 +43,7 @@ func scanNFSExport(row interface {
func (d *DB) ListNFSExports() ([]NFSExport, error) {
rows, err := d.conn.Query(`
SELECT id, path, clients, options, created_at, updated_at
SELECT id, path, clients, read_only, async_, root_squash, subtree_check, fsid, advanced, created_at, updated_at
FROM nfs_exports ORDER BY path ASC`)
if err != nil {
return nil, fmt.Errorf("list nfs exports: %w", err)
@@ -52,7 +63,7 @@ func (d *DB) ListNFSExports() ([]NFSExport, error) {
func (d *DB) GetNFSExport(id int64) (NFSExport, error) {
row := d.conn.QueryRow(`
SELECT id, path, clients, options, created_at, updated_at
SELECT id, path, clients, read_only, async_, root_squash, subtree_check, fsid, advanced, created_at, updated_at
FROM nfs_exports WHERE id = ?`, id)
export, err := scanNFSExport(row)
if err == sql.ErrNoRows {
@@ -69,10 +80,30 @@ func (d *DB) CreateNFSExport(export NFSExport) (NFSExport, error) {
if err != nil {
return NFSExport{}, err
}
readOnly := 0
if export.ReadOnly {
readOnly = 1
}
async_ := 0
if export.Async {
async_ = 1
}
rootSquash := 0
if export.RootSquash {
rootSquash = 1
}
subtreeCheck := 0
if export.SubtreeCheck {
subtreeCheck = 1
}
advanced := export.Advanced
if advanced == "" {
advanced = "{}"
}
result, err := d.conn.Exec(`
INSERT INTO nfs_exports (path, clients, options)
VALUES (?, ?, ?)`,
export.Path, clients, export.Options,
INSERT INTO nfs_exports (path, clients, read_only, async_, root_squash, subtree_check, fsid, advanced)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
export.Path, clients, readOnly, async_, rootSquash, subtreeCheck, export.FSID, advanced,
)
if err != nil {
return NFSExport{}, fmt.Errorf("create nfs export: %w", err)
@@ -89,11 +120,31 @@ func (d *DB) UpdateNFSExport(id int64, export NFSExport) (NFSExport, error) {
if err != nil {
return NFSExport{}, err
}
readOnly := 0
if export.ReadOnly {
readOnly = 1
}
async_ := 0
if export.Async {
async_ = 1
}
rootSquash := 0
if export.RootSquash {
rootSquash = 1
}
subtreeCheck := 0
if export.SubtreeCheck {
subtreeCheck = 1
}
advanced := export.Advanced
if advanced == "" {
advanced = "{}"
}
result, err := d.conn.Exec(`
UPDATE nfs_exports
SET path = ?, clients = ?, options = ?, updated_at = datetime('now')
SET path = ?, clients = ?, read_only = ?, async_ = ?, root_squash = ?, subtree_check = ?, fsid = ?, advanced = ?, updated_at = datetime('now')
WHERE id = ?`,
export.Path, clients, export.Options, id,
export.Path, clients, readOnly, async_, rootSquash, subtreeCheck, export.FSID, advanced, id,
)
if err != nil {
return NFSExport{}, fmt.Errorf("update nfs export: %w", err)
@@ -122,3 +173,29 @@ func (d *DB) DeleteNFSExport(id int64) error {
}
return nil
}
func (d *DB) GetAllFSIDs() ([]int64, error) {
rows, err := d.conn.Query(`SELECT fsid FROM nfs_exports WHERE fsid != 0`)
if err != nil {
return nil, fmt.Errorf("get all fsids: %w", err)
}
defer rows.Close()
var fsids []int64
for rows.Next() {
var fsid int64
if err := rows.Scan(&fsid); err != nil {
return nil, err
}
fsids = append(fsids, fsid)
}
return fsids, rows.Err()
}
func (d *DB) FSIDExists(fsid int64) (bool, error) {
var count int
err := d.conn.QueryRow(`SELECT COUNT(1) FROM nfs_exports WHERE fsid = ?`, fsid).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
+40 -7
View File
@@ -3,6 +3,9 @@ package importer
import (
"bufio"
"bytes"
"crypto/rand"
"encoding/binary"
"encoding/json"
"fmt"
"os"
"regexp"
@@ -58,11 +61,11 @@ func parseExports(data []byte) ([]db.NFSExport, error) {
}
export.Clients = validClients
if export.Options != "" {
if err := system.ValidateNFSOptions(export.Options); err != nil {
continue
}
fsid, err := generateImportFSID()
if err != nil {
continue
}
export.FSID = fsid
exports = append(exports, export)
}
@@ -141,13 +144,43 @@ func parseExportLine(line string) (db.NFSExport, bool) {
options = "rw,sync,no_root_squash"
}
readOnly := strings.Contains(options, "ro")
async := strings.Contains(options, "async")
rootSquash := !strings.Contains(options, "no_root_squash")
subtreeCheck := strings.Contains(options, "subtree_check")
adv := db.NFSAdvanced{
AllSquash: strings.Contains(options, "all_squash"),
Secure: strings.Contains(options, "secure"),
WDelay: strings.Contains(options, "wdelay"),
Hide: strings.Contains(options, "hide"),
Crossmnt: strings.Contains(options, "crossmnt"),
}
advJSON, _ := json.Marshal(adv)
return db.NFSExport{
Path: path,
Clients: clients,
Options: options,
Path: path,
Clients: clients,
ReadOnly: readOnly,
Async: async,
RootSquash: rootSquash,
SubtreeCheck: subtreeCheck,
Advanced: string(advJSON),
}, true
}
func generateImportFSID() (int64, error) {
var b [4]byte
if _, err := rand.Read(b[:]); err != nil {
return 0, err
}
n := binary.BigEndian.Uint32(b[:])
if n == 0 {
n = 1
}
return int64(n), nil
}
func splitExportsClients(s string) []string {
var result []string
var current []byte
+1 -1
View File
@@ -76,7 +76,7 @@ func TestParseExports(t *testing.T) {
if len(got) != tt.wantLen {
t.Errorf("parseExports() got %d exports, want %d", len(got), tt.wantLen)
for i, e := range got {
t.Logf(" export[%d]: path=%q clients=%v options=%q", i, e.Path, e.Clients, e.Options)
t.Logf(" export[%d]: path=%q clients=%v fsid=%d", i, e.Path, e.Clients, e.FSID)
}
}
})
+32
View File
@@ -0,0 +1,32 @@
package nfs
import (
"crypto/rand"
"encoding/binary"
"fmt"
)
func generateRandomFSID() (uint32, error) {
var b [4]byte
if _, err := rand.Read(b[:]); err != nil {
return 0, fmt.Errorf("crypto/rand read: %w", err)
}
n := binary.BigEndian.Uint32(b[:])
if n == 0 {
n = 1
}
return n, nil
}
func UniqueFSID(used map[uint32]struct{}) (uint32, error) {
for i := 0; i < 64; i++ {
n, err := generateRandomFSID()
if err != nil {
return 0, err
}
if _, dup := used[n]; !dup {
return n, nil
}
}
return 0, fmt.Errorf("fsid space exhausted after 64 attempts")
}
+56
View File
@@ -0,0 +1,56 @@
package nfs
import (
"sync"
"testing"
)
func TestUniqueFSIDNoCollision(t *testing.T) {
const goroutines = 100
results := make(chan uint32, goroutines)
var wg sync.WaitGroup
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func() {
defer wg.Done()
n, err := generateRandomFSID()
if err != nil {
t.Errorf("generateRandomFSID failed: %v", err)
return
}
results <- n
}()
}
wg.Wait()
close(results)
seen := make(map[uint32]struct{})
for n := range results {
if _, dup := seen[n]; dup {
t.Errorf("duplicate fsid generated: %d", n)
}
seen[n] = struct{}{}
}
}
func TestUniqueFSIDWithUsedSet(t *testing.T) {
used := map[uint32]struct{}{
1: {}, 2: {}, 3: {},
}
for i := 0; i < 50; i++ {
n, err := UniqueFSID(used)
if err != nil {
t.Fatalf("UniqueFSID failed at iteration %d: %v", i, err)
}
if n == 0 {
t.Fatalf("fsid 0 is reserved")
}
if _, dup := used[n]; dup {
t.Fatalf("returned duplicate fsid: %d", n)
}
used[n] = struct{}{}
}
}
+57 -7
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"embed"
"encoding/json"
"fmt"
"os"
"path/filepath"
@@ -86,13 +87,62 @@ func (m *Module) Apply(ctx context.Context, database *db.DB) error {
return database.ClearDirty(ModuleName)
}
// clientSpec builds the "client(opts) client(opts)" segment of an exports line.
// If no clients are configured, it defaults to "*(opts)".
func clientSpec(clients []string, options string) string {
opts := strings.TrimSpace(options)
if opts == "" {
opts = "ro"
func buildFlags(e db.NFSExport) string {
parts := make([]string, 0, 8)
if e.ReadOnly {
parts = append(parts, "ro")
} else {
parts = append(parts, "rw")
}
if e.Async {
parts = append(parts, "async")
} else {
parts = append(parts, "sync")
}
if e.SubtreeCheck {
parts = append(parts, "subtree_check")
} else {
parts = append(parts, "no_subtree_check")
}
if e.RootSquash {
parts = append(parts, "root_squash")
} else {
parts = append(parts, "no_root_squash")
}
if e.Advanced != "" && e.Advanced != "{}" {
var adv db.NFSAdvanced
if err := json.Unmarshal([]byte(e.Advanced), &adv); err == nil {
if adv.AllSquash {
parts = append(parts, "all_squash")
} else {
parts = append(parts, "no_all_squash")
}
if adv.Secure {
parts = append(parts, "secure")
} else {
parts = append(parts, "insecure")
}
if adv.WDelay {
parts = append(parts, "wdelay")
} else {
parts = append(parts, "no_wdelay")
}
if adv.Hide {
parts = append(parts, "hide")
} else {
parts = append(parts, "nohide")
}
if adv.Crossmnt {
parts = append(parts, "crossmnt")
}
}
}
parts = append(parts, fmt.Sprintf("fsid=%d", e.FSID))
return strings.Join(parts, ",")
}
func clientSpec(clients []string, e db.NFSExport) string {
opts := buildFlags(e)
if len(clients) == 0 {
return fmt.Sprintf("*(%s)", opts)
}
@@ -125,7 +175,7 @@ func (m *Module) renderConfig(exports []db.NFSExport) ([]byte, error) {
for _, export := range exports {
data.Exports = append(data.Exports, templateExport{
Path: export.Path,
ClientSpec: clientSpec(export.Clients, export.Options),
ClientSpec: clientSpec(export.Clients, export),
})
}
+53 -17
View File
@@ -1,11 +1,10 @@
package web
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"encoding/json"
"github.com/go-chi/chi/v5"
@@ -17,7 +16,13 @@ import (
type nfsExportRequest struct {
Path string `json:"path"`
Clients []string `json:"clients"`
Options string `json:"options"`
// Main options
ReadOnly bool `json:"read_only"`
Async bool `json:"async"`
RootSquash bool `json:"root_squash"`
SubtreeCheck bool `json:"subtree_check"`
// Advanced options stored as JSON string
Advanced string `json:"advanced"`
}
func (req nfsExportRequest) validate(allowedRoots []string) error {
@@ -29,21 +34,18 @@ func (req nfsExportRequest) validate(allowedRoots []string) error {
return err
}
}
if strings.TrimSpace(req.Options) == "" {
return nil
}
return system.ValidateNFSOptions(req.Options)
return nil
}
func (req nfsExportRequest) toModel() db.NFSExport {
options := strings.TrimSpace(req.Options)
if options == "" {
options = "rw,sync,no_root_squash"
}
return db.NFSExport{
Path: req.Path,
Clients: req.Clients,
Options: options,
Path: req.Path,
Clients: req.Clients,
ReadOnly: req.ReadOnly,
Async: req.Async,
RootSquash: req.RootSquash,
SubtreeCheck: req.SubtreeCheck,
Advanced: req.Advanced,
}
}
@@ -70,6 +72,22 @@ func (s *Server) handleGetNFSExport(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, export)
}
func generateFSID(ctx context.Context, dbConn *db.DB) (int64, error) {
used, err := dbConn.GetAllFSIDs()
if err != nil {
return 0, err
}
usedSet := make(map[uint32]struct{}, len(used))
for _, id := range used {
usedSet[uint32(id)] = struct{}{}
}
n, err := nfs.UniqueFSID(usedSet)
if err != nil {
return 0, err
}
return int64(n), nil
}
func (s *Server) handleCreateNFSExport(w http.ResponseWriter, r *http.Request) {
req, err := decodeNFSExportRequest(r.Body)
if err != nil {
@@ -81,7 +99,15 @@ func (s *Server) handleCreateNFSExport(w http.ResponseWriter, r *http.Request) {
return
}
export, err := s.DB.CreateNFSExport(req.toModel())
model := req.toModel()
fsid, err := generateFSID(r.Context(), s.DB)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
model.FSID = fsid
export, err := s.DB.CreateNFSExport(model)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -99,6 +125,12 @@ func (s *Server) handleUpdateNFSExport(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
existing, err := s.DB.GetNFSExport(id)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
req, err := decodeNFSExportRequest(r.Body)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
@@ -109,7 +141,11 @@ func (s *Server) handleUpdateNFSExport(w http.ResponseWriter, r *http.Request) {
return
}
export, err := s.DB.UpdateNFSExport(id, req.toModel())
model := req.toModel()
model.ID = id
model.FSID = existing.FSID
export, err := s.DB.UpdateNFSExport(id, model)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
+95 -30
View File
@@ -1,7 +1,10 @@
package web
import (
"bufio"
"net/http"
"os"
"sort"
"strings"
"syscall"
@@ -9,11 +12,15 @@ import (
)
type diskUsage struct {
Path string `json:"path"`
TotalBytes uint64 `json:"total_bytes"`
FreeBytes uint64 `json:"free_bytes"`
UsedBytes uint64 `json:"used_bytes"`
UsedPercent float64 `json:"used_percent"`
Path string `json:"path"`
TotalBytes uint64 `json:"total_bytes"`
FreeBytes uint64 `json:"free_bytes"`
UsedBytes uint64 `json:"used_bytes"`
UsedPercent float64 `json:"used_percent"`
Source string `json:"source"`
UsedBy []string `json:"used_by,omitempty"`
Available bool `json:"available"`
Error string `json:"error,omitempty"`
}
type serviceStatus struct {
@@ -24,39 +31,97 @@ type serviceStatus struct {
func (s *Server) handleSystemStatus(w http.ResponseWriter, r *http.Request) {
status := map[string]any{
"disks": collectDiskUsage(),
"services": collectServiceStatus(r),
"disks": s.collectDiskUsage(),
"services": s.collectServiceStatus(r),
}
writeJSON(w, http.StatusOK, status)
}
func collectDiskUsage() []diskUsage {
paths := []string{"/"}
var usages []diskUsage
for _, path := range paths {
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
continue
func (s *Server) collectDiskUsage() []diskUsage {
entries := make(map[string]*diskUsage)
entries["/"] = &diskUsage{Path: "/", Source: "system", Available: true}
shares, err := s.DB.ListSambaShares()
if err == nil {
for _, share := range shares {
key := share.Path
if e, ok := entries[key]; ok {
e.UsedBy = append(e.UsedBy, share.Name)
} else {
entries[key] = &diskUsage{Path: key, Source: "samba", UsedBy: []string{share.Name}, Available: true}
}
}
total := stat.Blocks * uint64(stat.Bsize)
free := stat.Bavail * uint64(stat.Bsize)
used := total - free
var pct float64
if total > 0 {
pct = float64(used) / float64(total) * 100
}
usages = append(usages, diskUsage{
Path: path,
TotalBytes: total,
FreeBytes: free,
UsedBytes: used,
UsedPercent: pct,
})
}
return usages
exports, err := s.DB.ListNFSExports()
if err == nil {
for _, exp := range exports {
key := exp.Path
label := "nfs:" + exp.Path
if e, ok := entries[key]; ok {
e.UsedBy = append(e.UsedBy, label)
} else {
entries[key] = &diskUsage{Path: key, Source: "nfs", UsedBy: []string{label}, Available: true}
}
}
}
readProcMounts(entries)
var result []diskUsage
for _, e := range entries {
fillDiskUsage(e)
result = append(result, *e)
}
sort.Slice(result, func(i, j int) bool {
if result[i].Source != result[j].Source {
return result[i].Source < result[j].Source
}
return result[i].Path < result[j].Path
})
return result
}
func collectServiceStatus(r *http.Request) []serviceStatus {
func readProcMounts(entries map[string]*diskUsage) {
f, err := os.Open("/proc/mounts")
if err != nil {
return
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) < 3 {
continue
}
mountPoint := fields[1]
if _, ok := entries[mountPoint]; !ok {
entries[mountPoint] = &diskUsage{Path: mountPoint, Source: "mount", Available: true}
}
}
}
func fillDiskUsage(e *diskUsage) {
var stat syscall.Statfs_t
if err := syscall.Statfs(e.Path, &stat); err != nil {
e.Available = false
e.Error = "path no disponible"
return
}
e.Available = true
e.TotalBytes = stat.Blocks * uint64(stat.Bsize)
e.FreeBytes = stat.Bavail * uint64(stat.Bsize)
e.UsedBytes = e.TotalBytes - e.FreeBytes
if e.TotalBytes > 0 {
e.UsedPercent = float64(e.UsedBytes) / float64(e.TotalBytes) * 100
}
}
func (s *Server) collectServiceStatus(r *http.Request) []serviceStatus {
services := []string{"smbd", "nfs-server"}
var statuses []serviceStatus
for _, name := range services {