Routers form working properly and listing
This commit is contained in:
+93
@@ -199,4 +199,97 @@ th {
|
|||||||
.badge.removed {
|
.badge.removed {
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
background: #f3f4f6;
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
width: 520px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
border: 0;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 10px;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.router-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.router-form label {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.router-form input {
|
||||||
|
height: 42px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0 12px;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
+125
-7
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
import { apiGet } from "./api";
|
import { apiGet, apiPost } from "./api";
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Router,
|
Router,
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Activity,
|
Activity,
|
||||||
Server,
|
Server,
|
||||||
Settings,
|
Settings,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
|
|
||||||
@@ -19,19 +20,66 @@ type RouterItem = {
|
|||||||
status: string;
|
status: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CreateRouterRequest = {
|
||||||
|
name: string;
|
||||||
|
serialNumber: string;
|
||||||
|
lanIp: string;
|
||||||
|
lanSubnet: string;
|
||||||
|
};
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [page, setPage] = useState("dashboard");
|
const [page, setPage] = useState("dashboard");
|
||||||
const [routers, setRouters] = useState<RouterItem[]>([]);
|
const [routers, setRouters] = useState<RouterItem[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [form, setForm] = useState<CreateRouterRequest>({
|
||||||
|
name: "",
|
||||||
|
serialNumber: "",
|
||||||
|
lanIp: "",
|
||||||
|
lanSubnet: "",
|
||||||
|
});
|
||||||
|
|
||||||
async function loadRouters() {
|
async function loadRouters() {
|
||||||
setLoading(true);
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
const data = await apiGet<RouterItem[]>("/api/routers");
|
const data = await apiGet<RouterItem[]>("/api/routers");
|
||||||
|
setRouters(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to load routers");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setRouters(data);
|
async function createRouter(event: FormEvent) {
|
||||||
setLoading(false);
|
event.preventDefault();
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
await apiPost<RouterItem, CreateRouterRequest>("/api/routers", form);
|
||||||
|
|
||||||
|
setCreateOpen(false);
|
||||||
|
setForm({
|
||||||
|
name: "",
|
||||||
|
serialNumber: "",
|
||||||
|
lanIp: "",
|
||||||
|
lanSubnet: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
await loadRouters();
|
||||||
|
setPage("routers");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to create router");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -77,6 +125,8 @@ function App() {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="content">
|
<main className="content">
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
|
||||||
{page === "dashboard" && (
|
{page === "dashboard" && (
|
||||||
<>
|
<>
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
@@ -103,7 +153,9 @@ function App() {
|
|||||||
<h1>Routers</h1>
|
<h1>Routers</h1>
|
||||||
<p>Manage routers and OpenVPN provisioning</p>
|
<p>Manage routers and OpenVPN provisioning</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="primary">+ New Router</button>
|
<button className="primary" onClick={() => setCreateOpen(true)}>
|
||||||
|
+ New Router
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
@@ -143,6 +195,72 @@ function App() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{createOpen && (
|
||||||
|
<div className="modal-backdrop">
|
||||||
|
<div className="modal">
|
||||||
|
<div className="modal-header">
|
||||||
|
<div>
|
||||||
|
<h2>New Router</h2>
|
||||||
|
<p>Create a router before allocating VPN details.</p>
|
||||||
|
</div>
|
||||||
|
<button className="icon-button" onClick={() => setCreateOpen(false)}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={createRouter} className="router-form">
|
||||||
|
<label>
|
||||||
|
Router Name
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
placeholder="Ex: Loja Braga"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Serial Number
|
||||||
|
<input
|
||||||
|
value={form.serialNumber}
|
||||||
|
onChange={(e) => setForm({ ...form, serialNumber: e.target.value })}
|
||||||
|
placeholder="Ex: LR-001"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
LAN IP
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={form.lanIp}
|
||||||
|
onChange={(e) => setForm({ ...form, lanIp: e.target.value })}
|
||||||
|
placeholder="Ex: 192.168.60.1"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
LAN Subnet
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={form.lanSubnet}
|
||||||
|
onChange={(e) => setForm({ ...form, lanSubnet: e.target.value })}
|
||||||
|
placeholder="Ex: 192.168.60.0/24"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button type="button" className="secondary" onClick={() => setCreateOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="primary" disabled={saving}>
|
||||||
|
{saving ? "Saving..." : "Save Router"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+24
@@ -2,6 +2,9 @@ const API_BASE = import.meta.env.VITE_API_BASE;
|
|||||||
const API_KEY = import.meta.env.VITE_API_KEY;
|
const API_KEY = import.meta.env.VITE_API_KEY;
|
||||||
|
|
||||||
export async function apiGet<T>(path: string): Promise<T> {
|
export async function apiGet<T>(path: string): Promise<T> {
|
||||||
|
console.log("API_BASE:", API_BASE);
|
||||||
|
console.log("Calling:", `${API_BASE}${path}`);
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE}${path}`, {
|
const response = await fetch(`${API_BASE}${path}`, {
|
||||||
headers: {
|
headers: {
|
||||||
"X-API-Key": API_KEY,
|
"X-API-Key": API_KEY,
|
||||||
@@ -12,5 +15,26 @@ export async function apiGet<T>(path: string): Promise<T> {
|
|||||||
throw new Error(`API error ${response.status}`);
|
throw new Error(`API error ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiPost<TResponse, TBody>(
|
||||||
|
path: string,
|
||||||
|
body: TBody
|
||||||
|
): Promise<TResponse> {
|
||||||
|
const response = await fetch(`${API_BASE}${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-API-Key": API_KEY,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => null);
|
||||||
|
throw new Error(error?.error || `API error ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user