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>
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
--- /dev/null
+'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();
+ }
+});
--- /dev/null
+
+config global 'global'
}
},
+ "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,
}
},
+ "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": {
--- /dev/null
+#
+# 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
+
--- /dev/null
+'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);
+ }
+});
--- /dev/null
+'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);
+ }
+});
--- /dev/null
+'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);
+ }
+});
--- /dev/null
+'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);
+ }
+});
--- /dev/null
+// 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;
--- /dev/null
+// 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;