GSoC ’23: Migrating LuCI Apps to JavaScript: A Comprehensive Guide

The latest OpenWRT versions introduced a new web interface system that eliminates the need for lua. Instead, the client’s browser handles the rendering and computation, allowing routers to focus on their primary tasks. This change brings the advantage of eliminating the lua runtime and saving storage space, having faster routers. In the previous CBI-based system, pages were rendered on the router and sent as HTML to the browser, increasing the load on the routers. This inefficiency can result in performance problems. To aid in this transition, LuCI offers the LuCI-JavaScript API, which is now utilized for constructing web interfaces.

luci-app-olsr

I have successfully migrated luci-app-olsr to JavaScript, making it a valuable example for building or migrating LuCI apps. First and foremost, I would like to express my heartfelt thanks to my mentor Andreas Bräu. Without his unwavering support, I would not have been able to successfully migrate this huge application.

This tutorial covers the essential aspects of the process, providing a comprehensive guide. This app is an extensive application that includes both status views and an admin backend.

Below is the tree view representation of the directory structure for the app:

How to migrate your app :

ACLs

In the file root/usr/share/rpcd/acl.d/luci-app-olsr.json, we provide all the necessary access permissions for our application to function properly.

{
	"luci-app-olsr": {
		"description": "Grant UCI access for luci-app-olsr",
		"read": {
			"ubus": {
				"luci-rpc": [
					"*"
				],
				"olsrinfo": [
					"getjsondata",
					"hasipip"
				]
			},
			"file": {
				"/etc/modules.d": [
					"list",
					"read"
				],
				"/usr/lib": [ "list" ]
			},
			"uci": [
				"luci_olsr",
				"olsrd",
				"olsrd6"
			]
		},
		"write": {
			"uci": [
				"luci_olsr",
				"olsrd",
				"olsrd6"
			]
		}
	}
}

Similarly, in root/usr/share/rpcd/acl.d/luci-app-olsr-unauthenticated.json, we grant the required access permissions for our application when the user is not authenticated. This is used for status views.

To learn more about how ACL (Access Control List) works, you can refer to this resource: OpenWRT’s docs  It is important to consider applying the principle of least privilege when configuring ACLs.

MENU

In the file root/usr/share/luci/menu.d/luci-app-olsr-backend.json, we define the location where our view will be displayed in the admin menu. This is utilized for admin specific views.

{
	"admin/services/olsrd": {
		"title": "OLSR IPv4",
		"order": 5,
		"depends": {
			"acl": ["luci-app-olsr"]
		},
		"action": {
			"type": "view",
			"path": "olsr/frontend/olsrd"
		}
	},
	"admin/services/olsrd/display": {
		"title": "Display",
		"order": 10,
		"action": {
			"type": "view",
			"path": "olsr/frontend/olsrddisplay"
		}
	},
	"admin/services/olsrd/iface": {
		"order": 10,
		"action": {
			"type": "view",
			"path": "olsr/frontend/olsrdiface"
		}
	},
	"admin/services/olsrd/hna": {
		"title": "HNA Announcements",
		"order": 15,
		"action": {
			"type": "view",
			"path": "olsr/frontend/olsrdhna"
		}
	},
	"admin/services/olsrd/plugins": {
		"title": "Plugins",
		"order": 20,
		"action": {
			"type": "view",
			"path": "olsr/frontend/olsrdplugins"
		}
	},
	"admin/services/olsrd6": {
		"title": "OLSR IPv6",
		"order": 5,
		"depends": {
			"acl": ["luci-app-olsr"]
		},
		"action": {
			"type": "view",
			"path": "olsr/frontend/olsrd6"
		}
	},
	"admin/services/olsrd6/display": {
		"title": "Display",
		"order": 10,
		"action": {
			"type": "view",
			"path": "olsr/frontend/olsrddisplay"
		}
	},
	"admin/services/olsrd6/iface": {
		"order": 10,
		"action": {
			"type": "view",
			"path": "olsr/frontend/olsrdiface6"
		}
	},
	"admin/services/olsrd6/hna": {
		"title": "HNA Announcements",
		"order": 15,
		"action": {
			"type": "view",
			"path": "olsr/frontend/olsrdhna6"
		}
	},
	"admin/services/olsrd6/plugins": {
		"title": "Plugins",
		"order": 20,
		"action": {
			"type": "view",
			"path": "olsr/frontend/olsrdplugins6"
		}
	}
}

On the other hand, in root/usr/share/luci/menu.d/luci-app-olsr-frontend.json, we specify the location where our view will be displayed in the menu. This is used for the status views of our application.

The path indicates the location where the JavaScript view to be rendered is present with respect to the htdocs/luci-static/resources/view directory.

Forms and Flexible Views

By utilizing root/etc/uci-defaults/40_luci-olsr, we ensure the creation of a straightforward configuration file for our application upon installation.

To explore the JavaScript APIs offered by LuCI, you can visit the following link: LuCI client side API documentation. A recommended starting point is the core luci.js class.

FORMS

LuCI forms allow you to create UCI or JSON-backed configuration forms. To create a typical form, you start by creating an instance of either LuCI.form.Map or LuCI.form.JSONMap using new. Then, you can add sections and options to the form instance. Finally, invoking the render() method on the instance generates the HTML markup and inserts it into the Document Object Model(DOM). For a better understanding of how LuCI forms work, you can refer to the following : LuCI.form.

This is an example demonstrating the usage of LuCI.form within one of the admin’s views, using a small portion of the olsrd.js code. The full code for the file can be found here.

'use strict';
'require view';
'require form';
'require fs';
'require uci';
'require ui';
'require rpc';

return view.extend({
	callHasIpIp: rpc.declare({
		object: 'olsrinfo',
		method: 'hasipip',
	}),
	load: function () {
		return Promise.all([uci.load('olsrd').then(() => {
			var hasDefaults = false;

			uci.sections('olsrd', 'InterfaceDefaults', function (s) {
				hasDefaults = true;
				return false;
			});

			if (!hasDefaults) {
				uci.add('olsrd', 'InterfaceDefaults');
			}
		})]);
	},
	render: function () {
		var m, s, o;

		var has_ipip;

		m = new form.Map(
			'olsrd',
			_('OLSR Daemon'),
			_(
				'The OLSR daemon is an implementation of the Optimized Link State Routing protocol. ' +
					'As such it allows mesh routing for any network equipment. ' +
					'It runs on any wifi card that supports ad-hoc mode and of course on any ethernet device. ' +
					'Visit <a href="http://www.olsr.org">olsrd.org</a> for help and documentation.'
			)
		);


		s = m.section(form.TypedSection, 'olsrd', _('General settings'));
		s.anonymous = true;

		s.tab('general', _('General Settings'));
		s.tab('lquality', _('Link Quality Settings'));
		this.callHasIpIp()
		.then(function (res) {
			var output = res.result;
			has_ipip = output.trim().length > 0;
		})
		.catch(function (err) {
			console.error(err);
		})
		.finally(function () {
               
            //... This snippet represents only a small portion of the complete code.
	
		});

		s.tab('advanced', _('Advanced Settings'));

		var ipv = s.taboption('general', form.ListValue, 'IpVersion', _('Internet protocol'), _('IP-version to use. If 6and4 is selected then one olsrd instance is started for each protocol.'));
		ipv.value('4', 'IPv4');
		ipv.value('6and4', '6and4');

		var poll = s.taboption('advanced', form.Value, 'Pollrate', _('Pollrate'), _('Polling rate for OLSR sockets in seconds. Default is 0.05.'));
		poll.optional = true;
		poll.datatype = 'ufloat';
		poll.placeholder = '0.05';

		var nicc = s.taboption('advanced', form.Value, 'NicChgsPollInt', _('Nic changes poll interval'), _('Interval to poll network interfaces for configuration changes (in seconds). Default is "2.5".'));
		nicc.optional = true;
		nicc.datatype = 'ufloat';
		nicc.placeholder = '2.5';

		var tos = s.taboption('advanced', form.Value, 'TosValue', _('TOS value'), _('Type of service value for the IP header of control traffic. Default is "16".'));
		tos.optional = true;
		tos.datatype = 'uinteger';
		tos.placeholder = '16';

        //... This snippet represents only a small portion of the complete code.

		return m.render();
	},
});

Flexible Views

For enhanced flexibility in our pages, we have the option to manually define the HTML, which I have used in the status views. This approach allows us to have more control over the page structure and content, providing greater customization possibilities

This is an example demonstrating the usage of flexible views within one of the status’s views, using a small portion of the topology.js code. The full code for the file can be found here.

'use strict';
'require uci';
'require view';
'require poll';
'require rpc';
'require ui';


return view.extend({
	callGetJsonStatus: rpc.declare({
		object: 'olsrinfo',
		method: 'getjsondata',
		params: ['otable', 'v4_port', 'v6_port'],
	}),

	fetch_jsoninfo: function (otable) {
		var jsonreq4 = '';
		var jsonreq6 = '';
		var v4_port = parseInt(uci.get('olsrd', 'olsrd_jsoninfo', 'port') || '') || 9090;
		var v6_port = parseInt(uci.get('olsrd6', 'olsrd_jsoninfo', 'port') || '') || 9090;
		var json;
		var self = this;
		return new Promise(function (resolve, reject) {
			L.resolveDefault(self.callGetJsonStatus(otable, v4_port, v6_port), {})
				.then(function (res) {
					json = res;

					jsonreq4 = JSON.parse(json.jsonreq4);
					jsonreq6 = json.jsonreq6 !== '' ? JSON.parse(json.jsonreq6) : [];
					var jsondata4 = {};
					var jsondata6 = {};
					var data4 = [];
					var data6 = [];
					var has_v4 = false;
					var has_v6 = false;

					if (jsonreq4 === '' && jsonreq6 === '') {
						window.location.href = 'error_olsr';
						reject([null, 0, 0, true]);
						return;
					}

					if (jsonreq4 !== '') {
						has_v4 = true;
						jsondata4 = jsonreq4 || {};
						if (otable === 'status') {
							data4 = jsondata4;
						} else {
							data4 = jsondata4[otable] || [];
						}

						for (var i = 0; i < data4.length; i++) {
							data4[i]['proto'] = '4';
						}
					}

					if (jsonreq6 !== '') {
						has_v6 = true;
						jsondata6 = jsonreq6 || {};
						if (otable === 'status') {
							data6 = jsondata6;
						} else {
							data6 = jsondata6[otable] || [];
						}

						for (var j = 0; j < data6.length; j++) {
							data6[j]['proto'] = '6';
						}
					}

					for (var k = 0; k < data6.length; k++) {
						data4.push(data6[k]);
					}

					resolve([data4, has_v4, has_v6, false]);
				})
				.catch(function (err) {
					console.error(err);
					reject([null, 0, 0, true]);
				});
		});
	},
	action_topology: function () {
		var self = this;
		return new Promise(function (resolve, reject) {
			self
				.fetch_jsoninfo('topology')
				.then(function ([data, has_v4, has_v6, error]) {
					if (error) {
						reject(error);
					}

					function compare(a, b) {
						if (a.proto === b.proto) {
							return a.tcEdgeCost < b.tcEdgeCost;
						} else {
							return a.proto < b.proto;
						}
					}

					data.sort(compare);

					var result = { routes: data, has_v4: has_v4, has_v6: has_v6 };
					resolve(result);
				})
				.catch(function (err) {
					reject(err);
				});
		});
	},
	load: function () {
		return Promise.all([uci.load('olsrd'), uci.load('luci_olsr')]);
	},
	render: function () {
		var routes_res;
		var has_v4;
		var has_v6;

		return this.action_topology()
			.then(function (result) {
				routes_res = result.routes;
				has_v4 = result.has_v4;
				has_v6 = result.has_v6;
				var table = E('div', { 'class': 'table cbi-section-table' }, [
					E('div', { 'class': 'tr cbi-section-table-titles' }, [
						E('div', { 'class': 'th cbi-section-table-cell' }, _('OLSR node')),
						E('div', { 'class': 'th cbi-section-table-cell' }, _('Last hop')),
						E('div', { 'class': 'th cbi-section-table-cell' }, _('LQ')),
						E('div', { 'class': 'th cbi-section-table-cell' }, _('NLQ')),
						E('div', { 'class': 'th cbi-section-table-cell' }, _('ETX')),
					]),
				]);
				var i = 1;

				for (var k = 0; k < routes_res.length; k++) {
					var route = routes_res[k];
					var cost = (parseInt(route.tcEdgeCost) || 0).toFixed(3);
					var color = etx_color(parseInt(cost));
					var lq = (parseInt(route.linkQuality) || 0).toFixed(3);
					var nlq = (parseInt(route.neighborLinkQuality) || 0).toFixed(3);

					var tr = E('div', { 'class': 'tr cbi-section-table-row cbi-rowstyle-' + i + ' proto-' + route.proto }, [
						route.proto === '6'
							? E('div', { 'class': 'td cbi-section-table-cell left' }, [E('a', { 'href': 'http://[' + route.destinationIP + ']/cgi-bin-status.html' }, route.destinationIP)])
							: E('div', { 'class': 'td cbi-section-table-cell left' }, [E('a', { 'href': 'http://' + route.destinationIP + '/cgi-bin-status.html' }, route.destinationIP)]),
						route.proto === '6'
							? E('div', { 'class': 'td cbi-section-table-cell left' }, [E('a', { 'href': 'http://[' + route.lastHopIP + ']/cgi-bin-status.html' }, route.lastHopIP)])
							: E('div', { 'class': 'td cbi-section-table-cell left' }, [E('a', { 'href': 'http://' + route.lastHopIP + '/cgi-bin-status.html' }, route.lastHopIP)]),
						E('div', { 'class': 'td cbi-section-table-cell left' }, lq),
						E('div', { 'class': 'td cbi-section-table-cell left' }, nlq),
						E('div', { 'class': 'td cbi-section-table-cell left', 'style': 'background-color:' + color }, cost),
					]);

					table.appendChild(tr);
					i = (i % 2) + 1;
				}

				var fieldset = E('fieldset', { 'class': 'cbi-section' }, [E('legend', {}, _('Overview of currently known OLSR nodes')), table]);

                //... This snippet represents only a small portion of the complete code.

				var result = E([], {}, [h2, divToggleButtons, fieldset, statusOlsrLegend, statusOlsrCommonJs]);

				return result;
			})
			.catch(function (error) {
				console.error(error);
			});
	},
	handleSaveApply: null,
	handleSave: null,
});

RPCD: OpenWrt ubus RPC daemon

rpcd is the OpenWrt ubus RPC daemon responsible for the backend server. To enable the exposure of shell script functionality via ubus, the rpcd plugin utilizes executable files located in the /usr/libexec/rpcd/ directory. When rpcd is triggered, it runs these executables, allowing the execution of various methods. For instance, consider the file root/usr/libexec/rpcd/olsrinfo.sh Here we are creating two new ubus methods getjsondata & hasipip, of the object olsrinfo

#!/bin/sh                                                                                                                                                                
. /usr/share/libubox/jshn.sh                                                                                                                                             
. /lib/functions.sh                                                                                                                                                      
                                                                                                                                                                         
case "$1" in                                                                                                                                                             
  list)                                                                                                                                                                  
    json_init
    json_add_object "getjsondata"
    json_add_string 'otable' 'String'
    json_add_int 'v4_port' 'Integer'
    json_add_int 'v6_port' 'Integer'
    json_close_object
	json_add_object "hasipip"
	json_close_object
    json_dump
    ;;                                                                                                                                                                   
  call)                                                                                                                                                                  
    case "$2" in                                                                                                                                                         
      getjsondata)                                                                                                                                                       
        json_init                                                                                                                                                        
        json_load "$(cat)"                                                                                                                                               
        json_get_var otable  otable                                                                                                                                      
        json_get_var v4_port v4_port                                                                                                                                     
        json_get_var v6_port v6_port                                                                                                                                     
                                                                                                                                                                         
        jsonreq4=$(echo "/${otable}" | nc 127.0.0.1 "${v4_port}" | sed -n '/^[}{ ]/p' 2>/dev/null)                                                                       
        jsonreq6=$(echo "/${otable}" | nc ::1 "${v6_port}" | sed -n '/^[}{ ]/p' 2>/dev/null)                                                                             
                                                                                                                                                                         
        json_init                                                                                                                                                        
        json_add_string "jsonreq4" "$jsonreq4"                                                                                                                           
        json_add_string "jsonreq6" "$jsonreq6"                                                                                                                           
        json_dump                                                                                                                                                        
        ;;
	 hasipip)
        result=$(ls /etc/modules.d/ | grep -E "[0-9]*-ipip")
        json_init
        json_add_string "result" "$result"
        json_dump
        ;;                                                                                                                                                               
    esac                                                                                                                                                                 
    ;;                                                                                                                                                                   
esac  

We use these methods by declaring an rpc as follows & then by calling them which I’ve shown in the topology.js code.

callGetJsonStatus: rpc.declare({
		object: 'olsrinfo',
		method: 'getjsondata',
		params: ['otable', 'v4_port', 'v6_port'],
	})

Feel free to reach out to me via email if you have any doubts or questions. I’m here to help! Stay tuned for more valuable content as I continue to share useful information and resources. Thank you for your support!

One thought on “GSoC ’23: Migrating LuCI Apps to JavaScript: A Comprehensive Guide

Leave a Reply

Your email address will not be published. Required fields are marked *