feat: structured NFS options with auto-fsid and manual watched mounts
NFS exports: replace plain-text options string with typed booleans (read_only, async, root_squash, subtree_check) + advanced JSON blob. fsid is auto-generated via crypto/rand with collision retry and is never user-settable. Breaking API change (options field removed). Dashboard: filter disks to manual+samba+nfs sources only; no more auto-discovery of all /proc/mounts entries. Settings: new 'Puntos de montaje vigilados' card with add/remove for manual mount points. AllowedRoots validation applied. Version bump: 0.1.10 -> 0.2.0
This commit is contained in:
+11
-1
@@ -48,12 +48,17 @@ export interface DiskUsage {
|
||||
free_bytes: number;
|
||||
used_bytes: number;
|
||||
used_percent: number;
|
||||
source: "system" | "mount" | "samba" | "nfs";
|
||||
source: "system" | "mount" | "samba" | "nfs" | "manual";
|
||||
used_by?: string[];
|
||||
available: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface WatchedMount {
|
||||
id: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface ServiceStatus {
|
||||
name: string;
|
||||
active: boolean;
|
||||
@@ -128,6 +133,11 @@ export const api = {
|
||||
createUser: (u: Partial<User> & { password?: string }) => request<User>("POST", "/users/", u),
|
||||
updateUser: (id: number, u: Partial<User> & { password?: string }) => request<User>("PUT", `/users/${id}/`, u),
|
||||
deleteUser: (id: number) => request<void>("DELETE", `/users/${id}/`),
|
||||
|
||||
// watched mounts
|
||||
listWatchedMounts: () => request<{ mounts: WatchedMount[] }>("GET", "/system/watched-mounts"),
|
||||
createWatchedMount: (path: string) => request<WatchedMount>("POST", "/system/watched-mounts", { path }),
|
||||
deleteWatchedMount: (id: number) => request<void>("DELETE", `/system/watched-mounts/${id}`),
|
||||
};
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
|
||||
@@ -10,6 +10,10 @@ export default function Dashboard() {
|
||||
api.systemStatus().then(setStatus).catch(() => setStatus(null));
|
||||
}, []);
|
||||
|
||||
const visibleDisks = status?.disks.filter(
|
||||
(d) => d.source === "manual" || d.source === "samba" || d.source === "nfs"
|
||||
) ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
|
||||
@@ -19,9 +23,9 @@ export default function Dashboard() {
|
||||
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wide text-slate-400">
|
||||
Uso de disco
|
||||
</h2>
|
||||
{status?.disks?.length ? (
|
||||
{visibleDisks.length ? (
|
||||
<div className="space-y-4">
|
||||
{status.disks.map((d) => (
|
||||
{visibleDisks.map((d) => (
|
||||
<div key={d.path}>
|
||||
<div className="mb-1 flex justify-between text-sm">
|
||||
<span className="text-slate-300">{d.path}</span>
|
||||
@@ -39,7 +43,9 @@ export default function Dashboard() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">Sin datos de disco.</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
Sin puntos de montaje vigilados. Añádelos en Ajustes.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
+93
-30
@@ -1,11 +1,12 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { api, formatBytes, SystemStatus } from "../api";
|
||||
import { api, formatBytes, SystemStatus, WatchedMount } 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",
|
||||
nfs: "bg-amber-500/20 text-amber-300",
|
||||
manual: "bg-cyan-500/20 text-cyan-300",
|
||||
};
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
@@ -13,6 +14,7 @@ const SOURCE_LABELS: Record<string, string> = {
|
||||
mount: "Mount",
|
||||
samba: "SMB",
|
||||
nfs: "NFS",
|
||||
manual: "Vigilado",
|
||||
};
|
||||
|
||||
function DiskUsageItem({ disk }: { disk: SystemStatus["disks"][number] }) {
|
||||
@@ -57,14 +59,19 @@ function DiskUsageItem({ disk }: { disk: SystemStatus["disks"][number] }) {
|
||||
export default function Settings() {
|
||||
const [oldPassword, setOldPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [status, setStatus] = useState<SystemStatus | null>(null);
|
||||
const [watched, setWatched] = useState<WatchedMount[]>([]);
|
||||
const [newPath, setNewPath] = useState("");
|
||||
const [watchError, setWatchError] = useState<string | null>(null);
|
||||
const [watchLoading, setWatchLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.systemStatus().then(setStatus).catch(() => setStatus(null));
|
||||
api.listWatchedMounts().then(r => setWatched(r.mounts ?? [])).catch(() => setWatched([]));
|
||||
}, []);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
@@ -76,7 +83,7 @@ export default function Settings() {
|
||||
setError("La nueva contraseña debe tener al menos 8 caracteres.");
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirm) {
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError("La nueva contraseña y la confirmación no coinciden.");
|
||||
return;
|
||||
}
|
||||
@@ -87,7 +94,7 @@ export default function Settings() {
|
||||
setSuccess(true);
|
||||
setOldPassword("");
|
||||
setNewPassword("");
|
||||
setConfirm("");
|
||||
setConfirmPassword("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Error al cambiar la contraseña.");
|
||||
} finally {
|
||||
@@ -95,8 +102,31 @@ 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") ?? [];
|
||||
async function addWatched(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setWatchError(null);
|
||||
const path = newPath.trim();
|
||||
if (!path) return;
|
||||
setWatchLoading(true);
|
||||
try {
|
||||
const m = await api.createWatchedMount(path);
|
||||
setWatched(prev => [...prev, m].sort((a, b) => a.path.localeCompare(b.path)));
|
||||
setNewPath("");
|
||||
} catch (err) {
|
||||
setWatchError(err instanceof Error ? err.message : "Error al añadir");
|
||||
} finally {
|
||||
setWatchLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeWatched(id: number) {
|
||||
if (!confirm("¿Eliminar este punto de montaje vigilado?")) return;
|
||||
await api.deleteWatchedMount(id);
|
||||
setWatched(prev => prev.filter(m => m.id !== id));
|
||||
}
|
||||
|
||||
const manualDisks = status?.disks.filter(d => d.source === "manual") ?? [];
|
||||
const shareDisks = status?.disks.filter(d => d.source === "samba" || d.source === "nfs") ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -141,8 +171,8 @@ export default function Settings() {
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
@@ -154,28 +184,61 @@ export default function Settings() {
|
||||
</form>
|
||||
</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">
|
||||
Puntos de montaje vigilados
|
||||
</h2>
|
||||
|
||||
<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>
|
||||
<form onSubmit={addWatched} className="mb-4 flex gap-2">
|
||||
<input
|
||||
className="input flex-1"
|
||||
placeholder="/mnt/data"
|
||||
value={newPath}
|
||||
onChange={(e) => setNewPath(e.target.value)}
|
||||
/>
|
||||
<button className="btn-primary" type="submit" disabled={watchLoading}>
|
||||
{watchLoading ? "..." : "Añadir"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{watchError && (
|
||||
<div className="mb-3 rounded-md bg-red-500/15 px-3 py-2 text-sm text-red-200">{watchError}</div>
|
||||
)}
|
||||
|
||||
{watched.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{watched.map((m) => (
|
||||
<div key={m.id} className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-300">{m.path}</span>
|
||||
<button
|
||||
className="btn-ghost text-xs text-slate-400 hover:text-red-300"
|
||||
onClick={() => removeWatched(m.id)}
|
||||
>
|
||||
Quitar
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">Sin puntos de montaje vigilados.</p>
|
||||
)}
|
||||
|
||||
{manualDisks.length > 0 && (
|
||||
<div className="mt-4 border-t border-slate-800 pt-4">
|
||||
{manualDisks.map((d) => <DiskUsageItem key={d.path} disk={d} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wide text-slate-400">
|
||||
Recursos compartidos
|
||||
</h2>
|
||||
{shareDisks.length > 0 ? (
|
||||
shareDisks.map((d) => <DiskUsageItem key={d.path} disk={d} />)
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">Sin shares ni exports configurados.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user