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:
@@ -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);
|
||||||
+14
-1
@@ -41,11 +41,24 @@ type NFSExport struct {
|
|||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Clients []string `json:"clients"`
|
Clients []string `json:"clients"`
|
||||||
Options string `json:"options"`
|
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"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_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 {
|
type DirtyModule struct {
|
||||||
Module string `json:"module"`
|
Module string `json:"module"`
|
||||||
MarkedAt time.Time `json:"marked_at"`
|
MarkedAt time.Time `json:"marked_at"`
|
||||||
|
|||||||
@@ -61,10 +61,30 @@ func (d *DB) ReplaceNFSExports(exports []NFSExport) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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(`
|
_, err = tx.Exec(`
|
||||||
INSERT INTO nfs_exports (path, clients, options)
|
INSERT INTO nfs_exports (path, clients, read_only, async_, root_squash, subtree_check, fsid, advanced)
|
||||||
VALUES (?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
exp.Path, clients, exp.Options,
|
exp.Path, clients, readOnly, async_, rootSquash, subtreeCheck, exp.FSID, advanced,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("insert nfs export %s: %w", exp.Path, err)
|
return fmt.Errorf("insert nfs export %s: %w", exp.Path, err)
|
||||||
|
|||||||
@@ -10,11 +10,17 @@ func scanNFSExport(row interface {
|
|||||||
}) (NFSExport, error) {
|
}) (NFSExport, error) {
|
||||||
var export NFSExport
|
var export NFSExport
|
||||||
var clients, createdAt, updatedAt string
|
var clients, createdAt, updatedAt string
|
||||||
|
var readOnly, async_, rootSquash, subtreeCheck, fsid int
|
||||||
if err := row.Scan(
|
if err := row.Scan(
|
||||||
&export.ID,
|
&export.ID,
|
||||||
&export.Path,
|
&export.Path,
|
||||||
&clients,
|
&clients,
|
||||||
&export.Options,
|
&readOnly,
|
||||||
|
&async_,
|
||||||
|
&rootSquash,
|
||||||
|
&subtreeCheck,
|
||||||
|
&fsid,
|
||||||
|
&export.Advanced,
|
||||||
&createdAt,
|
&createdAt,
|
||||||
&updatedAt,
|
&updatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -25,6 +31,11 @@ func scanNFSExport(row interface {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return NFSExport{}, err
|
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.CreatedAt = parseTime(createdAt)
|
||||||
export.UpdatedAt = parseTime(updatedAt)
|
export.UpdatedAt = parseTime(updatedAt)
|
||||||
return export, nil
|
return export, nil
|
||||||
@@ -32,7 +43,7 @@ func scanNFSExport(row interface {
|
|||||||
|
|
||||||
func (d *DB) ListNFSExports() ([]NFSExport, error) {
|
func (d *DB) ListNFSExports() ([]NFSExport, error) {
|
||||||
rows, err := d.conn.Query(`
|
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`)
|
FROM nfs_exports ORDER BY path ASC`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list nfs exports: %w", err)
|
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) {
|
func (d *DB) GetNFSExport(id int64) (NFSExport, error) {
|
||||||
row := d.conn.QueryRow(`
|
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)
|
FROM nfs_exports WHERE id = ?`, id)
|
||||||
export, err := scanNFSExport(row)
|
export, err := scanNFSExport(row)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
@@ -69,10 +80,30 @@ func (d *DB) CreateNFSExport(export NFSExport) (NFSExport, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return NFSExport{}, err
|
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(`
|
result, err := d.conn.Exec(`
|
||||||
INSERT INTO nfs_exports (path, clients, options)
|
INSERT INTO nfs_exports (path, clients, read_only, async_, root_squash, subtree_check, fsid, advanced)
|
||||||
VALUES (?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
export.Path, clients, export.Options,
|
export.Path, clients, readOnly, async_, rootSquash, subtreeCheck, export.FSID, advanced,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return NFSExport{}, fmt.Errorf("create nfs export: %w", err)
|
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 {
|
if err != nil {
|
||||||
return NFSExport{}, err
|
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(`
|
result, err := d.conn.Exec(`
|
||||||
UPDATE nfs_exports
|
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 = ?`,
|
WHERE id = ?`,
|
||||||
export.Path, clients, export.Options, id,
|
export.Path, clients, readOnly, async_, rootSquash, subtreeCheck, export.FSID, advanced, id,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return NFSExport{}, fmt.Errorf("update nfs export: %w", err)
|
return NFSExport{}, fmt.Errorf("update nfs export: %w", err)
|
||||||
@@ -122,3 +173,29 @@ func (d *DB) DeleteNFSExport(id int64) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ package importer
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -58,11 +61,11 @@ func parseExports(data []byte) ([]db.NFSExport, error) {
|
|||||||
}
|
}
|
||||||
export.Clients = validClients
|
export.Clients = validClients
|
||||||
|
|
||||||
if export.Options != "" {
|
fsid, err := generateImportFSID()
|
||||||
if err := system.ValidateNFSOptions(export.Options); err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
export.FSID = fsid
|
||||||
|
|
||||||
exports = append(exports, export)
|
exports = append(exports, export)
|
||||||
}
|
}
|
||||||
@@ -141,13 +144,43 @@ func parseExportLine(line string) (db.NFSExport, bool) {
|
|||||||
options = "rw,sync,no_root_squash"
|
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{
|
return db.NFSExport{
|
||||||
Path: path,
|
Path: path,
|
||||||
Clients: clients,
|
Clients: clients,
|
||||||
Options: options,
|
ReadOnly: readOnly,
|
||||||
|
Async: async,
|
||||||
|
RootSquash: rootSquash,
|
||||||
|
SubtreeCheck: subtreeCheck,
|
||||||
|
Advanced: string(advJSON),
|
||||||
}, true
|
}, 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 {
|
func splitExportsClients(s string) []string {
|
||||||
var result []string
|
var result []string
|
||||||
var current []byte
|
var current []byte
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ func TestParseExports(t *testing.T) {
|
|||||||
if len(got) != tt.wantLen {
|
if len(got) != tt.wantLen {
|
||||||
t.Errorf("parseExports() got %d exports, want %d", len(got), tt.wantLen)
|
t.Errorf("parseExports() got %d exports, want %d", len(got), tt.wantLen)
|
||||||
for i, e := range got {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"embed"
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -86,13 +87,62 @@ func (m *Module) Apply(ctx context.Context, database *db.DB) error {
|
|||||||
return database.ClearDirty(ModuleName)
|
return database.ClearDirty(ModuleName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// clientSpec builds the "client(opts) client(opts)" segment of an exports line.
|
func buildFlags(e db.NFSExport) string {
|
||||||
// If no clients are configured, it defaults to "*(opts)".
|
parts := make([]string, 0, 8)
|
||||||
func clientSpec(clients []string, options string) string {
|
if e.ReadOnly {
|
||||||
opts := strings.TrimSpace(options)
|
parts = append(parts, "ro")
|
||||||
if opts == "" {
|
} else {
|
||||||
opts = "ro"
|
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 {
|
if len(clients) == 0 {
|
||||||
return fmt.Sprintf("*(%s)", opts)
|
return fmt.Sprintf("*(%s)", opts)
|
||||||
}
|
}
|
||||||
@@ -125,7 +175,7 @@ func (m *Module) renderConfig(exports []db.NFSExport) ([]byte, error) {
|
|||||||
for _, export := range exports {
|
for _, export := range exports {
|
||||||
data.Exports = append(data.Exports, templateExport{
|
data.Exports = append(data.Exports, templateExport{
|
||||||
Path: export.Path,
|
Path: export.Path,
|
||||||
ClientSpec: clientSpec(export.Clients, export.Options),
|
ClientSpec: clientSpec(export.Clients, export),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"encoding/json"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
@@ -17,7 +16,13 @@ import (
|
|||||||
type nfsExportRequest struct {
|
type nfsExportRequest struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Clients []string `json:"clients"`
|
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 {
|
func (req nfsExportRequest) validate(allowedRoots []string) error {
|
||||||
@@ -29,21 +34,18 @@ func (req nfsExportRequest) validate(allowedRoots []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(req.Options) == "" {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return system.ValidateNFSOptions(req.Options)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (req nfsExportRequest) toModel() db.NFSExport {
|
func (req nfsExportRequest) toModel() db.NFSExport {
|
||||||
options := strings.TrimSpace(req.Options)
|
|
||||||
if options == "" {
|
|
||||||
options = "rw,sync,no_root_squash"
|
|
||||||
}
|
|
||||||
return db.NFSExport{
|
return db.NFSExport{
|
||||||
Path: req.Path,
|
Path: req.Path,
|
||||||
Clients: req.Clients,
|
Clients: req.Clients,
|
||||||
Options: options,
|
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)
|
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) {
|
func (s *Server) handleCreateNFSExport(w http.ResponseWriter, r *http.Request) {
|
||||||
req, err := decodeNFSExportRequest(r.Body)
|
req, err := decodeNFSExportRequest(r.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -81,7 +99,15 @@ func (s *Server) handleCreateNFSExport(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -99,6 +125,12 @@ func (s *Server) handleUpdateNFSExport(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "invalid id")
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
existing, err := s.DB.GetNFSExport(id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
req, err := decodeNFSExportRequest(r.Body)
|
req, err := decodeNFSExportRequest(r.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
@@ -109,7 +141,11 @@ func (s *Server) handleUpdateNFSExport(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusNotFound, err.Error())
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
@@ -14,6 +17,10 @@ type diskUsage struct {
|
|||||||
FreeBytes uint64 `json:"free_bytes"`
|
FreeBytes uint64 `json:"free_bytes"`
|
||||||
UsedBytes uint64 `json:"used_bytes"`
|
UsedBytes uint64 `json:"used_bytes"`
|
||||||
UsedPercent float64 `json:"used_percent"`
|
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 {
|
type serviceStatus struct {
|
||||||
@@ -24,39 +31,97 @@ type serviceStatus struct {
|
|||||||
|
|
||||||
func (s *Server) handleSystemStatus(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSystemStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
status := map[string]any{
|
status := map[string]any{
|
||||||
"disks": collectDiskUsage(),
|
"disks": s.collectDiskUsage(),
|
||||||
"services": collectServiceStatus(r),
|
"services": s.collectServiceStatus(r),
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, status)
|
writeJSON(w, http.StatusOK, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectDiskUsage() []diskUsage {
|
func (s *Server) collectDiskUsage() []diskUsage {
|
||||||
paths := []string{"/"}
|
entries := make(map[string]*diskUsage)
|
||||||
var usages []diskUsage
|
|
||||||
for _, path := range paths {
|
entries["/"] = &diskUsage{Path: "/", Source: "system", Available: true}
|
||||||
var stat syscall.Statfs_t
|
|
||||||
if err := syscall.Statfs(path, &stat); err != nil {
|
shares, err := s.DB.ListSambaShares()
|
||||||
continue
|
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectServiceStatus(r *http.Request) []serviceStatus {
|
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 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"}
|
services := []string{"smbd", "nfs-server"}
|
||||||
var statuses []serviceStatus
|
var statuses []serviceStatus
|
||||||
for _, name := range services {
|
for _, name := range services {
|
||||||
|
|||||||
+10
-1
@@ -13,7 +13,12 @@ export interface NFSExport {
|
|||||||
id: number;
|
id: number;
|
||||||
path: string;
|
path: string;
|
||||||
clients: string[];
|
clients: string[];
|
||||||
options: string;
|
read_only: boolean;
|
||||||
|
async: boolean;
|
||||||
|
root_squash: boolean;
|
||||||
|
subtree_check: boolean;
|
||||||
|
fsid: number;
|
||||||
|
advanced: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
@@ -43,6 +48,10 @@ export interface DiskUsage {
|
|||||||
free_bytes: number;
|
free_bytes: number;
|
||||||
used_bytes: number;
|
used_bytes: number;
|
||||||
used_percent: number;
|
used_percent: number;
|
||||||
|
source: "system" | "mount" | "samba" | "nfs";
|
||||||
|
used_by?: string[];
|
||||||
|
available: boolean;
|
||||||
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServiceStatus {
|
export interface ServiceStatus {
|
||||||
|
|||||||
+127
-16
@@ -6,12 +6,34 @@ import Modal from "../components/Modal";
|
|||||||
const empty: Partial<NFSExport> = {
|
const empty: Partial<NFSExport> = {
|
||||||
path: "",
|
path: "",
|
||||||
clients: [],
|
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<string, boolean> {
|
||||||
|
try { return JSON.parse(raw || "{}"); } catch { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeAdvanced(m: Record<string, boolean>): string {
|
||||||
|
return JSON.stringify(m);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Nfs() {
|
export default function Nfs() {
|
||||||
const [exports, setExports] = useState<NFSExport[]>([]);
|
const [exports, setExports] = useState<NFSExport[]>([]);
|
||||||
const [editing, setEditing] = useState<Partial<NFSExport> | null>(null);
|
const [editing, setEditing] = useState<Partial<NFSExport> | null>(null);
|
||||||
|
const [advanced, setAdvanced] = useState<Record<string, boolean>>({});
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const { refresh } = useDirty();
|
const { refresh } = useDirty();
|
||||||
|
|
||||||
@@ -24,15 +46,31 @@ export default function Nfs() {
|
|||||||
load();
|
load();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
function openEdit(x: Partial<NFSExport>) {
|
||||||
|
setAdvanced(parseAdvanced(x.advanced ?? "{}"));
|
||||||
|
setShowAdvanced(false);
|
||||||
|
setEditing(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNew() {
|
||||||
|
setAdvanced({});
|
||||||
|
setShowAdvanced(false);
|
||||||
|
setEditing({ ...empty });
|
||||||
|
}
|
||||||
|
|
||||||
async function save(e: FormEvent) {
|
async function save(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!editing) return;
|
if (!editing) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
|
const payload = {
|
||||||
|
...editing,
|
||||||
|
advanced: serializeAdvanced(advanced),
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
if (editing.id) {
|
if (editing.id) {
|
||||||
await api.updateExport(editing.id, editing);
|
await api.updateExport(editing.id, payload);
|
||||||
} else {
|
} else {
|
||||||
await api.createExport(editing);
|
await api.createExport(payload);
|
||||||
}
|
}
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
await load();
|
await load();
|
||||||
@@ -49,11 +87,24 @@ export default function Nfs() {
|
|||||||
await refresh();
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold text-white">Exports NFS</h1>
|
<h1 className="text-2xl font-bold text-white">Exports NFS</h1>
|
||||||
<button className="btn-primary" onClick={() => setEditing({ ...empty })}>
|
<button className="btn-primary" onClick={openNew}>
|
||||||
Nuevo export
|
Nuevo export
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -64,7 +115,8 @@ export default function Nfs() {
|
|||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3">Path</th>
|
<th className="px-4 py-3">Path</th>
|
||||||
<th className="px-4 py-3">Clientes</th>
|
<th className="px-4 py-3">Clientes</th>
|
||||||
<th className="px-4 py-3">Opciones</th>
|
<th className="px-4 py-3">Flags</th>
|
||||||
|
<th className="px-4 py-3">FSID</th>
|
||||||
<th className="px-4 py-3"></th>
|
<th className="px-4 py-3"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -73,9 +125,14 @@ export default function Nfs() {
|
|||||||
<tr key={x.id} className="border-b border-slate-800/60">
|
<tr key={x.id} className="border-b border-slate-800/60">
|
||||||
<td className="px-4 py-3 font-medium text-slate-100">{x.path}</td>
|
<td className="px-4 py-3 font-medium text-slate-100">{x.path}</td>
|
||||||
<td className="px-4 py-3 text-slate-300">{x.clients.join(", ") || "*"}</td>
|
<td className="px-4 py-3 text-slate-300">{x.clients.join(", ") || "*"}</td>
|
||||||
<td className="px-4 py-3 text-slate-400">{x.options}</td>
|
<td className="px-4 py-3 text-slate-400 text-xs font-mono">{flagsSummary(x)}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="inline-flex items-center rounded bg-slate-700 px-2 py-0.5 text-xs font-mono text-slate-300">
|
||||||
|
{x.fsid}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
<button className="btn-ghost mr-2" onClick={() => setEditing({ ...x })}>
|
<button className="btn-ghost mr-2" onClick={() => openEdit({ ...x })}>
|
||||||
Editar
|
Editar
|
||||||
</button>
|
</button>
|
||||||
<button className="btn-danger" onClick={() => remove(x.id)}>
|
<button className="btn-danger" onClick={() => remove(x.id)}>
|
||||||
@@ -86,7 +143,7 @@ export default function Nfs() {
|
|||||||
))}
|
))}
|
||||||
{exports.length === 0 && (
|
{exports.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={4} className="px-4 py-6 text-center text-slate-500">
|
<td colSpan={5} className="px-4 py-6 text-center text-slate-500">
|
||||||
No hay exports configurados.
|
No hay exports configurados.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -126,17 +183,71 @@ export default function Nfs() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label className="label">Opciones</label>
|
<div className="space-y-2">
|
||||||
|
<span className="label">Opciones</span>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||||
<input
|
<input
|
||||||
className="input"
|
type="checkbox"
|
||||||
value={editing.options ?? ""}
|
className="checkbox"
|
||||||
onChange={(e) => setEditing({ ...editing, options: e.target.value })}
|
checked={!!editing.read_only}
|
||||||
|
onChange={(e) => setEditing({ ...editing, read_only: e.target.checked })}
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-slate-500">
|
Read-only
|
||||||
Ej: rw,sync,no_root_squash · ro,async,root_squash
|
</label>
|
||||||
</p>
|
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox"
|
||||||
|
checked={!!editing.async}
|
||||||
|
onChange={(e) => setEditing({ ...editing, async: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Async
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox"
|
||||||
|
checked={!!editing.subtree_check}
|
||||||
|
onChange={(e) => setEditing({ ...editing, subtree_check: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Subtree check
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox"
|
||||||
|
checked={!!editing.root_squash}
|
||||||
|
onChange={(e) => setEditing({ ...editing, root_squash: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Root squash
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details className="group" open={showAdvanced}>
|
||||||
|
<summary
|
||||||
|
className="cursor-pointer text-sm text-slate-400 hover:text-slate-200"
|
||||||
|
onClick={(e) => { e.preventDefault(); setShowAdvanced(v => !v); }}
|
||||||
|
>
|
||||||
|
{showAdvanced ? "▾" : "▸"} Opciones avanzadas
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||||
|
{ADVANCED_KEYS.map(({ key, label }) => (
|
||||||
|
<label key={key} className="flex items-center gap-2 text-sm text-slate-300">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox"
|
||||||
|
checked={!!advanced[key]}
|
||||||
|
onChange={() => toggleAdvanced(key)}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<button type="button" className="btn-ghost" onClick={() => setEditing(null)}>
|
<button type="button" className="btn-ghost" onClick={() => setEditing(null)}>
|
||||||
Cancelar
|
Cancelar
|
||||||
|
|||||||
@@ -1,5 +1,58 @@
|
|||||||
import { FormEvent, useState } from "react";
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
import { api } from "../api";
|
import { api, formatBytes, SystemStatus } from "../api";
|
||||||
|
|
||||||
|
const SOURCE_COLORS: Record<string, string> = {
|
||||||
|
system: "bg-blue-500/20 text-blue-300",
|
||||||
|
mount: "bg-purple-500/20 text-purple-300",
|
||||||
|
samba: "bg-emerald-500/20 text-emerald-300",
|
||||||
|
nfs: "bg-amber-500/20 text-amber-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SOURCE_LABELS: Record<string, string> = {
|
||||||
|
system: "Sistema",
|
||||||
|
mount: "Mount",
|
||||||
|
samba: "SMB",
|
||||||
|
nfs: "NFS",
|
||||||
|
};
|
||||||
|
|
||||||
|
function DiskUsageItem({ disk }: { disk: SystemStatus["disks"][number] }) {
|
||||||
|
return (
|
||||||
|
<div className="mb-4 last:mb-0">
|
||||||
|
<div className="mb-1 flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-sm">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-slate-300">{disk.path}</span>
|
||||||
|
<span className={`rounded-full px-2 py-0.5 text-xs ${SOURCE_COLORS[disk.source] ?? "bg-slate-700 text-slate-300"}`}>
|
||||||
|
{SOURCE_LABELS[disk.source] ?? disk.source}
|
||||||
|
</span>
|
||||||
|
{disk.used_by && disk.used_by.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{disk.used_by.map((name) => (
|
||||||
|
<span key={name} className="rounded bg-slate-700 px-1.5 py-0.5 text-xs text-slate-300">
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{disk.available ? (
|
||||||
|
<span className="text-slate-400">
|
||||||
|
{formatBytes(disk.used_bytes)} / {formatBytes(disk.total_bytes)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-amber-400 text-xs">⚠ {disk.error}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{disk.available && (
|
||||||
|
<div className="h-2 overflow-hidden rounded bg-slate-800">
|
||||||
|
<div
|
||||||
|
className="h-full bg-brand-500"
|
||||||
|
style={{ width: `${Math.min(disk.used_percent, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const [oldPassword, setOldPassword] = useState("");
|
const [oldPassword, setOldPassword] = useState("");
|
||||||
@@ -8,6 +61,11 @@ export default function Settings() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [status, setStatus] = useState<SystemStatus | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.systemStatus().then(setStatus).catch(() => setStatus(null));
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function handleSubmit(e: FormEvent) {
|
async function handleSubmit(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -37,6 +95,9 @@ export default function Settings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const systemDisks = status?.disks.filter((d) => d.source === "system" || d.source === "mount") ?? [];
|
||||||
|
const shareDisks = status?.disks.filter((d) => d.source === "samba" || d.source === "nfs") ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h1 className="text-2xl font-bold text-white">Ajustes</h1>
|
<h1 className="text-2xl font-bold text-white">Ajustes</h1>
|
||||||
@@ -92,6 +153,30 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-5 md:grid-cols-2">
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wide text-slate-400">
|
||||||
|
Puntos de montaje
|
||||||
|
</h2>
|
||||||
|
{systemDisks.length ? (
|
||||||
|
systemDisks.map((d) => <DiskUsageItem key={d.path} disk={d} />)
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-slate-500">Sin datos de montaje.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wide text-slate-400">
|
||||||
|
Recursos compartidos
|
||||||
|
</h2>
|
||||||
|
{shareDisks.length ? (
|
||||||
|
shareDisks.map((d) => <DiskUsageItem key={d.path} disk={d} />)
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-slate-500">Sin shares ni exports configurados.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user