feat(nfs): per-host NFS options (IP/CIDR with own ro/async/squash flags)
This is a backward-compatible MINOR bump (0.4.0 → 0.5.0).
BREAKING NOTES (for users upgrading from pre-0.5.0):
- The nfs_exports.clients column schema changed from []string to
[]NFSClient (per-host options). A migration (0005) transforms existing
string arrays into object arrays, taking export-level options as
defaults for each host.
- ValidateNFSClient now only accepts IPv4 (192.168.1.1) or IPv4/CIDR
(192.168.1.0/24). Hostnames, wildcards, netgroups are rejected.
- If you use NASCTL_IMPORT_ON_BOOT, re-import your /etc/exports to pick
up per-host options.
What changed:
- NFSClient type: {host, read_only, async, root_squash, subtree_check, advanced}
- NFSExport.Clients is now []NFSClient (was []string)
- export-level flags (ro/async/root_squash/subtree_check/advanced) are
preserved as template defaults for newly added hosts in the UI.
- buildExportLine generates: path host1(ro,sync,...) host2(rw,async,...) fsid=N
- ValidateNFSClient: strict IPv4/CIDR only (0-255 octets, /0-32 prefix)
- parseExportLine now parses per-host options from /etc/exports (previously
only the first host's options were kept, others were discarded)
- UI: per-host rows with toggles (ro/async/root_squash/subtree_check) and
advanced options (all_squash, secure, wdelay, hide, crossmnt)
This commit is contained in:
+10
-1
@@ -9,10 +9,19 @@ export interface SambaShare {
|
||||
valid_groups: string[];
|
||||
}
|
||||
|
||||
export interface NFSClient {
|
||||
host: string;
|
||||
read_only: boolean;
|
||||
async: boolean;
|
||||
root_squash: boolean;
|
||||
subtree_check: boolean;
|
||||
advanced: string;
|
||||
}
|
||||
|
||||
export interface NFSExport {
|
||||
id: number;
|
||||
path: string;
|
||||
clients: string[];
|
||||
clients: NFSClient[];
|
||||
read_only: boolean;
|
||||
async: boolean;
|
||||
root_squash: boolean;
|
||||
|
||||
+140
-66
@@ -1,10 +1,10 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { api, NFSExport } from "../api";
|
||||
import { api, NFSExport, NFSClient } from "../api";
|
||||
import { useDirty } from "../DirtyContext";
|
||||
import Modal from "../components/Modal";
|
||||
import PathField from "../components/PathField";
|
||||
|
||||
const empty: Partial<NFSExport> = {
|
||||
const DEFAULT_EXPORT = {
|
||||
path: "",
|
||||
clients: [],
|
||||
read_only: false,
|
||||
@@ -30,9 +30,30 @@ function serializeAdvanced(m: Record<string, boolean>): string {
|
||||
return JSON.stringify(m);
|
||||
}
|
||||
|
||||
function clientDefaults(exp: Partial<NFSExport>): Partial<NFSClient> {
|
||||
return {
|
||||
host: "",
|
||||
read_only: exp.read_only ?? false,
|
||||
async: exp.async ?? false,
|
||||
root_squash: exp.root_squash ?? true,
|
||||
subtree_check: exp.subtree_check ?? false,
|
||||
advanced: exp.advanced ?? "{}",
|
||||
};
|
||||
}
|
||||
|
||||
function exportFlagsSummary(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(",");
|
||||
}
|
||||
|
||||
export default function Nfs() {
|
||||
const [exports, setExports] = useState<NFSExport[]>([]);
|
||||
const [editing, setEditing] = useState<Partial<NFSExport> | null>(null);
|
||||
const [hostDrafts, setHostDrafts] = useState<NFSClient[]>([]);
|
||||
const [advanced, setAdvanced] = useState<Record<string, boolean>>({});
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -47,26 +68,34 @@ export default function Nfs() {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
function openEdit(x: Partial<NFSExport>) {
|
||||
function openEdit(x: NFSExport) {
|
||||
setAdvanced(parseAdvanced(x.advanced ?? "{}"));
|
||||
setShowAdvanced(false);
|
||||
setEditing(x);
|
||||
setHostDrafts(x.clients.map(c => ({ ...c })));
|
||||
}
|
||||
|
||||
function openNew() {
|
||||
setAdvanced({});
|
||||
setShowAdvanced(false);
|
||||
setEditing({ ...empty });
|
||||
setEditing({ ...DEFAULT_EXPORT });
|
||||
setHostDrafts([]);
|
||||
}
|
||||
|
||||
async function save(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!editing) return;
|
||||
setError(null);
|
||||
|
||||
const payload = {
|
||||
...editing,
|
||||
clients: hostDrafts.map(h => ({
|
||||
...h,
|
||||
advanced: h.advanced !== undefined ? h.advanced : serializeAdvanced(advanced),
|
||||
})),
|
||||
advanced: serializeAdvanced(advanced),
|
||||
};
|
||||
|
||||
try {
|
||||
if (editing.id) {
|
||||
await api.updateExport(editing.id, payload);
|
||||
@@ -74,6 +103,7 @@ export default function Nfs() {
|
||||
await api.createExport(payload);
|
||||
}
|
||||
setEditing(null);
|
||||
setHostDrafts([]);
|
||||
await load();
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
@@ -92,13 +122,26 @@ export default function Nfs() {
|
||||
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(",");
|
||||
function addHost() {
|
||||
const defaults = clientDefaults(editing ?? {});
|
||||
setHostDrafts(prev => [...prev, defaults as NFSClient]);
|
||||
}
|
||||
|
||||
function removeHost(idx: number) {
|
||||
setHostDrafts(prev => prev.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
function updateHost(idx: number, field: keyof NFSClient, value: string | boolean) {
|
||||
setHostDrafts(prev => prev.map((h, i) => i === idx ? { ...h, [field]: value } : h));
|
||||
}
|
||||
|
||||
function toggleHostAdvanced(idx: number, key: string) {
|
||||
setHostDrafts(prev => prev.map((h, i) => {
|
||||
if (i !== idx) return h;
|
||||
const adv = parseAdvanced(h.advanced || "{}");
|
||||
adv[key] = !adv[key];
|
||||
return { ...h, advanced: serializeAdvanced(adv) };
|
||||
}));
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -116,7 +159,7 @@ export default function Nfs() {
|
||||
<tr>
|
||||
<th className="px-4 py-3">Path</th>
|
||||
<th className="px-4 py-3">Clientes</th>
|
||||
<th className="px-4 py-3">Flags</th>
|
||||
<th className="px-4 py-3">Flags (export)</th>
|
||||
<th className="px-4 py-3">FSID</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
@@ -125,8 +168,12 @@ export default function Nfs() {
|
||||
{exports.map((x) => (
|
||||
<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 text-slate-300">{x.clients.join(", ") || "*"}</td>
|
||||
<td className="px-4 py-3 text-slate-400 text-xs font-mono">{flagsSummary(x)}</td>
|
||||
<td className="px-4 py-3 text-slate-300">
|
||||
{x.clients.length === 0
|
||||
? "*"
|
||||
: x.clients.map(c => c.host).join(", ")}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-400 text-xs font-mono">{exportFlagsSummary(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}
|
||||
@@ -154,11 +201,12 @@ export default function Nfs() {
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<Modal title={editing.id ? "Editar export" : "Nuevo export"} onClose={() => setEditing(null)}>
|
||||
<Modal title={editing.id ? "Editar export" : "Nuevo export"} onClose={() => { setEditing(null); setHostDrafts([]); }}>
|
||||
<form onSubmit={save} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-500/15 px-3 py-2 text-sm text-red-200">{error}</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="label">Path (absoluto)</label>
|
||||
<PathField
|
||||
@@ -166,62 +214,93 @@ export default function Nfs() {
|
||||
onChange={p => setEditing({ ...editing, path: p })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Clientes / redes (separados por coma)</label>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="192.168.1.0/24, 10.0.0.5"
|
||||
value={(editing.clients ?? []).join(", ")}
|
||||
onChange={(e) =>
|
||||
setEditing({
|
||||
...editing,
|
||||
clients: e.target.value
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="label">Hosts</span>
|
||||
<button type="button" className="text-xs text-indigo-400 hover:text-indigo-300" onClick={addHost}>
|
||||
+ Añadir host
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{hostDrafts.length === 0 && (
|
||||
<p className="text-sm text-slate-500 py-2">Sin hosts — usa "Añadir host" para agregar.</p>
|
||||
)}
|
||||
{hostDrafts.map((c, idx) => (
|
||||
<div key={idx} className="rounded border border-slate-700 p-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
className="input flex-1"
|
||||
placeholder="192.168.1.20 o 192.168.1.0/24"
|
||||
value={c.host}
|
||||
onChange={e => updateHost(idx, "host", e.target.value)}
|
||||
/>
|
||||
<button type="button" className="btn-ghost text-red-400 text-xs px-2" onClick={() => removeHost(idx)}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
|
||||
<label className="flex items-center gap-1.5 text-xs text-slate-300">
|
||||
<input type="checkbox" className="checkbox" checked={c.read_only} onChange={e => updateHost(idx, "read_only", e.target.checked)} />
|
||||
Read-only
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-xs text-slate-300">
|
||||
<input type="checkbox" className="checkbox" checked={c.async} onChange={e => updateHost(idx, "async", e.target.checked)} />
|
||||
Async
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-xs text-slate-300">
|
||||
<input type="checkbox" className="checkbox" checked={c.root_squash} onChange={e => updateHost(idx, "root_squash", e.target.checked)} />
|
||||
Root squash
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-xs text-slate-300">
|
||||
<input type="checkbox" className="checkbox" checked={c.subtree_check} onChange={e => updateHost(idx, "subtree_check", e.target.checked)} />
|
||||
Subtree check
|
||||
</label>
|
||||
</div>
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer text-xs text-slate-500 hover:text-slate-300">
|
||||
{showAdvanced ? "▾" : "▸"} Avanzado
|
||||
</summary>
|
||||
<div className="mt-1 grid grid-cols-2 gap-y-1">
|
||||
{ADVANCED_KEYS.map(({ key, label }) => {
|
||||
const adv = parseAdvanced(c.advanced || "{}");
|
||||
return (
|
||||
<label key={key} className="flex items-center gap-1.5 text-xs text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox"
|
||||
checked={!!adv[key]}
|
||||
onChange={() => toggleHostAdvanced(idx, key)}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<span className="label">Opciones</span>
|
||||
<span className="label">Opciones por defecto (plantilla para hosts nuevos)</span>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox"
|
||||
checked={!!editing.read_only}
|
||||
onChange={(e) => setEditing({ ...editing, read_only: e.target.checked })}
|
||||
/>
|
||||
<input type="checkbox" className="checkbox" checked={!!editing.read_only} onChange={e => setEditing({ ...editing, read_only: e.target.checked })} />
|
||||
Read-only
|
||||
</label>
|
||||
<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 })}
|
||||
/>
|
||||
<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
|
||||
<input type="checkbox" className="checkbox" checked={!!editing.root_squash} onChange={e => setEditing({ ...editing, root_squash: e.target.checked })} />
|
||||
Root squash
|
||||
</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
|
||||
<input type="checkbox" className="checkbox" checked={!!editing.subtree_check} onChange={e => setEditing({ ...editing, subtree_check: e.target.checked })} />
|
||||
Subtree check
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -229,19 +308,14 @@ export default function Nfs() {
|
||||
<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); }}
|
||||
onClick={e => { e.preventDefault(); setShowAdvanced(v => !v); }}
|
||||
>
|
||||
{showAdvanced ? "▾" : "▸"} Opciones avanzadas
|
||||
{showAdvanced ? "▾" : "▸"} Opciones avanzadas (plantilla)
|
||||
</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)}
|
||||
/>
|
||||
<input type="checkbox" className="checkbox" checked={!!advanced[key]} onChange={() => toggleAdvanced(key)} />
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
@@ -249,7 +323,7 @@ export default function Nfs() {
|
||||
</details>
|
||||
|
||||
<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); setHostDrafts([]); }}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button className="btn-primary">Guardar</button>
|
||||
|
||||
Reference in New Issue
Block a user