feat: fix audit log display, add dashboard counts, restructure WireGuard page, add translations

This commit is contained in:
xtcnet 2026-03-08 14:17:18 +07:00
parent f8ad3fe807
commit ec55362d15
5 changed files with 373 additions and 288 deletions

View file

@ -1,3 +1,4 @@
import db from "../db.js";
import internalDeadHost from "./dead-host.js"; import internalDeadHost from "./dead-host.js";
import internalProxyHost from "./proxy-host.js"; import internalProxyHost from "./proxy-host.js";
import internalRedirectionHost from "./redirection-host.js"; import internalRedirectionHost from "./redirection-host.js";
@ -23,15 +24,29 @@ const internalReport = {
return Promise.all(promises); return Promise.all(promises);
}) })
.then((counts) => { .then(async (counts) => {
const knex = db();
let wgServers = 0;
let wgClients = 0;
try {
const srvResult = await knex("wg_interface").count("id as count").first();
wgServers = srvResult?.count || 0;
const cliResult = await knex("wg_client").count("id as count").first();
wgClients = cliResult?.count || 0;
} catch (_) {
// WireGuard tables may not exist yet
}
return { return {
proxy: counts.shift(), proxy: counts.shift(),
redirection: counts.shift(), redirection: counts.shift(),
stream: counts.shift(), stream: counts.shift(),
dead: counts.shift(), dead: counts.shift(),
wgServers: Number(wgServers),
wgClients: Number(wgClients),
}; };
}); });
}, },
}; };
export default internalReport; export default internalReport;

View file

@ -34,41 +34,11 @@ export function SiteFooter() {
<div className="col-12 col-lg-auto mt-3 mt-lg-0"> <div className="col-12 col-lg-auto mt-3 mt-lg-0">
<ul className="list-inline list-inline-dots mb-0"> <ul className="list-inline list-inline-dots mb-0">
<li className="list-inline-item"> <li className="list-inline-item">
© 2025{" "} © D3V.AC 2026{" "}
<a href="https://jc21.com" rel="noreferrer" target="_blank" className="link-secondary">
jc21.com
</a>
</li> </li>
<li className="list-inline-item">
Theme by{" "}
<a href="https://tabler.io" rel="noreferrer" target="_blank" className="link-secondary">
Tabler
</a>
</li>
<li className="list-inline-item">
<a
href={`https://github.com/NginxProxyManager/nginx-proxy-manager/releases/tag/${getVersion()}`}
className="link-secondary"
target="_blank"
rel="noopener"
>
{" "}
{getVersion()}{" "}
</a>
</li>
{versionData?.updateAvailable && versionData?.latest && (
<li className="list-inline-item">
<a
href={`https://github.com/NginxProxyManager/nginx-proxy-manager/releases/tag/${versionData.latest}`}
className="link-warning fw-bold"
target="_blank"
rel="noopener"
title={`New version ${versionData.latest} is available`}
>
<T id="update-available" data={{ latestVersion: versionData.latest }} />
</a>
</li>
)}
</ul> </ul>
</div> </div>
</div> </div>

View file

@ -1,4 +1,4 @@
import { IconArrowsCross, IconBolt, IconBoltOff, IconDisc, IconLock, IconShield, IconUser } from "@tabler/icons-react"; import { IconArrowsCross, IconBolt, IconBoltOff, IconDisc, IconLock, IconNetwork, IconServer, IconShield, IconUser } from "@tabler/icons-react";
import cn from "classnames"; import cn from "classnames";
import type { AuditLog } from "src/api/backend"; import type { AuditLog } from "src/api/backend";
import { useLocaleState } from "src/context"; import { useLocaleState } from "src/context";
@ -17,6 +17,12 @@ const getEventValue = (event: AuditLog) => {
return event.meta?.incomingPort || "N/A"; return event.meta?.incomingPort || "N/A";
case "certificate": case "certificate":
return event.meta?.domainNames?.join(", ") || event.meta?.niceName || "N/A"; return event.meta?.domainNames?.join(", ") || event.meta?.niceName || "N/A";
case "wireguard-server":
return event.meta?.name || `Server #${event.objectId}`;
case "wireguard-client":
return event.meta?.name || `Client #${event.objectId}`;
case "wireguard-server-links":
return `Server #${event.objectId}`;
default: default:
return `UNKNOWN EVENT TYPE: ${event.objectType}`; return `UNKNOWN EVENT TYPE: ${event.objectType}`;
} }
@ -58,6 +64,13 @@ const getIcon = (row: AuditLog) => {
case "certificate": case "certificate":
ico = <IconShield size={16} className={c} />; ico = <IconShield size={16} className={c} />;
break; break;
case "wireguard-server":
case "wireguard-server-links":
ico = <IconServer size={16} className={c} />;
break;
case "wireguard-client":
ico = <IconNetwork size={16} className={c} />;
break;
} }
return ico; return ico;

View file

@ -1,9 +1,22 @@
import { IconArrowsCross, IconBolt, IconBoltOff, IconDisc } from "@tabler/icons-react"; import {
IconArrowsCross,
IconBolt,
IconBoltOff,
IconDisc,
IconNetwork,
IconServer,
} from "@tabler/icons-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { HasPermission } from "src/components"; import { HasPermission } from "src/components";
import { useHostReport } from "src/hooks"; import { useHostReport } from "src/hooks";
import { T } from "src/locale"; import { T } from "src/locale";
import { DEAD_HOSTS, PROXY_HOSTS, REDIRECTION_HOSTS, STREAMS, VIEW } from "src/modules/Permissions"; import {
DEAD_HOSTS,
PROXY_HOSTS,
REDIRECTION_HOSTS,
STREAMS,
VIEW,
} from "src/modules/Permissions";
const Dashboard = () => { const Dashboard = () => {
const { data: hostReport } = useHostReport(); const { data: hostReport } = useHostReport();
@ -122,6 +135,58 @@ const Dashboard = () => {
</a> </a>
</div> </div>
</HasPermission> </HasPermission>
{/* WireGuard Servers */}
<div className="col-sm-6 col-lg-3">
<a
href="/wireguard"
className="card card-sm card-link card-link-pop"
onClick={(e) => {
e.preventDefault();
navigate("/wireguard");
}}
>
<div className="card-body">
<div className="row align-items-center">
<div className="col-auto">
<span className="bg-purple text-white avatar">
<IconServer />
</span>
</div>
<div className="col">
<div className="font-weight-medium">
<T id="wireguard-servers.count" data={{ count: hostReport?.wgServers ?? 0 }} />
</div>
</div>
</div>
</div>
</a>
</div>
{/* WireGuard Clients */}
<div className="col-sm-6 col-lg-3">
<a
href="/wireguard"
className="card card-sm card-link card-link-pop"
onClick={(e) => {
e.preventDefault();
navigate("/wireguard");
}}
>
<div className="card-body">
<div className="row align-items-center">
<div className="col-auto">
<span className="bg-cyan text-white avatar">
<IconNetwork />
</span>
</div>
<div className="col">
<div className="font-weight-medium">
<T id="wireguard-clients.count" data={{ count: hostReport?.wgClients ?? 0 }} />
</div>
</div>
</div>
</div>
</a>
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -48,8 +48,6 @@ function timeAgo(date: string | null): string {
} }
function WireGuard() { function WireGuard() {
const [activeTab, setActiveTab] = useState<"servers" | "clients">("servers");
const { data: clients, isLoading: clientsLoading } = useWgClients(); const { data: clients, isLoading: clientsLoading } = useWgClients();
const { data: interfaces, isLoading: ifacesLoading } = useWgInterfaces(); const { data: interfaces, isLoading: ifacesLoading } = useWgInterfaces();
@ -64,19 +62,12 @@ function WireGuard() {
const [clientFilter, setClientFilter] = useState(""); const [clientFilter, setClientFilter] = useState("");
const [serverFilter, setServerFilter] = useState(""); const [serverFilter, setServerFilter] = useState("");
const [selectedServerId, setSelectedServerId] = useState<number | "all">("all");
if (clientsLoading || ifacesLoading) { if (clientsLoading || ifacesLoading) {
return <Loading />; return <Loading />;
} }
const filteredClients = clients?.filter(
(c) =>
!clientFilter ||
c.name.toLowerCase().includes(clientFilter.toLowerCase()) ||
c.ipv4Address.includes(clientFilter) ||
c.interfaceName?.toLowerCase().includes(clientFilter.toLowerCase()),
);
const filteredInterfaces = interfaces?.filter( const filteredInterfaces = interfaces?.filter(
(i) => (i) =>
!serverFilter || !serverFilter ||
@ -85,6 +76,23 @@ function WireGuard() {
(i.host && i.host.toLowerCase().includes(serverFilter.toLowerCase())) (i.host && i.host.toLowerCase().includes(serverFilter.toLowerCase()))
); );
const filteredClients = clients?.filter((c) => {
// Filter by selected server
if (selectedServerId !== "all" && c.interfaceId !== selectedServerId) {
return false;
}
// Filter by search text
if (
clientFilter &&
!c.name.toLowerCase().includes(clientFilter.toLowerCase()) &&
!c.ipv4Address.includes(clientFilter) &&
!c.interfaceName?.toLowerCase().includes(clientFilter.toLowerCase())
) {
return false;
}
return true;
});
// Server Handlers // Server Handlers
const handleNewServer = async () => { const handleNewServer = async () => {
const result = (await EasyModal.show(WireGuardServerModal)) as any; const result = (await EasyModal.show(WireGuardServerModal)) as any;
@ -159,235 +167,225 @@ function WireGuard() {
</div> </div>
</div> </div>
{/* Tabs */} {/* ================== SERVERS TABLE ================== */}
<div className="card mb-4">
<div className="card-header">
<h3 className="card-title">
<IconServer className="me-2" size={20} />
WireGuard Servers
<span className="badge bg-blue ms-2">{interfaces?.length || 0}</span>
</h3>
</div>
<div className="table-responsive">
<div className="p-3 border-bottom d-flex align-items-center justify-content-between">
<div className="d-flex w-100 flex-column flex-md-row justify-content-between align-items-center">
<div className="text-muted d-none d-md-block">
Listing WireGuard Servers
</div>
<div className="d-flex flex-wrap gap-2 justify-content-md-end w-100 w-md-auto align-items-center">
<input
type="text"
className="form-control form-control-sm"
placeholder="Search servers..."
value={serverFilter}
onChange={(e) => setServerFilter(e.target.value)}
style={{ width: 250 }}
/>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={handleNewServer}
id="wg-new-server-btn"
>
<IconPlus size={16} className="me-1" />
New Server
</button>
</div>
</div>
</div>
<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>
{filteredInterfaces?.map((iface) => (
<tr key={iface.id}>
<td className="fw-bold">{iface.name}</td>
<td>
<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>
)}
</td>
<td>
<div className="d-flex align-items-center">
<span className="badge bg-azure me-2">{iface.linkedServers?.length || 0}</span>
{iface.linkedServers?.length > 0 && interfaces && (
<span className="text-muted small">
({interfaces.filter(i => iface.linkedServers.includes(i.id)).map(i => i.name).join(", ")})
</span>
)}
</div>
</td>
<td className="text-end">
<div className="btn-group btn-group-sm">
<button
type="button"
className="btn btn-outline-primary"
title="Linked Servers"
onClick={() => handleManageLinks(iface)}
>
<IconLink size={16} />
</button>
<button
type="button"
className="btn btn-outline-primary"
title="Edit Server"
onClick={() => handleEditServer(iface)}
>
<IconEdit size={16} />
</button>
<button
type="button"
className="btn btn-outline-danger"
title="Delete Server"
onClick={() => handleDeleteServer(iface.id, iface.name)}
>
<IconTrash size={16} />
</button>
</div>
</td>
</tr>
))}
{(!filteredInterfaces || filteredInterfaces.length === 0) && (
<tr>
<td colSpan={7} className="text-center text-muted py-5">
{serverFilter
? "No servers match your filter"
: "No WireGuard servers configured. Click 'New Server' to create one."}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* ================== CLIENTS TABLE ================== */}
<div className="card"> <div className="card">
<div className="card-header"> <div className="card-header">
<ul className="nav nav-tabs card-header-tabs" data-bs-toggle="tabs"> <h3 className="card-title">
<li className="nav-item cursor-pointer"> <IconNetwork className="me-2" size={20} />
<a className={`nav-link ${activeTab === "servers" ? "active" : ""}`} onClick={() => setActiveTab("servers")}> WireGuard Clients
<IconServer className="me-1" size={16}/> Servers <span className="badge bg-green ms-2">{clients?.length || 0}</span>
<span className="badge bg-blue ms-2">{interfaces?.length || 0}</span> </h3>
</a>
</li>
<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>
</ul>
</div> </div>
<div className="table-responsive">
{/* Tab Content */} <div className="p-3 border-bottom d-flex align-items-center justify-content-between">
{activeTab === "clients" && ( <div className="d-flex w-100 flex-column flex-md-row justify-content-between align-items-center">
<div className="table-responsive"> <div className="text-muted d-none d-md-block">
<div className="p-3 border-bottom d-flex align-items-center justify-content-between"> Listing WireGuard Clients
<div className="d-flex w-100 flex-column flex-md-row justify-content-between align-items-center"> </div>
<div className="text-muted d-none d-md-block"> <div className="d-flex flex-wrap gap-2 justify-content-md-end w-100 w-md-auto align-items-center">
Listing WireGuard Clients {/* Server filter dropdown */}
</div> <select
<div className="d-flex flex-wrap gap-2 justify-content-md-end w-100 w-md-auto align-items-center"> className="form-select form-select-sm"
<input style={{ width: 200 }}
type="text" value={selectedServerId}
className="form-control form-control-sm" onChange={(e) =>
placeholder="Search clients..." setSelectedServerId(e.target.value === "all" ? "all" : Number(e.target.value))
value={clientFilter} }
onChange={(e) => setClientFilter(e.target.value)} >
style={{ width: 250 }} <option value="all">All Servers</option>
/> {interfaces?.map((iface) => (
<button <option key={iface.id} value={iface.id}>
type="button" {iface.name}
className="btn btn-primary btn-sm" </option>
onClick={handleNewClient} ))}
id="wg-new-client-btn" </select>
> <input
<IconPlus size={16} className="me-1" /> type="text"
New Client className="form-control form-control-sm"
</button> placeholder="Search clients..."
</div> value={clientFilter}
onChange={(e) => setClientFilter(e.target.value)}
style={{ width: 250 }}
/>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={handleNewClient}
id="wg-new-client-btn"
>
<IconPlus size={16} className="me-1" />
New Client
</button>
</div> </div>
</div> </div>
<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>
</div> </div>
)} <table className="table table-vcenter table-nowrap card-table">
<thead>
{activeTab === "servers" && ( <tr>
<div className="table-responsive"> <th>Status</th>
<div className="p-3 border-bottom d-flex align-items-center justify-content-between"> <th>Name</th>
<div className="d-flex w-100 flex-column flex-md-row justify-content-between align-items-center"> <th>Server</th>
<div className="text-muted d-none d-md-block"> <th>IP Address</th>
Listing WireGuard Servers <th>Last Handshake</th>
</div> <th>Transfer / </th>
<div className="d-flex flex-wrap gap-2 justify-content-md-end w-100 w-md-auto align-items-center"> <th className="text-end">Actions</th>
<input </tr>
type="text" </thead>
className="form-control form-control-sm" <tbody>
placeholder="Search servers..." {filteredClients?.map((client) => {
value={serverFilter} const isConnected =
onChange={(e) => setServerFilter(e.target.value)} client.latestHandshakeAt &&
style={{ width: 250 }} Date.now() - new Date(client.latestHandshakeAt).getTime() <
/> 3 * 60 * 1000;
<button return (
type="button" <tr key={client.id}>
className="btn btn-primary btn-sm"
onClick={handleNewServer}
>
<IconPlus size={16} className="me-1" />
New Server
</button>
</div>
</div>
</div>
<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>
{filteredInterfaces?.map((iface) => (
<tr key={iface.id}>
<td className="fw-bold">{iface.name}</td>
<td> <td>
<code>{iface.ipv4Cidr}</code> <span
className={`badge ${
!client.enabled
? "bg-secondary"
: isConnected
? "bg-success"
: "bg-warning"
}`}
>
{!client.enabled
? "Disabled"
: isConnected
? "Connected"
: "Idle"}
</span>
</td> </td>
<td>{iface.listenPort}</td> <td className="fw-bold">{client.name}</td>
<td className="text-muted">{iface.host || "None"}</td>
<td> <td>
{iface.isolateClients ? ( <div className="text-muted">{client.interfaceName || "—"}</div>
<span className="badge bg-green text-green-fg">Enabled</span>
) : (
<span className="badge bg-secondary text-secondary-fg">Disabled</span>
)}
</td> </td>
<td> <td>
<div className="d-flex align-items-center"> <code>{client.ipv4Address}</code>
<span className="badge bg-azure me-2">{iface.linkedServers?.length || 0}</span> </td>
{iface.linkedServers?.length > 0 && interfaces && ( <td>{timeAgo(client.latestHandshakeAt)}</td>
<span className="text-muted small"> <td>
({interfaces.filter(i => iface.linkedServers.includes(i.id)).map(i => i.name).join(", ")}) <div className="d-flex flex-column text-muted small">
</span> <span> {formatBytes(client.transferRx)}</span>
)} <span> {formatBytes(client.transferTx)}</span>
</div> </div>
</td> </td>
<td className="text-end"> <td className="text-end">
@ -395,42 +393,66 @@ function WireGuard() {
<button <button
type="button" type="button"
className="btn btn-outline-primary" className="btn btn-outline-primary"
title="Linked Servers" title="QR Code"
onClick={() => handleManageLinks(iface)} onClick={() =>
handleQR(client.id, client.name)
}
> >
<IconLink size={16} /> <IconQrcode size={16} />
</button> </button>
<button <button
type="button" type="button"
className="btn btn-outline-primary" className="btn btn-outline-primary"
title="Edit Server" title="Download Config"
onClick={() => handleEditServer(iface)} onClick={() =>
handleDownload(client.id, client.name)
}
> >
<IconEdit size={16} /> <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>
<button <button
type="button" type="button"
className="btn btn-outline-danger" className="btn btn-outline-danger"
title="Delete Server" title="Delete"
onClick={() => handleDeleteServer(iface.id, iface.name)} onClick={() =>
handleDeleteClient(client.id, client.name)
}
> >
<IconTrash size={16} /> <IconTrash size={16} />
</button> </button>
</div> </div>
</td> </td>
</tr> </tr>
))} );
{(!interfaces || interfaces.length === 0) && ( })}
<tr> {(!filteredClients || filteredClients.length === 0) && (
<td colSpan={7} className="text-center text-muted py-5"> <tr>
No WireGuard servers configured. <td colSpan={7} className="text-center text-muted py-5">
</td> {clientFilter || selectedServerId !== "all"
</tr> ? "No clients match your filter"
)} : "No WireGuard clients yet. Click 'New Client' to create one."}
</tbody> </td>
</table> </tr>
</div> )}
)} </tbody>
</table>
</div>
</div> </div>
</div> </div>
); );