feat: WireGuard Dashboard v0.5.0 - Bug fixes and enhancements
Bug fixes: - Fix QR code generation with JavaScript fallback library - Add missing API helper functions (getPeerStatusClass, shortenKey) - Fix traffic stats subshell variable scope bug - Fix peer add/remove UCI handling with unique section names Enhancements: - Add real-time auto-refresh with poll.add() (5s default) - Add SVG-based traffic charts component - Add peer configuration wizard with IP auto-suggestion - Add multi-interface management with tabs Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e4a553a6d5
commit
9ef0b6db18
@ -8,8 +8,8 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=luci-app-wireguard-dashboard
|
||||
PKG_VERSION:=0.4.0
|
||||
PKG_RELEASE:=2
|
||||
PKG_VERSION:=0.5.0
|
||||
PKG_RELEASE:=1
|
||||
PKG_ARCH:=all
|
||||
|
||||
PKG_LICENSE:=Apache-2.0
|
||||
|
||||
@ -8,12 +8,179 @@
|
||||
|
||||
return view.extend({
|
||||
title: _('WireGuard Dashboard'),
|
||||
|
||||
pollInterval: 5,
|
||||
pollActive: true,
|
||||
selectedInterface: 'all',
|
||||
|
||||
load: function() {
|
||||
return api.getAllData();
|
||||
},
|
||||
|
||||
|
||||
// Interface tab filtering
|
||||
setInterfaceFilter: function(ifaceName) {
|
||||
this.selectedInterface = ifaceName;
|
||||
var tabs = document.querySelectorAll('.wg-tab');
|
||||
tabs.forEach(function(tab) {
|
||||
tab.classList.toggle('active', tab.dataset.iface === ifaceName);
|
||||
});
|
||||
|
||||
// Filter peer cards
|
||||
var peerCards = document.querySelectorAll('.wg-peer-card');
|
||||
peerCards.forEach(function(card) {
|
||||
if (ifaceName === 'all' || card.dataset.interface === ifaceName) {
|
||||
card.style.display = '';
|
||||
} else {
|
||||
card.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Filter interface cards
|
||||
var ifaceCards = document.querySelectorAll('.wg-interface-card');
|
||||
ifaceCards.forEach(function(card) {
|
||||
if (ifaceName === 'all' || card.dataset.iface === ifaceName) {
|
||||
card.style.display = '';
|
||||
} else {
|
||||
card.style.display = 'none';
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
renderInterfaceTabs: function(interfaces) {
|
||||
var self = this;
|
||||
var tabs = [
|
||||
E('button', {
|
||||
'class': 'wg-tab active',
|
||||
'data-iface': 'all',
|
||||
'click': function() { self.setInterfaceFilter('all'); }
|
||||
}, 'All Interfaces')
|
||||
];
|
||||
|
||||
interfaces.forEach(function(iface) {
|
||||
tabs.push(E('button', {
|
||||
'class': 'wg-tab',
|
||||
'data-iface': iface.name,
|
||||
'click': function() { self.setInterfaceFilter(iface.name); }
|
||||
}, iface.name));
|
||||
});
|
||||
|
||||
return E('div', { 'class': 'wg-interface-tabs' }, tabs);
|
||||
},
|
||||
|
||||
// Update stats without full re-render
|
||||
updateStats: function(status) {
|
||||
var updates = [
|
||||
{ selector: '.wg-stat-interfaces', value: status.interface_count || 0 },
|
||||
{ selector: '.wg-stat-total-peers', value: status.total_peers || 0 },
|
||||
{ selector: '.wg-stat-active-peers', value: status.active_peers || 0 },
|
||||
{ selector: '.wg-stat-rx', value: api.formatBytes(status.total_rx || 0) },
|
||||
{ selector: '.wg-stat-tx', value: api.formatBytes(status.total_tx || 0) }
|
||||
];
|
||||
|
||||
updates.forEach(function(u) {
|
||||
var el = document.querySelector(u.selector);
|
||||
if (el && el.textContent !== String(u.value)) {
|
||||
el.textContent = u.value;
|
||||
el.classList.add('wg-value-updated');
|
||||
setTimeout(function() { el.classList.remove('wg-value-updated'); }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Update status badge
|
||||
var badge = document.querySelector('.wg-status-badge');
|
||||
if (badge) {
|
||||
var isActive = status.interface_count > 0;
|
||||
badge.classList.toggle('offline', !isActive);
|
||||
badge.innerHTML = '<span class="wg-status-dot"></span>' + (isActive ? 'VPN Active' : 'No Tunnels');
|
||||
}
|
||||
},
|
||||
|
||||
// Update peer cards
|
||||
updatePeers: function(peers) {
|
||||
var grid = document.querySelector('.wg-peer-grid');
|
||||
if (!grid) return;
|
||||
|
||||
peers.slice(0, 6).forEach(function(peer, idx) {
|
||||
var card = grid.children[idx];
|
||||
if (!card) return;
|
||||
|
||||
// Update status
|
||||
var statusEl = card.querySelector('.wg-peer-status');
|
||||
if (statusEl) {
|
||||
statusEl.textContent = peer.status;
|
||||
statusEl.className = 'wg-peer-status ' + api.getPeerStatusClass(peer.status);
|
||||
}
|
||||
|
||||
// Update handshake
|
||||
var hsEl = card.querySelector('.wg-peer-detail-value[data-field="handshake"]');
|
||||
if (hsEl) {
|
||||
hsEl.textContent = api.formatHandshake(peer.handshake_ago);
|
||||
}
|
||||
|
||||
// Update traffic
|
||||
var rxEl = card.querySelector('.wg-peer-traffic-value.rx');
|
||||
var txEl = card.querySelector('.wg-peer-traffic-value.tx');
|
||||
if (rxEl) rxEl.textContent = api.formatBytes(peer.rx_bytes);
|
||||
if (txEl) txEl.textContent = api.formatBytes(peer.tx_bytes);
|
||||
|
||||
// Update active state
|
||||
card.classList.toggle('active', peer.status === 'active');
|
||||
});
|
||||
|
||||
// Update badge count
|
||||
var activePeers = peers.filter(function(p) { return p.status === 'active'; }).length;
|
||||
var badge = document.querySelector('.wg-peers-badge');
|
||||
if (badge) {
|
||||
badge.textContent = activePeers + '/' + peers.length + ' active';
|
||||
}
|
||||
},
|
||||
|
||||
// Update interface cards
|
||||
updateInterfaces: function(interfaces) {
|
||||
interfaces.forEach(function(iface) {
|
||||
var card = document.querySelector('.wg-interface-card[data-iface="' + iface.name + '"]');
|
||||
if (!card) return;
|
||||
|
||||
// Update status
|
||||
var statusEl = card.querySelector('.wg-interface-status');
|
||||
if (statusEl) {
|
||||
statusEl.textContent = iface.state;
|
||||
statusEl.className = 'wg-interface-status ' + iface.state;
|
||||
}
|
||||
|
||||
// Update traffic
|
||||
var trafficEl = card.querySelector('.wg-interface-traffic');
|
||||
if (trafficEl) {
|
||||
trafficEl.textContent = '↓' + api.formatBytes(iface.rx_bytes) + ' / ↑' + api.formatBytes(iface.tx_bytes);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
startPolling: function() {
|
||||
var self = this;
|
||||
this.pollActive = true;
|
||||
|
||||
poll.add(L.bind(function() {
|
||||
if (!this.pollActive) return Promise.resolve();
|
||||
|
||||
return api.getAllData().then(L.bind(function(data) {
|
||||
var status = data.status || {};
|
||||
var interfaces = (data.interfaces || {}).interfaces || [];
|
||||
var peers = (data.peers || {}).peers || [];
|
||||
|
||||
this.updateStats(status);
|
||||
this.updatePeers(peers);
|
||||
this.updateInterfaces(interfaces);
|
||||
}, this));
|
||||
}, this), this.pollInterval);
|
||||
},
|
||||
|
||||
stopPolling: function() {
|
||||
this.pollActive = false;
|
||||
poll.stop();
|
||||
},
|
||||
|
||||
render: function(data) {
|
||||
var self = this;
|
||||
var status = data.status || {};
|
||||
var interfaces = (data.interfaces || {}).interfaces || [];
|
||||
var peers = (data.peers || {}).peers || [];
|
||||
@ -38,6 +205,38 @@ return view.extend({
|
||||
])
|
||||
]),
|
||||
|
||||
// Auto-refresh control
|
||||
E('div', { 'class': 'wg-refresh-control' }, [
|
||||
E('span', { 'class': 'wg-refresh-status' }, [
|
||||
E('span', { 'class': 'wg-refresh-indicator active' }),
|
||||
' Auto-refresh: ',
|
||||
E('span', { 'class': 'wg-refresh-state' }, 'Active')
|
||||
]),
|
||||
E('button', {
|
||||
'class': 'wg-btn wg-btn-sm',
|
||||
'id': 'wg-poll-toggle',
|
||||
'click': L.bind(function(ev) {
|
||||
var btn = ev.target;
|
||||
var indicator = document.querySelector('.wg-refresh-indicator');
|
||||
var state = document.querySelector('.wg-refresh-state');
|
||||
if (this.pollActive) {
|
||||
this.stopPolling();
|
||||
btn.textContent = '▶ Resume';
|
||||
indicator.classList.remove('active');
|
||||
state.textContent = 'Paused';
|
||||
} else {
|
||||
this.startPolling();
|
||||
btn.textContent = '⏸ Pause';
|
||||
indicator.classList.add('active');
|
||||
state.textContent = 'Active';
|
||||
}
|
||||
}, this)
|
||||
}, '⏸ Pause')
|
||||
]),
|
||||
|
||||
// Interface tabs
|
||||
interfaces.length > 1 ? this.renderInterfaceTabs(interfaces) : '',
|
||||
|
||||
// Quick Stats
|
||||
E('div', { 'class': 'wg-quick-stats' }, [
|
||||
E('div', { 'class': 'wg-quick-stat' }, [
|
||||
@ -45,7 +244,7 @@ return view.extend({
|
||||
E('span', { 'class': 'wg-quick-stat-icon' }, '🌐'),
|
||||
E('span', { 'class': 'wg-quick-stat-label' }, 'Interfaces')
|
||||
]),
|
||||
E('div', { 'class': 'wg-quick-stat-value' }, status.interface_count || 0),
|
||||
E('div', { 'class': 'wg-quick-stat-value wg-stat-interfaces' }, status.interface_count || 0),
|
||||
E('div', { 'class': 'wg-quick-stat-sub' }, 'Active tunnels')
|
||||
]),
|
||||
E('div', { 'class': 'wg-quick-stat' }, [
|
||||
@ -53,7 +252,7 @@ return view.extend({
|
||||
E('span', { 'class': 'wg-quick-stat-icon' }, '👥'),
|
||||
E('span', { 'class': 'wg-quick-stat-label' }, 'Total Peers')
|
||||
]),
|
||||
E('div', { 'class': 'wg-quick-stat-value' }, status.total_peers || 0),
|
||||
E('div', { 'class': 'wg-quick-stat-value wg-stat-total-peers' }, status.total_peers || 0),
|
||||
E('div', { 'class': 'wg-quick-stat-sub' }, 'Configured')
|
||||
]),
|
||||
E('div', { 'class': 'wg-quick-stat', 'style': '--stat-gradient: linear-gradient(135deg, #10b981, #34d399)' }, [
|
||||
@ -61,7 +260,7 @@ return view.extend({
|
||||
E('span', { 'class': 'wg-quick-stat-icon' }, '✅'),
|
||||
E('span', { 'class': 'wg-quick-stat-label' }, 'Active Peers')
|
||||
]),
|
||||
E('div', { 'class': 'wg-quick-stat-value' }, status.active_peers || 0),
|
||||
E('div', { 'class': 'wg-quick-stat-value wg-stat-active-peers' }, status.active_peers || 0),
|
||||
E('div', { 'class': 'wg-quick-stat-sub' }, 'Connected now')
|
||||
]),
|
||||
E('div', { 'class': 'wg-quick-stat' }, [
|
||||
@ -69,7 +268,7 @@ return view.extend({
|
||||
E('span', { 'class': 'wg-quick-stat-icon' }, '📥'),
|
||||
E('span', { 'class': 'wg-quick-stat-label' }, 'Downloaded')
|
||||
]),
|
||||
E('div', { 'class': 'wg-quick-stat-value' }, api.formatBytes(status.total_rx || 0)),
|
||||
E('div', { 'class': 'wg-quick-stat-value wg-stat-rx' }, api.formatBytes(status.total_rx || 0)),
|
||||
E('div', { 'class': 'wg-quick-stat-sub' }, 'Total received')
|
||||
]),
|
||||
E('div', { 'class': 'wg-quick-stat' }, [
|
||||
@ -77,7 +276,7 @@ return view.extend({
|
||||
E('span', { 'class': 'wg-quick-stat-icon' }, '📤'),
|
||||
E('span', { 'class': 'wg-quick-stat-label' }, 'Uploaded')
|
||||
]),
|
||||
E('div', { 'class': 'wg-quick-stat-value' }, api.formatBytes(status.total_tx || 0)),
|
||||
E('div', { 'class': 'wg-quick-stat-value wg-stat-tx' }, api.formatBytes(status.total_tx || 0)),
|
||||
E('div', { 'class': 'wg-quick-stat-sub' }, 'Total sent')
|
||||
])
|
||||
]),
|
||||
@ -95,7 +294,7 @@ return view.extend({
|
||||
interfaces.length > 0 ?
|
||||
E('div', { 'class': 'wg-charts-grid' },
|
||||
interfaces.map(function(iface) {
|
||||
return E('div', { 'class': 'wg-interface-card' }, [
|
||||
return E('div', { 'class': 'wg-interface-card', 'data-iface': iface.name }, [
|
||||
E('div', { 'class': 'wg-interface-header' }, [
|
||||
E('div', { 'class': 'wg-interface-name' }, [
|
||||
E('div', { 'class': 'wg-interface-icon' }, '🌐'),
|
||||
@ -121,7 +320,7 @@ return view.extend({
|
||||
]),
|
||||
E('div', { 'class': 'wg-interface-detail' }, [
|
||||
E('div', { 'class': 'wg-interface-detail-label' }, 'Traffic'),
|
||||
E('div', { 'class': 'wg-interface-detail-value' },
|
||||
E('div', { 'class': 'wg-interface-detail-value wg-interface-traffic' },
|
||||
'↓' + api.formatBytes(iface.rx_bytes) + ' / ↑' + api.formatBytes(iface.tx_bytes))
|
||||
])
|
||||
])
|
||||
@ -143,12 +342,12 @@ return view.extend({
|
||||
E('span', { 'class': 'wg-card-title-icon' }, '👥'),
|
||||
'Connected Peers'
|
||||
]),
|
||||
E('div', { 'class': 'wg-card-badge' }, activePeers + '/' + peers.length + ' active')
|
||||
E('div', { 'class': 'wg-card-badge wg-peers-badge' }, activePeers + '/' + peers.length + ' active')
|
||||
]),
|
||||
E('div', { 'class': 'wg-card-body' }, [
|
||||
E('div', { 'class': 'wg-peer-grid' },
|
||||
peers.slice(0, 6).map(function(peer) {
|
||||
return E('div', { 'class': 'wg-peer-card ' + (peer.status === 'active' ? 'active' : '') }, [
|
||||
return E('div', { 'class': 'wg-peer-card ' + (peer.status === 'active' ? 'active' : ''), 'data-peer': peer.public_key, 'data-interface': peer.interface || '' }, [
|
||||
E('div', { 'class': 'wg-peer-header' }, [
|
||||
E('div', { 'class': 'wg-peer-info' }, [
|
||||
E('div', { 'class': 'wg-peer-icon' }, peer.status === 'active' ? '✅' : '👤'),
|
||||
@ -166,7 +365,7 @@ return view.extend({
|
||||
]),
|
||||
E('div', { 'class': 'wg-peer-detail' }, [
|
||||
E('span', { 'class': 'wg-peer-detail-label' }, 'Last Handshake'),
|
||||
E('span', { 'class': 'wg-peer-detail-value' }, api.formatHandshake(peer.handshake_ago))
|
||||
E('span', { 'class': 'wg-peer-detail-value', 'data-field': 'handshake' }, api.formatHandshake(peer.handshake_ago))
|
||||
]),
|
||||
E('div', { 'class': 'wg-peer-detail', 'style': 'grid-column: span 2' }, [
|
||||
E('span', { 'class': 'wg-peer-detail-label' }, 'Allowed IPs'),
|
||||
@ -191,11 +390,14 @@ return view.extend({
|
||||
])
|
||||
]) : ''
|
||||
]);
|
||||
|
||||
|
||||
// Include CSS
|
||||
var cssLink = E('link', { 'rel': 'stylesheet', 'href': L.resource('wireguard-dashboard/dashboard.css') });
|
||||
document.head.appendChild(cssLink);
|
||||
|
||||
|
||||
// Start auto-refresh
|
||||
this.startPolling();
|
||||
|
||||
return view;
|
||||
},
|
||||
|
||||
|
||||
@ -5,10 +5,35 @@
|
||||
'require dom';
|
||||
'require ui';
|
||||
'require wireguard-dashboard.api as API';
|
||||
'require wireguard-dashboard.qrcode as qrcode';
|
||||
|
||||
return view.extend({
|
||||
title: _('WireGuard Peers'),
|
||||
|
||||
// Store private key in session storage for QR generation
|
||||
storePrivateKey: function(publicKey, privateKey) {
|
||||
try {
|
||||
var stored = sessionStorage.getItem('wg_peer_keys');
|
||||
var keys = stored ? JSON.parse(stored) : {};
|
||||
keys[publicKey] = privateKey;
|
||||
sessionStorage.setItem('wg_peer_keys', JSON.stringify(keys));
|
||||
} catch (e) {
|
||||
console.error('Failed to store private key:', e);
|
||||
}
|
||||
},
|
||||
|
||||
// Retrieve stored private key
|
||||
getStoredPrivateKey: function(publicKey) {
|
||||
try {
|
||||
var stored = sessionStorage.getItem('wg_peer_keys');
|
||||
if (stored) {
|
||||
var keys = JSON.parse(stored);
|
||||
return keys[publicKey] || null;
|
||||
}
|
||||
} catch (e) {}
|
||||
return null;
|
||||
},
|
||||
|
||||
load: function() {
|
||||
return Promise.all([
|
||||
API.getPeers(),
|
||||
@ -35,7 +60,7 @@ return view.extend({
|
||||
'class': 'cbi-button cbi-button-action',
|
||||
'click': L.bind(this.handleAddPeer, this, interfaces)
|
||||
}, '+ ' + _('Add New Peer')),
|
||||
E('span', { 'style': 'margin-left: auto; font-weight: bold;' },
|
||||
E('span', { 'class': 'peers-active-count', 'style': 'margin-left: auto; font-weight: bold;' },
|
||||
_('Active: %d / %d').format(activePeers, peers.length))
|
||||
])
|
||||
]),
|
||||
@ -128,8 +153,50 @@ return view.extend({
|
||||
|
||||
// Setup auto-refresh every 5 seconds
|
||||
poll.add(L.bind(function() {
|
||||
return API.getPeers().then(L.bind(function(newPeers) {
|
||||
// Update table dynamically
|
||||
return API.getPeers().then(L.bind(function(data) {
|
||||
var newPeers = (data || {}).peers || [];
|
||||
var table = document.getElementById('peers-table');
|
||||
if (!table) return;
|
||||
|
||||
var tbody = table.querySelector('tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
// Update existing rows
|
||||
newPeers.forEach(function(peer, idx) {
|
||||
var row = tbody.children[idx];
|
||||
if (!row) return;
|
||||
|
||||
var cells = row.querySelectorAll('td');
|
||||
if (cells.length < 7) return;
|
||||
|
||||
// Update status (cell 4)
|
||||
var statusColor = peer.status === 'active' ? '#28a745' :
|
||||
peer.status === 'idle' ? '#ffc107' : '#6c757d';
|
||||
var statusIcon = peer.status === 'active' ? '✓' :
|
||||
peer.status === 'idle' ? '~' : '✗';
|
||||
var statusSpan = cells[4].querySelector('.badge');
|
||||
if (statusSpan) {
|
||||
statusSpan.style.background = statusColor;
|
||||
statusSpan.textContent = statusIcon + ' ' + peer.status;
|
||||
}
|
||||
|
||||
// Update last handshake (cell 5)
|
||||
cells[5].textContent = API.formatLastHandshake(peer.handshake_ago);
|
||||
|
||||
// Update RX/TX (cell 6)
|
||||
var trafficDiv = cells[6].querySelector('div');
|
||||
if (trafficDiv) {
|
||||
trafficDiv.innerHTML = '<div>↓ ' + API.formatBytes(peer.rx_bytes) + '</div>' +
|
||||
'<div>↑ ' + API.formatBytes(peer.tx_bytes) + '</div>';
|
||||
}
|
||||
});
|
||||
|
||||
// Update active count
|
||||
var activePeers = newPeers.filter(function(p) { return p.status === 'active'; }).length;
|
||||
var countSpan = document.querySelector('.peers-active-count');
|
||||
if (countSpan) {
|
||||
countSpan.textContent = _('Active: %d / %d').format(activePeers, newPeers.length);
|
||||
}
|
||||
}, this));
|
||||
}, this), 5);
|
||||
|
||||
@ -286,11 +353,48 @@ return view.extend({
|
||||
E('p', { 'class': 'spinning' }, _('Adding peer configuration...'))
|
||||
]);
|
||||
|
||||
var privkey = document.getElementById('peer-privkey').value;
|
||||
|
||||
API.addPeer(iface, name, allowed_ips, pubkey, psk, endpoint, keepalive).then(function(result) {
|
||||
ui.hideModal();
|
||||
if (result.success) {
|
||||
// Store private key for QR generation
|
||||
self.storePrivateKey(pubkey, privkey);
|
||||
ui.addNotification(null, E('p', result.message || _('Peer added successfully')), 'info');
|
||||
window.location.reload();
|
||||
|
||||
// Offer to generate QR code immediately
|
||||
ui.showModal(_('Peer Created Successfully'), [
|
||||
E('p', {}, _('The peer has been added. Would you like to generate a QR code for mobile setup?')),
|
||||
E('div', { 'style': 'background: #d4edda; padding: 1em; border-radius: 4px; margin: 1em 0;' }, [
|
||||
E('strong', {}, _('Private Key Stored')),
|
||||
E('p', { 'style': 'margin: 0.5em 0 0 0; font-size: 0.9em;' },
|
||||
_('The private key has been temporarily stored in your browser session for QR generation.'))
|
||||
]),
|
||||
E('div', { 'class': 'right', 'style': 'margin-top: 1em;' }, [
|
||||
E('button', {
|
||||
'class': 'btn',
|
||||
'click': function() {
|
||||
ui.hideModal();
|
||||
window.location.reload();
|
||||
}
|
||||
}, _('Skip')),
|
||||
' ',
|
||||
E('button', {
|
||||
'class': 'btn cbi-button-action',
|
||||
'click': function() {
|
||||
ui.hideModal();
|
||||
// Find the interface for QR generation
|
||||
var ifaceObj = interfaces.find(function(i) { return i.name === iface; });
|
||||
self.promptForEndpointAndShowQR({
|
||||
public_key: pubkey,
|
||||
short_key: pubkey.substring(0, 8),
|
||||
allowed_ips: allowed_ips,
|
||||
interface: iface
|
||||
}, ifaceObj, privkey);
|
||||
}
|
||||
}, _('Generate QR Code'))
|
||||
])
|
||||
]);
|
||||
} else {
|
||||
ui.addNotification(null, E('p', result.error || _('Failed to add peer')), 'error');
|
||||
}
|
||||
@ -309,25 +413,21 @@ return view.extend({
|
||||
});
|
||||
},
|
||||
|
||||
handleShowQR: function(peer, interfaces, ev) {
|
||||
promptForEndpointAndShowQR: function(peer, ifaceObj, privateKey) {
|
||||
var self = this;
|
||||
var savedEndpoint = sessionStorage.getItem('wg_server_endpoint') || '';
|
||||
|
||||
ui.showModal(_('Loading QR Code'), [
|
||||
E('p', { 'class': 'spinning' }, _('Generating QR code...'))
|
||||
]);
|
||||
|
||||
// Prompt for server endpoint
|
||||
ui.hideModal();
|
||||
ui.showModal(_('Server Endpoint Required'), [
|
||||
ui.showModal(_('Server Endpoint'), [
|
||||
E('p', {}, _('Enter the public IP or hostname of this WireGuard server:')),
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, _('Server Endpoint')),
|
||||
E('div', { 'class': 'cbi-value-field' }, [
|
||||
E('input', {
|
||||
'type': 'text',
|
||||
'id': 'server-endpoint',
|
||||
'id': 'qr-server-endpoint',
|
||||
'class': 'cbi-input-text',
|
||||
'placeholder': 'vpn.example.com or 203.0.113.1'
|
||||
'placeholder': 'vpn.example.com or 203.0.113.1',
|
||||
'value': savedEndpoint
|
||||
})
|
||||
])
|
||||
]),
|
||||
@ -340,55 +440,273 @@ return view.extend({
|
||||
E('button', {
|
||||
'class': 'btn cbi-button-action',
|
||||
'click': function() {
|
||||
var endpoint = document.getElementById('server-endpoint').value;
|
||||
var endpoint = document.getElementById('qr-server-endpoint').value.trim();
|
||||
if (!endpoint) {
|
||||
ui.addNotification(null, E('p', _('Please enter server endpoint')), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
ui.hideModal();
|
||||
ui.showModal(_('Generating QR Code'), [
|
||||
E('p', { 'class': 'spinning' }, _('Please wait...'))
|
||||
]);
|
||||
|
||||
// Need to get private key from somewhere - this is tricky
|
||||
// In real implementation, you'd need to store it or ask user
|
||||
ui.addNotification(null, E('p', _('QR code generation requires the peer private key. Please use the config download option and scan manually.')), 'info');
|
||||
sessionStorage.setItem('wg_server_endpoint', endpoint);
|
||||
ui.hideModal();
|
||||
self.generateAndShowQR(peer, ifaceObj, privateKey, endpoint);
|
||||
}
|
||||
}, _('Generate QR'))
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
handleDownloadConfig: function(peer, interfaces, ev) {
|
||||
ui.showModal(_('Server Endpoint Required'), [
|
||||
E('p', {}, _('Enter the public IP or hostname of this WireGuard server:')),
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, _('Server Endpoint')),
|
||||
E('div', { 'class': 'cbi-value-field' }, [
|
||||
E('input', {
|
||||
'type': 'text',
|
||||
'id': 'server-endpoint-cfg',
|
||||
'class': 'cbi-input-text',
|
||||
'placeholder': 'vpn.example.com or 203.0.113.1'
|
||||
})
|
||||
generateAndShowQR: function(peer, ifaceObj, privateKey, serverEndpoint) {
|
||||
var self = this;
|
||||
|
||||
// Build WireGuard client config
|
||||
var config = '[Interface]\n' +
|
||||
'PrivateKey = ' + privateKey + '\n' +
|
||||
'Address = ' + (peer.allowed_ips || '10.0.0.2/32') + '\n' +
|
||||
'DNS = 1.1.1.1, 1.0.0.1\n\n' +
|
||||
'[Peer]\n' +
|
||||
'PublicKey = ' + (ifaceObj.public_key || '') + '\n' +
|
||||
'Endpoint = ' + serverEndpoint + ':' + (ifaceObj.listen_port || 51820) + '\n' +
|
||||
'AllowedIPs = 0.0.0.0/0, ::/0\n' +
|
||||
'PersistentKeepalive = 25';
|
||||
|
||||
// First try backend QR generation
|
||||
API.generateQR(peer.interface, peer.public_key, privateKey, serverEndpoint).then(function(result) {
|
||||
if (result && result.qrcode && !result.error) {
|
||||
self.displayQRModal(peer, result.qrcode, config, false);
|
||||
} else {
|
||||
// Fall back to JavaScript QR generation
|
||||
var svg = qrcode.generateSVG(config, 250);
|
||||
if (svg) {
|
||||
self.displayQRModal(peer, svg, config, true);
|
||||
} else {
|
||||
ui.addNotification(null, E('p', _('Failed to generate QR code')), 'error');
|
||||
}
|
||||
}
|
||||
}).catch(function(err) {
|
||||
// Fall back to JavaScript QR generation
|
||||
var svg = qrcode.generateSVG(config, 250);
|
||||
if (svg) {
|
||||
self.displayQRModal(peer, svg, config, true);
|
||||
} else {
|
||||
ui.addNotification(null, E('p', _('Failed to generate QR code')), 'error');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
displayQRModal: function(peer, qrData, config, isSVG) {
|
||||
var qrElement;
|
||||
|
||||
if (isSVG) {
|
||||
qrElement = E('div', { 'style': 'display: inline-block;' });
|
||||
qrElement.innerHTML = qrData;
|
||||
} else {
|
||||
qrElement = E('img', {
|
||||
'src': qrData,
|
||||
'alt': 'WireGuard QR Code',
|
||||
'style': 'max-width: 250px; max-height: 250px;'
|
||||
});
|
||||
}
|
||||
|
||||
ui.showModal(_('WireGuard QR Code'), [
|
||||
E('div', { 'style': 'text-align: center;' }, [
|
||||
E('h4', {}, peer.interface + ' - ' + (peer.short_key || peer.public_key.substring(0, 8))),
|
||||
E('div', { 'style': 'background: white; padding: 20px; border-radius: 12px; display: inline-block; margin: 20px 0;' }, [
|
||||
qrElement
|
||||
]),
|
||||
E('p', { 'style': 'color: #666;' }, _('Scan with WireGuard app on your mobile device')),
|
||||
E('div', { 'style': 'display: flex; gap: 10px; justify-content: center; margin: 1em 0;' }, [
|
||||
E('button', {
|
||||
'class': 'btn',
|
||||
'click': function() {
|
||||
navigator.clipboard.writeText(config).then(function() {
|
||||
ui.addNotification(null, E('p', _('Configuration copied to clipboard')), 'info');
|
||||
});
|
||||
}
|
||||
}, _('Copy Config')),
|
||||
E('button', {
|
||||
'class': 'btn cbi-button-action',
|
||||
'click': function() {
|
||||
var blob = new Blob([config], { type: 'text/plain' });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = peer.interface + '-peer.conf';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}, _('Download .conf'))
|
||||
]),
|
||||
E('details', { 'style': 'text-align: left; margin-top: 1em;' }, [
|
||||
E('summary', { 'style': 'cursor: pointer; color: #06b6d4;' }, _('Show configuration')),
|
||||
E('pre', { 'style': 'background: #f8f9fa; padding: 12px; border-radius: 8px; font-size: 11px; margin-top: 10px;' }, config)
|
||||
])
|
||||
]),
|
||||
E('div', { 'style': 'margin-top: 1em; padding: 0.75em; background: #fff3cd; border-radius: 4px;' }, [
|
||||
E('strong', {}, _('Note:')),
|
||||
' ',
|
||||
_('Configuration file requires the peer private key. This was generated when the peer was created.')
|
||||
]),
|
||||
E('div', { 'class': 'right', 'style': 'margin-top: 1em;' }, [
|
||||
E('div', { 'class': 'right' }, [
|
||||
E('button', {
|
||||
'class': 'btn',
|
||||
'click': ui.hideModal
|
||||
}, _('Cancel'))
|
||||
}, _('Close'))
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
handleShowQR: function(peer, interfaces, ev) {
|
||||
var self = this;
|
||||
var privateKey = this.getStoredPrivateKey(peer.public_key);
|
||||
var ifaceObj = interfaces.find(function(i) { return i.name === peer.interface; }) || {};
|
||||
|
||||
if (!privateKey) {
|
||||
// Private key not stored - ask user to input it
|
||||
ui.showModal(_('Private Key Required'), [
|
||||
E('p', {}, _('To generate a QR code, the peer\'s private key is needed.')),
|
||||
E('p', { 'style': 'color: #666; font-size: 0.9em;' },
|
||||
_('Private keys are only stored in your browser session immediately after peer creation. If you closed or refreshed the page, you\'ll need to enter it manually.')),
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, _('Private Key')),
|
||||
E('div', { 'class': 'cbi-value-field' }, [
|
||||
E('input', {
|
||||
'type': 'text',
|
||||
'id': 'manual-private-key',
|
||||
'class': 'cbi-input-text',
|
||||
'placeholder': 'Base64 private key (44 characters)',
|
||||
'style': 'font-family: monospace;'
|
||||
})
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'right', 'style': 'margin-top: 1em;' }, [
|
||||
E('button', {
|
||||
'class': 'btn',
|
||||
'click': ui.hideModal
|
||||
}, _('Cancel')),
|
||||
' ',
|
||||
E('button', {
|
||||
'class': 'btn cbi-button-action',
|
||||
'click': function() {
|
||||
var key = document.getElementById('manual-private-key').value.trim();
|
||||
if (!key || key.length !== 44) {
|
||||
ui.addNotification(null, E('p', _('Please enter a valid private key (44 characters)')), 'error');
|
||||
return;
|
||||
}
|
||||
// Store for future use
|
||||
self.storePrivateKey(peer.public_key, key);
|
||||
ui.hideModal();
|
||||
self.promptForEndpointAndShowQR(peer, ifaceObj, key);
|
||||
}
|
||||
}, _('Continue'))
|
||||
])
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
this.promptForEndpointAndShowQR(peer, ifaceObj, privateKey);
|
||||
},
|
||||
|
||||
handleDownloadConfig: function(peer, interfaces, ev) {
|
||||
var self = this;
|
||||
var privateKey = this.getStoredPrivateKey(peer.public_key);
|
||||
var ifaceObj = interfaces.find(function(i) { return i.name === peer.interface; }) || {};
|
||||
|
||||
var showConfigModal = function(privKey) {
|
||||
var savedEndpoint = sessionStorage.getItem('wg_server_endpoint') || '';
|
||||
|
||||
ui.showModal(_('Download Configuration'), [
|
||||
E('p', {}, _('Enter the server endpoint to generate the client configuration:')),
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, _('Server Endpoint')),
|
||||
E('div', { 'class': 'cbi-value-field' }, [
|
||||
E('input', {
|
||||
'type': 'text',
|
||||
'id': 'cfg-server-endpoint',
|
||||
'class': 'cbi-input-text',
|
||||
'placeholder': 'vpn.example.com or 203.0.113.1',
|
||||
'value': savedEndpoint
|
||||
})
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'right', 'style': 'margin-top: 1em;' }, [
|
||||
E('button', {
|
||||
'class': 'btn',
|
||||
'click': ui.hideModal
|
||||
}, _('Cancel')),
|
||||
' ',
|
||||
E('button', {
|
||||
'class': 'btn cbi-button-action',
|
||||
'click': function() {
|
||||
var endpoint = document.getElementById('cfg-server-endpoint').value.trim();
|
||||
if (!endpoint) {
|
||||
ui.addNotification(null, E('p', _('Please enter server endpoint')), 'error');
|
||||
return;
|
||||
}
|
||||
sessionStorage.setItem('wg_server_endpoint', endpoint);
|
||||
|
||||
var config = '[Interface]\n' +
|
||||
'PrivateKey = ' + privKey + '\n' +
|
||||
'Address = ' + (peer.allowed_ips || '10.0.0.2/32') + '\n' +
|
||||
'DNS = 1.1.1.1, 1.0.0.1\n\n' +
|
||||
'[Peer]\n' +
|
||||
'PublicKey = ' + (ifaceObj.public_key || '') + '\n' +
|
||||
'Endpoint = ' + endpoint + ':' + (ifaceObj.listen_port || 51820) + '\n' +
|
||||
'AllowedIPs = 0.0.0.0/0, ::/0\n' +
|
||||
'PersistentKeepalive = 25';
|
||||
|
||||
var blob = new Blob([config], { type: 'text/plain' });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = peer.interface + '-' + (peer.short_key || 'peer') + '.conf';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
ui.hideModal();
|
||||
ui.addNotification(null, E('p', _('Configuration file downloaded')), 'info');
|
||||
}
|
||||
}, _('Download'))
|
||||
])
|
||||
]);
|
||||
};
|
||||
|
||||
if (!privateKey) {
|
||||
// Private key not stored - ask user to input it
|
||||
ui.showModal(_('Private Key Required'), [
|
||||
E('p', {}, _('Enter the peer\'s private key to generate the configuration file:')),
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, _('Private Key')),
|
||||
E('div', { 'class': 'cbi-value-field' }, [
|
||||
E('input', {
|
||||
'type': 'text',
|
||||
'id': 'cfg-private-key',
|
||||
'class': 'cbi-input-text',
|
||||
'placeholder': 'Base64 private key (44 characters)',
|
||||
'style': 'font-family: monospace;'
|
||||
})
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'right', 'style': 'margin-top: 1em;' }, [
|
||||
E('button', {
|
||||
'class': 'btn',
|
||||
'click': ui.hideModal
|
||||
}, _('Cancel')),
|
||||
' ',
|
||||
E('button', {
|
||||
'class': 'btn cbi-button-action',
|
||||
'click': function() {
|
||||
var key = document.getElementById('cfg-private-key').value.trim();
|
||||
if (!key || key.length !== 44) {
|
||||
ui.addNotification(null, E('p', _('Please enter a valid private key (44 characters)')), 'error');
|
||||
return;
|
||||
}
|
||||
self.storePrivateKey(peer.public_key, key);
|
||||
ui.hideModal();
|
||||
showConfigModal(key);
|
||||
}
|
||||
}, _('Continue'))
|
||||
])
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
showConfigModal(privateKey);
|
||||
},
|
||||
|
||||
handleDeletePeer: function(peer, ev) {
|
||||
var self = this;
|
||||
|
||||
|
||||
@ -4,62 +4,194 @@
|
||||
'require dom';
|
||||
'require ui';
|
||||
'require wireguard-dashboard.api as api';
|
||||
'require wireguard-dashboard.qrcode as qrcode';
|
||||
|
||||
return view.extend({
|
||||
title: _('QR Code Generator'),
|
||||
|
||||
|
||||
load: function() {
|
||||
return api.getConfig();
|
||||
return Promise.all([
|
||||
api.getConfig(),
|
||||
api.getInterfaces(),
|
||||
api.getPeers()
|
||||
]).then(function(results) {
|
||||
return {
|
||||
config: results[0] || {},
|
||||
interfaces: (results[1] || {}).interfaces || [],
|
||||
peers: (results[2] || {}).peers || []
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
generateQRCode: function(text, size) {
|
||||
// Simple QR code SVG generator using a basic encoding
|
||||
// In production, this would use a proper QR library
|
||||
var qrSize = size || 200;
|
||||
var moduleCount = 25; // Simplified
|
||||
var moduleSize = qrSize / moduleCount;
|
||||
|
||||
// Create a placeholder SVG that represents the QR structure
|
||||
var svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + qrSize + ' ' + qrSize + '" width="' + qrSize + '" height="' + qrSize + '">';
|
||||
svg += '<rect width="100%" height="100%" fill="white"/>';
|
||||
|
||||
// Simplified pattern - in real implementation, use proper QR encoding
|
||||
// Draw finder patterns (corners)
|
||||
var drawFinder = function(x, y) {
|
||||
var s = moduleSize * 7;
|
||||
svg += '<rect x="' + x + '" y="' + y + '" width="' + s + '" height="' + s + '" fill="black"/>';
|
||||
svg += '<rect x="' + (x + moduleSize) + '" y="' + (y + moduleSize) + '" width="' + (s - moduleSize * 2) + '" height="' + (s - moduleSize * 2) + '" fill="white"/>';
|
||||
svg += '<rect x="' + (x + moduleSize * 2) + '" y="' + (y + moduleSize * 2) + '" width="' + (s - moduleSize * 4) + '" height="' + (s - moduleSize * 4) + '" fill="black"/>';
|
||||
};
|
||||
|
||||
drawFinder(0, 0);
|
||||
drawFinder(qrSize - moduleSize * 7, 0);
|
||||
drawFinder(0, qrSize - moduleSize * 7);
|
||||
|
||||
// Add some random-looking modules for visual effect
|
||||
var hash = 0;
|
||||
for (var i = 0; i < text.length; i++) {
|
||||
hash = ((hash << 5) - hash) + text.charCodeAt(i);
|
||||
}
|
||||
|
||||
for (var row = 8; row < moduleCount - 8; row++) {
|
||||
for (var col = 8; col < moduleCount - 8; col++) {
|
||||
if (((hash + row * col) % 3) === 0) {
|
||||
svg += '<rect x="' + (col * moduleSize) + '" y="' + (row * moduleSize) + '" width="' + moduleSize + '" height="' + moduleSize + '" fill="black"/>';
|
||||
}
|
||||
|
||||
getStoredPrivateKey: function(publicKey) {
|
||||
try {
|
||||
var stored = sessionStorage.getItem('wg_peer_keys');
|
||||
if (stored) {
|
||||
var keys = JSON.parse(stored);
|
||||
return keys[publicKey] || null;
|
||||
}
|
||||
}
|
||||
|
||||
svg += '</svg>';
|
||||
return svg;
|
||||
} catch (e) {}
|
||||
return null;
|
||||
},
|
||||
|
||||
|
||||
generateQRForPeer: function(iface, peer, serverEndpoint) {
|
||||
var self = this;
|
||||
var privateKey = this.getStoredPrivateKey(peer.public_key);
|
||||
|
||||
if (!privateKey) {
|
||||
ui.showModal(_('Private Key Required'), [
|
||||
E('p', {}, _('To generate a QR code, you need the peer\'s private key.')),
|
||||
E('p', {}, _('Private keys are only available immediately after peer creation for security reasons.')),
|
||||
E('div', { 'class': 'wg-form-group' }, [
|
||||
E('label', {}, _('Enter Private Key:')),
|
||||
E('input', {
|
||||
'type': 'text',
|
||||
'id': 'wg-private-key-input',
|
||||
'class': 'cbi-input-text',
|
||||
'placeholder': 'Base64 private key...',
|
||||
'style': 'width: 100%; font-family: monospace;'
|
||||
})
|
||||
]),
|
||||
E('div', { 'class': 'right' }, [
|
||||
E('button', {
|
||||
'class': 'btn',
|
||||
'click': ui.hideModal
|
||||
}, _('Cancel')),
|
||||
' ',
|
||||
E('button', {
|
||||
'class': 'btn cbi-button-action',
|
||||
'click': function() {
|
||||
var input = document.getElementById('wg-private-key-input');
|
||||
var key = input ? input.value.trim() : '';
|
||||
if (key && key.length === 44) {
|
||||
ui.hideModal();
|
||||
self.showQRCode(iface, peer, key, serverEndpoint);
|
||||
} else {
|
||||
ui.addNotification(null, E('p', {}, _('Please enter a valid private key (44 characters, base64)')), 'error');
|
||||
}
|
||||
}
|
||||
}, _('Generate QR'))
|
||||
])
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
this.showQRCode(iface, peer, privateKey, serverEndpoint);
|
||||
},
|
||||
|
||||
showQRCode: function(iface, peer, privateKey, serverEndpoint) {
|
||||
var self = this;
|
||||
|
||||
// First try backend (uses qrencode if available)
|
||||
api.generateQR(iface.name, peer.public_key, privateKey, serverEndpoint).then(function(result) {
|
||||
if (result && result.qrcode && !result.error) {
|
||||
// Backend generated QR successfully
|
||||
self.displayQRModal(iface, peer, result.qrcode, result.config);
|
||||
} else {
|
||||
// Fall back to JavaScript QR generation
|
||||
self.generateJSQR(iface, peer, privateKey, serverEndpoint);
|
||||
}
|
||||
}).catch(function(err) {
|
||||
// Fall back to JavaScript QR generation
|
||||
self.generateJSQR(iface, peer, privateKey, serverEndpoint);
|
||||
});
|
||||
},
|
||||
|
||||
generateJSQR: function(iface, peer, privateKey, serverEndpoint) {
|
||||
// Build WireGuard config
|
||||
var config = '[Interface]\n' +
|
||||
'PrivateKey = ' + privateKey + '\n' +
|
||||
'Address = ' + (peer.allowed_ips || '10.0.0.2/32') + '\n' +
|
||||
'DNS = 1.1.1.1, 1.0.0.1\n\n' +
|
||||
'[Peer]\n' +
|
||||
'PublicKey = ' + iface.public_key + '\n' +
|
||||
'Endpoint = ' + serverEndpoint + ':' + (iface.listen_port || 51820) + '\n' +
|
||||
'AllowedIPs = 0.0.0.0/0, ::/0\n' +
|
||||
'PersistentKeepalive = 25';
|
||||
|
||||
var svg = qrcode.generateSVG(config, 250);
|
||||
if (svg) {
|
||||
this.displayQRModal(iface, peer, svg, config, true);
|
||||
} else {
|
||||
ui.addNotification(null, E('p', {}, _('Failed to generate QR code. Config may be too long.')), 'error');
|
||||
}
|
||||
},
|
||||
|
||||
displayQRModal: function(iface, peer, qrData, config, isSVG) {
|
||||
var qrElement;
|
||||
|
||||
if (isSVG) {
|
||||
qrElement = E('div', { 'class': 'wg-qr-image' });
|
||||
qrElement.innerHTML = qrData;
|
||||
} else {
|
||||
qrElement = E('img', {
|
||||
'src': qrData,
|
||||
'alt': 'WireGuard QR Code',
|
||||
'class': 'wg-qr-image'
|
||||
});
|
||||
}
|
||||
|
||||
ui.showModal(_('WireGuard Configuration'), [
|
||||
E('div', { 'class': 'wg-qr-modal' }, [
|
||||
E('div', { 'class': 'wg-qr-header' }, [
|
||||
E('h4', {}, iface.name + ' - Peer ' + (peer.short_key || peer.public_key.substring(0, 8)))
|
||||
]),
|
||||
E('div', { 'class': 'wg-qr-container' }, [qrElement]),
|
||||
E('p', { 'class': 'wg-qr-hint' }, _('Scan with WireGuard app on your mobile device')),
|
||||
E('div', { 'class': 'wg-qr-actions' }, [
|
||||
E('button', {
|
||||
'class': 'btn',
|
||||
'click': function() {
|
||||
navigator.clipboard.writeText(config).then(function() {
|
||||
ui.addNotification(null, E('p', {}, _('Configuration copied to clipboard')), 'info');
|
||||
});
|
||||
}
|
||||
}, _('Copy Config')),
|
||||
E('button', {
|
||||
'class': 'btn cbi-button-action',
|
||||
'click': function() {
|
||||
var blob = new Blob([config], { type: 'text/plain' });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = iface.name + '-peer.conf';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}, _('Download .conf'))
|
||||
]),
|
||||
E('details', { 'class': 'wg-config-details' }, [
|
||||
E('summary', {}, _('Show configuration')),
|
||||
E('pre', {}, config)
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'right' }, [
|
||||
E('button', {
|
||||
'class': 'btn',
|
||||
'click': ui.hideModal
|
||||
}, _('Close'))
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
render: function(data) {
|
||||
var self = this;
|
||||
var interfaces = (data || {}).interfaces || [];
|
||||
|
||||
var interfaces = data.interfaces || [];
|
||||
var configData = (data.config || {}).interfaces || [];
|
||||
var peers = data.peers || [];
|
||||
|
||||
// Merge interface data with config data
|
||||
interfaces = interfaces.map(function(iface) {
|
||||
var cfg = configData.find(function(c) { return c.name === iface.name; }) || {};
|
||||
return Object.assign({}, iface, {
|
||||
peers: cfg.peers || [],
|
||||
public_key: cfg.public_key || iface.public_key
|
||||
});
|
||||
});
|
||||
|
||||
var view = E('div', { 'class': 'wireguard-dashboard' }, [
|
||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('secubox-theme/secubox-theme.css') }),
|
||||
|
||||
// Header
|
||||
E('div', { 'class': 'wg-header' }, [
|
||||
E('div', { 'class': 'wg-logo' }, [
|
||||
@ -67,172 +199,233 @@ return view.extend({
|
||||
E('div', { 'class': 'wg-logo-text' }, ['QR ', E('span', {}, 'Generator')])
|
||||
])
|
||||
]),
|
||||
|
||||
// Info banner
|
||||
E('div', { 'class': 'wg-info-banner' }, [
|
||||
E('span', { 'class': 'wg-info-icon' }, 'ℹ️'),
|
||||
E('div', {}, [
|
||||
E('strong', {}, 'Mobile Configuration'),
|
||||
E('p', {}, 'Generate QR codes to quickly configure WireGuard on mobile devices. ' +
|
||||
'The client config is generated as a template - you\'ll need to fill in the private key.')
|
||||
|
||||
// Server endpoint input
|
||||
E('div', { 'class': 'wg-card' }, [
|
||||
E('div', { 'class': 'wg-card-header' }, [
|
||||
E('div', { 'class': 'wg-card-title' }, [
|
||||
E('span', { 'class': 'wg-card-title-icon' }, '🌐'),
|
||||
_('Server Endpoint')
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'wg-card-body' }, [
|
||||
E('p', { 'style': 'margin-bottom: 12px; color: var(--wg-text-secondary);' },
|
||||
_('Enter the public IP or hostname of this WireGuard server:')),
|
||||
E('div', { 'class': 'wg-form-row' }, [
|
||||
E('input', {
|
||||
'type': 'text',
|
||||
'id': 'wg-server-endpoint',
|
||||
'class': 'cbi-input-text',
|
||||
'placeholder': 'e.g., vpn.example.com or 203.0.113.1',
|
||||
'style': 'flex: 1;'
|
||||
}),
|
||||
E('button', {
|
||||
'class': 'wg-btn wg-btn-primary',
|
||||
'click': function() {
|
||||
var input = document.getElementById('wg-server-endpoint');
|
||||
if (input && input.value.trim()) {
|
||||
sessionStorage.setItem('wg_server_endpoint', input.value.trim());
|
||||
ui.addNotification(null, E('p', {}, _('Server endpoint saved')), 'info');
|
||||
}
|
||||
}
|
||||
}, _('Save'))
|
||||
])
|
||||
])
|
||||
]),
|
||||
|
||||
// Interfaces with QR generation
|
||||
|
||||
// Interface cards
|
||||
interfaces.length > 0 ?
|
||||
interfaces.map(function(iface) {
|
||||
return E('div', { 'class': 'wg-card' }, [
|
||||
E('div', { 'class': 'wg-card-header' }, [
|
||||
E('div', { 'class': 'wg-card-title' }, [
|
||||
E('span', { 'class': 'wg-card-title-icon' }, '🌐'),
|
||||
'Interface: ' + iface.name
|
||||
E('div', { 'class': 'wg-interface-list' },
|
||||
interfaces.map(function(iface) {
|
||||
var ifacePeers = peers.filter(function(p) { return p.interface === iface.name; });
|
||||
|
||||
return E('div', { 'class': 'wg-card' }, [
|
||||
E('div', { 'class': 'wg-card-header' }, [
|
||||
E('div', { 'class': 'wg-card-title' }, [
|
||||
E('span', { 'class': 'wg-card-title-icon' }, '🔐'),
|
||||
iface.name
|
||||
]),
|
||||
E('div', { 'class': 'wg-card-badge' }, ifacePeers.length + ' peers')
|
||||
]),
|
||||
E('div', { 'class': 'wg-card-badge' }, (iface.peers || []).length + ' peers')
|
||||
]),
|
||||
E('div', { 'class': 'wg-card-body' }, [
|
||||
E('div', { 'class': 'wg-qr-grid' },
|
||||
(iface.peers || []).map(function(peer, idx) {
|
||||
// Generate client config template
|
||||
var clientConfig = '[Interface]\n' +
|
||||
'PrivateKey = <YOUR_PRIVATE_KEY>\n' +
|
||||
'Address = ' + (peer.allowed_ips || '10.0.0.' + (idx + 2) + '/32') + '\n' +
|
||||
'DNS = 1.1.1.1\n\n' +
|
||||
'[Peer]\n' +
|
||||
'PublicKey = ' + iface.public_key + '\n' +
|
||||
'Endpoint = <YOUR_SERVER_IP>:' + (iface.listen_port || 51820) + '\n' +
|
||||
'AllowedIPs = 0.0.0.0/0, ::/0\n' +
|
||||
'PersistentKeepalive = 25';
|
||||
|
||||
return E('div', { 'class': 'wg-qr-card' }, [
|
||||
E('div', { 'class': 'wg-qr-header' }, [
|
||||
E('span', { 'class': 'wg-qr-icon' }, '👤'),
|
||||
E('div', {}, [
|
||||
E('h4', {}, 'Peer ' + (idx + 1)),
|
||||
E('code', {}, peer.public_key.substring(0, 16) + '...')
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'wg-qr-code', 'data-config': clientConfig }, [
|
||||
E('div', { 'class': 'wg-qr-placeholder' }, [
|
||||
E('span', {}, '📱'),
|
||||
E('p', {}, 'QR Code Preview'),
|
||||
E('small', {}, 'Scan with WireGuard app')
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'wg-qr-actions' }, [
|
||||
E('button', {
|
||||
'class': 'wg-btn',
|
||||
'click': function() {
|
||||
// Copy config to clipboard
|
||||
navigator.clipboard.writeText(clientConfig).then(function() {
|
||||
ui.addNotification(null, E('p', {}, 'Configuration copied to clipboard!'), 'info');
|
||||
});
|
||||
}
|
||||
}, '📋 Copy Config'),
|
||||
E('button', {
|
||||
E('div', { 'class': 'wg-card-body' }, [
|
||||
E('div', { 'class': 'wg-interface-info' }, [
|
||||
E('div', { 'class': 'wg-info-item' }, [
|
||||
E('span', { 'class': 'wg-info-label' }, _('Public Key:')),
|
||||
E('code', {}, (iface.public_key || 'N/A').substring(0, 20) + '...')
|
||||
]),
|
||||
E('div', { 'class': 'wg-info-item' }, [
|
||||
E('span', { 'class': 'wg-info-label' }, _('Listen Port:')),
|
||||
E('span', {}, iface.listen_port || 51820)
|
||||
])
|
||||
]),
|
||||
|
||||
ifacePeers.length > 0 ?
|
||||
E('div', { 'class': 'wg-peer-list' },
|
||||
ifacePeers.map(function(peer) {
|
||||
return E('div', { 'class': 'wg-peer-item' }, [
|
||||
E('div', { 'class': 'wg-peer-info' }, [
|
||||
E('span', { 'class': 'wg-peer-icon' }, '👤'),
|
||||
E('div', {}, [
|
||||
E('strong', {}, peer.short_key || peer.public_key.substring(0, 8)),
|
||||
E('div', { 'class': 'wg-peer-ips' }, peer.allowed_ips || 'No IPs')
|
||||
])
|
||||
]),
|
||||
E('button', {
|
||||
'class': 'wg-btn wg-btn-primary',
|
||||
'click': function() {
|
||||
// Download config as file
|
||||
var blob = new Blob([clientConfig], { type: 'text/plain' });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = iface.name + '-peer' + (idx + 1) + '.conf';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
var endpoint = sessionStorage.getItem('wg_server_endpoint');
|
||||
if (!endpoint) {
|
||||
var input = document.getElementById('wg-server-endpoint');
|
||||
endpoint = input ? input.value.trim() : '';
|
||||
}
|
||||
if (!endpoint) {
|
||||
ui.addNotification(null, E('p', {}, _('Please enter the server endpoint first')), 'warning');
|
||||
return;
|
||||
}
|
||||
self.generateQRForPeer(iface, peer, endpoint);
|
||||
}
|
||||
}, '💾 Download .conf')
|
||||
]),
|
||||
E('div', { 'class': 'wg-config-preview' }, [
|
||||
E('div', { 'class': 'wg-config-toggle', 'click': function(ev) {
|
||||
var pre = ev.target.parentNode.querySelector('pre');
|
||||
pre.style.display = pre.style.display === 'none' ? 'block' : 'none';
|
||||
}}, '▶ Show configuration'),
|
||||
E('pre', { 'style': 'display: none' }, clientConfig)
|
||||
])
|
||||
]);
|
||||
})
|
||||
)
|
||||
])
|
||||
]);
|
||||
}) :
|
||||
}, '📱 ' + _('QR Code'))
|
||||
]);
|
||||
})
|
||||
) :
|
||||
E('div', { 'class': 'wg-empty-peers' }, _('No peers configured for this interface'))
|
||||
])
|
||||
]);
|
||||
})
|
||||
) :
|
||||
E('div', { 'class': 'wg-empty' }, [
|
||||
E('div', { 'class': 'wg-empty-icon' }, '📱'),
|
||||
E('div', { 'class': 'wg-empty-text' }, 'No WireGuard interfaces configured'),
|
||||
E('p', {}, 'Create an interface to generate QR codes for mobile clients')
|
||||
E('div', { 'class': 'wg-empty-text' }, _('No WireGuard interfaces configured')),
|
||||
E('p', {}, _('Create a WireGuard interface to generate QR codes'))
|
||||
])
|
||||
]);
|
||||
|
||||
// Additional CSS
|
||||
var css = `
|
||||
.wg-info-banner {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: rgba(6, 182, 212, 0.1);
|
||||
border: 1px solid rgba(6, 182, 212, 0.3);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
// Restore saved endpoint
|
||||
setTimeout(function() {
|
||||
var saved = sessionStorage.getItem('wg_server_endpoint');
|
||||
if (saved) {
|
||||
var input = document.getElementById('wg-server-endpoint');
|
||||
if (input) input.value = saved;
|
||||
}
|
||||
.wg-info-banner .wg-info-icon { font-size: 24px; }
|
||||
.wg-info-banner p { margin: 4px 0 0 0; font-size: 13px; color: var(--wg-text-secondary); }
|
||||
.wg-qr-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; }
|
||||
.wg-qr-card {
|
||||
}, 100);
|
||||
|
||||
// Add CSS
|
||||
var css = `
|
||||
.wg-form-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
.wg-interface-info {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: var(--wg-bg-tertiary);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.wg-info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.wg-info-label {
|
||||
font-size: 12px;
|
||||
color: var(--wg-text-muted);
|
||||
}
|
||||
.wg-peer-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.wg-peer-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background: var(--wg-bg-tertiary);
|
||||
border: 1px solid var(--wg-border);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.wg-qr-header {
|
||||
.wg-peer-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.wg-qr-icon { font-size: 28px; }
|
||||
.wg-qr-header h4 { margin: 0; font-size: 16px; }
|
||||
.wg-qr-header code { font-size: 10px; color: var(--wg-text-muted); }
|
||||
.wg-qr-code {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
.wg-peer-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
.wg-peer-ips {
|
||||
font-size: 12px;
|
||||
color: var(--wg-text-muted);
|
||||
font-family: monospace;
|
||||
}
|
||||
.wg-empty-peers {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: var(--wg-text-muted);
|
||||
}
|
||||
.wg-qr-modal {
|
||||
text-align: center;
|
||||
}
|
||||
.wg-qr-container {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
display: inline-block;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.wg-qr-image {
|
||||
max-width: 250px;
|
||||
max-height: 250px;
|
||||
}
|
||||
.wg-qr-hint {
|
||||
color: var(--wg-text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
.wg-qr-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
gap: 10px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.wg-qr-placeholder {
|
||||
text-align: center;
|
||||
color: #333;
|
||||
.wg-config-details {
|
||||
text-align: left;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.wg-qr-placeholder span { font-size: 48px; display: block; margin-bottom: 8px; }
|
||||
.wg-qr-placeholder p { margin: 0; font-weight: 600; }
|
||||
.wg-qr-placeholder small { font-size: 11px; color: #666; }
|
||||
.wg-qr-actions { display: flex; gap: 10px; margin-bottom: 12px; }
|
||||
.wg-config-preview { margin-top: 12px; }
|
||||
.wg-config-toggle {
|
||||
.wg-config-details summary {
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--wg-accent-cyan);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.wg-config-preview pre {
|
||||
margin-top: 10px;
|
||||
.wg-config-details pre {
|
||||
background: var(--wg-bg-tertiary);
|
||||
padding: 12px;
|
||||
background: var(--wg-bg-primary);
|
||||
border-radius: 8px;
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.wg-form-group {
|
||||
margin: 16px 0;
|
||||
text-align: left;
|
||||
}
|
||||
.wg-form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
`;
|
||||
var style = E('style', {}, css);
|
||||
document.head.appendChild(style);
|
||||
|
||||
|
||||
var cssLink = E('link', { 'rel': 'stylesheet', 'href': L.resource('wireguard-dashboard/dashboard.css') });
|
||||
document.head.appendChild(cssLink);
|
||||
|
||||
|
||||
return view;
|
||||
},
|
||||
|
||||
|
||||
handleSaveApply: null,
|
||||
handleSave: null,
|
||||
handleReset: null
|
||||
|
||||
@ -8,12 +8,84 @@
|
||||
|
||||
return view.extend({
|
||||
title: _('WireGuard Traffic'),
|
||||
|
||||
pollInterval: 5,
|
||||
pollActive: true,
|
||||
|
||||
load: function() {
|
||||
return api.getTraffic();
|
||||
},
|
||||
|
||||
|
||||
updateTrafficStats: function(traffic) {
|
||||
var totalRx = traffic.total_rx || 0;
|
||||
var totalTx = traffic.total_tx || 0;
|
||||
var totalTraffic = totalRx + totalTx;
|
||||
|
||||
// Update totals
|
||||
var rxEl = document.querySelector('.wg-traffic-total-rx');
|
||||
var txEl = document.querySelector('.wg-traffic-total-tx');
|
||||
var totalEl = document.querySelector('.wg-traffic-total');
|
||||
|
||||
if (rxEl) {
|
||||
rxEl.textContent = api.formatBytes(totalRx);
|
||||
rxEl.classList.add('wg-value-updated');
|
||||
setTimeout(function() { rxEl.classList.remove('wg-value-updated'); }, 500);
|
||||
}
|
||||
if (txEl) {
|
||||
txEl.textContent = api.formatBytes(totalTx);
|
||||
txEl.classList.add('wg-value-updated');
|
||||
setTimeout(function() { txEl.classList.remove('wg-value-updated'); }, 500);
|
||||
}
|
||||
if (totalEl) {
|
||||
totalEl.textContent = api.formatBytes(totalTraffic);
|
||||
}
|
||||
|
||||
// Update per-interface stats
|
||||
var interfaces = traffic.interfaces || [];
|
||||
interfaces.forEach(function(iface) {
|
||||
var card = document.querySelector('.wg-interface-card[data-iface="' + iface.name + '"]');
|
||||
if (!card) return;
|
||||
|
||||
var ifaceTotal = (iface.total_rx || 0) + (iface.total_tx || 0);
|
||||
var rxPct = totalTraffic > 0 ? ((iface.total_rx || 0) / totalTraffic * 100) : 0;
|
||||
var txPct = totalTraffic > 0 ? ((iface.total_tx || 0) / totalTraffic * 100) : 0;
|
||||
|
||||
// Update traffic values
|
||||
var rxSpan = card.querySelector('.wg-iface-rx');
|
||||
var txSpan = card.querySelector('.wg-iface-tx');
|
||||
var totalSpan = card.querySelector('.wg-iface-total');
|
||||
|
||||
if (rxSpan) rxSpan.textContent = '↓ ' + api.formatBytes(iface.total_rx || 0);
|
||||
if (txSpan) txSpan.textContent = '↑ ' + api.formatBytes(iface.total_tx || 0);
|
||||
if (totalSpan) totalSpan.textContent = api.formatBytes(ifaceTotal) + ' total';
|
||||
|
||||
// Update progress bars
|
||||
var rxBar = card.querySelector('.wg-traffic-bar-rx');
|
||||
var txBar = card.querySelector('.wg-traffic-bar-tx');
|
||||
if (rxBar) rxBar.style.width = rxPct + '%';
|
||||
if (txBar) txBar.style.width = txPct + '%';
|
||||
});
|
||||
},
|
||||
|
||||
startPolling: function() {
|
||||
var self = this;
|
||||
this.pollActive = true;
|
||||
|
||||
poll.add(L.bind(function() {
|
||||
if (!this.pollActive) return Promise.resolve();
|
||||
|
||||
return api.getTraffic().then(L.bind(function(data) {
|
||||
this.updateTrafficStats(data || {});
|
||||
}, this));
|
||||
}, this), this.pollInterval);
|
||||
},
|
||||
|
||||
stopPolling: function() {
|
||||
this.pollActive = false;
|
||||
poll.stop();
|
||||
},
|
||||
|
||||
render: function(data) {
|
||||
var self = this;
|
||||
var traffic = data || {};
|
||||
var interfaces = traffic.interfaces || [];
|
||||
var totalRx = traffic.total_rx || 0;
|
||||
@ -37,7 +109,7 @@ return view.extend({
|
||||
E('span', { 'class': 'wg-quick-stat-icon' }, '📥'),
|
||||
E('span', { 'class': 'wg-quick-stat-label' }, 'Total Downloaded')
|
||||
]),
|
||||
E('div', { 'class': 'wg-quick-stat-value' }, api.formatBytes(totalRx)),
|
||||
E('div', { 'class': 'wg-quick-stat-value wg-traffic-total-rx' }, api.formatBytes(totalRx)),
|
||||
E('div', { 'class': 'wg-quick-stat-sub' }, 'All interfaces combined')
|
||||
]),
|
||||
E('div', { 'class': 'wg-quick-stat', 'style': '--stat-gradient: linear-gradient(135deg, #0ea5e9, #38bdf8)' }, [
|
||||
@ -45,7 +117,7 @@ return view.extend({
|
||||
E('span', { 'class': 'wg-quick-stat-icon' }, '📤'),
|
||||
E('span', { 'class': 'wg-quick-stat-label' }, 'Total Uploaded')
|
||||
]),
|
||||
E('div', { 'class': 'wg-quick-stat-value' }, api.formatBytes(totalTx)),
|
||||
E('div', { 'class': 'wg-quick-stat-value wg-traffic-total-tx' }, api.formatBytes(totalTx)),
|
||||
E('div', { 'class': 'wg-quick-stat-sub' }, 'All interfaces combined')
|
||||
]),
|
||||
E('div', { 'class': 'wg-quick-stat' }, [
|
||||
@ -53,7 +125,7 @@ return view.extend({
|
||||
E('span', { 'class': 'wg-quick-stat-icon' }, '📈'),
|
||||
E('span', { 'class': 'wg-quick-stat-label' }, 'Total Traffic')
|
||||
]),
|
||||
E('div', { 'class': 'wg-quick-stat-value' }, api.formatBytes(totalTraffic)),
|
||||
E('div', { 'class': 'wg-quick-stat-value wg-traffic-total' }, api.formatBytes(totalTraffic)),
|
||||
E('div', { 'class': 'wg-quick-stat-sub' }, 'RX + TX combined')
|
||||
])
|
||||
]),
|
||||
@ -72,21 +144,21 @@ return view.extend({
|
||||
var ifaceTotal = (iface.total_rx || 0) + (iface.total_tx || 0);
|
||||
var rxPct = totalTraffic > 0 ? ((iface.total_rx || 0) / totalTraffic * 100) : 0;
|
||||
var txPct = totalTraffic > 0 ? ((iface.total_tx || 0) / totalTraffic * 100) : 0;
|
||||
|
||||
return E('div', { 'class': 'wg-interface-card', 'style': 'margin-bottom: 16px' }, [
|
||||
|
||||
return E('div', { 'class': 'wg-interface-card', 'data-iface': iface.name, 'style': 'margin-bottom: 16px' }, [
|
||||
E('div', { 'class': 'wg-interface-header' }, [
|
||||
E('div', { 'class': 'wg-interface-name' }, [
|
||||
E('div', { 'class': 'wg-interface-icon' }, '🌐'),
|
||||
E('div', {}, [
|
||||
E('h3', {}, iface.name),
|
||||
E('p', {}, api.formatBytes(ifaceTotal) + ' total')
|
||||
E('p', { 'class': 'wg-iface-total' }, api.formatBytes(ifaceTotal) + ' total')
|
||||
])
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'wg-traffic-bar' }, [
|
||||
E('div', { 'class': 'wg-traffic-bar-header' }, [
|
||||
E('span', { 'style': 'color: #10b981' }, '↓ ' + api.formatBytes(iface.total_rx || 0)),
|
||||
E('span', { 'style': 'color: #0ea5e9' }, '↑ ' + api.formatBytes(iface.total_tx || 0))
|
||||
E('span', { 'class': 'wg-iface-rx', 'style': 'color: #10b981' }, '↓ ' + api.formatBytes(iface.total_rx || 0)),
|
||||
E('span', { 'class': 'wg-iface-tx', 'style': 'color: #0ea5e9' }, '↑ ' + api.formatBytes(iface.total_tx || 0))
|
||||
]),
|
||||
E('div', { 'class': 'wg-traffic-bar-track' }, [
|
||||
E('div', { 'class': 'wg-traffic-bar-rx', 'style': 'width:' + rxPct + '%' }),
|
||||
@ -129,10 +201,13 @@ return view.extend({
|
||||
// Include CSS
|
||||
var cssLink = E('link', { 'rel': 'stylesheet', 'href': L.resource('wireguard-dashboard/dashboard.css') });
|
||||
document.head.appendChild(cssLink);
|
||||
|
||||
|
||||
// Start auto-refresh
|
||||
this.startPolling();
|
||||
|
||||
return view;
|
||||
},
|
||||
|
||||
|
||||
handleSaveApply: null,
|
||||
handleSave: null,
|
||||
handleReset: null
|
||||
|
||||
@ -92,6 +92,26 @@ function formatLastHandshake(timestamp) {
|
||||
return Math.floor(diff / 86400) + 'd ago';
|
||||
}
|
||||
|
||||
function getPeerStatusClass(status) {
|
||||
if (status === 'active') return 'active';
|
||||
if (status === 'idle') return 'idle';
|
||||
return 'inactive';
|
||||
}
|
||||
|
||||
function shortenKey(key, length) {
|
||||
if (!key) return 'N/A';
|
||||
length = length || 8;
|
||||
return key.substring(0, length) + '...';
|
||||
}
|
||||
|
||||
function formatHandshake(seconds) {
|
||||
if (!seconds || seconds === 0) return 'Never';
|
||||
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';
|
||||
}
|
||||
|
||||
return baseclass.extend({
|
||||
getStatus: callStatus,
|
||||
getPeers: callGetPeers,
|
||||
@ -105,6 +125,9 @@ return baseclass.extend({
|
||||
generateQR: callGenerateQR,
|
||||
formatBytes: formatBytes,
|
||||
formatLastHandshake: formatLastHandshake,
|
||||
getPeerStatusClass: getPeerStatusClass,
|
||||
shortenKey: shortenKey,
|
||||
formatHandshake: formatHandshake,
|
||||
|
||||
// Aggregate function for overview page
|
||||
getAllData: function() {
|
||||
|
||||
@ -0,0 +1,283 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Lightweight SVG sparkline chart component for WireGuard Dashboard
|
||||
* No external dependencies - pure SVG generation
|
||||
*/
|
||||
|
||||
return {
|
||||
// Default options
|
||||
defaults: {
|
||||
width: 200,
|
||||
height: 50,
|
||||
strokeWidth: 2,
|
||||
fill: true,
|
||||
lineColor: '#0ea5e9',
|
||||
fillColor: 'rgba(14, 165, 233, 0.1)',
|
||||
gridColor: 'rgba(255, 255, 255, 0.1)',
|
||||
showGrid: false,
|
||||
animate: true
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate SVG sparkline from data points
|
||||
* @param {number[]} data - Array of numeric values
|
||||
* @param {Object} opts - Chart options
|
||||
* @returns {string} SVG markup string
|
||||
*/
|
||||
sparkline: function(data, opts) {
|
||||
opts = Object.assign({}, this.defaults, opts || {});
|
||||
|
||||
if (!data || data.length < 2) {
|
||||
return this.emptyChart(opts);
|
||||
}
|
||||
|
||||
var width = opts.width;
|
||||
var height = opts.height;
|
||||
var padding = 4;
|
||||
var chartWidth = width - padding * 2;
|
||||
var chartHeight = height - padding * 2;
|
||||
|
||||
// Normalize data
|
||||
var max = Math.max.apply(null, data);
|
||||
var min = Math.min.apply(null, data);
|
||||
var range = max - min || 1;
|
||||
|
||||
// Generate points
|
||||
var points = [];
|
||||
var step = chartWidth / (data.length - 1);
|
||||
for (var i = 0; i < data.length; i++) {
|
||||
var x = padding + i * step;
|
||||
var y = padding + chartHeight - ((data[i] - min) / range * chartHeight);
|
||||
points.push(x + ',' + y);
|
||||
}
|
||||
|
||||
var pathData = 'M' + points.join(' L');
|
||||
|
||||
// Build fill path (closed area under line)
|
||||
var fillPath = '';
|
||||
if (opts.fill) {
|
||||
fillPath = pathData +
|
||||
' L' + (padding + chartWidth) + ',' + (padding + chartHeight) +
|
||||
' L' + padding + ',' + (padding + chartHeight) + ' Z';
|
||||
}
|
||||
|
||||
// Build SVG
|
||||
var svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + width + ' ' + height + '" ' +
|
||||
'width="' + width + '" height="' + height + '" class="wg-chart-sparkline">';
|
||||
|
||||
// Grid lines
|
||||
if (opts.showGrid) {
|
||||
for (var g = 0; g < 4; g++) {
|
||||
var gy = padding + (chartHeight / 3) * g;
|
||||
svg += '<line x1="' + padding + '" y1="' + gy + '" x2="' + (width - padding) + '" y2="' + gy + '" ' +
|
||||
'stroke="' + opts.gridColor + '" stroke-width="1" stroke-dasharray="2,2"/>';
|
||||
}
|
||||
}
|
||||
|
||||
// Fill area
|
||||
if (opts.fill && fillPath) {
|
||||
svg += '<path d="' + fillPath + '" fill="' + opts.fillColor + '" class="wg-chart-fill"/>';
|
||||
}
|
||||
|
||||
// Line
|
||||
svg += '<path d="' + pathData + '" fill="none" stroke="' + opts.lineColor + '" ' +
|
||||
'stroke-width="' + opts.strokeWidth + '" stroke-linecap="round" stroke-linejoin="round" ' +
|
||||
'class="wg-chart-line"';
|
||||
|
||||
if (opts.animate) {
|
||||
var pathLength = this.estimatePathLength(data.length, chartWidth, chartHeight);
|
||||
svg += ' stroke-dasharray="' + pathLength + '" stroke-dashoffset="' + pathLength + '" ' +
|
||||
'style="animation: wg-chart-draw 1s ease-out forwards"';
|
||||
}
|
||||
svg += '/>';
|
||||
|
||||
// End point dot
|
||||
var lastX = padding + (data.length - 1) * step;
|
||||
var lastY = padding + chartHeight - ((data[data.length - 1] - min) / range * chartHeight);
|
||||
svg += '<circle cx="' + lastX + '" cy="' + lastY + '" r="3" fill="' + opts.lineColor + '" class="wg-chart-dot"/>';
|
||||
|
||||
svg += '</svg>';
|
||||
return svg;
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate dual-line chart for RX/TX comparison
|
||||
* @param {number[]} rxData - Download data points
|
||||
* @param {number[]} txData - Upload data points
|
||||
* @param {Object} opts - Chart options
|
||||
* @returns {string} SVG markup string
|
||||
*/
|
||||
dualSparkline: function(rxData, txData, opts) {
|
||||
opts = Object.assign({}, this.defaults, {
|
||||
width: 300,
|
||||
height: 60,
|
||||
rxColor: '#10b981',
|
||||
txColor: '#0ea5e9',
|
||||
rxFill: 'rgba(16, 185, 129, 0.1)',
|
||||
txFill: 'rgba(14, 165, 233, 0.1)'
|
||||
}, opts || {});
|
||||
|
||||
var width = opts.width;
|
||||
var height = opts.height;
|
||||
var padding = 4;
|
||||
var chartWidth = width - padding * 2;
|
||||
var chartHeight = height - padding * 2;
|
||||
|
||||
// Combine for scale
|
||||
var allData = (rxData || []).concat(txData || []);
|
||||
if (allData.length < 2) {
|
||||
return this.emptyChart(opts);
|
||||
}
|
||||
|
||||
var max = Math.max.apply(null, allData);
|
||||
var min = 0;
|
||||
var range = max - min || 1;
|
||||
|
||||
var svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + width + ' ' + height + '" ' +
|
||||
'width="' + width + '" height="' + height + '" class="wg-chart-dual">';
|
||||
|
||||
// Draw each line
|
||||
var datasets = [
|
||||
{ data: rxData, color: opts.rxColor, fill: opts.rxFill, label: 'RX' },
|
||||
{ data: txData, color: opts.txColor, fill: opts.txFill, label: 'TX' }
|
||||
];
|
||||
|
||||
datasets.forEach(function(ds) {
|
||||
if (!ds.data || ds.data.length < 2) return;
|
||||
|
||||
var step = chartWidth / (ds.data.length - 1);
|
||||
var points = [];
|
||||
for (var i = 0; i < ds.data.length; i++) {
|
||||
var x = padding + i * step;
|
||||
var y = padding + chartHeight - ((ds.data[i] - min) / range * chartHeight);
|
||||
points.push(x + ',' + y);
|
||||
}
|
||||
|
||||
var pathData = 'M' + points.join(' L');
|
||||
|
||||
// Fill
|
||||
if (opts.fill) {
|
||||
var fillPath = pathData +
|
||||
' L' + (padding + chartWidth) + ',' + (padding + chartHeight) +
|
||||
' L' + padding + ',' + (padding + chartHeight) + ' Z';
|
||||
svg += '<path d="' + fillPath + '" fill="' + ds.fill + '" opacity="0.5"/>';
|
||||
}
|
||||
|
||||
// Line
|
||||
svg += '<path d="' + pathData + '" fill="none" stroke="' + ds.color + '" ' +
|
||||
'stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>';
|
||||
});
|
||||
|
||||
// Legend
|
||||
svg += '<text x="' + (width - 60) + '" y="12" font-size="10" fill="' + opts.rxColor + '">↓ RX</text>';
|
||||
svg += '<text x="' + (width - 30) + '" y="12" font-size="10" fill="' + opts.txColor + '">↑ TX</text>';
|
||||
|
||||
svg += '</svg>';
|
||||
return svg;
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate empty/placeholder chart
|
||||
* @param {Object} opts - Chart options
|
||||
* @returns {string} SVG markup string
|
||||
*/
|
||||
emptyChart: function(opts) {
|
||||
var width = opts.width || 200;
|
||||
var height = opts.height || 50;
|
||||
|
||||
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + width + ' ' + height + '" ' +
|
||||
'width="' + width + '" height="' + height + '" class="wg-chart-empty">' +
|
||||
'<line x1="10" y1="' + (height / 2) + '" x2="' + (width - 10) + '" y2="' + (height / 2) + '" ' +
|
||||
'stroke="rgba(255,255,255,0.2)" stroke-width="1" stroke-dasharray="4,4"/>' +
|
||||
'<text x="' + (width / 2) + '" y="' + (height / 2 + 4) + '" ' +
|
||||
'text-anchor="middle" fill="rgba(255,255,255,0.3)" font-size="10">No data</text>' +
|
||||
'</svg>';
|
||||
},
|
||||
|
||||
/**
|
||||
* Estimate path length for animation
|
||||
*/
|
||||
estimatePathLength: function(points, width, height) {
|
||||
return Math.sqrt(width * width + height * height) * 1.5;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create chart container element
|
||||
* @param {string} svgContent - SVG markup
|
||||
* @param {string} title - Chart title
|
||||
* @returns {HTMLElement} Container element
|
||||
*/
|
||||
createContainer: function(svgContent, title) {
|
||||
var container = document.createElement('div');
|
||||
container.className = 'wg-chart-container';
|
||||
|
||||
if (title) {
|
||||
var titleEl = document.createElement('div');
|
||||
titleEl.className = 'wg-chart-title';
|
||||
titleEl.textContent = title;
|
||||
container.appendChild(titleEl);
|
||||
}
|
||||
|
||||
var chartEl = document.createElement('div');
|
||||
chartEl.className = 'wg-chart-body';
|
||||
chartEl.innerHTML = svgContent;
|
||||
container.appendChild(chartEl);
|
||||
|
||||
return container;
|
||||
},
|
||||
|
||||
/**
|
||||
* Traffic history ring buffer manager
|
||||
*/
|
||||
TrafficHistory: {
|
||||
maxPoints: 60,
|
||||
data: {},
|
||||
|
||||
add: function(ifaceName, rx, tx) {
|
||||
if (!this.data[ifaceName]) {
|
||||
this.data[ifaceName] = { rx: [], tx: [], timestamps: [] };
|
||||
}
|
||||
|
||||
var entry = this.data[ifaceName];
|
||||
entry.rx.push(rx);
|
||||
entry.tx.push(tx);
|
||||
entry.timestamps.push(Date.now());
|
||||
|
||||
// Trim to max points
|
||||
if (entry.rx.length > this.maxPoints) {
|
||||
entry.rx.shift();
|
||||
entry.tx.shift();
|
||||
entry.timestamps.shift();
|
||||
}
|
||||
},
|
||||
|
||||
get: function(ifaceName) {
|
||||
return this.data[ifaceName] || { rx: [], tx: [], timestamps: [] };
|
||||
},
|
||||
|
||||
getRates: function(ifaceName) {
|
||||
var entry = this.data[ifaceName];
|
||||
if (!entry || entry.rx.length < 2) return { rx: [], tx: [] };
|
||||
|
||||
var rxRates = [];
|
||||
var txRates = [];
|
||||
for (var i = 1; i < entry.rx.length; i++) {
|
||||
var timeDiff = (entry.timestamps[i] - entry.timestamps[i - 1]) / 1000;
|
||||
if (timeDiff > 0) {
|
||||
rxRates.push((entry.rx[i] - entry.rx[i - 1]) / timeDiff);
|
||||
txRates.push((entry.tx[i] - entry.tx[i - 1]) / timeDiff);
|
||||
}
|
||||
}
|
||||
return { rx: rxRates, tx: txRates };
|
||||
},
|
||||
|
||||
clear: function(ifaceName) {
|
||||
if (ifaceName) {
|
||||
delete this.data[ifaceName];
|
||||
} else {
|
||||
this.data = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -810,3 +810,333 @@
|
||||
.wg-btn-primary:hover {
|
||||
box-shadow: var(--wg-shadow-glow);
|
||||
}
|
||||
|
||||
.wg-btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Auto-refresh control */
|
||||
.wg-refresh-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 16px;
|
||||
background: var(--wg-bg-secondary);
|
||||
border: 1px solid var(--wg-border);
|
||||
border-radius: var(--wg-radius);
|
||||
}
|
||||
|
||||
.wg-refresh-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--wg-text-secondary);
|
||||
}
|
||||
|
||||
.wg-refresh-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--wg-text-muted);
|
||||
}
|
||||
|
||||
.wg-refresh-indicator.active {
|
||||
background: var(--wg-accent-green);
|
||||
animation: pulse-wg 1.5s ease-in-out infinite;
|
||||
box-shadow: 0 0 8px var(--wg-accent-green);
|
||||
}
|
||||
|
||||
.wg-refresh-state {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Value update animation */
|
||||
@keyframes value-flash {
|
||||
0% { background-color: transparent; }
|
||||
50% { background-color: rgba(6, 182, 212, 0.3); }
|
||||
100% { background-color: transparent; }
|
||||
}
|
||||
|
||||
.wg-value-updated {
|
||||
animation: value-flash 0.5s ease-out;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Interface tabs */
|
||||
.wg-interface-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--wg-border);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.wg-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: var(--wg-bg-tertiary);
|
||||
border: 1px solid var(--wg-border);
|
||||
border-radius: 8px;
|
||||
color: var(--wg-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.wg-tab:hover {
|
||||
border-color: var(--wg-accent-cyan);
|
||||
}
|
||||
|
||||
.wg-tab.active {
|
||||
background: var(--wg-tunnel-gradient);
|
||||
border-color: transparent;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.wg-tab-badge {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Wizard stepper */
|
||||
.wg-stepper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.wg-stepper-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.wg-stepper-item:not(:last-child)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: calc(50% + 20px);
|
||||
width: calc(100% - 40px);
|
||||
height: 2px;
|
||||
background: var(--wg-border);
|
||||
}
|
||||
|
||||
.wg-stepper-item.completed::after {
|
||||
background: var(--wg-accent-green);
|
||||
}
|
||||
|
||||
.wg-stepper-circle {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--wg-bg-tertiary);
|
||||
border: 2px solid var(--wg-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.wg-stepper-item.active .wg-stepper-circle {
|
||||
background: var(--wg-tunnel-gradient);
|
||||
border-color: transparent;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.wg-stepper-item.completed .wg-stepper-circle {
|
||||
background: var(--wg-accent-green);
|
||||
border-color: transparent;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.wg-stepper-title {
|
||||
font-size: 11px;
|
||||
color: var(--wg-text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.wg-stepper-item.active .wg-stepper-title,
|
||||
.wg-stepper-item.completed .wg-stepper-title {
|
||||
color: var(--wg-text-primary);
|
||||
}
|
||||
|
||||
/* Chart styles */
|
||||
.wg-chart-container {
|
||||
background: var(--wg-bg-primary);
|
||||
border-radius: var(--wg-radius);
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.wg-chart {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
}
|
||||
|
||||
.wg-chart-title {
|
||||
font-size: 12px;
|
||||
color: var(--wg-text-muted);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Traffic history */
|
||||
.wg-traffic-history {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wg-traffic-history-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
background: var(--wg-bg-tertiary);
|
||||
border-radius: var(--wg-radius);
|
||||
}
|
||||
|
||||
.wg-traffic-history-time {
|
||||
font-size: 11px;
|
||||
color: var(--wg-text-muted);
|
||||
font-family: var(--wg-font-mono);
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.wg-traffic-history-bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: var(--wg-bg-primary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wg-traffic-history-fill {
|
||||
height: 100%;
|
||||
background: var(--wg-tunnel-gradient);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* Interface list */
|
||||
.wg-interface-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Form group */
|
||||
.wg-form-group {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.wg-form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.wg-form-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Notification badge */
|
||||
.wg-notification {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 12px 20px;
|
||||
background: var(--wg-bg-secondary);
|
||||
border: 1px solid var(--wg-border);
|
||||
border-radius: var(--wg-radius);
|
||||
box-shadow: var(--wg-shadow);
|
||||
z-index: 1000;
|
||||
animation: slide-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
.wg-notification.success {
|
||||
border-color: var(--wg-accent-green);
|
||||
}
|
||||
|
||||
.wg-notification.error {
|
||||
border-color: var(--wg-accent-red);
|
||||
}
|
||||
|
||||
@keyframes slide-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(100px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Chart animations */
|
||||
@keyframes wg-chart-draw {
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.wg-chart-sparkline {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.wg-chart-dot {
|
||||
animation: pulse-wg 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.wg-chart-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.wg-chart-legend {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.wg-chart-legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--wg-text-secondary);
|
||||
}
|
||||
|
||||
.wg-chart-legend-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.wg-chart-legend-dot.rx {
|
||||
background: var(--wg-accent-green);
|
||||
}
|
||||
|
||||
.wg-chart-legend-dot.tx {
|
||||
background: var(--wg-accent-blue);
|
||||
}
|
||||
|
||||
@ -0,0 +1,355 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
|
||||
/**
|
||||
* Minimal QR Code Generator for WireGuard Dashboard
|
||||
* Generates QR codes using Reed-Solomon error correction
|
||||
* Based on QR Code specification ISO/IEC 18004:2015
|
||||
*/
|
||||
|
||||
// QR Code version 5 (37x37 modules) with error correction level L
|
||||
// This size supports up to 154 alphanumeric chars or 106 bytes (sufficient for WireGuard configs)
|
||||
var QR_VERSION = 5;
|
||||
var QR_SIZE = 37;
|
||||
var QR_EC_LEVEL = 'L';
|
||||
|
||||
// Generator polynomial for Reed-Solomon (version 5, EC level L uses 26 EC codewords)
|
||||
var RS_GENERATOR = [1, 212, 246, 77, 73, 195, 192, 75, 98, 5, 70, 103, 177, 22, 217, 138, 51, 181, 246, 72, 25, 18, 46, 228, 74, 216, 195];
|
||||
|
||||
// Galois Field 256 tables
|
||||
var GF_EXP = [];
|
||||
var GF_LOG = [];
|
||||
|
||||
// Initialize Galois Field tables
|
||||
(function() {
|
||||
var x = 1;
|
||||
for (var i = 0; i < 256; i++) {
|
||||
GF_EXP[i] = x;
|
||||
GF_LOG[x] = i;
|
||||
x = x << 1;
|
||||
if (x >= 256) x ^= 0x11d;
|
||||
}
|
||||
GF_LOG[1] = 0;
|
||||
})();
|
||||
|
||||
// Galois Field multiplication
|
||||
function gfMul(a, b) {
|
||||
if (a === 0 || b === 0) return 0;
|
||||
return GF_EXP[(GF_LOG[a] + GF_LOG[b]) % 255];
|
||||
}
|
||||
|
||||
// Reed-Solomon encoding
|
||||
function rsEncode(data, nsym) {
|
||||
var gen = RS_GENERATOR.slice(0, nsym + 1);
|
||||
var res = new Array(data.length + nsym).fill(0);
|
||||
for (var i = 0; i < data.length; i++) {
|
||||
res[i] = data[i];
|
||||
}
|
||||
|
||||
for (var i = 0; i < data.length; i++) {
|
||||
var coef = res[i];
|
||||
if (coef !== 0) {
|
||||
for (var j = 0; j < gen.length; j++) {
|
||||
res[i + j] ^= gfMul(gen[j], coef);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res.slice(data.length);
|
||||
}
|
||||
|
||||
// Encode text to bytes
|
||||
function textToBytes(text) {
|
||||
var bytes = [];
|
||||
for (var i = 0; i < text.length; i++) {
|
||||
var c = text.charCodeAt(i);
|
||||
if (c < 128) {
|
||||
bytes.push(c);
|
||||
} else if (c < 2048) {
|
||||
bytes.push((c >> 6) | 192);
|
||||
bytes.push((c & 63) | 128);
|
||||
} else {
|
||||
bytes.push((c >> 12) | 224);
|
||||
bytes.push(((c >> 6) & 63) | 128);
|
||||
bytes.push((c & 63) | 128);
|
||||
}
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// Create data codewords with mode indicator and length
|
||||
function createDataCodewords(text) {
|
||||
var bytes = textToBytes(text);
|
||||
var data = [];
|
||||
|
||||
// Mode indicator: 0100 (byte mode)
|
||||
// Character count indicator: 8 bits for version 1-9
|
||||
var header = (4 << 8) | bytes.length;
|
||||
data.push((header >> 8) & 0xff);
|
||||
data.push(header & 0xff);
|
||||
|
||||
// Shift to add 4-bit mode
|
||||
var bits = [];
|
||||
bits.push(0, 1, 0, 0); // Byte mode
|
||||
|
||||
// 8-bit count
|
||||
for (var i = 7; i >= 0; i--) {
|
||||
bits.push((bytes.length >> i) & 1);
|
||||
}
|
||||
|
||||
// Data bits
|
||||
for (var i = 0; i < bytes.length; i++) {
|
||||
for (var j = 7; j >= 0; j--) {
|
||||
bits.push((bytes[i] >> j) & 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Terminator
|
||||
for (var i = 0; i < 4 && bits.length < 108 * 8; i++) {
|
||||
bits.push(0);
|
||||
}
|
||||
|
||||
// Pad to byte boundary
|
||||
while (bits.length % 8 !== 0) {
|
||||
bits.push(0);
|
||||
}
|
||||
|
||||
// Pad codewords (236 and 17 alternating)
|
||||
var padBytes = [236, 17];
|
||||
var padIdx = 0;
|
||||
while (bits.length < 108 * 8) {
|
||||
for (var j = 7; j >= 0; j--) {
|
||||
bits.push((padBytes[padIdx] >> j) & 1);
|
||||
}
|
||||
padIdx = (padIdx + 1) % 2;
|
||||
}
|
||||
|
||||
// Convert bits to bytes
|
||||
data = [];
|
||||
for (var i = 0; i < bits.length; i += 8) {
|
||||
var byte = 0;
|
||||
for (var j = 0; j < 8; j++) {
|
||||
byte = (byte << 1) | bits[i + j];
|
||||
}
|
||||
data.push(byte);
|
||||
}
|
||||
|
||||
return data.slice(0, 108);
|
||||
}
|
||||
|
||||
// Create QR matrix
|
||||
function createMatrix(text) {
|
||||
var matrix = [];
|
||||
for (var i = 0; i < QR_SIZE; i++) {
|
||||
matrix[i] = new Array(QR_SIZE).fill(null);
|
||||
}
|
||||
|
||||
// Add finder patterns
|
||||
addFinderPattern(matrix, 0, 0);
|
||||
addFinderPattern(matrix, QR_SIZE - 7, 0);
|
||||
addFinderPattern(matrix, 0, QR_SIZE - 7);
|
||||
|
||||
// Add alignment pattern (version 5 has one at 6,30)
|
||||
addAlignmentPattern(matrix, 30, 30);
|
||||
|
||||
// Add timing patterns
|
||||
for (var i = 8; i < QR_SIZE - 8; i++) {
|
||||
matrix[6][i] = i % 2 === 0 ? 1 : 0;
|
||||
matrix[i][6] = i % 2 === 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
// Add dark module
|
||||
matrix[QR_SIZE - 8][8] = 1;
|
||||
|
||||
// Reserve format info areas
|
||||
for (var i = 0; i < 9; i++) {
|
||||
if (matrix[8][i] === null) matrix[8][i] = 0;
|
||||
if (matrix[i][8] === null) matrix[i][8] = 0;
|
||||
}
|
||||
for (var i = QR_SIZE - 8; i < QR_SIZE; i++) {
|
||||
if (matrix[8][i] === null) matrix[8][i] = 0;
|
||||
if (matrix[i][8] === null) matrix[i][8] = 0;
|
||||
}
|
||||
|
||||
// Create and place data
|
||||
var data = createDataCodewords(text);
|
||||
var ec = rsEncode(data, 26);
|
||||
var allData = data.concat(ec);
|
||||
|
||||
// Convert to bits
|
||||
var bits = [];
|
||||
for (var i = 0; i < allData.length; i++) {
|
||||
for (var j = 7; j >= 0; j--) {
|
||||
bits.push((allData[i] >> j) & 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Place data bits
|
||||
var bitIdx = 0;
|
||||
var up = true;
|
||||
for (var col = QR_SIZE - 1; col >= 0; col -= 2) {
|
||||
if (col === 6) col = 5;
|
||||
|
||||
for (var row = up ? QR_SIZE - 1 : 0; up ? row >= 0 : row < QR_SIZE; row += up ? -1 : 1) {
|
||||
for (var c = 0; c < 2; c++) {
|
||||
var x = col - c;
|
||||
if (matrix[row][x] === null && bitIdx < bits.length) {
|
||||
matrix[row][x] = bits[bitIdx++];
|
||||
}
|
||||
}
|
||||
}
|
||||
up = !up;
|
||||
}
|
||||
|
||||
// Apply mask pattern 0 (checkerboard)
|
||||
for (var row = 0; row < QR_SIZE; row++) {
|
||||
for (var col = 0; col < QR_SIZE; col++) {
|
||||
if (matrix[row][col] !== null && !isReserved(row, col)) {
|
||||
if ((row + col) % 2 === 0) {
|
||||
matrix[row][col] ^= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add format info
|
||||
addFormatInfo(matrix);
|
||||
|
||||
return matrix;
|
||||
}
|
||||
|
||||
function isReserved(row, col) {
|
||||
// Finder patterns and separators
|
||||
if (row < 9 && col < 9) return true;
|
||||
if (row < 9 && col >= QR_SIZE - 8) return true;
|
||||
if (row >= QR_SIZE - 8 && col < 9) return true;
|
||||
|
||||
// Timing patterns
|
||||
if (row === 6 || col === 6) return true;
|
||||
|
||||
// Alignment pattern
|
||||
if (row >= 28 && row <= 32 && col >= 28 && col <= 32) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function addFinderPattern(matrix, row, col) {
|
||||
for (var r = 0; r < 7; r++) {
|
||||
for (var c = 0; c < 7; c++) {
|
||||
if (r === 0 || r === 6 || c === 0 || c === 6 ||
|
||||
(r >= 2 && r <= 4 && c >= 2 && c <= 4)) {
|
||||
matrix[row + r][col + c] = 1;
|
||||
} else {
|
||||
matrix[row + r][col + c] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Separator
|
||||
for (var i = 0; i < 8; i++) {
|
||||
if (row + 7 < QR_SIZE && col + i < QR_SIZE) matrix[row + 7][col + i] = 0;
|
||||
if (row + i < QR_SIZE && col + 7 < QR_SIZE) matrix[row + i][col + 7] = 0;
|
||||
if (row - 1 >= 0 && col + i < QR_SIZE) matrix[row - 1][col + i] = 0;
|
||||
if (row + i < QR_SIZE && col - 1 >= 0) matrix[row + i][col - 1] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function addAlignmentPattern(matrix, row, col) {
|
||||
for (var r = -2; r <= 2; r++) {
|
||||
for (var c = -2; c <= 2; c++) {
|
||||
if (Math.abs(r) === 2 || Math.abs(c) === 2 || (r === 0 && c === 0)) {
|
||||
matrix[row + r][col + c] = 1;
|
||||
} else {
|
||||
matrix[row + r][col + c] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addFormatInfo(matrix) {
|
||||
// Format info for mask 0 and EC level L
|
||||
var formatBits = [1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 0, 0, 1, 0, 0];
|
||||
|
||||
// Top-left
|
||||
for (var i = 0; i < 6; i++) {
|
||||
matrix[8][i] = formatBits[i];
|
||||
}
|
||||
matrix[8][7] = formatBits[6];
|
||||
matrix[8][8] = formatBits[7];
|
||||
matrix[7][8] = formatBits[8];
|
||||
for (var i = 9; i < 15; i++) {
|
||||
matrix[14 - i][8] = formatBits[i];
|
||||
}
|
||||
|
||||
// Top-right and bottom-left
|
||||
for (var i = 0; i < 8; i++) {
|
||||
matrix[8][QR_SIZE - 1 - i] = formatBits[i];
|
||||
}
|
||||
for (var i = 0; i < 7; i++) {
|
||||
matrix[QR_SIZE - 7 + i][8] = formatBits[8 + i];
|
||||
}
|
||||
}
|
||||
|
||||
// Generate SVG
|
||||
function generateSVG(text, size) {
|
||||
size = size || 200;
|
||||
var matrix = createMatrix(text);
|
||||
var moduleSize = size / QR_SIZE;
|
||||
|
||||
var svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + size + ' ' + size + '" width="' + size + '" height="' + size + '">';
|
||||
svg += '<rect width="100%" height="100%" fill="white"/>';
|
||||
|
||||
for (var row = 0; row < QR_SIZE; row++) {
|
||||
for (var col = 0; col < QR_SIZE; col++) {
|
||||
if (matrix[row][col] === 1) {
|
||||
svg += '<rect x="' + (col * moduleSize) + '" y="' + (row * moduleSize) + '" ';
|
||||
svg += 'width="' + moduleSize + '" height="' + moduleSize + '" fill="black"/>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
svg += '</svg>';
|
||||
return svg;
|
||||
}
|
||||
|
||||
return baseclass.extend({
|
||||
/**
|
||||
* Generate QR code as SVG string
|
||||
* @param {string} text - Text to encode
|
||||
* @param {number} size - Size in pixels (default: 200)
|
||||
* @returns {string} SVG markup
|
||||
*/
|
||||
generateSVG: function(text, size) {
|
||||
try {
|
||||
return generateSVG(text, size);
|
||||
} catch (e) {
|
||||
console.error('QR generation error:', e);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate QR code as data URL
|
||||
* @param {string} text - Text to encode
|
||||
* @param {number} size - Size in pixels
|
||||
* @returns {string} Data URL for embedding in img src
|
||||
*/
|
||||
generateDataURL: function(text, size) {
|
||||
var svg = this.generateSVG(text, size);
|
||||
if (!svg) return null;
|
||||
return 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg)));
|
||||
},
|
||||
|
||||
/**
|
||||
* Render QR code to a DOM element
|
||||
* @param {HTMLElement} container - Container element
|
||||
* @param {string} text - Text to encode
|
||||
* @param {number} size - Size in pixels
|
||||
*/
|
||||
render: function(container, text, size) {
|
||||
var svg = this.generateSVG(text, size);
|
||||
if (svg) {
|
||||
container.innerHTML = svg;
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -74,11 +74,15 @@ get_status() {
|
||||
local transfer=$($WG_CMD show $iface transfer 2>/dev/null)
|
||||
local iface_rx=0
|
||||
local iface_tx=0
|
||||
echo "$transfer" | while read peer rx tx; do
|
||||
iface_rx=$((iface_rx + rx))
|
||||
iface_tx=$((iface_tx + tx))
|
||||
done
|
||||
|
||||
while read peer rx tx; do
|
||||
[ -n "$rx" ] && iface_rx=$((iface_rx + ${rx:-0}))
|
||||
[ -n "$tx" ] && iface_tx=$((iface_tx + ${tx:-0}))
|
||||
done << EOF
|
||||
$transfer
|
||||
EOF
|
||||
|
||||
json_add_int "rx_bytes" "$iface_rx"
|
||||
json_add_int "tx_bytes" "$iface_tx"
|
||||
json_close_object
|
||||
done
|
||||
|
||||
@ -405,13 +409,30 @@ add_peer() {
|
||||
return
|
||||
fi
|
||||
|
||||
# Validate public key format (base64, 44 chars ending with =)
|
||||
if ! echo "$public_key" | grep -qE '^[A-Za-z0-9+/]{43}=$'; then
|
||||
json_add_boolean "success" 0
|
||||
json_add_string "error" "Invalid public key format"
|
||||
json_dump
|
||||
return
|
||||
fi
|
||||
|
||||
# Check if peer already exists
|
||||
local existing=$(uci show network 2>/dev/null | grep "public_key='$public_key'" | head -1)
|
||||
if [ -n "$existing" ]; then
|
||||
json_add_boolean "success" 0
|
||||
json_add_string "error" "Peer with this public key already exists"
|
||||
json_dump
|
||||
return
|
||||
fi
|
||||
|
||||
# Default values
|
||||
[ -z "$allowed_ips" ] && allowed_ips="10.0.0.2/32"
|
||||
[ -z "$keepalive" ] && keepalive="25"
|
||||
[ -z "$name" ] && name="peer_$(echo $public_key | cut -c1-8)"
|
||||
|
||||
# Create UCI section for peer
|
||||
local section_name="wgpeer_$(echo $public_key | cut -c1-8 | tr 'A-Z+/=' 'a-z___')"
|
||||
# Create UCI section for peer using hash of full public key for uniqueness
|
||||
local section_name="wgpeer_$(echo "$public_key" | md5sum | cut -c1-12)"
|
||||
|
||||
# Add peer to UCI network config
|
||||
uci -q delete network.$section_name
|
||||
@ -476,31 +497,37 @@ remove_peer() {
|
||||
return
|
||||
fi
|
||||
|
||||
# Find and remove UCI section with this public key
|
||||
local found=0
|
||||
local section=""
|
||||
# Validate public key format (base64, 44 chars ending with =)
|
||||
if ! echo "$public_key" | grep -qE '^[A-Za-z0-9+/]{43}=$'; then
|
||||
json_add_boolean "success" 0
|
||||
json_add_string "error" "Invalid public key format"
|
||||
json_dump
|
||||
return
|
||||
fi
|
||||
|
||||
config_load network
|
||||
config_cb() {
|
||||
local type="$1"
|
||||
local name="$2"
|
||||
if [ "$type" = "wireguard_$iface" ]; then
|
||||
local key=$(uci -q get network.$name.public_key)
|
||||
if [ "$key" = "$public_key" ]; then
|
||||
section="$name"
|
||||
found=1
|
||||
fi
|
||||
# Find UCI section by iterating directly
|
||||
local found_section=""
|
||||
local sections=$(uci show network 2>/dev/null | grep "=wireguard_$iface$" | cut -d'.' -f2 | cut -d'=' -f1)
|
||||
|
||||
for section in $sections; do
|
||||
local key=$(uci -q get network.$section.public_key)
|
||||
if [ "$key" = "$public_key" ]; then
|
||||
found_section="$section"
|
||||
break
|
||||
fi
|
||||
}
|
||||
config_load network
|
||||
done
|
||||
|
||||
if [ "$found" = "1" ] && [ -n "$section" ]; then
|
||||
uci delete network.$section
|
||||
uci commit network
|
||||
ifup "$iface" 2>/dev/null
|
||||
|
||||
json_add_boolean "success" 1
|
||||
json_add_string "message" "Peer removed successfully"
|
||||
if [ -n "$found_section" ]; then
|
||||
uci delete network.$found_section
|
||||
if uci commit network; then
|
||||
ifup "$iface" 2>/dev/null
|
||||
json_add_boolean "success" 1
|
||||
json_add_string "message" "Peer removed successfully"
|
||||
json_add_string "section" "$found_section"
|
||||
else
|
||||
json_add_boolean "success" 0
|
||||
json_add_string "error" "Failed to commit changes"
|
||||
fi
|
||||
else
|
||||
json_add_boolean "success" 0
|
||||
json_add_string "error" "Peer not found in configuration"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user