diff --git a/net/vnstat/Makefile b/net/vnstat/Makefile index 8bbf7518f..be82eae67 100644 --- a/net/vnstat/Makefile +++ b/net/vnstat/Makefile @@ -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 diff --git a/net/vnstat/src/opnsense/mvc/app/controllers/OPNsense/Vnstat/Api/ServiceController.php b/net/vnstat/src/opnsense/mvc/app/controllers/OPNsense/Vnstat/Api/ServiceController.php index a8fbebb7c..cb60cc496 100644 --- a/net/vnstat/src/opnsense/mvc/app/controllers/OPNsense/Vnstat/Api/ServiceController.php +++ b/net/vnstat/src/opnsense/mvc/app/controllers/OPNsense/Vnstat/Api/ServiceController.php @@ -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 diff --git a/net/vnstat/src/opnsense/service/conf/actions.d/actions_vnstat.conf b/net/vnstat/src/opnsense/service/conf/actions.d/actions_vnstat.conf index 4a4fcbd64..bdfbd6dc2 100644 --- a/net/vnstat/src/opnsense/service/conf/actions.d/actions_vnstat.conf +++ b/net/vnstat/src/opnsense/service/conf/actions.d/actions_vnstat.conf @@ -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: diff --git a/net/vnstat/src/opnsense/www/js/widgets/Metadata/Vnstat.xml b/net/vnstat/src/opnsense/www/js/widgets/Metadata/Vnstat.xml new file mode 100644 index 000000000..bf6521ff9 --- /dev/null +++ b/net/vnstat/src/opnsense/www/js/widgets/Metadata/Vnstat.xml @@ -0,0 +1,23 @@ + + + Vnstat.js + + /api/vnstat/service/dbiflist + /api/vnstat/service/json + + + Vnstat Traffic + Date + RX + TX + Total + Avg Rate + Hourly + Daily + Monthly + Yearly + No vnstat data available + Refresh Interval (minutes) + + + diff --git a/net/vnstat/src/opnsense/www/js/widgets/Vnstat.js b/net/vnstat/src/opnsense/www/js/widgets/Vnstat.js new file mode 100644 index 000000000..96f89ac1f --- /dev/null +++ b/net/vnstat/src/opnsense/www/js/widgets/Vnstat.js @@ -0,0 +1,299 @@ +/* + * Copyright (C) 2024 Joe Roback + * 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 = $('
'); + let $selectors = $(` +
+ + +
+ `); + 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($('').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', [[`${this.translations.msg_no_data}`]]); + 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', [[`${this.translations.msg_no_data}`]]); + return; + } + + const iface = data.interfaces[0]; + const traffic = iface.traffic?.[this.currentPeriod]; + if (!traffic || traffic.length === 0) { + super.updateTable('vnstat-table', [[`${this.translations.msg_no_data}`]]); + return; + } + + let rows = []; + rows.push([ + `${this.translations.h_date}`, + `${this.translations.h_rx}`, + `${this.translations.h_tx}`, + `${this.translations.h_total}`, + `${this.translations.h_avg_rate}` + ]); + + 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; + } + } +}