Add nasctl: Go NAS control plane with React frontend
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>nasctl</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2538
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "nasctl-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"typescript": "^5.6.2",
|
||||
"vite": "^5.4.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { api } from "./api";
|
||||
import { DirtyProvider } from "./DirtyContext";
|
||||
import Layout from "./components/Layout";
|
||||
import Login from "./pages/Login";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import Users from "./pages/Users";
|
||||
import Samba from "./pages/Samba";
|
||||
import Nfs from "./pages/Nfs";
|
||||
import Log from "./pages/Log";
|
||||
|
||||
type AuthState = { loading: boolean; authenticated: boolean; username: string };
|
||||
|
||||
export default function App() {
|
||||
const [auth, setAuth] = useState<AuthState>({ loading: true, authenticated: false, username: "" });
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.status()
|
||||
.then((res) => setAuth({ loading: false, authenticated: res.authenticated, username: res.username }))
|
||||
.catch(() => setAuth({ loading: false, authenticated: false, username: "" }));
|
||||
}, []);
|
||||
|
||||
if (auth.loading) {
|
||||
return <div className="flex min-h-screen items-center justify-center text-slate-400">Cargando...</div>;
|
||||
}
|
||||
|
||||
if (!auth.authenticated) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
element={<Login onLoggedIn={(username) => setAuth({ loading: false, authenticated: true, username })} />}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DirtyProvider>
|
||||
<Routes>
|
||||
<Route
|
||||
element={
|
||||
<Layout
|
||||
username={auth.username}
|
||||
onLogout={() => setAuth({ loading: false, authenticated: false, username: "" })}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/users" element={<Users />} />
|
||||
<Route path="/samba" element={<Samba />} />
|
||||
<Route path="/nfs" element={<Nfs />} />
|
||||
<Route path="/log" element={<Log />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</DirtyProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { createContext, useContext, useCallback, useEffect, useState, ReactNode } from "react";
|
||||
import { api, DirtyModule } from "./api";
|
||||
|
||||
interface DirtyContextValue {
|
||||
modules: DirtyModule[];
|
||||
refresh: () => Promise<void>;
|
||||
applying: boolean;
|
||||
apply: () => Promise<void>;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const DirtyContext = createContext<DirtyContextValue | undefined>(undefined);
|
||||
|
||||
export function DirtyProvider({ children }: { children: ReactNode }) {
|
||||
const [modules, setModules] = useState<DirtyModule[]>([]);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.dirty();
|
||||
setModules(res.modules ?? []);
|
||||
} catch {
|
||||
// ignore transient errors
|
||||
}
|
||||
}, []);
|
||||
|
||||
const apply = useCallback(async () => {
|
||||
setApplying(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.apply();
|
||||
const failed = res.results.filter((r) => r.error);
|
||||
if (failed.length > 0) {
|
||||
setError(failed.map((f) => `${f.module}: ${f.error}`).join("; "));
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "apply failed");
|
||||
} finally {
|
||||
setApplying(false);
|
||||
await refresh();
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return (
|
||||
<DirtyContext.Provider value={{ modules, refresh, applying, apply, error }}>
|
||||
{children}
|
||||
</DirtyContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDirty() {
|
||||
const ctx = useContext(DirtyContext);
|
||||
if (!ctx) throw new Error("useDirty must be used within DirtyProvider");
|
||||
return ctx;
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
export interface SambaShare {
|
||||
id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
comment: string;
|
||||
read_only: boolean;
|
||||
guest_ok: boolean;
|
||||
valid_users: string[];
|
||||
valid_groups: string[];
|
||||
}
|
||||
|
||||
export interface NFSExport {
|
||||
id: number;
|
||||
path: string;
|
||||
clients: string[];
|
||||
options: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
groups: string[];
|
||||
smb_enabled: boolean;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export interface DirtyModule {
|
||||
module: string;
|
||||
marked_at: string;
|
||||
}
|
||||
|
||||
export interface ApplyLogEntry {
|
||||
id: number;
|
||||
module: string;
|
||||
message: string;
|
||||
success: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface DiskUsage {
|
||||
path: string;
|
||||
total_bytes: number;
|
||||
free_bytes: number;
|
||||
used_bytes: number;
|
||||
used_percent: number;
|
||||
}
|
||||
|
||||
export interface ServiceStatus {
|
||||
name: string;
|
||||
active: boolean;
|
||||
state: string;
|
||||
}
|
||||
|
||||
export interface SystemStatus {
|
||||
disks: DiskUsage[];
|
||||
services: ServiceStatus[];
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(`/api${path}`, {
|
||||
method,
|
||||
headers: body ? { "Content-Type": "application/json" } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (res.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
const text = await res.text();
|
||||
const data = text ? JSON.parse(text) : {};
|
||||
if (!res.ok) {
|
||||
throw new ApiError(res.status, data.error || res.statusText);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// auth
|
||||
status: () => request<{ authenticated: boolean; username: string }>("GET", "/auth/status"),
|
||||
login: (username: string, password: string) =>
|
||||
request<{ username: string }>("POST", "/auth/login", { username, password }),
|
||||
logout: () => request<{ ok: boolean }>("POST", "/auth/logout"),
|
||||
|
||||
// dirty / apply
|
||||
dirty: () => request<{ modules: DirtyModule[] | null }>("GET", "/dirty"),
|
||||
apply: () => request<{ results: { module: string; applied: boolean; error?: string }[] }>("POST", "/apply"),
|
||||
applyLog: () => request<{ entries: ApplyLogEntry[] }>("GET", "/apply/log"),
|
||||
systemStatus: () => request<SystemStatus>("GET", "/system/status"),
|
||||
|
||||
// samba
|
||||
listShares: () => request<{ shares: SambaShare[] | null }>("GET", "/samba/shares/"),
|
||||
createShare: (s: Partial<SambaShare>) => request<SambaShare>("POST", "/samba/shares/", s),
|
||||
updateShare: (id: number, s: Partial<SambaShare>) => request<SambaShare>("PUT", `/samba/shares/${id}/`, s),
|
||||
deleteShare: (id: number) => request<void>("DELETE", `/samba/shares/${id}/`),
|
||||
|
||||
// nfs
|
||||
listExports: () => request<{ exports: NFSExport[] | null }>("GET", "/nfs/exports/"),
|
||||
createExport: (e: Partial<NFSExport>) => request<NFSExport>("POST", "/nfs/exports/", e),
|
||||
updateExport: (id: number, e: Partial<NFSExport>) => request<NFSExport>("PUT", `/nfs/exports/${id}/`, e),
|
||||
deleteExport: (id: number) => request<void>("DELETE", `/nfs/exports/${id}/`),
|
||||
|
||||
// users
|
||||
listUsers: () => request<{ users: User[] | null }>("GET", "/users/"),
|
||||
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}/`),
|
||||
};
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useDirty } from "../DirtyContext";
|
||||
|
||||
export default function DirtyBanner() {
|
||||
const { modules, apply, applying, error } = useDirty();
|
||||
if (modules.length === 0 && !error) return null;
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-10">
|
||||
{modules.length > 0 && (
|
||||
<div className="flex items-center justify-between gap-4 border-b border-amber-700/50 bg-amber-500/15 px-6 py-3 text-amber-200">
|
||||
<span className="text-sm">
|
||||
Tienes cambios sin aplicar:{" "}
|
||||
<strong className="font-semibold">
|
||||
{modules.map((m) => m.module).join(", ")}
|
||||
</strong>
|
||||
</span>
|
||||
<button className="btn-primary" onClick={apply} disabled={applying}>
|
||||
{applying ? "Aplicando..." : "Aplicar cambios"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="border-b border-red-700/50 bg-red-500/15 px-6 py-3 text-sm text-red-200">
|
||||
Error al aplicar: {error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||
import { api } from "../api";
|
||||
import DirtyBanner from "./DirtyBanner";
|
||||
|
||||
const navItems = [
|
||||
{ to: "/", label: "Dashboard", end: true },
|
||||
{ to: "/users", label: "Usuarios" },
|
||||
{ to: "/samba", label: "SMB / Samba" },
|
||||
{ to: "/nfs", label: "NFS" },
|
||||
{ to: "/log", label: "Historial" },
|
||||
];
|
||||
|
||||
export default function Layout({ username, onLogout }: { username: string; onLogout: () => void }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
async function handleLogout() {
|
||||
await api.logout();
|
||||
onLogout();
|
||||
navigate("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<aside className="flex w-60 flex-col border-r border-slate-800 bg-slate-900/80">
|
||||
<div className="px-6 py-5 text-xl font-bold tracking-tight text-white">
|
||||
nas<span className="text-brand-500">ctl</span>
|
||||
</div>
|
||||
<nav className="flex-1 space-y-1 px-3">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
`block rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
||||
isActive ? "bg-brand-600 text-white" : "text-slate-300 hover:bg-slate-800"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="border-t border-slate-800 px-4 py-4 text-sm text-slate-400">
|
||||
<div className="mb-2 truncate">Sesión: <span className="text-slate-200">{username}</span></div>
|
||||
<button className="btn-ghost w-full" onClick={handleLogout}>
|
||||
Cerrar sesión
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="flex-1">
|
||||
<DirtyBanner />
|
||||
<div className="p-8">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export default function Modal({
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-20 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
|
||||
<div
|
||||
className="w-full max-w-lg rounded-lg border border-slate-700 bg-slate-900 p-6 shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-white">{title}</h2>
|
||||
<button className="text-slate-400 hover:text-slate-200" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-slate-950 text-slate-100 antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center rounded-md px-3 py-2 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-brand-500 disabled:opacity-50;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply btn bg-brand-600 text-white hover:bg-brand-700;
|
||||
}
|
||||
.btn-ghost {
|
||||
@apply btn bg-slate-800 text-slate-200 hover:bg-slate-700;
|
||||
}
|
||||
.btn-danger {
|
||||
@apply btn bg-red-600 text-white hover:bg-red-700;
|
||||
}
|
||||
.input {
|
||||
@apply w-full rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500;
|
||||
}
|
||||
.label {
|
||||
@apply mb-1 block text-sm font-medium text-slate-300;
|
||||
}
|
||||
.card {
|
||||
@apply rounded-lg border border-slate-800 bg-slate-900/60 p-5 shadow;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, formatBytes, SystemStatus } from "../api";
|
||||
import { useDirty } from "../DirtyContext";
|
||||
|
||||
export default function Dashboard() {
|
||||
const [status, setStatus] = useState<SystemStatus | null>(null);
|
||||
const { modules } = useDirty();
|
||||
|
||||
useEffect(() => {
|
||||
api.systemStatus().then(setStatus).catch(() => setStatus(null));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
|
||||
|
||||
<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">
|
||||
Uso de disco
|
||||
</h2>
|
||||
{status?.disks?.length ? (
|
||||
<div className="space-y-4">
|
||||
{status.disks.map((d) => (
|
||||
<div key={d.path}>
|
||||
<div className="mb-1 flex justify-between text-sm">
|
||||
<span className="text-slate-300">{d.path}</span>
|
||||
<span className="text-slate-400">
|
||||
{formatBytes(d.used_bytes)} / {formatBytes(d.total_bytes)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded bg-slate-800">
|
||||
<div
|
||||
className="h-full bg-brand-500"
|
||||
style={{ width: `${Math.min(d.used_percent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">Sin datos de disco.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wide text-slate-400">
|
||||
Servicios
|
||||
</h2>
|
||||
{status?.services?.length ? (
|
||||
<ul className="space-y-2">
|
||||
{status.services.map((s) => (
|
||||
<li key={s.name} className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-300">{s.name}</span>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs ${
|
||||
s.active ? "bg-emerald-500/20 text-emerald-300" : "bg-slate-700 text-slate-300"
|
||||
}`}
|
||||
>
|
||||
{s.state}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">Sin datos de servicios.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-slate-400">
|
||||
Cambios pendientes
|
||||
</h2>
|
||||
{modules.length > 0 ? (
|
||||
<p className="text-sm text-amber-300">
|
||||
Módulos con cambios sin aplicar: {modules.map((m) => m.module).join(", ")}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-emerald-300">Todo aplicado. No hay cambios pendientes.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, ApplyLogEntry } from "../api";
|
||||
|
||||
export default function Log() {
|
||||
const [entries, setEntries] = useState<ApplyLogEntry[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
api.applyLog().then((res) => setEntries(res.entries ?? [])).catch(() => setEntries([]));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-white">Historial de aplicaciones</h1>
|
||||
<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">Fecha</th>
|
||||
<th className="px-4 py-3">Módulo</th>
|
||||
<th className="px-4 py-3">Resultado</th>
|
||||
<th className="px-4 py-3">Mensaje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((e) => (
|
||||
<tr key={e.id} className="border-b border-slate-800/60">
|
||||
<td className="px-4 py-3 text-slate-400">{new Date(e.created_at).toLocaleString()}</td>
|
||||
<td className="px-4 py-3 font-medium text-slate-100">{e.module}</td>
|
||||
<td className="px-4 py-3">
|
||||
{e.success ? (
|
||||
<span className="text-emerald-300">OK</span>
|
||||
) : (
|
||||
<span className="text-red-300">Error</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-300">{e.message}</td>
|
||||
</tr>
|
||||
))}
|
||||
{entries.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-6 text-center text-slate-500">
|
||||
Sin registros todavía.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { api } from "../api";
|
||||
|
||||
export default function Login({ onLoggedIn }: { onLoggedIn: (username: string) => void }) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.login(username, password);
|
||||
onLoggedIn(res.username);
|
||||
} catch {
|
||||
setError("Credenciales inválidas");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<form onSubmit={handleSubmit} className="card w-full max-w-sm">
|
||||
<div className="mb-6 text-center text-2xl font-bold text-white">
|
||||
nas<span className="text-brand-500">ctl</span>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="mb-4 rounded-md bg-red-500/15 px-3 py-2 text-sm text-red-200">{error}</div>
|
||||
)}
|
||||
<div className="mb-4">
|
||||
<label className="label">Usuario</label>
|
||||
<input className="input" value={username} onChange={(e) => setUsername(e.target.value)} autoFocus />
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<label className="label">Contraseña</label>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button className="btn-primary w-full" disabled={loading}>
|
||||
{loading ? "Entrando..." : "Iniciar sesión"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { api, NFSExport } from "../api";
|
||||
import { useDirty } from "../DirtyContext";
|
||||
import Modal from "../components/Modal";
|
||||
|
||||
const empty: Partial<NFSExport> = {
|
||||
path: "",
|
||||
clients: [],
|
||||
options: "rw,sync,no_root_squash",
|
||||
};
|
||||
|
||||
export default function Nfs() {
|
||||
const [exports, setExports] = useState<NFSExport[]>([]);
|
||||
const [editing, setEditing] = useState<Partial<NFSExport> | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { refresh } = useDirty();
|
||||
|
||||
async function load() {
|
||||
const res = await api.listExports();
|
||||
setExports(res.exports ?? []);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
async function save(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!editing) return;
|
||||
setError(null);
|
||||
try {
|
||||
if (editing.id) {
|
||||
await api.updateExport(editing.id, editing);
|
||||
} else {
|
||||
await api.createExport(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 export?")) return;
|
||||
await api.deleteExport(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">Exports NFS</h1>
|
||||
<button className="btn-primary" onClick={() => setEditing({ ...empty })}>
|
||||
Nuevo export
|
||||
</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">Path</th>
|
||||
<th className="px-4 py-3">Clientes</th>
|
||||
<th className="px-4 py-3">Opciones</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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">{x.options}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button className="btn-ghost mr-2" onClick={() => setEditing({ ...x })}>
|
||||
Editar
|
||||
</button>
|
||||
<button className="btn-danger" onClick={() => remove(x.id)}>
|
||||
Eliminar
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{exports.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-6 text-center text-slate-500">
|
||||
No hay exports configurados.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<Modal title={editing.id ? "Editar export" : "Nuevo export"} 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">Path (absoluto)</label>
|
||||
<input
|
||||
className="input"
|
||||
value={editing.path ?? ""}
|
||||
onChange={(e) => setEditing({ ...editing, path: e.target.value })}
|
||||
/>
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { api, User } from "../api";
|
||||
import { useDirty } from "../DirtyContext";
|
||||
import Modal from "../components/Modal";
|
||||
|
||||
type EditUser = Partial<User> & { password?: string };
|
||||
|
||||
const empty: EditUser = {
|
||||
username: "",
|
||||
groups: [],
|
||||
smb_enabled: false,
|
||||
disabled: false,
|
||||
password: "",
|
||||
};
|
||||
|
||||
export default function Users() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [editing, setEditing] = useState<EditUser | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { refresh } = useDirty();
|
||||
|
||||
async function load() {
|
||||
const res = await api.listUsers();
|
||||
setUsers(res.users ?? []);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
async function save(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!editing) return;
|
||||
setError(null);
|
||||
try {
|
||||
if (editing.id) {
|
||||
await api.updateUser(editing.id, editing);
|
||||
} else {
|
||||
await api.createUser(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 usuario? Se ejecutará userdel al aplicar.")) return;
|
||||
await api.deleteUser(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">Usuarios del sistema</h1>
|
||||
<button className="btn-primary" onClick={() => setEditing({ ...empty })}>
|
||||
Nuevo usuario
|
||||
</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">Usuario</th>
|
||||
<th className="px-4 py-3">Grupos</th>
|
||||
<th className="px-4 py-3">SMB</th>
|
||||
<th className="px-4 py-3">Estado</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} className="border-b border-slate-800/60">
|
||||
<td className="px-4 py-3 font-medium text-slate-100">{u.username}</td>
|
||||
<td className="px-4 py-3 text-slate-300">{u.groups.join(", ") || "—"}</td>
|
||||
<td className="px-4 py-3">
|
||||
{u.smb_enabled ? (
|
||||
<span className="rounded-full bg-emerald-500/20 px-2 py-0.5 text-xs text-emerald-300">
|
||||
Habilitado
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-slate-700 px-2 py-0.5 text-xs text-slate-300">
|
||||
Deshabilitado
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-400">{u.disabled ? "Bloqueado" : "Activo"}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button className="btn-ghost mr-2" onClick={() => setEditing({ ...u, password: "" })}>
|
||||
Editar
|
||||
</button>
|
||||
<button className="btn-danger" onClick={() => remove(u.id)}>
|
||||
Eliminar
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{users.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-6 text-center text-slate-500">
|
||||
No hay usuarios configurados.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<Modal title={editing.id ? "Editar usuario" : "Nuevo usuario"} 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 de usuario</label>
|
||||
<input
|
||||
className="input"
|
||||
value={editing.username ?? ""}
|
||||
disabled={!!editing.id}
|
||||
onChange={(e) => setEditing({ ...editing, username: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Grupos (separados por coma)</label>
|
||||
<input
|
||||
className="input"
|
||||
value={(editing.groups ?? []).join(", ")}
|
||||
onChange={(e) =>
|
||||
setEditing({
|
||||
...editing,
|
||||
groups: e.target.value
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">
|
||||
Contraseña {editing.id && <span className="text-slate-500">(dejar vacío para no cambiar)</span>}
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={editing.password ?? ""}
|
||||
onChange={(e) => setEditing({ ...editing, password: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-6">
|
||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editing.smb_enabled ?? false}
|
||||
onChange={(e) => setEditing({ ...editing, smb_enabled: e.target.checked })}
|
||||
/>
|
||||
Acceso SMB
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editing.disabled ?? false}
|
||||
onChange={(e) => setEditing({ ...editing, disabled: e.target.checked })}
|
||||
/>
|
||||
Cuenta bloqueada
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
50: "#eff6ff",
|
||||
500: "#3b82f6",
|
||||
600: "#2563eb",
|
||||
700: "#1d4ed8",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"outDir": "./.tsbuild"
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"root":["./src/app.tsx","./src/dirtycontext.tsx","./src/api.ts","./src/main.tsx","./src/components/dirtybanner.tsx","./src/components/layout.tsx","./src/components/modal.tsx","./src/pages/dashboard.tsx","./src/pages/log.tsx","./src/pages/login.tsx","./src/pages/nfs.tsx","./src/pages/samba.tsx","./src/pages/users.tsx"],"version":"5.9.3"}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
// Build output goes into internal/web/dist so it is embedded into the Go binary.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: "../internal/web/dist",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://localhost:8080",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user