ca27ea5522
component.js now creates an AbortController for each page mount, passing it to load(). On unmount, the controller is aborted to cancel in-flight requests that would otherwise mutate unmounted state. Page load functions consistently pass the signal to apiFetch and guard state mutations with abort checks. This eliminates the need for per-page abortController boilerplate and prevents stale errors from appearing on rapid navigation. Users page now guards catch block and loading state cleanup against aborted requests, matching passkeys.js pattern.
579 lines
27 KiB
JavaScript
579 lines
27 KiB
JavaScript
/** WireGuard page — tunnel & peer management. */
|
|
import { html, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, modelFetch, ActionButton, ActionCell, MonoText, ActionGroup, QuickModal, downloadBlob, formAction, ApplyConfirm, qrSVG } from '/static/hoover/index.js?v=10';
|
|
|
|
/* ── LAN detection helper ────────────────────────────────────── */
|
|
function getLanSubnets() {
|
|
try {
|
|
const fw = getModel('firewall');
|
|
if (!fw?.data) return [];
|
|
const subnets = [];
|
|
for (const iface of (fw.data.interfaces || [])) {
|
|
if (!iface.zone) continue;
|
|
const zone = fw.data.zones?.[iface.zone];
|
|
if (!zone || zone.masquerade) continue;
|
|
for (const ip of (iface.ips || [])) {
|
|
if (!ip.includes('/')) continue;
|
|
if (!subnets.includes(ip)) subnets.push(ip);
|
|
}
|
|
}
|
|
return subnets;
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/* ── Allowed IPs helper ──────────────────────────────────────── */
|
|
function parseAllowedIps(value) {
|
|
if (!value || !value.trim()) return [];
|
|
return value.split(',').map(s => s.trim()).filter(Boolean);
|
|
}
|
|
|
|
/* ── Color helpers ────────────────────────────────────────────── */
|
|
function classColor(classKey) {
|
|
if (!classKey) return '';
|
|
const h = classKey.split('').reduce((a, c) => a + c.charCodeAt(0), 0);
|
|
return '#' + ((h * 137) % 256).toString(16).padStart(2, '0')
|
|
+ '55' + ((h * 71) % 256).toString(16).padStart(2, '0');
|
|
}
|
|
|
|
/* ── Access classes helper to check keys initialized ──────── */
|
|
function classHasKeys(cls) {
|
|
return cls && cls.public_key && cls.public_key.length > 0;
|
|
}
|
|
|
|
/* ── Add Peer Modal ──────────────────────────────────────────── */
|
|
const addPeer = QuickModal({
|
|
title: 'Add WireGuard Peer',
|
|
fields: [
|
|
{ label: 'Name', id: 'wg-name', placeholder: 'client-name' },
|
|
{ label: 'Endpoint (optional)', id: 'wg-endpoint', placeholder: '1.2.3.4:51820' },
|
|
{ label: 'Description (optional)', id: 'wg-description', placeholder: 'Peer label' },
|
|
{ label: 'Access Class *', id: 'wg-class', tag: 'select' },
|
|
{ label: 'Allowed IPs Preset', id: 'wg-allowed-preset', tag: 'select', value: 'all' },
|
|
{ label: 'Allowed IPs (custom)', id: 'wg-allowed', placeholder: '0.0.0.0/0' },
|
|
{ label: 'Persistent Keepalive (optional)', id: 'wg-keepalive', type: 'number', placeholder: '25' },
|
|
],
|
|
submit: {
|
|
url: '/api/wireguard/peers',
|
|
body: (data) => {
|
|
const preset = ($val('wg-allowed-preset') || 'all');
|
|
let allowed_ips;
|
|
if (preset === 'lan') {
|
|
allowed_ips = getLanSubnets();
|
|
} else if (preset === 'none') {
|
|
allowed_ips = [];
|
|
} else if (preset === 'custom') {
|
|
allowed_ips = parseAllowedIps($val('wg-allowed'));
|
|
} else {
|
|
allowed_ips = ['0.0.0.0/0'];
|
|
}
|
|
return {
|
|
name: ($val('wg-name') || '').trim(),
|
|
endpoint: ($val('wg-endpoint') || '').trim() || undefined,
|
|
description: ($val('wg-description') || '').trim() || undefined,
|
|
access_class: ($val('wg-class') || '').trim() || undefined,
|
|
allowed_ips,
|
|
persistent_keepalive: $val('wg-keepalive') ? parseInt($val('wg-keepalive')) : undefined,
|
|
};
|
|
},
|
|
validate: (b) => !b.name ? 'Name is required' :
|
|
!b.access_class ? 'Access Class is required' : null,
|
|
successMsg: 'Peer added',
|
|
},
|
|
refresh: 'wireguard',
|
|
postRender: (inner, data) => {
|
|
const presetEl = document.getElementById('wg-allowed-preset');
|
|
if (presetEl) {
|
|
const customField = inner.querySelector('.form-group:has(#wg-allowed)');
|
|
const toggle = () => {
|
|
customField.style.display = presetEl.value === 'custom' ? '' : 'none';
|
|
};
|
|
presetEl.onchange = toggle;
|
|
toggle();
|
|
}
|
|
},
|
|
});
|
|
|
|
function updateAddPeerOptions(wireguardState) {
|
|
const classes = wireguardState?.access_classes || {};
|
|
updateClassDropdown(classes);
|
|
updateLanOptions();
|
|
}
|
|
|
|
function updateClassDropdown(classes) {
|
|
const selectEl = document.getElementById('wg-class');
|
|
if (!selectEl) return;
|
|
selectEl.innerHTML = Object.entries(classes).map(([k, v]) =>
|
|
`<option value="${esc(k)}">${esc(v.name || k)}</option>`
|
|
).join('');
|
|
}
|
|
|
|
function updateLanOptions() {
|
|
const selectEl = document.getElementById('wg-allowed-preset');
|
|
if (!selectEl) return;
|
|
const subnets = getLanSubnets();
|
|
selectEl.innerHTML =
|
|
'<option value="all">Route all traffic (default)</option>' +
|
|
(subnets.length
|
|
? `<option value="lan">Route LAN only (${esc(subnets.join(', '))})</option>`
|
|
: '<option value="lan">Route LAN only (detecting\u2026)</option>') +
|
|
'<option value="none">Route nothing (peer-initiated only)</option>' +
|
|
'<option value="custom">Custom</option>';
|
|
}
|
|
|
|
/* ── Download Config + QR Modal ──────────────────────────────── */
|
|
function downloadConfigModal(peerName, config, state) {
|
|
const peer = (config?.peers || {})[peerName];
|
|
const ak = peer?.access_class;
|
|
let listenPort = 51820;
|
|
if (ak && config?.access_classes?.[ak]) {
|
|
listenPort = config.access_classes[ak].listen_port || 51820;
|
|
}
|
|
const baseHost = (config?.interface?.server_endpoint || '').split(':')[0];
|
|
const endpoint = baseHost ? baseHost + ':' + listenPort : '';
|
|
|
|
let generatedConfig = null;
|
|
let logoBase64 = null;
|
|
|
|
const renderQrView = (innerEl, modalIdx) => {
|
|
const svgStr = qrSVG({ text: generatedConfig, size: 256, logo: logoBase64, logoSize: 48 });
|
|
innerEl.innerHTML = `
|
|
<h4 class="mb-3">Peer Config: ${esc(peerName)}</h4>
|
|
|
|
<div class="text-center mb-3">${svgStr}</div>
|
|
|
|
<div class="mb-3">
|
|
<label class="form-label">Logo overlay (rescans QR)</label>
|
|
<input type="file" id="qr-logo-input" accept="image/*" class="form-control mb-1">
|
|
<small class="text-muted">Upload a logo to overlay on the QR code.</small>
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<label class="form-label">Config Text</label>
|
|
<pre class="code" style="max-height:200px;overflow:auto;font-size:0.8rem;">${esc(generatedConfig)}</pre>
|
|
</div>
|
|
|
|
<div class="d-flex justify-content-end gap-2 mt-3">
|
|
<button class="btn btn-sm btn-outline" id="qr-restart">Regenerate</button>
|
|
<button class="btn btn-sm btn-outline" id="qr-download">Download .conf</button>
|
|
<button class="btn btn-sm btn-primary" id="qr-close">Close</button>
|
|
</div>
|
|
`;
|
|
|
|
innerEl.querySelector('#qr-logo-input').addEventListener('change', (e) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
const reader = new FileReader();
|
|
reader.onload = (ev) => {
|
|
logoBase64 = ev.target.result;
|
|
renderQrView(innerEl, modalIdx);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
});
|
|
|
|
innerEl.querySelector('#qr-restart').addEventListener('click', () => {
|
|
generatedConfig = null;
|
|
logoBase64 = null;
|
|
openModal((inn, i) => downloadConfigModal(peerName, config, state), modalIdx);
|
|
});
|
|
|
|
innerEl.querySelector('#qr-download').addEventListener('click', () => {
|
|
downloadBlob(new Blob([generatedConfig], { type: 'text/plain' }), peerName + '.conf');
|
|
toast('Config downloaded', 'success');
|
|
closeModal(modalIdx);
|
|
});
|
|
|
|
innerEl.querySelector('#qr-close').addEventListener('click', () => closeModal(modalIdx));
|
|
};
|
|
|
|
openModal((inner, idx) => {
|
|
if (generatedConfig) {
|
|
renderQrView(inner, idx);
|
|
return;
|
|
}
|
|
|
|
formModal(inner, 'Peer Config: ' + peerName,
|
|
[
|
|
{ label: 'Server Endpoint', id: 'wg-srv-endpoint', placeholder: 'vpn.example.com:51820', value: endpoint },
|
|
],
|
|
[
|
|
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
|
|
{
|
|
label: 'Generate', cls: 'btn-primary', action: 's',
|
|
handler: formAction(async () => {
|
|
const ep = ($val('wg-srv-endpoint') || '').trim();
|
|
if (!ep) throw 'Server endpoint is required';
|
|
const resp = await apiFetch('/api/wireguard/generate-client', {
|
|
method: 'POST',
|
|
body: { name: peerName, server_endpoint: ep },
|
|
});
|
|
if (!resp.ok) throw resp.error || 'Failed';
|
|
const configContent = resp.data?.config;
|
|
if (!configContent) throw 'No config returned';
|
|
generatedConfig = configContent;
|
|
|
|
// Re-render modal with QR view
|
|
openModal((inn, i) => downloadConfigModal(peerName, config, state), idx);
|
|
}),
|
|
},
|
|
],
|
|
);
|
|
});
|
|
}
|
|
|
|
/* ── Interface Settings Modal ────────────────────────────────── */
|
|
function settingsModal(wireguardData, state) {
|
|
const iface = wireguardData?.config?.interface || {};
|
|
openModal((inner, idx) => {
|
|
formModal(inner, 'WireGuard Settings',
|
|
[
|
|
{ label: 'Listen Port', id: 'wg-port', type: 'number', value: iface.listen_port || 51820, placeholder: '51820' },
|
|
{ label: 'Addresses (comma-separated CIDR)', id: 'wg-addrs', value: (iface.addresses || []).join(', ') || '10.137.0.1/24' },
|
|
{ label: 'Server Endpoint', id: 'wg-srv-endpoint', placeholder: 'vpn.example.com:51820', value: iface.server_endpoint || '' },
|
|
{ label: 'Description', id: 'wg-desc', value: iface.description || '', placeholder: 'Optional label' },
|
|
{ label: 'PostUp (advanced)', id: 'wg-post-up', tag: 'textarea', value: iface.post_up || '', placeholder: 'Shell command after interface up' },
|
|
{ label: 'PostDown (advanced)', id: 'wg-post-down', tag: 'textarea', value: iface.post_down || '', placeholder: 'Shell command after interface down' },
|
|
],
|
|
[
|
|
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
|
|
{
|
|
label: 'Save', cls: 'btn-primary', action: 's',
|
|
handler: formAction(async () => {
|
|
const port = parseInt($val('wg-port'), 10);
|
|
if (isNaN(port) || port < 1 || port > 65535) throw 'Invalid port';
|
|
const addresses = ($val('wg-addrs') || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
if (!addresses.length) throw 'At least one address required';
|
|
const body = {
|
|
interface: {
|
|
listen_port: port,
|
|
addresses,
|
|
server_endpoint: ($val('wg-srv-endpoint') || '').trim() || undefined,
|
|
description: ($val('wg-desc') || '').trim() || undefined,
|
|
post_up: ($val('wg-post-up') || '').trim() || null,
|
|
post_down: ($val('wg-post-down') || '').trim() || null,
|
|
},
|
|
};
|
|
const resp = await apiFetch('/api/wireguard/config', {
|
|
method: 'PATCH',
|
|
body,
|
|
});
|
|
if (!resp.ok) throw resp.error || 'Failed to save';
|
|
toast('Settings saved', 'success');
|
|
closeModal(idx);
|
|
modelFetch('wireguard');
|
|
}),
|
|
},
|
|
],
|
|
);
|
|
});
|
|
}
|
|
|
|
/* ── Class Settings Modal ────────────────────────────────────── */
|
|
const addClass = QuickModal({
|
|
title: 'Add Access Class',
|
|
fields: [
|
|
{ label: 'Key', id: 'wc-key', placeholder: 'my-class' },
|
|
{ label: 'Name', id: 'wc-name', placeholder: 'Display Name' },
|
|
{ label: 'Description', id: 'wc-desc', placeholder: 'Optional' },
|
|
{ label: 'Subnet (CIDR)', id: 'wc-subnet', placeholder: '10.137.2.0/24' },
|
|
{ label: 'Listen Port', id: 'wc-port', type: 'number', placeholder: '51822' },
|
|
{ label: 'LAN Access', id: 'wc-lan', tag: 'select', value: '0' },
|
|
],
|
|
submit: {
|
|
url: '/api/wireguard/classes',
|
|
body: () => ({
|
|
key: ($val('wc-key') || '').trim(),
|
|
name: ($val('wc-name') || '').trim(),
|
|
description: ($val('wc-desc') || '').trim(),
|
|
subnet: ($val('wc-subnet') || '').trim() || undefined,
|
|
listen_port: parseInt($val('wc-port'), 10) || 0,
|
|
lan_access: $val('wc-lan') === '1',
|
|
}),
|
|
validate: (b) => !b.key ? 'Key is required' :
|
|
!/^[a-z0-9]+$/.test(b.key) ? 'Key must be lowercase alphanumeric' :
|
|
!b.subnet ? 'Subnet is required' :
|
|
!b.listen_port ? 'Listen port is required' : null,
|
|
successMsg: 'Class added',
|
|
},
|
|
refresh: 'wireguard',
|
|
postRender: (inner) => {
|
|
const sel = document.getElementById('wc-lan');
|
|
if (sel) {
|
|
sel.innerHTML = '<option value="1">Yes (Full LAN Access)</option><option value="0">No (Internet Only)</option>';
|
|
}
|
|
},
|
|
});
|
|
|
|
function editClassModal(key, cls, peerCount) {
|
|
openModal((inner, idx) => {
|
|
formModal(inner, 'Edit Access Class: ' + key,
|
|
[
|
|
{ label: 'Key', id: 'wc-key', value: key, disabled: true },
|
|
{ label: 'Name', id: 'wc-name', value: cls?.name || '' },
|
|
{ label: 'Description', id: 'wc-desc', value: cls?.description || '' },
|
|
{ label: 'Subnet (CIDR)', id: 'wc-subnet', value: cls?.subnet || '', placeholder: '10.137.2.0/24' },
|
|
{ label: 'Listen Port', id: 'wc-port', type: 'number', value: cls?.listen_port || '', placeholder: '51822' },
|
|
{ label: 'LAN Access', id: 'wc-lan', tag: 'select', value: cls?.lan_access ? '1' : '0' },
|
|
],
|
|
[
|
|
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
|
|
{
|
|
label: 'Save', cls: 'btn-primary', action: 's',
|
|
handler: formAction(async () => {
|
|
const resp = await apiFetch('/api/wireguard/classes', {
|
|
method: 'PATCH',
|
|
body: {
|
|
key,
|
|
name: ($val('wc-name') || '').trim() || key,
|
|
description: ($val('wc-desc') || '').trim(),
|
|
subnet: ($val('wc-subnet') || '').trim() || undefined,
|
|
listen_port: parseInt($val('wc-port'), 10) || undefined,
|
|
lan_access: $val('wc-lan') === '1',
|
|
},
|
|
});
|
|
if (!resp.ok) throw resp.error || 'Failed';
|
|
toast('Class updated', 'success');
|
|
closeModal(idx);
|
|
modelFetch('wireguard');
|
|
}),
|
|
},
|
|
],
|
|
);
|
|
});
|
|
}
|
|
|
|
async function initClassKeys(classKey) {
|
|
const resp = await apiFetch('/api/wireguard/classes/keys/' + enc(classKey), {
|
|
method: 'POST',
|
|
});
|
|
if (!resp.ok) {
|
|
toast(resp.error || 'Failed to generate keys', 'error');
|
|
return;
|
|
}
|
|
toast('Keys generated for class "' + classKey + '"', 'success');
|
|
modelFetch('wireguard');
|
|
}
|
|
|
|
async function deleteAccessClass(key) {
|
|
if (!confirm(`Delete access class '${key}'?`)) return;
|
|
const resp = await apiFetch('/api/wireguard/classes', {
|
|
method: 'DELETE',
|
|
body: { key },
|
|
});
|
|
if (!resp.ok) {
|
|
toast(resp.error || 'Failed to delete class', 'error');
|
|
return;
|
|
}
|
|
toast('Class deleted', 'success');
|
|
modelFetch('wireguard');
|
|
}
|
|
|
|
async function toggleClassTunnel(classKey, isUp) {
|
|
const url = '/api/wireguard/classes/' + enc(classKey) + '/' + (isUp ? 'down' : 'up');
|
|
const resp = await apiFetch(url, { method: 'POST' });
|
|
if (!resp.ok) {
|
|
toast(resp.error || 'Failed', 'error');
|
|
return;
|
|
}
|
|
toast('Tunnel ' + (isUp ? 'stopped' : 'started'), 'success');
|
|
modelFetch('wireguard');
|
|
}
|
|
|
|
/* ── Access Classes Section ──────────────────────────────────── */
|
|
function renderAccessClasses(config, status) {
|
|
const classes = config?.access_classes || {};
|
|
const entries = Object.entries(classes);
|
|
if (!entries.length) return null;
|
|
|
|
const peerCountMap = {};
|
|
for (const [pname, pinfo] of Object.entries(config?.peers || {})) {
|
|
const ac = pinfo?.access_class;
|
|
if (ac) {
|
|
peerCountMap[ac] = (peerCountMap[ac] || 0) + 1;
|
|
}
|
|
}
|
|
|
|
const classStatuses = status?.classes || {};
|
|
|
|
const rows = entries.map(([k, v]) => {
|
|
const pCount = peerCountMap[k] || 0;
|
|
const clsStatus = classStatuses[k] || { up: false };
|
|
const isUp = clsStatus.up;
|
|
const hasKeys = classHasKeys(v);
|
|
const color = classColor(k);
|
|
return html`<tr key=${k}>
|
|
<td style="border-left: 3px solid ${color}"><strong>${esc(k)}</strong></td>
|
|
<td>${esc(v.name || k)}</td>
|
|
<td class="text-sm">${esc(v.description || '-')}</td>
|
|
<td class="text-sm">${esc(v.subnet || '-')}</td>
|
|
<td class="text-sm">${v.listen_port || '-'}</td>
|
|
<td class="text-sm">${v.lan_access ? 'Yes' : 'No'}</td>
|
|
<td>${pCount}</td>
|
|
<td class="text-sm">
|
|
<${StatusDot} status=${isUp ? 'success' : 'danger'} />
|
|
</td>
|
|
<td>
|
|
${!hasKeys
|
|
? html`<button class="btn btn-sm btn-warning" onClick=${() => initClassKeys(k)} title="Generate keys">Keys</button>`
|
|
: ''}
|
|
<button class="btn btn-sm btn-outline" onClick=${() => editClassModal(k, v, pCount)}>Edit</button>
|
|
<button class="btn btn-sm btn-outline" onClick=${() => toggleClassTunnel(k, isUp)}>${isUp ? 'Stop' : 'Start'}</button>
|
|
${(pCount > 0)
|
|
? html`<button class="btn btn-sm btn-outline" disabled title="Peers reference this class">Delete</button>`
|
|
: html`<button class="btn btn-sm btn-outline" onClick=${() => deleteAccessClass(k)}>Delete</button>`}
|
|
</td>
|
|
</tr>`;
|
|
});
|
|
|
|
return html`<div class="mt-4">
|
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
<h4 class="section-title m-0">Access Classes</h4>
|
|
<button class="btn btn-sm btn-primary" onClick=${() => addClass()}>Add Class</button>
|
|
</div>
|
|
<table class="table table-sm"><thead><tr>
|
|
<th>Key</th><th>Name</th><th>Description</th><th>Subnet</th><th>Port</th><th>LAN</th><th>Peers</th><th>Status</th><th>Actions</th>
|
|
</tr></thead><tbody>${rows}</tbody></table>
|
|
</div>`;
|
|
}
|
|
|
|
/* ── Main Page ───────────────────────────────────────────────── */
|
|
export default definePage({
|
|
init() {
|
|
return {
|
|
wireguard: getModel('wireguard'),
|
|
};
|
|
},
|
|
subscribe(state) {
|
|
// Update add-peer modal options when state changes
|
|
if (state.wireguard?.data) {
|
|
updateAddPeerOptions(state.wireguard.data.config);
|
|
}
|
|
},
|
|
render(state) {
|
|
const guard = renderGuard(state.wireguard, 'WireGuard', 'Tunnel & peer management', state.wireguard.data?.config);
|
|
if (guard) return guard;
|
|
|
|
const wgData = state.wireguard.data;
|
|
const st = wgData?.status || {};
|
|
const config = wgData?.config || {};
|
|
const isUp = st.up || false;
|
|
const listenPort = config.interface?.listen_port || '-';
|
|
const serverEndpoint = config.interface?.server_endpoint || '';
|
|
|
|
// Build merged peer rows: configured peers + live status
|
|
const configuredPeers = wgData?.peers || [];
|
|
const statusPeersMap = {};
|
|
for (const [cKey, cSt] of Object.entries(st.classes || {})) {
|
|
for (const sp of (cSt.peers || [])) {
|
|
statusPeersMap[sp.public_key] = { ...sp, _class: cKey };
|
|
}
|
|
}
|
|
// Also check legacy status peers
|
|
for (const sp of (st.peers || [])) {
|
|
statusPeersMap[sp.public_key] = sp;
|
|
}
|
|
|
|
const peersByClass = config?.access_classes || {};
|
|
|
|
const peerRows = configuredPeers.map(p => {
|
|
const sp = statusPeersMap[p.public_key];
|
|
const isConnected = sp && !!sp.latest_handshake;
|
|
const accessClass = p.access_class;
|
|
const classInfo = accessClass ? (peersByClass[accessClass] || null) : null;
|
|
const borderColor = classInfo ? ' style="border-left: 3px solid ' + classColor(accessClass) + '"' : '';
|
|
return html`<tr key=${p.name}${borderColor}>
|
|
<td>
|
|
<${StatusDot} status=${isConnected ? 'success' : 'danger'} />
|
|
<strong>${esc(p.name || 'unnamed')}</strong>
|
|
${p.description ? html`<br/><span class="text-muted text-sm">${esc(p.description)}</span>` : ''}
|
|
</td>
|
|
<td><${MonoText} text=${p.public_key || 'N/A'} maxLength=20 /></td>
|
|
<td class="text-sm">${esc((p.allowed_ips || []).join(', ') || '-')}</td>
|
|
<td class="text-sm">${esc(p.endpoint || '-')}</td>
|
|
<td class="text-sm">
|
|
${classInfo
|
|
? html`<${Badge} text=${esc(classInfo.name)} cls="bg-info text-white" />`
|
|
: html`<${Badge} text="Unassigned" cls="bg-secondary text-white" />`}
|
|
</td>
|
|
<td class="text-sm">${esc(sp?.latest_handshake || 'Never')}</td>
|
|
<td class="text-sm">
|
|
Recv: ${esc(sp?.transfer_received || '0')}<br/>
|
|
Sent: ${esc(sp?.transfer_sent || '0')}
|
|
</td>
|
|
<${ActionCell}
|
|
editLabel="Config" editClick=${() => downloadConfigModal(p.name, config, state)}
|
|
removeUrl=${'/api/wireguard/peers/' + enc(p.name)}
|
|
removeMessage=${'Remove peer ' + p.name + '?'}
|
|
removeSuccess="Peer removed"
|
|
removeRefresh="wireguard"
|
|
deleteKey=${p.name} />
|
|
</tr>`;
|
|
});
|
|
|
|
// Per-class summary
|
|
const classEntries = Object.entries(config?.access_classes || {});
|
|
let classSummaryCards = null;
|
|
if (classEntries.length) {
|
|
const cards = classEntries.map(([k, v]) => {
|
|
const cSt = (st.classes || {})[k] || { up: false, peers: [] };
|
|
const isUp = cSt.up;
|
|
const pCount = (wgData?.peers || []).filter(p => p.access_class === k).length;
|
|
const color = classColor(k);
|
|
return html`<div key=${k} class="card" style="border-left: 3px solid ${color}">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<span><${StatusDot} status=${isUp ? 'success' : 'danger'} /> <strong>${esc(v.name || k)}</strong> <span class="text-muted">(${esc(k)})</span></span>
|
|
<span class="text-sm">${pCount} peer(s), port ${v.listen_port || '-'}</span>
|
|
</div>
|
|
<div class="card-body text-sm">
|
|
<div class="d-flex justify-content-between">
|
|
<span>Subnet: ${esc(v.subnet || '-')}</span>
|
|
<span>LAN: ${v.lan_access ? 'Yes' : 'No'}</span>
|
|
<span>Keys: ${classHasKeys(v) ? 'Ready' : html`<button class="btn btn-xs btn-warning" onClick=${() => initClassKeys(k)}>Generate</button>`}</span>
|
|
</div>
|
|
<div style="margin-top: 4px;">
|
|
<button class="btn btn-xs btn-outline" onClick=${() => toggleClassTunnel(k, isUp)}>${isUp ? 'Stop' : 'Start'}</button>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
});
|
|
classSummaryCards = html`<div class="mt-2 mb-2 d-flex gap-2 flex-wrap">${cards}</div>`;
|
|
}
|
|
|
|
const actions = ActionGroup(
|
|
html`<button class="btn btn-primary" onClick=${() => addPeer()}>Add Peer</button>`,
|
|
html`<button class="btn btn-outline" onClick=${() => settingsModal(wgData, state)} title="Interface Settings">\u{1F527}</button>`,
|
|
ActionButton({
|
|
url: '/api/wireguard/' + (isUp ? 'down' : 'up'),
|
|
labelOn: 'Stop All', labelOff: 'Start All', condition: isUp,
|
|
successMsg: 'Tunnel ' + (isUp ? 'stopped' : 'started'),
|
|
refresh: 'wireguard',
|
|
}),
|
|
ApplyConfirm({
|
|
pending: st.pending_changes || false,
|
|
successMsg: 'WireGuard applied',
|
|
refresh: ['wireguard', 'firewall'],
|
|
}),
|
|
);
|
|
|
|
const subtitleParts = ['Tunnel: ' + (isUp ? 'up' : 'down'), 'Listen: ' + listenPort];
|
|
if (serverEndpoint) subtitleParts.push('Endpoint: ' + serverEndpoint);
|
|
|
|
return [
|
|
PageHeader({
|
|
title: 'WireGuard',
|
|
subtitle: subtitleParts.join(' | '),
|
|
actions,
|
|
}),
|
|
ServiceStatus({ state: isUp ? 'up' : 'down' }),
|
|
classSummaryCards,
|
|
peerRows.length
|
|
? Table({
|
|
columns: ['Peer', 'Public Key', 'Allowed IPs', 'Endpoint', 'Class', 'Handshake', 'Transfer', 'Actions'],
|
|
rows: peerRows,
|
|
})
|
|
: Empty({ text: 'No peers configured. Add a peer above.' }),
|
|
renderAccessClasses(config, st),
|
|
];
|
|
},
|
|
});
|