diff --git a/package/secubox/luci-app-wireguard-dashboard/Makefile b/package/secubox/luci-app-wireguard-dashboard/Makefile
index c2ba95e4..1d4dcb1f 100644
--- a/package/secubox/luci-app-wireguard-dashboard/Makefile
+++ b/package/secubox/luci-app-wireguard-dashboard/Makefile
@@ -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
diff --git a/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/view/wireguard-dashboard/overview.js b/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/view/wireguard-dashboard/overview.js
index 3fa5972f..acd93467 100644
--- a/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/view/wireguard-dashboard/overview.js
+++ b/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/view/wireguard-dashboard/overview.js
@@ -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 = '' + (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;
},
diff --git a/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/view/wireguard-dashboard/peers.js b/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/view/wireguard-dashboard/peers.js
index 92798de3..098959d8 100644
--- a/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/view/wireguard-dashboard/peers.js
+++ b/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/view/wireguard-dashboard/peers.js
@@ -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 = '
↓ ' + API.formatBytes(peer.rx_bytes) + '
' +
+ '↑ ' + API.formatBytes(peer.tx_bytes) + '
';
+ }
+ });
+
+ // 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;
diff --git a/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/view/wireguard-dashboard/qrcodes.js b/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/view/wireguard-dashboard/qrcodes.js
index 73ffe580..1126cdbd 100644
--- a/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/view/wireguard-dashboard/qrcodes.js
+++ b/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/view/wireguard-dashboard/qrcodes.js
@@ -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 = '';
- 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 = \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 = :' + (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
diff --git a/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/view/wireguard-dashboard/traffic.js b/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/view/wireguard-dashboard/traffic.js
index 92905243..1457c309 100644
--- a/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/view/wireguard-dashboard/traffic.js
+++ b/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/view/wireguard-dashboard/traffic.js
@@ -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
diff --git a/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/wireguard-dashboard/api.js b/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/wireguard-dashboard/api.js
index 4b9a8bd2..4a7f6fd7 100644
--- a/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/wireguard-dashboard/api.js
+++ b/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/wireguard-dashboard/api.js
@@ -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() {
diff --git a/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/wireguard-dashboard/chart.js b/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/wireguard-dashboard/chart.js
new file mode 100644
index 00000000..6ad5432f
--- /dev/null
+++ b/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/wireguard-dashboard/chart.js
@@ -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 = '';
+ 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 = '';
+ 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 '';
+ },
+
+ /**
+ * 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 = {};
+ }
+ }
+ }
+};
diff --git a/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/wireguard-dashboard/dashboard.css b/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/wireguard-dashboard/dashboard.css
index ad379755..5aab2df6 100644
--- a/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/wireguard-dashboard/dashboard.css
+++ b/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/wireguard-dashboard/dashboard.css
@@ -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);
+}
diff --git a/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/wireguard-dashboard/qrcode.js b/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/wireguard-dashboard/qrcode.js
new file mode 100644
index 00000000..b8bfbf3f
--- /dev/null
+++ b/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/wireguard-dashboard/qrcode.js
@@ -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 = '';
+ 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;
+ }
+ }
+});
diff --git a/package/secubox/luci-app-wireguard-dashboard/root/usr/libexec/rpcd/luci.wireguard-dashboard b/package/secubox/luci-app-wireguard-dashboard/root/usr/libexec/rpcd/luci.wireguard-dashboard
index 2e89a1e7..baec31ee 100755
--- a/package/secubox/luci-app-wireguard-dashboard/root/usr/libexec/rpcd/luci.wireguard-dashboard
+++ b/package/secubox/luci-app-wireguard-dashboard/root/usr/libexec/rpcd/luci.wireguard-dashboard
@@ -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"