Add nasctl: Go NAS control plane with React frontend
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { api, SambaShare } from "../api";
|
||||
import { useDirty } from "../DirtyContext";
|
||||
import Modal from "../components/Modal";
|
||||
|
||||
const empty: Partial<SambaShare> = {
|
||||
name: "",
|
||||
path: "",
|
||||
comment: "",
|
||||
read_only: false,
|
||||
guest_ok: false,
|
||||
valid_users: [],
|
||||
valid_groups: [],
|
||||
};
|
||||
|
||||
export default function Samba() {
|
||||
const [shares, setShares] = useState<SambaShare[]>([]);
|
||||
const [editing, setEditing] = useState<Partial<SambaShare> | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { refresh } = useDirty();
|
||||
|
||||
async function load() {
|
||||
const res = await api.listShares();
|
||||
setShares(res.shares ?? []);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
async function save(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!editing) return;
|
||||
setError(null);
|
||||
try {
|
||||
if (editing.id) {
|
||||
await api.updateShare(editing.id, editing);
|
||||
} else {
|
||||
await api.createShare(editing);
|
||||
}
|
||||
setEditing(null);
|
||||
await load();
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Error al guardar");
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
if (!confirm("¿Eliminar este share?")) return;
|
||||
await api.deleteShare(id);
|
||||
await load();
|
||||
await refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-white">Shares SMB / Samba</h1>
|
||||
<button className="btn-primary" onClick={() => setEditing({ ...empty })}>
|
||||
Nuevo share
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card overflow-x-auto p-0">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-slate-800 text-slate-400">
|
||||
<tr>
|
||||
<th className="px-4 py-3">Nombre</th>
|
||||
<th className="px-4 py-3">Path</th>
|
||||
<th className="px-4 py-3">Modo</th>
|
||||
<th className="px-4 py-3">Invitado</th>
|
||||
<th className="px-4 py-3">Usuarios</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shares.map((s) => (
|
||||
<tr key={s.id} className="border-b border-slate-800/60">
|
||||
<td className="px-4 py-3 font-medium text-slate-100">{s.name}</td>
|
||||
<td className="px-4 py-3 text-slate-300">{s.path}</td>
|
||||
<td className="px-4 py-3">{s.read_only ? "Solo lectura" : "Lectura/Escritura"}</td>
|
||||
<td className="px-4 py-3">{s.guest_ok ? "Sí" : "No"}</td>
|
||||
<td className="px-4 py-3 text-slate-400">{s.valid_users.join(", ") || "—"}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button className="btn-ghost mr-2" onClick={() => setEditing({ ...s })}>
|
||||
Editar
|
||||
</button>
|
||||
<button className="btn-danger" onClick={() => remove(s.id)}>
|
||||
Eliminar
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{shares.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-6 text-center text-slate-500">
|
||||
No hay shares configurados.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<Modal title={editing.id ? "Editar share" : "Nuevo share"} onClose={() => setEditing(null)}>
|
||||
<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">Nombre</label>
|
||||
<input
|
||||
className="input"
|
||||
value={editing.name ?? ""}
|
||||
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Path (absoluto)</label>
|
||||
<input
|
||||
className="input"
|
||||
value={editing.path ?? ""}
|
||||
onChange={(e) => setEditing({ ...editing, path: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Comentario</label>
|
||||
<input
|
||||
className="input"
|
||||
value={editing.comment ?? ""}
|
||||
onChange={(e) => setEditing({ ...editing, comment: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Usuarios válidos (separados por coma)</label>
|
||||
<input
|
||||
className="input"
|
||||
value={(editing.valid_users ?? []).join(", ")}
|
||||
onChange={(e) =>
|
||||
setEditing({
|
||||
...editing,
|
||||
valid_users: e.target.value
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-6">
|
||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editing.read_only ?? false}
|
||||
onChange={(e) => setEditing({ ...editing, read_only: e.target.checked })}
|
||||
/>
|
||||
Solo lectura
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editing.guest_ok ?? false}
|
||||
onChange={(e) => setEditing({ ...editing, guest_ok: e.target.checked })}
|
||||
/>
|
||||
Permitir invitado
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button type="button" className="btn-ghost" onClick={() => setEditing(null)}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button className="btn-primary">Guardar</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user