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:
+10
-1
@@ -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 {
|
||||
|
||||
+129
-18
@@ -6,12 +6,34 @@ import Modal from "../components/Modal";
|
||||
const empty: Partial<NFSExport> = {
|
||||
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<string, boolean> {
|
||||
try { return JSON.parse(raw || "{}"); } catch { return {}; }
|
||||
}
|
||||
|
||||
function serializeAdvanced(m: Record<string, boolean>): string {
|
||||
return JSON.stringify(m);
|
||||
}
|
||||
|
||||
export default function Nfs() {
|
||||
const [exports, setExports] = useState<NFSExport[]>([]);
|
||||
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 { refresh } = useDirty();
|
||||
|
||||
@@ -24,15 +46,31 @@ export default function Nfs() {
|
||||
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) {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
@@ -64,7 +115,8 @@ 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">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>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -73,9 +125,14 @@ export default function Nfs() {
|
||||
<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">{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">
|
||||
<button className="btn-ghost mr-2" onClick={() => setEditing({ ...x })}>
|
||||
<button className="btn-ghost mr-2" onClick={() => openEdit({ ...x })}>
|
||||
Editar
|
||||
</button>
|
||||
<button className="btn-danger" onClick={() => remove(x.id)}>
|
||||
@@ -86,7 +143,7 @@ export default function Nfs() {
|
||||
))}
|
||||
{exports.length === 0 && (
|
||||
<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.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -126,17 +183,71 @@ export default function Nfs() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Opciones</label>
|
||||
<input
|
||||
className="input"
|
||||
value={editing.options ?? ""}
|
||||
onChange={(e) => setEditing({ ...editing, options: e.target.value })}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Ej: rw,sync,no_root_squash · ro,async,root_squash
|
||||
</p>
|
||||
|
||||
<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
|
||||
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 })}
|
||||
/>
|
||||
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>
|
||||
|
||||
<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">
|
||||
<button type="button" className="btn-ghost" onClick={() => setEditing(null)}>
|
||||
Cancelar
|
||||
|
||||
@@ -1,5 +1,58 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
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() {
|
||||
const [oldPassword, setOldPassword] = useState("");
|
||||
@@ -8,6 +61,11 @@ export default function Settings() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = 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) {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-white">Ajustes</h1>
|
||||
@@ -92,6 +153,30 @@ export default function Settings() {
|
||||
</div>
|
||||
</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">
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user