diff --git a/src/opnsense/www/css/dashboard.css b/src/opnsense/www/css/dashboard.css index 280fde1f1..41a6a33ce 100644 --- a/src/opnsense/www/css/dashboard.css +++ b/src/opnsense/www/css/dashboard.css @@ -38,6 +38,10 @@ transition: opacity 0.3s ease, transform 0.3s ease; } +.transition-spinner, transition-check { + transition: opacity 0.3s ease, transform 0.3s ease; +} + .hide { opacity: 0; transform: scale(0); @@ -350,3 +354,9 @@ div { text-align: center; } /* ----- */ + +.ovpn-common-name { + display: flex; + align-items: center; + justify-content: center; +} diff --git a/src/opnsense/www/js/widgets/BaseTableWidget.js b/src/opnsense/www/js/widgets/BaseTableWidget.js index 380e5ac32..5c4780351 100644 --- a/src/opnsense/www/js/widgets/BaseTableWidget.js +++ b/src/opnsense/www/js/widgets/BaseTableWidget.js @@ -154,7 +154,7 @@ export default class BaseTableWidget extends BaseWidget { } if (rowIdentifier !== null) { - rowIdentifier = rowIdentifier.replace(/[:/]/gi, '__'); + rowIdentifier = this.sanitizeSelector(rowIdentifier); } data.forEach(row => { diff --git a/src/opnsense/www/js/widgets/BaseWidget.js b/src/opnsense/www/js/widgets/BaseWidget.js index 5696b521e..29c4fbe2f 100644 --- a/src/opnsense/www/js/widgets/BaseWidget.js +++ b/src/opnsense/www/js/widgets/BaseWidget.js @@ -227,4 +227,66 @@ export default class BaseWidget { return ""; } } + + sanitizeSelector(selector) { + return selector.replace(/[:/.]/gi, '__'); + } + + startCommandTransition(id, $target) { + /** + * Note: this function works best wen applied to an element + * inside a div with at least the following styles: + * display: flex; + * align-items: center; + * justify-content: center; + */ + id = this.sanitizeSelector(id); + let $container = $(` + + + + + `); + + // Copy inline styles from target to container + let inlineStyles = $target.attr('style'); + if (inlineStyles) { + $container.attr('style', inlineStyles); + } + + $target.before($container); + $target.addClass('show'); + $target.toggleClass('show hide'); + $(`#spinner-${id}`).addClass('show'); + $target.prop('disabled', true); + } + + async endCommandTransition(id, $target, success=true) { + id = this.sanitizeSelector(id); + let $container = $target.prev('.transition-icon-container'); + let $spinner = $container.find(`#spinner-${id}`); + let $check = $container.find(`#check-${id}`); + + return new Promise(resolve => { + if (success) { + setTimeout(() => { + $spinner.removeClass('show').addClass('hide'); + $check.toggleClass('hide show'); + setTimeout(() => { + $check.toggleClass('hide show'); + $target.prop('disabled', false); + $target.toggleClass('show hide'); + $container.remove(); + resolve(); + }, 500); + }, 200); + } else { + $target.toggleClass('show hide'); + $target.prop('disabled', false); + $spinner.removeClass('show').addClass('hide'); + $container.remove(); + resolve(); + } + }); + } } diff --git a/src/opnsense/www/js/widgets/Metadata/Core.xml b/src/opnsense/www/js/widgets/Metadata/Core.xml index 796113566..265824cd6 100644 --- a/src/opnsense/www/js/widgets/Metadata/Core.xml +++ b/src/opnsense/www/js/widgets/Metadata/Core.xml @@ -285,4 +285,16 @@ Start - + + OpenVPNClients.js + + /api/openvpn/service/search_sessions + + + OpenVPN Client Connections + No clients connected + Server + Kill connection + + + \ No newline at end of file diff --git a/src/opnsense/www/js/widgets/OpenVPNClients.js b/src/opnsense/www/js/widgets/OpenVPNClients.js new file mode 100644 index 000000000..7327a663d --- /dev/null +++ b/src/opnsense/www/js/widgets/OpenVPNClients.js @@ -0,0 +1,175 @@ +/* + * 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 OpenVPNClients extends BaseTableWidget { + constructor() { + super(); + this.resizeHandles = "e, w"; + + this.locked = false; + } + + getGridOptions() { + return { + sizeToContent: 650 + }; + } + + getMarkup() { + let $container = $('
'); + let $clientTable = this.createTable('ovpn-client-table', { + headerPosition: 'left' + }); + + $container.append($clientTable); + return $container; + } + + async _killClient(id, commonName) { + let split = id.split('_'); + let params = {server_id: split[0], session_id: split[1]}; + await this.ajaxCall('/api/openvpn/service/kill_session/', JSON.stringify(params), 'POST').then(async (data) => { + if (data && data.status === 'not_found') { + // kill by common name + params.session_id = commonName; + await this.ajaxCall('/api/openvpn/service/kill_session/', JSON.stringify(params), 'POST'); + } + }); + } + + async updateClients() { + const sessions = await this.ajaxCall('/api/openvpn/service/search_sessions', JSON.stringify({'type': ['server']}), 'POST'); + + if (!sessions || !sessions.rows || sessions.rows.length === 0) { + $('#ovpn-client-table-container').html(` +
+ ${this.translations.noclients} +
+ `); + return; + } + + let servers = {}; + sessions.rows.forEach((session) => { + let id = session.id.toString().split("_")[0]; + if (!servers[id]) { + servers[id] = { + description: session.description || '', + clients: [] + }; + } + + if (!session.is_client) { + servers[id] = session; + servers[id].clients = null; + } else { + + servers[id].clients.push(session); + } + }); + + for (const [server_id, server] of Object.entries(servers)) { + let $clients = $('
'); + + if (server.clients) { + // sort the sessions per server + server.clients.sort((a, b) => (b.bytes_received + b.bytes_sent) - (a.bytes_received + a.bytes_sent)); + server.clients.forEach((client) => { + let color = "text-success"; + // disabled servers are not included in the list. Stopped servers have no "status" property + if (client.status) { + // server active, status may either be 'ok' or 'failed' + if (client.status === 'failed') { + color = "text-danger"; + } else { + color = "text-success"; + } + } else { + // server is stopped + color = "text-muted"; + } + $clients.append($(` +
+
+ + +   + ${client.common_name} + + + + +
+
+ ${client.real_address} / ${client.virtual_address} +
+
+ ${client.connected_since} +
+
+ RX: ${this._formatBytes(client.bytes_received, 0)} / TX: ${this._formatBytes(client.bytes_sent, 0)} +
+
+ `)); + }); + } else { + $clients = $(`${this.translations.noclients}`); + } + + this.updateTable('ovpn-client-table', [[`${this.translations.server} ${server.description}`, $clients.prop('outerHTML')]], `server_${server_id}`); + + $('.ovpn-command-kill').on('click', async (event) => { + this.locked = true; + + let $target = $(event.currentTarget); + let rowId = $target.data('row-id'); + let commonName = $target.data('common-name'); + + this.startCommandTransition(rowId, $target); + await this._killClient(rowId, commonName); + await this.endCommandTransition(rowId, $target); + await this.updateClients(); + this.locked = false; + + }); + + $('.ovpn-client-command').tooltip({container: 'body'}); + }; + } + + async onWidgetTick() { + if (!this.locked) { + await this.updateClients(); + } + } +}