luci-mod-system: implement plugin UI architecture
authorPaul Donald <redacted>
Wed, 4 Feb 2026 20:15:32 +0000 (21:15 +0100)
committerPaul Donald <redacted>
Mon, 30 Mar 2026 22:13:54 +0000 (00:13 +0200)
include some example plugins also.
JS files provide UI to configure behaviour of plugins
which typically live in

/usr/share/ucode/luci/plugins/<class>/<type>

Signed-off-by: Paul Donald <redacted>
12 files changed:
luci.mk
modules/luci-mod-system/htdocs/luci-static/resources/view/system/plugins.js [new file with mode: 0644]
modules/luci-mod-system/root/etc/config/luci_plugins [new file with mode: 0644]
modules/luci-mod-system/root/usr/share/luci/menu.d/luci-mod-system.json
modules/luci-mod-system/root/usr/share/rpcd/acl.d/luci-mod-system.json
plugins/plugins-example/Makefile [new file with mode: 0644]
plugins/plugins-example/htdocs/luci-static/resources/view/plugins/0aef1fa8f9a045bdaf51a35ce99eb5c5.js [new file with mode: 0644]
plugins/plugins-example/htdocs/luci-static/resources/view/plugins/263fe72d7e834fa99a82639ed0d9e3bd.js [new file with mode: 0644]
plugins/plugins-example/htdocs/luci-static/resources/view/plugins/3ed2ee077c4941f8ab394106fd95ad9d.js [new file with mode: 0644]
plugins/plugins-example/htdocs/luci-static/resources/view/plugins/6c4b5551b62b4bc8a3053fb519d71d5f.js [new file with mode: 0644]
plugins/plugins-example/ucode/plugins/http/headers/0aef1fa8f9a045bdaf51a35ce99eb5c5.uc [new file with mode: 0644]
plugins/plugins-example/ucode/plugins/http/headers/263fe72d7e834fa99a82639ed0d9e3bd.uc [new file with mode: 0644]

diff --git a/luci.mk b/luci.mk
index 27e5302cbad51fa285934a5b69f0a788395869f8..27f9a8bf55679086159c349bbae53ab15850ef6f 100644 (file)
--- a/luci.mk
+++ b/luci.mk
@@ -65,8 +65,9 @@ LUCI_MENU.col=1. Collections
 LUCI_MENU.mod=2. Modules
 LUCI_MENU.app=3. Applications
 LUCI_MENU.theme=4. Themes
-LUCI_MENU.proto=5. Protocols
-LUCI_MENU.lib=6. Libraries
+LUCI_MENU.plugin=5. Plugins
+LUCI_MENU.proto=6. Protocols
+LUCI_MENU.lib=7. Libraries
 
 # Language aliases
 LUCI_LC_ALIAS.bn_BD=bn
diff --git a/modules/luci-mod-system/htdocs/luci-static/resources/view/system/plugins.js b/modules/luci-mod-system/htdocs/luci-static/resources/view/system/plugins.js
new file mode 100644 (file)
index 0000000..424b3d7
--- /dev/null
@@ -0,0 +1,148 @@
+'use strict';
+'require dom';
+'require form';
+'require fs';
+'require uci';
+'require view';
+
+// const plugins_path = '/usr/share/ucode/luci/plugins';
+const view_plugins = `/www/${L.resource('view/plugins')}`;
+
+const luci_plugins = 'luci_plugins';
+
+return view.extend({
+       load() {
+               return Promise.all([
+                       L.resolveDefault(fs.list(`/www/${L.resource('view/plugins')}`), []).then((entries) => {
+                               return Promise.all(entries.filter((e) => {
+                                       return (e.type == 'file' && e.name.match(/\.js$/));
+                               }).map((e) => {
+                                       return 'view.plugins.' + e.name.replace(/\.js$/, '');
+                               }).sort().map((n) => {
+                                       return L.require(n);
+                               }));
+                       }),
+                       uci.load(luci_plugins),
+               ])
+       },
+
+       render([plugins]) {
+               let m, s, o, p_enabled;
+               const groups = new Set();
+
+               // Set global uci config if absent
+               if (!uci.get(luci_plugins, 'global')) {
+                       uci.add(luci_plugins, 'global', 'global');
+               }
+
+               for (let plugin of plugins) {
+                       const name = plugin.id;
+                       const class_type = `${plugin.class}_${plugin.type}`;
+                       const class_type_i18n = `${plugin.class_i18n} ${plugin.type_i18n}`
+                       groups.add(class_type);
+                       groups[class_type] = class_type_i18n;
+                       plugins[plugin.id] = plugin;
+
+                       // Set basic uci config for each plugin if absent
+                       if (!uci.get(luci_plugins, plugin.id)) {
+                               // add the plugin via its uuid under its class+type for filtering
+                               uci.add(luci_plugins, class_type, plugin.id);
+                               uci.set(luci_plugins, plugin.id, 'name', plugin.name);
+                       }
+               }
+
+               m = new form.Map(luci_plugins, _('Plugins'));
+               m.tabbed = true;
+
+               s = m.section(form.NamedSection, 'global', 'global', _('Global Settings'));
+
+               o = s.option(form.Flag, 'enabled', _('Enabled'));
+               o.default = o.disabled;
+               o.optional = true;
+
+               for (const group of new Set([...groups].sort())) {
+                       o = s.option(form.Flag, group + '_enabled', groups[group] + ' ' + _('Enabled'));
+                       o.default = o.disabled;
+                       o.optional = true;
+               }
+
+               for (const group of new Set([...groups].sort())) {
+
+                       s = m.section(form.GridSection, group, groups[group]);
+
+                       s.sectiontitle = function(section_id) {
+                               const plugin = plugins[section_id];
+
+                               return plugin.title;
+                       };
+
+                       p_enabled = s.option(form.Flag, 'enabled', _('Enabled'));
+                       p_enabled.editable = true;
+                       p_enabled.modalonly = false;
+                       p_enabled.renderWidget = function(section_id, option_index, cfgvalue) {
+                               const widget = form.Flag.prototype.renderWidget.apply(this, [section_id, option_index, cfgvalue]);
+
+                               widget.querySelector('input[type="checkbox"]').addEventListener('click', L.bind(function(section_id, plugin, ev) {
+                                       if (ev.target.checked && plugin && plugin.addFormOptions)
+                                               this.section.renderMoreOptionsModal(section_id);
+                               }, this, section_id, plugins[section_id]));
+
+                               return widget;
+                       };
+
+                       o = s.option(form.DummyValue, '_dummy', _('Status'));
+                       o.width = '50%';
+                       o.modalonly = false;
+                       o.textvalue = function(section_id) {
+                               const section = uci.get(luci_plugins, section_id);
+                               const plugin = plugins[section_id];
+
+                               if (section.enabled != '1')
+                                       return E('em', {}, [_('Plugin is disabled')]);
+
+                               const  summary = plugin ? plugin.configSummary(section) : null;
+                               return summary || E('em', _('none'));
+                       };
+
+                       s.modaltitle = function(section_id) {
+                               const plugin = plugins[section_id];
+
+                               return plugin ? plugin.title : null;
+                       };
+
+                       s.addModalOptions = function(s) {
+                               const name = s.section;
+                               const plugin = plugins[name];
+
+                               if (!plugin)
+                                       return;
+
+                               s.description = plugin.description;
+
+                               plugin.addFormOptions(s);
+
+                               const opt = s.children.filter(function(o) { return o.option == 'enabled' })[0];
+                               if (opt)
+                                       opt.cfgvalue = function(section_id, set_value) {
+                                               if (arguments.length == 2)
+                                                       return form.Flag.prototype.cfgvalue.apply(this, [section_id, p_enabled.formvalue(section_id)]);
+                                               else
+                                                       return form.Flag.prototype.cfgvalue.apply(this, [section_id]);
+                                       };
+                       };
+
+                       s.renderRowActions = function(section_id) {
+                               const plugin = plugins[section_id];
+
+                               const trEl = this.super('renderRowActions', [ section_id, _('Configure…') ]);
+
+                               if (!plugin || !plugin.addFormOptions)
+                                       dom.content(trEl, null);
+
+                               return trEl;
+                       };
+               }
+
+               return m.render();
+       }
+});
diff --git a/modules/luci-mod-system/root/etc/config/luci_plugins b/modules/luci-mod-system/root/etc/config/luci_plugins
new file mode 100644 (file)
index 0000000..2271b8e
--- /dev/null
@@ -0,0 +1,2 @@
+
+config global 'global'
index 0822d44845c66539f8b81ad2d8adf40ccdd3a555..488a36b83555f4bc63fbf57df57a02dc6bdad744 100644 (file)
                }
        },
 
+       "admin/system/plugins": {
+               "title": "Plugins",
+               "order": 3,
+               "action": {
+                       "type": "view",
+                       "path": "system/plugins"
+               },
+               "depends": {
+                       "acl": [ "luci-mod-system-plugins" ]
+               }
+       },
+
        "admin/system/startup": {
                "title": "Startup",
                "order": 45,
index 379d89fc6fcbc283dc974f65cd70590af1bb5292..45c92b1747dd9d2c11d7bea15eecb6704c61948e 100644 (file)
                }
        },
 
+       "luci-mod-system-plugins": {
+               "description": "Grant access to Plugin management",
+               "read": {
+                       "file": {
+                               "/usr/share/ucode/luci/*": [ "read" ]
+                       }
+               }
+       },
+
        "luci-mod-system-uhttpd": {
                "description": "Grant access to uHTTPd configuration",
                "read": {
diff --git a/plugins/plugins-example/Makefile b/plugins/plugins-example/Makefile
new file mode 100644 (file)
index 0000000..1cb9555
--- /dev/null
@@ -0,0 +1,19 @@
+#
+# Copyright (C) 2026
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+include $(TOPDIR)/rules.mk
+
+LUCI_TITLE:=LuCI Plugins - HTTP Headers examples and HTTP 2FA UI example
+LUCI_DEPENDS:=+luci-base +luci-mod-system
+
+LUCI_TYPE:=plugin
+
+PKG_LICENSE:=Apache-2.0
+
+include ../../luci.mk
+
+# call BuildPackage - OpenWrt buildroot signature
+
diff --git a/plugins/plugins-example/htdocs/luci-static/resources/view/plugins/0aef1fa8f9a045bdaf51a35ce99eb5c5.js b/plugins/plugins-example/htdocs/luci-static/resources/view/plugins/0aef1fa8f9a045bdaf51a35ce99eb5c5.js
new file mode 100644 (file)
index 0000000..3f5cbfd
--- /dev/null
@@ -0,0 +1,44 @@
+'use strict';
+'require baseclass';
+'require form';
+
+/*
+class, type, name and id are used to build a reference for the uci config. E.g.
+
+config http_headers '0aef1fa8f9a045bdaf51a35ce99eb5c5'
+       option name 'X-Foobar'
+       ...
+
+*/
+
+return baseclass.extend({
+
+       class: 'http',
+       class_i18n: _('HTTP'),
+
+       type: 'headers',
+       type_i18n: _('Headers'),
+
+       name: 'X-Foobar', // to make visual ID in UCI config easy
+       id: '0aef1fa8f9a045bdaf51a35ce99eb5c5', // cat /proc/sys/kernel/random/uuid | tr -d - 
+       title: _('X-Foobar Example Plugin'),
+       description: _('This plugin sets an X-Foobar HTTP header.'),
+
+       addFormOptions(s) {
+               let o;
+
+               o = s.option(form.Flag, 'enabled', _('Enabled'));
+
+               o = s.option(form.Value, 'foo', _('Foo'));
+               o.default = 'foo';
+               o.depends('enabled', '1');
+
+               o = s.option(form.Value, 'bar', _('Bar'));
+               o.default = '4000';
+               o.depends('enabled', '1');
+       },
+
+       configSummary(section) {
+               return _('I am class %s, type %s, name %s, bar: %d').format(this.class_i18n, this.type_i18n, this.name, section.bar || 1000);
+       }
+});
diff --git a/plugins/plugins-example/htdocs/luci-static/resources/view/plugins/263fe72d7e834fa99a82639ed0d9e3bd.js b/plugins/plugins-example/htdocs/luci-static/resources/view/plugins/263fe72d7e834fa99a82639ed0d9e3bd.js
new file mode 100644 (file)
index 0000000..81829ef
--- /dev/null
@@ -0,0 +1,44 @@
+'use strict';
+'require baseclass';
+'require form';
+
+/*
+class, type, name and id are used to build a reference for the uci config. E.g.
+
+config http_headers '263fe72d7e834fa99a82639ed0d9e3bd'
+       option name 'X-Example'
+       ...
+
+*/
+
+return baseclass.extend({
+
+       class: 'http',
+       class_i18n: _('HTTP'),
+
+       type: 'headers',
+       type_i18n: _('Headers'),
+
+       name: 'X-Example', // to make visual ID in UCI config easy
+       id: '263fe72d7e834fa99a82639ed0d9e3bd', // cat /proc/sys/kernel/random/uuid | tr -d -
+       title: _('X-Example Example Plugin'),
+       description: _('This plugin sets an X-Example HTTP header.'),
+
+       addFormOptions(s) {
+               let o;
+
+               o = s.option(form.Flag, 'enabled', _('Enabled'));
+
+               o = s.option(form.Value, 'foo', _('Foo'));
+               o.default = 'foo';
+               o.depends('enabled', '1');
+
+               o = s.option(form.Value, 'bar', _('Bar'));
+               o.default = '3000';
+               o.depends('enabled', '1');
+       },
+
+       configSummary(section) {
+               return _('I am class %s, type %s, name %s, bar: %d').format(this.class_i18n, this.type_i18n, this.name, section.bar || 1000);
+       }
+});
diff --git a/plugins/plugins-example/htdocs/luci-static/resources/view/plugins/3ed2ee077c4941f8ab394106fd95ad9d.js b/plugins/plugins-example/htdocs/luci-static/resources/view/plugins/3ed2ee077c4941f8ab394106fd95ad9d.js
new file mode 100644 (file)
index 0000000..300c374
--- /dev/null
@@ -0,0 +1,44 @@
+'use strict';
+'require baseclass';
+'require form';
+
+/*
+class, type, name and id are used to build a reference for the uci config. E.g.
+
+config foo_bar '3ed2ee077c4941f8ab394106fd95ad9d'
+       option name 'Chonki Boi'
+       ...
+
+*/
+
+return baseclass.extend({
+
+       class: 'foo',
+       class_i18n: _('FOO'),
+
+       type: 'bar',
+       type_i18n: _('Bar'),
+
+       name: 'Chonki Boi', // to make visual ID in UCI config easy
+       id: '3ed2ee077c4941f8ab394106fd95ad9d', // cat /proc/sys/kernel/random/uuid | tr -d -
+       title: _('Chonki Boi Example Plugin'),
+       description: _('This plugin does nothing. It is just a UI example.'),
+
+       addFormOptions(s) {
+               let o;
+
+               o = s.option(form.Flag, 'enabled', _('Enabled'));
+
+               o = s.option(form.Value, 'foo', _('Foo'));
+               o.default = 'chonkk value';
+               o.depends('enabled', '1');
+
+               o = s.option(form.Value, 'bar', _('Bar'));
+               o.default = '1000';
+               o.depends('enabled', '1');
+       },
+
+       configSummary(section) {
+               return _('I am class %s, type %s, name %s, bar: %d').format(this.class_i18n, this.type_i18n, this.name, section.bar || 1000);
+       }
+});
diff --git a/plugins/plugins-example/htdocs/luci-static/resources/view/plugins/6c4b5551b62b4bc8a3053fb519d71d5f.js b/plugins/plugins-example/htdocs/luci-static/resources/view/plugins/6c4b5551b62b4bc8a3053fb519d71d5f.js
new file mode 100644 (file)
index 0000000..832395c
--- /dev/null
@@ -0,0 +1,44 @@
+'use strict';
+'require baseclass';
+'require form';
+
+/*
+class, type, name and id are used to build a reference for the uci config. E.g.
+
+config http_auth '6c4b5551b62b4bc8a3053fb519d71d5f'
+       option name '2FA'
+       ...
+
+*/
+
+return baseclass.extend({
+
+       class: 'http',
+       class_i18n: _('HTTP'),
+
+       type: 'auth',
+       type_i18n: _('Auth'),
+
+       name: '2FA', // to make visual ID in UCI config easy
+       id: '6c4b5551b62b4bc8a3053fb519d71d5f', // cat /proc/sys/kernel/random/uuid | tr -d -
+       title: _('2FA Example Plugin'),
+       description: _('This plugin does nothing. It is just a UI example.'),
+
+       addFormOptions(s) {
+               let o;
+
+               o = s.option(form.Flag, 'enabled', _('Enabled'));
+
+               o = s.option(form.Value, 'foo', _('Foo'));
+               o.default = '2FA value';
+               o.depends('enabled', '1');
+
+               o = s.option(form.Value, 'bar', _('Bar'));
+               o.default = '2000';
+               o.depends('enabled', '1');
+       },
+
+       configSummary(section) {
+               return _('I am class %s, type %s, name %s, bar: %d').format(this.class_i18n, this.type_i18n, this.name, section.bar || 1000);
+       }
+});
diff --git a/plugins/plugins-example/ucode/plugins/http/headers/0aef1fa8f9a045bdaf51a35ce99eb5c5.uc b/plugins/plugins-example/ucode/plugins/http/headers/0aef1fa8f9a045bdaf51a35ce99eb5c5.uc
new file mode 100644 (file)
index 0000000..24aed34
--- /dev/null
@@ -0,0 +1,31 @@
+// Copyright 2026
+// SPDX-License-Identifier: Apache-2.0
+
+/*
+The plugin filename shall be the 32 character uuid in its JS config front-end.
+This allows parsing plugins against user-defined configuration. User retains
+all control over whether a plugin is active or not.
+*/
+
+'use strict';
+
+import { cursor } from 'uci';
+
+/* 
+The ucode plugin portion shall return a default action which returns a value
+and type of value appropriate for its usage class and type. For http.headers,
+it shall return a string array[] with header_name, header_value, without any
+\r or \n.
+*/
+
+function default_action(...args) {
+       const uci = cursor();
+       const str = uci.get('luci_plugins', args[0], 'bar') || '4000';
+       const value = sprintf('%s; %s', str, ...args);
+       // do stuff
+       // should produce: x-foobar: 4000; 0aef1fa8f9a045bdaf51a35ce99eb5c5
+       return ['X-Foobar', value];
+};
+
+
+return default_action;
diff --git a/plugins/plugins-example/ucode/plugins/http/headers/263fe72d7e834fa99a82639ed0d9e3bd.uc b/plugins/plugins-example/ucode/plugins/http/headers/263fe72d7e834fa99a82639ed0d9e3bd.uc
new file mode 100644 (file)
index 0000000..06cfb44
--- /dev/null
@@ -0,0 +1,31 @@
+// Copyright 2026
+// SPDX-License-Identifier: Apache-2.0
+
+/*
+The plugin filename shall be the 32 character uuid in its JS config front-end.
+This allows parsing plugins against user-defined configuration. User retains
+all control over whether a plugin is active or not.
+*/
+
+'use strict';
+
+import { cursor } from 'uci';
+
+/* 
+The ucode plugin portion shall return a default action which returns a value
+and type of value appropriate for its usage class and type. For http.headers,
+it shall return a string array[] with header_name, header_value, without any
+\r or \n.
+*/
+
+function default_action(...args) {
+       const uci = cursor();
+       const str = uci.get('luci_plugins', args[0], 'foo') || 'foo';
+       const value = sprintf('%s; %s', str, ...args);
+       // do stuff
+       // should produce: x-example: foo; 263fe72d7e834fa99a82639ed0d9e3bd
+       return ['X-Example', value];
+};
+
+
+return default_action;
git clone https://git.99rst.org/PROJECT