2026-03-07 13:49:44 +00:00
|
|
|
import {
|
|
|
|
|
IconPlus,
|
|
|
|
|
IconDownload,
|
|
|
|
|
IconQrcode,
|
|
|
|
|
IconPlayerPlay,
|
|
|
|
|
IconPlayerPause,
|
|
|
|
|
IconTrash,
|
|
|
|
|
IconNetwork,
|
2026-03-08 02:33:24 +00:00
|
|
|
IconServer,
|
|
|
|
|
IconEdit,
|
|
|
|
|
IconLink,
|
2026-03-07 13:49:44 +00:00
|
|
|
} from "@tabler/icons-react";
|
|
|
|
|
import EasyModal from "ez-modal-react";
|
|
|
|
|
import { useState } from "react";
|
|
|
|
|
import { downloadWgConfig } from "src/api/backend/wireguard";
|
|
|
|
|
import { Loading } from "src/components";
|
|
|
|
|
import {
|
|
|
|
|
useWgClients,
|
2026-03-08 02:33:24 +00:00
|
|
|
useWgInterfaces,
|
|
|
|
|
useCreateWgInterface,
|
|
|
|
|
useUpdateWgInterface,
|
|
|
|
|
useDeleteWgInterface,
|
|
|
|
|
useUpdateWgInterfaceLinks,
|
2026-03-07 13:49:44 +00:00
|
|
|
useCreateWgClient,
|
|
|
|
|
useDeleteWgClient,
|
|
|
|
|
useToggleWgClient,
|
|
|
|
|
} from "src/hooks/useWireGuard";
|
|
|
|
|
import WireGuardClientModal from "src/modals/WireGuardClientModal";
|
2026-03-08 02:33:24 +00:00
|
|
|
import WireGuardServerModal from "src/modals/WireGuardServerModal";
|
|
|
|
|
import WireGuardLinkedServersModal from "src/modals/WireGuardLinkedServersModal";
|
2026-03-07 13:49:44 +00:00
|
|
|
import WireGuardQRModal from "src/modals/WireGuardQRModal";
|
|
|
|
|
|
|
|
|
|
function formatBytes(bytes: number | null): string {
|
|
|
|
|
if (bytes === null || bytes === 0) return "0 B";
|
|
|
|
|
const k = 1024;
|
|
|
|
|
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
|
|
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
|
|
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function timeAgo(date: string | null): string {
|
|
|
|
|
if (!date) return "Never";
|
|
|
|
|
const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000);
|
|
|
|
|
if (seconds < 60) return `${seconds}s ago`;
|
|
|
|
|
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
|
|
|
|
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
|
|
|
|
return `${Math.floor(seconds / 86400)}d ago`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function WireGuard() {
|
2026-03-08 02:33:24 +00:00
|
|
|
const [activeTab, setActiveTab] = useState<"servers" | "clients">("clients");
|
|
|
|
|
|
2026-03-07 13:49:44 +00:00
|
|
|
const { data: clients, isLoading: clientsLoading } = useWgClients();
|
2026-03-08 02:33:24 +00:00
|
|
|
const { data: interfaces, isLoading: ifacesLoading } = useWgInterfaces();
|
|
|
|
|
|
|
|
|
|
const createServer = useCreateWgInterface();
|
|
|
|
|
const updateServer = useUpdateWgInterface();
|
|
|
|
|
const deleteServer = useDeleteWgInterface();
|
|
|
|
|
const updateLinks = useUpdateWgInterfaceLinks();
|
|
|
|
|
|
2026-03-07 13:49:44 +00:00
|
|
|
const createClient = useCreateWgClient();
|
|
|
|
|
const deleteClient = useDeleteWgClient();
|
|
|
|
|
const toggleClient = useToggleWgClient();
|
2026-03-08 02:33:24 +00:00
|
|
|
|
|
|
|
|
const [clientFilter, setClientFilter] = useState("");
|
2026-03-07 13:49:44 +00:00
|
|
|
|
2026-03-08 02:33:24 +00:00
|
|
|
if (clientsLoading || ifacesLoading) {
|
2026-03-07 13:49:44 +00:00
|
|
|
return <Loading />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const filteredClients = clients?.filter(
|
|
|
|
|
(c) =>
|
2026-03-08 02:33:24 +00:00
|
|
|
!clientFilter ||
|
|
|
|
|
c.name.toLowerCase().includes(clientFilter.toLowerCase()) ||
|
|
|
|
|
c.ipv4Address.includes(clientFilter) ||
|
|
|
|
|
c.interfaceName?.toLowerCase().includes(clientFilter.toLowerCase()),
|
2026-03-07 13:49:44 +00:00
|
|
|
);
|
|
|
|
|
|
2026-03-08 02:33:24 +00:00
|
|
|
// Server Handlers
|
|
|
|
|
const handleNewServer = async () => {
|
|
|
|
|
const result = (await EasyModal.show(WireGuardServerModal)) as any;
|
|
|
|
|
if (result) {
|
|
|
|
|
createServer.mutate(result);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleEditServer = async (wgInterface: any) => {
|
|
|
|
|
const result = (await EasyModal.show(WireGuardServerModal, { wgInterface })) as any;
|
|
|
|
|
if (result) {
|
|
|
|
|
updateServer.mutate({ id: wgInterface.id, data: result });
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleManageLinks = async (wgInterface: any) => {
|
|
|
|
|
if (!interfaces) return;
|
|
|
|
|
const result = (await EasyModal.show(WireGuardLinkedServersModal, { wgInterface, allInterfaces: interfaces })) as any;
|
|
|
|
|
if (result) {
|
|
|
|
|
updateLinks.mutate({ id: wgInterface.id, data: result });
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleDeleteServer = async (id: number, name: string) => {
|
|
|
|
|
if (window.confirm(`Are you absolutely sure you want to delete server "${name}"? This will also delete all associated clients and peering links.`)) {
|
|
|
|
|
deleteServer.mutate(id);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Client Handlers
|
2026-03-07 13:49:44 +00:00
|
|
|
const handleNewClient = async () => {
|
2026-03-08 02:33:24 +00:00
|
|
|
const result = (await EasyModal.show(WireGuardClientModal, { interfaces: interfaces || [] })) as any;
|
|
|
|
|
if (result && result.name && result.interface_id) {
|
|
|
|
|
createClient.mutate({ name: result.name, interface_id: result.interface_id });
|
2026-03-07 13:49:44 +00:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-08 02:33:24 +00:00
|
|
|
const handleDeleteClient = async (id: number, name: string) => {
|
2026-03-07 13:49:44 +00:00
|
|
|
if (window.confirm(`Are you sure you want to delete client "${name}"?`)) {
|
|
|
|
|
deleteClient.mutate(id);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-08 02:33:24 +00:00
|
|
|
const handleToggleClient = (id: number, currentlyEnabled: boolean) => {
|
2026-03-07 13:49:44 +00:00
|
|
|
toggleClient.mutate({ id, enabled: !currentlyEnabled });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleQR = (id: number, name: string) => {
|
|
|
|
|
EasyModal.show(WireGuardQRModal, { clientId: id, clientName: name });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleDownload = (id: number, name: string) => {
|
|
|
|
|
const cleanName = name.replace(/[^a-zA-Z0-9_.-]/g, "-").substring(0, 32);
|
|
|
|
|
downloadWgConfig(id, cleanName);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="container-xl">
|
2026-03-08 02:33:24 +00:00
|
|
|
{/* Page Header */}
|
2026-03-07 13:49:44 +00:00
|
|
|
<div className="page-header d-print-none">
|
|
|
|
|
<div className="row align-items-center">
|
2026-03-08 02:33:24 +00:00
|
|
|
<div className="col">
|
2026-03-07 13:49:44 +00:00
|
|
|
<h2 className="page-title">
|
|
|
|
|
<IconNetwork className="me-2" size={28} />
|
|
|
|
|
WireGuard VPN
|
|
|
|
|
</h2>
|
|
|
|
|
</div>
|
2026-03-08 02:33:24 +00:00
|
|
|
<div className="col-auto ms-auto d-print-none">
|
|
|
|
|
<div className="btn-list">
|
|
|
|
|
{activeTab === "servers" ? (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="btn btn-primary d-none d-sm-inline-block"
|
|
|
|
|
onClick={handleNewServer}
|
|
|
|
|
>
|
|
|
|
|
<IconPlus size={16} className="me-1" />
|
|
|
|
|
New Server
|
|
|
|
|
</button>
|
|
|
|
|
) : (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="btn btn-primary d-none d-sm-inline-block"
|
|
|
|
|
onClick={handleNewClient}
|
|
|
|
|
id="wg-new-client-btn"
|
|
|
|
|
>
|
|
|
|
|
<IconPlus size={16} className="me-1" />
|
|
|
|
|
New Client
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
2026-03-07 13:49:44 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-03-08 02:33:24 +00:00
|
|
|
</div>
|
2026-03-07 13:49:44 +00:00
|
|
|
|
2026-03-08 02:33:24 +00:00
|
|
|
{/* Tabs */}
|
2026-03-07 13:49:44 +00:00
|
|
|
<div className="card">
|
|
|
|
|
<div className="card-header">
|
2026-03-08 02:33:24 +00:00
|
|
|
<ul className="nav nav-tabs card-header-tabs" data-bs-toggle="tabs">
|
|
|
|
|
<li className="nav-item cursor-pointer">
|
|
|
|
|
<a className={`nav-link ${activeTab === "clients" ? "active" : ""}`} onClick={() => setActiveTab("clients")}>
|
|
|
|
|
<IconNetwork className="me-1" size={16}/> Clients
|
|
|
|
|
<span className="badge bg-green ms-2">{clients?.length || 0}</span>
|
|
|
|
|
</a>
|
|
|
|
|
</li>
|
|
|
|
|
<li className="nav-item cursor-pointer">
|
|
|
|
|
<a className={`nav-link ${activeTab === "servers" ? "active" : ""}`} onClick={() => setActiveTab("servers")}>
|
|
|
|
|
<IconServer className="me-1" size={16}/> Servers
|
|
|
|
|
<span className="badge bg-blue ms-2">{interfaces?.length || 0}</span>
|
|
|
|
|
</a>
|
|
|
|
|
</li>
|
|
|
|
|
</ul>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Tab Content */}
|
|
|
|
|
{activeTab === "clients" && (
|
|
|
|
|
<div className="table-responsive">
|
|
|
|
|
<div className="p-3 border-bottom d-flex align-items-center justify-content-between">
|
|
|
|
|
<h3 className="card-title mb-0">Clients</h3>
|
2026-03-07 13:49:44 +00:00
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
className="form-control form-control-sm"
|
2026-03-08 02:33:24 +00:00
|
|
|
placeholder="Filter clients..."
|
|
|
|
|
value={clientFilter}
|
|
|
|
|
onChange={(e) => setClientFilter(e.target.value)}
|
|
|
|
|
style={{ width: 250 }}
|
2026-03-07 13:49:44 +00:00
|
|
|
/>
|
|
|
|
|
</div>
|
2026-03-08 02:33:24 +00:00
|
|
|
<table className="table table-vcenter table-nowrap card-table">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
<th>Status</th>
|
|
|
|
|
<th>Name</th>
|
|
|
|
|
<th>Server</th>
|
|
|
|
|
<th>IP Address</th>
|
|
|
|
|
<th>Last Handshake</th>
|
|
|
|
|
<th>Transfer ↓ / ↑</th>
|
|
|
|
|
<th className="text-end">Actions</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
{filteredClients?.map((client) => {
|
|
|
|
|
const isConnected =
|
|
|
|
|
client.latestHandshakeAt &&
|
|
|
|
|
Date.now() - new Date(client.latestHandshakeAt).getTime() <
|
|
|
|
|
3 * 60 * 1000;
|
|
|
|
|
return (
|
|
|
|
|
<tr key={client.id}>
|
|
|
|
|
<td>
|
|
|
|
|
<span
|
|
|
|
|
className={`badge ${
|
|
|
|
|
!client.enabled
|
|
|
|
|
? "bg-secondary"
|
|
|
|
|
: isConnected
|
|
|
|
|
? "bg-success"
|
|
|
|
|
: "bg-warning"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{!client.enabled
|
|
|
|
|
? "Disabled"
|
|
|
|
|
: isConnected
|
|
|
|
|
? "Connected"
|
|
|
|
|
: "Idle"}
|
|
|
|
|
</span>
|
|
|
|
|
</td>
|
|
|
|
|
<td className="fw-bold">{client.name}</td>
|
|
|
|
|
<td>
|
|
|
|
|
<div className="text-muted">{client.interfaceName}</div>
|
|
|
|
|
</td>
|
|
|
|
|
<td>
|
|
|
|
|
<code>{client.ipv4Address}</code>
|
|
|
|
|
</td>
|
|
|
|
|
<td>{timeAgo(client.latestHandshakeAt)}</td>
|
|
|
|
|
<td>
|
|
|
|
|
<div className="d-flex flex-column text-muted small">
|
|
|
|
|
<span>↓ {formatBytes(client.transferRx)}</span>
|
|
|
|
|
<span>↑ {formatBytes(client.transferTx)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
<td className="text-end">
|
|
|
|
|
<div className="btn-group btn-group-sm">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="btn btn-outline-primary"
|
|
|
|
|
title="QR Code"
|
|
|
|
|
onClick={() =>
|
|
|
|
|
handleQR(client.id, client.name)
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<IconQrcode size={16} />
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="btn btn-outline-primary"
|
|
|
|
|
title="Download Config"
|
|
|
|
|
onClick={() =>
|
|
|
|
|
handleDownload(client.id, client.name)
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<IconDownload size={16} />
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className={`btn ${client.enabled ? "btn-outline-warning" : "btn-outline-success"}`}
|
|
|
|
|
title={
|
|
|
|
|
client.enabled ? "Disable" : "Enable"
|
|
|
|
|
}
|
|
|
|
|
onClick={() =>
|
|
|
|
|
handleToggleClient(client.id, client.enabled)
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{client.enabled ? (
|
|
|
|
|
<IconPlayerPause size={16} />
|
|
|
|
|
) : (
|
|
|
|
|
<IconPlayerPlay size={16} />
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="btn btn-outline-danger"
|
|
|
|
|
title="Delete"
|
|
|
|
|
onClick={() =>
|
|
|
|
|
handleDeleteClient(client.id, client.name)
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<IconTrash size={16} />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
{(!filteredClients || filteredClients.length === 0) && (
|
|
|
|
|
<tr>
|
|
|
|
|
<td colSpan={7} className="text-center text-muted py-5">
|
|
|
|
|
{clientFilter
|
|
|
|
|
? "No clients match your filter"
|
|
|
|
|
: "No WireGuard clients yet. Click 'New Client' to create one."}
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
)}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
2026-03-07 13:49:44 +00:00
|
|
|
</div>
|
2026-03-08 02:33:24 +00:00
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{activeTab === "servers" && (
|
|
|
|
|
<div className="table-responsive">
|
|
|
|
|
<table className="table table-vcenter table-nowrap card-table">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
<th>Interface</th>
|
|
|
|
|
<th>Subnet</th>
|
|
|
|
|
<th>Port</th>
|
|
|
|
|
<th>Endpoint Host</th>
|
|
|
|
|
<th>Isolation</th>
|
|
|
|
|
<th>Links</th>
|
|
|
|
|
<th className="text-end">Actions</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
{interfaces?.map((iface) => (
|
|
|
|
|
<tr key={iface.id}>
|
|
|
|
|
<td className="fw-bold">{iface.name}</td>
|
2026-03-07 13:49:44 +00:00
|
|
|
<td>
|
2026-03-08 02:33:24 +00:00
|
|
|
<code>{iface.ipv4Cidr}</code>
|
|
|
|
|
</td>
|
|
|
|
|
<td>{iface.listenPort}</td>
|
|
|
|
|
<td className="text-muted">{iface.host || "None"}</td>
|
|
|
|
|
<td>
|
|
|
|
|
{iface.isolateClients ? (
|
|
|
|
|
<span className="badge bg-green text-green-fg">Enabled</span>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="badge bg-secondary text-secondary-fg">Disabled</span>
|
|
|
|
|
)}
|
2026-03-07 13:49:44 +00:00
|
|
|
</td>
|
|
|
|
|
<td>
|
2026-03-08 02:33:24 +00:00
|
|
|
<div className="d-flex align-items-center">
|
|
|
|
|
<span className="badge bg-azure me-2">{iface.linkedServers?.length || 0}</span>
|
|
|
|
|
{iface.linkedServers?.length > 0 && (
|
|
|
|
|
<span className="text-muted small">
|
|
|
|
|
({interfaces.filter(i => iface.linkedServers.includes(i.id)).map(i => i.name).join(", ")})
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-03-07 13:49:44 +00:00
|
|
|
</td>
|
|
|
|
|
<td className="text-end">
|
|
|
|
|
<div className="btn-group btn-group-sm">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="btn btn-outline-primary"
|
2026-03-08 02:33:24 +00:00
|
|
|
title="Linked Servers"
|
|
|
|
|
onClick={() => handleManageLinks(iface)}
|
2026-03-07 13:49:44 +00:00
|
|
|
>
|
2026-03-08 02:33:24 +00:00
|
|
|
<IconLink size={16} />
|
2026-03-07 13:49:44 +00:00
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="btn btn-outline-primary"
|
2026-03-08 02:33:24 +00:00
|
|
|
title="Edit Server"
|
|
|
|
|
onClick={() => handleEditServer(iface)}
|
2026-03-07 13:49:44 +00:00
|
|
|
>
|
2026-03-08 02:33:24 +00:00
|
|
|
<IconEdit size={16} />
|
2026-03-07 13:49:44 +00:00
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="btn btn-outline-danger"
|
2026-03-08 02:33:24 +00:00
|
|
|
title="Delete Server"
|
|
|
|
|
onClick={() => handleDeleteServer(iface.id, iface.name)}
|
2026-03-07 13:49:44 +00:00
|
|
|
>
|
|
|
|
|
<IconTrash size={16} />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
2026-03-08 02:33:24 +00:00
|
|
|
))}
|
|
|
|
|
{(!interfaces || interfaces.length === 0) && (
|
|
|
|
|
<tr>
|
|
|
|
|
<td colSpan={7} className="text-center text-muted py-5">
|
|
|
|
|
No WireGuard servers configured.
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
)}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-03-07 13:49:44 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default WireGuard;
|