secubox-openwrt/package/secubox/luci-app-wireguard-dashboard/htdocs/luci-static/resources/wireguard-dashboard/chart.js
CyberMind-FR 9ef0b6db18 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>
2026-01-09 09:32:31 +01:00

284 lines
8.1 KiB
JavaScript

'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 = {};
}
}
}
};