diff --git a/plist b/plist index 1f299b5cc..4fba63431 100644 --- a/plist +++ b/plist @@ -1930,6 +1930,7 @@ /usr/local/opnsense/www/js/widgets/BaseWidget.js /usr/local/opnsense/www/js/widgets/Cpu.js /usr/local/opnsense/www/js/widgets/Interfaces.js +/usr/local/opnsense/www/js/widgets/SystemInformation.js /usr/local/opnsense/www/themes/opnsense/LICENSE /usr/local/opnsense/www/themes/opnsense/assets/fonts/SourceSansPro-Bold/SourceSansPro-Bold.eot /usr/local/opnsense/www/themes/opnsense/assets/fonts/SourceSansPro-Bold/SourceSansPro-Bold.otf diff --git a/src/opnsense/mvc/app/controllers/OPNsense/Core/Api/DashboardController.php b/src/opnsense/mvc/app/controllers/OPNsense/Core/Api/DashboardController.php index 71d456bae..931e429af 100644 --- a/src/opnsense/mvc/app/controllers/OPNsense/Core/Api/DashboardController.php +++ b/src/opnsense/mvc/app/controllers/OPNsense/Core/Api/DashboardController.php @@ -46,6 +46,15 @@ class DashboardController extends ApiControllerBase ], 'interfaces' => [ 'title' => gettext('Interfaces'), + ], + 'systeminformation' => [ + 'title' => gettext('System Information'), + 'name' => gettext('Name'), + 'versions' => gettext('Versions'), + 'updates' => gettext('Updates'), + 'datetime' => gettext('Current date/time'), + 'uptime' => gettext('Uptime'), + 'config' => gettext('Last configuration change') ] ]; } diff --git a/src/opnsense/mvc/app/controllers/OPNsense/Core/Api/SystemController.php b/src/opnsense/mvc/app/controllers/OPNsense/Core/Api/SystemController.php index e1681b629..8c78c8da4 100644 --- a/src/opnsense/mvc/app/controllers/OPNsense/Core/Api/SystemController.php +++ b/src/opnsense/mvc/app/controllers/OPNsense/Core/Api/SystemController.php @@ -33,6 +33,7 @@ namespace OPNsense\Core\Api; use OPNsense\Base\ApiControllerBase; use OPNsense\Core\ACL; use OPNsense\Core\Backend; +use OPNsense\Core\Config; /** * Class SystemController @@ -40,6 +41,24 @@ use OPNsense\Core\Backend; */ class SystemController extends ApiControllerBase { + private function formatUptime($uptime) + { + $days = floor($uptime / (3600 * 24)); + $hours = floor(($uptime % (3600 * 24)) / 3600); + $minutes = floor(($uptime % 3600) / 60); + $seconds = $uptime % 60; + + if ($days > 0) { + $plural = $days > 1 ? gettext("days") : gettext("day"); + return sprintf( + "%d %s, %02d:%02d:%02d", + $days, $plural, $hours, $minutes, $seconds + ); + } else { + return sprintf("%02d:%02d:%02d", $hours, $minutes, $seconds); + } + } + public function haltAction() { if ($this->request->isPost()) { @@ -185,4 +204,56 @@ class SystemController extends ApiControllerBase return ["status" => "failed"]; } + + public function systemInformationAction() + { + $config = Config::getInstance()->object(); + $backend = new Backend(); + + $product = json_decode($backend->configdRun('firmware product'), true); + $current = explode('_', $product['product_version'])[0]; + /* information from changelog, more accurate for production release */ + $from_changelog = strpos($product['product_id'], '-devel') === false && + !empty($product['product_latest']) && + $product['product_latest'] != $current; + + /* update status from last check, also includes major releases */ + $from_check = !empty($product['product_check']['upgrade_sets']) || + !empty($product['product_check']['downgrade_packages']) || + !empty($product['product_check']['new_packages']) || + !empty($product['product_check']['reinstall_packages']) || + !empty($product['product_check']['remove_packages']) || + !empty($product['product_check']['upgrade_packages']); + + $response = [ + 'name' => $config->system->hostname . '.' . $config->system->domain, + 'versions' => [ + sprintf('%s %s-%s', $product['product_name'], $product['product_version'], $product['product_arch']), + php_uname('s') . ' ' . php_uname('r'), + trim($backend->configdRun('system openssl version')) + ], + 'updates' => ($from_changelog || $from_check) + ? gettext('Click to view pending updates.') + : gettext('Click to check for updates.'), + ]; + + return json_encode($response); + } + + public function systemTimeAction() + { + $config = Config::getInstance()->object(); + $boottime = json_decode((new Backend())->configdRun('system sysctl values kern.boottime'), true); + preg_match("/sec = (\d+)/", $boottime['kern.boottime'], $matches); + + $last_change = date("D M j G:i:s T Y", !empty($config->revision->time) ? intval($config->revision->time) : 0); + + $response = [ + 'uptime' => $this->formatUptime(time() - $matches[1]), + 'datetime' => date("D M j G:i:s T Y"), + 'config' => $last_change, + ]; + + return json_encode($response); + } } diff --git a/src/opnsense/mvc/app/views/OPNsense/Core/dashboard.volt b/src/opnsense/mvc/app/views/OPNsense/Core/dashboard.volt index 0ead897c7..b5f24f772 100644 --- a/src/opnsense/mvc/app/views/OPNsense/Core/dashboard.volt +++ b/src/opnsense/mvc/app/views/OPNsense/Core/dashboard.volt @@ -32,6 +32,8 @@ + + diff --git a/src/opnsense/service/conf/actions.d/actions_system.conf b/src/opnsense/service/conf/actions.d/actions_system.conf index 67197aa95..61b3c4fb0 100644 --- a/src/opnsense/service/conf/actions.d/actions_system.conf +++ b/src/opnsense/service/conf/actions.d/actions_system.conf @@ -114,3 +114,9 @@ command:/usr/local/opnsense/scripts/system/cpu.py parameters:--interval %s type:stream_output message:Stream CPU stats + +[openssl.version] +command:/usr/local/bin/openssl version | cut -f -2 -d ' ' +parameters: +type:script_output +message:Show OpenSSL version diff --git a/src/opnsense/www/js/widgets/BaseTableWidget.js b/src/opnsense/www/js/widgets/BaseTableWidget.js index fd4e85909..61b6a4cef 100644 --- a/src/opnsense/www/js/widgets/BaseTableWidget.js +++ b/src/opnsense/www/js/widgets/BaseTableWidget.js @@ -20,6 +20,7 @@ export default class BaseTableWidget extends BaseWidget { '.flextable-row': {'padding': '0.5em 0.5em'}, '.header .flex-row': {'border-bottom': ''}, '.flex-row': {'width': this._calculateColumnWidth.bind(this)}, + '.column .flex-row': {'width': '100%'}, '.column': {'width': ''}, '.flex-cell': {'width': ''}, } @@ -47,11 +48,26 @@ export default class BaseTableWidget extends BaseWidget { return ''; } + _constructTable() { + if (this.options === null) { + console.error('No table options set'); + return null; + } + + this.$flextable = $(`
`) + + if (this.options.headerPosition === 'top') { + this.$headerContainer = $(`
`); + this.$flextable.append(this.$headerContainer); + } + } + setTableOptions(options = {}) { /** * headerPosition: top, left or none. * top: headers are on top of the table. Data layout: [{header1: value1}, {header1: value2}, ...] - * left: headers are on the left of the table (key-value). Data layout: [{header1: value1, header2: value2}, ...] + * left: headers are on the left of the table (key-value). Data layout: [{header1: value1}, {header2: value2}, ...]. + * Supports nested columns (e.g. {header1: [value1, value2...]}) * none: no headers. Data layout: [[value1, value2], ...] */ this.options = { @@ -60,15 +76,12 @@ export default class BaseTableWidget extends BaseWidget { } } - updateTable(data = [], clear = true) { + updateTable(data = []) { let $table = $(`#${this.flextableId}`); - if (clear) { - $table.children('.flextable-row').remove(); - } + $table.children('.flextable-row').remove(); for (const row of data) { - this.data.push(row); let rowType = Array.isArray(row) && row !== null ? 'flat' : 'nested'; if (rowType === 'flat' && this.options.headerPosition !== 'none') { console.error('Flat data is not supported with headers'); @@ -80,16 +93,17 @@ export default class BaseTableWidget extends BaseWidget { return null; } - if (rowType === 'flat') { - let $row = $(`
`) - for (const item of row) { - $row.append($(` -
${item}
- `)); - } - $table.append($row); - } else { - if (this.options.headerPosition === 'top') { + switch (this.options.headerPosition) { + case "none": + let $row = $(`
`) + for (const item of row) { + $row.append($(` +
${item}
+ `)); + } + $table.append($row); + break; + case "top": let $flextableRow = $(`
`); for (const [h, c] of Object.entries(row)) { if (!this.headers.has(h)) { @@ -115,49 +129,35 @@ export default class BaseTableWidget extends BaseWidget { } } $table.append($flextableRow); - } else if (this.options.headerPosition === 'left') { + break; + case "left": for (const [h, c] of Object.entries(row)) { if (Array.isArray(c)) { // nested column - let $row = $('
'); + let $row = $('
'); $row.append($(` -
${h}
+
${h}
`)); let $column = $('
'); for (const item of c) { $column.append($(`
-
${item}
+
${item}
`)); } $table.append($row.append($column)); } else { $table.append($(` -
-
${h}
-
${c}
+
+
${h}
+
${c}
- `)); + `)); } } - } + break; } - - } - } - - _constructTable() { - if (this.options === null) { - console.error('No table options set'); - return null; - } - - this.$flextable = $(`
`) - - if (this.options.headerPosition === 'top') { - this.$headerContainer = $(`
`); - this.$flextable.append(this.$headerContainer); } } diff --git a/src/opnsense/www/js/widgets/SystemInformation.js b/src/opnsense/www/js/widgets/SystemInformation.js new file mode 100644 index 000000000..c2152928f --- /dev/null +++ b/src/opnsense/www/js/widgets/SystemInformation.js @@ -0,0 +1,75 @@ +// endpoint:/api/core/system/systemInformation + +/** + * Copyright (C) 2024 Deciso B.V. + * + * 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. + */ + +import BaseTableWidget from "./BaseTableWidget.js"; + +export default class SystemInformation extends BaseTableWidget { + constructor() { + super(); + this.tickTimeout = 1000; + } + + getMarkup() { + super.setTableOptions({ + headerPosition: 'left' + }); + return super.getMarkup(); + } + + async onWidgetTick() { + await ajaxGet('/api/core/system/systemTime', {}, (data, status) => { + $('#datetime').text(data['datetime']); + $('#uptime').text(data['uptime']); + $('#config').text(data['config']); + }); + } + + async onMarkupRendered() { + await ajaxGet('/api/core/system/systemInformation', {}, (data, status) => { + let rows = []; + for (let [key, value] of Object.entries(data)) { + if (!key in this.translations) { + console.error('Missing translation for ' + key); + continue; + } + + if (key === 'updates') { + value = $('').attr('href', '/ui/core/firmware#checkupdate').text(value).prop('outerHTML'); + } + + rows.push({[this.translations[key]]: value}); + } + + rows.push({[this.translations['uptime']]: $('').prop('outerHTML')}); + rows.push({[this.translations['datetime']]: $('').prop('outerHTML')}); + rows.push({[this.translations['config']]: $('').prop('outerHTML')}); + super.updateTable(rows); + }); + } +}