From 65cd753818cc5caef866988dd4edd80fc5fb0728 Mon Sep 17 00:00:00 2001 From: Daniel Arroyo Date: Sun, 5 Jul 2026 22:56:39 -0400 Subject: [PATCH] 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 --- .../db/migrations/0002_nfs_structured.sql | 9 ++ internal/db/models.go | 25 ++- internal/db/queries_import.go | 26 +++- internal/db/queries_nfs.go | 93 ++++++++++- internal/importer/nfs.go | 47 +++++- internal/importer/nfs_test.go | 2 +- internal/modules/nfs/fsid.go | 32 ++++ internal/modules/nfs/fsid_test.go | 56 +++++++ internal/modules/nfs/nfs.go | 64 +++++++- internal/web/handlers_nfs.go | 70 +++++++-- internal/web/handlers_system.go | 125 +++++++++++---- web/src/api.ts | 11 +- web/src/pages/Nfs.tsx | 147 +++++++++++++++--- web/src/pages/Settings.tsx | 89 ++++++++++- 14 files changed, 696 insertions(+), 100 deletions(-) create mode 100644 internal/db/migrations/0002_nfs_structured.sql create mode 100644 internal/modules/nfs/fsid.go create mode 100644 internal/modules/nfs/fsid_test.go diff --git a/internal/db/migrations/0002_nfs_structured.sql b/internal/db/migrations/0002_nfs_structured.sql new file mode 100644 index 0000000..4d5b4ea --- /dev/null +++ b/internal/db/migrations/0002_nfs_structured.sql @@ -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); diff --git a/internal/db/models.go b/internal/db/models.go index 807ac94..634e9be 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -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 { diff --git a/internal/db/queries_import.go b/internal/db/queries_import.go index abe88f1..b056260 100644 --- a/internal/db/queries_import.go +++ b/internal/db/queries_import.go @@ -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) diff --git a/internal/db/queries_nfs.go b/internal/db/queries_nfs.go index 622e76d..83e60f3 100644 --- a/internal/db/queries_nfs.go +++ b/internal/db/queries_nfs.go @@ -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 +} diff --git a/internal/importer/nfs.go b/internal/importer/nfs.go index ce2b5a9..3aa4d10 100644 --- a/internal/importer/nfs.go +++ b/internal/importer/nfs.go @@ -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 diff --git a/internal/importer/nfs_test.go b/internal/importer/nfs_test.go index b679753..1150f21 100644 --- a/internal/importer/nfs_test.go +++ b/internal/importer/nfs_test.go @@ -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) } } }) diff --git a/internal/modules/nfs/fsid.go b/internal/modules/nfs/fsid.go new file mode 100644 index 0000000..daa101a --- /dev/null +++ b/internal/modules/nfs/fsid.go @@ -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") +} diff --git a/internal/modules/nfs/fsid_test.go b/internal/modules/nfs/fsid_test.go new file mode 100644 index 0000000..8510239 --- /dev/null +++ b/internal/modules/nfs/fsid_test.go @@ -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{}{} + } +} diff --git a/internal/modules/nfs/nfs.go b/internal/modules/nfs/nfs.go index cd80b83..992ef96 100644 --- a/internal/modules/nfs/nfs.go +++ b/internal/modules/nfs/nfs.go @@ -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), }) } diff --git a/internal/web/handlers_nfs.go b/internal/web/handlers_nfs.go index 700ef61..d36d955 100644 --- a/internal/web/handlers_nfs.go +++ b/internal/web/handlers_nfs.go @@ -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 diff --git a/internal/web/handlers_system.go b/internal/web/handlers_system.go index 9850e57..221dc22 100644 --- a/internal/web/handlers_system.go +++ b/internal/web/handlers_system.go @@ -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 { diff --git a/web/src/api.ts b/web/src/api.ts index ced595e..d09205c 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -13,7 +13,12 @@ export interface NFSExport { id: number; path: string; clients: string[]; - options: string; + read_only: boolean; + async: boolean; + root_squash: boolean; + subtree_check: boolean; + fsid: number; + advanced: string; } export interface User { @@ -43,6 +48,10 @@ export interface DiskUsage { free_bytes: number; used_bytes: number; used_percent: number; + source: "system" | "mount" | "samba" | "nfs"; + used_by?: string[]; + available: boolean; + error?: string; } export interface ServiceStatus { diff --git a/web/src/pages/Nfs.tsx b/web/src/pages/Nfs.tsx index 6007788..11fd3c4 100644 --- a/web/src/pages/Nfs.tsx +++ b/web/src/pages/Nfs.tsx @@ -6,12 +6,34 @@ import Modal from "../components/Modal"; const empty: Partial = { path: "", clients: [], - options: "rw,sync,no_root_squash", + read_only: false, + async: false, + root_squash: true, + subtree_check: false, + advanced: "{}", }; +const ADVANCED_KEYS = [ + { key: "all_squash", label: "All squash" }, + { key: "secure", label: "Secure" }, + { key: "wdelay", label: "WDelay" }, + { key: "hide", label: "Hide" }, + { key: "crossmnt", label: "Crossmnt" }, +]; + +function parseAdvanced(raw: string): Record { + try { return JSON.parse(raw || "{}"); } catch { return {}; } +} + +function serializeAdvanced(m: Record): string { + return JSON.stringify(m); +} + export default function Nfs() { const [exports, setExports] = useState([]); const [editing, setEditing] = useState | null>(null); + const [advanced, setAdvanced] = useState>({}); + const [showAdvanced, setShowAdvanced] = useState(false); const [error, setError] = useState(null); const { refresh } = useDirty(); @@ -24,15 +46,31 @@ export default function Nfs() { load(); }, []); + function openEdit(x: Partial) { + setAdvanced(parseAdvanced(x.advanced ?? "{}")); + setShowAdvanced(false); + setEditing(x); + } + + function openNew() { + setAdvanced({}); + setShowAdvanced(false); + setEditing({ ...empty }); + } + async function save(e: FormEvent) { e.preventDefault(); if (!editing) return; setError(null); + const payload = { + ...editing, + advanced: serializeAdvanced(advanced), + }; try { if (editing.id) { - await api.updateExport(editing.id, editing); + await api.updateExport(editing.id, payload); } else { - await api.createExport(editing); + await api.createExport(payload); } setEditing(null); await load(); @@ -49,11 +87,24 @@ export default function Nfs() { await refresh(); } + function toggleAdvanced(key: string) { + setAdvanced(prev => ({ ...prev, [key]: !prev[key] })); + } + + function flagsSummary(x: NFSExport): string { + const parts: string[] = []; + parts.push(x.read_only ? "ro" : "rw"); + parts.push(x.async ? "async" : "sync"); + parts.push(x.subtree_check ? "subtree_check" : "no_subtree_check"); + parts.push(x.root_squash ? "root_squash" : "no_root_squash"); + return parts.join(","); + } + return (

Exports NFS

-
@@ -64,7 +115,8 @@ export default function Nfs() { Path Clientes - Opciones + Flags + FSID @@ -73,9 +125,14 @@ export default function Nfs() { {x.path} {x.clients.join(", ") || "*"} - {x.options} + {flagsSummary(x)} + + + {x.fsid} + + -
-
- - setEditing({ ...editing, options: e.target.value })} - /> -

- Ej: rw,sync,no_root_squash · ro,async,root_squash -

+ +
+ Opciones +
+ + + + +
+ +
+ { e.preventDefault(); setShowAdvanced(v => !v); }} + > + {showAdvanced ? "▾" : "▸"} Opciones avanzadas + +
+ {ADVANCED_KEYS.map(({ key, label }) => ( + + ))} +
+
+
); }