diff --git a/src/opnsense/mvc/app/controllers/OPNsense/Core/Api/DashboardController.php b/src/opnsense/mvc/app/controllers/OPNsense/Core/Api/DashboardController.php
index 97c2b923767..9f86d50e92f 100644
--- a/src/opnsense/mvc/app/controllers/OPNsense/Core/Api/DashboardController.php
+++ b/src/opnsense/mvc/app/controllers/OPNsense/Core/Api/DashboardController.php
@@ -163,7 +163,7 @@ public function saveWidgetsAction()
$dashboard = json_encode($this->request->getPost());
if (strlen($dashboard) > (1024 * 1024)) {
// prevent saving large blobs of data
- $result['message'] = 'dashboard size limit reached';
+ $result['message'] = 'Dashboard size limit reached';
} elseif (($node = $this->usermdl->getUserByName($this->getUserName())) !== null) {
$node->dashboard = base64_encode($dashboard);
if ($this->usermdl->serializeToConfig(false, true)) {
diff --git a/src/opnsense/www/js/opnsense_widget_manager.js b/src/opnsense/www/js/opnsense_widget_manager.js
index ea65f7ca96f..9650c40c75e 100644
--- a/src/opnsense/www/js/opnsense_widget_manager.js
+++ b/src/opnsense/www/js/opnsense_widget_manager.js
@@ -414,6 +414,7 @@ class WidgetManager {
$('.link-handle').hide();
$('.close-handle').show();
$('.edit-handle').show();
+ $('.title-invisible').css('display', '');
}
changed = true;
@@ -808,6 +809,19 @@ class WidgetManager {
$option.append($(`
${value.title}
`));
$option.append($select);
break;
+ case 'textarea':
+ let $textarea = $(``);
+ $option.append($textarea);
+ break;
default:
console.error('Unknown option type', value.type);
continue;
@@ -816,28 +830,33 @@ class WidgetManager {
$content.append($option);
}
+ const hasTextarea = Object.values(options).some(v => v.type === 'textarea');
+ const modalTitle = widget.dialogTitle || this.gettext.options;
// present widget options
BootstrapDialog.show({
- title: this.gettext.options,
+ title: modalTitle,
draggable: true,
animate: false,
message: $content,
buttons: [{
label: this.gettext.ok,
- hotkey: 13,
+ hotkey: hasTextarea ? undefined : 13,
action: async (dialog) => {
let values = {};
for (const [key, value] of Object.entries(options)) {
switch (value.type) {
case 'select':
values[key] = $(`#${value.id}`).val() ?? value.default;
- break;
+ break;
case 'select_multiple':
values[key] = $(`#${value.id}`).val();
if (values[key].count === 0) {
values[key] = value.default;
}
break;
+ case 'textarea':
+ values[key] = $(`#${value.id}`).val() ?? value.default;
+ break;
default:
console.error('Unknown option type', value.type);
}
diff --git a/src/opnsense/www/js/widgets/BaseWidget.js b/src/opnsense/www/js/widgets/BaseWidget.js
index 7987dbdf4ce..525c898eff3 100644
--- a/src/opnsense/www/js/widgets/BaseWidget.js
+++ b/src/opnsense/www/js/widgets/BaseWidget.js
@@ -106,6 +106,10 @@ class BaseWidget {
return {};
}
+ get dialogTitle() {
+ return null;
+ }
+
getMarkup() {
return $("");
}
diff --git a/src/opnsense/www/js/widgets/Metadata/Core.xml b/src/opnsense/www/js/widgets/Metadata/Core.xml
index d3864af5317..262b7abee36 100644
--- a/src/opnsense/www/js/widgets/Metadata/Core.xml
+++ b/src/opnsense/www/js/widgets/Metadata/Core.xml
@@ -385,4 +385,11 @@
N/A
+
+ Notes.js
+
+ Notes
+ Note editor
+
+
diff --git a/src/opnsense/www/js/widgets/Notes.js b/src/opnsense/www/js/widgets/Notes.js
new file mode 100644
index 00000000000..a5077742b26
--- /dev/null
+++ b/src/opnsense/www/js/widgets/Notes.js
@@ -0,0 +1,73 @@
+/*
+ * Copyright (C) 2026 Konstantinos Spartalis (cspartalis@potatonetworks.com)
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
+ * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
+ * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+ * AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
+ * OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ */
+
+export default class Notes extends BaseWidget {
+ constructor(config) {
+ super(config);
+ this.configurable = true;
+ }
+
+ getGridOptions() {
+ return {
+ minH: 82,
+ }
+ }
+
+ get dialogTitle() {
+ return this.translations.titleedit;
+ }
+
+ getMarkup() {
+ return $(`
+
+ `);
+ }
+
+ async getWidgetOptions() {
+ return {
+ note: {
+ title: this.translations.title,
+ type: 'textarea',
+ id: `notes-option-${this.id}`,
+ default: '',
+ },
+ };
+ }
+
+ async onWidgetOptionsChanged(options) {
+ $(`#notes-text-${this.id}`).text(options.note || '');
+ this.config.callbacks.updateGrid();
+ }
+
+ async onMarkupRendered() {
+ const config = await this.getWidgetConfig();
+ const note = config.note || '';
+
+ $(`#notes-text-${this.id}`).text(note);
+ }
+}