Multi-interface management, topology view, performance tuning, precompiled CSS
- Interface model with per-interface subnet/port/keys; import/adopt existing wg-quick configs (key-less imported peers, optional key rotation), cascade delete - Split wireguard.py into a package (keys via cryptography X25519, status, addressing, conf parse/render, sync, host tuning) - ECharts horizontal topology view (interface -> peers -> site subnets) - Advanced options: MTU, MSS clamping, FwMark/Table, custom PostUp/PostDown, per-peer keepalive override - Runtime settings (sample interval/retention, online threshold, UI refresh) with traffic sample pruning; host tuning (UDP buffers, backlog, GRO forwarding) - Precompiled Tailwind CSS replacing Play CDN runtime (fixes FOUC); stable table layout and diffed polling renders - Host network mode in compose; NAT/isolation iptables moved into app sync Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent
192960ad3e
commit
1b227c2470
Vendored
+45
File diff suppressed because one or more lines are too long
+36
-30
@@ -1,4 +1,4 @@
|
||||
const REFRESH_MS = 5000;
|
||||
let refreshMs = 5000;
|
||||
|
||||
function fmtBytes(num) {
|
||||
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
|
||||
@@ -28,39 +28,45 @@ async function refresh() {
|
||||
return;
|
||||
}
|
||||
|
||||
const online = data.peers.filter((p) => p.online).length;
|
||||
const set = (id, value) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = value;
|
||||
};
|
||||
set("peer-count", data.peers.length);
|
||||
set("online-count", online);
|
||||
set("total-rx", fmtBytes(data.interface.total_rx));
|
||||
set("total-tx", fmtBytes(data.interface.total_tx));
|
||||
if (data.meta && data.meta.refresh_seconds) {
|
||||
refreshMs = data.meta.refresh_seconds * 1000;
|
||||
}
|
||||
|
||||
const tbody = document.querySelector("#peer-table tbody");
|
||||
if (!tbody) return;
|
||||
const badgeBase = "rounded-full px-2 py-0.5 text-xs font-medium";
|
||||
tbody.innerHTML = "";
|
||||
for (const p of data.peers) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "hover:bg-slate-50";
|
||||
const badge = p.online
|
||||
? `<span class="${badgeBase} bg-emerald-100 text-emerald-700">online</span>`
|
||||
: p.enabled
|
||||
? `<span class="${badgeBase} bg-slate-100 text-slate-500">offline</span>`
|
||||
: `<span class="${badgeBase} bg-red-100 text-red-700">disabled</span>`;
|
||||
tr.innerHTML =
|
||||
`<td class="px-4 py-3"><a href="/peers/${p.id}" class="font-medium text-blue-600 hover:underline"></a></td>` +
|
||||
`<td class="px-4 py-3 font-mono text-xs">${p.address}</td>` +
|
||||
`<td class="px-4 py-3">${badge}</td>` +
|
||||
`<td class="px-4 py-3 text-slate-500">${fmtHandshake(p.latest_handshake)}</td>` +
|
||||
`<td class="px-4 py-3 text-slate-500">${fmtBytes(p.rx_bytes)}</td>` +
|
||||
`<td class="px-4 py-3 text-slate-500">${fmtBytes(p.tx_bytes)}</td>`;
|
||||
tr.querySelector("a").textContent = p.name;
|
||||
tbody.appendChild(tr);
|
||||
const esc = (s) =>
|
||||
String(s).replace(/[&<>"]/g, (ch) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """ })[ch]
|
||||
);
|
||||
let html = "";
|
||||
for (const iface of data.interfaces) {
|
||||
for (const p of iface.peers) {
|
||||
const badge = p.online
|
||||
? `<span class="${badgeBase} bg-emerald-100 text-emerald-700">online</span>`
|
||||
: p.enabled
|
||||
? `<span class="${badgeBase} bg-slate-100 text-slate-500">offline</span>`
|
||||
: `<span class="${badgeBase} bg-red-100 text-red-700">disabled</span>`;
|
||||
html +=
|
||||
`<tr class="hover:bg-slate-50">` +
|
||||
`<td class="truncate px-4 py-3"><a href="/peers/${p.id}" class="font-medium text-blue-600 hover:underline">${esc(p.name)}</a></td>` +
|
||||
`<td class="truncate px-4 py-3 text-slate-500">${esc(iface.name)}</td>` +
|
||||
`<td class="truncate px-4 py-3 font-mono text-xs">${esc(p.address)}</td>` +
|
||||
`<td class="px-4 py-3">${badge}</td>` +
|
||||
`<td class="whitespace-nowrap px-4 py-3 text-slate-500 tabular-nums">${fmtHandshake(p.latest_handshake)}</td>` +
|
||||
`<td class="whitespace-nowrap px-4 py-3 text-slate-500 tabular-nums">${fmtBytes(p.rx_bytes)}</td>` +
|
||||
`<td class="whitespace-nowrap px-4 py-3 text-slate-500 tabular-nums">${fmtBytes(p.tx_bytes)}</td>` +
|
||||
`</tr>`;
|
||||
}
|
||||
}
|
||||
if (tbody.dataset.html !== html) {
|
||||
tbody.dataset.html = html;
|
||||
tbody.innerHTML = html;
|
||||
}
|
||||
}
|
||||
|
||||
refresh();
|
||||
setInterval(refresh, REFRESH_MS);
|
||||
async function loop() {
|
||||
await refresh();
|
||||
setTimeout(loop, refreshMs);
|
||||
}
|
||||
loop();
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,209 @@
|
||||
let refreshMs = 5000;
|
||||
let lastPayload = "";
|
||||
|
||||
const container = document.getElementById("topology-chart");
|
||||
const chart = echarts.init(container);
|
||||
chart.on("click", (params) => {
|
||||
if (params.dataType === "node" && params.data.peerId) {
|
||||
window.location.href = `/peers/${params.data.peerId}`;
|
||||
}
|
||||
});
|
||||
window.addEventListener("resize", () => chart.resize());
|
||||
|
||||
const COL_X = { server: 120, peer: 480, subnet: 840 };
|
||||
const PEER_GAP = 78;
|
||||
const SUBNET_GAP = 34;
|
||||
const BAND_GAP = 70;
|
||||
|
||||
function fmtBytes(num) {
|
||||
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
|
||||
let i = 0;
|
||||
while (Math.abs(num) >= 1024 && i < units.length - 1) {
|
||||
num /= 1024;
|
||||
i++;
|
||||
}
|
||||
return num.toFixed(1) + " " + units[i];
|
||||
}
|
||||
|
||||
function splitCidrs(value) {
|
||||
return (value || "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function peerCategory(p) {
|
||||
if (!p.enabled) return "Disabled";
|
||||
return p.online ? "Online" : "Offline";
|
||||
}
|
||||
|
||||
function buildOption(data) {
|
||||
const nodes = [];
|
||||
const links = [];
|
||||
let y = 0;
|
||||
|
||||
for (const iface of data.interfaces) {
|
||||
const bandTop = y;
|
||||
let peerY = bandTop;
|
||||
|
||||
for (const p of iface.peers) {
|
||||
const subnets = splitCidrs(p.extra_allowed_ips);
|
||||
const rowHeight = Math.max(PEER_GAP, subnets.length * SUBNET_GAP + 26);
|
||||
const rowCenter = peerY + rowHeight / 2;
|
||||
const active = p.enabled && p.online;
|
||||
|
||||
const lines = [
|
||||
`Peer: ${p.name}${p.has_private_key ? "" : " (imported)"}`,
|
||||
`Address: ${p.address}`,
|
||||
`Status: ${peerCategory(p).toLowerCase()}`,
|
||||
];
|
||||
if (p.endpoint) lines.push(`Endpoint: ${p.endpoint}`);
|
||||
lines.push(`RX ${fmtBytes(p.rx_bytes)} / TX ${fmtBytes(p.tx_bytes)}`);
|
||||
if (p.client_allowed_ips) lines.push(`Client routes: ${p.client_allowed_ips}`);
|
||||
|
||||
nodes.push({
|
||||
id: `peer-${p.id}`,
|
||||
peerId: p.id,
|
||||
name: p.name,
|
||||
category: peerCategory(p),
|
||||
x: COL_X.peer,
|
||||
y: rowCenter,
|
||||
symbolSize: 26,
|
||||
tooltipLines: lines,
|
||||
});
|
||||
links.push({
|
||||
source: `iface-${iface.id}`,
|
||||
target: `peer-${p.id}`,
|
||||
lineStyle: {
|
||||
width: active ? 2.5 : 1.5,
|
||||
type: active ? "solid" : "dashed",
|
||||
color: active ? "#10b981" : "#94a3b8",
|
||||
curveness: 0.12,
|
||||
},
|
||||
});
|
||||
|
||||
subnets.forEach((subnet, i) => {
|
||||
const id = `subnet-${p.id}-${subnet}`;
|
||||
nodes.push({
|
||||
id,
|
||||
name: subnet,
|
||||
category: "Site subnet",
|
||||
x: COL_X.subnet,
|
||||
y: rowCenter - ((subnets.length - 1) * SUBNET_GAP) / 2 + i * SUBNET_GAP,
|
||||
symbol: "roundRect",
|
||||
symbolSize: [Math.max(96, subnet.length * 7.5), 22],
|
||||
label: {
|
||||
position: "inside",
|
||||
fontSize: 10,
|
||||
fontFamily: "monospace",
|
||||
color: "#92400e",
|
||||
},
|
||||
tooltipLines: [`Site subnet: ${subnet}`, `Via peer: ${p.name}`],
|
||||
});
|
||||
links.push({
|
||||
source: `peer-${p.id}`,
|
||||
target: id,
|
||||
lineStyle: {
|
||||
width: 1.5,
|
||||
type: p.enabled ? "solid" : "dashed",
|
||||
color: "#f59e0b",
|
||||
curveness: 0.12,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
peerY += rowHeight;
|
||||
}
|
||||
|
||||
const bandHeight = Math.max(peerY - bandTop, PEER_GAP);
|
||||
nodes.push({
|
||||
id: `iface-${iface.id}`,
|
||||
name: iface.name,
|
||||
category: "Server",
|
||||
x: COL_X.server,
|
||||
y: bandTop + bandHeight / 2,
|
||||
symbolSize: 52,
|
||||
label: { fontWeight: "bold" },
|
||||
tooltipLines: [
|
||||
`Interface: ${iface.name} (${iface.up ? "up" : "down"})${iface.imported ? " [imported]" : ""}`,
|
||||
`Address: ${iface.address}`,
|
||||
`Subnet: ${iface.subnet}`,
|
||||
`Endpoint: ${iface.host}:${iface.listen_port}`,
|
||||
`Peer isolation: ${iface.peer_isolation ? "on" : "off"}`,
|
||||
`RX ${fmtBytes(iface.total_rx)} / TX ${fmtBytes(iface.total_tx)}`,
|
||||
],
|
||||
itemStyle: iface.up ? {} : { color: "#94a3b8" },
|
||||
});
|
||||
|
||||
y = bandTop + bandHeight + BAND_GAP;
|
||||
}
|
||||
|
||||
const totalHeight = Math.max(y - BAND_GAP, 200);
|
||||
container.style.height = Math.max(480, totalHeight + 120) + "px";
|
||||
chart.resize();
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
formatter: (params) =>
|
||||
params.dataType === "node" && params.data.tooltipLines
|
||||
? params.data.tooltipLines.join("<br>")
|
||||
: "",
|
||||
},
|
||||
legend: {
|
||||
bottom: 0,
|
||||
data: ["Server", "Online", "Offline", "Disabled", "Site subnet"],
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: "graph",
|
||||
layout: "none",
|
||||
roam: true,
|
||||
edgeSymbol: ["none", "arrow"],
|
||||
edgeSymbolSize: 6,
|
||||
label: { show: true, position: "right", fontSize: 11, color: "#334155" },
|
||||
categories: [
|
||||
{ name: "Server", itemStyle: { color: "#1e293b" } },
|
||||
{ name: "Online", itemStyle: { color: "#10b981" } },
|
||||
{ name: "Offline", itemStyle: { color: "#cbd5e1" } },
|
||||
{ name: "Disabled", itemStyle: { color: "#f87171" } },
|
||||
{ name: "Site subnet", itemStyle: { color: "#fef3c7", borderColor: "#f59e0b", borderWidth: 1 } },
|
||||
],
|
||||
emphasis: { focus: "adjacency" },
|
||||
data: nodes,
|
||||
links,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const res = await fetch("/api/status");
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (data.meta && data.meta.refresh_seconds) {
|
||||
refreshMs = data.meta.refresh_seconds * 1000;
|
||||
}
|
||||
const payload = JSON.stringify(data.interfaces);
|
||||
if (payload === lastPayload) return;
|
||||
lastPayload = payload;
|
||||
chart.setOption(buildOption(data), true);
|
||||
const badge = document.getElementById("isolation-badge");
|
||||
if (badge) {
|
||||
const isolated = data.interfaces
|
||||
.filter((i) => i.peer_isolation)
|
||||
.map((i) => i.name);
|
||||
badge.innerHTML = isolated.length
|
||||
? `<span class="rounded-full bg-amber-100 px-3 py-1 text-xs font-medium text-amber-700">Peer isolation on: ${isolated.join(", ")}</span>`
|
||||
: "";
|
||||
}
|
||||
} catch {
|
||||
/* keep last rendering */
|
||||
}
|
||||
}
|
||||
|
||||
async function loop() {
|
||||
await refresh();
|
||||
setTimeout(loop, refreshMs);
|
||||
}
|
||||
loop();
|
||||
Reference in New Issue
Block a user