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
|
include $(TOPDIR)/rules.mk
|
||||||
|
|
||||||
PKG_NAME:=luci-app-wireguard-dashboard
|
PKG_NAME:=luci-app-wireguard-dashboard
|
||||||
PKG_VERSION:=0.4.0
|
PKG_VERSION:=0.5.0
|
||||||
PKG_RELEASE:=2
|
PKG_RELEASE:=1
|
||||||
PKG_ARCH:=all
|
PKG_ARCH:=all
|
||||||
|
|
||||||
PKG_LICENSE:=Apache-2.0
|
PKG_LICENSE:=Apache-2.0
|
||||||
|
|||||||
@ -8,12 +8,179 @@
|
|||||||
|
|
||||||
return view.extend({
|
return view.extend({
|
||||||
title: _('WireGuard Dashboard'),
|
title: _('WireGuard Dashboard'),
|
||||||
|
pollInterval: 5,
|
||||||
|
pollActive: true,
|
||||||
|
selectedInterface: 'all',
|
||||||
|
|
||||||
load: function() {
|
load: function() {
|
||||||
return api.getAllData();
|
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) {
|
render: function(data) {
|
||||||
|
var self = this;
|
||||||
var status = data.status || {};
|
var status = data.status || {};
|
||||||
var interfaces = (data.interfaces || {}).interfaces || [];
|
var interfaces = (data.interfaces || {}).interfaces || [];
|
||||||
var peers = (data.peers || {}).peers || [];
|
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
|
// Quick Stats
|
||||||
E('div', { 'class': 'wg-quick-stats' }, [
|
E('div', { 'class': 'wg-quick-stats' }, [
|
||||||
E('div', { 'class': 'wg-quick-stat' }, [
|
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-icon' }, '🌐'),
|
||||||
E('span', { 'class': 'wg-quick-stat-label' }, 'Interfaces')
|
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-sub' }, 'Active tunnels')
|
||||||
]),
|
]),
|
||||||
E('div', { 'class': 'wg-quick-stat' }, [
|
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-icon' }, '👥'),
|
||||||
E('span', { 'class': 'wg-quick-stat-label' }, 'Total Peers')
|
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-sub' }, 'Configured')
|
||||||
]),
|
]),
|
||||||
E('div', { 'class': 'wg-quick-stat', 'style': '--stat-gradient: linear-gradient(135deg, #10b981, #34d399)' }, [
|
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-icon' }, '✅'),
|
||||||
E('span', { 'class': 'wg-quick-stat-label' }, 'Active Peers')
|
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-sub' }, 'Connected now')
|
||||||
]),
|
]),
|
||||||
E('div', { 'class': 'wg-quick-stat' }, [
|
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-icon' }, '📥'),
|
||||||
E('span', { 'class': 'wg-quick-stat-label' }, 'Downloaded')
|
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-sub' }, 'Total received')
|
||||||
]),
|
]),
|
||||||
E('div', { 'class': 'wg-quick-stat' }, [
|
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-icon' }, '📤'),
|
||||||
E('span', { 'class': 'wg-quick-stat-label' }, 'Uploaded')
|
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')
|
E('div', { 'class': 'wg-quick-stat-sub' }, 'Total sent')
|
||||||
])
|
])
|
||||||
]),
|
]),
|
||||||
@ -95,7 +294,7 @@ return view.extend({
|
|||||||
interfaces.length > 0 ?
|
interfaces.length > 0 ?
|
||||||
E('div', { 'class': 'wg-charts-grid' },
|
E('div', { 'class': 'wg-charts-grid' },
|
||||||
interfaces.map(function(iface) {
|
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-header' }, [
|
||||||
E('div', { 'class': 'wg-interface-name' }, [
|
E('div', { 'class': 'wg-interface-name' }, [
|
||||||
E('div', { 'class': 'wg-interface-icon' }, '🌐'),
|
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' }, [
|
||||||
E('div', { 'class': 'wg-interface-detail-label' }, 'Traffic'),
|
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))
|
'↓' + api.formatBytes(iface.rx_bytes) + ' / ↑' + api.formatBytes(iface.tx_bytes))
|
||||||
])
|
])
|
||||||
])
|
])
|
||||||
@ -143,12 +342,12 @@ return view.extend({
|
|||||||
E('span', { 'class': 'wg-card-title-icon' }, '👥'),
|
E('span', { 'class': 'wg-card-title-icon' }, '👥'),
|
||||||
'Connected Peers'
|
'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-card-body' }, [
|
||||||
E('div', { 'class': 'wg-peer-grid' },
|
E('div', { 'class': 'wg-peer-grid' },
|
||||||
peers.slice(0, 6).map(function(peer) {
|
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-header' }, [
|
||||||
E('div', { 'class': 'wg-peer-info' }, [
|
E('div', { 'class': 'wg-peer-info' }, [
|
||||||
E('div', { 'class': 'wg-peer-icon' }, peer.status === 'active' ? '✅' : '👤'),
|
E('div', { 'class': 'wg-peer-icon' }, peer.status === 'active' ? '✅' : '👤'),
|
||||||
@ -166,7 +365,7 @@ return view.extend({
|
|||||||
]),
|
]),
|
||||||
E('div', { 'class': 'wg-peer-detail' }, [
|
E('div', { 'class': 'wg-peer-detail' }, [
|
||||||
E('span', { 'class': 'wg-peer-detail-label' }, 'Last Handshake'),
|
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('div', { 'class': 'wg-peer-detail', 'style': 'grid-column: span 2' }, [
|
||||||
E('span', { 'class': 'wg-peer-detail-label' }, 'Allowed IPs'),
|
E('span', { 'class': 'wg-peer-detail-label' }, 'Allowed IPs'),
|
||||||
@ -191,11 +390,14 @@ return view.extend({
|
|||||||
])
|
])
|
||||||
]) : ''
|
]) : ''
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Include CSS
|
// Include CSS
|
||||||
var cssLink = E('link', { 'rel': 'stylesheet', 'href': L.resource('wireguard-dashboard/dashboard.css') });
|
var cssLink = E('link', { 'rel': 'stylesheet', 'href': L.resource('wireguard-dashboard/dashboard.css') });
|
||||||
document.head.appendChild(cssLink);
|
document.head.appendChild(cssLink);
|
||||||
|
|
||||||
|
// Start auto-refresh
|
||||||
|
this.startPolling();
|
||||||
|
|
||||||
return view;
|
return view;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -5,10 +5,35 @@
|
|||||||
'require dom';
|
'require dom';
|
||||||
'require ui';
|
'require ui';
|
||||||
'require wireguard-dashboard.api as API';
|
'require wireguard-dashboard.api as API';
|
||||||
|
'require wireguard-dashboard.qrcode as qrcode';
|
||||||
|
|
||||||
return view.extend({
|
return view.extend({
|
||||||
title: _('WireGuard Peers'),
|
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() {
|
load: function() {
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
API.getPeers(),
|
API.getPeers(),
|
||||||
@ -35,7 +60,7 @@ return view.extend({
|
|||||||
'class': 'cbi-button cbi-button-action',
|
'class': 'cbi-button cbi-button-action',
|
||||||
'click': L.bind(this.handleAddPeer, this, interfaces)
|
'click': L.bind(this.handleAddPeer, this, interfaces)
|
||||||
}, '+ ' + _('Add New Peer')),
|
}, '+ ' + _('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))
|
_('Active: %d / %d').format(activePeers, peers.length))
|
||||||
])
|
])
|
||||||
]),
|
]),
|
||||||
@ -128,8 +153,50 @@ return view.extend({
|
|||||||
|
|
||||||
// Setup auto-refresh every 5 seconds
|
// Setup auto-refresh every 5 seconds
|
||||||
poll.add(L.bind(function() {
|
poll.add(L.bind(function() {
|
||||||
return API.getPeers().then(L.bind(function(newPeers) {
|
return API.getPeers().then(L.bind(function(data) {
|
||||||
// Update table dynamically
|
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));
|
||||||
}, this), 5);
|
}, this), 5);
|
||||||
|
|
||||||
@ -286,11 +353,48 @@ return view.extend({
|
|||||||
E('p', { 'class': 'spinning' }, _('Adding peer configuration...'))
|
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) {
|
API.addPeer(iface, name, allowed_ips, pubkey, psk, endpoint, keepalive).then(function(result) {
|
||||||
ui.hideModal();
|
ui.hideModal();
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
// Store private key for QR generation
|
||||||
|
self.storePrivateKey(pubkey, privkey);
|
||||||
ui.addNotification(null, E('p', result.message || _('Peer added successfully')), 'info');
|
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 {
|
} else {
|
||||||
ui.addNotification(null, E('p', result.error || _('Failed to add peer')), 'error');
|
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 self = this;
|
||||||
|
var savedEndpoint = sessionStorage.getItem('wg_server_endpoint') || '';
|
||||||
|
|
||||||
ui.showModal(_('Loading QR Code'), [
|
ui.showModal(_('Server Endpoint'), [
|
||||||
E('p', { 'class': 'spinning' }, _('Generating QR code...'))
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Prompt for server endpoint
|
|
||||||
ui.hideModal();
|
|
||||||
ui.showModal(_('Server Endpoint Required'), [
|
|
||||||
E('p', {}, _('Enter the public IP or hostname of this WireGuard server:')),
|
E('p', {}, _('Enter the public IP or hostname of this WireGuard server:')),
|
||||||
E('div', { 'class': 'cbi-value' }, [
|
E('div', { 'class': 'cbi-value' }, [
|
||||||
E('label', { 'class': 'cbi-value-title' }, _('Server Endpoint')),
|
E('label', { 'class': 'cbi-value-title' }, _('Server Endpoint')),
|
||||||
E('div', { 'class': 'cbi-value-field' }, [
|
E('div', { 'class': 'cbi-value-field' }, [
|
||||||
E('input', {
|
E('input', {
|
||||||
'type': 'text',
|
'type': 'text',
|
||||||
'id': 'server-endpoint',
|
'id': 'qr-server-endpoint',
|
||||||
'class': 'cbi-input-text',
|
'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', {
|
E('button', {
|
||||||
'class': 'btn cbi-button-action',
|
'class': 'btn cbi-button-action',
|
||||||
'click': function() {
|
'click': function() {
|
||||||
var endpoint = document.getElementById('server-endpoint').value;
|
var endpoint = document.getElementById('qr-server-endpoint').value.trim();
|
||||||
if (!endpoint) {
|
if (!endpoint) {
|
||||||
ui.addNotification(null, E('p', _('Please enter server endpoint')), 'error');
|
ui.addNotification(null, E('p', _('Please enter server endpoint')), 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
sessionStorage.setItem('wg_server_endpoint', endpoint);
|
||||||
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');
|
|
||||||
ui.hideModal();
|
ui.hideModal();
|
||||||
|
self.generateAndShowQR(peer, ifaceObj, privateKey, endpoint);
|
||||||
}
|
}
|
||||||
}, _('Generate QR'))
|
}, _('Generate QR'))
|
||||||
])
|
])
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
|
|
||||||
handleDownloadConfig: function(peer, interfaces, ev) {
|
generateAndShowQR: function(peer, ifaceObj, privateKey, serverEndpoint) {
|
||||||
ui.showModal(_('Server Endpoint Required'), [
|
var self = this;
|
||||||
E('p', {}, _('Enter the public IP or hostname of this WireGuard server:')),
|
|
||||||
E('div', { 'class': 'cbi-value' }, [
|
// Build WireGuard client config
|
||||||
E('label', { 'class': 'cbi-value-title' }, _('Server Endpoint')),
|
var config = '[Interface]\n' +
|
||||||
E('div', { 'class': 'cbi-value-field' }, [
|
'PrivateKey = ' + privateKey + '\n' +
|
||||||
E('input', {
|
'Address = ' + (peer.allowed_ips || '10.0.0.2/32') + '\n' +
|
||||||
'type': 'text',
|
'DNS = 1.1.1.1, 1.0.0.1\n\n' +
|
||||||
'id': 'server-endpoint-cfg',
|
'[Peer]\n' +
|
||||||
'class': 'cbi-input-text',
|
'PublicKey = ' + (ifaceObj.public_key || '') + '\n' +
|
||||||
'placeholder': 'vpn.example.com or 203.0.113.1'
|
'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('div', { 'class': 'right' }, [
|
||||||
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('button', {
|
E('button', {
|
||||||
'class': 'btn',
|
'class': 'btn',
|
||||||
'click': ui.hideModal
|
'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) {
|
handleDeletePeer: function(peer, ev) {
|
||||||
var self = this;
|
var self = this;
|
||||||
|
|
||||||
|
|||||||
@ -4,62 +4,194 @@
|
|||||||
'require dom';
|
'require dom';
|
||||||
'require ui';
|
'require ui';
|
||||||
'require wireguard-dashboard.api as api';
|
'require wireguard-dashboard.api as api';
|
||||||
|
'require wireguard-dashboard.qrcode as qrcode';
|
||||||
|
|
||||||
return view.extend({
|
return view.extend({
|
||||||
title: _('QR Code Generator'),
|
title: _('QR Code Generator'),
|
||||||
|
|
||||||
load: function() {
|
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) {
|
getStoredPrivateKey: function(publicKey) {
|
||||||
// Simple QR code SVG generator using a basic encoding
|
try {
|
||||||
// In production, this would use a proper QR library
|
var stored = sessionStorage.getItem('wg_peer_keys');
|
||||||
var qrSize = size || 200;
|
if (stored) {
|
||||||
var moduleCount = 25; // Simplified
|
var keys = JSON.parse(stored);
|
||||||
var moduleSize = qrSize / moduleCount;
|
return keys[publicKey] || null;
|
||||||
|
|
||||||
// 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"/>';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
} catch (e) {}
|
||||||
|
return null;
|
||||||
svg += '</svg>';
|
|
||||||
return svg;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
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) {
|
render: function(data) {
|
||||||
var self = this;
|
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' }, [
|
var view = E('div', { 'class': 'wireguard-dashboard' }, [
|
||||||
E('link', { 'rel': 'stylesheet', 'href': L.resource('secubox-theme/secubox-theme.css') }),
|
E('link', { 'rel': 'stylesheet', 'href': L.resource('secubox-theme/secubox-theme.css') }),
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
E('div', { 'class': 'wg-header' }, [
|
E('div', { 'class': 'wg-header' }, [
|
||||||
E('div', { 'class': 'wg-logo' }, [
|
E('div', { 'class': 'wg-logo' }, [
|
||||||
@ -67,172 +199,233 @@ return view.extend({
|
|||||||
E('div', { 'class': 'wg-logo-text' }, ['QR ', E('span', {}, 'Generator')])
|
E('div', { 'class': 'wg-logo-text' }, ['QR ', E('span', {}, 'Generator')])
|
||||||
])
|
])
|
||||||
]),
|
]),
|
||||||
|
|
||||||
// Info banner
|
// Server endpoint input
|
||||||
E('div', { 'class': 'wg-info-banner' }, [
|
E('div', { 'class': 'wg-card' }, [
|
||||||
E('span', { 'class': 'wg-info-icon' }, 'ℹ️'),
|
E('div', { 'class': 'wg-card-header' }, [
|
||||||
E('div', {}, [
|
E('div', { 'class': 'wg-card-title' }, [
|
||||||
E('strong', {}, 'Mobile Configuration'),
|
E('span', { 'class': 'wg-card-title-icon' }, '🌐'),
|
||||||
E('p', {}, 'Generate QR codes to quickly configure WireGuard on mobile devices. ' +
|
_('Server Endpoint')
|
||||||
'The client config is generated as a template - you\'ll need to fill in the private key.')
|
])
|
||||||
|
]),
|
||||||
|
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.length > 0 ?
|
||||||
interfaces.map(function(iface) {
|
E('div', { 'class': 'wg-interface-list' },
|
||||||
return E('div', { 'class': 'wg-card' }, [
|
interfaces.map(function(iface) {
|
||||||
E('div', { 'class': 'wg-card-header' }, [
|
var ifacePeers = peers.filter(function(p) { return p.interface === iface.name; });
|
||||||
E('div', { 'class': 'wg-card-title' }, [
|
|
||||||
E('span', { 'class': 'wg-card-title-icon' }, '🌐'),
|
return E('div', { 'class': 'wg-card' }, [
|
||||||
'Interface: ' + iface.name
|
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-interface-info' }, [
|
||||||
E('div', { 'class': 'wg-card-body' }, [
|
E('div', { 'class': 'wg-info-item' }, [
|
||||||
E('div', { 'class': 'wg-qr-grid' },
|
E('span', { 'class': 'wg-info-label' }, _('Public Key:')),
|
||||||
(iface.peers || []).map(function(peer, idx) {
|
E('code', {}, (iface.public_key || 'N/A').substring(0, 20) + '...')
|
||||||
// Generate client config template
|
]),
|
||||||
var clientConfig = '[Interface]\n' +
|
E('div', { 'class': 'wg-info-item' }, [
|
||||||
'PrivateKey = <YOUR_PRIVATE_KEY>\n' +
|
E('span', { 'class': 'wg-info-label' }, _('Listen Port:')),
|
||||||
'Address = ' + (peer.allowed_ips || '10.0.0.' + (idx + 2) + '/32') + '\n' +
|
E('span', {}, iface.listen_port || 51820)
|
||||||
'DNS = 1.1.1.1\n\n' +
|
])
|
||||||
'[Peer]\n' +
|
]),
|
||||||
'PublicKey = ' + iface.public_key + '\n' +
|
|
||||||
'Endpoint = <YOUR_SERVER_IP>:' + (iface.listen_port || 51820) + '\n' +
|
ifacePeers.length > 0 ?
|
||||||
'AllowedIPs = 0.0.0.0/0, ::/0\n' +
|
E('div', { 'class': 'wg-peer-list' },
|
||||||
'PersistentKeepalive = 25';
|
ifacePeers.map(function(peer) {
|
||||||
|
return E('div', { 'class': 'wg-peer-item' }, [
|
||||||
return E('div', { 'class': 'wg-qr-card' }, [
|
E('div', { 'class': 'wg-peer-info' }, [
|
||||||
E('div', { 'class': 'wg-qr-header' }, [
|
E('span', { 'class': 'wg-peer-icon' }, '👤'),
|
||||||
E('span', { 'class': 'wg-qr-icon' }, '👤'),
|
E('div', {}, [
|
||||||
E('div', {}, [
|
E('strong', {}, peer.short_key || peer.public_key.substring(0, 8)),
|
||||||
E('h4', {}, 'Peer ' + (idx + 1)),
|
E('div', { 'class': 'wg-peer-ips' }, peer.allowed_ips || 'No IPs')
|
||||||
E('code', {}, peer.public_key.substring(0, 16) + '...')
|
])
|
||||||
])
|
]),
|
||||||
]),
|
E('button', {
|
||||||
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', {
|
|
||||||
'class': 'wg-btn wg-btn-primary',
|
'class': 'wg-btn wg-btn-primary',
|
||||||
'click': function() {
|
'click': function() {
|
||||||
// Download config as file
|
var endpoint = sessionStorage.getItem('wg_server_endpoint');
|
||||||
var blob = new Blob([clientConfig], { type: 'text/plain' });
|
if (!endpoint) {
|
||||||
var url = URL.createObjectURL(blob);
|
var input = document.getElementById('wg-server-endpoint');
|
||||||
var a = document.createElement('a');
|
endpoint = input ? input.value.trim() : '';
|
||||||
a.href = url;
|
}
|
||||||
a.download = iface.name + '-peer' + (idx + 1) + '.conf';
|
if (!endpoint) {
|
||||||
a.click();
|
ui.addNotification(null, E('p', {}, _('Please enter the server endpoint first')), 'warning');
|
||||||
URL.revokeObjectURL(url);
|
return;
|
||||||
|
}
|
||||||
|
self.generateQRForPeer(iface, peer, endpoint);
|
||||||
}
|
}
|
||||||
}, '💾 Download .conf')
|
}, '📱 ' + _('QR Code'))
|
||||||
]),
|
]);
|
||||||
E('div', { 'class': 'wg-config-preview' }, [
|
})
|
||||||
E('div', { 'class': 'wg-config-toggle', 'click': function(ev) {
|
) :
|
||||||
var pre = ev.target.parentNode.querySelector('pre');
|
E('div', { 'class': 'wg-empty-peers' }, _('No peers configured for this interface'))
|
||||||
pre.style.display = pre.style.display === 'none' ? 'block' : 'none';
|
])
|
||||||
}}, '▶ Show configuration'),
|
]);
|
||||||
E('pre', { 'style': 'display: none' }, clientConfig)
|
})
|
||||||
])
|
) :
|
||||||
]);
|
|
||||||
})
|
|
||||||
)
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
}) :
|
|
||||||
E('div', { 'class': 'wg-empty' }, [
|
E('div', { 'class': 'wg-empty' }, [
|
||||||
E('div', { 'class': 'wg-empty-icon' }, '📱'),
|
E('div', { 'class': 'wg-empty-icon' }, '📱'),
|
||||||
E('div', { 'class': 'wg-empty-text' }, 'No WireGuard interfaces configured'),
|
E('div', { 'class': 'wg-empty-text' }, _('No WireGuard interfaces configured')),
|
||||||
E('p', {}, 'Create an interface to generate QR codes for mobile clients')
|
E('p', {}, _('Create a WireGuard interface to generate QR codes'))
|
||||||
])
|
])
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Additional CSS
|
// Restore saved endpoint
|
||||||
var css = `
|
setTimeout(function() {
|
||||||
.wg-info-banner {
|
var saved = sessionStorage.getItem('wg_server_endpoint');
|
||||||
display: flex;
|
if (saved) {
|
||||||
gap: 12px;
|
var input = document.getElementById('wg-server-endpoint');
|
||||||
padding: 16px;
|
if (input) input.value = saved;
|
||||||
background: rgba(6, 182, 212, 0.1);
|
|
||||||
border: 1px solid rgba(6, 182, 212, 0.3);
|
|
||||||
border-radius: 10px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
}
|
||||||
.wg-info-banner .wg-info-icon { font-size: 24px; }
|
}, 100);
|
||||||
.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; }
|
// Add CSS
|
||||||
.wg-qr-card {
|
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);
|
background: var(--wg-bg-tertiary);
|
||||||
border: 1px solid var(--wg-border);
|
border: 1px solid var(--wg-border);
|
||||||
border-radius: 12px;
|
border-radius: 8px;
|
||||||
padding: 20px;
|
|
||||||
}
|
}
|
||||||
.wg-qr-header {
|
.wg-peer-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
}
|
||||||
.wg-qr-icon { font-size: 28px; }
|
.wg-peer-icon {
|
||||||
.wg-qr-header h4 { margin: 0; font-size: 16px; }
|
font-size: 24px;
|
||||||
.wg-qr-header code { font-size: 10px; color: var(--wg-text-muted); }
|
}
|
||||||
.wg-qr-code {
|
.wg-peer-ips {
|
||||||
background: white;
|
font-size: 12px;
|
||||||
border-radius: 12px;
|
color: var(--wg-text-muted);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.wg-empty-peers {
|
||||||
|
text-align: center;
|
||||||
padding: 20px;
|
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;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-bottom: 16px;
|
gap: 10px;
|
||||||
|
margin: 16px 0;
|
||||||
}
|
}
|
||||||
.wg-qr-placeholder {
|
.wg-config-details {
|
||||||
text-align: center;
|
text-align: left;
|
||||||
color: #333;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
.wg-qr-placeholder span { font-size: 48px; display: block; margin-bottom: 8px; }
|
.wg-config-details summary {
|
||||||
.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 {
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 12px;
|
|
||||||
color: var(--wg-accent-cyan);
|
color: var(--wg-accent-cyan);
|
||||||
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
.wg-config-preview pre {
|
.wg-config-details pre {
|
||||||
margin-top: 10px;
|
background: var(--wg-bg-tertiary);
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
background: var(--wg-bg-primary);
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
line-height: 1.6;
|
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
white-space: pre-wrap;
|
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);
|
var style = E('style', {}, css);
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
|
|
||||||
var cssLink = E('link', { 'rel': 'stylesheet', 'href': L.resource('wireguard-dashboard/dashboard.css') });
|
var cssLink = E('link', { 'rel': 'stylesheet', 'href': L.resource('wireguard-dashboard/dashboard.css') });
|
||||||
document.head.appendChild(cssLink);
|
document.head.appendChild(cssLink);
|
||||||
|
|
||||||
return view;
|
return view;
|
||||||
},
|
},
|
||||||
|
|
||||||
handleSaveApply: null,
|
handleSaveApply: null,
|
||||||
handleSave: null,
|
handleSave: null,
|
||||||
handleReset: null
|
handleReset: null
|
||||||
|
|||||||
@ -8,12 +8,84 @@
|
|||||||
|
|
||||||
return view.extend({
|
return view.extend({
|
||||||
title: _('WireGuard Traffic'),
|
title: _('WireGuard Traffic'),
|
||||||
|
pollInterval: 5,
|
||||||
|
pollActive: true,
|
||||||
|
|
||||||
load: function() {
|
load: function() {
|
||||||
return api.getTraffic();
|
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) {
|
render: function(data) {
|
||||||
|
var self = this;
|
||||||
var traffic = data || {};
|
var traffic = data || {};
|
||||||
var interfaces = traffic.interfaces || [];
|
var interfaces = traffic.interfaces || [];
|
||||||
var totalRx = traffic.total_rx || 0;
|
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-icon' }, '📥'),
|
||||||
E('span', { 'class': 'wg-quick-stat-label' }, 'Total Downloaded')
|
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-sub' }, 'All interfaces combined')
|
||||||
]),
|
]),
|
||||||
E('div', { 'class': 'wg-quick-stat', 'style': '--stat-gradient: linear-gradient(135deg, #0ea5e9, #38bdf8)' }, [
|
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-icon' }, '📤'),
|
||||||
E('span', { 'class': 'wg-quick-stat-label' }, 'Total Uploaded')
|
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-sub' }, 'All interfaces combined')
|
||||||
]),
|
]),
|
||||||
E('div', { 'class': 'wg-quick-stat' }, [
|
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-icon' }, '📈'),
|
||||||
E('span', { 'class': 'wg-quick-stat-label' }, 'Total Traffic')
|
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')
|
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 ifaceTotal = (iface.total_rx || 0) + (iface.total_tx || 0);
|
||||||
var rxPct = totalTraffic > 0 ? ((iface.total_rx || 0) / totalTraffic * 100) : 0;
|
var rxPct = totalTraffic > 0 ? ((iface.total_rx || 0) / totalTraffic * 100) : 0;
|
||||||
var txPct = totalTraffic > 0 ? ((iface.total_tx || 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-header' }, [
|
||||||
E('div', { 'class': 'wg-interface-name' }, [
|
E('div', { 'class': 'wg-interface-name' }, [
|
||||||
E('div', { 'class': 'wg-interface-icon' }, '🌐'),
|
E('div', { 'class': 'wg-interface-icon' }, '🌐'),
|
||||||
E('div', {}, [
|
E('div', {}, [
|
||||||
E('h3', {}, iface.name),
|
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' }, [
|
||||||
E('div', { 'class': 'wg-traffic-bar-header' }, [
|
E('div', { 'class': 'wg-traffic-bar-header' }, [
|
||||||
E('span', { 'style': 'color: #10b981' }, '↓ ' + api.formatBytes(iface.total_rx || 0)),
|
E('span', { 'class': 'wg-iface-rx', '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-tx', 'style': 'color: #0ea5e9' }, '↑ ' + api.formatBytes(iface.total_tx || 0))
|
||||||
]),
|
]),
|
||||||
E('div', { 'class': 'wg-traffic-bar-track' }, [
|
E('div', { 'class': 'wg-traffic-bar-track' }, [
|
||||||
E('div', { 'class': 'wg-traffic-bar-rx', 'style': 'width:' + rxPct + '%' }),
|
E('div', { 'class': 'wg-traffic-bar-rx', 'style': 'width:' + rxPct + '%' }),
|
||||||
@ -129,10 +201,13 @@ return view.extend({
|
|||||||
// Include CSS
|
// Include CSS
|
||||||
var cssLink = E('link', { 'rel': 'stylesheet', 'href': L.resource('wireguard-dashboard/dashboard.css') });
|
var cssLink = E('link', { 'rel': 'stylesheet', 'href': L.resource('wireguard-dashboard/dashboard.css') });
|
||||||
document.head.appendChild(cssLink);
|
document.head.appendChild(cssLink);
|
||||||
|
|
||||||
|
// Start auto-refresh
|
||||||
|
this.startPolling();
|
||||||
|
|
||||||
return view;
|
return view;
|
||||||
},
|
},
|
||||||
|
|
||||||
handleSaveApply: null,
|
handleSaveApply: null,
|
||||||
handleSave: null,
|
handleSave: null,
|
||||||
handleReset: null
|
handleReset: null
|
||||||
|
|||||||
@ -92,6 +92,26 @@ function formatLastHandshake(timestamp) {
|
|||||||
return Math.floor(diff / 86400) + 'd ago';
|
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({
|
return baseclass.extend({
|
||||||
getStatus: callStatus,
|
getStatus: callStatus,
|
||||||
getPeers: callGetPeers,
|
getPeers: callGetPeers,
|
||||||
@ -105,6 +125,9 @@ return baseclass.extend({
|
|||||||
generateQR: callGenerateQR,
|
generateQR: callGenerateQR,
|
||||||
formatBytes: formatBytes,
|
formatBytes: formatBytes,
|
||||||
formatLastHandshake: formatLastHandshake,
|
formatLastHandshake: formatLastHandshake,
|
||||||
|
getPeerStatusClass: getPeerStatusClass,
|
||||||
|
shortenKey: shortenKey,
|
||||||
|
formatHandshake: formatHandshake,
|
||||||
|
|
||||||
// Aggregate function for overview page
|
// Aggregate function for overview page
|
||||||
getAllData: function() {
|
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 {
|
.wg-btn-primary:hover {
|
||||||
box-shadow: var(--wg-shadow-glow);
|
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 transfer=$($WG_CMD show $iface transfer 2>/dev/null)
|
||||||
local iface_rx=0
|
local iface_rx=0
|
||||||
local iface_tx=0
|
local iface_tx=0
|
||||||
echo "$transfer" | while read peer rx tx; do
|
while read peer rx tx; do
|
||||||
iface_rx=$((iface_rx + rx))
|
[ -n "$rx" ] && iface_rx=$((iface_rx + ${rx:-0}))
|
||||||
iface_tx=$((iface_tx + tx))
|
[ -n "$tx" ] && iface_tx=$((iface_tx + ${tx:-0}))
|
||||||
done
|
done << EOF
|
||||||
|
$transfer
|
||||||
|
EOF
|
||||||
|
|
||||||
|
json_add_int "rx_bytes" "$iface_rx"
|
||||||
|
json_add_int "tx_bytes" "$iface_tx"
|
||||||
json_close_object
|
json_close_object
|
||||||
done
|
done
|
||||||
|
|
||||||
@ -405,13 +409,30 @@ add_peer() {
|
|||||||
return
|
return
|
||||||
fi
|
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
|
# Default values
|
||||||
[ -z "$allowed_ips" ] && allowed_ips="10.0.0.2/32"
|
[ -z "$allowed_ips" ] && allowed_ips="10.0.0.2/32"
|
||||||
[ -z "$keepalive" ] && keepalive="25"
|
[ -z "$keepalive" ] && keepalive="25"
|
||||||
[ -z "$name" ] && name="peer_$(echo $public_key | cut -c1-8)"
|
[ -z "$name" ] && name="peer_$(echo $public_key | cut -c1-8)"
|
||||||
|
|
||||||
# Create UCI section for peer
|
# Create UCI section for peer using hash of full public key for uniqueness
|
||||||
local section_name="wgpeer_$(echo $public_key | cut -c1-8 | tr 'A-Z+/=' 'a-z___')"
|
local section_name="wgpeer_$(echo "$public_key" | md5sum | cut -c1-12)"
|
||||||
|
|
||||||
# Add peer to UCI network config
|
# Add peer to UCI network config
|
||||||
uci -q delete network.$section_name
|
uci -q delete network.$section_name
|
||||||
@ -476,31 +497,37 @@ remove_peer() {
|
|||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Find and remove UCI section with this public key
|
# Validate public key format (base64, 44 chars ending with =)
|
||||||
local found=0
|
if ! echo "$public_key" | grep -qE '^[A-Za-z0-9+/]{43}=$'; then
|
||||||
local section=""
|
json_add_boolean "success" 0
|
||||||
|
json_add_string "error" "Invalid public key format"
|
||||||
|
json_dump
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
config_load network
|
# Find UCI section by iterating directly
|
||||||
config_cb() {
|
local found_section=""
|
||||||
local type="$1"
|
local sections=$(uci show network 2>/dev/null | grep "=wireguard_$iface$" | cut -d'.' -f2 | cut -d'=' -f1)
|
||||||
local name="$2"
|
|
||||||
if [ "$type" = "wireguard_$iface" ]; then
|
for section in $sections; do
|
||||||
local key=$(uci -q get network.$name.public_key)
|
local key=$(uci -q get network.$section.public_key)
|
||||||
if [ "$key" = "$public_key" ]; then
|
if [ "$key" = "$public_key" ]; then
|
||||||
section="$name"
|
found_section="$section"
|
||||||
found=1
|
break
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
}
|
done
|
||||||
config_load network
|
|
||||||
|
|
||||||
if [ "$found" = "1" ] && [ -n "$section" ]; then
|
if [ -n "$found_section" ]; then
|
||||||
uci delete network.$section
|
uci delete network.$found_section
|
||||||
uci commit network
|
if uci commit network; then
|
||||||
ifup "$iface" 2>/dev/null
|
ifup "$iface" 2>/dev/null
|
||||||
|
json_add_boolean "success" 1
|
||||||
json_add_boolean "success" 1
|
json_add_string "message" "Peer removed successfully"
|
||||||
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
|
else
|
||||||
json_add_boolean "success" 0
|
json_add_boolean "success" 0
|
||||||
json_add_string "error" "Peer not found in configuration"
|
json_add_string "error" "Peer not found in configuration"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user