This commit is contained in:
Joe Roback 2026-04-05 12:16:31 +02:00 committed by GitHub
commit 9fd1dbdd34
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 371 additions and 1 deletions

View file

@ -1,6 +1,6 @@
PLUGIN_NAME= vnstat
PLUGIN_VERSION= 1.3
PLUGIN_REVISION= 1
PLUGIN_REVISION= 2
PLUGIN_COMMENT= Network traffic monitor
PLUGIN_DEPENDS= vnstat
PLUGIN_MAINTAINER= m.muenz@gmail.com

View file

@ -89,6 +89,42 @@ class ServiceController extends ApiMutableServiceControllerBase
return array("response" => $response);
}
/**
* list interfaces tracked by vnstat
* @return array
*/
public function dbiflistAction()
{
$backend = new Backend();
$response = trim($backend->configdRun("vnstat dbiflist"));
if (empty($response)) {
return array("interfaces" => array());
}
$interfaces = array_map('trim', explode("\n", $response));
$interfaces = array_values(array_filter($interfaces, function ($v) {
return !empty($v);
}));
return array("interfaces" => $interfaces);
}
/**
* retrieve vnstat data as structured JSON for a specific interface
* @return array
*/
public function jsonAction()
{
$iface = $this->request->get('iface');
if (empty($iface) || !preg_match('/^[a-zA-Z0-9_.]+$/', $iface)) {
return array("error" => "Invalid or missing interface name");
}
$backend = new Backend();
$response = json_decode($backend->configdRun("vnstat json " . escapeshellarg($iface)), true);
if ($response === null) {
return array("error" => "Failed to retrieve vnstat data");
}
return $response;
}
/**
* remove database folder
* @return array

View file

@ -46,6 +46,18 @@ parameters:
type:script_output
message:request Vnstat yearly status
[dbiflist]
command:/usr/local/bin/vnstat --dbiflist 1
parameters:
type:script_output
message:request Vnstat interface list
[json]
command:/usr/local/bin/vnstat --json --iface %s
parameters:%s
type:script_output
message:request Vnstat json status
[resetdb]
command:rm -rf /var/lib/vnstat
parameters:

View file

@ -0,0 +1,23 @@
<metadata>
<vnstat>
<filename>Vnstat.js</filename>
<endpoints>
<endpoint>/api/vnstat/service/dbiflist</endpoint>
<endpoint>/api/vnstat/service/json</endpoint>
</endpoints>
<translations>
<title>Vnstat Traffic</title>
<h_date>Date</h_date>
<h_rx>RX</h_rx>
<h_tx>TX</h_tx>
<h_total>Total</h_total>
<h_avg_rate>Avg Rate</h_avg_rate>
<period_hourly>Hourly</period_hourly>
<period_daily>Daily</period_daily>
<period_monthly>Monthly</period_monthly>
<period_yearly>Yearly</period_yearly>
<msg_no_data>No vnstat data available</msg_no_data>
<refresh_interval>Refresh Interval (minutes)</refresh_interval>
</translations>
</vnstat>
</metadata>

View file

@ -0,0 +1,299 @@
/*
* Copyright (C) 2024 Joe Roback <joe.roback@gmail.com>
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
* AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
* AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
* OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*/
export default class Vnstat extends BaseTableWidget {
constructor(config) {
super(config);
this.currentPeriod = 'month';
this.currentInterface = null;
this.configurable = true;
}
getGridOptions() {
return {
sizeToContent: 650
};
}
async getWidgetOptions() {
return {
refresh_interval: {
title: this.translations.refresh_interval,
type: 'select',
options: [
{ value: '60', label: '1' },
{ value: '120', label: '2' },
{ value: '300', label: '5' },
{ value: '600', label: '10' },
{ value: '900', label: '15' },
{ value: '1800', label: '30' },
],
default: '300'
}
};
}
async onWidgetOptionsChanged() {
const config = await this.getWidgetConfig();
this.tickTimeout = Number(config.refresh_interval) || 300;
}
getMarkup() {
let $container = $('<div id="vnstat-container"></div>');
let $selectors = $(`
<div class="vnstat-selectors" style="padding: 5px 10px; display: flex; gap: 5px;">
<select id="vnstat-interface-select" class="form-control">
</select>
<select id="vnstat-period-select" class="form-control">
<option value="hour">${this.translations.period_hourly}</option>
<option value="day">${this.translations.period_daily}</option>
<option value="month" selected>${this.translations.period_monthly}</option>
<option value="year">${this.translations.period_yearly}</option>
</select>
</div>
`);
let $table = this.createTable('vnstat-table', {
headerPosition: 'none'
});
$container.append($selectors);
$container.append($table);
return $container;
}
async onMarkupRendered() {
$(document).on('change.vnstat-widget', '#vnstat-period-select', (e) => {
this.currentPeriod = e.target.value;
this._savePrefs();
this._fetchAndUpdateTable();
});
$(document).on('change.vnstat-widget', '#vnstat-interface-select', (e) => {
this.currentInterface = e.target.value;
this._savePrefs();
this._fetchAndUpdateTable();
});
const prefs = this._loadPrefs();
if (prefs) {
if (prefs.period) {
this.currentPeriod = prefs.period;
$('#vnstat-period-select').val(prefs.period);
}
if (prefs.interface) {
this.currentInterface = prefs.interface;
}
}
}
async onWidgetTick() {
await this._populateInterfaceDropdown();
await this._fetchAndUpdateTable();
}
async _populateInterfaceDropdown() {
const data = await this.ajaxCall('/api/vnstat/service/dbiflist');
if (!data || !data.interfaces) return;
const names = data.interfaces;
if (this._lastIfaceList && JSON.stringify(this._lastIfaceList) === JSON.stringify(names)) {
return;
}
this._lastIfaceList = names;
const $select = $('#vnstat-interface-select');
$select.empty();
for (const name of names) {
$select.append($('<option></option>').val(name).text(name));
}
if (this.currentInterface && names.includes(this.currentInterface)) {
$select.val(this.currentInterface);
} else if (names.includes('WAN')) {
$select.val('WAN');
this.currentInterface = 'WAN';
} else if (names.length > 0) {
$select.val(names[0]);
this.currentInterface = names[0];
}
}
async _fetchAndUpdateTable() {
if (!this.currentInterface) {
super.updateTable('vnstat-table', [[`<b>${this.translations.msg_no_data}</b>`]]);
return;
}
const data = await this.ajaxCall(`/api/vnstat/service/json?iface=${encodeURIComponent(this.currentInterface)}`);
if (!data || data.error || !data.interfaces || data.interfaces.length === 0) {
super.updateTable('vnstat-table', [[`<b>${this.translations.msg_no_data}</b>`]]);
return;
}
const iface = data.interfaces[0];
const traffic = iface.traffic?.[this.currentPeriod];
if (!traffic || traffic.length === 0) {
super.updateTable('vnstat-table', [[`<b>${this.translations.msg_no_data}</b>`]]);
return;
}
let rows = [];
rows.push([
`<b>${this.translations.h_date}</b>`,
`<b>${this.translations.h_rx}</b>`,
`<b>${this.translations.h_tx}</b>`,
`<b>${this.translations.h_total}</b>`,
`<b>${this.translations.h_avg_rate}</b>`
]);
const limits = { hour: 24, day: 31, month: 12 };
let entries = [...traffic];
entries.sort((a, b) => {
return this._dateToSortKey(b, this.currentPeriod) -
this._dateToSortKey(a, this.currentPeriod);
});
if (limits[this.currentPeriod]) {
entries = entries.slice(0, limits[this.currentPeriod]);
}
entries.reverse();
for (const entry of entries) {
const seconds = this._periodSeconds(entry, this.currentPeriod);
const totalBytes = entry.rx + entry.tx;
const bitsPerSec = seconds > 0 ? (totalBytes * 8) / seconds : 0;
rows.push([
this._formatDate(entry, this.currentPeriod),
this._formatBytes(entry.rx),
this._formatBytes(entry.tx),
this._formatBytes(totalBytes),
this._formatRate(bitsPerSec)
]);
}
super.updateTable('vnstat-table', rows);
}
onWidgetClose() {
$(document).off('change.vnstat-widget');
}
_savePrefs() {
try {
localStorage.setItem('vnstat-widget-prefs', JSON.stringify({
interface: this.currentInterface,
period: this.currentPeriod
}));
} catch (e) {
// localStorage may be unavailable
}
}
_loadPrefs() {
try {
const raw = localStorage.getItem('vnstat-widget-prefs');
return raw ? JSON.parse(raw) : null;
} catch (e) {
return null;
}
}
_periodSeconds(entry, period) {
const d = entry.date;
const now = new Date();
if (period === 'hour') {
if (d.year === now.getFullYear() && d.month === now.getMonth() + 1 &&
d.day === now.getDate() && entry.time.hour === now.getHours()) {
return now.getMinutes() * 60 + now.getSeconds() || 1;
}
return 3600;
} else if (period === 'day') {
if (d.year === now.getFullYear() && d.month === now.getMonth() + 1 &&
d.day === now.getDate()) {
return now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds() || 1;
}
return 86400;
} else if (period === 'month') {
if (d.year === now.getFullYear() && d.month === now.getMonth() + 1) {
const startOfMonth = new Date(d.year, d.month - 1, 1);
return Math.floor((now - startOfMonth) / 1000) || 1;
}
const daysInMonth = new Date(d.year, d.month, 0).getDate();
return daysInMonth * 86400;
} else {
if (d.year === now.getFullYear()) {
const startOfYear = new Date(d.year, 0, 1);
return Math.floor((now - startOfYear) / 1000) || 1;
}
const isLeap = (d.year % 4 === 0 && d.year % 100 !== 0) || (d.year % 400 === 0);
return (isLeap ? 366 : 365) * 86400;
}
}
_formatRate(bitsPerSec) {
if (bitsPerSec === 0) return '0 bit/s';
const units = ['bit/s', 'Kbit/s', 'Mbit/s', 'Gbit/s', 'Tbit/s'];
const k = 1000;
const i = Math.floor(Math.log(bitsPerSec) / Math.log(k));
const idx = Math.min(i, units.length - 1);
return (bitsPerSec / Math.pow(k, idx)).toFixed(2) + ' ' + units[idx];
}
_formatBytes(bytes) {
if (bytes === 0) return '0 B';
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
const k = 1024;
const i = Math.floor(Math.log(bytes) / Math.log(k));
const idx = Math.min(i, units.length - 1);
return (bytes / Math.pow(k, idx)).toFixed(2) + ' ' + units[idx];
}
_formatDate(entry, period) {
const d = entry.date;
if (period === 'year') {
return `${d.year}`;
} else if (period === 'month') {
return `${d.year}-${String(d.month).padStart(2, '0')}`;
} else if (period === 'hour') {
return `${d.year}-${String(d.month).padStart(2, '0')}-${String(d.day).padStart(2, '0')} ${String(entry.time.hour).padStart(2, '0')}:00`;
} else {
return `${d.year}-${String(d.month).padStart(2, '0')}-${String(d.day).padStart(2, '0')}`;
}
}
_dateToSortKey(entry, period) {
const d = entry.date;
if (period === 'year') {
return d.year;
} else if (period === 'month') {
return d.year * 100 + d.month;
} else if (period === 'hour') {
return d.year * 1000000 + d.month * 10000 + d.day * 100 + entry.time.hour;
} else {
return d.year * 10000 + d.month * 100 + d.day;
}
}
}