diff --git a/plist b/plist index 8dca4b326..d5da95907 100644 --- a/plist +++ b/plist @@ -232,11 +232,13 @@ /usr/local/opnsense/mvc/app/controllers/OPNsense/CaptivePortal/VoucherController.php /usr/local/opnsense/mvc/app/controllers/OPNsense/CaptivePortal/forms/dialogZone.xml /usr/local/opnsense/mvc/app/controllers/OPNsense/Core/Api/BackupController.php +/usr/local/opnsense/mvc/app/controllers/OPNsense/Core/Api/DashboardController.php /usr/local/opnsense/mvc/app/controllers/OPNsense/Core/Api/FirmwareController.php /usr/local/opnsense/mvc/app/controllers/OPNsense/Core/Api/MenuController.php /usr/local/opnsense/mvc/app/controllers/OPNsense/Core/Api/ServiceController.php /usr/local/opnsense/mvc/app/controllers/OPNsense/Core/Api/SystemController.php /usr/local/opnsense/mvc/app/controllers/OPNsense/Core/BackupController.php +/usr/local/opnsense/mvc/app/controllers/OPNsense/Core/DashboardController.php /usr/local/opnsense/mvc/app/controllers/OPNsense/Core/FirmwareController.php /usr/local/opnsense/mvc/app/controllers/OPNsense/Core/HaltController.php /usr/local/opnsense/mvc/app/controllers/OPNsense/Core/IndexController.php @@ -771,6 +773,7 @@ /usr/local/opnsense/mvc/app/views/OPNsense/CaptivePortal/index.volt /usr/local/opnsense/mvc/app/views/OPNsense/CaptivePortal/vouchers.volt /usr/local/opnsense/mvc/app/views/OPNsense/Core/backup_history.volt +/usr/local/opnsense/mvc/app/views/OPNsense/Core/dashboard.volt /usr/local/opnsense/mvc/app/views/OPNsense/Core/firmware.volt /usr/local/opnsense/mvc/app/views/OPNsense/Core/halt.volt /usr/local/opnsense/mvc/app/views/OPNsense/Core/license.volt @@ -1318,6 +1321,7 @@ /usr/local/opnsense/www/css/bootstrap-datepicker3.min.css /usr/local/opnsense/www/css/bootstrap-select.css /usr/local/opnsense/www/css/chart.css +/usr/local/opnsense/www/css/dashboard.css /usr/local/opnsense/www/css/dns-overview.css /usr/local/opnsense/www/css/flags/1x1/ad.svg /usr/local/opnsense/www/css/flags/1x1/ae.svg @@ -1838,6 +1842,8 @@ /usr/local/opnsense/www/css/flags/flag-icon.css /usr/local/opnsense/www/css/flags/flag-icon.scss /usr/local/opnsense/www/css/font-awesome.min.css +/usr/local/opnsense/www/css/gridstack-extra.min.css +/usr/local/opnsense/www/css/gridstack.min.css /usr/local/opnsense/www/css/jqtree.css /usr/local/opnsense/www/css/jquery.bootgrid.css /usr/local/opnsense/www/css/nv.d3.css @@ -1871,10 +1877,12 @@ /usr/local/opnsense/www/js/chartjs-adapter-moment.min.js /usr/local/opnsense/www/js/chartjs-plugin-colorschemes.js /usr/local/opnsense/www/js/chartjs-plugin-colorschemes.min.js +/usr/local/opnsense/www/js/chartjs-plugin-matrix.min.js /usr/local/opnsense/www/js/chartjs-plugin-streaming.js /usr/local/opnsense/www/js/chartjs-plugin-streaming.min.js /usr/local/opnsense/www/js/d3.min.js /usr/local/opnsense/www/js/d3.min.js.LICENSE +/usr/local/opnsense/www/js/gridstack-all.min.js /usr/local/opnsense/www/js/jquery-3.5.1.min.js /usr/local/opnsense/www/js/jquery-sortable.js /usr/local/opnsense/www/js/jquery.bootgrid.LICENSE @@ -1890,15 +1898,20 @@ /usr/local/opnsense/www/js/opnsense_status.js /usr/local/opnsense/www/js/opnsense_theme.js /usr/local/opnsense/www/js/opnsense_ui.js +/usr/local/opnsense/www/js/opnsense_widget_manager.js /usr/local/opnsense/www/js/pick-a-color-1.2.3.LICENSE /usr/local/opnsense/www/js/pick-a-color-1.2.3.js /usr/local/opnsense/www/js/pick-a-color-1.2.3.min.js /usr/local/opnsense/www/js/polyfills.js /usr/local/opnsense/www/js/qrcode.js +/usr/local/opnsense/www/js/smoothie.js /usr/local/opnsense/www/js/theme.js /usr/local/opnsense/www/js/tinycolor-1.4.1.min.js /usr/local/opnsense/www/js/tokenize2.js /usr/local/opnsense/www/js/tree.jquery.min.js +/usr/local/opnsense/www/js/widgets/BaseTableWidget.js +/usr/local/opnsense/www/js/widgets/BaseWidget.js +/usr/local/opnsense/www/js/widgets/Interfaces.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 new file mode 100644 index 000000000..26df759f8 --- /dev/null +++ b/src/opnsense/mvc/app/controllers/OPNsense/Core/Api/DashboardController.php @@ -0,0 +1,157 @@ +isPageAccessible($this->getUserName(), $line)) { + return false; + } + } + } + + return true; + } + + public function getDashboardAction() + { + $this->sessionClose(); + $result = []; + $dashboard = null; + + $config = Config::getInstance()->object(); + foreach ($config->system->user as $node) { + if ($this->getUserName() === (string)$node->name) { + $dashboard = (string)$node->dashboard; + } + } + + $widgetModules = array_filter(glob('/usr/local/opnsense/www/js/widgets/*.js'), + function($element) { + $base = basename($element); + if (str_contains($base, '.js') && !str_contains($base, 'Base')) { + return $this->canAccessEndpoints($element); + } + + return false; + } + ); + + $widgetModules = array_map(function($element) {return basename($element);}, $widgetModules); + + $result['modules'] = []; + foreach ($widgetModules as $module) { + $result['modules'][] = [ + 'id' => strtolower(basename($module, '.js')), + 'module' => basename($module) + ]; + } + + $result['dashboard'] = !empty($dashboard) ? base64_decode($dashboard) : null; + + return json_encode($result); + } + + public function saveWidgetsAction() + { + + $result = ['result' => 'failed']; + + if ($this->request->isPost() && !empty($this->request->getRawBody())) { + $dashboard = $this->request->getRawBody(); + if (strlen($dashboard) > (1024 * 1024)) { + // prevent saving large blobs of data + return json_encode($result); + } + + $encoded = base64_encode($dashboard); + $config = Config::getInstance()->object(); + $name = $this->getUserName(); + foreach ($config->system->user as $node) { + if ($name === (string)$node->name) { + $node->dashboard = $encoded; + Config::getInstance()->save(); + $result = ['result' => 'saved']; + break; + } + } + } + + return json_encode($result); + } + + public function restoreDefaultsAction() + { + $result = ['result' => 'failed']; + + $config = Config::getInstance()->object(); + $name = $this->getUserName(); + + foreach ($config->system->user as $node) { + if ($name === (string)$node->name) { + $node->dashboard = null; + Config::getInstance()->save(); + $result = ['result' => 'saved']; + break; + } + } + + return json_encode($result); + } +} \ No newline at end of file diff --git a/src/opnsense/mvc/app/controllers/OPNsense/Core/DashboardController.php b/src/opnsense/mvc/app/controllers/OPNsense/Core/DashboardController.php new file mode 100644 index 000000000..f97b6050d --- /dev/null +++ b/src/opnsense/mvc/app/controllers/OPNsense/Core/DashboardController.php @@ -0,0 +1,37 @@ +view->pick('OPNsense/Core/dashboard'); + } +} diff --git a/src/opnsense/mvc/app/models/OPNsense/Core/ACL/ACL.xml b/src/opnsense/mvc/app/models/OPNsense/Core/ACL/ACL.xml index da724bb89..fbe572dd3 100644 --- a/src/opnsense/mvc/app/models/OPNsense/Core/ACL/ACL.xml +++ b/src/opnsense/mvc/app/models/OPNsense/Core/ACL/ACL.xml @@ -53,6 +53,9 @@ index.php* widgets/widgets/*.widget.php* widgets/api/get.php* + ui/core/dashboard + ui/js/widgets/* + api/core/dashboard/* api/diagnostics/dns/reverse_lookup* api/routes/gateway/status* api/diagnostics/interface/getInterfaceNames diff --git a/src/opnsense/mvc/app/models/OPNsense/Core/Menu/Menu.xml b/src/opnsense/mvc/app/models/OPNsense/Core/Menu/Menu.xml index 2cbbb09ee..92e0dd6c9 100644 --- a/src/opnsense/mvc/app/models/OPNsense/Core/Menu/Menu.xml +++ b/src/opnsense/mvc/app/models/OPNsense/Core/Menu/Menu.xml @@ -5,11 +5,13 @@ - - + + + + - + diff --git a/src/opnsense/mvc/app/views/OPNsense/Core/dashboard.volt b/src/opnsense/mvc/app/views/OPNsense/Core/dashboard.volt new file mode 100644 index 000000000..165bf0424 --- /dev/null +++ b/src/opnsense/mvc/app/views/OPNsense/Core/dashboard.volt @@ -0,0 +1,60 @@ +{# + # 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. + #} + +{% set theme_name = ui_theme|default('opnsense') %} + + + + + + + + + + + + + + + +
diff --git a/src/opnsense/www/css/dashboard.css b/src/opnsense/www/css/dashboard.css new file mode 100644 index 000000000..5af7e58cc --- /dev/null +++ b/src/opnsense/www/css/dashboard.css @@ -0,0 +1,249 @@ +.fa-circle-o-notch { + font-size: 3em; +} + +.grid-stack-item-content { + text-align: center; + border-style: solid; + border-color: rgba(217, 79, 0, 0.15); + border-width: 2px; + background-color: #fbfbfb; + border-radius: 0.5em 0.5em 0.5em 0.5em; +} + +.widget-content { + position: relative; + width: 100%; + height: 100%; + padding: 1px; +} + +.widget-header { + margin-top: 0.5em; + margin-left: 1em; + margin-right: 1em; + display: flex; + justify-content: space-between; + align-items: center; +} + +.fa-stack.small { + font-size: 0.5em; +} + +/* Align fa icons to middle to adjust for icon stack */ +i { + vertical-align: middle; +} + +.close-handle { + vertical-align: middle; + text-align: right; + cursor: pointer; +} + +.close-handle > i { + font-size: 0.7em; +} + +.panel-divider { + width: 100%; + text-align: center; + height: 8px; + margin-bottom: 10px; +} + +table { + table-layout: fixed; +} + +td { + word-break: break-all; +} + +.line { + display: inline-block; + height: 1px; + width: 70%; + background: #d94f00; + margin: 5px; +} + +.canvas-container { + width: 100%; +} + +.smoothie-chart-tooltip { + z-index: 1; /* necessary to force to foreground */ + background: rgba(50, 50, 50, 0.9); + padding-left: 15px; + padding-right: 15px; + color: white; + font-size: 13px; + border-radius: 0.5em 0.5em 0.5em 0.5em; + pointer-events: none; +} + +.flex-container { + display: flex; + flex-wrap: nowrap; + white-space: nowrap; +} + +.gateway-info { + margin: 5px; + padding: 5px; + font-size: 13px; +} + +.gateway-detail-container { + display: none; + margin: 5px; +} + +.interface-info { + display: flex; + flex-wrap: wrap; + align-items: center; + height: 100%; +} + +.nowrap { + flex-wrap: nowrap; +} + +.gateway-graph { + display: none; +} + +.flex-container > .gateway-graph { + font-size: 13px; +} + +.vertical-center-row { + height: 100%; + display: inline; +} + +.interfaces-info { + margin: 5px; + font-size: 13px; +} + +.interface-descr { + margin-left: 10px; + font-size: 15px; + cursor: pointer; + text-decoration: underline; +} + +.interfaces-detail-container { + margin: 5px; + display: none; +} + +.d-flex { + display: flex; +} + +.d-flex > .justify-content-start { + justify-content: start; +} + +.d-flex > .justify-content-end { + justify-content: end; +} + +#chartjs-toolip { + z-index: 20; +} + +/* Gauge */ +#gauge-container { + width: 80%; + margin: 5px auto; + background-color: #ccc; + height: 10px; + position: relative; + border-radius: 5px; + overflow: hidden; +} + +#gauge-fill { + height: 100%; + width: 0; + background-color: green; + border-radius: 5px; + transition: width 0.5s, background-color 0.5s; +} + + +/* Custom flex table */ +div { + box-sizing: border-box; +} + +.flextable-container { + display: block; + margin: 2em auto; + width: 95%; + max-width: 1200px; /* XXX */ +} + +.flextable-header { + display: flex; + flex-flow: row wrap; + transition: 0.5s; + padding: 0.5em 0.5em; + border-top: solid 1px rgba(217, 79, 0, 0.15); +} + +.flextable-row { + display: flex; + flex-flow: row wrap; + transition: 0.5s; + padding: 0.5em 0.5em; + border-top: solid 1px rgba(217, 79, 0, 0.15); + align-items: center +} + +.flextable-header .flex-row { + font-weight: bold; +} + +.flextable-row:hover { + background: #F5F5F5; + transition: 500ms; +} + +.flex-row { + text-align: left; + word-break: break-word; +} + +.column { + display: flex; + flex-flow: column wrap; + width: 50%; + padding: 0; +} +.column .flex-row { + display: flex; + flex-flow: row wrap; + width: 100%; + padding: 0; + border: 0; + border-top: rgba(217, 79, 0, 0.15); +} +.column .flex-row:hover { + background: #F5F5F5; + transition: 500ms; +} +.flex-cell { + width: 100%; + text-align: left; +} + +.column .flex-row:not(:last-child) { + border-bottom: solid 1px rgba(217, 79, 0, 0.15); +} \ No newline at end of file diff --git a/src/opnsense/www/css/gridstack-extra.min.css b/src/opnsense/www/css/gridstack-extra.min.css new file mode 100644 index 000000000..8d0065302 --- /dev/null +++ b/src/opnsense/www/css/gridstack-extra.min.css @@ -0,0 +1 @@ +.gs-2>.grid-stack-item{width:50%}.gs-2>.grid-stack-item[gs-x="1"]{left:50%}.gs-2>.grid-stack-item[gs-w="2"]{width:100%}.gs-3>.grid-stack-item{width:33.333%}.gs-3>.grid-stack-item[gs-x="1"]{left:33.333%}.gs-3>.grid-stack-item[gs-w="2"]{width:66.667%}.gs-3>.grid-stack-item[gs-x="2"]{left:66.667%}.gs-3>.grid-stack-item[gs-w="3"]{width:100%}.gs-4>.grid-stack-item{width:25%}.gs-4>.grid-stack-item[gs-x="1"]{left:25%}.gs-4>.grid-stack-item[gs-w="2"]{width:50%}.gs-4>.grid-stack-item[gs-x="2"]{left:50%}.gs-4>.grid-stack-item[gs-w="3"]{width:75%}.gs-4>.grid-stack-item[gs-x="3"]{left:75%}.gs-4>.grid-stack-item[gs-w="4"]{width:100%}.gs-5>.grid-stack-item{width:20%}.gs-5>.grid-stack-item[gs-x="1"]{left:20%}.gs-5>.grid-stack-item[gs-w="2"]{width:40%}.gs-5>.grid-stack-item[gs-x="2"]{left:40%}.gs-5>.grid-stack-item[gs-w="3"]{width:60%}.gs-5>.grid-stack-item[gs-x="3"]{left:60%}.gs-5>.grid-stack-item[gs-w="4"]{width:80%}.gs-5>.grid-stack-item[gs-x="4"]{left:80%}.gs-5>.grid-stack-item[gs-w="5"]{width:100%}.gs-6>.grid-stack-item{width:16.667%}.gs-6>.grid-stack-item[gs-x="1"]{left:16.667%}.gs-6>.grid-stack-item[gs-w="2"]{width:33.333%}.gs-6>.grid-stack-item[gs-x="2"]{left:33.333%}.gs-6>.grid-stack-item[gs-w="3"]{width:50%}.gs-6>.grid-stack-item[gs-x="3"]{left:50%}.gs-6>.grid-stack-item[gs-w="4"]{width:66.667%}.gs-6>.grid-stack-item[gs-x="4"]{left:66.667%}.gs-6>.grid-stack-item[gs-w="5"]{width:83.333%}.gs-6>.grid-stack-item[gs-x="5"]{left:83.333%}.gs-6>.grid-stack-item[gs-w="6"]{width:100%}.gs-7>.grid-stack-item{width:14.286%}.gs-7>.grid-stack-item[gs-x="1"]{left:14.286%}.gs-7>.grid-stack-item[gs-w="2"]{width:28.571%}.gs-7>.grid-stack-item[gs-x="2"]{left:28.571%}.gs-7>.grid-stack-item[gs-w="3"]{width:42.857%}.gs-7>.grid-stack-item[gs-x="3"]{left:42.857%}.gs-7>.grid-stack-item[gs-w="4"]{width:57.143%}.gs-7>.grid-stack-item[gs-x="4"]{left:57.143%}.gs-7>.grid-stack-item[gs-w="5"]{width:71.429%}.gs-7>.grid-stack-item[gs-x="5"]{left:71.429%}.gs-7>.grid-stack-item[gs-w="6"]{width:85.714%}.gs-7>.grid-stack-item[gs-x="6"]{left:85.714%}.gs-7>.grid-stack-item[gs-w="7"]{width:100%}.gs-8>.grid-stack-item{width:12.5%}.gs-8>.grid-stack-item[gs-x="1"]{left:12.5%}.gs-8>.grid-stack-item[gs-w="2"]{width:25%}.gs-8>.grid-stack-item[gs-x="2"]{left:25%}.gs-8>.grid-stack-item[gs-w="3"]{width:37.5%}.gs-8>.grid-stack-item[gs-x="3"]{left:37.5%}.gs-8>.grid-stack-item[gs-w="4"]{width:50%}.gs-8>.grid-stack-item[gs-x="4"]{left:50%}.gs-8>.grid-stack-item[gs-w="5"]{width:62.5%}.gs-8>.grid-stack-item[gs-x="5"]{left:62.5%}.gs-8>.grid-stack-item[gs-w="6"]{width:75%}.gs-8>.grid-stack-item[gs-x="6"]{left:75%}.gs-8>.grid-stack-item[gs-w="7"]{width:87.5%}.gs-8>.grid-stack-item[gs-x="7"]{left:87.5%}.gs-8>.grid-stack-item[gs-w="8"]{width:100%}.gs-9>.grid-stack-item{width:11.111%}.gs-9>.grid-stack-item[gs-x="1"]{left:11.111%}.gs-9>.grid-stack-item[gs-w="2"]{width:22.222%}.gs-9>.grid-stack-item[gs-x="2"]{left:22.222%}.gs-9>.grid-stack-item[gs-w="3"]{width:33.333%}.gs-9>.grid-stack-item[gs-x="3"]{left:33.333%}.gs-9>.grid-stack-item[gs-w="4"]{width:44.444%}.gs-9>.grid-stack-item[gs-x="4"]{left:44.444%}.gs-9>.grid-stack-item[gs-w="5"]{width:55.556%}.gs-9>.grid-stack-item[gs-x="5"]{left:55.556%}.gs-9>.grid-stack-item[gs-w="6"]{width:66.667%}.gs-9>.grid-stack-item[gs-x="6"]{left:66.667%}.gs-9>.grid-stack-item[gs-w="7"]{width:77.778%}.gs-9>.grid-stack-item[gs-x="7"]{left:77.778%}.gs-9>.grid-stack-item[gs-w="8"]{width:88.889%}.gs-9>.grid-stack-item[gs-x="8"]{left:88.889%}.gs-9>.grid-stack-item[gs-w="9"]{width:100%}.gs-10>.grid-stack-item{width:10%}.gs-10>.grid-stack-item[gs-x="1"]{left:10%}.gs-10>.grid-stack-item[gs-w="2"]{width:20%}.gs-10>.grid-stack-item[gs-x="2"]{left:20%}.gs-10>.grid-stack-item[gs-w="3"]{width:30%}.gs-10>.grid-stack-item[gs-x="3"]{left:30%}.gs-10>.grid-stack-item[gs-w="4"]{width:40%}.gs-10>.grid-stack-item[gs-x="4"]{left:40%}.gs-10>.grid-stack-item[gs-w="5"]{width:50%}.gs-10>.grid-stack-item[gs-x="5"]{left:50%}.gs-10>.grid-stack-item[gs-w="6"]{width:60%}.gs-10>.grid-stack-item[gs-x="6"]{left:60%}.gs-10>.grid-stack-item[gs-w="7"]{width:70%}.gs-10>.grid-stack-item[gs-x="7"]{left:70%}.gs-10>.grid-stack-item[gs-w="8"]{width:80%}.gs-10>.grid-stack-item[gs-x="8"]{left:80%}.gs-10>.grid-stack-item[gs-w="9"]{width:90%}.gs-10>.grid-stack-item[gs-x="9"]{left:90%}.gs-10>.grid-stack-item[gs-w="10"]{width:100%}.gs-11>.grid-stack-item{width:9.091%}.gs-11>.grid-stack-item[gs-x="1"]{left:9.091%}.gs-11>.grid-stack-item[gs-w="2"]{width:18.182%}.gs-11>.grid-stack-item[gs-x="2"]{left:18.182%}.gs-11>.grid-stack-item[gs-w="3"]{width:27.273%}.gs-11>.grid-stack-item[gs-x="3"]{left:27.273%}.gs-11>.grid-stack-item[gs-w="4"]{width:36.364%}.gs-11>.grid-stack-item[gs-x="4"]{left:36.364%}.gs-11>.grid-stack-item[gs-w="5"]{width:45.455%}.gs-11>.grid-stack-item[gs-x="5"]{left:45.455%}.gs-11>.grid-stack-item[gs-w="6"]{width:54.545%}.gs-11>.grid-stack-item[gs-x="6"]{left:54.545%}.gs-11>.grid-stack-item[gs-w="7"]{width:63.636%}.gs-11>.grid-stack-item[gs-x="7"]{left:63.636%}.gs-11>.grid-stack-item[gs-w="8"]{width:72.727%}.gs-11>.grid-stack-item[gs-x="8"]{left:72.727%}.gs-11>.grid-stack-item[gs-w="9"]{width:81.818%}.gs-11>.grid-stack-item[gs-x="9"]{left:81.818%}.gs-11>.grid-stack-item[gs-w="10"]{width:90.909%}.gs-11>.grid-stack-item[gs-x="10"]{left:90.909%}.gs-11>.grid-stack-item[gs-w="11"]{width:100%} \ No newline at end of file diff --git a/src/opnsense/www/css/gridstack.min.css b/src/opnsense/www/css/gridstack.min.css new file mode 100644 index 000000000..a719b4bd6 --- /dev/null +++ b/src/opnsense/www/css/gridstack.min.css @@ -0,0 +1 @@ +.grid-stack{position:relative}.grid-stack-rtl{direction:ltr}.grid-stack-rtl>.grid-stack-item{direction:rtl}.grid-stack-placeholder>.placeholder-content{background-color:rgba(0,0,0,.1);margin:0;position:absolute;width:auto;z-index:0!important}.grid-stack>.grid-stack-item{position:absolute;padding:0}.grid-stack>.grid-stack-item>.grid-stack-item-content{margin:0;position:absolute;width:auto;overflow-x:hidden;overflow-y:auto}.grid-stack>.grid-stack-item.size-to-content:not(.size-to-content-max)>.grid-stack-item-content{overflow-y:hidden}.grid-stack-item>.ui-resizable-handle{position:absolute;font-size:.1px;display:block;-ms-touch-action:none;touch-action:none}.grid-stack-item.ui-resizable-autohide>.ui-resizable-handle,.grid-stack-item.ui-resizable-disabled>.ui-resizable-handle{display:none}.grid-stack-item>.ui-resizable-ne,.grid-stack-item>.ui-resizable-nw,.grid-stack-item>.ui-resizable-se,.grid-stack-item>.ui-resizable-sw{background-image:url('data:image/svg+xml;utf8,');background-repeat:no-repeat;background-position:center}.grid-stack-item>.ui-resizable-ne{transform:translate(0,10px) rotate(45deg)}.grid-stack-item>.ui-resizable-sw{transform:rotate(45deg)}.grid-stack-item>.ui-resizable-nw{transform:translate(0,10px) rotate(-45deg)}.grid-stack-item>.ui-resizable-se{transform:rotate(-45deg)}.grid-stack-item>.ui-resizable-nw{cursor:nw-resize;width:20px;height:20px;top:0}.grid-stack-item>.ui-resizable-n{cursor:n-resize;height:10px;top:0;left:25px;right:25px}.grid-stack-item>.ui-resizable-ne{cursor:ne-resize;width:20px;height:20px;top:0}.grid-stack-item>.ui-resizable-e{cursor:e-resize;width:10px;top:15px;bottom:15px}.grid-stack-item>.ui-resizable-se{cursor:se-resize;width:20px;height:20px}.grid-stack-item>.ui-resizable-s{cursor:s-resize;height:10px;left:25px;bottom:0;right:25px}.grid-stack-item>.ui-resizable-sw{cursor:sw-resize;width:20px;height:20px}.grid-stack-item>.ui-resizable-w{cursor:w-resize;width:10px;top:15px;bottom:15px}.grid-stack-item.ui-draggable-dragging>.ui-resizable-handle{display:none!important}.grid-stack-item.ui-draggable-dragging{will-change:left,top;cursor:move}.grid-stack-item.ui-resizable-resizing{will-change:width,height}.ui-draggable-dragging,.ui-resizable-resizing{z-index:10000}.ui-draggable-dragging>.grid-stack-item-content,.ui-resizable-resizing>.grid-stack-item-content{box-shadow:1px 4px 6px rgba(0,0,0,.2);opacity:.8}.grid-stack-animate,.grid-stack-animate .grid-stack-item{transition:left .3s,top .3s,height .3s,width .3s}.grid-stack-animate .grid-stack-item.grid-stack-placeholder,.grid-stack-animate .grid-stack-item.ui-draggable-dragging,.grid-stack-animate .grid-stack-item.ui-resizable-resizing{transition:left 0s,top 0s,height 0s,width 0s}.grid-stack>.grid-stack-item[gs-y="0"]{top:0}.grid-stack>.grid-stack-item[gs-x="0"]{left:0}.gs-12>.grid-stack-item{width:8.333%}.gs-12>.grid-stack-item[gs-x="1"]{left:8.333%}.gs-12>.grid-stack-item[gs-w="2"]{width:16.667%}.gs-12>.grid-stack-item[gs-x="2"]{left:16.667%}.gs-12>.grid-stack-item[gs-w="3"]{width:25%}.gs-12>.grid-stack-item[gs-x="3"]{left:25%}.gs-12>.grid-stack-item[gs-w="4"]{width:33.333%}.gs-12>.grid-stack-item[gs-x="4"]{left:33.333%}.gs-12>.grid-stack-item[gs-w="5"]{width:41.667%}.gs-12>.grid-stack-item[gs-x="5"]{left:41.667%}.gs-12>.grid-stack-item[gs-w="6"]{width:50%}.gs-12>.grid-stack-item[gs-x="6"]{left:50%}.gs-12>.grid-stack-item[gs-w="7"]{width:58.333%}.gs-12>.grid-stack-item[gs-x="7"]{left:58.333%}.gs-12>.grid-stack-item[gs-w="8"]{width:66.667%}.gs-12>.grid-stack-item[gs-x="8"]{left:66.667%}.gs-12>.grid-stack-item[gs-w="9"]{width:75%}.gs-12>.grid-stack-item[gs-x="9"]{left:75%}.gs-12>.grid-stack-item[gs-w="10"]{width:83.333%}.gs-12>.grid-stack-item[gs-x="10"]{left:83.333%}.gs-12>.grid-stack-item[gs-w="11"]{width:91.667%}.gs-12>.grid-stack-item[gs-x="11"]{left:91.667%}.gs-12>.grid-stack-item[gs-w="12"]{width:100%}.gs-1>.grid-stack-item{width:100%} \ No newline at end of file diff --git a/src/opnsense/www/js/chartjs-plugin-matrix.min.js b/src/opnsense/www/js/chartjs-plugin-matrix.min.js new file mode 100644 index 000000000..48d5b138e --- /dev/null +++ b/src/opnsense/www/js/chartjs-plugin-matrix.min.js @@ -0,0 +1,8 @@ +/*! + * chartjs-chart-matrix v2.0.1 + * https://chartjs-chart-matrix.pages.dev/ + * (c) 2023 Jukka Kurkela + * Released under the MIT license + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports,require("chart.js"),require("chart.js/helpers")):"function"==typeof define&&define.amd?define(["exports","chart.js","chart.js/helpers"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self)["chartjs-chart-matrix"]={},t.Chart,t.Chart.helpers)}(this,(function(t,e,i){"use strict";class r extends e.DatasetController{static id="matrix";static version="2.0.1";static defaults={dataElementType:"matrix",animations:{numbers:{type:"number",properties:["x","y","width","height"]}}};static overrides={interaction:{mode:"nearest",intersect:!0},scales:{x:{type:"linear",offset:!0},y:{type:"linear",reverse:!0}}};initialize(){this.enableOptionSharing=!0,super.initialize()}update(t){const e=this._cachedMeta;this.updateElements(e.data,0,e.data.length,t)}updateElements(t,e,i,r){const s=this,a="reset"===r,{xScale:h,yScale:l}=s._cachedMeta,d=s.resolveDataElementOptions(e,r),c=s.getSharedOptions(r,t[e],d);for(let d=e;d=a.left&&e<=a.right)&&(o||i>=a.top&&i<=a.bottom)}class d extends e.Element{static id="matrix";static defaults={backgroundColor:void 0,borderColor:void 0,borderWidth:void 0,borderRadius:0,anchorX:"center",anchorY:"center",width:20,height:20};constructor(t){super(),this.options=void 0,this.width=void 0,this.height=void 0,t&&Object.assign(this,t)}draw(t){const e=this.options,{inner:r,outer:n}=h(this),o=i.toTRBLCorners(e.borderRadius);t.save(),n.w!==r.w||n.h!==r.h?(t.beginPath(),i.addRoundedRectPath(t,{x:n.x,y:n.y,w:n.w,h:n.h,radius:o}),i.addRoundedRectPath(t,{x:r.x,y:r.y,w:r.w,h:r.h,radius:o}),t.fillStyle=e.backgroundColor,t.fill(),t.fillStyle=e.borderColor,t.fill("evenodd")):(t.beginPath(),i.addRoundedRectPath(t,{x:r.x,y:r.y,w:r.w,h:r.h,radius:o}),t.fillStyle=e.backgroundColor,t.fill()),t.restore()}inRange(t,e,i){return l(this,t,e,i)}inXRange(t,e){return l(this,t,null,e)}inYRange(t,e){return l(this,null,t,e)}getCenterPoint(t){const{x:e,y:i,width:r,height:n}=this.getProps(["x","y","width","height"],t);return{x:e+r/2,y:i+n/2}}tooltipPosition(){return this.getCenterPoint()}getRange(t){return"x"===t?this.width/2:this.height/2}}e.Chart.register(r,d),t.MatrixController=r,t.MatrixElement=d})); +//# sourceMappingURL=chartjs-chart-matrix.min.js.map diff --git a/src/opnsense/www/js/gridstack-all.min.js b/src/opnsense/www/js/gridstack-all.min.js new file mode 100644 index 000000000..1f3485031 --- /dev/null +++ b/src/opnsense/www/js/gridstack-all.min.js @@ -0,0 +1 @@ +!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.GridStack=t():e.GridStack=t()}(self,()=>(()=>{"use strict";var s={d:(e,t)=>{for(var i in t)s.o(t,i)&&!s.o(e,i)&&Object.defineProperty(e,i,{enumerable:!0,get:t[i]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t)},e={};s.d(e,{GridStack:()=>C});class v{static getElements(t,i=document){if("string"!=typeof t)return[t];{const s="getElementById"in i?i:void 0;if(s&&!isNaN(+t[0])){const i=s.getElementById(t);return i?[i]:[]}let e=i.querySelectorAll(t);return e.length||"."===t[0]||"#"===t[0]||((e=i.querySelectorAll("."+t)).length||(e=i.querySelectorAll("#"+t))),Array.from(e)}}static getElement(t,i=document){if("string"!=typeof t)return t;{const s="getElementById"in i?i:void 0;if(!t.length)return null;if(s&&"#"===t[0])return s.getElementById(t.substring(1));if("#"===t[0]||"."===t[0]||"["===t[0])return i.querySelector(t);if(s&&!isNaN(+t[0]))return s.getElementById(t);let e=i.querySelector(t);return e=(e=s&&!e?s.getElementById(t):e)||i.querySelector("."+t)}}static shouldSizeToContent(e,t=!1){return e?.grid&&(t?!0===e.sizeToContent||!0===e.grid.opts.sizeToContent&&void 0===e.sizeToContent:!!e.sizeToContent||e.grid.opts.sizeToContent&&!1!==e.sizeToContent)}static isIntercepted(e,t){return!(e.y>=t.y+t.h||e.y+e.h<=t.y||e.x+e.w<=t.x||e.x>=t.x+t.w)}static isTouching(e,t){return v.isIntercepted(e,{x:t.x-.5,y:t.y-.5,w:t.w+1,h:t.h+1})}static areaIntercept(e,t){var i=(e.x>t.x?e:t).x,s=e.x+e.wt.y?e:t).y,e=e.y+e.hMath.max(t.x+t.w,e),0)||12,-1===t?e.sort((e,t)=>(t.x??1e3)+(t.y??1e3)*i-((e.x??1e3)+(e.y??1e3)*i)):e.sort((e,t)=>(e.x??1e3)+(e.y??1e3)*i-((t.x??1e3)+(t.y??1e3)*i))}static find(e,t){return t?e.find(e=>e.id===t):void 0}static createStylesheet(e,t,i){let s=document.createElement("style");i=i?.nonce;return i&&(s.nonce=i),s.setAttribute("type","text/css"),s.setAttribute("gs-style-id",e),s.styleSheet?s.styleSheet.cssText="":s.appendChild(document.createTextNode("")),t?t.insertBefore(s,t.firstChild):(t=document.getElementsByTagName("head")[0]).appendChild(s),s.sheet}static removeStylesheet(e,t){let i=(t||document).querySelector("STYLE[gs-style-id="+e+"]");i&&i.parentNode&&i.remove()}static addCSSRule(e,t,i){"function"==typeof e.addRule?e.addRule(t,i):"function"==typeof e.insertRule&&e.insertRule(t+`{${i}}`)}static toBool(e){return"boolean"==typeof e?e:"string"==typeof e?!(""===(e=e.toLowerCase())||"no"===e||"false"===e||"0"===e):Boolean(e)}static toNumber(e){return null===e||0===e.length?void 0:Number(e)}static parseHeight(e){let t,i="px";if("string"==typeof e)if("auto"===e||""===e)t=0;else{var s=e.match(/^(-[0-9]+\.[0-9]+|[0-9]*\.[0-9]+|-[0-9]+|[0-9]+)(px|em|rem|vh|vw|%|cm|mm)?$/);if(!s)throw new Error("Invalid height val = "+e);i=s[2]||"px",t=parseFloat(s[1])}else t=e;return{h:t,unit:i}}static defaults(i,...e){return e.forEach(e=>{for(const t in e){if(!e.hasOwnProperty(t))return;null===i[t]||void 0===i[t]?i[t]=e[t]:"object"==typeof e[t]&&"object"==typeof i[t]&&this.defaults(i[t],e[t])}}),i}static same(e,t){if("object"!=typeof e)return e==t;if(typeof e!=typeof t)return!1;if(Object.keys(e).length!==Object.keys(t).length)return!1;for(const i in e)if(e[i]!==t[i])return!1;return!0}static copyPos(e,t,i=!1){return void 0!==t.x&&(e.x=t.x),void 0!==t.y&&(e.y=t.y),void 0!==t.w&&(e.w=t.w),void 0!==t.h&&(e.h=t.h),i&&(t.minW&&(e.minW=t.minW),t.minH&&(e.minH=t.minH),t.maxW&&(e.maxW=t.maxW),t.maxH&&(e.maxH=t.maxH)),e}static samePos(e,t){return e&&t&&e.x===t.x&&e.y===t.y&&(e.w||1)===(t.w||1)&&(e.h||1)===(t.h||1)}static sanitizeMinMax(e){e.minW||delete e.minW,e.minH||delete e.minH,e.maxW||delete e.maxW,e.maxH||delete e.maxH}static removeInternalAndSame(t,i){if("object"==typeof t&&"object"==typeof i)for(var s in t){let e=t[s];if("_"===s[0]||e===i[s])delete t[s];else if(e&&"object"==typeof e&&void 0!==i[s]){for(var o in e)e[o]!==i[s][o]&&"_"!==o[0]||delete e[o];Object.keys(e).length||delete t[s]}}}static removeInternalForSave(e,t=!0){for(var i in e)"_"!==i[0]&&null!==e[i]&&void 0!==e[i]||delete e[i];delete e.grid,t&&delete e.el,e.autoPosition||delete e.autoPosition,e.noResize||delete e.noResize,e.noMove||delete e.noMove,e.locked||delete e.locked,1!==e.w&&e.w!==e.minW||delete e.w,1!==e.h&&e.h!==e.minH||delete e.h}static throttle(t,i){let s=!1;return(...e)=>{s||(s=!0,setTimeout(()=>{t(...e),s=!1},i))}}static removePositioningStyles(e){let t=e.style;t.position&&t.removeProperty("position"),t.left&&t.removeProperty("left"),t.top&&t.removeProperty("top"),t.width&&t.removeProperty("width"),t.height&&t.removeProperty("height")}static getScrollElement(e){if(!e)return document.scrollingElement||document.documentElement;var t=getComputedStyle(e);return/(auto|scroll)/.test(t.overflow+t.overflowY)?e:this.getScrollElement(e.parentElement)}static updateScrollPosition(s,o,n){var r,a=s.getBoundingClientRect(),l=window.innerHeight||document.documentElement.clientHeight;if(a.top<0||a.bottom>l){let e=a.bottom-l,t=a.top,i=this.getScrollElement(s);null!==i&&(r=i.scrollTop,a.top<0&&n<0?s.offsetHeight>l?i.scrollTop+=n:i.scrollTop+=Math.abs(t)>Math.abs(n)?n:t:0l?i.scrollTop+=n:i.scrollTop+=ne===s)&&(i[s]=v.cloneDeep(e[s]));return i}static cloneNode(e){const t=e.cloneNode(!0);return t.removeAttribute("id"),t}static appendTo(e,t){let i;(i="string"==typeof t?v.getElement(t):t)&&i.appendChild(e)}static addElStyles(t,e){if(e instanceof Object)for(const i in e)e.hasOwnProperty(i)&&(Array.isArray(e[i])?e[i].forEach(e=>{t.style[i]=e}):t.style[i]=e[i])}static initEvent(t,e){const i={type:e.type},s={button:0,which:0,buttons:1,bubbles:!0,cancelable:!0,target:e.target||t.target};return t.dataTransfer&&(i.dataTransfer=t.dataTransfer),["altKey","ctrlKey","metaKey","shiftKey"].forEach(e=>i[e]=t[e]),["pageX","pageY","clientX","clientY","screenX","screenY"].forEach(e=>i[e]=t[e]),{...i,...s}}static simulateMouseEvent(e,t,i){const s=document.createEvent("MouseEvents");s.initMouseEvent(t,!0,!0,window,1,e.screenX,e.screenY,e.clientX,e.clientY,e.ctrlKey,e.altKey,e.shiftKey,e.metaKey,0,e.target),(i||e.target).dispatchEvent(s)}static getValuesFromTransformedElement(e){const t=document.createElement("div");v.addElStyles(t,{opacity:"0",position:"fixed",top:"0px",left:"0px",width:"1px",height:"1px",zIndex:"-999999"}),e.appendChild(t);var i=t.getBoundingClientRect();return e.removeChild(t),t.remove(),{xScale:1/i.width,yScale:1/i.height,xOffset:i.left,yOffset:i.top}}}class d{constructor(e={}){this.addedNodes=[],this.removedNodes=[],this.column=e.column||12,this.maxRow=e.maxRow,this._float=e.float,this.nodes=e.nodes||[],this.onChange=e.onChange}batchUpdate(e=!0,t=!0){return!!this.batchMode!==e&&((this.batchMode=e)?(this._prevFloat=this._float,this._float=!0,this.cleanNodes(),this.saveInitial()):(this._float=this._prevFloat,delete this._prevFloat,t&&this._packNodes(),this._notify())),this}_useEntireRowArea(e,t){return(!this.float||this.batchMode&&!this._prevFloat)&&!this._hasLocked&&(!e._moving||e._skipDown||t.y<=e.y)}_fixCollisions(t,i=t,s,o={}){if(this.sortNodes(-1),!(s=s||this.collide(t,i)))return!1;if(t._moving&&!o.nested&&!this.float&&this.swap(t,s))return!0;let e=i,n=(this._useEntireRowArea(t,i)&&(e={x:0,w:this.column,y:i.y,h:i.h},s=this.collide(t,e,o.skip)),!1),r={nested:!0,pack:!1};for(;s=s||this.collide(t,e,o.skip);){let e;if(s.locked||t._moving&&!t._skipDown&&i.y>t.y&&!this.float&&(!this.collide(s,{...s,y:t.y},t)||!this.collide(s,{...s,y:i.y-s.h},t))?(t._skipDown=t._skipDown||i.y>t.y,e=this.moveNode(t,{...i,y:s.y+s.h,...r}),s.locked&&e?v.copyPos(i,t):!s.locked&&e&&o.pack&&(this._packNodes(),i.y=s.y+s.h,v.copyPos(t,i)),n=n||e):e=this.moveNode(s,{...s,y:i.y+i.h,skip:t,...r}),!e)return n;s=void 0}return n}collide(e,t=e,i){const s=e._id,o=i?._id;return this.nodes.find(e=>e._id!==s&&e._id!==o&&v.isIntercepted(e,t))}collideAll(e,t=e,i){const s=e._id,o=i?._id;return this.nodes.filter(e=>e._id!==s&&e._id!==o&&v.isIntercepted(e,t))}directionCollideCoverage(e,t,i){if(t.rect&&e._rect){let n,r=e._rect,a={...t.rect},l=(a.y>r.y?(a.h+=a.y-r.y,a.y=r.y):a.h+=r.y-a.y,a.x>r.x?(a.w+=a.x-r.x,a.x=r.x):a.w+=r.x-a.x,.5);return i.forEach(s=>{if(!s.locked&&s._rect){let e=s._rect,t=Number.MAX_VALUE,i=Number.MAX_VALUE;r.ye.y+e.h&&(t=(e.y+e.h-a.y)/e.h),r.xe.x+e.w&&(i=(e.x+e.w-a.x)/e.w);var o=Math.min(i,t);o>l&&(l=o,n=s)}}),t.collide=n}}cacheRects(t,i,s,o,n,r){return this.nodes.forEach(e=>e._rect={y:e.y*i+s,x:e.x*t+r,w:e.w*t-r-o,h:e.h*i-s-n}),this}swap(i,s){if(!s||s.locked||!i||i.locked)return!1;function e(){var e=s.x,t=s.y;return s.x=i.x,s.y=i.y,i.h!=s.h?(i.x=e,i.y=s.y+s.h):(i.w!=s.w?i.x=s.x+s.w:i.x=e,i.y=t),i._dirty=s._dirty=!0}let t;return i.w!==s.w||i.h!==s.h||i.x!==s.x&&i.y!==s.y||!(t=v.isTouching(i,s))?!1!==t?i.w===s.w&&i.x===s.x&&(t=t||v.isTouching(i,s))?(s.y{let s;e.locked||(e.autoPosition=!0,"list"===o&&t&&(s=i[t-1])),this.addNode(e,!1,s)}),t||delete this._inColumnResize,e||this.batchUpdate(!1),this}set float(e){this._float!==e&&(this._float=e||!1,e||this._packNodes()._notify())}get float(){return this._float||!1}sortNodes(e=1,t=this.column){return this.nodes=v.sort(this.nodes,e,t),this}_packNodes(){return this.batchMode||(this.sortNodes(),this.float?this.nodes.forEach(t=>{if(!t._updating&&void 0!==t._orig&&t.y!==t._orig.y){let e=t.y;for(;e>t._orig.y;)--e,this.collide(t,{x:t.x,y:e,w:t.w,h:t.h})||(t._dirty=!0,t.y=e)}}):this.nodes.forEach((e,t)=>{if(!e.locked)for(;0this.column&&this.column<12&&!this._inColumnResize&&t._id&&-1===this.findCacheLayout(t,12)){let e={...t};e.autoPosition||void 0===e.x?(delete e.x,delete e.y):e.x=Math.min(11,e.x),e.w=Math.min(12,e.w||1),this.cacheOneLayout(e,12)}return t.w>this.column?t.w=this.column:t.w<1&&(t.w=1),this.maxRow&&t.h>this.maxRow?t.h=this.maxRow:t.h<1&&(t.h=1),t.x<0&&(t.x=0),t.y<0&&(t.y=0),t.x+t.w>this.column&&(e?t.w=this.column-t.x:t.x=this.column-t.w),this.maxRow&&t.y+t.h>this.maxRow&&(e?t.h=this.maxRow-t.y:t.y=this.maxRow-t.h),v.samePos(t,i)||(t._dirty=!0),this}getDirtyNodes(e){return e?this.nodes.filter(e=>e._dirty&&!v.samePos(e,e._orig)):this.nodes.filter(e=>e._dirty)}_notify(e){if(this.batchMode||!this.onChange)return this;e=(e||[]).concat(this.getDirtyNodes());return this.onChange(e),this}cleanNodes(){return this.batchMode||this.nodes.forEach(e=>{delete e._dirty,delete e._lastTried}),this}saveInitial(){return this.nodes.forEach(e=>{e._orig=v.copyPos({},e),delete e._dirty}),this._hasLocked=this.nodes.some(e=>e.locked),this}restoreInitial(){return this.nodes.forEach(e=>{v.samePos(e,e._orig)||(v.copyPos(e,e._orig),e._dirty=!0)}),this._notify(),this}findEmptyPosition(i,s=this.nodes,t=this.column,o){let n=!1;for(let e=o?o.y*t+(o.x+o.w):0;!n;++e){var r=e%t,a=Math.floor(e/t);if(!(r+i.w>t)){let t={x:r,y:a,w:i.w,h:i.h};s.find(e=>v.isIntercepted(t,e))||(i.x===r&&i.y===a||(i._dirty=!0),i.x=r,i.y=a,delete i.autoPosition,n=!0)}}return n}addNode(t,e=!1,i){let s;return this.nodes.find(e=>e._id===t._id)||(this._inColumnResize?this.nodeBoundFix(t):this.prepareNode(t),delete t._temporaryRemoved,delete t._removeDOM,t.autoPosition&&this.findEmptyPosition(t,this.nodes,this.column,i)&&(delete t.autoPosition,s=!0),this.nodes.push(t),e&&this.addedNodes.push(t),s||this._fixCollisions(t),this.batchMode||this._packNodes()._notify(),t)}removeNode(t,e=!0,i=!1){return this.nodes.find(e=>e._id===t._id)&&(i&&this.removedNodes.push(t),e&&(t._removeDOM=!0),this.nodes=this.nodes.filter(e=>e._id!==t._id),t._isAboutToRemove||this._packNodes(),this._notify([t])),this}removeAll(e=!0){return delete this._layouts,this.nodes.length?(e&&this.nodes.forEach(e=>e._removeDOM=!0),this.removedNodes=this.nodes,this.nodes=[],this._notify(this.removedNodes)):this}moveNodeCheck(t,e){if(!this.changedPosConstrain(t,e))return!1;if(e.pack=!0,!this.maxRow)return this.moveNode(t,e);let i,s=new d({column:this.column,float:this.float,nodes:this.nodes.map(e=>e._id===t._id?i={...e}:{...e})});if(!i)return!1;var o=s.moveNode(i,e)&&s.getRow()<=Math.max(this.getRow(),this.maxRow);if(!o&&!e.resizing&&e.collide){e=e.collide.el.gridstackNode;if(this.swap(t,e))return this._notify(),!0}return!!o&&(s.nodes.filter(e=>e._dirty).forEach(t=>{let e=this.nodes.find(e=>e._id===t._id);e&&(v.copyPos(e,t),e._dirty=!0)}),this._notify(),!0)}willItFit(e){if(delete e._willFitPos,!this.maxRow)return!0;let t=new d({column:this.column,float:this.float,nodes:this.nodes.map(e=>({...e}))}),i={...e};return this.cleanupNode(i),delete i.el,delete i._id,delete i.content,delete i.grid,t.addNode(i),t.getRow()<=this.maxRow&&(e._willFitPos=v.copyPos({},i),!0)}changedPosConstrain(e,t){return t.w=t.w||e.w,t.h=t.h||e.h,e.x!==t.x||e.y!==t.y||(e.maxW&&(t.w=Math.min(t.w,e.maxW)),e.maxH&&(t.h=Math.min(t.h,e.maxH)),e.minW&&(t.w=Math.max(t.w,e.minW)),e.minH&&(t.h=Math.max(t.h,e.minH)),e.w!==t.w||e.h!==t.h)}moveNode(i,s){if(!i||!s)return!1;let o;void 0!==s.pack||this.batchMode||(o=s.pack=!0),"number"!=typeof s.x&&(s.x=i.x),"number"!=typeof s.y&&(s.y=i.y),"number"!=typeof s.w&&(s.w=i.w),"number"!=typeof s.h&&(s.h=i.h);var n,r=i.w!==s.w||i.h!==s.h,a=v.copyPos({},i,!0);if(v.copyPos(a,s),this.nodeBoundFix(a,r),v.copyPos(s,a),!s.forceCollide&&v.samePos(i,s))return!1;let e=v.copyPos({},i),l=this.collideAll(i,a,s.skip),h=!0;if(l.length){let e=i._moving&&!s.nested,t=e?this.directionCollideCoverage(i,s,l):l[0];e&&t&&i.grid?.opts?.subGridDynamic&&!i.grid._isTemp&&(.8Math.max(e,t.y+t.h),0)}beginUpdate(e){return e._updating||(e._updating=!0,delete e._skipDown,this.batchMode||this.saveInitial()),this}endUpdate(){let e=this.nodes.find(e=>e._updating);return e&&(delete e._updating,delete e._skipDown),this}save(i=!0,s){let e=this._layouts?.length,o=e&&this.column!==e-1?this._layouts[e-1]:null,n=[];return this.sortNodes(),this.nodes.forEach(t=>{var e=o?.find(e=>e._id===t._id),e={...t,...e||{}};v.removeInternalForSave(e,!i),s&&s(t,e),n.push(e)}),n}layoutsNodesChange(t){return this._layouts&&!this._inColumnResize&&this._layouts.forEach((s,e)=>{if(!s||e===this.column)return this;if(e{if(t._orig){let e=s.find(e=>e._id===t._id);e&&(0<=e.y&&t.y!==t._orig.y&&(e.y+=t.y-t._orig.y),t.x!==t._orig.x&&(e.x=Math.round(t.x*i)),t.w!==t._orig.w&&(e.w=Math.round(t.w*i)))}})}}),this}columnChanged(o,n,e="moveScale"){if(!this.nodes.length||!n||o===n)return this;if("none"===e)return this;const r="compact"===e||"list"===e;r&&this.sortNodes(1,o),n{let e=l.find(e=>e._id===t._id);e&&(r||t.autoPosition||(e.x=t.x??e.x,e.y=t.y??e.y),e.w=t.w??e.w,null!=t.x&&void 0!==t.y||(e.autoPosition=!0))})),v.forEach(t=>{var e=l.findIndex(e=>e._id===t._id);if(-1!==e){const i=l[e];r?i.w=t.w:((t.autoPosition||isNaN(t.x)||isNaN(t.y))&&this.findEmptyPosition(t,a),t.autoPosition||(i.x=t.x??i.x,i.y=t.y??i.y,i.w=t.w??i.w,a.push(i)),l.splice(e,1))}})}if(r)this.compact(e,!1);else{if(l.length)if("function"==typeof e)e(n,o,a,l);else{let t=r?1:n/o,i="move"===e||"moveScale"===e,s="scale"===e||"moveScale"===e;l.forEach(e=>{e.x=1===n?0:i?Math.round(e.x*t):Math.min(e.x,n-1),e.w=1===n||1===o?1:s?Math.round(e.w*t)||1:Math.min(e.w,n),a.push(e)}),l=[]}a=v.sort(a,-1,n),this._inColumnResize=!0,this.nodes=[],a.forEach(e=>{this.addNode(e,!1),delete e._orig})}return this.nodes.forEach(e=>delete e._orig),this.batchUpdate(!1,!r),delete this._inColumnResize,this}cacheLayout(e,t,i=!1){let s=[];return e.forEach((t,e)=>{if(void 0===t._id){const e=t.id?this.nodes.find(e=>e.id===t.id):void 0;t._id=e?._id??d._idSeq++}s[e]={x:t.x,y:t.y,w:t.w,_id:t._id}}),this._layouts=!i&&this._layouts||[],this._layouts[t]=s,this}cacheOneLayout(e,t){e._id=e._id??d._idSeq++;let i={x:e.x,y:e.y,w:e.w,_id:e._id};!e.autoPosition&&void 0!==e.x||(delete i.x,delete i.y,e.autoPosition&&(i.autoPosition=!0)),this._layouts=this._layouts||[],this._layouts[t]=this._layouts[t]||[];e=this.findCacheLayout(e,t);return-1===e?this._layouts[t].push(i):this._layouts[t][e]=i,this}findCacheLayout(t,e){return this._layouts?.[e]?.findIndex(e=>e._id===t._id)??-1}removeNodeFromLayoutCache(t){if(this._layouts)for(let e=0;e{delete i.pointerLeaveTimeout,t(e,"mouseleave")},10))}class f{constructor(e,t,i){this.host=e,this.dir=t,this.option=i,this.moving=!1,this._mouseDown=this._mouseDown.bind(this),this._mouseMove=this._mouseMove.bind(this),this._mouseUp=this._mouseUp.bind(this),this._init()}_init(){const e=this.el=document.createElement("div");return e.classList.add("ui-resizable-handle"),e.classList.add(""+f.prefix+this.dir),e.style.zIndex="100",e.style.userSelect="none",this.host.appendChild(this.el),this.el.addEventListener("mousedown",this._mouseDown),c&&(this.el.addEventListener("touchstart",r),this.el.addEventListener("pointerdown",h)),this}destroy(){return this.moving&&this._mouseUp(this.mouseDownEvent),this.el.removeEventListener("mousedown",this._mouseDown),c&&(this.el.removeEventListener("touchstart",r),this.el.removeEventListener("pointerdown",h)),this.host.removeChild(this.el),delete this.el,delete this.host,this}_mouseDown(e){this.mouseDownEvent=e,document.addEventListener("mousemove",this._mouseMove,{capture:!0,passive:!0}),document.addEventListener("mouseup",this._mouseUp,!0),c&&(this.el.addEventListener("touchmove",a),this.el.addEventListener("touchend",l)),e.stopPropagation(),e.preventDefault()}_mouseMove(e){var t=this.mouseDownEvent;this.moving?this._triggerEvent("move",e):2{var e=this.el.parentElement.getBoundingClientRect(),t={width:this.originalRect.width,height:this.originalRect.height+this.scrolled,left:this.originalRect.left,top:this.originalRect.top-this.scrolled},t=this.temporalRect||t;return{position:{left:(t.left-e.left)*this.rectScale.x,top:(t.top-e.top)*this.rectScale.y},size:{width:t.width*this.rectScale.x,height:t.height*this.rectScale.y}}},this._mouseOver=this._mouseOver.bind(this),this._mouseOut=this._mouseOut.bind(this),this.enable(),this._setupAutoHide(this.option.autoHide),this._setupHandlers()}on(e,t){super.on(e,t)}off(e){super.off(e)}enable(){super.enable(),this.el.classList.remove("ui-resizable-disabled"),this._setupAutoHide(this.option.autoHide)}disable(){super.disable(),this.el.classList.add("ui-resizable-disabled"),this._setupAutoHide(!1)}destroy(){this._removeHandlers(),this._setupAutoHide(!1),delete this.el,super.destroy()}updateOption(t){var e=t.handles&&t.handles!==this.option.handles,i=t.autoHide&&t.autoHide!==this.option.autoHide;return Object.keys(t).forEach(e=>this.option[e]=t[e]),e&&(this._removeHandlers(),this._setupHandlers()),i&&this._setupAutoHide(this.option.autoHide),this}_setupAutoHide(e){return e?(this.el.classList.add("ui-resizable-autohide"),this.el.addEventListener("mouseover",this._mouseOver),this.el.addEventListener("mouseout",this._mouseOut)):(this.el.classList.remove("ui-resizable-autohide"),this.el.removeEventListener("mouseover",this._mouseOver),this.el.removeEventListener("mouseout",this._mouseOut),p.overResizeElement===this&&delete p.overResizeElement),this}_mouseOver(e){p.overResizeElement||p.dragElement||(p.overResizeElement=this).el.classList.remove("ui-resizable-autohide")}_mouseOut(e){p.overResizeElement===this&&(delete p.overResizeElement,this.el.classList.add("ui-resizable-autohide"))}_setupHandlers(){return this.handlers=this.option.handles.split(",").map(e=>e.trim()).map(t=>new f(this.el,t,{start:e=>{this._resizeStart(e)},stop:e=>{this._resizeStop(e)},move:e=>{this._resizing(e,t)}})),this}_resizeStart(e){this.sizeToContent=v.shouldSizeToContent(this.el.gridstackNode,!0),this.originalRect=this.el.getBoundingClientRect(),this.scrollEl=v.getScrollElement(this.el),this.scrollY=this.scrollEl.scrollTop,this.scrolled=0,this.startEvent=e,this._setupHelper(),this._applyChange();e=v.initEvent(e,{type:"resizestart",target:this.el});return this.option.start&&this.option.start(e,this._ui()),this.el.classList.add("ui-resizable-resizing"),this.triggerEvent("resizestart",e),this}_resizing(e,t){this.scrolled=this.scrollEl.scrollTop-this.scrollY,this.temporalRect=this._getChange(e,t),this._applyChange();t=v.initEvent(e,{type:"resize",target:this.el});return this.option.resize&&this.option.resize(t,this._ui()),this.triggerEvent("resize",t),this}_resizeStop(e){e=v.initEvent(e,{type:"resizestop",target:this.el});return this.option.stop&&this.option.stop(e),this.el.classList.remove("ui-resizable-resizing"),this.triggerEvent("resizestop",e),this._cleanHelper(),delete this.startEvent,delete this.originalRect,delete this.temporalRect,delete this.scrollY,delete this.scrolled,this}_setupHelper(){this.elOriginStyleVal=_._originStyleProp.map(e=>this.el.style[e]),this.parentOriginStylePosition=this.el.parentElement.style.position;var e=this.el.parentElement,e=v.getValuesFromTransformedElement(e);return this.rectScale={x:e.xScale,y:e.yScale},getComputedStyle(this.el.parentElement).position.match(/static/)&&(this.el.parentElement.style.position="relative"),this.el.style.position="absolute",this.el.style.opacity="0.8",this}_cleanHelper(){return _._originStyleProp.forEach((e,t)=>{this.el.style[e]=this.elOriginStyleVal[t]||null}),this.el.parentElement.style.position=this.parentOriginStylePosition||null,this}_getChange(e,t){const i=this.startEvent,s={width:this.originalRect.width,height:this.originalRect.height+this.scrolled,left:this.originalRect.left,top:this.originalRect.top-this.scrolled},o=e.clientX-i.clientX,n=this.sizeToContent?0:e.clientY-i.clientY;-1{var t=this.temporalRect[e],i="width"===e||"left"===e?this.rectScale.x:"height"===e||"top"===e?this.rectScale.y:1;this.el.style[e]=(t-s[e])*i+"px"}),this}_removeHandlers(){return this.handlers.forEach(e=>e.destroy()),delete this.handlers,this}}_._originStyleProp=["width","height","position","left","top","opacity","zIndex"];class b extends y{constructor(e,t={}){super(),this.el=e,this.option=t,this.dragTransform={xScale:1,yScale:1,xOffset:0,yOffset:0};var i=t.handle.substring(1);this.dragEl=!e.classList.contains(i)&&e.querySelector(t.handle)||e,this._mouseDown=this._mouseDown.bind(this),this._mouseMove=this._mouseMove.bind(this),this._mouseUp=this._mouseUp.bind(this),this.enable()}on(e,t){super.on(e,t)}off(e){super.off(e)}enable(){!1!==this.disabled&&(super.enable(),this.dragEl.addEventListener("mousedown",this._mouseDown),c&&(this.dragEl.addEventListener("touchstart",r),this.dragEl.addEventListener("pointerdown",h)),this.el.classList.remove("ui-draggable-disabled"))}disable(e=!1){!0!==this.disabled&&(super.disable(),this.dragEl.removeEventListener("mousedown",this._mouseDown),c&&(this.dragEl.removeEventListener("touchstart",r),this.dragEl.removeEventListener("pointerdown",h)),e||this.el.classList.add("ui-draggable-disabled"))}destroy(){this.dragTimeout&&window.clearTimeout(this.dragTimeout),delete this.dragTimeout,this.mouseDownEvent&&this._mouseUp(this.mouseDownEvent),this.disable(!0),delete this.el,delete this.helper,delete this.option,super.destroy()}updateOption(t){return Object.keys(t).forEach(e=>this.option[e]=t[e]),this}_mouseDown(e){if(!p.mouseHandled)return 0!==e.button||e.target.closest('input,textarea,button,select,option,[contenteditable="true"],.ui-resizable-handle')||this.option.cancel&&e.target.closest(this.option.cancel)||(this.mouseDownEvent=e,delete this.dragging,delete p.dragElement,delete p.dropElement,document.addEventListener("mousemove",this._mouseMove,{capture:!0,passive:!0}),document.addEventListener("mouseup",this._mouseUp,!0),c&&(this.dragEl.addEventListener("touchmove",a),this.dragEl.addEventListener("touchend",l)),e.preventDefault(),document.activeElement&&document.activeElement.blur(),p.mouseHandled=!0),!0}_callDrag(e){this.dragging&&(e=v.initEvent(e,{target:this.el,type:"drag"}),this.option.drag&&this.option.drag(e,this.ui()),this.triggerEvent("drag",e))}_mouseMove(e){let t=this.mouseDownEvent;var i;if(this.dragging)if(this._dragFollow(e),p.pauseDrag){const t=Number.isInteger(p.pauseDrag)?p.pauseDrag:100;this.dragTimeout&&window.clearTimeout(this.dragTimeout),this.dragTimeout=window.setTimeout(()=>this._callDrag(e),t)}else this._callDrag(e);else 3this.el.style[e])),t}_setupHelperStyle(e){this.helper.classList.add("ui-draggable-dragging");const t=this.helper.style;return t.pointerEvents="none",t.width=this.dragOffset.width+"px",t.height=this.dragOffset.height+"px",t.willChange="left, top",t.position="fixed",this._dragFollow(e),t.transition="none",setTimeout(()=>{this.helper&&(t.transition=null)},0),this}_removeHelperStyle(){if(this.helper.classList.remove("ui-draggable-dragging"),!(this.helper?.gridstackNode)?._isAboutToRemove&&this.dragElementOriginStyle){let t=this.helper,e=this.dragElementOriginStyle.transition||null;t.style.transition=this.dragElementOriginStyle.transition="none",b.originStyleProp.forEach(e=>t.style[e]=this.dragElementOriginStyle[e]||null),setTimeout(()=>t.style.transition=e,50)}return delete this.dragElementOriginStyle,this}_dragFollow(e){const t=this.helper.style,i=this.dragOffset;t.left=+(e.clientX+i.offsetLeft)*this.dragTransform.xScale+"px",t.top=+(e.clientY+i.offsetTop)*this.dragTransform.yScale+"px"}_setupHelperContainmentStyle(){return this.helperContainment=this.helper.parentElement,"fixed"!==this.helper.style.position&&(this.parentOriginStylePosition=this.helperContainment.style.position,getComputedStyle(this.helperContainment).position.match(/static/)&&(this.helperContainment.style.position="relative")),this}_getDragOffset(e,t,i){let s=0,o=0;i&&(s=this.dragTransform.xOffset,o=this.dragTransform.yOffset);i=t.getBoundingClientRect();return{left:i.left,top:i.top,offsetLeft:-e.clientX+i.left-s,offsetTop:-e.clientY+i.top-o,width:i.width*this.dragTransform.xScale,height:i.height*this.dragTransform.yScale}}ui(){var e=this.el.parentElement.getBoundingClientRect(),t=this.helper.getBoundingClientRect();return{position:{top:(t.top-e.top)*this.dragTransform.yScale,left:(t.left-e.left)*this.dragTransform.xScale}}}}b.originStyleProp=["transition","pointerEvents","position","left","top","minWidth","willChange"];class w extends y{constructor(e,t={}){super(),this.el=e,this.option=t,this._mouseEnter=this._mouseEnter.bind(this),this._mouseLeave=this._mouseLeave.bind(this),this.enable(),this._setupAccept()}on(e,t){super.on(e,t)}off(e){super.off(e)}enable(){!1!==this.disabled&&(super.enable(),this.el.classList.add("ui-droppable"),this.el.classList.remove("ui-droppable-disabled"),this.el.addEventListener("mouseenter",this._mouseEnter),this.el.addEventListener("mouseleave",this._mouseLeave),c&&(this.el.addEventListener("pointerenter",u),this.el.addEventListener("pointerleave",m)))}disable(e=!1){!0!==this.disabled&&(super.disable(),this.el.classList.remove("ui-droppable"),e||this.el.classList.add("ui-droppable-disabled"),this.el.removeEventListener("mouseenter",this._mouseEnter),this.el.removeEventListener("mouseleave",this._mouseLeave),c&&(this.el.removeEventListener("pointerenter",u),this.el.removeEventListener("pointerleave",m)))}destroy(){this.disable(!0),this.el.classList.remove("ui-droppable"),this.el.classList.remove("ui-droppable-disabled"),super.destroy()}updateOption(t){return Object.keys(t).forEach(e=>this.option[e]=t[e]),this._setupAccept(),this}_mouseEnter(e){p.dragElement&&this._canDrop(p.dragElement.el)&&(e.preventDefault(),e.stopPropagation(),p.dropElement&&p.dropElement!==this&&p.dropElement._mouseLeave(e,!0),p.dropElement=this,e=v.initEvent(e,{target:this.el,type:"dropover"}),this.option.over&&this.option.over(e,this._ui(p.dragElement)),this.triggerEvent("dropover",e),this.el.classList.add("ui-droppable-over"))}_mouseLeave(i,e=!1){if(p.dragElement&&p.dropElement===this){i.preventDefault(),i.stopPropagation();var t=v.initEvent(i,{target:this.el,type:"dropout"});if(this.option.out&&this.option.out(t,this._ui(p.dragElement)),this.triggerEvent("dropout",t),p.dropElement===this&&(delete p.dropElement,!e)){let e,t=this.el.parentElement;for(;!e&&t;)e=t.ddElement?.ddDroppable,t=t.parentElement;e&&e._mouseEnter(i)}}}drop(e){e.preventDefault();e=v.initEvent(e,{target:this.el,type:"drop"});this.option.drop&&this.option.drop(e,this._ui(p.dragElement)),this.triggerEvent("drop",e)}_canDrop(e){return e&&(!this.accept||this.accept(e))}_setupAccept(){return this.option.accept&&("string"==typeof this.option.accept?this.accept=e=>e.classList.contains(this.option.accept)||e.matches(this.option.accept):this.accept=this.option.accept),this}_ui(e){return{draggable:e.el,...e.ui()}}}class E{static init(e){return e.ddElement||(e.ddElement=new E(e)),e.ddElement}constructor(e){this.el=e}on(e,t){return this.ddDraggable&&-1<["drag","dragstart","dragstop"].indexOf(e)?this.ddDraggable.on(e,t):this.ddDroppable&&-1<["drop","dropover","dropout"].indexOf(e)?this.ddDroppable.on(e,t):this.ddResizable&&-1<["resizestart","resize","resizestop"].indexOf(e)&&this.ddResizable.on(e,t),this}off(e){return this.ddDraggable&&-1<["drag","dragstart","dragstop"].indexOf(e)?this.ddDraggable.off(e):this.ddDroppable&&-1<["drop","dropover","dropout"].indexOf(e)?this.ddDroppable.off(e):this.ddResizable&&-1<["resizestart","resize","resizestop"].indexOf(e)&&this.ddResizable.off(e),this}setupDraggable(e){return this.ddDraggable?this.ddDraggable.updateOption(e):this.ddDraggable=new b(this.el,e),this}cleanDraggable(){return this.ddDraggable&&(this.ddDraggable.destroy(),delete this.ddDraggable),this}setupResizable(e){return this.ddResizable?this.ddResizable.updateOption(e):this.ddResizable=new _(this.el,e),this}cleanResizable(){return this.ddResizable&&(this.ddResizable.destroy(),delete this.ddResizable),this}setupDroppable(e){return this.ddDroppable?this.ddDroppable.updateOption(e):this.ddDroppable=new w(this.el,e),this}cleanDroppable(){return this.ddDroppable&&(this.ddDroppable.destroy(),delete this.ddDroppable),this}}const x=new class{resizable(e,s,o,n){return this._getDDElements(e).forEach(t=>{if("disable"===s||"enable"===s)t.ddResizable&&t.ddResizable[s]();else if("destroy"===s)t.ddResizable&&t.cleanResizable();else if("option"===s)t.setupResizable({[o]:n});else{const o=t.el.gridstackNode.grid;let e=t.el.getAttribute("gs-resize-handles")||o.opts.resizable.handles||"e,s,se";"all"===e&&(e="n,e,s,w,se,sw,ne,nw");var i=!o.opts.alwaysShowResizeHandle;t.setupResizable({...o.opts.resizable,handles:e,autoHide:i,start:s.start,stop:s.stop,resize:s.resize})}}),this}draggable(e,t,i,s){return this._getDDElements(e).forEach(e=>{if("disable"===t||"enable"===t)e.ddDraggable&&e.ddDraggable[t]();else if("destroy"===t)e.ddDraggable&&e.cleanDraggable();else if("option"===t)e.setupDraggable({[i]:s});else{const i=e.el.gridstackNode.grid;e.setupDraggable({...i.opts.draggable,start:t.start,stop:t.stop,drag:t.drag})}}),this}dragIn(e,t){return this._getDDElements(e).forEach(e=>e.setupDraggable(t)),this}droppable(e,t,i,s){return"function"!=typeof t.accept||t._accept||(t._accept=t.accept,t.accept=e=>t._accept(e)),this._getDDElements(e).forEach(e=>{"disable"===t||"enable"===t?e.ddDroppable&&e.ddDroppable[t]():"destroy"===t?e.ddDroppable&&e.cleanDroppable():"option"===t?e.setupDroppable({[i]:s}):e.setupDroppable(t)}),this}isDroppable(e){return!(!(e&&e.ddElement&&e.ddElement.ddDroppable)||e.ddElement.ddDroppable.disabled)}isDraggable(e){return!(!(e&&e.ddElement&&e.ddElement.ddDraggable)||e.ddElement.ddDraggable.disabled)}isResizable(e){return!(!(e&&e.ddElement&&e.ddElement.ddResizable)||e.ddElement.ddResizable.disabled)}on(e,t,i){return this._getDDElements(e).forEach(e=>e.on(t,e=>{i(e,p.dragElement?p.dragElement.el:e.target,p.dragElement?p.dragElement.helper:null)})),this}off(e,t){return this._getDDElements(e).forEach(e=>e.off(t)),this}_getDDElements(e,t=!0){let i=v.getElements(e);if(!i.length)return[];let s=i.map(e=>e.ddElement||(t?E.init(e):null));return t||s.filter(e=>e),s}};class C{static init(e={},t=".grid-stack"){if("undefined"==typeof document)return null;let i=C.getGridElement(t);return i?(i.gridstack||(i.gridstack=new C(i,v.cloneDeep(e))),i.gridstack):("string"==typeof t?console.error('GridStack.initAll() no grid was found with selector "'+t+'" - element missing or wrong selector ?\nNote: ".grid-stack" is required for proper CSS styling and drag/drop, and is the default selector.'):console.error("GridStack.init() no grid element was passed."),null)}static initAll(t={},e=".grid-stack"){let i=[];return"undefined"!=typeof document&&(C.getGridElements(e).forEach(e=>{e.gridstack||(e.gridstack=new C(e,v.cloneDeep(t))),i.push(e.gridstack)}),0===i.length&&console.error('GridStack.initAll() no grid was found with selector "'+e+'" - element missing or wrong selector ?\nNote: ".grid-stack" is required for proper CSS styling and drag/drop, and is the default selector.')),i}static addGrid(t,i={}){if(!t)return null;let s=t;if(s.gridstack){const t=s.gridstack;return i&&(t.opts={...t.opts,...i}),void 0!==i.children&&t.load(i.children),t}if(!t.classList.contains("grid-stack")||C.addRemoveCB)if(C.addRemoveCB)s=C.addRemoveCB(t,i,!0,!0);else{let e=document.implementation.createHTMLDocument("");e.body.innerHTML=`
`,s=e.body.children[0],t.appendChild(s)}return C.init(i,s)}static registerEngine(e){C.engineClass=e}get placeholder(){if(!this._placeholder){let e=document.createElement("div");e.className="placeholder-content",this.opts.placeholderText&&(e.innerHTML=this.opts.placeholderText),this._placeholder=document.createElement("div"),this._placeholder.classList.add(this.opts.placeholderClass,g.itemClass,this.opts.itemClass),this.placeholder.appendChild(e)}return this._placeholder}constructor(t,i={}){this.el=t,this.opts=i,this._gsEventHandler={},this._extraDragRow=0,this.dragTransform={xScale:1,yScale:1,xOffset:0,yOffset:0},t.gridstack=this,i=i||{},t.classList.contains("grid-stack")||this.el.classList.add("grid-stack"),i.row&&(i.minRow=i.maxRow=i.row,delete i.row);var e=v.toNumber(t.getAttribute("gs-row"));"auto"===i.column&&delete i.column,void 0!==i.alwaysShowResizeHandle&&(i._alwaysShowResizeHandle=i.alwaysShowResizeHandle);let s=i.columnOpts?.breakpoints;const o=i;if(o.oneColumnModeDomSort&&(delete o.oneColumnModeDomSort,console.log("warning: Gridstack oneColumnModeDomSort no longer supported. Use GridStackOptions.columnOpts instead.")),o.oneColumnSize||!1===o.disableOneColumnMode){const t=o.oneColumnSize||768;delete o.oneColumnSize,delete o.disableOneColumnMode,i.columnOpts=i.columnOpts||{};let e=(s=i.columnOpts.breakpoints=i.columnOpts.breakpoints||[]).find(e=>1===e.c);e?e.w=t:(e={c:1,w:t},s.push(e,{c:12,w:t+1}))}const n=i.columnOpts;n&&(n.columnWidth||n.breakpoints?.length?n.columnMax=n.columnMax||12:(delete i.columnOpts,s=void 0)),1(t.w||0)-(e.w||0));let r={...v.cloneDeep(g),column:v.toNumber(t.getAttribute("gs-column"))||g.column,minRow:e||v.toNumber(t.getAttribute("gs-min-row"))||g.minRow,maxRow:e||v.toNumber(t.getAttribute("gs-max-row"))||g.maxRow,staticGrid:v.toBool(t.getAttribute("gs-static"))||g.staticGrid,draggable:{handle:(i.handleClass?"."+i.handleClass:i.handle||"")||g.draggable.handle},removableOptions:{accept:i.itemClass||g.removableOptions.accept,decline:g.removableOptions.decline}};t.getAttribute("gs-animate")&&(r.animate=v.toBool(t.getAttribute("gs-animate"))),i=v.defaults(i,r),this._initMargin(),this.checkDynamicColumn(),this.el.classList.add("gs-"+i.column),"auto"===i.rtl&&(i.rtl="rtl"===t.style.direction),i.rtl&&this.el.classList.add("grid-stack-rtl");const a=this.el.parentElement?.parentElement;let l=a?.classList.contains(g.itemClass)?a.gridstackNode:void 0,h=(l&&((l.subGrid=this).parentGridItem=l,this.el.classList.add("grid-stack-nested"),l.el.classList.add("grid-stack-sub-grid")),this._isAutoCellHeight="auto"===i.cellHeight,this._isAutoCellHeight||"initial"===i.cellHeight?this.cellHeight(void 0,!1):("number"==typeof i.cellHeight&&i.cellHeightUnit&&i.cellHeightUnit!==g.cellHeightUnit&&(i.cellHeight=i.cellHeight+i.cellHeightUnit,delete i.cellHeightUnit),this.cellHeight(i.cellHeight,!1)),"mobile"===i.alwaysShowResizeHandle&&(i.alwaysShowResizeHandle=c),this._styleSheetClass="gs-id-"+d._idSeq++,this.el.classList.add(this._styleSheetClass),this._setStaticClass(),i.engineClass||C.engineClass||d);this.engine=new h({column:this.getColumn(),float:i.float,maxRow:i.maxRow,onChange:e=>{let t=0;this.engine.nodes.forEach(e=>{t=Math.max(t,e.y+e.h)}),e.forEach(e=>{let t=e.el;t&&(e._removeDOM?(t&&t.remove(),delete e._removeDOM):this._writePosAttr(t,e))}),this._updateStyles(!1,t)}}),this._updateStyles(!1,0),i.auto&&(this.batchUpdate(),this.getGridItems().forEach(e=>this._prepareElement(e)),this.batchUpdate(!1)),i.children&&(e=i.children,delete i.children,e.length&&this.load(e)),this.setAnimation(i.animate),i.subGridDynamic&&!p.pauseDrag&&(p.pauseDrag=!0),void 0!==i.draggable?.pause&&(p.pauseDrag=i.draggable.pause),this._setupRemoveDrop(),this._setupAcceptWidget(),this._updateResizeEvent()}addWidget(t,i){let s,o;if("string"==typeof t){let e=document.implementation.createHTMLDocument("");e.body.innerHTML=t,s=e.body.children[0]}else if(0===arguments.length||1===arguments.length&&(void 0!==t.el||void 0!==t.x||void 0!==t.y||void 0!==t.w||void 0!==t.h||void 0!==t.content))if((o=i=t)?.el)s=o.el;else if(C.addRemoveCB)s=C.addRemoveCB(this.el,i,!0,!1);else{let e=i?.content||"",t=document.implementation.createHTMLDocument("");t.body.innerHTML=`
${e}
`,s=t.body.children[0]}else s=t;if(s){if((o=s.gridstackNode)&&s.parentElement===this.el&&this.engine.nodes.find(e=>e._id===o._id))return s;t=this._readAttr(s);return i=v.cloneDeep(i)||{},v.defaults(i,t),o=this.engine.prepareNode(i),this._writeAttr(s,i),this._insertNotAppend?this.el.prepend(s):this.el.appendChild(s),this.makeWidget(s,i),s}}makeSubGrid(e,s,o,t=!0){let i,n=e.gridstackNode;if((n=n||this.makeWidget(e).gridstackNode).subGrid?.el)return n.subGrid;let r,a=this;for(;a&&!i;)i=a.opts?.subGridOpts,a=a.parentGridItem?.grid;s=v.cloneDeep({...i||{},children:void 0,...s||n.subGridOpts||{}}),"auto"===(n.subGridOpts=s).column&&(r=!0,s.column=Math.max(n.w||1,o?.w||1),delete s.columnOpts);let l,h,d=n.el.querySelector(".grid-stack-item-content");if(t){if(this._removeDD(n.el),h={...n,x:0,y:0},v.removeInternalForSave(h),delete h.subGridOpts,n.content&&(h.content=n.content,delete n.content),C.addRemoveCB)l=C.addRemoveCB(this.el,h,!0,!1);else{let e=document.implementation.createHTMLDocument("");e.body.innerHTML='
',(l=e.body.children[0]).appendChild(d),e.body.innerHTML='
',d=e.body.children[0],n.el.appendChild(d)}this._prepareDragDropByNode(n)}if(o){let e=r?s.column:n.w,t=n.h+o.h,i=n.el.style;i.transition="none",this.update(n.el,{w:e,h:t}),setTimeout(()=>i.transition=null)}let g=n.subGrid=C.addGrid(d,s);return o?._moving&&(g._isTemp=!0),r&&(g._autoColumn=!0),t&&g.addWidget(l,h),o&&(o._moving?window.setTimeout(()=>v.simulateMouseEvent(o._event,"mouseenter",g.el),0):g.addWidget(n.el,n)),g}removeAsSubGrid(e){let t=this.parentGridItem?.grid;t&&(t.batchUpdate(),t.removeWidget(this.parentGridItem.el,!0,!0),this.engine.nodes.forEach(e=>{e.x+=this.parentGridItem.x,e.y+=this.parentGridItem.y,t.addWidget(e.el,e)}),t.batchUpdate(!1),this.parentGridItem&&delete this.parentGridItem.subGrid,delete this.parentGridItem,e&&window.setTimeout(()=>v.simulateMouseEvent(e._event,"mouseenter",t.el),0))}save(i=!0,s=!1,o=C.saveCB){let t=this.engine.save(i,o);if(t.forEach(e=>{var t;i&&e.el&&!e.subGrid&&!o?(t=e.el.querySelector(".grid-stack-item-content"),e.content=t?t.innerHTML:void 0,e.content||delete e.content):(i||o||delete e.content,e.subGrid?.el&&(t=e.subGrid.save(i,s,o),e.subGridOpts=s?t:{children:t},delete e.subGrid)),delete e.el}),s){let e=v.cloneDeep(this.opts);e.marginBottom===e.marginTop&&e.marginRight===e.marginLeft&&e.marginTop===e.marginRight&&(e.margin=e.marginTop,delete e.marginTop,delete e.marginRight,delete e.marginBottom,delete e.marginLeft),e.rtl===("rtl"===this.el.style.direction)&&(e.rtl="auto"),this._isAutoCellHeight&&(e.cellHeight="auto"),this._autoColumn&&(e.column="auto");const s=e._alwaysShowResizeHandle;return delete e._alwaysShowResizeHandle,void 0!==s?e.alwaysShowResizeHandle=s:delete e.alwaysShowResizeHandle,v.removeInternalAndSame(e,g),e.children=t,e}return t}load(t,e=C.addRemoveCB||!0){t=v.cloneDeep(t);const i=this.getColumn(),s=t.some(e=>void 0!==e.x||void 0!==e.y);s&&(t=v.sort(t,-1,i)),this._insertNotAppend=s,t.some(e=>(e.x||0)+(e.w||1)>i)&&(this._ignoreLayoutsNodeChange=!0,this.engine.cacheLayout(t,12,!0));var o=C.addRemoveCB;"function"==typeof e&&(C.addRemoveCB=e);let n=[];this.batchUpdate();var r=!this.engine.nodes.length;r&&this.setAnimation(!1),e&&[...this.engine.nodes].forEach(e=>{!e.id||v.find(t,e.id)||(C.addRemoveCB&&C.addRemoveCB(this.el,e,!1,!1),n.push(e),this.removeWidget(e.el,!0,!1))});let a=[];return this.engine.nodes=this.engine.nodes.filter(e=>!v.find(t,e.id)||(a.push(e),!1)),t.forEach(t=>{let i=v.find(a,t.id);if(i){if(v.shouldSizeToContent(i)&&(t.h=i.h),this.engine.nodeBoundFix(t),!t.autoPosition&&void 0!==t.x&&void 0!==t.y||(t.w=t.w||i.w,t.h=t.h||i.h,this.engine.findEmptyPosition(t)),this.engine.nodes.push(i),v.samePos(i,t)&&this.moveNode(i,{...t,forceCollide:!0}),this.update(i.el,t),t.subGridOpts?.children){let e=i.el.querySelector(".grid-stack");e&&e.gridstack&&(e.gridstack.load(t.subGridOpts.children),this._insertNotAppend=!0)}}else e&&this.addWidget(t)}),this.engine.removedNodes=n,this.batchUpdate(!1),delete this._ignoreLayoutsNodeChange,delete this._insertNotAppend,o?C.addRemoveCB=o:delete C.addRemoveCB,r&&this.opts.animate&&setTimeout(()=>this.setAnimation(this.opts.animate)),this}batchUpdate(e=!0){return this.engine.batchUpdate(e),e||(this._updateContainerHeight(),this._triggerRemoveEvent(),this._triggerAddEvent(),this._triggerChangeEvent()),this}getCellHeight(e=!1){if(this.opts.cellHeight&&"auto"!==this.opts.cellHeight&&(!e||!this.opts.cellHeightUnit||"px"===this.opts.cellHeightUnit))return this.opts.cellHeight;if("rem"===this.opts.cellHeightUnit)return this.opts.cellHeight*parseFloat(getComputedStyle(document.documentElement).fontSize);if("em"===this.opts.cellHeightUnit)return this.opts.cellHeight*parseFloat(getComputedStyle(this.el).fontSize);if("cm"===this.opts.cellHeightUnit)return this.opts.cellHeight*(96/2.54);if("mm"===this.opts.cellHeightUnit)return this.opts.cellHeight*(96/2.54)/10;let t=this.el.querySelector("."+this.opts.itemClass);if(t)return e=v.toNumber(t.getAttribute("gs-h"))||1,Math.round(t.offsetHeight/e);e=parseInt(this.el.getAttribute("gs-current-row"));return e?Math.round(this.el.getBoundingClientRect().height/e):this.opts.cellHeight}cellHeight(e,t=!0){t&&void 0!==e&&this._isAutoCellHeight!==("auto"===e)&&(this._isAutoCellHeight="auto"===e,this._updateResizeEvent()),void 0===(e="initial"!==e&&"auto"!==e?e:void 0)&&(i=-this.opts.marginRight-this.opts.marginLeft+this.opts.marginTop+this.opts.marginBottom,e=this.cellWidth()+i);var i=v.parseHeight(e);return this.opts.cellHeightUnit===i.unit&&this.opts.cellHeight===i.h||(this.opts.cellHeightUnit=i.unit,this.opts.cellHeight=i.h,this.resizeToContentCheck(),t&&this._updateStyles(!0)),this}cellWidth(){return this._widthOrContainer()/this.getColumn()}_widthOrContainer(e=!1){return(!e||!this.opts.columnOpts?.breakpointForWindow)&&(this.el.clientWidth||this.el.parentElement.clientWidth)||window.innerWidth}checkDynamicColumn(){const t=this.opts.columnOpts;if(!t||!t.columnWidth&&!t.breakpoints?.length)return!1;const i=this.getColumn();let s=i;var o=this._widthOrContainer(!0);if(t.columnWidth)s=Math.min(Math.round(o/t.columnWidth)||1,t.columnMax);else{s=t.columnMax;let e=0;for(;ee.c===s);return this.column(s,i?.layout||t.layout),!0}}compact(e="compact",t=!0){return this.engine.compact(e,t),this._triggerChangeEvent(),this}column(e,t="moveScale"){if(!e||e<1||this.opts.column===e)return this;var i=this.getColumn();return this.opts.column=e,this.engine&&(this.engine.column=e,this.el.classList.remove("gs-"+i),this.el.classList.add("gs-"+e),this.engine.columnChanged(i,e,t),this._isAutoCellHeight&&this.cellHeight(),this.resizeToContentCheck(!0),this._ignoreLayoutsNodeChange=!0,this._triggerChangeEvent(),delete this._ignoreLayoutsNodeChange),this}getColumn(){return this.opts.column}getGridItems(){return Array.from(this.el.children).filter(e=>e.matches("."+this.opts.itemClass)&&!e.matches("."+this.opts.placeholderClass))}destroy(e=!0){if(this.el)return this.offAll(),this._updateResizeEvent(!0),this.setStatic(!0,!1),this.setAnimation(!1),e?this.el.parentNode.removeChild(this.el):(this.removeAll(e),this.el.classList.remove(this._styleSheetClass),this.el.removeAttribute("gs-current-row")),this._removeStylesheet(),this.parentGridItem&&delete this.parentGridItem.subGrid,delete this.parentGridItem,delete this.opts,delete this._placeholder,delete this.engine,delete this.el.gridstack,delete this.el,this}float(e){return this.opts.float!==e&&(this.opts.float=this.engine.float=e,this._triggerChangeEvent()),this}getFloat(){return this.engine.float}getCellFromPixel(e,t=!1){var i=this.el.getBoundingClientRect(),t=t?{top:i.top+document.documentElement.scrollTop,left:i.left}:{top:this.el.offsetTop,left:this.el.offsetLeft},s=e.left-t.left,e=e.top-t.top,t=i.width/this.getColumn(),i=i.height/parseInt(this.el.getAttribute("gs-current-row"));return{x:Math.floor(s/t),y:Math.floor(e/i)}}getRow(){return Math.max(this.engine.getRow(),this.opts.minRow)}isAreaEmpty(e,t,i,s){return this.engine.isAreaEmpty(e,t,i,s)}makeWidget(e,t){e=C.getElement(e),this._prepareElement(e,!0,t),t=e.gridstackNode;return this._updateContainerHeight(),t.subGridOpts&&this.makeSubGrid(e,t.subGridOpts,void 0,!1),1===this.opts.column&&(this._ignoreLayoutsNodeChange=!0),this._triggerAddEvent(),this._triggerChangeEvent(),delete this._ignoreLayoutsNodeChange,e}on(e,t){return-1!==e.indexOf(" ")?e.split(" ").forEach(e=>this.on(e,t)):"change"===e||"added"===e||"removed"===e||"enable"===e||"disable"===e?(this._gsEventHandler[e]="enable"===e||"disable"===e?e=>t(e):e=>t(e,e.detail),this.el.addEventListener(e,this._gsEventHandler[e])):"drag"===e||"dragstart"===e||"dragstop"===e||"resizestart"===e||"resize"===e||"resizestop"===e||"dropped"===e||"resizecontent"===e?this._gsEventHandler[e]=t:console.error("GridStack.on("+e+") event not supported"),this}off(e){return-1!==e.indexOf(" ")?e.split(" ").forEach(e=>this.off(e)):("change"!==e&&"added"!==e&&"removed"!==e&&"enable"!==e&&"disable"!==e||this._gsEventHandler[e]&&this.el.removeEventListener(e,this._gsEventHandler[e]),delete this._gsEventHandler[e]),this}offAll(){return Object.keys(this._gsEventHandler).forEach(e=>this.off(e)),this}removeWidget(e,i=!0,s=!0){return C.getElements(e).forEach(t=>{if(!t.parentElement||t.parentElement===this.el){let e=t.gridstackNode;(e=e||this.engine.nodes.find(e=>t===e.el))&&(C.addRemoveCB&&C.addRemoveCB(this.el,e,!1,!1),delete t.gridstackNode,this._removeDD(t),this.engine.removeNode(e,i,s),i&&t.parentElement&&t.remove())}}),s&&(this._triggerRemoveEvent(),this._triggerChangeEvent()),this}removeAll(e=!0){return this.engine.nodes.forEach(e=>{delete e.el.gridstackNode,this._removeDD(e.el)}),this.engine.removeAll(e),this._triggerRemoveEvent(),this}setAnimation(e){return e?this.el.classList.add("grid-stack-animate"):this.el.classList.remove("grid-stack-animate"),this}hasAnimationCSS(){return this.el.classList.contains("grid-stack-animate")}setStatic(t,i=!0,s=!0){return!!this.opts.staticGrid!==t&&(t?this.opts.staticGrid=!0:delete this.opts.staticGrid,this._setupRemoveDrop(),this._setupAcceptWidget(),this.engine.nodes.forEach(e=>{this._prepareDragDropByNode(e),e.subGrid&&s&&e.subGrid.setStatic(t,i,s)}),i&&this._setStaticClass()),this}update(e,a){var t,i;return 2{let r=n?.gridstackNode;if(r){let t=v.cloneDeep(a);this.engine.nodeBoundFix(t),delete t.autoPosition,delete t.id;let i,e=["x","y","w","h"];if(e.some(e=>void 0!==t[e]&&t[e]!==r[e])&&(i={},e.forEach(e=>{i[e]=(void 0!==t[e]?t:r)[e],delete t[e]})),!i&&(t.minW||t.minH||t.maxW||t.maxH)&&(i={}),void 0!==t.content){const a=n.querySelector(".grid-stack-item-content");a&&a.innerHTML!==t.content&&(a.innerHTML=t.content,r.subGrid?.el&&(a.appendChild(r.subGrid.el),r.subGrid.opts.styleInHead||r.subGrid._updateStyles(!0))),delete t.content}let s=!1,o=!1;for(const n in t)"_"!==n[0]&&r[n]!==t[n]&&(r[n]=t[n],s=!0,o=o||!this.opts.staticGrid&&("noResize"===n||"noMove"===n||"locked"===n));if(v.sanitizeMinMax(r),i){const n=void 0!==i.w&&i.w!==r.w;this.moveNode(r,i),this.resizeToContentCheck(n,r)}(i||s)&&this._writeAttr(n,r),o&&this._prepareDragDropByNode(r)}}),this)}moveNode(e,t){this.engine.cleanNodes().beginUpdate(e).moveNode(e,t),this._updateContainerHeight(),this._triggerChangeEvent(),this.engine.endUpdate()}resizeToContent(s){if(s&&(s.classList.remove("size-to-content-max"),s.clientHeight)){const r=s.gridstackNode;if(r){const a=r.grid;if(a&&s.parentElement===a.el){var o=a.getCellHeight(!0);if(o){let e,i=r.h?r.h*o:s.clientHeight;if(e=(e=r.resizeToContentParent?s.querySelector(r.resizeToContentParent):e)||s.querySelector(C.resizeToContentParent)){var n=s.clientHeight-e.clientHeight,n=r.h?r.h*o-n:e.clientHeight;let t;if(r.subGrid)t=r.subGrid.getRow()*r.subGrid.getCellHeight(!0);else{const s=e.firstElementChild;if(!s)return void console.error(`Error: GridStack.resizeToContent() widget id:${r.id} '${C.resizeToContentParent}'.firstElementChild is null, make sure to have a div like container. Skipping sizing.`);t=s.getBoundingClientRect().height||n}if(n!==t){i+=t-n;let e=Math.ceil(i/o);n=Number.isInteger(r.sizeToContent)?r.sizeToContent:0;n&&e>n&&(e=n,s.classList.add("size-to-content-max")),r.minH&&er.maxH&&(e=r.maxH),e!==r.h&&(a._ignoreLayoutsNodeChange=!0,a.moveNode(r,{h:e}),delete a._ignoreLayoutsNodeChange)}}}}}}}resizeToContentCBCheck(e){C.resizeToContentCB?C.resizeToContentCB(e):this.resizeToContent(e)}margin(e){if(!("string"==typeof e&&1{delete e._dirty}),this._triggerEvent("added",this.engine.addedNodes),this.engine.addedNodes=[]),this}_triggerRemoveEvent(){return this.engine.batchMode||this.engine.removedNodes?.length&&(this._triggerEvent("removed",this.engine.removedNodes),this.engine.removedNodes=[]),this}_triggerEvent(e,t){t=t?new CustomEvent(e,{bubbles:!1,detail:t}):new Event(e);return this.el.dispatchEvent(t),this}_removeStylesheet(){var e;return this._styles&&(e=this.opts.styleInHead?void 0:this.el.parentNode,v.removeStylesheet(this._styleSheetClass,e),delete this._styles),this}_updateStyles(e=!1,t){if(e&&this._removeStylesheet(),void 0===t&&(t=this.getRow()),this._updateContainerHeight(),0===this.opts.cellHeight)return this;let i=this.opts.cellHeight,s=this.opts.cellHeightUnit,o=`.${this._styleSheetClass} > .`+this.opts.itemClass;if(!this._styles){const e=this.opts.styleInHead?void 0:this.el.parentNode;if(this._styles=v.createStylesheet(this._styleSheetClass,e,{nonce:this.opts.nonce}),!this._styles)return this;this._styles._max=0,v.addCSSRule(this._styles,o,"height: "+i+s);var n=this.opts.marginTop+this.opts.marginUnit,r=this.opts.marginBottom+this.opts.marginUnit,a=this.opts.marginRight+this.opts.marginUnit,l=this.opts.marginLeft+this.opts.marginUnit,h=o+" > .grid-stack-item-content",d=`.${this._styleSheetClass} > .grid-stack-placeholder > .placeholder-content`;v.addCSSRule(this._styles,h,`top: ${n}; right: ${a}; bottom: ${r}; left: ${l};`),v.addCSSRule(this._styles,d,`top: ${n}; right: ${a}; bottom: ${r}; left: ${l};`),v.addCSSRule(this._styles,o+" > .ui-resizable-n",`top: ${n};`),v.addCSSRule(this._styles,o+" > .ui-resizable-s","bottom: "+r),v.addCSSRule(this._styles,o+" > .ui-resizable-ne","right: "+a),v.addCSSRule(this._styles,o+" > .ui-resizable-e","right: "+a),v.addCSSRule(this._styles,o+" > .ui-resizable-se",`right: ${a}; bottom: `+r),v.addCSSRule(this._styles,o+" > .ui-resizable-nw","left: "+l),v.addCSSRule(this._styles,o+" > .ui-resizable-w","left: "+l),v.addCSSRule(this._styles,o+" > .ui-resizable-sw",`left: ${l}; bottom: `+r)}if((t=t||this._styles._max)>this._styles._max){var g=e=>i*e+s;for(let e=this._styles._max+1;e<=t;e++)v.addCSSRule(this._styles,o+`[gs-y="${e}"]`,"top: "+g(e)),v.addCSSRule(this._styles,o+`[gs-h="${e+1}"]`,"height: "+g(e+1));this._styles._max=t}return this}_updateContainerHeight(){if(!this.engine||this.engine.batchMode)return this;const e=this.parentGridItem;let t=this.getRow()+this._extraDragRow;var i=this.opts.cellHeight,s=this.opts.cellHeightUnit;if(!i)return this;if(!e){const e=v.parseHeight(getComputedStyle(this.el).minHeight);if(0{e.subGrid&&e.subGrid.onResize()}),this._skipInitialResize||this.resizeToContentCheck(e),delete this._skipInitialResize,this.batchUpdate(!1),this}}resizeToContentCheck(e=!1,t=void 0){if(this.engine){if(e&&this.hasAnimationCSS())return setTimeout(()=>this.resizeToContentCheck(!1,t),310);if(t)v.shouldSizeToContent(t)&&this.resizeToContentCBCheck(t.el);else if(this.engine.nodes.some(e=>v.shouldSizeToContent(e))){const e=[...this.engine.nodes];this.batchUpdate(),e.forEach(e=>{v.shouldSizeToContent(e)&&this.resizeToContentCBCheck(e.el)}),this.batchUpdate(!1)}this._gsEventHandler.resizecontent&&this._gsEventHandler.resizecontent(null,t?[t]:this.engine.nodes)}}_updateResizeEvent(e=!1){var t=!this.parentGridItem&&(this._isAutoCellHeight||this.opts.sizeToContent||this.opts.columnOpts||this.engine.nodes.find(e=>e.sizeToContent));return e||!t||this.resizeObserver?!e&&t||!this.resizeObserver||(this.resizeObserver.disconnect(),delete this.resizeObserver,delete this._sizeThrottle):(this._sizeThrottle=v.throttle(()=>this.onResize(),this.opts.cellHeightThrottle),this.resizeObserver=new ResizeObserver(()=>this._sizeThrottle()),this.resizeObserver.observe(this.el),this._skipInitialResize=!0),this}static getElement(e=".grid-stack-item"){return v.getElement(e)}static getElements(e=".grid-stack-item"){return v.getElements(e)}static getGridElement(e){return C.getElement(e)}static getGridElements(e){return v.getElements(e)}_initMargin(){let e,t=0,i=[];return 2===(i="string"==typeof this.opts.margin?this.opts.margin.split(" "):i).length?(this.opts.marginTop=this.opts.marginBottom=i[0],this.opts.marginLeft=this.opts.marginRight=i[1]):4===i.length?(this.opts.marginTop=i[0],this.opts.marginRight=i[1],this.opts.marginBottom=i[2],this.opts.marginLeft=i[3]):(e=v.parseHeight(this.opts.margin),this.opts.marginUnit=e.unit,t=this.opts.margin=e.h),void 0===this.opts.marginTop?this.opts.marginTop=t:(e=v.parseHeight(this.opts.marginTop),this.opts.marginTop=e.h,delete this.opts.margin),void 0===this.opts.marginBottom?this.opts.marginBottom=t:(e=v.parseHeight(this.opts.marginBottom),this.opts.marginBottom=e.h,delete this.opts.margin),void 0===this.opts.marginRight?this.opts.marginRight=t:(e=v.parseHeight(this.opts.marginRight),this.opts.marginRight=e.h,delete this.opts.margin),void 0===this.opts.marginLeft?this.opts.marginLeft=t:(e=v.parseHeight(this.opts.marginLeft),this.opts.marginLeft=e.h,delete this.opts.margin),this.opts.marginUnit=e.unit,this.opts.marginTop===this.opts.marginBottom&&this.opts.marginLeft===this.opts.marginRight&&this.opts.marginTop===this.opts.marginRight&&(this.opts.margin=this.opts.marginTop),this}static getDD(){return x}static setupDragIn(e,t,i=document){void 0!==t?.pause&&(p.pauseDrag=t.pause),t={...o,...t||{}};let s="string"==typeof e?v.getElements(e,i):e;s.length&&s?.forEach(e=>{x.isDraggable(e)||x.dragIn(e,t)})}movable(e,i){return this.opts.staticGrid||C.getElements(e).forEach(e=>{const t=e.gridstackNode;t&&(i?delete t.noMove:t.noMove=!0,this._prepareDragDropByNode(t))}),this}resizable(e,i){return this.opts.staticGrid||C.getElements(e).forEach(e=>{let t=e.gridstackNode;t&&(i?delete t.noResize:t.noResize=!0,this._prepareDragDropByNode(t))}),this}disable(e=!0){if(!this.opts.staticGrid)return this.enableMove(!1,e),this.enableResize(!1,e),this._triggerEvent("disable"),this}enable(e=!0){if(!this.opts.staticGrid)return this.enableMove(!0,e),this.enableResize(!0,e),this._triggerEvent("enable"),this}enableMove(t,i=!0){return this.opts.staticGrid||(t?delete this.opts.disableDrag:this.opts.disableDrag=!0,this.engine.nodes.forEach(e=>{this._prepareDragDropByNode(e),e.subGrid&&i&&e.subGrid.enableMove(t,i)})),this}enableResize(t,i=!0){return this.opts.staticGrid||(t?delete this.opts.disableResize:this.opts.disableResize=!0,this.engine.nodes.forEach(e=>{this._prepareDragDropByNode(e),e.subGrid&&i&&e.subGrid.enableResize(t,i)})),this}_removeDD(e){return x.draggable(e,"destroy").resizable(e,"destroy"),e.gridstackNode&&delete e.gridstackNode._initDD,delete e.ddElement,this}_setupAcceptWidget(){if(this.opts.staticGrid||!this.opts.acceptWidgets&&!this.opts.removable)return x.droppable(this.el,"destroy"),this;let a,l,r=(e,t,i)=>{let s=t.gridstackNode;if(s){if(i=i||t,!s.grid?.el){i.style.transform=`scale(${1/this.dragTransform.xScale},${1/this.dragTransform.yScale})`;const a=i.getBoundingClientRect();i.style.left=a.x+(this.dragTransform.xScale-1)*(e.clientX-a.x)/this.dragTransform.xScale+"px",i.style.top=a.y+(this.dragTransform.yScale-1)*(e.clientY-a.y)/this.dragTransform.yScale+"px",i.style.transformOrigin="0px 0px"}var o=this.el.getBoundingClientRect(),{top:n,left:r}=i.getBoundingClientRect(),o=(r-=o.left,{position:{top:(n-=o.top)*this.dragTransform.xScale,left:r*this.dragTransform.yScale}});if(s._temporaryRemoved){if(s.x=Math.max(0,Math.round(r/l)),s.y=Math.max(0,Math.round(n/a)),delete s.autoPosition,this.engine.nodeBoundFix(s),!this.engine.willItFit(s)){if(s.autoPosition=!0,!this.engine.willItFit(s))return void x.off(t,"drag");s._willFitPos&&(v.copyPos(s,s._willFitPos),delete s._willFitPos)}this._onStartMoving(i,e,o,s,l,a)}else this._dragOrResize(i,e,o,s,l,a)}};return x.droppable(this.el,{accept:e=>{var t,i=e.gridstackNode;if(i?.grid===this)return!0;if(!this.opts.acceptWidgets)return!1;let s=!0;return(s="function"==typeof this.opts.acceptWidgets?this.opts.acceptWidgets(e):(t=!0===this.opts.acceptWidgets?".grid-stack-item":this.opts.acceptWidgets,e.matches(t)))&&i&&this.opts.maxRow&&(e={w:i.w,h:i.h,minW:i.minW,minH:i.minH},s=this.engine.willItFit(e)),s}}).on(this.el,"dropover",(e,t,i)=>{let s=t.gridstackNode;if(s?.grid===this&&!s._temporaryRemoved)return!1;s?.grid&&s.grid!==this&&!s._temporaryRemoved&&s.grid._leave(t,i),l=this.cellWidth(),a=this.getCellHeight(!0),(s=s||this._readAttr(t,!1)).grid||(s._isExternal=!0,t.gridstackNode=s),i=i||t;var o=s.w||Math.round(i.offsetWidth/l)||1,n=s.h||Math.round(i.offsetHeight/a)||1;return s.grid&&s.grid!==this?(t._gridstackNodeOrig||(t._gridstackNodeOrig=s),t.gridstackNode=s={...s,w:o,h:n,grid:this},delete s.x,delete s.y,this.engine.cleanupNode(s).nodeBoundFix(s),s._initDD=s._isExternal=s._temporaryRemoved=!0):(s.w=o,s.h=n,s._temporaryRemoved=!0),this._itemRemoving(s.el,!1),x.on(t,"drag",r),r(e,t,i),!1}).on(this.el,"dropout",(e,t,i)=>{var s=t.gridstackNode;return!!s&&(s.grid&&s.grid!==this||(this._leave(t,i),this._isTemp&&this.removeAsSubGrid(s)),!1)}).on(this.el,"drop",(e,t,i)=>{let s=t.gridstackNode;if(s?.grid===this&&!s._isExternal)return!1;var o=!!this.placeholder.parentElement,n=(this.placeholder.remove(),o&&this.opts.animate),r=(n&&this.setAnimation(!1),t._gridstackNodeOrig);if(delete t._gridstackNodeOrig,o&&r?.grid&&r.grid!==this){let e=r.grid;e.engine.removeNodeFromLayoutCache(r),e.engine.removedNodes.push(r),e._triggerRemoveEvent()._triggerChangeEvent(),e.parentGridItem&&!e.engine.nodes.length&&e.opts.subGridDynamic&&e.removeAsSubGrid()}if(!s)return!1;if(o&&(this.engine.cleanupNode(s),s.grid=this),delete s.grid._isTemp,x.off(t,"drag"),i!==t?(i.remove(),t.gridstackNode=r,o&&(t=t.cloneNode(!0))):(t.remove(),this._removeDD(t)),!o)return!1;(t.gridstackNode=s).el=t;let a=s.subGrid?.el?.gridstack;return v.copyPos(s,this._readAttr(this.placeholder)),v.removePositioningStyles(t),this.el.appendChild(t),this._prepareElement(t,!0,s),a&&(a.parentGridItem=s,a.opts.styleInHead||a._updateStyles(!0)),this._updateContainerHeight(),this.engine.addedNodes.push(s),this._triggerAddEvent(),this._triggerChangeEvent(),this.engine.endUpdate(),this._gsEventHandler.dropped&&this._gsEventHandler.dropped({...e,type:"dropped"},r&&r.grid?r:void 0,s),n&&setTimeout(()=>this.setAnimation(this.opts.animate)),!1}),this}_itemRemoving(e,t){let i=e?e.gridstackNode:void 0;i&&i.grid&&!e.classList.contains(this.opts.removableOptions.decline)&&(t?i._isAboutToRemove=!0:delete i._isAboutToRemove,t?e.classList.add("grid-stack-item-removing"):e.classList.remove("grid-stack-item-removing"))}_setupRemoveDrop(){if(!this.opts.staticGrid&&"string"==typeof this.opts.removable){var e=document.querySelector(this.opts.removable);if(!e)return this;x.isDroppable(e)||x.droppable(e,this.opts.removableOptions).on(e,"dropover",(e,t)=>this._itemRemoving(t,!0)).on(e,"dropout",(e,t)=>this._itemRemoving(t,!1))}return this}_prepareDragDropByNode(n){let r=n.el;var e=n.noMove||this.opts.disableDrag,t=n.noResize||this.opts.disableResize;if(this.opts.staticGrid||e&&t)return n._initDD&&(this._removeDD(r),delete n._initDD),r.classList.add("ui-draggable-disabled","ui-resizable-disabled"),this;if(!n._initDD){let i,s,e=(e,t)=>{this._gsEventHandler[e.type]&&this._gsEventHandler[e.type](e,e.target),i=this.cellWidth(),s=this.getCellHeight(!0),this._onStartMoving(r,e,t,n,i,s)},t=(e,t)=>{this._dragOrResize(r,e,t,n,i,s)},o=t=>{this.placeholder.remove(),delete n._moving,delete n._event,delete n._lastTried;var e=n.w!==n._orig.w,i=t.target;if(i.gridstackNode&&i.gridstackNode.grid===this){if(n.el=i,n._isAboutToRemove){let e=r.gridstackNode.grid;e._gsEventHandler[t.type]&&e._gsEventHandler[t.type](t,i),e.engine.nodes.push(n),e.removeWidget(r,!0,!0)}else v.removePositioningStyles(i),n._temporaryRemoved?(v.copyPos(n,n._orig),this._writePosAttr(i,n),this.engine.addNode(n)):this._writePosAttr(i,n),this._gsEventHandler[t.type]&&this._gsEventHandler[t.type](t,i);this._extraDragRow=0,this._updateContainerHeight(),this._triggerChangeEvent(),this.engine.endUpdate(),"resizestop"===t.type&&(Number.isInteger(n.sizeToContent)&&(n.sizeToContent=n.h),this.resizeToContentCheck(e,n))}};x.draggable(r,{start:e,stop:o,drag:t}).resizable(r,{start:e,stop:o,resize:t}),n._initDD=!0}return x.draggable(r,e?"disable":"enable").resizable(r,t?"disable":"enable"),this}_onStartMoving(e,t,i,s,o,n){if(this.engine.cleanNodes().beginUpdate(s),this._writePosAttr(this.placeholder,s),this.el.appendChild(this.placeholder),s.grid?.el)this.dragTransform=v.getValuesFromTransformedElement(e);else if(this.placeholder&&this.placeholder.closest(".grid-stack")){const e=this.placeholder.closest(".grid-stack");this.dragTransform=v.getValuesFromTransformedElement(e)}else this.dragTransform={xScale:1,xOffset:0,yScale:1,yOffset:0};s.el=this.placeholder,s._lastUiPosition=i.position,s._prevYPix=i.position.top,s._moving="dragstart"===t.type,delete s._lastTried,"dropover"===t.type&&s._temporaryRemoved&&(this.engine.addNode(s),s._moving=!0),this.engine.cacheRects(o,n,this.opts.marginTop,this.opts.marginRight,this.opts.marginBottom,this.opts.marginLeft),"resizestart"===t.type&&(x.resizable(e,"option","minWidth",o*(s.minW||1)).resizable(e,"option","minHeight",n*(s.minH||1)),s.maxW&&x.resizable(e,"option","maxWidth",o*s.maxW),s.maxH&&x.resizable(e,"option","maxHeight",n*s.maxH))}_dragOrResize(e,t,i,s,o,n){let r,a={...s._orig},l=this.opts.marginLeft,h=this.opts.marginRight,d=this.opts.marginTop,g=this.opts.marginBottom,p=Math.round(.1*n),c=Math.round(.1*o);if(l=Math.min(l,c),h=Math.min(h,c),d=Math.min(d,p),g=Math.min(g,p),"drag"===t.type){if(s._temporaryRemoved)return;var u=i.position.top-s._prevYPix,u=(s._prevYPix=i.position.top,!1!==this.opts.draggable.scroll&&v.updateScrollPosition(e,i.position,u),i.position.left+(i.position.left>s._lastUiPosition.left?-h:l)),m=i.position.top+(i.position.top>s._lastUiPosition.top?-g:d),u=(a.x=Math.round(u/o),a.y=Math.round(m/n),this._extraDragRow);if(this.engine.collide(s,a)){let e=this.getRow(),t=Math.max(0,a.y+s.h-e);this.opts.maxRow&&e+t>this.opts.maxRow&&(t=Math.max(0,this.opts.maxRow-e)),this._extraDragRow=t}else this._extraDragRow=0;if(this._extraDragRow!==u&&this._updateContainerHeight(),s.x===a.x&&s.y===a.y)return}else if("resize"===t.type){if(a.x<0)return;if(v.updateScrollResize(t,e,n),a.w=Math.round((i.size.width-l)/o),a.h=Math.round((i.size.height-d)/n),s.w===a.w&&s.h===a.h)return;if(s._lastTried&&s._lastTried.w===a.w&&s._lastTried.h===a.h)return;m=i.position.left+l,u=i.position.top+d;a.x=Math.round(m/o),a.y=Math.round(u/n),r=!0}s._event=t,s._lastTried=a;e={x:i.position.left+l,y:i.position.top+d,w:(i.size?i.size.width:s.w*o)-l-h,h:(i.size?i.size.height:s.h*n)-d-g};this.engine.moveNodeCheck(s,{...a,cellWidth:o,cellHeight:n,rect:e,resizing:r})&&(s._lastUiPosition=i.position,this.engine.cacheRects(o,n,d,h,g,l),delete s._skipDown,r&&s.subGrid&&s.subGrid.onResize(),this._extraDragRow=0,this._updateContainerHeight(),m=t.target,this._writePosAttr(m,s),this._gsEventHandler[t.type]&&this._gsEventHandler[t.type](t,m))}_leave(e,t){let i=e.gridstackNode;i&&((t=t||e).style.transform="scale(1)",x.off(e,"drag"),i._temporaryRemoved||(i._temporaryRemoved=!0,this.engine.removeNode(i),i.el=i._isExternal&&t?t:e,!0===this.opts.removable&&this._itemRemoving(e,!0),e._gridstackNodeOrig?(e.gridstackNode=e._gridstackNodeOrig,delete e._gridstackNodeOrig):i._isExternal&&(delete i.el,delete e.gridstackNode,this.engine.restoreInitial())))}commit(){return this.batchUpdate(!1).prototype,this}}return C.resizeToContentParent=".grid-stack-item-content",C.Utils=v,C.Engine=d,C.GDRev="10.1.1",e.GridStack})()); \ No newline at end of file diff --git a/src/opnsense/www/js/opnsense_widget_manager.js b/src/opnsense/www/js/opnsense_widget_manager.js new file mode 100644 index 000000000..3227860dd --- /dev/null +++ b/src/opnsense/www/js/opnsense_widget_manager.js @@ -0,0 +1,362 @@ +/** + * 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. + */ + +class ResizeObserverWrapper { + _lastWidths = {}; + _lastHeights = {}; + + _debounce(f, delay = 50, ensure = true) { + // debounce to prevent a flood of calls in a short time + let lastCall = Number.NEGATIVE_INFINITY; + let wait; + let handle; + return (...args) => { + wait = lastCall + delay - Date.now(); + clearTimeout(handle); + if (wait <= 0 || ensure) { + handle = setTimeout(() => { + f(...args); + lastCall = Date.now(); + }, wait); + } + }; + } + + observe(elements, onSizeChanged, onInitialize) { + const observer = new ResizeObserver(this._debounce((entries) => { + if (entries != undefined && entries.length > 0) { + for (const entry of entries) { + const width = entry.contentRect.width; + const height = entry.contentRect.height; + + let id = entry.target.id; + if (id.length === 0) { + // element has just rendered + onInitialize(entry.target); + // we're observing multiple elements of the same class, assign a unique id + entry.target.id = Math.random().toString(36).substring(7); + this._lastWidths[id] = width; + this._lastHeights[id] = height; + } else { + if (width !== this._lastWidths[id] || height !== this._lastHeights[id]) { + this._lastWidths[id] = width; + this._lastHeights[id] = height; + onSizeChanged(entry.target, width, height); + } + } + } + } + + })); + + elements.forEach((element) => { + observer.observe(element); + }); + } +} + +class WidgetManager { + constructor(gridStackOptions = {}, gettext = {}) { + this.gridStackOptions = gridStackOptions; + this.gettext = gettext; + this.loadedModules = {}; // id -> widget module + this.widgetConfigurations = {}; // id -> per-widget configuration + this.widgetClasses = {}; // id -> instantiated widget module + this.widgetHTMLElements = {}; // id -> Element types + this.widgetTickRoutines = {}; // id -> tick routines + this.grid = null; // gridstack instance + this.moduleDiff = []; // list of module ids that are allowed, but not currently rendered + this.renderDefaultDashboard = true; + } + + async initialize() { + try { + // import allowed modules and current persisted configuration + await this._loadWidgets(); + // prepare widget markup + this._initializeWidgets(); + // render grid and append widget markup + this._initializeGridStack(); + // load all dynamic content and start tick routines + await this._loadDynamicContent(); + } catch (error) { + console.error('Failed initializing Widgets', error); + } + } + + async _loadWidgets() { + const response = await $.ajax('/api/core/dashboard/getDashboard', { + type: 'GET', + dataType: 'json', + contentType: 'application/json' + }).then(async (data) => { + if ('dashboard' in data && data.dashboard != null) { + this.renderDefaultDashboard = false; + let configuration = JSON.parse(data.dashboard); + configuration.forEach(item => { + this.widgetConfigurations[item.id] = item; + }); + } + + const promises = data.modules.map(async (item) => { + const mod = await import('/ui/js/widgets/' + item.module); + this.loadedModules[item.id] = mod.default; + }); + + // Load all modules simultaneously - this shouldn't take long + await Promise.all(promises).catch((error) => { + console.error('Failed to load widgets', error); + null; + }); + }); + } + + _initializeWidgets() { + if ($.isEmptyObject(this.loadedModules)) { + throw new Error('No widgets loaded'); + } + + if (!this.renderDefaultDashboard) { + // restore + for (const [id, configuration] of Object.entries(this.widgetConfigurations)) { + if (id in this.loadedModules) { + this._createGridStackWidget(id, this.loadedModules[id], configuration); + } + } + } else { + // default + for (const [identifier, widgetClass] of Object.entries(this.loadedModules)) { + this._createGridStackWidget(identifier, widgetClass); + } + } + + this.moduleDiff = Object.keys(this.loadedModules).filter(x => !Object.keys(this.widgetConfigurations).includes(x)); + } + + _createGridStackWidget(id, widgetClass, config = {}) { + // instantiate widget + const widget = new widgetClass(config); + // make id accessible to the widget, useful for traceability (e.g. data-widget-id attribute in the DOM) + widget.setId(id); + this.widgetClasses[id] = widget; + + // setup generic panels + let content = widget.getMarkup(); + let $panel = this._makeWidget(id, widget.title, content); + + if (id in this.widgetConfigurations) { + this.widgetConfigurations[id].content = $panel.prop('outerHTML'); + } else { + // let each widget override settings + const options = widget.getGridOptions(); + let gridElement = { + content: $panel.prop('outerHTML'), + id: id, + ...options + }; + + // lock the system information widget + if (id == 'systeminformation') { + gridElement = { + x: 0, y: 0, + id: id, + ...gridElement, + }; + } + + this.widgetConfigurations[id] = gridElement; + } + } + + + _onWidgetClose(id) { + clearInterval(this.widgetTickRoutines[id]); + this.widgetClasses[id].onWidgetClose(); + this.grid.removeWidget(this.widgetHTMLElements[id]); + this.moduleDiff.push(id); + // TODO propagate updated diff to widget selection + } + + // runs only once + _initializeGridStack() { + this.grid = GridStack.init(this.gridStackOptions); + // before we render the grid, register the added event so we can store the Element type objects + this.grid.on('added', (event, items) => { + // store Elements for later use, such as update() and resizeToContent() + items.forEach((item) => { + this.widgetHTMLElements[item.id] = item.el; + }); + }); + + for (const event of ['disable', 'dragstop', 'dropped', 'removed', 'resizestop']) { + this.grid.on(event, (event, items) => { + $('#save-grid').show(); + }); + } + + // render to the DOM + this.grid.load(Object.values(this.widgetConfigurations)); + + // click handler for widget removal. + $('.close-handle').click((event) => { + let widgetId = $(event.currentTarget).data('widget-id'); + this._onWidgetClose(widgetId); + }); + + // force the cell height of each widget to the lowest value. The grid will adjust the height + // according to the content of the widget. + this.grid.cellHeight(1); + + // Serialization options + let $btn_group = $('.btn-group-container'); + $btn_group.append($(``)); + $btn_group.append($(``)); + $('#save-grid').hide(); + + $('#save-grid').click(() => { + //this.grid.cellHeight('auto', false); + let items = this.grid.save(false); + //this.grid.cellHeight(1, false); + + $.ajax({ + type: "POST", + url: "/api/core/dashboard/saveWidgets", + dataType: "text", + contentType: 'text/plain', + data: JSON.stringify(items), + complete: function(data, status) { + let response = JSON.parse(data.responseText); + + if (response['result'] == 'failed') { + console.error('Failed to save widgets', data); + } + } + }); + }); + + $('#restore-defaults').click(() => { + ajaxGet("/api/core/dashboard/restoreDefaults", null, (response, status) => { + if (response['result'] == 'failed') { + console.error('Failed to restore default widgets'); + } else { + window.location.reload(); + } + }); + }); + } + + /* Executes all widget post-render callbacks asynchronously and in "parallel". + * No widget should wait on other widgets, and therefore the + * individual widget tick() callbacks are not bound to a master timer, + * this has the benefit of making it configurable per widget. + */ + async _loadDynamicContent() { + // map to an array of context-bound _onMarkupRendered functions + let fns = Object.values(this.widgetClasses).map((widget) => { + return this._onMarkupRendered.bind(this, widget); + }); + // convert each _onMarkupRendered(widget) to a promise + let promises = fns.map(func => new Promise(resolve => resolve(func()))); + // fire away + await Promise.all(promises).catch((error) => { + console.error('Failed to load dynamic content', error); + null; + }); + + // handle dynamic resize of widgets - do this after the dynamic content has been loaded + new ResizeObserverWrapper().observe( + document.querySelectorAll('.widget'), + (elem, width, height) => { + for (const subclass of elem.className.split(" ")) { + let id = subclass.split('-')[1]; + if (id in this.widgetClasses) { + if (this.widgetClasses[id].onWidgetResize(elem, width, height)) { + this._updateGrid(elem.parentElement.parentElement); + } + } + } + }, + (elem) => { + this._updateGrid(elem.parentElement.parentElement); + } + ); + } + + // Executed for each widget; starts the widget-specific tick routine. + async _onMarkupRendered(widget) { + // first: load the widget dynamic content, make sure to bind the widget context to the callback + let onMarkupRendered = widget.onMarkupRendered.bind(widget); + // show a spinner while the widget is loading + let $selector = $(`.widget-${widget.id} > .widget-content > .panel-divider`); + $selector.after($(`
`)); + await onMarkupRendered(); + $(`.spinner-${widget.id}`).remove(); + + // second: start the widget-specific tick routine + let onWidgetTick = widget.onWidgetTick.bind(widget); + await onWidgetTick(); + const interval = setInterval(async () => { + await onWidgetTick(); + this._updateGrid(this.widgetHTMLElements[widget.id]); + }, widget.tickTimeout); + // store the reference to the tick routine so we can clear it later on widget removal + this.widgetTickRoutines[widget.id] = interval; + } + + // Recalculate widget/grid dimensions + _updateGrid(elem = null) { + if (elem !== null) { + this.grid.resizeToContent(elem); + } else { + for (const item of this.grid.getGridItems()) { + this.grid.resizeToContent(item); + } + } + } + + // Generic widget panels + _makeWidget(identifier, title, content) { + let $panel = $(`
`); + let $content = $(`
`); + let $header = $(` +
+
+
${title}
+
+ +
+
+ `); + $content.append($header); + let $divider = $(`
`); + $content.append($divider); + $content.append(content); + $panel.append($content); + + return $panel; + } +} \ No newline at end of file diff --git a/src/opnsense/www/js/smoothie.js b/src/opnsense/www/js/smoothie.js new file mode 100644 index 000000000..2af8f1580 --- /dev/null +++ b/src/opnsense/www/js/smoothie.js @@ -0,0 +1,1176 @@ +;(function(exports) { + +/** + * @license + * MIT License: + * + * Copyright (c) 2010-2013, Joe Walnes + * 2013-2018, Drew Noakes + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +/** + * Smoothie Charts - http://smoothiecharts.org/ + * (c) 2010-2013, Joe Walnes + * 2013-2018, Drew Noakes + * + * v1.0: Main charting library, by Joe Walnes + * v1.1: Auto scaling of axis, by Neil Dunn + * v1.2: fps (frames per second) option, by Mathias Petterson + * v1.3: Fix for divide by zero, by Paul Nikitochkin + * v1.4: Set minimum, top-scale padding, remove timeseries, add optional timer to reset bounds, by Kelley Reynolds + * v1.5: Set default frames per second to 50... smoother. + * .start(), .stop() methods for conserving CPU, by Dmitry Vyal + * options.interpolation = 'bezier' or 'line', by Dmitry Vyal + * options.maxValue to fix scale, by Dmitry Vyal + * v1.6: minValue/maxValue will always get converted to floats, by Przemek Matylla + * v1.7: options.grid.fillStyle may be a transparent color, by Dmitry A. Shashkin + * Smooth rescaling, by Kostas Michalopoulos + * v1.8: Set max length to customize number of live points in the dataset with options.maxDataSetLength, by Krishna Narni + * v1.9: Display timestamps along the bottom, by Nick and Stev-io + * (https://groups.google.com/forum/?fromgroups#!topic/smoothie-charts/-Ywse8FCpKI%5B1-25%5D) + * Refactored by Krishna Narni, to support timestamp formatting function + * v1.10: Switch to requestAnimationFrame, removed the now obsoleted options.fps, by Gergely Imreh + * v1.11: options.grid.sharpLines option added, by @drewnoakes + * Addressed warning seen in Firefox when seriesOption.fillStyle undefined, by @drewnoakes + * v1.12: Support for horizontalLines added, by @drewnoakes + * Support for yRangeFunction callback added, by @drewnoakes + * v1.13: Fixed typo (#32), by @alnikitich + * v1.14: Timer cleared when last TimeSeries removed (#23), by @davidgaleano + * Fixed diagonal line on chart at start/end of data stream, by @drewnoakes + * v1.15: Support for npm package (#18), by @dominictarr + * Fixed broken removeTimeSeries function (#24) by @davidgaleano + * Minor performance and tidying, by @drewnoakes + * v1.16: Bug fix introduced in v1.14 relating to timer creation/clearance (#23), by @drewnoakes + * TimeSeries.append now deals with out-of-order timestamps, and can merge duplicates, by @zacwitte (#12) + * Documentation and some local variable renaming for clarity, by @drewnoakes + * v1.17: Allow control over font size (#10), by @drewnoakes + * Timestamp text won't overlap, by @drewnoakes + * v1.18: Allow control of max/min label precision, by @drewnoakes + * Added 'borderVisible' chart option, by @drewnoakes + * Allow drawing series with fill but no stroke (line), by @drewnoakes + * v1.19: Avoid unnecessary repaints, and fixed flicker in old browsers having multiple charts in document (#40), by @asbai + * v1.20: Add SmoothieChart.getTimeSeriesOptions and SmoothieChart.bringToFront functions, by @drewnoakes + * v1.21: Add 'step' interpolation mode, by @drewnoakes + * v1.22: Add support for different pixel ratios. Also add optional y limit formatters, by @copacetic + * v1.23: Fix bug introduced in v1.22 (#44), by @drewnoakes + * v1.24: Fix bug introduced in v1.23, re-adding parseFloat to y-axis formatter defaults, by @siggy_sf + * v1.25: Fix bug seen when adding a data point to TimeSeries which is older than the current data, by @Nking92 + * Draw time labels on top of series, by @comolosabia + * Add TimeSeries.clear function, by @drewnoakes + * v1.26: Add support for resizing on high device pixel ratio screens, by @copacetic + * v1.27: Fix bug introduced in v1.26 for non whole number devicePixelRatio values, by @zmbush + * v1.28: Add 'minValueScale' option, by @megawac + * Fix 'labelPos' for different size of 'minValueString' 'maxValueString', by @henryn + * v1.29: Support responsive sizing, by @drewnoakes + * v1.29.1: Include types in package, and make property optional, by @TrentHouliston + * v1.30: Fix inverted logic in devicePixelRatio support, by @scanlime + * v1.31: Support tooltips, by @Sly1024 and @drewnoakes + * v1.32: Support frame rate limit, by @dpuyosa + * v1.33: Use Date static method instead of instance, by @nnnoel + * Fix bug with tooltips when multiple charts on a page, by @jpmbiz70 + * v1.34: Add disabled option to TimeSeries, by @TechGuard (#91) + * Add nonRealtimeData option, by @annazhelt (#92, #93) + * Add showIntermediateLabels option, by @annazhelt (#94) + * Add displayDataFromPercentile option, by @annazhelt (#95) + * Fix bug when hiding tooltip element, by @ralphwetzel (#96) + * Support intermediate y-axis labels, by @beikeland (#99) + * v1.35: Fix issue with responsive mode at high DPI, by @drewnoakes (#101) + * v1.36: Add tooltipLabel to ITimeSeriesPresentationOptions. + * If tooltipLabel is present, tooltipLabel displays inside tooltip + * next to value, by @jackdesert (#102) + * Fix bug rendering issue in series fill when using scroll backwards, by @olssonfredrik + * Add title option, by @mesca + * Fix data drop stoppage by rejecting NaNs in append(), by @timdrysdale + * Allow setting interpolation per time series, by @WofWca (#123) + * Fix chart constantly jumping in 1-2 pixel steps, by @WofWca (#131) + * Fix a memory leak appearing when some `timeSeries.disabled === true`, by @WofWca (#132) + * Fix: make all lines sharp, remove the `grid.sharpLines` option by @WofWca (#134) + * Improve performance, by @WofWca (#135) + * Fix `this.delay` not being respected with `nonRealtimeData: true`, by @WofWca (#137) + * Fix series fill & stroke being inconsistent for last data time < render time, by @WofWca (#138) + * v1.36.1: Fix a potential XSS when `tooltipLabel` or `strokeStyle` are controlled by users, by @WofWca + * v1.36.2: fix: 1px lines jumping 1px left and right at rational `millisPerPixel`, by @WofWca + * perf: improve `render()` performane a bit, by @WofWca + * v1.37: Add `fillToBottom` option to fill timeSeries to 0 instead of to the bottom of the canvas, by @socketpair & @WofWca (#140) + */ + + // Date.now polyfill + Date.now = Date.now || function() { return new Date().getTime(); }; + + var Util = { + extend: function() { + arguments[0] = arguments[0] || {}; + for (var i = 1; i < arguments.length; i++) + { + for (var key in arguments[i]) + { + if (arguments[i].hasOwnProperty(key)) + { + if (typeof(arguments[i][key]) === 'object') { + if (arguments[i][key] instanceof Array) { + arguments[0][key] = arguments[i][key]; + } else { + arguments[0][key] = Util.extend(arguments[0][key], arguments[i][key]); + } + } else { + arguments[0][key] = arguments[i][key]; + } + } + } + } + return arguments[0]; + }, + binarySearch: function(data, value) { + var low = 0, + high = data.length; + while (low < high) { + var mid = (low + high) >> 1; + if (value < data[mid][0]) + high = mid; + else + low = mid + 1; + } + return low; + }, + // So lines (especially vertical and horizontal) look a) consistent along their length and b) sharp. + pixelSnap: function(position, lineWidth) { + if (lineWidth % 2 === 0) { + // Closest pixel edge. + return Math.round(position); + } else { + // Closest pixel center. + return Math.floor(position) + 0.5; + } + }, + }; + + /** + * Initialises a new TimeSeries with optional data options. + * + * Options are of the form (defaults shown): + * + *
+   * {
+   *   resetBounds: true,        // enables/disables automatic scaling of the y-axis
+   *   resetBoundsInterval: 3000 // the period between scaling calculations, in millis
+   * }
+   * 
+ * + * Presentation options for TimeSeries are specified as an argument to SmoothieChart.addTimeSeries. + * + * @constructor + */ + function TimeSeries(options) { + this.options = Util.extend({}, TimeSeries.defaultOptions, options); + this.disabled = false; + this.clear(); + } + + TimeSeries.defaultOptions = { + resetBoundsInterval: 3000, + resetBounds: true + }; + + /** + * Clears all data and state from this TimeSeries object. + */ + TimeSeries.prototype.clear = function() { + this.data = []; + this.maxValue = Number.NaN; // The maximum value ever seen in this TimeSeries. + this.minValue = Number.NaN; // The minimum value ever seen in this TimeSeries. + }; + + /** + * Recalculate the min/max values for this TimeSeries object. + * + * This causes the graph to scale itself in the y-axis. + */ + TimeSeries.prototype.resetBounds = function() { + if (this.data.length) { + // Walk through all data points, finding the min/max value + this.maxValue = this.data[0][1]; + this.minValue = this.data[0][1]; + for (var i = 1; i < this.data.length; i++) { + var value = this.data[i][1]; + if (value > this.maxValue) { + this.maxValue = value; + } + if (value < this.minValue) { + this.minValue = value; + } + } + } else { + // No data exists, so set min/max to NaN + this.maxValue = Number.NaN; + this.minValue = Number.NaN; + } + }; + + /** + * Adds a new data point to the TimeSeries, preserving chronological order. + * + * @param timestamp the position, in time, of this data point + * @param value the value of this data point + * @param sumRepeatedTimeStampValues if timestamp has an exact match in the series, this flag controls + * whether it is replaced, or the values summed (defaults to false.) + */ + TimeSeries.prototype.append = function(timestamp, value, sumRepeatedTimeStampValues) { + // Reject NaN + if (isNaN(timestamp) || isNaN(value)){ + return + } + + var lastI = this.data.length - 1; + if (lastI >= 0) { + // Rewind until we find the place for the new data + var i = lastI; + while (true) { + var iThData = this.data[i]; + if (timestamp >= iThData[0]) { + if (timestamp === iThData[0]) { + // Update existing values in the array + if (sumRepeatedTimeStampValues) { + // Sum this value into the existing 'bucket' + iThData[1] += value; + value = iThData[1]; + } else { + // Replace the previous value + iThData[1] = value; + } + } else { + // Splice into the correct position to keep timestamps in order + this.data.splice(i + 1, 0, [timestamp, value]); + } + + break; + } + + i--; + if (i < 0) { + // This new item is the oldest data + this.data.splice(0, 0, [timestamp, value]); + + break; + } + } + } else { + // It's the first element + this.data.push([timestamp, value]); + } + + this.maxValue = isNaN(this.maxValue) ? value : Math.max(this.maxValue, value); + this.minValue = isNaN(this.minValue) ? value : Math.min(this.minValue, value); + }; + + TimeSeries.prototype.dropOldData = function(oldestValidTime, maxDataSetLength) { + // We must always keep one expired data point as we need this to draw the + // line that comes into the chart from the left, but any points prior to that can be removed. + var removeCount = 0; + while (this.data.length - removeCount >= maxDataSetLength && this.data[removeCount + 1][0] < oldestValidTime) { + removeCount++; + } + if (removeCount !== 0) { + this.data.splice(0, removeCount); + } + }; + + /** + * Initialises a new SmoothieChart. + * + * Options are optional, and should be of the form below. Just specify the values you + * need and the rest will be given sensible defaults as shown: + * + *
+   * {
+   *   minValue: undefined,                      // specify to clamp the lower y-axis to a given value
+   *   maxValue: undefined,                      // specify to clamp the upper y-axis to a given value
+   *   maxValueScale: 1,                         // allows proportional padding to be added above the chart. for 10% padding, specify 1.1.
+   *   minValueScale: 1,                         // allows proportional padding to be added below the chart. for 10% padding, specify 1.1.
+   *   yRangeFunction: undefined,                // function({min: , max: }) { return {min: , max: }; }
+   *   scaleSmoothing: 0.125,                    // controls the rate at which y-value zoom animation occurs
+   *   millisPerPixel: 20,                       // sets the speed at which the chart pans by
+   *   enableDpiScaling: true,                   // support rendering at different DPI depending on the device
+   *   yMinFormatter: function(min, precision) { // callback function that formats the min y value label
+   *     return parseFloat(min).toFixed(precision);
+   *   },
+   *   yMaxFormatter: function(max, precision) { // callback function that formats the max y value label
+   *     return parseFloat(max).toFixed(precision);
+   *   },
+   *   yIntermediateFormatter: function(intermediate, precision) { // callback function that formats the intermediate y value labels
+   *     return parseFloat(intermediate).toFixed(precision);
+   *   },
+   *   maxDataSetLength: 2,
+   *   interpolation: 'bezier'                   // one of 'bezier', 'linear', or 'step'
+   *   timestampFormatter: null,                 // optional function to format time stamps for bottom of chart
+   *                                             // you may use SmoothieChart.timeFormatter, or your own: function(date) { return ''; }
+   *   scrollBackwards: false,                   // reverse the scroll direction of the chart
+   *   horizontalLines: [],                      // [ { value: 0, color: '#ffffff', lineWidth: 1 } ]
+   *   grid:
+   *   {
+   *     fillStyle: '#000000',                   // the background colour of the chart
+   *     lineWidth: 1,                           // the pixel width of grid lines
+   *     strokeStyle: '#777777',                 // colour of grid lines
+   *     millisPerLine: 1000,                    // distance between vertical grid lines
+   *     verticalSections: 2,                    // number of vertical sections marked out by horizontal grid lines
+   *     borderVisible: true                     // whether the grid lines trace the border of the chart or not
+   *   },
+   *   labels
+   *   {
+   *     disabled: false,                        // enables/disables labels showing the min/max values
+   *     fillStyle: '#ffffff',                   // colour for text of labels,
+   *     fontSize: 15,
+   *     fontFamily: 'sans-serif',
+   *     precision: 2,
+   *     showIntermediateLabels: false,          // shows intermediate labels between min and max values along y axis
+   *     intermediateLabelSameAxis: true,
+   *   },
+   *   title
+   *   {
+   *     text: '',                               // the text to display on the left side of the chart
+   *     fillStyle: '#ffffff',                   // colour for text
+   *     fontSize: 15,
+   *     fontFamily: 'sans-serif',
+   *     verticalAlign: 'middle'                 // one of 'top', 'middle', or 'bottom'
+   *   },
+   *   tooltip: false                            // show tooltip when mouse is over the chart
+   *   tooltipLine: {                            // properties for a vertical line at the cursor position
+   *     lineWidth: 1,
+   *     strokeStyle: '#BBBBBB'
+   *   },
+   *   tooltipFormatter: SmoothieChart.tooltipFormatter, // formatter function for tooltip text
+   *   nonRealtimeData: false,                   // use time of latest data as current time
+   *   displayDataFromPercentile: 1,             // display not latest data, but data from the given percentile
+   *                                             // useful when trying to see old data saved by setting a high value for maxDataSetLength
+   *                                             // should be a value between 0 and 1
+   *   responsive: false,                        // whether the chart should adapt to the size of the canvas
+   *   limitFPS: 0                               // maximum frame rate the chart will render at, in FPS (zero means no limit)
+   * }
+   * 
+ * + * @constructor + */ + function SmoothieChart(options) { + this.options = Util.extend({}, SmoothieChart.defaultChartOptions, options); + this.seriesSet = []; + this.currentValueRange = 1; + this.currentVisMinValue = 0; + this.lastRenderTimeMillis = 0; + this.lastChartTimestamp = 0; + + this.mousemove = this.mousemove.bind(this); + this.mouseout = this.mouseout.bind(this); + } + + /** Formats the HTML string content of the tooltip. */ + SmoothieChart.tooltipFormatter = function (timestamp, data) { + var timestampFormatter = this.options.timestampFormatter || SmoothieChart.timeFormatter, + // A dummy element to hold children. Maybe there's a better way. + elements = document.createElement('div'), + label; + elements.appendChild(document.createTextNode( + timestampFormatter(new Date(timestamp)) + )); + + for (var i = 0; i < data.length; ++i) { + label = data[i].series.options.tooltipLabel || '' + if (label !== ''){ + label = label + ' '; + } + var dataEl = document.createElement('span'); + dataEl.style.color = data[i].series.options.strokeStyle; + dataEl.appendChild(document.createTextNode( + label + this.options.yMaxFormatter(data[i].value, this.options.labels.precision) + )); + elements.appendChild(document.createElement('br')); + elements.appendChild(dataEl); + } + + return elements.innerHTML; + }; + + SmoothieChart.defaultChartOptions = { + millisPerPixel: 20, + enableDpiScaling: true, + yMinFormatter: function(min, precision) { + return parseFloat(min).toFixed(precision); + }, + yMaxFormatter: function(max, precision) { + return parseFloat(max).toFixed(precision); + }, + yIntermediateFormatter: function(intermediate, precision) { + return parseFloat(intermediate).toFixed(precision); + }, + maxValueScale: 1, + minValueScale: 1, + interpolation: 'bezier', + scaleSmoothing: 0.125, + maxDataSetLength: 2, + scrollBackwards: false, + displayDataFromPercentile: 1, + grid: { + fillStyle: '#000000', + strokeStyle: '#777777', + lineWidth: 2, + millisPerLine: 1000, + verticalSections: 2, + borderVisible: true + }, + labels: { + fillStyle: '#ffffff', + disabled: false, + fontSize: 10, + fontFamily: 'monospace', + precision: 2, + showIntermediateLabels: false, + intermediateLabelSameAxis: true, + }, + title: { + text: '', + fillStyle: '#ffffff', + fontSize: 15, + fontFamily: 'monospace', + verticalAlign: 'middle' + }, + horizontalLines: [], + tooltip: false, + tooltipLine: { + lineWidth: 1, + strokeStyle: '#BBBBBB' + }, + tooltipFormatter: SmoothieChart.tooltipFormatter, + nonRealtimeData: false, + responsive: false, + limitFPS: 0 + }; + + // Based on http://inspirit.github.com/jsfeat/js/compatibility.js + SmoothieChart.AnimateCompatibility = (function() { + var requestAnimationFrame = function(callback, element) { + var requestAnimationFrame = + window.requestAnimationFrame || + window.webkitRequestAnimationFrame || + window.mozRequestAnimationFrame || + window.oRequestAnimationFrame || + window.msRequestAnimationFrame || + function(callback) { + return window.setTimeout(function() { + callback(Date.now()); + }, 16); + }; + return requestAnimationFrame.call(window, callback, element); + }, + cancelAnimationFrame = function(id) { + var cancelAnimationFrame = + window.cancelAnimationFrame || + function(id) { + clearTimeout(id); + }; + return cancelAnimationFrame.call(window, id); + }; + + return { + requestAnimationFrame: requestAnimationFrame, + cancelAnimationFrame: cancelAnimationFrame + }; + })(); + + SmoothieChart.defaultSeriesPresentationOptions = { + lineWidth: 1, + strokeStyle: '#ffffff', + // Maybe default to false in the next breaking version. + fillToBottom: true, + }; + + /** + * Adds a TimeSeries to this chart, with optional presentation options. + * + * Presentation options should be of the form (defaults shown): + * + *
+   * {
+   *   lineWidth: 1,
+   *   strokeStyle: '#ffffff',
+   *   fillStyle: undefined,
+   *   interpolation: undefined;
+   *   tooltipLabel: undefined,
+   *   fillToBottom: true,
+   * }
+   * 
+ */ + SmoothieChart.prototype.addTimeSeries = function(timeSeries, options) { + this.seriesSet.push({timeSeries: timeSeries, options: Util.extend({}, SmoothieChart.defaultSeriesPresentationOptions, options)}); + if (timeSeries.options.resetBounds && timeSeries.options.resetBoundsInterval > 0) { + timeSeries.resetBoundsTimerId = setInterval( + function() { + timeSeries.resetBounds(); + }, + timeSeries.options.resetBoundsInterval + ); + } + }; + + /** + * Removes the specified TimeSeries from the chart. + */ + SmoothieChart.prototype.removeTimeSeries = function(timeSeries) { + // Find the correct timeseries to remove, and remove it + var numSeries = this.seriesSet.length; + for (var i = 0; i < numSeries; i++) { + if (this.seriesSet[i].timeSeries === timeSeries) { + this.seriesSet.splice(i, 1); + break; + } + } + // If a timer was operating for that timeseries, remove it + if (timeSeries.resetBoundsTimerId) { + // Stop resetting the bounds, if we were + clearInterval(timeSeries.resetBoundsTimerId); + } + }; + + /** + * Gets render options for the specified TimeSeries. + * + * As you may use a single TimeSeries in multiple charts with different formatting in each usage, + * these settings are stored in the chart. + */ + SmoothieChart.prototype.getTimeSeriesOptions = function(timeSeries) { + // Find the correct timeseries to remove, and remove it + var numSeries = this.seriesSet.length; + for (var i = 0; i < numSeries; i++) { + if (this.seriesSet[i].timeSeries === timeSeries) { + return this.seriesSet[i].options; + } + } + }; + + /** + * Brings the specified TimeSeries to the top of the chart. It will be rendered last. + */ + SmoothieChart.prototype.bringToFront = function(timeSeries) { + // Find the correct timeseries to remove, and remove it + var numSeries = this.seriesSet.length; + for (var i = 0; i < numSeries; i++) { + if (this.seriesSet[i].timeSeries === timeSeries) { + var set = this.seriesSet.splice(i, 1); + this.seriesSet.push(set[0]); + break; + } + } + }; + + /** + * Instructs the SmoothieChart to start rendering to the provided canvas, with specified delay. + * + * @param canvas the target canvas element + * @param delayMillis an amount of time to wait before a data point is shown. This can prevent the end of the series + * from appearing on screen, with new values flashing into view, at the expense of some latency. + */ + SmoothieChart.prototype.streamTo = function(canvas, delayMillis) { + this.canvas = canvas; + + this.clientWidth = parseInt(this.canvas.getAttribute('width')); + this.clientHeight = parseInt(this.canvas.getAttribute('height')); + + this.delay = delayMillis; + this.start(); + }; + + SmoothieChart.prototype.getTooltipEl = function () { + // Create the tool tip element lazily + if (!this.tooltipEl) { + this.tooltipEl = document.createElement('div'); + this.tooltipEl.className = 'smoothie-chart-tooltip'; + this.tooltipEl.style.pointerEvents = 'none'; + this.tooltipEl.style.position = 'absolute'; + this.tooltipEl.style.display = 'none'; + document.body.appendChild(this.tooltipEl); + } + return this.tooltipEl; + }; + + SmoothieChart.prototype.updateTooltip = function () { + if(!this.options.tooltip){ + return; + } + var el = this.getTooltipEl(); + + if (!this.mouseover || !this.options.tooltip) { + el.style.display = 'none'; + return; + } + + var time = this.lastChartTimestamp; + + // x pixel to time + var t = this.options.scrollBackwards + ? time - this.mouseX * this.options.millisPerPixel + : time - (this.clientWidth - this.mouseX) * this.options.millisPerPixel; + + var data = []; + + // For each data set... + for (var d = 0; d < this.seriesSet.length; d++) { + var timeSeries = this.seriesSet[d].timeSeries; + if (timeSeries.disabled) { + continue; + } + + // find datapoint closest to time 't' + var closeIdx = Util.binarySearch(timeSeries.data, t); + if (closeIdx > 0 && closeIdx < timeSeries.data.length) { + data.push({ series: this.seriesSet[d], index: closeIdx, value: timeSeries.data[closeIdx][1] }); + } + } + + if (data.length) { + // TODO make `tooltipFormatter` return element(s) instead of an HTML string so it's harder for users + // to introduce an XSS. This would be a breaking change. + el.innerHTML = this.options.tooltipFormatter.call(this, t, data); + el.style.display = 'block'; + } else { + el.style.display = 'none'; + } + }; + + SmoothieChart.prototype.mousemove = function (evt) { + this.mouseover = true; + this.mouseX = evt.offsetX; + this.mouseY = evt.offsetY; + this.mousePageX = evt.pageX; + this.mousePageY = evt.pageY; + if(!this.options.tooltip){ + return; + } + var el = this.getTooltipEl(); + el.style.top = Math.round(this.mousePageY) + 'px'; + el.style.left = Math.round(this.mousePageX) + 'px'; + this.updateTooltip(); + }; + + SmoothieChart.prototype.mouseout = function () { + this.mouseover = false; + this.mouseX = this.mouseY = -1; + if (this.tooltipEl) + this.tooltipEl.style.display = 'none'; + }; + + /** + * Make sure the canvas has the optimal resolution for the device's pixel ratio. + */ + SmoothieChart.prototype.resize = function () { + var dpr = !this.options.enableDpiScaling || !window ? 1 : window.devicePixelRatio, + width, height; + if (this.options.responsive) { + // Newer behaviour: Use the canvas's size in the layout, and set the internal + // resolution according to that size and the device pixel ratio (eg: high DPI) + width = this.canvas.offsetWidth; + height = this.canvas.offsetHeight; + + if (width !== this.lastWidth) { + this.lastWidth = width; + this.canvas.setAttribute('width', (Math.floor(width * dpr)).toString()); + this.canvas.getContext('2d').scale(dpr, dpr); + } + if (height !== this.lastHeight) { + this.lastHeight = height; + this.canvas.setAttribute('height', (Math.floor(height * dpr)).toString()); + this.canvas.getContext('2d').scale(dpr, dpr); + } + + this.clientWidth = width; + this.clientHeight = height; + } else { + width = parseInt(this.canvas.getAttribute('width')); + height = parseInt(this.canvas.getAttribute('height')); + + if (dpr !== 1) { + // Older behaviour: use the canvas's inner dimensions and scale the element's size + // according to that size and the device pixel ratio (eg: high DPI) + + if (Math.floor(this.clientWidth * dpr) !== width) { + this.canvas.setAttribute('width', (Math.floor(width * dpr)).toString()); + this.canvas.style.width = width + 'px'; + this.clientWidth = width; + this.canvas.getContext('2d').scale(dpr, dpr); + } + + if (Math.floor(this.clientHeight * dpr) !== height) { + this.canvas.setAttribute('height', (Math.floor(height * dpr)).toString()); + this.canvas.style.height = height + 'px'; + this.clientHeight = height; + this.canvas.getContext('2d').scale(dpr, dpr); + } + } else { + this.clientWidth = width; + this.clientHeight = height; + } + } + }; + + /** + * Starts the animation of this chart. + */ + SmoothieChart.prototype.start = function() { + if (this.frame) { + // We're already running, so just return + return; + } + + this.canvas.addEventListener('mousemove', this.mousemove); + this.canvas.addEventListener('mouseout', this.mouseout); + + // Renders a frame, and queues the next frame for later rendering + var animate = function() { + this.frame = SmoothieChart.AnimateCompatibility.requestAnimationFrame(function() { + if(this.options.nonRealtimeData){ + var dateZero = new Date(0); + // find the data point with the latest timestamp + var maxTimeStamp = this.seriesSet.reduce(function(max, series){ + var dataSet = series.timeSeries.data; + var indexToCheck = Math.round(this.options.displayDataFromPercentile * dataSet.length) - 1; + indexToCheck = indexToCheck >= 0 ? indexToCheck : 0; + indexToCheck = indexToCheck <= dataSet.length -1 ? indexToCheck : dataSet.length -1; + if(dataSet && dataSet.length > 0) + { + // timestamp corresponds to element 0 of the data point + var lastDataTimeStamp = dataSet[indexToCheck][0]; + max = max > lastDataTimeStamp ? max : lastDataTimeStamp; + } + return max; + }.bind(this), dateZero); + // use the max timestamp as current time + this.render(this.canvas, maxTimeStamp > dateZero ? maxTimeStamp : null); + } else { + this.render(); + } + animate(); + }.bind(this)); + }.bind(this); + + animate(); + }; + + /** + * Stops the animation of this chart. + */ + SmoothieChart.prototype.stop = function() { + if (this.frame) { + SmoothieChart.AnimateCompatibility.cancelAnimationFrame(this.frame); + delete this.frame; + this.canvas.removeEventListener('mousemove', this.mousemove); + this.canvas.removeEventListener('mouseout', this.mouseout); + } + }; + + SmoothieChart.prototype.updateValueRange = function() { + // Calculate the current scale of the chart, from all time series. + var chartOptions = this.options, + chartMaxValue = Number.NaN, + chartMinValue = Number.NaN; + + for (var d = 0; d < this.seriesSet.length; d++) { + // TODO(ndunn): We could calculate / track these values as they stream in. + var timeSeries = this.seriesSet[d].timeSeries; + if (timeSeries.disabled) { + continue; + } + + if (!isNaN(timeSeries.maxValue)) { + chartMaxValue = !isNaN(chartMaxValue) ? Math.max(chartMaxValue, timeSeries.maxValue) : timeSeries.maxValue; + } + + if (!isNaN(timeSeries.minValue)) { + chartMinValue = !isNaN(chartMinValue) ? Math.min(chartMinValue, timeSeries.minValue) : timeSeries.minValue; + } + } + + // Scale the chartMaxValue to add padding at the top if required + if (chartOptions.maxValue != null) { + chartMaxValue = chartOptions.maxValue; + } else { + chartMaxValue *= chartOptions.maxValueScale; + } + + // Set the minimum if we've specified one + if (chartOptions.minValue != null) { + chartMinValue = chartOptions.minValue; + } else { + chartMinValue -= Math.abs(chartMinValue * chartOptions.minValueScale - chartMinValue); + } + + // If a custom range function is set, call it + if (this.options.yRangeFunction) { + var range = this.options.yRangeFunction({min: chartMinValue, max: chartMaxValue}); + chartMinValue = range.min; + chartMaxValue = range.max; + } + + if (!isNaN(chartMaxValue) && !isNaN(chartMinValue)) { + var targetValueRange = chartMaxValue - chartMinValue; + var valueRangeDiff = (targetValueRange - this.currentValueRange); + var minValueDiff = (chartMinValue - this.currentVisMinValue); + this.isAnimatingScale = Math.abs(valueRangeDiff) > 0.1 || Math.abs(minValueDiff) > 0.1; + this.currentValueRange += chartOptions.scaleSmoothing * valueRangeDiff; + this.currentVisMinValue += chartOptions.scaleSmoothing * minValueDiff; + } + + this.valueRange = { min: chartMinValue, max: chartMaxValue }; + }; + + SmoothieChart.prototype.render = function(canvas, time) { + var chartOptions = this.options, + nowMillis = Date.now(); + + // Respect any frame rate limit. + if (chartOptions.limitFPS > 0 && nowMillis - this.lastRenderTimeMillis < (1000/chartOptions.limitFPS)) + return; + + time = (time || nowMillis) - (this.delay || 0); + + // Round time down to pixel granularity, so that pixel sample values remain the same, + // just shifted 1px to the left, so motion appears smoother. + time -= time % chartOptions.millisPerPixel; + + if (!this.isAnimatingScale) { + // We're not animating. We can use the last render time and the scroll speed to work out whether + // we actually need to paint anything yet. If not, we can return immediately. + var sameTime = this.lastChartTimestamp === time; + if (sameTime) { + // Render at least every 1/6th of a second. The canvas may be resized, which there is + // no reliable way to detect. + var needToRenderInCaseCanvasResized = nowMillis - this.lastRenderTimeMillis > 1000/6; + if (!needToRenderInCaseCanvasResized) { + return; + } + } + } + + this.lastRenderTimeMillis = nowMillis; + this.lastChartTimestamp = time; + + this.resize(); + + canvas = canvas || this.canvas; + var context = canvas.getContext('2d'), + // Using `this.clientWidth` instead of `canvas.clientWidth` because the latter is slow. + dimensions = { top: 0, left: 0, width: this.clientWidth, height: this.clientHeight }, + // Calculate the threshold time for the oldest data points. + oldestValidTime = time - (dimensions.width * chartOptions.millisPerPixel), + valueToYPosition = function(value, lineWidth) { + var offset = value - this.currentVisMinValue, + unsnapped = this.currentValueRange === 0 + ? dimensions.height + : dimensions.height * (1 - offset / this.currentValueRange); + return Util.pixelSnap(unsnapped, lineWidth); + }.bind(this), + timeToXPosition = function(t, lineWidth) { + // Why not write it as `(time - t) / chartOptions.millisPerPixel`: + // If a datapoint's `t` is very close or is at the center of a pixel, that expression, + // due to floating point error, may take value whose `% 1` sometimes is very close to + // 0 and sometimes is close to 1, depending on the value of render time (`time`), + // which would make `pixelSnap` snap it sometimes to the right and sometimes to the left, + // which would look like it's jumping. + // You can try the default examples, with `millisPerPixel = 100 / 3` and + // `grid.lineWidth = 1`. The grid would jump. + // Writing it this way seems to avoid such inconsistency because in the above example + // `offset` is (almost?) always a whole number. + // TODO Maybe there's a more elegant (and reliable?) way. + var offset = time / chartOptions.millisPerPixel - t / chartOptions.millisPerPixel; + var unsnapped = chartOptions.scrollBackwards + ? offset + : dimensions.width - offset; + return Util.pixelSnap(unsnapped, lineWidth); + }; + + this.updateValueRange(); + + context.font = chartOptions.labels.fontSize + 'px ' + chartOptions.labels.fontFamily; + + // Save the state of the canvas context, any transformations applied in this method + // will get removed from the stack at the end of this method when .restore() is called. + context.save(); + + // Move the origin. + context.translate(dimensions.left, dimensions.top); + + // Create a clipped rectangle - anything we draw will be constrained to this rectangle. + // This prevents the occasional pixels from curves near the edges overrunning and creating + // screen cheese (that phrase should need no explanation). + context.beginPath(); + context.rect(0, 0, dimensions.width, dimensions.height); + context.clip(); + + // Clear the working area. + context.save(); + context.fillStyle = chartOptions.grid.fillStyle; + context.clearRect(0, 0, dimensions.width, dimensions.height); + context.fillRect(0, 0, dimensions.width, dimensions.height); + context.restore(); + + // Grid lines... + context.save(); + context.lineWidth = chartOptions.grid.lineWidth; + context.strokeStyle = chartOptions.grid.strokeStyle; + // Vertical (time) dividers. + if (chartOptions.grid.millisPerLine > 0) { + context.beginPath(); + for (var t = time - (time % chartOptions.grid.millisPerLine); + t >= oldestValidTime; + t -= chartOptions.grid.millisPerLine) { + var gx = timeToXPosition(t, chartOptions.grid.lineWidth); + context.moveTo(gx, 0); + context.lineTo(gx, dimensions.height); + } + context.stroke(); + } + + // Horizontal (value) dividers. + for (var v = 1; v < chartOptions.grid.verticalSections; v++) { + var gy = Util.pixelSnap(v * dimensions.height / chartOptions.grid.verticalSections, chartOptions.grid.lineWidth); + context.beginPath(); + context.moveTo(0, gy); + context.lineTo(dimensions.width, gy); + context.stroke(); + } + // Bounding rectangle. + if (chartOptions.grid.borderVisible) { + context.strokeRect(0, 0, dimensions.width, dimensions.height); + } + context.restore(); + + // Draw any horizontal lines... + if (chartOptions.horizontalLines && chartOptions.horizontalLines.length) { + for (var hl = 0; hl < chartOptions.horizontalLines.length; hl++) { + var line = chartOptions.horizontalLines[hl], + lineWidth = line.lineWidth || 1, + hly = valueToYPosition(line.value, lineWidth); + context.strokeStyle = line.color || '#ffffff'; + context.lineWidth = lineWidth; + context.beginPath(); + context.moveTo(0, hly); + context.lineTo(dimensions.width, hly); + context.stroke(); + } + } + + // For each data set... + for (var d = 0; d < this.seriesSet.length; d++) { + var timeSeries = this.seriesSet[d].timeSeries, + dataSet = timeSeries.data; + + // Delete old data that's moved off the left of the chart. + timeSeries.dropOldData(oldestValidTime, chartOptions.maxDataSetLength); + if (dataSet.length <= 1 || timeSeries.disabled) { + continue; + } + context.save(); + + var seriesOptions = this.seriesSet[d].options, + // Keep in mind that `context.lineWidth = 0` doesn't actually set it to `0`. + drawStroke = seriesOptions.strokeStyle && seriesOptions.strokeStyle !== 'none', + lineWidthMaybeZero = drawStroke ? seriesOptions.lineWidth : 0; + + // Draw the line... + context.beginPath(); + // Retain lastX, lastY for calculating the control points of bezier curves. + var firstX = timeToXPosition(dataSet[0][0], lineWidthMaybeZero), + firstY = valueToYPosition(dataSet[0][1], lineWidthMaybeZero), + lastX = firstX, + lastY = firstY, + draw; + context.moveTo(firstX, firstY); + switch (seriesOptions.interpolation || chartOptions.interpolation) { + case "linear": + case "line": { + draw = function(x, y, lastX, lastY) { + context.lineTo(x,y); + } + break; + } + case "bezier": + default: { + // Great explanation of Bezier curves: http://en.wikipedia.org/wiki/Bezier_curve#Quadratic_curves + // + // Assuming A was the last point in the line plotted and B is the new point, + // we draw a curve with control points P and Q as below. + // + // A---P + // | + // | + // | + // Q---B + // + // Importantly, A and P are at the same y coordinate, as are B and Q. This is + // so adjacent curves appear to flow as one. + // + draw = function(x, y, lastX, lastY) { + context.bezierCurveTo( // startPoint (A) is implicit from last iteration of loop + Math.round((lastX + x) / 2), lastY, // controlPoint1 (P) + Math.round((lastX + x)) / 2, y, // controlPoint2 (Q) + x, y); // endPoint (B) + } + break; + } + case "step": { + draw = function(x, y, lastX, lastY) { + context.lineTo(x,lastY); + context.lineTo(x,y); + } + break; + } + } + + for (var i = 1; i < dataSet.length; i++) { + var iThData = dataSet[i], + x = timeToXPosition(iThData[0], lineWidthMaybeZero), + y = valueToYPosition(iThData[1], lineWidthMaybeZero); + draw(x, y, lastX, lastY); + lastX = x; lastY = y; + } + + if (drawStroke) { + context.lineWidth = seriesOptions.lineWidth; + context.strokeStyle = seriesOptions.strokeStyle; + context.stroke(); + } + + if (seriesOptions.fillStyle) { + // Close up the fill region. + var fillEndY = seriesOptions.fillToBottom + ? dimensions.height + lineWidthMaybeZero + 1 + : valueToYPosition(0, 0); + context.lineTo(lastX, fillEndY); + context.lineTo(firstX, fillEndY); + + context.fillStyle = seriesOptions.fillStyle; + context.fill(); + } + + context.restore(); + } + + if (chartOptions.tooltip && this.mouseX >= 0) { + // Draw vertical bar to show tooltip position + context.lineWidth = chartOptions.tooltipLine.lineWidth; + context.strokeStyle = chartOptions.tooltipLine.strokeStyle; + context.beginPath(); + context.moveTo(this.mouseX, 0); + context.lineTo(this.mouseX, dimensions.height); + context.stroke(); + } + this.updateTooltip(); + + var labelsOptions = chartOptions.labels; + // Draw the axis values on the chart. + if (!labelsOptions.disabled && !isNaN(this.valueRange.min) && !isNaN(this.valueRange.max)) { + var maxValueString = chartOptions.yMaxFormatter(this.valueRange.max, labelsOptions.precision), + minValueString = chartOptions.yMinFormatter(this.valueRange.min, labelsOptions.precision), + maxLabelPos = chartOptions.scrollBackwards ? 0 : dimensions.width - context.measureText(maxValueString).width - 2, + minLabelPos = chartOptions.scrollBackwards ? 0 : dimensions.width - context.measureText(minValueString).width - 2; + context.fillStyle = labelsOptions.fillStyle; + context.fillText(maxValueString, maxLabelPos, labelsOptions.fontSize); + context.fillText(minValueString, minLabelPos, dimensions.height - 2); + } + + // Display intermediate y axis labels along y-axis to the left of the chart + if ( labelsOptions.showIntermediateLabels + && !isNaN(this.valueRange.min) && !isNaN(this.valueRange.max) + && chartOptions.grid.verticalSections > 0) { + // show a label above every vertical section divider + var step = (this.valueRange.max - this.valueRange.min) / chartOptions.grid.verticalSections; + var stepPixels = dimensions.height / chartOptions.grid.verticalSections; + for (var v = 1; v < chartOptions.grid.verticalSections; v++) { + var gy = dimensions.height - Math.round(v * stepPixels), + yValue = chartOptions.yIntermediateFormatter(this.valueRange.min + (v * step), labelsOptions.precision), + //left of right axis? + intermediateLabelPos = + labelsOptions.intermediateLabelSameAxis + ? (chartOptions.scrollBackwards ? 0 : dimensions.width - context.measureText(yValue).width - 2) + : (chartOptions.scrollBackwards ? dimensions.width - context.measureText(yValue).width - 2 : 0); + + context.fillText(yValue, intermediateLabelPos, gy - chartOptions.grid.lineWidth); + } + } + + // Display timestamps along x-axis at the bottom of the chart. + if (chartOptions.timestampFormatter && chartOptions.grid.millisPerLine > 0) { + var textUntilX = chartOptions.scrollBackwards + ? context.measureText(minValueString).width + : dimensions.width - context.measureText(minValueString).width + 4; + for (var t = time - (time % chartOptions.grid.millisPerLine); + t >= oldestValidTime; + t -= chartOptions.grid.millisPerLine) { + var gx = timeToXPosition(t, 0); + // Only draw the timestamp if it won't overlap with the previously drawn one. + if ((!chartOptions.scrollBackwards && gx < textUntilX) || (chartOptions.scrollBackwards && gx > textUntilX)) { + // Formats the timestamp based on user specified formatting function + // SmoothieChart.timeFormatter function above is one such formatting option + var tx = new Date(t), + ts = chartOptions.timestampFormatter(tx), + tsWidth = context.measureText(ts).width; + + textUntilX = chartOptions.scrollBackwards + ? gx + tsWidth + 2 + : gx - tsWidth - 2; + + context.fillStyle = chartOptions.labels.fillStyle; + if(chartOptions.scrollBackwards) { + context.fillText(ts, gx, dimensions.height - 2); + } else { + context.fillText(ts, gx - tsWidth, dimensions.height - 2); + } + } + } + } + + // Display title. + if (chartOptions.title.text !== '') { + context.font = chartOptions.title.fontSize + 'px ' + chartOptions.title.fontFamily; + var titleXPos = chartOptions.scrollBackwards ? dimensions.width - context.measureText(chartOptions.title.text).width - 2 : 2; + if (chartOptions.title.verticalAlign == 'bottom') { + context.textBaseline = 'bottom'; + var titleYPos = dimensions.height; + } else if (chartOptions.title.verticalAlign == 'middle') { + context.textBaseline = 'middle'; + var titleYPos = dimensions.height / 2; + } else { + context.textBaseline = 'top'; + var titleYPos = 0; + } + context.fillStyle = chartOptions.title.fillStyle; + context.fillText(chartOptions.title.text, titleXPos, titleYPos); + } + + context.restore(); // See .save() above. + }; + + // Sample timestamp formatting function + SmoothieChart.timeFormatter = function(date) { + function pad2(number) { return (number < 10 ? '0' : '') + number } + return pad2(date.getHours()) + ':' + pad2(date.getMinutes()) + ':' + pad2(date.getSeconds()); + }; + + exports.TimeSeries = TimeSeries; + exports.SmoothieChart = SmoothieChart; + +})(typeof exports === 'undefined' ? this : exports); + diff --git a/src/opnsense/www/js/widgets/BaseTableWidget.js b/src/opnsense/www/js/widgets/BaseTableWidget.js new file mode 100644 index 000000000..540a9643b --- /dev/null +++ b/src/opnsense/www/js/widgets/BaseTableWidget.js @@ -0,0 +1,194 @@ +import BaseWidget from "./BaseWidget.js"; + +export default class BaseTableWidget extends BaseWidget { + constructor() { + super(); + + this.options = null; + this.data = []; + + this.curSize = null; + this.sizeStates = { + 0: { + '.flextable-row': {'padding': ''}, + '.header .flex-row': {'border-bottom': 'solid 1px'}, + '.flex-row': {'width': '100%'}, + '.column': {'width': '100%'}, + '.flex-cell': {'width': '100%'}, + }, + 500: { + '.flextable-row': {'padding': '0.5em 0.5em'}, + '.header .flex-row': {'border-bottom': ''}, + '.flex-row': {'width': this._calculateColumnWidth.bind(this)}, + '.column': {'width': ''}, + '.flex-cell': {'width': ''}, + } + } + this.widths = Object.keys(this.sizeStates).sort(); + + this.flextableId = Math.random().toString(36).substring(7); + this.$flextable = null; + this.$headerContainer = null; + this.headers = new Set(); + } + + _calculateColumnWidth() { + if (this.options !== null && this.data !== null) { + switch (this.options.headerPosition) { + case 'none': + return `calc(100% / ${this.data[0].length})`; + case 'top': + return `calc(100% / ${Object.keys(this.data[0]).length})`; + case 'left': + return `calc(100% / 2)`; + } + } + + return ''; + } + + 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}, ...] + * none: no headers. Data layout: [[value1, value2], ...] + */ + this.options = { + headerPosition: 'top', + ...options // merge and override defaults + } + } + + updateTable(data = [], clear = true) { + let $table = $(`#${this.flextableId}`); + + if (clear) { + $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'); + return null; + } + + if (rowType === 'nested' && this.options.headerPosition === 'none') { + console.error('Nested data requires headers'); + return null; + } + + if (rowType === 'flat') { + let $row = $(`
`) + for (const item of row) { + $row.append($(` +
${item}
+ `)); + } + $table.append($row); + } else { + if (this.options.headerPosition === 'top') { + let $flextableRow = $(`
`); + for (const [h, c] of Object.entries(row)) { + if (!this.headers.has(h)) { + this.$headerContainer.append($(` +
${h}
+ `)); + this.headers.add(h); + } + if (Array.isArray(c)) { + let $column = $('
'); + for (const item of c) { + $column.append($(` +
+
${item}
+
+ `)); + } + $flextableRow.append($column); + } else { + $flextableRow.append($(` +
${c}
+ `)); + } + } + $table.append($flextableRow); + } else if (this.options.headerPosition === 'left') { + for (const [h, c] of Object.entries(row)) { + if (Array.isArray(c)) { + // nested column + let $row = $('
'); + $row.append($(` +
${h}
+ `)); + let $column = $('
'); + for (const item of c) { + $column.append($(` +
+
${item}
+
+ `)); + } + $table.append($row.append($column)); + } else { + $table.append($(` +
+
${h}
+
${c}
+
+ `)); + } + } + } + } + + } + } + + _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); + } + } + + getMarkup() { + this._constructTable(); + return $(this.$flextable); + } + + async onMarkupRendered() { + + } + + onWidgetResize(elem, width, height) { + let lowIndex = 0; + for (let i = 0; i < this.widths.length; i++) { + if (this.widths[i] <= width) { + lowIndex = i; + } else { + break; + } + } + + const lowIndexWidth = this.widths[lowIndex]; + if (lowIndexWidth !== this.curSize) { + for (const [selector, styles] of Object.entries(this.sizeStates[lowIndexWidth])) { + $(elem).find(selector).css(styles); + } + this.curSize = lowIndexWidth; + return true; + } + + return false; + } +} diff --git a/src/opnsense/www/js/widgets/BaseWidget.js b/src/opnsense/www/js/widgets/BaseWidget.js new file mode 100644 index 000000000..6e38288ca --- /dev/null +++ b/src/opnsense/www/js/widgets/BaseWidget.js @@ -0,0 +1,42 @@ +export default class BaseWidget { + constructor(config) { + this.config = config; + this.title = ""; + this.id = null; + this.tickTimeout = 5000; // Default tick timeout + } + + setId(id) { + this.id = id; + } + + getGridOptions() { + // per-widget gridstack options override + return {}; + } + + getMarkup() { + return $(""); + } + + async onMarkupRendered() { + return null; + } + + onWidgetResize(elem, width, height) { + return false; + } + + async onWidgetTick() { + return null; + } + + onWidgetClose() { + return null; + } + + /* For testing purposes */ + sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } +} diff --git a/src/opnsense/www/js/widgets/Interfaces.js b/src/opnsense/www/js/widgets/Interfaces.js new file mode 100644 index 000000000..764b460c1 --- /dev/null +++ b/src/opnsense/www/js/widgets/Interfaces.js @@ -0,0 +1,140 @@ +// endpoint:/api/interfaces/overview/* + +/** + * 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 Interfaces extends BaseTableWidget { + constructor() { + super(); + this.title = "Interfaces"; + } + + getGridOptions() { + return { + // trigger overflow-y:scroll after 650px height + sizeToContent: 650 + } + } + + getMarkup() { + let options = { + headerPosition: 'none' + } + + this.setTableOptions(options); + return super.getMarkup(); + } + + async onMarkupRendered() { + ajaxGet('/api/interfaces/overview/interfacesInfo', {}, (data, status) => { + let rows = []; + data.rows.map((intf_data) => { + if (!intf_data.hasOwnProperty('config') || intf_data.enabled == false) { + return; + } + + if (intf_data.config.hasOwnProperty('virtual') && intf_data.config.virtual == '1') { + return; + } + + let row = []; + + let symbol = ''; + switch (intf_data.link_type) { + case 'ppp': + symbol = 'fa fa-mobile'; + break; + case 'wireless': + symbol = 'fa fa-signal'; + break; + default: + symbol = 'fa fa-exchange'; + break; + } + + row.push($(` +
+ + + ${intf_data.description} + +
+ `).prop('outerHTML')); + + let media = (!'media' in intf_data ? intf_data.cell_mode : intf_data.media) ?? ''; + row.push($(` +
+
${media}
+
+ `).prop('outerHTML')); + + let ipv4 = ''; + let ipv6 = ''; + if ('ipv4' in intf_data && intf_data.ipv4.length > 0) { + ipv4 = intf_data.ipv4[0].ipaddr; + } + + if ('ipv6' in intf_data && intf_data.ipv6.length > 0) { + ipv6 = intf_data.ipv6[0].ipaddr; + } + + row.push($(` +
+ ${ipv4} +
+ ${ipv6} +
+ `).prop('outerHTML')); + + rows.push(row); + }); + + super.updateTable(rows); + + $('[data-toggle="tooltip"]').tooltip(); + + return super.onMarkupRendered(); + }); + + } + + onWidgetResize(elem, width, height) { + if (width > 500) { + $('.interface-info-detail').parent().show(); + $('.interface-info').css('justify-content', 'initial'); + $('.interface-info').css('text-align', 'left'); + } else { + $('.interface-info-detail').parent().hide(); + $('.interface-info').css('justify-content', 'center'); + $('.interface-info').css('text-align', 'center'); + } + + return super.onWidgetResize(elem, width, height); + } +}