luci-base: remodel the LogreadBox after the syslog viewer
authorPaul Donald <[email protected]>
Mon, 20 Oct 2025 19:11:10 +0000 (21:11 +0200)
committerPaul Donald <[email protected]>
Mon, 20 Oct 2025 19:11:10 +0000 (21:11 +0200)
Remodeled the CBILogreadBox after the syslog viewer. Also
updated to use ubus log read, and drops the use of the logread binary
(logread is broken on snapshots). The JSON output from ubus is nice
enough to work with.

One potential drawback is that all log entries are sent to the browser
(as it always has been), and no on-device pre-filtering is available yet
except for line count.

Signed-off-by: Paul Donald <[email protected]>
modules/luci-base/htdocs/luci-static/resources/tools/views.js
modules/luci-base/root/usr/share/rpcd/acl.d/luci-base.json

index f851f61dffc3b857bf2b37baaedc3155508df76c..e223600215f205ab4c6b099ce5081104b5e31427 100644 (file)
 'use strict';
-'require fs';
+'require poll';
+'require rpc';
+'require uci';
+'require ui';
+'require view';
+
+/* Note that any view implementing this log reader requires the log read
+acl permission */
+
+const callLogRead = rpc.declare({
+       object: 'log',
+       method: 'read',
+       params: [ 'lines', 'stream', 'oneshot' ],
+       expect: { log: [] }
+});
 
 var CBILogreadBox = function(logtag, name) {
        return L.view.extend({
-               load: function() {
+
+               logFacilityFilter: 'any',
+               invertLogFacilitySearch: false,
+               logSeverityFilter: 'any',
+               invertLogSeveritySearch: false,
+               logTextFilter: '',
+               invertLogTextSearch: false,
+               logTagFilter: logtag ? logtag : '',
+               logName: name ? name : _('System'),
+               fetchMaxRows: 1000,
+
+               facilities: [
+                       ['any', 'any', _('Any')],
+                       ['0',  'kern',   _('Kernel')],
+                       ['1',  'user',   _('User')],
+                       ['2',  'mail',   _('Mail')],
+                       ['3',  'daemon', _('Daemon')],
+                       ['4',  'auth',   _('Auth')],
+                       ['5',  'syslog', _('Syslog')],
+                       ['6',  'lpr',    _('LPR')],
+                       ['7',  'news',   _('News')],
+                       ['8',  'uucp',   _('UUCP')],
+                       ['9',  'cron',   _('Cron')],
+                       ['10', 'authpriv', _('Auth Priv')],
+                       ['11', 'ftp', _('FTP')],
+                       ['12', 'ntp', _('NTP')],
+                       ['13', 'security', _('Log audit')],
+                       ['14', 'console', _('Log alert')],
+                       ['15', 'cron', _('Scheduling daemon')],
+                       ['16', 'local0', _('Local 0')],
+                       ['17', 'local1', _('Local 1')],
+                       ['18', 'local2', _('Local 2')],
+                       ['19', 'local3', _('Local 3')],
+                       ['20', 'local4', _('Local 4')],
+                       ['21', 'local5', _('Local 5')],
+                       ['22', 'local6', _('Local 6')],
+                       ['23', 'local7', _('Local 7')]
+               ],
+
+               severity: [
+                       ['any','any', _('Any')],
+                       ['0',  'emerg',   _('Emergency')],
+                       ['1',  'alert',   _('Alert')],
+                       ['2',  'crit',   _('Critical')],
+                       ['3',  'err', _('Error')],
+                       ['4',  'warn',   _('Warning')],
+                       ['5',  'notice', _('Notice')],
+                       ['6',  'info',    _('Info')],
+                       ['7',  'debug',   _('Debug')]
+               ],
+
+
+               async retrieveLog() {
+                       try {
+                               const tz = uci.get('system', '@system[0]', 'zonename')?.replaceAll(' ', '_');
+                               const ts = uci.get('system', '@system[0]', 'clock_timestyle') || 0;
+                               const hc = uci.get('system', '@system[0]', 'clock_hourcycle') || 'h23';
+                               const logEntries = await callLogRead(this.fetchMaxRows, false, true);
+                               const dateObj = new Intl.DateTimeFormat(undefined, {
+                                               dateStyle: 'medium',
+                                               timeStyle: (ts == 0) ? 'long' : 'full',
+                                               hourCycle: hc,
+                                               timeZone: tz
+                               });
+
+                               let loglines = logEntries.map(entry => {
+                                       const time = new Date(entry?.time);
+                                       const datestr = dateObj.format(time);
+                                       /* remember to add one since the 'any' entry occupies 1st position i.e. [0] */
+                                       const facility = this.facilities[Math.floor(entry?.priority / 8) + 1][1] ?? 'unknown';
+                                       const severity = this.severity[(entry?.priority % 8) + 1][1] ?? 'unknown';
+                                       return `[${datestr}] ${facility}.${severity}: ${entry?.msg}`;
+                               });
+
+                               loglines = loglines.filter(line => {
+                                       const sevMatch = this.logSeverityFilter === 'any' || line.includes(`.${this.logSeverityFilter}`);
+                                       const facMatch = this.logFacilityFilter === 'any' || line.includes(`${this.logFacilityFilter}.`);
+                                       return (this.invertLogSeveritySearch != sevMatch)
+                                                  && (this.invertLogFacilitySearch != facMatch);
+                               });
+
+                               loglines = loglines.filter(line => {
+                                       return line.toLowerCase().includes(this.logTagFilter?.toLowerCase());
+                               });
+
+                               loglines = loglines.filter(line => {
+                                       const match = line.includes(this.logTextFilter);
+                                       return this.invertLogTextSearch ? !match : match;
+                               });
+
+                               return {
+                                       value: loglines?.join('\n'),
+                                       rows: loglines?.length + 1
+                               };
+                       }
+                       catch (err) {
+                               ui.addNotification(null, E('p', {}, _('Unable to load log data: ' + err.message)));
+                               return {
+                                       value: '',
+                                       rows: 0
+                               };
+                       }
+               },
+
+               async pollLog() {
+                       const element = document.getElementById('syslog');
+                       if (element) {
+                               const log = await this.retrieveLog();
+                               element.value = log?.value;
+                               element.rows = log?.rows;
+                       }
+               },
+
+               async load() {
+                       poll.add(this.pollLog.bind(this));
                        return Promise.all([
-                               L.resolveDefault(fs.stat('/sbin/logread'), null),
-                               L.resolveDefault(fs.stat('/usr/sbin/logread'), null)
-                       ]);
+                               uci.load('system'),
+                       ]).then(() => this.retrieveLog());
                },
-               render: function(stat) {
-                       var logger = stat[0] ? stat[0].path : stat[1] ? stat[1].path : null;
-                       L.Poll.add(function() {
-                               return L.resolveDefault(fs.exec_direct(logger, ['-e', logtag])).then(function(res) {
-                                       var log = document.getElementById("logfile");
-                                       if (res) {
-                                               log.value = res.trim();
-                                       } else {
-                                               log.value = _('No related logs yet!');
-                                       }
-                                       log.scrollTop = log.scrollHeight;
-                               });
+
+               render(loglines) {
+                       const scrollDownButton = E('button', {
+                                       'id': 'scrollDownButton',
+                                       'class': 'cbi-button cbi-button-neutral'
+                               }, _('Scroll to tail', 'scroll to bottom (the tail) of the log file')
+                       );
+                       scrollDownButton.addEventListener('click', () => {
+                               scrollUpButton.scrollIntoView();
+                               scrollDownButton.blur();
+                       });
+
+                       const scrollUpButton = E('button', {
+                                       'id' : 'scrollUpButton',
+                                       'class': 'cbi-button cbi-button-neutral'
+                               }, _('Scroll to head', 'scroll to top (the head) of the log file')
+                       );
+                       scrollUpButton.addEventListener('click', () => {
+                               scrollDownButton.scrollIntoView();
+                               scrollUpButton.blur();          
+                       });
+
+                       const self = this;
+
+                       // Create facility invert checkbox
+                       const facilityInvert = E('input', {
+                               'id': 'invertLogFacilitySearch',
+                               'type': 'checkbox',
+                               'class': 'cbi-input-checkbox',
+                       });
+
+                       // Create facility select-dropdown from facilities map
+                       const facilitySelect = E('select', {
+                               'id': 'logFacilitySelect',
+                               'class': 'cbi-input-select',
+                               'style': 'margin-bottom:10px',
+                       },
+                       this.facilities.map(([_, val, label]) =>
+                               (val == 'any') ? E('option', { value: val, selected: '' }, label) : E('option', { value: val }, label)
+                       ));
+
+                       // Create severity invert checkbox
+                       const severityInvert = E('input', {
+                               'id': 'invertLogSeveritySearch',
+                               'type': 'checkbox',
+                               'class': 'cbi-input-checkbox',
+                       });
+
+                       // Create severity select-dropdown from facilities map
+                       const severitySelect = E('select', {
+                               'id': 'logSeveritySelect',
+                               'class': 'cbi-input-select',
+                       },
+                       this.severity.map(([_, val, label]) =>
+                               (val == 'any') ? E('option', { value: val, selected: '' }, label) : E('option', { value: val }, label)
+                       ));
+
+                       // Create raw text search invert checkbox
+                       const filterTextInvert = E('input', {
+                               'id': 'invertLogTextSearch',
+                               'type': 'checkbox',
+                               'class': 'cbi-input-checkbox',
+                       });
+
+                       // Create raw text search text input
+                       const filterTextInput = E('input', {
+                               'id': 'logTextFilter',
+                               'class': 'cbi-input-text',
+                       });
+
+                       // Create max rows input
+                       const filterMaxRows = E('input', {
+                               'id': 'logMaxRows',
+                               'type': 'number',
+                               'class': 'cbi-input',
                        });
-                       return E('div', { class: 'cbi-map' },
-                               E('div', { class: 'cbi-section' }, [
-                               E('div', { class: 'cbi-section-descr' }, _('The syslog output, pre-filtered for messages related to: ' + name)),
-                               E('textarea', {
-                                       'id': 'logfile',
-                                       'style': 'width: 100% !important; padding: 5px; font-family: monospace',
-                                       'readonly': 'readonly',
-                                       'wrap': 'off',
-                                       'rows': 25
-                               })
-                       ]));
+
+                       function handleLogFilterChange() {
+                               self.logFacilityFilter = facilitySelect.value;
+                               self.invertLogFacilitySearch = facilityInvert.checked;
+                               self.logSeverityFilter = severitySelect.value;
+                               self.invertLogSeveritySearch = severityInvert.checked;
+                               self.logTextFilter = filterTextInput.value;
+                               self.invertLogTextSearch = filterTextInvert.checked;
+                               self.fetchMaxRows = Number.parseInt(filterMaxRows.value);
+                               self.pollLog();
+                       }
+
+                       facilitySelect.addEventListener('change', handleLogFilterChange);
+                       facilityInvert.addEventListener('change', handleLogFilterChange);
+                       severitySelect.addEventListener('change', handleLogFilterChange);
+                       severityInvert.addEventListener('change', handleLogFilterChange);
+                       filterTextInput.addEventListener('input', handleLogFilterChange);
+                       filterTextInvert.addEventListener('change', handleLogFilterChange);
+                       filterMaxRows.addEventListener('change', handleLogFilterChange);
+
+                       return E([], [
+                               E('h2', {}, [ `${this.logName} ${_('Log')}` ]),
+                               E('div', { 'id': 'content_syslog' }, [
+                                       E('div', { class: 'cbi-section-descr' }, this.logTagFilter ? _('The syslog output, pre-filtered for messages related to: ' + this.logTagFilter) : '') ,
+                                       E('div', { 'style': 'margin-bottom:10px' }, [
+                                               E('label', { 'for': 'invertLogFacilitySearch', 'style': 'margin-right:5px' }, _('Not')),
+                                               facilityInvert,
+                                               E('label', { 'for': 'logFacilitySelect', 'style': 'margin: 0 5px' }, _('facility:')),
+                                               facilitySelect,
+                                               E('label', { 'for': 'invertLogSeveritySearch', 'style': 'margin: 0 5px' }, _('Not')),
+                                               severityInvert,
+                                               E('label', { 'for': 'logSeveritySelect', 'style': 'margin: 0 5px' }, _('severity:')),
+                                               severitySelect,
+                                       ]),
+                                       E('div', { 'style': 'margin-bottom:10px' }, [
+                                               E('label', { 'for': 'invertLogTextSearch', 'style': 'margin-right:5px' }, _('Not')),
+                                               filterTextInvert,
+                                               E('label', { 'for': 'logTextFilter', 'style': 'margin: 0 5px' }, _('including:')),
+                                               filterTextInput,
+                                               E('label', { 'for': 'logMaxRows', 'style': 'margin: 0 5px' }, _('Max rows:')),
+                                               filterMaxRows,
+                                       ]),
+                                       E('div', {'style': 'padding-bottom: 20px'}, [scrollDownButton]),
+                                       E('textarea', {
+                                               'id': 'syslog',
+                                               'style': 'font-size:12px',
+                                               'readonly': 'readonly',
+                                               'wrap': 'off',
+                                               'rows': loglines?.rows,
+                                       }, [ loglines?.value ]),
+                                       E('div', {'style': 'padding-bottom: 20px'}, [scrollUpButton])
+                               ])
+                       ]);
                },
+
                handleSaveApply: null,
                handleSave: null,
                handleReset: null
index af9fc514e134278074cc30b403d33b0cc3e54d60..f24d97cf1ba2e13579b9913aee3d0224c1b40c0f 100644 (file)
@@ -18,7 +18,8 @@
                        "ubus": {
                                "file": [ "list" ],
                                "uci": [ "changes", "get" ]
-                       }
+                       },
+                       "uci": [ "system" ]
                },
                "write": {
                        "cgi-io": [ "upload" ],