├── LICENSE ├── Makefile ├── README.md ├── dev-mods-examples ├── mod-app-example │ ├── htdocs │ │ └── luci-static │ │ │ └── resources │ │ │ └── view │ │ │ └── log-viewer │ │ │ └── dropbear.js │ └── root │ │ └── usr │ │ └── share │ │ └── luci │ │ └── menu.d │ │ └── luci-app-log-viewer-dropbear.json ├── mod-lines │ ├── htdocs │ │ └── luci-static │ │ │ └── resources │ │ │ └── view │ │ │ └── log-viewer │ │ │ └── log-widget.js │ └── screenshots │ │ └── 01.jpg ├── mod-multilog │ ├── htdocs │ │ └── luci-static │ │ │ └── resources │ │ │ └── view │ │ │ └── log-viewer │ │ │ └── multilog │ │ │ ├── boot.js │ │ │ ├── cron.js │ │ │ ├── log-multilog.js │ │ │ ├── maillog.js │ │ │ ├── messages.js │ │ │ └── secure.js │ ├── root │ │ └── usr │ │ │ └── share │ │ │ ├── luci │ │ │ └── menu.d │ │ │ │ └── luci-app-log-viewer.json │ │ │ └── rpcd │ │ │ └── acl.d │ │ │ └── luci-app-log-viewer.json │ └── screenshots │ │ └── 01.jpg └── mod-textarea │ ├── htdocs │ └── luci-static │ │ └── resources │ │ └── view │ │ └── log-viewer │ │ └── log-widget.js │ └── screenshots │ └── 01.jpg ├── htdocs └── luci-static │ └── resources │ └── view │ └── log-viewer │ ├── dmesg.js │ ├── log-base.js │ ├── log-system.js │ ├── log-widget.js │ └── syslog.js ├── po ├── ru │ └── log-viewer.po └── templates │ └── log-viewer.pot ├── root └── usr │ ├── libexec │ └── rpcd │ │ └── luci.log-viewer │ └── share │ ├── luci │ └── menu.d │ │ └── luci-app-log-viewer.json │ └── rpcd │ └── acl.d │ └── luci-app-log-viewer.json └── screenshots ├── 01.jpg ├── 02.jpg ├── 03.jpg └── 04.jpg /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 gSpotx2f 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (C) 2025 gSpot (https://github.com/gSpotx2f/luci-app-log) 3 | # 4 | # This is free software, licensed under the MIT License. 5 | # 6 | 7 | include $(TOPDIR)/rules.mk 8 | 9 | PKG_NAME:=luci-app-log-viewer 10 | PKG_VERSION:=1.3.0 11 | PKG_RELEASE:=1 12 | LUCI_TITLE:=Advanced syslog and kernel log (tail, search, etc) 13 | LUCI_PKGARCH:=all 14 | #LUCI_PKGARCH:=noarch 15 | PKG_LICENSE:=MIT 16 | 17 | #include ../../luci.mk 18 | include $(TOPDIR)/feeds/luci/luci.mk 19 | 20 | # call BuildPackage - OpenWrt buildroot signature 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # luci-app-log-viewer 2 | Advanced syslog and kernel log (tail, search, etc) for LuCI (OpenWrt webUI). 3 | 4 | OpenWrt >= 21.02. 5 | 6 | Supported LuCI themes: luci-theme-bootstrap, luci-theme-material, luci-theme-openwrt-2020. 7 | 8 | ## Installation notes 9 | 10 | wget --no-check-certificate -O /tmp/luci-app-log-viewer_1.3.0-r1_all.ipk https://github.com/gSpotx2f/packages-openwrt/raw/master/current/luci-app-log-viewer_1.3.0-r1_all.ipk 11 | opkg install /tmp/luci-app-log-viewer_1.3.0-r1_all.ipk 12 | rm /tmp/luci-app-log-viewer_1.3.0-r1_all.ipk 13 | service rpcd restart 14 | 15 | i18n-ru: 16 | 17 | wget --no-check-certificate -O /tmp/luci-i18n-log-viewer-ru_1.3.0-r1_all.ipk https://github.com/gSpotx2f/packages-openwrt/raw/master/current/luci-i18n-log-viewer-ru_1.3.0-r1_all.ipk 18 | opkg install /tmp/luci-i18n-log-viewer-ru_1.3.0-r1_all.ipk 19 | rm /tmp/luci-i18n-log-viewer-ru_1.3.0-r1_all.ipk 20 | 21 | ## Screenshots: 22 | 23 | Kernel log: 24 | 25 | ![](https://github.com/gSpotx2f/luci-app-log/blob/master/screenshots/01.jpg) 26 | 27 | System log (logd): 28 | 29 | ![](https://github.com/gSpotx2f/luci-app-log/blob/master/screenshots/02.jpg) 30 | 31 | Filter settings: 32 | 33 | ![](https://github.com/gSpotx2f/luci-app-log/blob/master/screenshots/03.jpg) 34 | 35 | System log (syslog-ng): 36 | 37 | ![](https://github.com/gSpotx2f/luci-app-log/blob/master/screenshots/04.jpg) 38 | 39 | ## Mod lines: 40 | 41 | wget --no-check-certificate -O /www/luci-static/resources/view/log-viewer/log-widget.js https://github.com/gSpotx2f/luci-app-log/raw/master/dev-mods-examples/mod-lines/htdocs/luci-static/resources/view/log-viewer/log-widget.js 42 | 43 | ![](https://github.com/gSpotx2f/luci-app-log/blob/master/dev-mods-examples/mod-lines/screenshots/01.jpg) 44 | 45 | ## Mod textarea: 46 | 47 | wget --no-check-certificate -O /www/luci-static/resources/view/log-viewer/log-widget.js https://github.com/gSpotx2f/luci-app-log/raw/master/dev-mods-examples/mod-textarea/htdocs/luci-static/resources/view/log-viewer/log-widget.js 48 | 49 | ![](https://github.com/gSpotx2f/luci-app-log/blob/master/dev-mods-examples/mod-textarea/screenshots/01.jpg) 50 | -------------------------------------------------------------------------------- /dev-mods-examples/mod-app-example/htdocs/luci-static/resources/view/log-viewer/dropbear.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 'require view.log-viewer.log-system as abc'; 3 | 4 | return abc.view.extend({ 5 | viewName : 'dropbear', 6 | title : _('Dropbear'), 7 | enableAutoRefresh: false, 8 | appPattern : 'dropbear\[[0-9]*\]:', 9 | }); 10 | -------------------------------------------------------------------------------- /dev-mods-examples/mod-app-example/root/usr/share/luci/menu.d/luci-app-log-viewer-dropbear.json: -------------------------------------------------------------------------------- 1 | { 2 | "admin/status/log-viewer/dropbear": { 3 | "title": "Dropbear", 4 | "order": 3, 5 | "action": { 6 | "type": "view", 7 | "path": "log-viewer/dropbear" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /dev-mods-examples/mod-lines/htdocs/luci-static/resources/view/log-viewer/log-widget.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 'require baseclass'; 3 | 'require ui'; 4 | 'require view.log-viewer.log-base as base'; 5 | 6 | document.head.append(E('style', {'type': 'text/css'}, 7 | ` 8 | :root { 9 | --app-log-entry-outline-color: #ccc; 10 | } 11 | :root[data-darkmode="true"] { 12 | --app-log-entry-outline-color: #555; 13 | } 14 | #log-area { 15 | width: 100%; 16 | height: 100%; 17 | margin-bottom: 1em; 18 | } 19 | .log-entry-line { 20 | display: inline-block; 21 | text-indent: 8px; 22 | margin: 0 0 1px 0; 23 | padding: 0 4px; 24 | -webkit-border-radius: 3px; 25 | -moz-border-radius: 3px; 26 | border-radius: 3px; 27 | border: 1px solid var(--app-log-entry-outline-color); 28 | font-weight: normal; 29 | /*font-size: 12px !important;*/ 30 | font-family: monospace !important; 31 | white-space: pre-wrap !important; 32 | overflow-wrap: anywhere !important; 33 | } 34 | `)); 35 | 36 | return baseclass.extend({ 37 | view: base.view.extend({ 38 | 39 | filterHighlightFunc(match) { 40 | return `${match}`; 41 | }, 42 | 43 | padNumber(number, lengthFirst, lengthLast) { 44 | let length = Math.max(lengthFirst, lengthLast); 45 | try { 46 | number = String(number).padStart(length, ' '); 47 | } catch(e) { 48 | if(e.name != 'TypeError') { 49 | throw e; 50 | }; 51 | }; 52 | return number; 53 | }, 54 | 55 | makeLogArea(logdataArray) { 56 | let lines = `${_('No entries available...')}`; 57 | let logArea = E('div', { 'id': 'log-area' }); 58 | 59 | for(let level of Object.keys(this.logLevels)) { 60 | this.logLevelsStat[level] = 0; 61 | }; 62 | 63 | let logdataArrayLen = logdataArray.length; 64 | 65 | if(logdataArray.length > 0) { 66 | lines = []; 67 | let firstNumLength = String(logdataArray[0][0]).length; 68 | let lastNumLength = String(logdataArray[logdataArrayLen - 1][0]).length; 69 | logdataArray.forEach((e, i) => { 70 | if(e[4] in this.logLevels) { 71 | this.logLevelsStat[e[4]] = this.logLevelsStat[e[4]] + 1; 72 | }; 73 | e[0] = this.padNumber(e[0], firstNumLength, lastNumLength); 74 | if(e[5]) { 75 | e[5] = ` ${e[5]}`; 76 | }; 77 | lines.push( 78 | `` + 79 | e.filter(i => (i)).join(' ') + 80 | '' 81 | ); 82 | }); 83 | lines = lines.join('
'); 84 | }; 85 | 86 | try { 87 | logArea.insertAdjacentHTML('beforeend', lines); 88 | } catch(err) { 89 | if(err.name == 'SyntaxError') { 90 | ui.addNotification(null, 91 | E('p', {}, _('HTML/XML error') + ': ' + err.message), 'error'); 92 | }; 93 | throw err; 94 | }; 95 | 96 | let levelsStatString = ''; 97 | if((Object.values(this.logLevelsStat).reduce((s,c) => s + c, 0)) > 0) { 98 | Object.entries(this.logLevelsStat).forEach(e => { 99 | if(e[0] in this.logLevels && e[1] > 0) { 100 | levelsStatString += `${e[1]}`; 101 | }; 102 | }); 103 | }; 104 | 105 | return E([ 106 | E('div', { 'class': 'log-entries-count' }, 107 | `${_('Entries')}: ${logdataArray.length} / ${this.totalLogLines}${levelsStatString}` 108 | ), 109 | logArea, 110 | ]); 111 | }, 112 | }), 113 | }); 114 | -------------------------------------------------------------------------------- /dev-mods-examples/mod-lines/screenshots/01.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gSpotx2f/luci-app-log/7de10f55fee9a6f34b418d685448125bd0924c2b/dev-mods-examples/mod-lines/screenshots/01.jpg -------------------------------------------------------------------------------- /dev-mods-examples/mod-multilog/htdocs/luci-static/resources/view/log-viewer/multilog/boot.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 'require view.log-viewer.multilog.log-multilog as abc'; 3 | 4 | return abc.view.extend({ 5 | viewName : 'multilog-boot', 6 | title : _('Log') + ' - ' + _('boot.log'), 7 | autoRefresh: false, 8 | logFile : '/var/log/boot.log', 9 | }); 10 | -------------------------------------------------------------------------------- /dev-mods-examples/mod-multilog/htdocs/luci-static/resources/view/log-viewer/multilog/cron.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 'require view.log-viewer.multilog.log-multilog as abc'; 3 | 4 | return abc.view.extend({ 5 | viewName : 'multilog-cron', 6 | title : _('Log') + ' - ' + _('cron'), 7 | autoRefresh: false, 8 | logFile : '/var/log/cron', 9 | }); 10 | -------------------------------------------------------------------------------- /dev-mods-examples/mod-multilog/htdocs/luci-static/resources/view/log-viewer/multilog/log-multilog.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 'require baseclass'; 3 | 'require fs'; 4 | 'require view.log-viewer.log-system as abc'; 5 | 6 | return baseclass.extend({ 7 | view: abc.view.extend({ 8 | /** 9 | * Log file. 10 | * 11 | * @property {string} logFile 12 | */ 13 | logFile: null, 14 | 15 | getLogHash() { 16 | return fs.stat(this.logFile).then((data) => { 17 | return data.mtime || ''; 18 | }).catch(e => {}); 19 | }, 20 | 21 | getLogData(tail) { 22 | return L.resolveDefault(fs.read_direct(this.logFile, 'text'), ''); 23 | }, 24 | }), 25 | }); 26 | -------------------------------------------------------------------------------- /dev-mods-examples/mod-multilog/htdocs/luci-static/resources/view/log-viewer/multilog/maillog.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 'require view.log-viewer.multilog.log-multilog as abc'; 3 | 4 | return abc.view.extend({ 5 | viewName : 'multilog-maillog', 6 | title : _('Log') + ' - ' + _('maillog'), 7 | autoRefresh: false, 8 | logFile : '/var/log/maillog', 9 | }); 10 | -------------------------------------------------------------------------------- /dev-mods-examples/mod-multilog/htdocs/luci-static/resources/view/log-viewer/multilog/messages.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 'require view.log-viewer.multilog.log-multilog as abc'; 3 | 4 | return abc.view.extend({ 5 | viewName : 'multilog-messages', 6 | title : _('Log') + ' - ' + _('messages'), 7 | autoRefresh: true, 8 | logFile : '/var/log/messages', 9 | }); 10 | -------------------------------------------------------------------------------- /dev-mods-examples/mod-multilog/htdocs/luci-static/resources/view/log-viewer/multilog/secure.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 'require view.log-viewer.multilog.log-multilog as abc'; 3 | 4 | return abc.view.extend({ 5 | viewName : 'multilog-secure', 6 | title : _('Log') + ' - ' + _('secure'), 7 | autoRefresh: false, 8 | logFile : '/var/log/secure', 9 | }); 10 | -------------------------------------------------------------------------------- /dev-mods-examples/mod-multilog/root/usr/share/luci/menu.d/luci-app-log-viewer.json: -------------------------------------------------------------------------------- 1 | { 2 | "admin/status/log-viewer": { 3 | "title": "Log Viewer", 4 | "order": 5, 5 | "action": { 6 | "type": "alias", 7 | "path": "admin/status/log-viewer/messages" 8 | }, 9 | "depends": { 10 | "acl": [ "luci-app-log-viewer" ] 11 | } 12 | }, 13 | 14 | "admin/status/log-viewer/messages": { 15 | "title": "messages", 16 | "order": 1, 17 | "action": { 18 | "type": "view", 19 | "path": "log-viewer/multilog/messages" 20 | } 21 | }, 22 | 23 | "admin/status/log-viewer/secure": { 24 | "title": "secure", 25 | "order": 2, 26 | "action": { 27 | "type": "view", 28 | "path": "log-viewer/multilog/secure" 29 | } 30 | }, 31 | 32 | "admin/status/log-viewer/maillog": { 33 | "title": "maillog", 34 | "order": 3, 35 | "action": { 36 | "type": "view", 37 | "path": "log-viewer/multilog/maillog" 38 | } 39 | }, 40 | 41 | "admin/status/log-viewer/cron": { 42 | "title": "cron", 43 | "order": 4, 44 | "action": { 45 | "type": "view", 46 | "path": "log-viewer/multilog/cron" 47 | } 48 | }, 49 | 50 | "admin/status/log-viewer/boot": { 51 | "title": "boot", 52 | "order": 5, 53 | "action": { 54 | "type": "view", 55 | "path": "log-viewer/multilog/boot" 56 | } 57 | }, 58 | 59 | "admin/status/log-viewer/dmesg": { 60 | "title": "kernel", 61 | "order": 6, 62 | "action": { 63 | "type": "view", 64 | "path": "log-viewer/dmesg" 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /dev-mods-examples/mod-multilog/root/usr/share/rpcd/acl.d/luci-app-log-viewer.json: -------------------------------------------------------------------------------- 1 | { 2 | "luci-app-log-viewer": { 3 | "description": "Grant access to log-viewer procedures", 4 | "read": { 5 | "cgi-io": [ "exec" ], 6 | "file": { 7 | "/sbin/logread*": [ "exec" ], 8 | "/usr/sbin/logread*": [ "exec" ], 9 | "/bin/dmesg -r": [ "exec" ], 10 | "/var/log/messages": [ "read" ], 11 | "/var/log/secure": [ "read" ], 12 | "/var/log/maillog": [ "read" ], 13 | "/var/log/cron": [ "read" ], 14 | "/var/log/boot.log": [ "read" ] 15 | }, 16 | "ubus": { 17 | "system": [ "info" ], 18 | "luci.log-viewer": [ "getSyslogSize", "getSyslogHash", "getDmesgSize", "getDmesgHash", "getLogfileSize" ] 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /dev-mods-examples/mod-multilog/screenshots/01.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gSpotx2f/luci-app-log/7de10f55fee9a6f34b418d685448125bd0924c2b/dev-mods-examples/mod-multilog/screenshots/01.jpg -------------------------------------------------------------------------------- /dev-mods-examples/mod-textarea/htdocs/luci-static/resources/view/log-viewer/log-widget.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 'require baseclass'; 3 | 'require ui'; 4 | 'require view.log-viewer.log-base as base'; 5 | 6 | return baseclass.extend({ 7 | view: base.view.extend({ 8 | rowsDefault: 20, 9 | 10 | htmlEntities(str) { 11 | return str; 12 | }, 13 | 14 | filterHighlightFunc(match) { 15 | return `►${match}◄`; 16 | }, 17 | 18 | padNumber(number, lengthFirst, lengthLast) { 19 | let length = Math.max(lengthFirst, lengthLast); 20 | try { 21 | number = String(number).padStart(length, ' '); 22 | } catch(e) { 23 | if(e.name != 'TypeError') { 24 | throw e; 25 | }; 26 | }; 27 | return number; 28 | }, 29 | 30 | makeLogArea(logdataArray) { 31 | let lines = _('No entries available...'); 32 | let logTextarea = E('textarea', { 33 | 'id' : 'syslog', 34 | 'class' : 'cbi-input-textarea', 35 | 'style' : 'width:100% !important; margin-bottom:1em; resize:none; font-size:12px; font-family:monospace !important', 36 | 'readonly' : 'readonly', 37 | 'wrap' : 'off', 38 | 'rows' : this.rowsDefault, 39 | 'spellcheck': 'false', 40 | }); 41 | 42 | for(let level of Object.keys(this.logLevels)) { 43 | this.logLevelsStat[level] = 0; 44 | }; 45 | 46 | let logdataArrayLen = logdataArray.length; 47 | 48 | if(logdataArrayLen > 0) { 49 | lines = []; 50 | let firstNumLength = String(logdataArray[0][0]).length; 51 | let lastNumLength = String(logdataArray[logdataArrayLen - 1][0]).length; 52 | logdataArray.forEach((e, i) => { 53 | if(e[4] in this.logLevels) { 54 | this.logLevelsStat[e[4]] = this.logLevelsStat[e[4]] + 1; 55 | }; 56 | e[0] = this.padNumber(e[0], firstNumLength, lastNumLength); 57 | if(e[5]) { 58 | e[5] = `\t${e[5]}`; 59 | }; 60 | lines.push(e.filter(i => (i)).join(' ')); 61 | }); 62 | lines = lines.join('\r\n'); 63 | }; 64 | 65 | logTextarea.value = lines; 66 | logTextarea.rows = ((logdataArrayLen + 1) < this.rowsDefault) ? this.rowsDefault : logdataArrayLen + 1; 67 | 68 | let levelsStatString = ''; 69 | if((Object.values(this.logLevelsStat).reduce((s,c) => s + c, 0)) > 0) { 70 | Object.entries(this.logLevelsStat).forEach(e => { 71 | if(e[0] in this.logLevels && e[1] > 0) { 72 | levelsStatString += `${e[1]}`; 73 | }; 74 | }); 75 | }; 76 | 77 | return E([ 78 | E('div', { 'class': 'log-entries-count' }, 79 | `${_('Entries')}: ${logdataArrayLen} / ${this.totalLogLines}${levelsStatString}` 80 | ), 81 | logTextarea, 82 | ]); 83 | }, 84 | }), 85 | }); 86 | -------------------------------------------------------------------------------- /dev-mods-examples/mod-textarea/screenshots/01.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gSpotx2f/luci-app-log/7de10f55fee9a6f34b418d685448125bd0924c2b/dev-mods-examples/mod-textarea/screenshots/01.jpg -------------------------------------------------------------------------------- /htdocs/luci-static/resources/view/log-viewer/dmesg.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 'require fs'; 3 | 'require rpc'; 4 | 'require ui'; 5 | 'require view.log-viewer.log-widget as widget'; 6 | 7 | return widget.view.extend({ 8 | viewName : 'dmesg', 9 | 10 | title : _('Kernel Log'), 11 | 12 | enableAutoRefresh : true, 13 | 14 | enableConvertTimestamp: true, 15 | 16 | entryRegexp : new RegExp(/^<(\d{1,2})>\[([\d\s.]+)\]\s+(.*)$/), 17 | 18 | entriesHandler : null, 19 | 20 | facilityName : [ 21 | 'kern', 22 | 'user', 23 | 'mail', 24 | 'daemon', 25 | 'auth', 26 | 'syslog', 27 | 'lpr', 28 | 'news', 29 | ], 30 | 31 | localtime : null, 32 | 33 | uptime : null, 34 | 35 | days : { 36 | 0: 'Sun', 37 | 1: 'Mon', 38 | 2: 'Tue', 39 | 3: 'Wed', 40 | 4: 'Thu', 41 | 5: 'Fri', 42 | 6: 'Sat', 43 | 7: 'Sun', 44 | }, 45 | 46 | months : { 47 | 1: 'Jan', 48 | 2: 'Feb', 49 | 3: 'Mar', 50 | 4: 'Apr', 51 | 5: 'May', 52 | 6: 'Jun', 53 | 7: 'Jul', 54 | 8: 'Aug', 55 | 9: 'Sep', 56 | 10: 'Oct', 57 | 11: 'Nov', 58 | 12: 'Dec', 59 | }, 60 | 61 | logCols : [ 62 | '#', 63 | _('Timestamp'), 64 | null, 65 | _('Facility'), 66 | _('Level'), 67 | _('Message'), 68 | ], 69 | 70 | callLogHash : rpc.declare({ 71 | object: 'luci.log-viewer', 72 | method: 'getDmesgHash', 73 | expect: { '': {} } 74 | }), 75 | 76 | callSystemInfo : rpc.declare({ 77 | object: 'system', 78 | method: 'info' 79 | }), 80 | 81 | getLogHash() { 82 | return this.callLogHash().then(data => { 83 | return data.hash || ''; 84 | }); 85 | }, 86 | 87 | convertTimestampFunc(t) { 88 | if(!this.convertTimestampValue || !this.localtime || !this.uptime) { 89 | return t; 90 | }; 91 | let date = new Date((this.localtime - this.uptime + Number(t)) * 1000); 92 | return '%s %s %d %02d:%02d:%02d %d'.format( 93 | this.days[ date.getUTCDay() ], 94 | this.months[ date.getUTCMonth() + 1 ], 95 | date.getUTCDate(), 96 | date.getUTCHours(), 97 | date.getUTCMinutes(), 98 | date.getUTCSeconds(), 99 | date.getUTCFullYear() 100 | ); 101 | }, 102 | 103 | async getLogData(tail) { 104 | await this.callSystemInfo().then(s => { 105 | this.localtime = s.localtime; 106 | this.uptime = s.uptime; 107 | }).catch(err => {}); 108 | return fs.exec_direct('/bin/dmesg', [ '-r' ], 'text').catch(err => { 109 | throw new Error(_('Unable to load log data:') + ' ' + err.message); 110 | }); 111 | }, 112 | 113 | parseLogData(logdata, tail) { 114 | if(!logdata) { 115 | return []; 116 | }; 117 | 118 | let unsupportedLog = false; 119 | this.logTimestampFlag = true; 120 | this.logFacilitiesFlag = true; 121 | this.logLevelsFlag = true; 122 | this.logHostsFlag = false; 123 | 124 | let strings = logdata.trim().split(/\n/); 125 | 126 | if(tail && tail > 0 && strings) { 127 | strings = strings.slice(-tail); 128 | }; 129 | 130 | this.totalLogLines = strings.length; 131 | 132 | let entriesArray = strings.map((e, i) => { 133 | let logLevelsTranslate = Object.keys(this.logLevels); 134 | let strArray = e.match(this.entryRegexp); 135 | if(strArray) { 136 | let level = 0; 137 | let facility = 0; 138 | if(strArray[1].length > 1) { 139 | let fieldArray = Number(strArray[1]).toString(8).split(''); 140 | level = logLevelsTranslate[Number(fieldArray[1])]; 141 | facility = Number(fieldArray[0]); 142 | } else { 143 | level = logLevelsTranslate[Number(strArray[1]).toString(8)]; 144 | }; 145 | return [ 146 | i + 1, // # (Number) 147 | this.convertTimestampFunc(strArray[2].trim()), // Timestamp (String) 148 | null, // Host (String) 149 | this.facilityName[ facility ], // Facility (String) 150 | level, // Level (String) 151 | this.htmlEntities(strArray[3]) || ' ', // Message (String) 152 | ]; 153 | } else { 154 | unsupportedLog = true; 155 | return; 156 | }; 157 | }); 158 | 159 | if(unsupportedLog) { 160 | throw new Error(_('Unable to load log data:') + ' ' + _('Unsupported log format')); 161 | } else { 162 | if(this.logSortingValue == 'desc') { 163 | entriesArray.reverse(); 164 | }; 165 | return entriesArray; 166 | }; 167 | }, 168 | }); 169 | -------------------------------------------------------------------------------- /htdocs/luci-static/resources/view/log-viewer/log-base.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 'require poll'; 3 | 'require baseclass'; 4 | 'require ui'; 5 | 'require view'; 6 | 7 | document.head.append(E('style', {'type': 'text/css'}, 8 | ` 9 | :root { 10 | --app-log-dark-font-color: #2e2e2e; 11 | --app-log-light-font-color: #fff; 12 | --app-log-debug-font-color: #737373; 13 | --app-log-raw-font-color: #737373; 14 | --app-log-emerg-color: #a93734; 15 | --app-log-alert: #ff7968; 16 | --app-log-crit: #fcc3bf; 17 | --app-log-err: #ffe9e8; 18 | --app-log-warn: #fff7e2; 19 | --app-log-notice: #e3ffec; 20 | --app-log-info: rgba(0,0,0,0); 21 | --app-log-debug: #ebf6ff; 22 | --app-log-raw: #eee; 23 | --app-log-entries-count-border: #ccc; 24 | } 25 | :root[data-darkmode="true"] { 26 | --app-log-dark-font-color: #fff; 27 | --app-log-light-font-color: #fff; 28 | --app-log-debug-font-color: #e7e7e7; 29 | --app-log-raw-font-color: #aaa; 30 | --app-log-emerg-color: #960909; 31 | --app-log-alert: #eb5050; 32 | --app-log-crit: #dc7f79; 33 | --app-log-err: #9a5954; 34 | --app-log-warn: #8d7000; 35 | --app-log-notice: #007627; 36 | --app-log-info: rgba(0,0,0,0); 37 | --app-log-debug: #5986b1; 38 | --app-log-raw: #353535; 39 | --app-log-entries-count-border: #555; 40 | } 41 | #logWrapper { 42 | overflow: auto !important; 43 | width: 100%; 44 | min-height: 20em'; 45 | } 46 | .log-empty { 47 | } 48 | .log-emerg { 49 | background-color: var(--app-log-emerg-color) !important; 50 | color: var(--app-log-light-font-color); 51 | } 52 | log-emerg .td { 53 | color: var(--app-log-light-font-color) !important; 54 | } 55 | log-emerg td { 56 | color: var(--app-log-light-font-color) !important; 57 | } 58 | .log-alert { 59 | background-color: var(--app-log-alert) !important; 60 | color: var(--app-log-light-font-color); 61 | } 62 | .log-alert .td { 63 | color: var(--app-log-light-font-color) !important; 64 | } 65 | .log-alert td { 66 | color: var(--app-log-light-font-color) !important; 67 | } 68 | .log-crit { 69 | background-color: var(--app-log-crit) !important; 70 | color: var(--app-log-dark-font-color) !important; 71 | } 72 | .log-crit .td { 73 | color: var(--app-log-dark-font-color) !important; 74 | } 75 | .log-crit td { 76 | color: var(--app-log-dark-font-color) !important; 77 | } 78 | .log-err { 79 | background-color: var(--app-log-err) !important; 80 | color: var(--app-log-dark-font-color) !important; 81 | } 82 | .log-err .td { 83 | color: var(--app-log-dark-font-color) !important; 84 | } 85 | .log-err td { 86 | color: var(--app-log-dark-font-color) !important; 87 | } 88 | .log-warn { 89 | background-color: var(--app-log-warn) !important; 90 | color: var(--app-log-dark-font-color) !important; 91 | } 92 | .log-warn .td { 93 | color: var(--app-log-dark-font-color) !important; 94 | } 95 | .log-warn td { 96 | color: var(--app-log-dark-font-color) !important; 97 | } 98 | .log-notice { 99 | background-color: var(--app-log-notice) !important; 100 | color: var(--app-log-dark-font-color) !important; 101 | } 102 | .log-notice .td { 103 | color: var(--app-log-dark-font-color) !important; 104 | } 105 | .log-notice td { 106 | color: var(--app-log-dark-font-color) !important; 107 | } 108 | .log-info { 109 | background-color: var(--app-log-info) !important; 110 | } 111 | .log-debug { 112 | background-color: var(--app-log-debug) !important; 113 | color: var(--app-log-debug-font-color) !important; 114 | } 115 | .log-debug .td { 116 | color: var(--app-log-debug-font-color) !important; 117 | } 118 | .log-debug td { 119 | color: var(--app-log-debug-font-color) !important; 120 | } 121 | .log-raw { 122 | background-color: var(--app-log-raw) !important; 123 | color: var(--app-log-raw-font-color) !important; 124 | } 125 | .log-raw .td { 126 | color: var(--app-log-raw-font-color) !important; 127 | } 128 | .log-raw td { 129 | color: var(--app-log-raw-font-color) !important; 130 | } 131 | .log-highlight-item { 132 | background-color: #ffef00; 133 | color: #2e2e2e; 134 | } 135 | .log-entries-count { 136 | margin: 0 0 5px 5px; 137 | font-weight: bold; 138 | opacity: 0.7; 139 | } 140 | .log-entries-count-level { 141 | display: inline-block !important; 142 | margin: 0 0 0 5px; 143 | padding: 0 4px; 144 | -webkit-border-radius: 3px; 145 | -moz-border-radius: 3px; 146 | border-radius: 3px; 147 | border: 1px solid var(--app-log-entries-count-border); 148 | font-weight: normal; 149 | } 150 | .log-host-dropdown-item { 151 | } 152 | .log-facility-dropdown-item { 153 | } 154 | #moreEntriesBar { 155 | opacity: 0.7; 156 | } 157 | #moreEntriesBar > button { 158 | margin: 1em 0 1em 0 !important; 159 | min-width: 100%; 160 | } 161 | .log-side-block { 162 | position: fixed; 163 | z-index: 200 !important; 164 | opacity: 0.7; 165 | right: 1px; 166 | top: 40vh; 167 | } 168 | .log-side-btn { 169 | position: relative; 170 | display: block; 171 | left: 1px; 172 | top: 1px; 173 | margin: 0 !important; 174 | min-width: 3.2em; 175 | } 176 | `)); 177 | 178 | return baseclass.extend({ 179 | view: view.extend({ 180 | /** 181 | * View name (for local storage and downloads). 182 | * 183 | * @property {string} viewName 184 | */ 185 | viewName : null, 186 | 187 | /** 188 | * Page title. 189 | * 190 | * @property {string} title 191 | */ 192 | title : null, 193 | 194 | /** 195 | * Enable auto refresh log. 196 | * 197 | * @property {bool} enableAutoRefresh 198 | */ 199 | enableAutoRefresh : false, 200 | 201 | /** 202 | * Enable timestamp conversion. 203 | * 204 | * @property {bool} enableConvertTimestamp 205 | */ 206 | enableConvertTimestamp: false, 207 | 208 | pollInterval : L.env.pollinterval, 209 | 210 | logFacilities : { 211 | 'kern' : E('span', { 'class': 'zonebadge log-facility-dropdown-item' }, E('strong', 'kern')), 212 | 'user' : E('span', { 'class': 'zonebadge log-facility-dropdown-item' }, E('strong', 'user')), 213 | 'mail' : E('span', { 'class': 'zonebadge log-facility-dropdown-item' }, E('strong', 'mail')), 214 | 'daemon' : E('span', { 'class': 'zonebadge log-facility-dropdown-item' }, E('strong', 'daemon')), 215 | 'auth' : E('span', { 'class': 'zonebadge log-facility-dropdown-item' }, E('strong', 'auth')), 216 | 'syslog' : E('span', { 'class': 'zonebadge log-facility-dropdown-item' }, E('strong', 'syslog')), 217 | 'lpr' : E('span', { 'class': 'zonebadge log-facility-dropdown-item' }, E('strong', 'lpr')), 218 | 'news' : E('span', { 'class': 'zonebadge log-facility-dropdown-item' }, E('strong', 'news')), 219 | 'uucp' : E('span', { 'class': 'zonebadge log-facility-dropdown-item' }, E('strong', 'uucp')), 220 | 'authpriv': E('span', { 'class': 'zonebadge log-facility-dropdown-item' }, E('strong', 'authpriv')), 221 | 'ftp' : E('span', { 'class': 'zonebadge log-facility-dropdown-item' }, E('strong', 'ftp')), 222 | 'ntp' : E('span', { 'class': 'zonebadge log-facility-dropdown-item' }, E('strong', 'ntp')), 223 | 'log' : E('span', { 'class': 'zonebadge log-facility-dropdown-item' }, E('strong', 'log')), 224 | 'clock' : E('span', { 'class': 'zonebadge log-facility-dropdown-item' }, E('strong', 'clock')), 225 | 'local0' : E('span', { 'class': 'zonebadge log-facility-dropdown-item' }, E('strong', 'local0')), 226 | 'local1' : E('span', { 'class': 'zonebadge log-facility-dropdown-item' }, E('strong', 'local1')), 227 | 'local2' : E('span', { 'class': 'zonebadge log-facility-dropdown-item' }, E('strong', 'local2')), 228 | 'local3' : E('span', { 'class': 'zonebadge log-facility-dropdown-item' }, E('strong', 'local3')), 229 | 'local4' : E('span', { 'class': 'zonebadge log-facility-dropdown-item' }, E('strong', 'local4')), 230 | 'local5' : E('span', { 'class': 'zonebadge log-facility-dropdown-item' }, E('strong', 'local5')), 231 | 'local6' : E('span', { 'class': 'zonebadge log-facility-dropdown-item' }, E('strong', 'local6')), 232 | 'local7' : E('span', { 'class': 'zonebadge log-facility-dropdown-item' }, E('strong', 'local7')), 233 | }, 234 | 235 | logLevels : { 236 | 'emerg' : E('span', { 'class': 'zonebadge log-emerg' }, E('strong', 'Emergency')), 237 | 'alert' : E('span', { 'class': 'zonebadge log-alert' }, E('strong', 'Alert')), 238 | 'crit' : E('span', { 'class': 'zonebadge log-crit' }, E('strong', 'Critical')), 239 | 'err' : E('span', { 'class': 'zonebadge log-err' }, E('strong', 'Error')), 240 | 'warn' : E('span', { 'class': 'zonebadge log-warn' }, E('strong', 'Warning')), 241 | 'notice': E('span', { 'class': 'zonebadge log-notice' }, E('strong', 'Notice')), 242 | 'info' : E('span', { 'class': 'zonebadge log-info' }, E('strong', 'Info')), 243 | 'debug' : E('span', { 'class': 'zonebadge log-debug' }, E('strong', 'Debug')), 244 | }, 245 | 246 | tailValue : 25, 247 | 248 | fastTailIncrement : 50, 249 | 250 | fastTailValue : null, 251 | 252 | timeFilterValue : null, 253 | 254 | timeFilterReValue : false, 255 | 256 | hostFilterValue : [], 257 | 258 | facilityFilterValue : [], 259 | 260 | levelFilterValue : [], 261 | 262 | msgFilterValue : null, 263 | 264 | msgFilterReValue : false, 265 | 266 | logSortingValue : 'asc', 267 | 268 | autoRefreshValue : true, 269 | 270 | autorefreshOn : true, 271 | 272 | logTimestampFlag : false, 273 | 274 | logHostsFlag : false, 275 | 276 | logFacilitiesFlag : false, 277 | 278 | logLevelsFlag : false, 279 | 280 | logHosts : {}, 281 | 282 | logLevelsStat : {}, 283 | 284 | logHostsDropdown : null, 285 | 286 | logFacilitiesDropdown: null, 287 | 288 | logLevelsDropdown : null, 289 | 290 | totalLogLines : 0, 291 | 292 | logCols : [ '#', null, null, null, null, _('Message') ], 293 | 294 | convertTimestampValue: false, 295 | 296 | convertTimestampOn : false, 297 | 298 | lastHash : null, 299 | 300 | actionButtons : [], 301 | 302 | htmlEntities(str) { 303 | return String(str).replace( 304 | /&/g, '&').replace( 305 | //g, '>').replace( 307 | /"/g, '"').replace( 308 | /'/g, '''); 309 | }, 310 | 311 | checkZeroValue(value) { 312 | return (/^[0-9]+$/.test(value)) ? value : 0 313 | }, 314 | 315 | makeLogConvertTimestampSection() { 316 | if(!this.enableConvertTimestamp) { 317 | return ''; 318 | }; 319 | return E('div', { 'class': 'cbi-value' }, [ 320 | E('label', { 321 | 'class': 'cbi-value-title', 322 | 'for' : 'convertTimestamp', 323 | }, _('Date')), 324 | E('div', { 'class': 'cbi-value-field' }, [ 325 | E('div', { 'class': 'cbi-checkbox' }, [ 326 | this.convertTimestamp, 327 | E('label', {}), 328 | ]), 329 | E('div', { 'class': 'cbi-value-description' }, 330 | _('Convert timestamps to a human readable date') 331 | ), 332 | ]), 333 | ]); 334 | }, 335 | 336 | makeLogTimeFilterSection() { 337 | return E('div', { 'class': 'cbi-value' }, [ 338 | E('label', { 339 | 'class': 'cbi-value-title', 340 | 'for' : 'timeFilter', 341 | }, _('Timestamp filter')), 342 | E('div', { 'class': 'cbi-value-field' }, 343 | E('span', { 'class': 'control-group' }, [ 344 | this.timeFilter, 345 | E('button', { 346 | 'class': 'cbi-button btn', 347 | 'click': L.bind(ev => { 348 | ev.target.blur(); 349 | ev.preventDefault(); 350 | this.timeFilter.value = null; 351 | this.timeFilter.focus(); 352 | }, this), 353 | }, '⌫'), 354 | ]) 355 | ), 356 | ]); 357 | }, 358 | 359 | makeLogtimeFilterReSection() { 360 | return E('div', { 'class': 'cbi-value' }, [ 361 | E('label', { 362 | 'class': 'cbi-value-title', 363 | 'for' : 'timeFilterRe', 364 | }, _('Filter is regexp')), 365 | E('div', { 'class': 'cbi-value-field' }, [ 366 | E('div', { 'class': 'cbi-checkbox' }, [ 367 | this.timeFilterRe, 368 | E('label', {}), 369 | ]), 370 | E('div', { 'class': 'cbi-value-description' }, 371 | _('Apply timestamp filter as regular expression') 372 | ), 373 | ]), 374 | ]); 375 | }, 376 | 377 | makeLogHostsDropdownItem(host) { 378 | return E( 379 | 'span', 380 | { 'class': 'zonebadge log-host-dropdown-item' }, 381 | E('strong', host) 382 | ); 383 | }, 384 | 385 | makeLogHostsDropdownSection() { 386 | this.logHostsDropdown = new ui.Dropdown( 387 | null, 388 | this.logHosts, 389 | { 390 | id : 'logHostsDropdown', 391 | multiple : true, 392 | select_placeholder: _('All'), 393 | } 394 | ); 395 | return E( 396 | 'div', { 'class': 'cbi-value' }, [ 397 | E('label', { 398 | 'class': 'cbi-value-title', 399 | 'for' : 'logHostsDropdown', 400 | }, _('Hosts')), 401 | E('div', { 'class': 'cbi-value-field' }, 402 | this.logHostsDropdown.render() 403 | ), 404 | ] 405 | ); 406 | }, 407 | 408 | makeLogFacilitiesDropdownSection() { 409 | this.logFacilitiesDropdown = new ui.Dropdown( 410 | null, 411 | this.logFacilities, 412 | { 413 | id : 'logFacilitiesDropdown', 414 | sort : Object.keys(this.logFacilities), 415 | multiple : true, 416 | select_placeholder: _('All'), 417 | } 418 | ); 419 | return E( 420 | 'div', { 'class': 'cbi-value' }, [ 421 | E('label', { 422 | 'class': 'cbi-value-title', 423 | 'for' : 'logFacilitiesDropdown', 424 | }, _('Facilities')), 425 | E('div', { 'class': 'cbi-value-field' }, 426 | this.logFacilitiesDropdown.render() 427 | ), 428 | ] 429 | ); 430 | }, 431 | 432 | makeLogLevelsDropdownSection() { 433 | this.logLevelsDropdown = new ui.Dropdown( 434 | null, 435 | this.logLevels, 436 | { 437 | id : 'logLevelsDropdown', 438 | sort : Object.keys(this.logLevels), 439 | multiple : true, 440 | select_placeholder: _('All'), 441 | } 442 | ); 443 | return E( 444 | 'div', { 'class': 'cbi-value' }, [ 445 | E('label', { 446 | 'class': 'cbi-value-title', 447 | 'for' : 'logLevelsDropdown', 448 | }, _('Logging levels')), 449 | E('div', { 'class': 'cbi-value-field' }, 450 | this.logLevelsDropdown.render() 451 | ), 452 | ] 453 | ); 454 | }, 455 | 456 | setRegexpValidator(elem, flag) { 457 | ui.addValidator( 458 | elem, 459 | 'string', 460 | true, 461 | v => { 462 | if(!flag.checked) { 463 | return true; 464 | }; 465 | try { 466 | new RegExp(v, 'giu'); 467 | return true; 468 | } catch(err) { 469 | return _('Invalid regular expression') + ':\n' + err.message; 470 | }; 471 | }, 472 | 'blur', 473 | 'focus', 474 | 'input' 475 | ); 476 | }, 477 | 478 | setFilterSettings() { 479 | this.tailValue = this.checkZeroValue(this.tailInput.value); 480 | if(this.logTimestampFlag) { 481 | if(this.enableConvertTimestamp) { 482 | this.convertTimestampValue = this.convertTimestamp.checked; 483 | }; 484 | this.timeFilterValue = this.timeFilter.value; 485 | this.timeFilterReValue = this.timeFilterRe.checked; 486 | }; 487 | if(this.logHostsFlag) { 488 | this.hostFilterValue = this.logHostsDropdown.getValue(); 489 | }; 490 | if(this.logFacilitiesFlag) { 491 | this.facilityFilterValue = this.logFacilitiesDropdown.getValue(); 492 | }; 493 | if(this.logLevelsFlag) { 494 | this.levelFilterValue = this.logLevelsDropdown.getValue(); 495 | }; 496 | this.msgFilterValue = this.msgFilter.value; 497 | this.msgFilterReValue = this.msgFilterRe.checked; 498 | this.logSortingValue = this.logSorting.value; 499 | this.autoRefreshValue = this.autoRefresh.checked; 500 | if(this.autorefreshOn) { 501 | if(this.autoRefreshValue) { 502 | poll.add(this.pollFuncWrapper, this.pollInterval); 503 | this.refreshBtn.style.visibility = 'hidden'; 504 | } else { 505 | poll.remove(this.pollFuncWrapper); 506 | this.refreshBtn.style.visibility = 'visible'; 507 | }; 508 | }; 509 | }, 510 | 511 | resetFormValues() { 512 | this.tailInput.value = this.tailValue; 513 | if(this.logTimestampFlag) { 514 | if(this.enableConvertTimestamp) { 515 | this.convertTimestamp.checked = this.convertTimestampValue; 516 | }; 517 | this.timeFilter.value = this.timeFilterValue; 518 | this.timeFilterRe.checked = this.timeFilterReValue; 519 | }; 520 | if(this.logHostsFlag) { 521 | this.logHostsDropdown.setValue(this.hostFilterValue); 522 | }; 523 | if(this.logFacilitiesFlag) { 524 | this.logFacilitiesDropdown.setValue(this.facilityFilterValue); 525 | }; 526 | if(this.logLevelsFlag) { 527 | this.logLevelsDropdown.setValue(this.levelFilterValue); 528 | }; 529 | this.msgFilter.value = this.msgFilterValue; 530 | this.msgFilterRe.checked = this.msgFilterReValue; 531 | this.logSorting.value = this.logSortingValue; 532 | this.autoRefresh.checked = this.autoRefreshValue; 533 | }, 534 | 535 | /** 536 | * Receives raw log data. 537 | * Abstract method, must be overridden by a subclass! 538 | * 539 | * @instance 540 | * @abstract 541 | * 542 | * @param {number} tail 543 | * @returns {string} 544 | * Returns the raw content of the log. 545 | */ 546 | getLogData(tail) { 547 | throw new Error('getLogData must be overridden by a subclass'); 548 | }, 549 | 550 | /** 551 | * Parses log data. 552 | * Abstract method, must be overridden by a subclass! 553 | * 554 | * @instance 555 | * @abstract 556 | * 557 | * @param {string} logdata 558 | * @param {number} tail 559 | * @returns {Array} 560 | * Returns an array of values: [ #, Timestamp, Host, Facility, Level, Message ]. 561 | */ 562 | parseLogData(logdata, tail) { 563 | throw new Error('parseLogData must be overridden by a subclass'); 564 | }, 565 | 566 | /** 567 | * Highlights the search result for a pattern. 568 | * Abstract method, must be overridden by a subclass! 569 | * 570 | * To disable the highlight option, views extending 571 | * this base class should overwrite the `filterHighlightFunc` 572 | * function with `null`. 573 | * 574 | * @instance 575 | * @abstract 576 | * 577 | * @param {string} logdata 578 | * @returns {string} 579 | * Returns a string with the highlighted part. 580 | */ 581 | filterHighlightFunc(match) { 582 | throw new Error('filterHighlightFunc must be overridden by a subclass'); 583 | }, 584 | 585 | setStringFilter(entriesArray, fieldNum, pattern) { 586 | let fArr = []; 587 | entriesArray.forEach((e, i) => { 588 | if(e[fieldNum] !== null && e[fieldNum].includes(pattern)) { 589 | if(typeof(this.filterHighlightFunc) == 'function') { 590 | e[fieldNum] = e[fieldNum].replace(pattern, this.filterHighlightFunc); 591 | }; 592 | fArr.push(e); 593 | }; 594 | }); 595 | return fArr; 596 | }, 597 | 598 | setRegexpFilter(entriesArray, fieldNum, pattern, formElem) { 599 | let fArr = []; 600 | try { 601 | let regExp = new RegExp(pattern, 'giu'); 602 | entriesArray.forEach((e, i) => { 603 | if(e[fieldNum] !== null && regExp.test(e[fieldNum])) { 604 | if(this.filterHighlightFunc) { 605 | e[fieldNum] = e[fieldNum].replace(regExp, this.filterHighlightFunc); 606 | }; 607 | fArr.push(e); 608 | }; 609 | regExp.lastIndex = 0; 610 | }); 611 | } catch(err) { 612 | if(err.name == 'SyntaxError') { 613 | ui.addNotification(null, 614 | E('p', {}, _('Invalid regular expression') + ': ' + err.message)); 615 | return entriesArray; 616 | } else { 617 | throw err; 618 | }; 619 | }; 620 | return fArr; 621 | }, 622 | 623 | setTimeFilter(entriesArray) { 624 | let fPattern = this.timeFilterValue; 625 | if(!fPattern) { 626 | return entriesArray; 627 | }; 628 | return (this.timeFilterReValue) ? 629 | this.setRegexpFilter(entriesArray, 1, fPattern, this.timeFilter) : 630 | this.setStringFilter(entriesArray, 1, fPattern); 631 | }, 632 | 633 | setHostFilter(entriesArray) { 634 | let logHostsKeys = Object.keys(this.logHosts); 635 | if(logHostsKeys.length > 0 && this.logHostsDropdown) { 636 | this.logHostsDropdown.addChoices(logHostsKeys, this.logHosts); 637 | if(this.hostFilterValue.length == 0) { 638 | return entriesArray; 639 | }; 640 | return entriesArray.filter(e => this.hostFilterValue.includes(e[2])); 641 | }; 642 | return entriesArray; 643 | }, 644 | 645 | setFacilityFilter(entriesArray) { 646 | let logFacilitiesKeys = Object.keys(this.logFacilities); 647 | if(logFacilitiesKeys.length > 0 && this.logFacilitiesDropdown) { 648 | if(this.facilityFilterValue.length == 0) { 649 | return entriesArray; 650 | }; 651 | return entriesArray.filter(e => this.facilityFilterValue.includes(e[3])); 652 | }; 653 | return entriesArray; 654 | }, 655 | 656 | setLevelFilter(entriesArray) { 657 | let logLevelsKeys = Object.keys(this.logLevels); 658 | if(logLevelsKeys.length > 0 && this.logLevelsDropdown) { 659 | if(this.levelFilterValue.length == 0) { 660 | return entriesArray; 661 | }; 662 | return entriesArray.filter(e => this.levelFilterValue.includes(e[4])); 663 | }; 664 | return entriesArray; 665 | }, 666 | 667 | setMsgFilter(entriesArray) { 668 | let fPattern = this.msgFilterValue; 669 | if(!fPattern) { 670 | return entriesArray; 671 | }; 672 | return (this.msgFilterReValue) ? 673 | this.setRegexpFilter(entriesArray, 5, fPattern, this.msgFilter) : 674 | this.setStringFilter(entriesArray, 5, fPattern); 675 | }, 676 | 677 | /** 678 | * Creates the contents of the log area. 679 | * Abstract method, must be overridden by a subclass! 680 | * 681 | * @instance 682 | * @abstract 683 | * 684 | * @param {Array} logdataArray 685 | * @returns {Node} 686 | * Returns a DOM node containing the log area. 687 | */ 688 | makeLogArea(logdataArray) { 689 | throw new Error('makeLogArea must be overridden by a subclass'); 690 | }, 691 | 692 | disableFormElems() { 693 | Array.from(this.logFilterForm.elements).forEach( 694 | e => e.disabled = true 695 | ); 696 | this.actionButtons.forEach(e => e.disabled = true); 697 | }, 698 | 699 | enableFormElems() { 700 | Array.from(this.logFilterForm.elements).forEach( 701 | e => e.disabled = false 702 | ); 703 | this.actionButtons.forEach(e => e.disabled = false); 704 | }, 705 | 706 | downloadLog(ev) { 707 | this.disableFormElems(); 708 | return this.getLogData(0).then(logdata => { 709 | logdata = logdata || ''; 710 | let link = E('a', { 711 | 'download': this.viewName + '.log', 712 | 'href' : URL.createObjectURL( 713 | new Blob([ logdata ], { type: 'text/plain' })), 714 | }); 715 | link.click(); 716 | URL.revokeObjectURL(link.href); 717 | }).catch(err => { 718 | ui.addNotification(null, 719 | E('p', {}, _('Download error') + ': ' + err.message)); 720 | }).finally(() => { 721 | this.enableFormElems(); 722 | }); 723 | }, 724 | 725 | restoreSettingsFromLocalStorage() { 726 | let tailValueLocal = localStorage.getItem(`luci-app-${this.viewName}-tailValue`); 727 | if(tailValueLocal) { 728 | this.tailValue = Number(tailValueLocal); 729 | }; 730 | let logSortingLocal = localStorage.getItem(`luci-app-${this.viewName}-logSortingValue`); 731 | if(logSortingLocal) { 732 | this.logSortingValue = logSortingLocal; 733 | }; 734 | if(this.enableConvertTimestamp) { 735 | let convertTimestampLocal = localStorage.getItem(`luci-app-${this.viewName}-convertTimestampValue`); 736 | if(convertTimestampLocal) { 737 | this.convertTimestampValue = Boolean(Number(convertTimestampLocal)); 738 | }; 739 | }; 740 | if(this.enableAutoRefresh) { 741 | let autoRefreshLocal = localStorage.getItem(`luci-app-${this.viewName}-autoRefreshValue`); 742 | if(autoRefreshLocal) { 743 | this.autoRefreshValue = Boolean(Number(autoRefreshLocal)); 744 | }; 745 | }; 746 | }, 747 | 748 | saveSettingsToLocalStorage(tailValue, logSortingValue, autoRefreshValue, convertTimestampValue) { 749 | tailValue = this.checkZeroValue(tailValue); 750 | if(this.tailValue != tailValue) { 751 | localStorage.setItem( 752 | `luci-app-${this.viewName}-tailValue`, String(tailValue)); 753 | }; 754 | if(this.logSortingValue != logSortingValue) { 755 | localStorage.setItem( 756 | `luci-app-${this.viewName}-logSortingValue`, logSortingValue); 757 | }; 758 | if(this.convertTimestampOn) { 759 | if(this.convertTimestampValue != convertTimestampValue) { 760 | localStorage.setItem( 761 | `luci-app-${this.viewName}-convertTimestampValue`, String(Number(convertTimestampValue))); 762 | }; 763 | }; 764 | if(this.autorefreshOn) { 765 | if(this.autoRefreshValue != autoRefreshValue) { 766 | localStorage.setItem( 767 | `luci-app-${this.viewName}-autoRefreshValue`, String(Number(autoRefreshValue))); 768 | }; 769 | }; 770 | }, 771 | 772 | reloadLog(tail, modal=false, autorefresh=false) { 773 | tail = (tail && tail > 0) ? tail : 0; 774 | if(!autorefresh) { 775 | this.disableFormElems(); 776 | poll.stop(); 777 | }; 778 | return this.getLogData(tail).then(logdata => { 779 | logdata = logdata || ''; 780 | this.logWrapper.innerHTML = ''; 781 | this.logWrapper.append( 782 | this.makeLogArea( 783 | this.setMsgFilter( 784 | this.setFacilityFilter( 785 | this.setLevelFilter( 786 | this.setHostFilter( 787 | this.setTimeFilter( 788 | this.parseLogData(logdata, tail) 789 | ) 790 | ) 791 | ) 792 | ) 793 | ) 794 | ) 795 | ); 796 | if(logdata && logdata !== '') { 797 | if(this.enableConvertTimestamp && !this.logConvertTimestampElem) { 798 | this.logConvertTimestampElem = this.makeLogConvertTimestampSection(); 799 | }; 800 | if(this.logTimestampFlag && !this.logTimeFilterElem) { 801 | this.logTimeFilterElem = this.makeLogTimeFilterSection(); 802 | }; 803 | if(this.logTimestampFlag && !this.logtimeFilterReElem) { 804 | this.logtimeFilterReElem = this.makeLogtimeFilterReSection(); 805 | }; 806 | 807 | if(this.logFacilitiesFlag && !this.logFacilitiesDropdown) { 808 | this.logFacilitiesDropdownElem = this.makeLogFacilitiesDropdownSection(); 809 | }; 810 | if(this.logLevelsFlag && !this.logLevelsDropdown) { 811 | this.logLevelsDropdownElem = this.makeLogLevelsDropdownSection(); 812 | }; 813 | if(this.logHostsFlag && !this.logHostsDropdown) { 814 | this.logHostsDropdownElem = this.makeLogHostsDropdownSection(); 815 | }; 816 | }; 817 | 818 | if(!autorefresh) { 819 | poll.start(); 820 | }; 821 | }).finally(() => { 822 | if(modal) { 823 | ui.hideModal(); 824 | }; 825 | if(!autorefresh) { 826 | this.enableFormElems(); 827 | }; 828 | }); 829 | }, 830 | 831 | filterSettingsModal() { 832 | return ui.showModal(_('Filter settings'), [ 833 | E('div', { 'class': 'cbi-map' }, 834 | E('div', { 'class': 'cbi-section' }, [ 835 | E('div', { 'class': 'cbi-section-node' }, [ 836 | E('div', { 'class': 'cbi-value' }, [ 837 | E('label', { 838 | 'class': 'cbi-value-title', 839 | 'for' : 'tailInput', 840 | }, _('Last entries')), 841 | E('div', { 'class': 'cbi-value-field' }, 842 | E('span', { 'class': 'control-group' }, [ 843 | this.tailInput, 844 | E('button', { 845 | 'class': 'cbi-button btn', 846 | 'click': L.bind(ev => { 847 | ev.target.blur(); 848 | ev.preventDefault(); 849 | this.tailInput.value = 0; 850 | this.tailInput.focus(); 851 | }, this), 852 | }, '⌫'), 853 | ]) 854 | ), 855 | ]), 856 | this.logConvertTimestampElem, 857 | this.logTimeFilterElem, 858 | this.logtimeFilterReElem, 859 | this.logHostsDropdownElem, 860 | this.logFacilitiesDropdownElem, 861 | this.logLevelsDropdownElem, 862 | E('div', { 'class': 'cbi-value' }, [ 863 | E('label', { 864 | 'class': 'cbi-value-title', 865 | 'for' : 'msgFilter', 866 | }, _('Message filter')), 867 | E('div', { 'class': 'cbi-value-field' }, 868 | E('span', { 'class': 'control-group' }, [ 869 | this.msgFilter, 870 | E('button', { 871 | 'class': 'cbi-button btn', 872 | 'click': L.bind(ev => { 873 | ev.target.blur(); 874 | ev.preventDefault(); 875 | this.msgFilter.value = null; 876 | this.msgFilter.focus(); 877 | }, this), 878 | }, '⌫'), 879 | ]) 880 | ) 881 | ]), 882 | E('div', { 'class': 'cbi-value' }, [ 883 | E('label', { 884 | 'class': 'cbi-value-title', 885 | 'for' : 'msgFilterRe', 886 | }, _('Filter is regexp')), 887 | E('div', { 'class': 'cbi-value-field' }, [ 888 | E('div', { 'class': 'cbi-checkbox' }, [ 889 | this.msgFilterRe, 890 | E('label', {}), 891 | ]), 892 | E('div', { 'class': 'cbi-value-description' }, 893 | _('Apply message filter as regular expression') 894 | ), 895 | ]), 896 | ]), 897 | E('div', { 'class': 'cbi-value' }, [ 898 | E('label', { 899 | 'class': 'cbi-value-title', 900 | 'for' : 'logSorting', 901 | }, _('Sorting entries')), 902 | E('div', { 'class': 'cbi-value-field' }, this.logSorting), 903 | ]), 904 | ((this.autorefreshOn) ? 905 | E('div', { 'class': 'cbi-value' }, [ 906 | E('label', { 907 | 'class': 'cbi-value-title', 908 | 'for' : 'autoRefresh', 909 | }, _('Auto refresh')), 910 | E('div', { 'class': 'cbi-value-field' }, 911 | E('div', { 'class': 'cbi-checkbox' }, [ 912 | this.autoRefresh, 913 | E('label', {}), 914 | ]) 915 | ), 916 | ]) : ''), 917 | ]), 918 | ]), 919 | ), 920 | this.logFilterForm, 921 | E('div', { 'class': 'right button-row' }, [ 922 | E('button', { 923 | 'class': 'btn', 924 | 'click': ev => { 925 | ev.target.blur(); 926 | this.resetFormValues(); 927 | this.timeFilter.focus(); 928 | this.msgFilter.focus(); 929 | ui.hideModal(); 930 | }, 931 | }, _('Dismiss')), 932 | ' ', 933 | E('button', { 934 | 'type' : 'submit', 935 | 'form' : 'logFilterForm', 936 | 'class': 'btn cbi-button-positive important', 937 | 'click': ui.createHandlerFn(this, function(ev) { 938 | ev.target.blur(); 939 | ev.preventDefault(); 940 | return this.onSubmitFilter(); 941 | }), 942 | }, _('Apply')), 943 | ]), 944 | ], 'cbi-modal'); 945 | }, 946 | 947 | updateLog(autorefresh=false) { 948 | let tail = (Number(this.tailValue) == 0 || Number(this.fastTailValue) == 0) 949 | ? 0 : Math.max(Number(this.tailValue), this.fastTailValue) 950 | return this.reloadLog(tail, false, autorefresh); 951 | }, 952 | 953 | /** 954 | * Creates a promise for the RPC request. 955 | * Abstract method, must be overridden by a subclass! 956 | * 957 | * To completely disable the auto log refresh option, views extending 958 | * this base class should overwrite the `getLogHash` function 959 | * with `null`. 960 | * 961 | * @instance 962 | * @abstract 963 | * 964 | * @returns {Promise} 965 | * Returns a promise that returns the unique value for the current log state. 966 | */ 967 | getLogHash() { 968 | throw new Error('getLogHash must be overridden by a subclass'); 969 | }, 970 | 971 | /** 972 | * Converts the timestamp format. 973 | * Abstract method, must be overridden by a subclass! 974 | * 975 | * To completely disable the convert timrstamp option, views extending 976 | * this base class should overwrite the `convertTimestampFunc` function 977 | * with `null`. 978 | * 979 | * @instance 980 | * @abstract 981 | * 982 | * @param {string} t 983 | * @returns {String} 984 | * Returns the converted timestamp string. 985 | */ 986 | convertTimestampFunc(t) { 987 | throw new Error('convertTimestampFunc must be overridden by a subclass'); 988 | }, 989 | 990 | async pollFunc() { 991 | await this.getLogHash().then(async hash => { 992 | if(this.lastHash !== hash) { 993 | this.lastHash = hash; 994 | return await this.updateLog(true); 995 | }; 996 | }); 997 | }, 998 | 999 | onSubmitFilter() { 1000 | if(this.logSorting.value != this.logSortingValue) { 1001 | if(this.logSorting.value == 'desc') { 1002 | this.logWrapper.after(this.moreEntriesBar); 1003 | } else { 1004 | this.logWrapper.before(this.moreEntriesBar); 1005 | }; 1006 | }; 1007 | this.saveSettingsToLocalStorage( 1008 | this.tailInput.value, this.logSorting.value, 1009 | this.autoRefresh.checked, this.convertTimestamp.checked); 1010 | this.setFilterSettings(); 1011 | this.fastTailValue = Number(this.tailValue); 1012 | return this.reloadLog(Number(this.tailValue), true); 1013 | }, 1014 | 1015 | scrollToTop() { 1016 | this.logWrapper.scrollIntoView(true); 1017 | }, 1018 | 1019 | scrollToBottom() { 1020 | this.logWrapper.scrollIntoView(false); 1021 | }, 1022 | 1023 | load() { 1024 | this.restoreSettingsFromLocalStorage(); 1025 | if(!this.enableAutoRefresh || typeof(this.getLogHash) != 'function') { 1026 | this.autorefreshOn = false; 1027 | this.autoRefreshValue = false; 1028 | }; 1029 | if(this.enableConvertTimestamp && typeof(this.convertTimestampFunc) == 'function') { 1030 | this.convertTimestampOn = true; 1031 | }; 1032 | return this.getLogData(this.tailValue); 1033 | }, 1034 | 1035 | render(logdata) { 1036 | this.pollFuncWrapper = L.bind(this.pollFunc, this); 1037 | 1038 | this.logWrapper = E('div', { 1039 | 'id': 'logWrapper', 1040 | }, this.makeLogArea(this.parseLogData(logdata, this.tailValue))); 1041 | 1042 | this.fastTailValue = this.tailValue 1043 | 1044 | this.tailInput = E('input', { 1045 | 'id' : 'tailInput', 1046 | 'name' : 'tailInput', 1047 | 'type' : 'text', 1048 | 'form' : 'logFilterForm', 1049 | 'class' : 'cbi-input-text', 1050 | 'style' : 'width:4em !important; min-width:4em !important', 1051 | 'maxlength': 5, 1052 | }); 1053 | this.tailInput.value = this.tailValue; 1054 | ui.addValidator(this.tailInput, 'uinteger', true); 1055 | 1056 | this.convertTimestamp = E('input', { 1057 | 'id' : 'convertTimestamp', 1058 | 'name' : 'convertTimestamp', 1059 | 'type' : 'checkbox', 1060 | 'form' : 'logFilterForm', 1061 | }); 1062 | this.convertTimestamp.checked = this.convertTimestampValue; 1063 | 1064 | this.timeFilter = E('input', { 1065 | 'id' : 'timeFilter', 1066 | 'name' : 'timeFilter', 1067 | 'type' : 'text', 1068 | 'form' : 'logFilterForm', 1069 | 'class' : 'cbi-input-text', 1070 | 'placeholder': _('Type a search pattern...'), 1071 | }); 1072 | 1073 | this.timeFilterRe = E('input', { 1074 | 'id' : 'timeFilterRe', 1075 | 'name' : 'timeFilterRe', 1076 | 'type' : 'checkbox', 1077 | 'form' : 'logFilterForm', 1078 | 'change': ev => this.timeFilter.focus(), 1079 | }); 1080 | 1081 | this.setRegexpValidator(this.timeFilter, this.timeFilterRe); 1082 | 1083 | this.logConvertTimestampElem = ''; 1084 | this.logTimeFilterElem = ''; 1085 | this.logtimeFilterReElem = ''; 1086 | this.logHostsDropdownElem = ''; 1087 | this.logFacilitiesDropdownElem = ''; 1088 | this.logLevelsDropdownElem = ''; 1089 | 1090 | if(this.logTimestampFlag) { 1091 | this.logConvertTimestampElem = this.makeLogConvertTimestampSection(); 1092 | this.logTimeFilterElem = this.makeLogTimeFilterSection(); 1093 | this.logtimeFilterReElem = this.makeLogtimeFilterReSection(); 1094 | }; 1095 | if(this.logLevelsFlag) { 1096 | this.logLevelsDropdownElem = this.makeLogLevelsDropdownSection(); 1097 | }; 1098 | if(this.logFacilitiesFlag) { 1099 | this.logFacilitiesDropdownElem = this.makeLogFacilitiesDropdownSection(); 1100 | }; 1101 | if(this.logHostsFlag) { 1102 | this.logHostsDropdownElem = this.makeLogHostsDropdownSection(); 1103 | }; 1104 | 1105 | this.msgFilter = E('input', { 1106 | 'id' : 'msgFilter', 1107 | 'name' : 'msgFilter', 1108 | 'type' : 'text', 1109 | 'form' : 'logFilterForm', 1110 | 'class' : 'cbi-input-text', 1111 | 'placeholder': _('Type a search pattern...'), 1112 | }); 1113 | 1114 | this.msgFilterRe = E('input', { 1115 | 'id' : 'msgFilterRe', 1116 | 'name' : 'msgFilterRe', 1117 | 'type' : 'checkbox', 1118 | 'form' : 'logFilterForm', 1119 | 'change': ev => this.msgFilter.focus(), 1120 | }); 1121 | 1122 | this.setRegexpValidator(this.msgFilter, this.msgFilterRe); 1123 | 1124 | this.logSorting = E('select', { 1125 | 'id' : 'logSorting', 1126 | 'name' : 'logSorting', 1127 | 'form' : 'logFilterForm', 1128 | 'class': "cbi-input-select", 1129 | }, [ 1130 | E('option', { 'value': 'asc' }, _('ascending')), 1131 | E('option', { 'value': 'desc' }, _('descending')), 1132 | ]); 1133 | this.logSorting.value = this.logSortingValue; 1134 | 1135 | this.autoRefresh = E('input', { 1136 | 'id' : 'autoRefresh', 1137 | 'name' : 'autoRefresh', 1138 | 'type' : 'checkbox', 1139 | 'form' : 'logFilterForm', 1140 | }); 1141 | this.autoRefresh.checked = this.autoRefreshValue; 1142 | 1143 | this.filterEditsBtn = E('button', { 1144 | 'class': 'cbi-button btn cbi-button-action', 1145 | 'click': L.bind(this.filterSettingsModal, this), 1146 | }, _('Edit')); 1147 | 1148 | this.logFilterForm = E('form', { 1149 | 'id' : 'logFilterForm', 1150 | 'name' : 'logFilterForm', 1151 | 'style' : 'display:none', 1152 | 'submit': ev => { 1153 | ev.preventDefault(); 1154 | return this.onSubmitFilter(); 1155 | }, 1156 | }); 1157 | 1158 | this.logDownloadBtn = E('button', { 1159 | 'id' : 'logDownloadBtn', 1160 | 'name' : 'logDownloadBtn', 1161 | 'class': 'cbi-button btn', 1162 | 'click': ui.createHandlerFn(this, this.downloadLog), 1163 | }, _('Download log')); 1164 | 1165 | this.refreshBtn = E('button', { 1166 | 'title': _('Refresh log'), 1167 | 'class': 'cbi-button btn log-side-btn', 1168 | 'style': `visibility:${(this.autoRefreshValue) ? 'hidden' : 'visible'}`, 1169 | 'click': ui.createHandlerFn(this, function(ev) { 1170 | ev.target.blur(); 1171 | return this.updateLog(); 1172 | }), 1173 | }, '⟳'); 1174 | 1175 | function getMoreEntries(ev) { 1176 | ev.target.blur(); 1177 | if(this.fastTailValue === null) { 1178 | this.fastTailValue = Number(this.tailValue); 1179 | }; 1180 | if(this.fastTailValue > 0) { 1181 | this.fastTailValue += this.fastTailIncrement; 1182 | }; 1183 | return this.reloadLog(this.fastTailValue); 1184 | } 1185 | 1186 | this.moreEntriesBtn = E('button', { 1187 | 'title': _('More entries'), 1188 | 'class': 'cbi-button btn log-side-btn', 1189 | 'style': 'margin-top:1px !important', 1190 | 'click': ui.createHandlerFn(this, getMoreEntries), 1191 | }, `+${this.fastTailIncrement}`); 1192 | 1193 | this.moreEntriesRowBtn = E('button', { 1194 | 'class': 'cbi-button btn', 1195 | 'click': ui.createHandlerFn(this, getMoreEntries), 1196 | }, _('More entries')); 1197 | 1198 | this.moreEntriesBar = E('div', { 1199 | 'id' : 'moreEntriesBar', 1200 | 'class': 'center', 1201 | }, this.moreEntriesRowBtn); 1202 | 1203 | this.allEntriesBtn = E('button', { 1204 | 'title': _('All entries'), 1205 | 'class': 'cbi-button btn log-side-btn', 1206 | 'style': 'margin-top:1px !important', 1207 | 'click': ui.createHandlerFn(this, function(ev) { 1208 | ev.target.blur(); 1209 | this.fastTailValue = 0; 1210 | return this.reloadLog(0); 1211 | }), 1212 | }, _('All')); 1213 | 1214 | this.filterModalBtn = E('button', { 1215 | 'title': _('Filter settings'), 1216 | 'class': 'cbi-button btn log-side-btn', 1217 | 'style': 'margin-top:10px !important', 1218 | 'click': ev => { 1219 | ev.target.blur(); 1220 | this.filterSettingsModal(); 1221 | }, 1222 | }, '▢'); 1223 | 1224 | this.actionButtons.push(this.filterEditsBtn, this.logDownloadBtn, 1225 | this.refreshBtn, this.moreEntriesBtn, 1226 | this.moreEntriesRowBtn, 1227 | this.allEntriesBtn, this.filterModalBtn); 1228 | 1229 | document.body.append( 1230 | E('div', { 1231 | 'align': 'right', 1232 | 'class': 'log-side-block', 1233 | }, [ 1234 | this.refreshBtn, 1235 | this.moreEntriesBtn, 1236 | this.allEntriesBtn, 1237 | this.filterModalBtn, 1238 | E('button', { 1239 | 'class': 'cbi-button btn log-side-btn', 1240 | 'style': 'margin-top:10px !important', 1241 | 'click': ev => { 1242 | this.scrollToTop(); 1243 | ev.target.blur(); 1244 | }, 1245 | }, '↑'), 1246 | E('button', { 1247 | 'class': 'cbi-button btn log-side-btn', 1248 | 'style': 'margin-top:1px !important', 1249 | 'click': ev => { 1250 | this.scrollToBottom(); 1251 | ev.target.blur(); 1252 | }, 1253 | }, '↓'), 1254 | ]) 1255 | ); 1256 | 1257 | if(this.autorefreshOn && this.autoRefreshValue) { 1258 | poll.add(this.pollFuncWrapper, this.pollInterval); 1259 | }; 1260 | 1261 | let logArea = [ this.moreEntriesBar, this.logWrapper ]; 1262 | if(this.logSortingValue == 'desc') { 1263 | logArea.reverse(); 1264 | }; 1265 | 1266 | return E([ 1267 | E('h2', { 'id': 'logTitle', 'class': 'fade-in' }, this.title), 1268 | E('div', { 'class': 'cbi-section-descr fade-in' }), 1269 | E('div', { 'class': 'cbi-section fade-in' }, 1270 | E('div', { 'class': 'cbi-section-node' }, [ 1271 | E('div', { 'class': 'cbi-value' }, [ 1272 | E('label', { 1273 | 'class': 'cbi-value-title', 1274 | 'for' : 'filterSettings', 1275 | }, _('Filter settings')), 1276 | E('div', { 'class': 'cbi-value-field' }, [ 1277 | E('div', {}, this.filterEditsBtn), 1278 | E('input', { 1279 | 'id' : 'filterSettings', 1280 | 'type': 'hidden', 1281 | }), 1282 | ]), 1283 | ]), 1284 | ]) 1285 | ), 1286 | E('div', { 'class': 'cbi-section fade-in' }, 1287 | E('div', { 'class': 'cbi-section-node' }, logArea) 1288 | ), 1289 | E('div', { 'class': 'cbi-section fade-in' }, 1290 | E('div', { 'class': 'cbi-section-node' }, 1291 | E('div', { 'class': 'cbi-value' }, 1292 | E('div', { 1293 | 'align': 'left', 1294 | 'style': 'width:100%', 1295 | }, this.logDownloadBtn) 1296 | ), 1297 | ) 1298 | ), 1299 | ]); 1300 | }, 1301 | 1302 | handleSaveApply: null, 1303 | handleSave : null, 1304 | handleReset : null, 1305 | }), 1306 | }) 1307 | -------------------------------------------------------------------------------- /htdocs/luci-static/resources/view/log-viewer/log-system.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 'require baseclass'; 3 | 'require fs'; 4 | 'require view.log-viewer.log-widget as widget'; 5 | 6 | return baseclass.extend({ 7 | view: widget.view.extend({ 8 | enableAutoRefresh: true, 9 | 10 | /** 11 | * Pattern for picking application-specific entries from the log. 12 | * 13 | * @property {string} appPattern 14 | */ 15 | appPattern : '^', 16 | 17 | /** 18 | * Enable "tail" option for the logread (logread -l). 19 | * Must be disabled for application-specific log. 20 | * 21 | * @property {bool} loggerTail 22 | */ 23 | loggerTail : false, 24 | 25 | logdRegexp : new RegExp(/^([^\s]{3}\s+[^\s]{3}\s+\d{1,2}\s+\d{1,2}:\d{1,2}:\d{1,2}\s+\d{4})\s+([a-z0-9]+)\.([a-z]+)\s+(.*)$/), 26 | 27 | syslog_ngRegexp : new RegExp(/^([^\s]{3}\s+\d{1,2}\s+\d{1,2}:\d{1,2}:\d{1,2})\s+([^\s]+)\s+(.*)$/), 28 | 29 | entryRegexp : null, 30 | 31 | loggerChecked : false, 32 | 33 | entriesHandler : null, 34 | 35 | logger : null, 36 | 37 | getLogHash() { 38 | return this.getLogData(1, true).then(data => { 39 | return data || ''; 40 | }); 41 | }, 42 | 43 | // logd 44 | logdHandler(strArray, lineNum) { 45 | return [ 46 | lineNum, // # (Number) 47 | strArray[1], // Timestamp (String) 48 | null, // Host (String) 49 | strArray[2], // Facility (String) 50 | strArray[3], // Level (String) 51 | this.htmlEntities(strArray[4]) || ' ', // Message (String) 52 | ]; 53 | }, 54 | 55 | // syslog-ng 56 | syslog_ngHandler(strArray, lineNum) { 57 | if(!(strArray[2] in this.logHosts)) { 58 | this.logHosts[strArray[2]] = this.makeLogHostsDropdownItem(strArray[2]); 59 | }; 60 | return [ 61 | lineNum, // # (Number) 62 | strArray[1], // Timestamp (String) 63 | strArray[2], // Host (String) 64 | null, // Facility (String) 65 | null, // Level (String) 66 | this.htmlEntities(strArray[3]) || ' ', // Message (String) 67 | ]; 68 | }, 69 | 70 | // unsupported log handler 71 | unsupportedLogHandler(line, lineNum) { 72 | return [ 73 | lineNum, // # (Number) 74 | null, // Timestamp (String) 75 | null, // Host (String) 76 | null, // Facility (String) 77 | null, // Level (String) 78 | this.htmlEntities(line) || ' ', // Message (String) 79 | ]; 80 | }, 81 | 82 | checkLogread() { 83 | return Promise.all([ 84 | L.resolveDefault(fs.stat('/sbin/logread'), null), 85 | L.resolveDefault(fs.stat('/usr/sbin/logread'), null), 86 | ]).then(stat => { 87 | let logger = (stat[0]) ? stat[0].path : (stat[1]) ? stat[1].path : null; 88 | if(logger) { 89 | this.logger = logger; 90 | } else { 91 | throw new Error(_('Logread not found')); 92 | }; 93 | }); 94 | }, 95 | 96 | async getLogData(tail, extraTstamp=false) { 97 | if(!this.logger) { 98 | await this.checkLogread(); 99 | }; 100 | let loggerArgs = []; 101 | if(this.loggerTail && tail) { 102 | loggerArgs.push('-l', String(tail)); 103 | }; 104 | loggerArgs.push('-e', this.appPattern); 105 | if(extraTstamp) { 106 | loggerArgs.push('-t'); 107 | }; 108 | return fs.exec_direct(this.logger, loggerArgs, 'text').catch(err => { 109 | throw new Error(_('Unable to load log data:') + ' ' + err.message); 110 | }); 111 | }, 112 | 113 | parseLogData(logdata, tail) { 114 | if(!logdata) { 115 | return []; 116 | }; 117 | 118 | let strings = logdata.trim().split(/\n/); 119 | 120 | if(!this.loggerTail && tail && tail > 0 && strings) { 121 | strings = strings.slice(-tail); 122 | }; 123 | 124 | this.totalLogLines = strings.length; 125 | 126 | let entriesArray = strings.map((e, i) => { 127 | if(!this.loggerChecked) { 128 | if(this.logdRegexp.test(e)) { 129 | this.entryRegexp = this.logdRegexp; 130 | this.logTimestampFlag = true; 131 | this.logFacilitiesFlag = true; 132 | this.logLevelsFlag = true; 133 | this.logHostsFlag = false; 134 | this.logHosts = {}; 135 | this.entriesHandler = this.logdHandler; 136 | this.logCols = [ 137 | '#', 138 | _('Timestamp'), 139 | null, 140 | _('Facility'), 141 | _('Level'), 142 | _('Message'), 143 | ]; 144 | this.loggerChecked = true; 145 | } 146 | else if(this.syslog_ngRegexp.test(e)) { 147 | this.entryRegexp = this.syslog_ngRegexp; 148 | this.logTimestampFlag = true; 149 | this.logFacilitiesFlag = false; 150 | this.logLevelsFlag = false; 151 | this.logHostsFlag = true; 152 | this.logFacilities = {}; 153 | this.logLevels = {}; 154 | this.entriesHandler = this.syslog_ngHandler; 155 | this.logCols = [ 156 | '#', 157 | _('Timestamp'), 158 | _('Host'), 159 | null, 160 | null, 161 | _('Message'), 162 | ]; 163 | this.loggerChecked = true; 164 | } else { 165 | return this.unsupportedLogHandler(e, i + 1); 166 | }; 167 | }; 168 | 169 | let strArray = e.match(this.entryRegexp); 170 | if(strArray) { 171 | return this.entriesHandler(strArray, i + 1); 172 | } else { 173 | return this.unsupportedLogHandler(e, i + 1); 174 | }; 175 | }); 176 | 177 | if(this.logSortingValue == 'desc') { 178 | entriesArray.reverse(); 179 | }; 180 | return entriesArray; 181 | }, 182 | }), 183 | }); 184 | -------------------------------------------------------------------------------- /htdocs/luci-static/resources/view/log-viewer/log-widget.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 'require baseclass'; 3 | 'require ui'; 4 | 'require view.log-viewer.log-base as base'; 5 | 6 | document.head.append(E('style', {'type': 'text/css'}, 7 | ` 8 | #logTable { 9 | width: calc(100% - 4px); 10 | } 11 | .log-entry-time-cell { 12 | min-width: 14em !important; 13 | } 14 | .log-entry-host-cell { 15 | min-width: 10em !important; 16 | overflow-wrap: anywhere !important; 17 | } 18 | .log-entry-message-cell { 19 | min-width: 20em !important; 20 | white-space: pre-wrap !important; 21 | overflow-wrap: anywhere !important; 22 | } 23 | .log-entry-text-nowrap { 24 | white-space: nowrap !important; 25 | }; 26 | `)); 27 | 28 | return baseclass.extend({ 29 | view: base.view.extend({ 30 | 31 | filterHighlightFunc(match) { 32 | return `${match}`; 33 | }, 34 | 35 | makeLogArea(logdataArray) { 36 | let lines = `${_('No entries available...')}`; 37 | let logTable = E('table', { 'id': 'logTable', 'class': 'table' }); 38 | 39 | for(let level of Object.keys(this.logLevels)) { 40 | this.logLevelsStat[level] = 0; 41 | }; 42 | 43 | if(logdataArray.length > 0) { 44 | lines = []; 45 | logdataArray.forEach((e, i) => { 46 | if(e[4] in this.logLevels) { 47 | this.logLevelsStat[e[4]] = this.logLevelsStat[e[4]] + 1; 48 | }; 49 | 50 | let line = [ `` ]; 51 | this.logCols.forEach((c, i) => { 52 | if(c) { 53 | let cellClass = ''; 54 | switch(i) { 55 | case 0: 56 | case 3: 57 | case 4: 58 | cellClass = 'log-entry-text-nowrap'; 59 | break; 60 | case 1: 61 | cellClass = 'log-entry-time-cell'; 62 | break; 63 | case 2: 64 | cellClass = 'log-entry-host-cell'; 65 | break; 66 | case 5: 67 | cellClass = 'log-entry-message-cell'; 68 | break; 69 | }; 70 | line.push(`${e[i] || ' '}`); 71 | }; 72 | }); 73 | line.push(''); 74 | lines.push(line.join('')); 75 | }); 76 | lines = lines.join(''); 77 | 78 | let logTableHeader = E('tr', { 'class': 'tr table-titles' }); 79 | this.logCols.forEach(e => { 80 | if(e) { 81 | logTableHeader.append( 82 | E('th', { 'class': 'th left log-entry-text-nowrap' }, e) 83 | ); 84 | }; 85 | }); 86 | logTable.append(logTableHeader); 87 | }; 88 | 89 | try { 90 | logTable.insertAdjacentHTML('beforeend', lines); 91 | } catch(err) { 92 | if(err.name == 'SyntaxError') { 93 | ui.addNotification(null, 94 | E('p', {}, _('HTML/XML error') + ': ' + err.message), 'error'); 95 | }; 96 | throw err; 97 | }; 98 | 99 | let levelsStatString = ''; 100 | if((Object.values(this.logLevelsStat).reduce((s,c) => s + c, 0)) > 0) { 101 | Object.entries(this.logLevelsStat).forEach(e => { 102 | if(e[0] in this.logLevels && e[1] > 0) { 103 | levelsStatString += `${e[1]}`; 104 | }; 105 | }); 106 | }; 107 | 108 | return E([ 109 | E('div', { 'class': 'log-entries-count' }, 110 | `${_('Entries')}: ${logdataArray.length} / ${this.totalLogLines}${levelsStatString}` 111 | ), 112 | logTable, 113 | ]); 114 | }, 115 | }), 116 | }); 117 | -------------------------------------------------------------------------------- /htdocs/luci-static/resources/view/log-viewer/syslog.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 'require view.log-viewer.log-system as abc'; 3 | 4 | return abc.view.extend({ 5 | viewName : 'syslog', 6 | title : _('System Log'), 7 | enableAutoRefresh: true, 8 | appPattern : '^', 9 | loggerTail : true, 10 | }); 11 | -------------------------------------------------------------------------------- /po/ru/log-viewer.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Content-Type: text/plain; charset=UTF-8\n" 4 | "Project-Id-Version: \n" 5 | "POT-Creation-Date: \n" 6 | "PO-Revision-Date: \n" 7 | "Last-Translator: \n" 8 | "Language-Team: \n" 9 | "MIME-Version: 1.0\n" 10 | "Content-Transfer-Encoding: 8bit\n" 11 | "Language: ru\n" 12 | "X-Generator: Poedit 2.0.6\n" 13 | 14 | msgid "Alert" 15 | msgstr "" 16 | 17 | msgid "All" 18 | msgstr "Все" 19 | 20 | msgid "All entries" 21 | msgstr "Все записи" 22 | 23 | msgid "Apply" 24 | msgstr "Применить" 25 | 26 | msgid "Apply message filter as regular expression" 27 | msgstr "Применять фильтр сообщений как регулярное выражение" 28 | 29 | msgid "Apply timestamp filter as regular expression" 30 | msgstr "Применять фильтр даты как регулярное выражение" 31 | 32 | msgid "Auto refresh" 33 | msgstr "Автообновление" 34 | 35 | msgid "Convert timestamps to a human readable date" 36 | msgstr "Конвертировать временные метки в удобочитаемую дату" 37 | 38 | msgid "Date" 39 | msgstr "Дата" 40 | 41 | msgid "Dismiss" 42 | msgstr "Закрыть" 43 | 44 | msgid "Download log" 45 | msgstr "Скачать журнал" 46 | 47 | msgid "Edit" 48 | msgstr "Изменить" 49 | 50 | msgid "Entries" 51 | msgstr "Записи" 52 | 53 | msgid "Facility" 54 | msgstr "Категория" 55 | 56 | msgid "Facilities" 57 | msgstr "Категории" 58 | 59 | msgid "Filter is regexp" 60 | msgstr "Фильтр - рег.выражение" 61 | 62 | msgid "Filter settings" 63 | msgstr "Настройки фильтра" 64 | 65 | msgid "Host" 66 | msgstr "Хост" 67 | 68 | msgid "Hosts" 69 | msgstr "Хосты" 70 | 71 | msgid "Invalid regular expression" 72 | msgstr "Неправильное регулярное выражение" 73 | 74 | msgid "Kernel Log" 75 | msgstr "Журнал ядра" 76 | 77 | msgid "Last entries" 78 | msgstr "Последние записи" 79 | 80 | msgid "Level" 81 | msgstr "Уровень" 82 | 83 | msgid "Log" 84 | msgstr "Лог" 85 | 86 | msgid "Log Viewer" 87 | msgstr "Просмотр лога" 88 | 89 | msgid "Logging levels" 90 | msgstr "Уровни логирования" 91 | 92 | msgid "Logread not found" 93 | msgstr "Logread не найден" 94 | 95 | msgid "Message" 96 | msgstr "Сообщение" 97 | 98 | msgid "Message filter" 99 | msgstr "Фильтр сообщений" 100 | 101 | msgid "More entries" 102 | msgstr "Больше записей" 103 | 104 | msgid "No entries available..." 105 | msgstr "Нет доступных записей..." 106 | 107 | msgid "Refresh log" 108 | msgstr "Обновить лог" 109 | 110 | msgid "Sorting entries" 111 | msgstr "Сортировка записей" 112 | 113 | msgid "System Log" 114 | msgstr "Системный журнал" 115 | 116 | msgid "Timestamp" 117 | msgstr "Время" 118 | 119 | msgid "Timestamp filter" 120 | msgstr "Фильтр даты" 121 | 122 | msgid "Type a search pattern..." 123 | msgstr "Введите шаблон для поиска..." 124 | 125 | msgid "Unable to load log data:" 126 | msgstr "Невозможно загрузить данные лога:" 127 | 128 | msgid "Unsupported log format" 129 | msgstr "Неподдерживаемый формат лога" 130 | 131 | msgid "ascending" 132 | msgstr "по возрастанию" 133 | 134 | msgid "descending" 135 | msgstr "по убыванию" 136 | -------------------------------------------------------------------------------- /po/templates/log-viewer.pot: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "Content-Type: text/plain; charset=UTF-8" 3 | 4 | msgid "Alert" 5 | msgstr "" 6 | 7 | msgid "All" 8 | msgstr "" 9 | 10 | msgid "All entries" 11 | msgstr "" 12 | 13 | msgid "Apply" 14 | msgstr "" 15 | 16 | msgid "Apply message filter as regular expression" 17 | msgstr "" 18 | 19 | msgid "Apply timestamp filter as regular expression" 20 | msgstr "" 21 | 22 | msgid "Auto refresh" 23 | msgstr "" 24 | 25 | msgid "Convert timestamps to a human readable date" 26 | msgstr "" 27 | 28 | msgid "Date" 29 | msgstr "" 30 | 31 | msgid "Dismiss" 32 | msgstr "" 33 | 34 | msgid "Download log" 35 | msgstr "" 36 | 37 | msgid "Edit" 38 | msgstr "" 39 | 40 | msgid "Entries" 41 | msgstr "" 42 | 43 | msgid "Facility" 44 | msgstr "" 45 | 46 | msgid "Facilities" 47 | msgstr "" 48 | 49 | msgid "Filter is regexp" 50 | msgstr "" 51 | 52 | msgid "Filter settings" 53 | msgstr "" 54 | 55 | msgid "Host" 56 | msgstr "" 57 | 58 | msgid "Hosts" 59 | msgstr "" 60 | 61 | msgid "Invalid regular expression" 62 | msgstr "" 63 | 64 | msgid "Kernel Log" 65 | msgstr "" 66 | 67 | msgid "Last entries" 68 | msgstr "" 69 | 70 | msgid "Level" 71 | msgstr "" 72 | 73 | msgid "Log" 74 | msgstr "" 75 | 76 | msgid "Log Viewer" 77 | msgstr "" 78 | 79 | msgid "Logging levels" 80 | msgstr "" 81 | 82 | msgid "Logread not found" 83 | msgstr "" 84 | 85 | msgid "Message" 86 | msgstr "" 87 | 88 | msgid "Message filter" 89 | msgstr "" 90 | 91 | msgid "More entries" 92 | msgstr "" 93 | 94 | msgid "No entries available..." 95 | msgstr "" 96 | 97 | msgid "Refresh log" 98 | msgstr "" 99 | 100 | msgid "Sorting entries" 101 | msgstr "" 102 | 103 | msgid "System Log" 104 | msgstr "" 105 | 106 | msgid "Timestamp" 107 | msgstr "" 108 | 109 | msgid "Timestamp filter" 110 | msgstr "" 111 | 112 | msgid "Type a search pattern..." 113 | msgstr "" 114 | 115 | msgid "Unable to load log data:" 116 | msgstr "" 117 | 118 | msgid "Unsupported log format" 119 | msgstr "" 120 | 121 | msgid "ascending" 122 | msgstr "" 123 | 124 | msgid "descending" 125 | msgstr "" 126 | -------------------------------------------------------------------------------- /root/usr/libexec/rpcd/luci.log-viewer: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | . /usr/share/libubox/jshn.sh 4 | 5 | readonly LOGREAD_CMD="logread" 6 | readonly DMESG_CMD="dmesg" 7 | 8 | make_value() { 9 | json_init 10 | json_add_string "$1" "$2" 11 | json_dump 12 | json_cleanup 13 | } 14 | 15 | get_syslog_size() { 16 | make_value 'bytes' "$($LOGREAD_CMD | wc -c)" 17 | } 18 | 19 | get_syslog_hash() { 20 | make_value 'hash' "$($LOGREAD_CMD -t -l 1)" 21 | } 22 | 23 | get_dmesg_size() { 24 | make_value 'bytes' "$($DMESG_CMD | wc -c)" 25 | } 26 | 27 | get_dmesg_hash() { 28 | make_value 'hash' "$($DMESG_CMD | tail -n 1)" 29 | } 30 | 31 | get_logfile_size() { 32 | make_value 'bytes' "$(ls -l "$1" | awk '{printf $5}')" 33 | } 34 | 35 | case "$1" in 36 | list) 37 | json_init 38 | json_add_object "getSyslogSize" 39 | json_close_object 40 | json_add_object "getSyslogHash" 41 | json_close_object 42 | json_add_object "getDmesgSize" 43 | json_close_object 44 | json_add_object "getDmesgHash" 45 | json_close_object 46 | json_add_object "getLogfileSize" 47 | json_add_string 'fpath' 'fpath' 48 | json_close_object 49 | json_dump 50 | json_cleanup 51 | ;; 52 | call) 53 | case "$2" in 54 | getSyslogSize) 55 | get_syslog_size 56 | ;; 57 | getSyslogHash) 58 | get_syslog_hash 59 | ;; 60 | getDmesgSize) 61 | get_dmesg_size 62 | ;; 63 | getDmesgHash) 64 | get_dmesg_hash 65 | ;; 66 | getLogfileSize) 67 | if [ -n "$3" ]; then 68 | json_load "$3" 69 | json_get_var fpath 'fpath' 70 | json_cleanup 71 | get_logfile_size "$fpath" 72 | fi 73 | ;; 74 | esac 75 | ;; 76 | esac 77 | -------------------------------------------------------------------------------- /root/usr/share/luci/menu.d/luci-app-log-viewer.json: -------------------------------------------------------------------------------- 1 | { 2 | "admin/status/log-viewer": { 3 | "title": "Log Viewer", 4 | "order": 5, 5 | "action": { 6 | "type": "alias", 7 | "path": "admin/status/log-viewer/syslog" 8 | }, 9 | "depends": { 10 | "acl": [ "luci-app-log-viewer" ] 11 | } 12 | }, 13 | 14 | "admin/status/log-viewer/syslog": { 15 | "title": "System Log", 16 | "order": 1, 17 | "action": { 18 | "type": "view", 19 | "path": "log-viewer/syslog" 20 | } 21 | }, 22 | 23 | "admin/status/log-viewer/dmesg": { 24 | "title": "Kernel Log", 25 | "order": 2, 26 | "action": { 27 | "type": "view", 28 | "path": "log-viewer/dmesg" 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /root/usr/share/rpcd/acl.d/luci-app-log-viewer.json: -------------------------------------------------------------------------------- 1 | { 2 | "luci-app-log-viewer": { 3 | "description": "Grant access to log-viewer procedures", 4 | "read": { 5 | "cgi-io": [ "exec" ], 6 | "file": { 7 | "/sbin/logread*": [ "exec" ], 8 | "/usr/sbin/logread*": [ "exec" ], 9 | "/bin/dmesg -r": [ "exec" ] 10 | }, 11 | "ubus": { 12 | "system": [ "info" ], 13 | "luci.log-viewer": [ "getSyslogSize", "getSyslogHash", "getDmesgSize", "getDmesgHash", "getLogfileSize" ] 14 | } 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /screenshots/01.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gSpotx2f/luci-app-log/7de10f55fee9a6f34b418d685448125bd0924c2b/screenshots/01.jpg -------------------------------------------------------------------------------- /screenshots/02.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gSpotx2f/luci-app-log/7de10f55fee9a6f34b418d685448125bd0924c2b/screenshots/02.jpg -------------------------------------------------------------------------------- /screenshots/03.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gSpotx2f/luci-app-log/7de10f55fee9a6f34b418d685448125bd0924c2b/screenshots/03.jpg -------------------------------------------------------------------------------- /screenshots/04.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gSpotx2f/luci-app-log/7de10f55fee9a6f34b418d685448125bd0924c2b/screenshots/04.jpg --------------------------------------------------------------------------------