luci-mod-system: implement plugin UI architecture

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 <newtwen+github@gmail.com>
This commit is contained in:
Paul Donald
2026-02-04 21:15:32 +01:00
parent a5bedae648
commit 617f364333
12 changed files with 431 additions and 2 deletions

View File

@@ -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

View File

@@ -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();
}
});

View File

@@ -0,0 +1,2 @@
config global 'global'

View File

@@ -85,6 +85,18 @@
}
},
"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,

View File

@@ -56,6 +56,15 @@
}
},
"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": {

View File

@@ -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

View File

@@ -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);
}
});

View File

@@ -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);
}
});

View File

@@ -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);
}
});

View File

@@ -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);
}
});

View File

@@ -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;

View File

@@ -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;