Loading CMakeLists.txt +5 −1 Changes for CMakeLists.txt: 5 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -10,7 +10,7 @@ project(webctl VERSION 0.2.3 LANGUAGES CXX) # forwarded to the backend; drops to the webctl user # after binding # modules/ one shared object per area (dashboard, users, network, shares, # raid, lvm, filesystems, services, updates, snmp, # raid, lvm, filesystems, services, journal, updates, snmp, # time, system), # dlopen()ed by the backend from its module directory # www/ the AJAX UI; www/modules/<name>.js per module Loading @@ -35,6 +35,7 @@ find_package(sysbus REQUIRED) set(WEBCTL_SYSCONFDIR "/etc/webctl" CACHE PATH "configuration directory") set(WEBCTL_MODULEDIR "${CMAKE_INSTALL_FULL_LIBDIR}/webctl/modules" CACHE PATH "backend module directory") set(WEBCTL_WWWDIR "${CMAKE_INSTALL_FULL_DATADIR}/webctl/www" CACHE PATH "UI files") set(WEBCTL_SYSLITE_JOURNAL "/var/log/syslite/journal.log" CACHE STRING "syslite's journal (its SYSLITE_JOURNAL)") add_library(webctl_common STATIC src/common/protocol.cpp Loading Loading @@ -88,6 +89,9 @@ webctl_module(network modules/network/network.cpp) target_link_libraries(webctl_mod_network PRIVATE sysbus::sysbus) webctl_module(services modules/services/services.cpp) target_link_libraries(webctl_mod_services PRIVATE sysbus::sysbus) # journal: syslite's journal file, read directly (as journalctl does) webctl_module(journal modules/journal/journal.cpp) target_compile_definitions(webctl_mod_journal PRIVATE WEBCTL_SYSLITE_JOURNAL="${WEBCTL_SYSLITE_JOURNAL}") # dashboard: load, memory, space, traffic from /proc and /sys; # modules/common/blockdev.cpp: guarded statvfs() webctl_module(dashboard modules/dashboard/dashboard.cpp modules/common/blockdev.cpp) Loading modules/journal/journal.cpp 0 → 100644 +223 −0 Changes for modules/journal/journal.cpp: 223 added lines, 0 removed lines. Original line number Diff line number Diff line /******************************************************************************* Copyright (c) 2026, Jan Koester jan.koester@gmx.net All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the <organization> nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. *******************************************************************************/ /* webctl module "journal": syslite's journal, read from its file * (WEBCTL_SYSLITE_JOURNAL, rotated to .1), one line each: * * 2026-10-04T08:12:33.123+0200 sshd[412]: Server listening on 0.0.0.0 port 22. * * read {unit, grep, lines, cursor} -> {lines, cursor, reset, exists, tags} * * unit: only its lines and syslite's lines about it (as journalctl -u); * grep: only lines containing it (case-insensitive); lines: the last N * (default 500, at most 5000). Without cursor the whole journal is read and * tags lists every tag in it; with the cursor of an earlier reply only the * lines since then (follow) -- reset: the cursor didn't fit (the journal * was rotated twice or removed), the lines are a full read again. */ #include <algorithm> #include <cctype> #include <deque> #include <fstream> #include <set> #include <string> #include <sys/stat.h> #include <webctl/module.h> using webctl::error; using webctl::tr; using webctl::jstr; namespace { const std::string JournalPath = WEBCTL_SYSLITE_JOURNAL; const long DefaultLines = 500, MaxLines = 5000; // the reply has to fit into one frame (1 MiB) to the frontend const size_t MaxBytes = 768 << 10; // "sshd.service" -> "sshd": the journal's tags are the bare names std::string unitName(const std::string &name) { const std::string suffix = ".service"; if (name.size() > suffix.size() && name.compare(name.size() - suffix.size(), suffix.size(), suffix) == 0) return name.substr(0, name.size() - suffix.size()); return name; } // "2026-...+0200 sshd[412]: text" -> "sshd" std::string lineTag(const std::string &line) { size_t s = line.find(' '); if (s == std::string::npos) return ""; size_t e = line.find_first_of("[:", s + 1); return e == std::string::npos ? "" : line.substr(s + 1, e - s - 1); } bool nameChar(char c) { return isalnum((unsigned char)c) || c == '-' || c == '_' || c == '.' || c == '@' || c == '\\'; } // a unit name ends at i: the line ends, or no name character follows -- // a '.' only when no letter or digit comes after it ("Starting x...") bool endsName(const std::string &line, size_t i) { if (i >= line.size() || !nameChar(line[i])) return true; return line[i] == '.' && (i + 1 >= line.size() || !isalnum((unsigned char)line[i + 1])); } std::string lower(std::string s) { for (char &c : s) c = (char)tolower((unsigned char)c); return s; } struct Filter { std::string unit; std::string grep; // lower case long lines = DefaultLines; // the unit's own lines, and syslite's lines about it (syslitectl's -u) bool unitMatches(const std::string &line) const { if (unit.empty()) return true; std::string tag = lineTag(line); if (tag == unit) return true; if (tag != "syslite") return false; size_t text = line.find(": "); for (size_t at = line.find(unit, text); at != std::string::npos; at = line.find(unit, at + 1)) if ((at == 0 || !nameChar(line[at - 1])) && endsName(line, at + unit.size())) return true; return false; } bool matches(const std::string &line) const { return unitMatches(line) && (grep.empty() || lower(line).find(grep) != std::string::npos); } }; struct Result { std::deque<std::string> lines; size_t bytes = 0; std::set<std::string> tags; bool collectTags = false; void add(const Filter &f, const std::string &line) { if (line.empty()) return; if (collectTags) { std::string tag = lineTag(line); if (!tag.empty()) tags.insert(tag); } if (!f.matches(line)) return; lines.push_back(line); bytes += line.size() + 8; while (!lines.empty() && ((long)lines.size() > f.lines || bytes > MaxBytes)) { bytes -= lines.front().size() + 8; lines.pop_front(); } } }; // the complete lines of path from offset on; returns where the next read // starts (after the last newline), or -1 when the file isn't there long long readFrom(const std::string &path, long long offset, const Filter &f, Result &r) { std::ifstream in(path, std::ios::binary); if (!in) return -1; in.seekg(offset); if (!in) return offset; long long pos = offset; std::string line; while (std::getline(in, line)) { if (in.eof()) break; // no newline yet: still being written pos += (long long)line.size() + 1; r.add(f, line); } return pos; } json_object *readJournal(const webctl_request *req) { Filter f; f.unit = unitName(webctl::str(req->args, "unit")); f.grep = lower(webctl::str(req->args, "grep")); f.lines = webctl::integer(req->args, "lines", DefaultLines); if (f.lines < 1) f.lines = DefaultLines; if (f.lines > MaxLines) f.lines = MaxLines; const std::string oldPath = JournalPath + ".1"; struct stat cur{}, old{}; bool haveCur = stat(JournalPath.c_str(), &cur) == 0; bool haveOld = stat(oldPath.c_str(), &old) == 0; // "inode:offset" in the current file std::string cursor = webctl::str(req->args, "cursor"); unsigned long long ino = 0; long long offset = -1; if (!cursor.empty()) { size_t colon = cursor.find(':'); if (colon != std::string::npos) { ino = strtoull(cursor.c_str(), nullptr, 10); offset = strtoll(cursor.c_str() + colon + 1, nullptr, 10); } } Result r; long long end = -1; bool reset = false; if (offset >= 0 && haveCur && ino == (unsigned long long)cur.st_ino && offset <= (long long)cur.st_size) { end = readFrom(JournalPath, offset, f, r); } else if (offset >= 0 && haveOld && ino == (unsigned long long)old.st_ino) { // rotated since: the rest of the old file, then the new one readFrom(oldPath, offset, f, r); end = haveCur ? readFrom(JournalPath, 0, f, r) : -1; } else { reset = !cursor.empty(); r.collectTags = true; if (haveOld) readFrom(oldPath, 0, f, r); end = haveCur ? readFrom(JournalPath, 0, f, r) : -1; } json_object *out = json_object_new_object(); json_object *lines = json_object_new_array(); for (auto &l : r.lines) json_object_array_add(lines, jstr(l)); json_object_object_add(out, "lines", lines); json_object_object_add(out, "cursor", jstr(end >= 0 ? std::to_string((unsigned long long)cur.st_ino) + ":" + std::to_string(end) : "")); json_object_object_add(out, "reset", json_object_new_boolean(reset)); json_object_object_add(out, "exists", json_object_new_boolean(haveCur || haveOld)); json_object_object_add(out, "path", jstr(JournalPath)); if (r.collectTags) { json_object *tags = json_object_new_array(); for (auto &t : r.tags) json_object_array_add(tags, jstr(t)); json_object_object_add(out, "tags", tags); } return out; } json_object *call(const webctl_request *req) { webctl::LanguageScope lang(req->lang); std::string action = req->action; if (action == "read") return readJournal(req); return error(tr("unknown action %1", "unbekannte Aktion %1", {action}), 404); } } // namespace WEBCTL_MODULE({WEBCTL_MODULE_ABI, "journal", "Journal", 31, nullptr, nullptr, call}); www/app.css +11 −0 Changes for www/app.css: 11 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -322,3 +322,14 @@ td.fs-use { min-width: 200px; } .sensor { display: grid; grid-template-columns: minmax(0, 1fr) minmax(80px, 2fr) 4em; align-items: center; gap: 10px; } .sensor-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sensor-temp { text-align: right; font-variant-numeric: tabular-nums; } /* ---- journal ---- */ .toolbar select { width: auto; } label.journal-follow { margin: 0; white-space: nowrap; } .journal { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: .5em .7em; height: calc(100vh - 260px); min-height: 18em; overflow: auto; font-size: .85em; } .journal > div { white-space: pre-wrap; word-break: break-word; padding: 1px 0; } .journal > div.err { color: var(--danger); } .journal > div.warn { color: var(--warn); } .journal .j-time { color: var(--muted); } .journal .j-tag { font-weight: 600; } www/modules/journal.js 0 → 100644 +145 −0 Changes for www/modules/journal.js: 145 added lines, 0 removed lines. Original line number Diff line number Diff line 'use strict'; // Journal (backend module "journal"): syslite's journal, filtered by unit // and text, live while "Follow" is on. webctl.openJournal(unit) shows it // for one unit (the services page). (() => { const {h, t, call, run} = webctl; webctl.translations({de: { 'Journal': 'Journal', 'All units': 'Alle Units', 'Search …': 'Suchen …', 'Follow': 'Live', 'Last {0} lines': 'Letzte {0} Zeilen', 'No journal at {0} yet.': 'Noch kein Journal unter {0}.', 'No line matches.': 'Keine Zeile passt.', '{0} lines': '{0} Zeilen', 'The journal holds syslite\'s messages and every service\'s output ({0}, the older part in {0}.1).': 'Das Journal enthält die Meldungen von syslite und die Ausgabe aller Dienste ({0}, der ältere Teil in {0}.1).', }}); const lineRe = /^(\S+) ([^\[:\s]+)(?:\[(\d+)\])?: ?(.*)$/; const levels = [100, 500, 2000, 5000]; let root, view, count, timer; let unit = '', grep = '', lines = 500, follow = true; let tags = [], cursor = '', shown = [], exists = true, path = ''; let pendingUnit = null; let busy = false; // "2026-10-04T08:12:33.123+0200" -> "2026-10-04 08:12:33" const time = ts => ts.slice(0, 19).replace('T', ' '); function lineNode(l) { const m = lineRe.exec(l); if (!m) return h('div', {}, l); const text = m[4]; const cls = /\b(error|failed|fatal|panic|segfault|denied)\b/i.test(text) || m[2] === 'coredump' ? 'err' : /\b(warn(ing)?|timeout|timed out)\b/i.test(text) ? 'warn' : null; return h('div', {class: cls, title: m[1]}, h('span', {class: 'j-time'}, time(m[1])), ' ', h('span', {class: 'j-tag'}, m[2] + (m[3] ? '[' + m[3] + ']' : '')), ': ', text); } const atBottom = () => view.scrollHeight - view.scrollTop - view.clientHeight < 30; function append(newLines, replace) { const stick = replace || atBottom(); if (replace) { shown = []; view.replaceChildren(); } shown.push(...newLines); view.append(...newLines.map(lineNode)); // keep at most "lines" lines on the page while (shown.length > lines) { shown.shift(); view.firstChild.remove(); } if (!shown.length) view.replaceChildren(h('p', {class: 'empty'}, exists ? t('No line matches.') : t('No journal at {0} yet.', path))); count.textContent = t('{0} lines', shown.length); if (stick) view.scrollTop = view.scrollHeight; } async function load(full) { if (busy) return; busy = true; try { const args = {unit, grep, lines}; if (!full && cursor) args.cursor = cursor; const r = await run(() => call('journal', 'read', args)); if (!r) return; exists = r.exists; path = r.path || path; if (r.tags) { tags = r.tags; drawUnits(); } cursor = r.cursor || ''; if (full || r.reset || !args.cursor) append(r.lines || [], true); else if ((r.lines || []).length) append(r.lines, false); } finally { busy = false; } } let unitSelect; function drawUnits() { const names = [...new Set([...tags, ...(unit ? [unit] : [])])].sort((a, b) => a.localeCompare(b)); unitSelect.replaceChildren(new Option(t('All units'), ''), ...names.map(n => new Option(n, n))); unitSelect.value = unit; } function setTimer() { clearInterval(timer); timer = follow ? setInterval(() => { if (!document.hidden) load(false); }, 2000) : null; } function draw() { unitSelect = h('select', {onchange: e => { unit = e.target.value; load(true); }}); drawUnits(); let typing; const search = h('input', {type: 'search', placeholder: t('Search …'), value: grep, oninput: e => { clearTimeout(typing); typing = setTimeout(() => { grep = e.target.value.trim(); load(true); }, 400); }}); const size = h('select', {onchange: e => { lines = +e.target.value; load(true); }}, levels.map(n => new Option(t('Last {0} lines', n), n))); size.value = lines; const live = h('label', {class: 'check journal-follow'}, h('input', {type: 'checkbox', checked: follow, onchange: e => { follow = e.target.checked; setTimer(); if (follow) load(false); }}), ' ', t('Follow')); view = h('div', {class: 'journal mono'}); count = h('span', {class: 'muted'}); root.replaceChildren( h('section', {class: 'panel'}, h('div', {class: 'toolbar'}, h('h2', {}, t('Journal')), unitSelect, search, size, live, h('button', {onclick: () => load(true)}, t('Refresh'))), view, h('p', {class: 'hint'}, count, ' · ', t('The journal holds syslite\'s messages and every service\'s output ({0}, the older part in {0}.1).', path || '/var/log/syslite/journal.log')))); } webctl.openJournal = name => { pendingUnit = name; location.hash = '#journal'; }; const page = { render(container) { root = container; if (pendingUnit !== null) { unit = pendingUnit; grep = ''; pendingUnit = null; } cursor = ''; draw(); load(true); setTimer(); return () => { clearInterval(timer); timer = null; }; }, }; webctl.register('journal', page); })(); www/modules/services.js +5 −0 Changes for www/modules/services.js: 5 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -20,6 +20,7 @@ '{0} restarted.': '{0} neu gestartet.', 'Disable': 'Autostart aus', 'Enable': 'Autostart an', '{0} enabled.': 'Autostart für {0} an.', '{0} disabled.': 'Autostart für {0} aus.', 'No service matches the filter.': 'Kein Dienst passt zum Filter.', 'No services.': 'Keine Dienste.', 'Journal': 'Journal', 'The journal lines of {0}': 'Die Journal-Zeilen von {0}', }}); let root; Loading Loading @@ -88,6 +89,10 @@ : u.enabled === 'disabled' ? h('button', {class: 'small', onclick: () => action(u, 'enable', t('{0} enabled.', u.name))}, t('Enable')) : null, webctl.openJournal ? h('button', {class: 'small', title: t('The journal lines of {0}', u.name), onclick: () => webctl.openJournal(u.name)}, t('Journal')) : null, ]}, ], shown, units.length ? t('No service matches the filter.') : t('No services.')))); if (filter) { Loading Loading
CMakeLists.txt +5 −1 Changes for CMakeLists.txt: 5 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -10,7 +10,7 @@ project(webctl VERSION 0.2.3 LANGUAGES CXX) # forwarded to the backend; drops to the webctl user # after binding # modules/ one shared object per area (dashboard, users, network, shares, # raid, lvm, filesystems, services, updates, snmp, # raid, lvm, filesystems, services, journal, updates, snmp, # time, system), # dlopen()ed by the backend from its module directory # www/ the AJAX UI; www/modules/<name>.js per module Loading @@ -35,6 +35,7 @@ find_package(sysbus REQUIRED) set(WEBCTL_SYSCONFDIR "/etc/webctl" CACHE PATH "configuration directory") set(WEBCTL_MODULEDIR "${CMAKE_INSTALL_FULL_LIBDIR}/webctl/modules" CACHE PATH "backend module directory") set(WEBCTL_WWWDIR "${CMAKE_INSTALL_FULL_DATADIR}/webctl/www" CACHE PATH "UI files") set(WEBCTL_SYSLITE_JOURNAL "/var/log/syslite/journal.log" CACHE STRING "syslite's journal (its SYSLITE_JOURNAL)") add_library(webctl_common STATIC src/common/protocol.cpp Loading Loading @@ -88,6 +89,9 @@ webctl_module(network modules/network/network.cpp) target_link_libraries(webctl_mod_network PRIVATE sysbus::sysbus) webctl_module(services modules/services/services.cpp) target_link_libraries(webctl_mod_services PRIVATE sysbus::sysbus) # journal: syslite's journal file, read directly (as journalctl does) webctl_module(journal modules/journal/journal.cpp) target_compile_definitions(webctl_mod_journal PRIVATE WEBCTL_SYSLITE_JOURNAL="${WEBCTL_SYSLITE_JOURNAL}") # dashboard: load, memory, space, traffic from /proc and /sys; # modules/common/blockdev.cpp: guarded statvfs() webctl_module(dashboard modules/dashboard/dashboard.cpp modules/common/blockdev.cpp) Loading
modules/journal/journal.cpp 0 → 100644 +223 −0 Changes for modules/journal/journal.cpp: 223 added lines, 0 removed lines. Original line number Diff line number Diff line /******************************************************************************* Copyright (c) 2026, Jan Koester jan.koester@gmx.net All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the <organization> nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. *******************************************************************************/ /* webctl module "journal": syslite's journal, read from its file * (WEBCTL_SYSLITE_JOURNAL, rotated to .1), one line each: * * 2026-10-04T08:12:33.123+0200 sshd[412]: Server listening on 0.0.0.0 port 22. * * read {unit, grep, lines, cursor} -> {lines, cursor, reset, exists, tags} * * unit: only its lines and syslite's lines about it (as journalctl -u); * grep: only lines containing it (case-insensitive); lines: the last N * (default 500, at most 5000). Without cursor the whole journal is read and * tags lists every tag in it; with the cursor of an earlier reply only the * lines since then (follow) -- reset: the cursor didn't fit (the journal * was rotated twice or removed), the lines are a full read again. */ #include <algorithm> #include <cctype> #include <deque> #include <fstream> #include <set> #include <string> #include <sys/stat.h> #include <webctl/module.h> using webctl::error; using webctl::tr; using webctl::jstr; namespace { const std::string JournalPath = WEBCTL_SYSLITE_JOURNAL; const long DefaultLines = 500, MaxLines = 5000; // the reply has to fit into one frame (1 MiB) to the frontend const size_t MaxBytes = 768 << 10; // "sshd.service" -> "sshd": the journal's tags are the bare names std::string unitName(const std::string &name) { const std::string suffix = ".service"; if (name.size() > suffix.size() && name.compare(name.size() - suffix.size(), suffix.size(), suffix) == 0) return name.substr(0, name.size() - suffix.size()); return name; } // "2026-...+0200 sshd[412]: text" -> "sshd" std::string lineTag(const std::string &line) { size_t s = line.find(' '); if (s == std::string::npos) return ""; size_t e = line.find_first_of("[:", s + 1); return e == std::string::npos ? "" : line.substr(s + 1, e - s - 1); } bool nameChar(char c) { return isalnum((unsigned char)c) || c == '-' || c == '_' || c == '.' || c == '@' || c == '\\'; } // a unit name ends at i: the line ends, or no name character follows -- // a '.' only when no letter or digit comes after it ("Starting x...") bool endsName(const std::string &line, size_t i) { if (i >= line.size() || !nameChar(line[i])) return true; return line[i] == '.' && (i + 1 >= line.size() || !isalnum((unsigned char)line[i + 1])); } std::string lower(std::string s) { for (char &c : s) c = (char)tolower((unsigned char)c); return s; } struct Filter { std::string unit; std::string grep; // lower case long lines = DefaultLines; // the unit's own lines, and syslite's lines about it (syslitectl's -u) bool unitMatches(const std::string &line) const { if (unit.empty()) return true; std::string tag = lineTag(line); if (tag == unit) return true; if (tag != "syslite") return false; size_t text = line.find(": "); for (size_t at = line.find(unit, text); at != std::string::npos; at = line.find(unit, at + 1)) if ((at == 0 || !nameChar(line[at - 1])) && endsName(line, at + unit.size())) return true; return false; } bool matches(const std::string &line) const { return unitMatches(line) && (grep.empty() || lower(line).find(grep) != std::string::npos); } }; struct Result { std::deque<std::string> lines; size_t bytes = 0; std::set<std::string> tags; bool collectTags = false; void add(const Filter &f, const std::string &line) { if (line.empty()) return; if (collectTags) { std::string tag = lineTag(line); if (!tag.empty()) tags.insert(tag); } if (!f.matches(line)) return; lines.push_back(line); bytes += line.size() + 8; while (!lines.empty() && ((long)lines.size() > f.lines || bytes > MaxBytes)) { bytes -= lines.front().size() + 8; lines.pop_front(); } } }; // the complete lines of path from offset on; returns where the next read // starts (after the last newline), or -1 when the file isn't there long long readFrom(const std::string &path, long long offset, const Filter &f, Result &r) { std::ifstream in(path, std::ios::binary); if (!in) return -1; in.seekg(offset); if (!in) return offset; long long pos = offset; std::string line; while (std::getline(in, line)) { if (in.eof()) break; // no newline yet: still being written pos += (long long)line.size() + 1; r.add(f, line); } return pos; } json_object *readJournal(const webctl_request *req) { Filter f; f.unit = unitName(webctl::str(req->args, "unit")); f.grep = lower(webctl::str(req->args, "grep")); f.lines = webctl::integer(req->args, "lines", DefaultLines); if (f.lines < 1) f.lines = DefaultLines; if (f.lines > MaxLines) f.lines = MaxLines; const std::string oldPath = JournalPath + ".1"; struct stat cur{}, old{}; bool haveCur = stat(JournalPath.c_str(), &cur) == 0; bool haveOld = stat(oldPath.c_str(), &old) == 0; // "inode:offset" in the current file std::string cursor = webctl::str(req->args, "cursor"); unsigned long long ino = 0; long long offset = -1; if (!cursor.empty()) { size_t colon = cursor.find(':'); if (colon != std::string::npos) { ino = strtoull(cursor.c_str(), nullptr, 10); offset = strtoll(cursor.c_str() + colon + 1, nullptr, 10); } } Result r; long long end = -1; bool reset = false; if (offset >= 0 && haveCur && ino == (unsigned long long)cur.st_ino && offset <= (long long)cur.st_size) { end = readFrom(JournalPath, offset, f, r); } else if (offset >= 0 && haveOld && ino == (unsigned long long)old.st_ino) { // rotated since: the rest of the old file, then the new one readFrom(oldPath, offset, f, r); end = haveCur ? readFrom(JournalPath, 0, f, r) : -1; } else { reset = !cursor.empty(); r.collectTags = true; if (haveOld) readFrom(oldPath, 0, f, r); end = haveCur ? readFrom(JournalPath, 0, f, r) : -1; } json_object *out = json_object_new_object(); json_object *lines = json_object_new_array(); for (auto &l : r.lines) json_object_array_add(lines, jstr(l)); json_object_object_add(out, "lines", lines); json_object_object_add(out, "cursor", jstr(end >= 0 ? std::to_string((unsigned long long)cur.st_ino) + ":" + std::to_string(end) : "")); json_object_object_add(out, "reset", json_object_new_boolean(reset)); json_object_object_add(out, "exists", json_object_new_boolean(haveCur || haveOld)); json_object_object_add(out, "path", jstr(JournalPath)); if (r.collectTags) { json_object *tags = json_object_new_array(); for (auto &t : r.tags) json_object_array_add(tags, jstr(t)); json_object_object_add(out, "tags", tags); } return out; } json_object *call(const webctl_request *req) { webctl::LanguageScope lang(req->lang); std::string action = req->action; if (action == "read") return readJournal(req); return error(tr("unknown action %1", "unbekannte Aktion %1", {action}), 404); } } // namespace WEBCTL_MODULE({WEBCTL_MODULE_ABI, "journal", "Journal", 31, nullptr, nullptr, call});
www/app.css +11 −0 Changes for www/app.css: 11 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -322,3 +322,14 @@ td.fs-use { min-width: 200px; } .sensor { display: grid; grid-template-columns: minmax(0, 1fr) minmax(80px, 2fr) 4em; align-items: center; gap: 10px; } .sensor-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sensor-temp { text-align: right; font-variant-numeric: tabular-nums; } /* ---- journal ---- */ .toolbar select { width: auto; } label.journal-follow { margin: 0; white-space: nowrap; } .journal { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: .5em .7em; height: calc(100vh - 260px); min-height: 18em; overflow: auto; font-size: .85em; } .journal > div { white-space: pre-wrap; word-break: break-word; padding: 1px 0; } .journal > div.err { color: var(--danger); } .journal > div.warn { color: var(--warn); } .journal .j-time { color: var(--muted); } .journal .j-tag { font-weight: 600; }
www/modules/journal.js 0 → 100644 +145 −0 Changes for www/modules/journal.js: 145 added lines, 0 removed lines. Original line number Diff line number Diff line 'use strict'; // Journal (backend module "journal"): syslite's journal, filtered by unit // and text, live while "Follow" is on. webctl.openJournal(unit) shows it // for one unit (the services page). (() => { const {h, t, call, run} = webctl; webctl.translations({de: { 'Journal': 'Journal', 'All units': 'Alle Units', 'Search …': 'Suchen …', 'Follow': 'Live', 'Last {0} lines': 'Letzte {0} Zeilen', 'No journal at {0} yet.': 'Noch kein Journal unter {0}.', 'No line matches.': 'Keine Zeile passt.', '{0} lines': '{0} Zeilen', 'The journal holds syslite\'s messages and every service\'s output ({0}, the older part in {0}.1).': 'Das Journal enthält die Meldungen von syslite und die Ausgabe aller Dienste ({0}, der ältere Teil in {0}.1).', }}); const lineRe = /^(\S+) ([^\[:\s]+)(?:\[(\d+)\])?: ?(.*)$/; const levels = [100, 500, 2000, 5000]; let root, view, count, timer; let unit = '', grep = '', lines = 500, follow = true; let tags = [], cursor = '', shown = [], exists = true, path = ''; let pendingUnit = null; let busy = false; // "2026-10-04T08:12:33.123+0200" -> "2026-10-04 08:12:33" const time = ts => ts.slice(0, 19).replace('T', ' '); function lineNode(l) { const m = lineRe.exec(l); if (!m) return h('div', {}, l); const text = m[4]; const cls = /\b(error|failed|fatal|panic|segfault|denied)\b/i.test(text) || m[2] === 'coredump' ? 'err' : /\b(warn(ing)?|timeout|timed out)\b/i.test(text) ? 'warn' : null; return h('div', {class: cls, title: m[1]}, h('span', {class: 'j-time'}, time(m[1])), ' ', h('span', {class: 'j-tag'}, m[2] + (m[3] ? '[' + m[3] + ']' : '')), ': ', text); } const atBottom = () => view.scrollHeight - view.scrollTop - view.clientHeight < 30; function append(newLines, replace) { const stick = replace || atBottom(); if (replace) { shown = []; view.replaceChildren(); } shown.push(...newLines); view.append(...newLines.map(lineNode)); // keep at most "lines" lines on the page while (shown.length > lines) { shown.shift(); view.firstChild.remove(); } if (!shown.length) view.replaceChildren(h('p', {class: 'empty'}, exists ? t('No line matches.') : t('No journal at {0} yet.', path))); count.textContent = t('{0} lines', shown.length); if (stick) view.scrollTop = view.scrollHeight; } async function load(full) { if (busy) return; busy = true; try { const args = {unit, grep, lines}; if (!full && cursor) args.cursor = cursor; const r = await run(() => call('journal', 'read', args)); if (!r) return; exists = r.exists; path = r.path || path; if (r.tags) { tags = r.tags; drawUnits(); } cursor = r.cursor || ''; if (full || r.reset || !args.cursor) append(r.lines || [], true); else if ((r.lines || []).length) append(r.lines, false); } finally { busy = false; } } let unitSelect; function drawUnits() { const names = [...new Set([...tags, ...(unit ? [unit] : [])])].sort((a, b) => a.localeCompare(b)); unitSelect.replaceChildren(new Option(t('All units'), ''), ...names.map(n => new Option(n, n))); unitSelect.value = unit; } function setTimer() { clearInterval(timer); timer = follow ? setInterval(() => { if (!document.hidden) load(false); }, 2000) : null; } function draw() { unitSelect = h('select', {onchange: e => { unit = e.target.value; load(true); }}); drawUnits(); let typing; const search = h('input', {type: 'search', placeholder: t('Search …'), value: grep, oninput: e => { clearTimeout(typing); typing = setTimeout(() => { grep = e.target.value.trim(); load(true); }, 400); }}); const size = h('select', {onchange: e => { lines = +e.target.value; load(true); }}, levels.map(n => new Option(t('Last {0} lines', n), n))); size.value = lines; const live = h('label', {class: 'check journal-follow'}, h('input', {type: 'checkbox', checked: follow, onchange: e => { follow = e.target.checked; setTimer(); if (follow) load(false); }}), ' ', t('Follow')); view = h('div', {class: 'journal mono'}); count = h('span', {class: 'muted'}); root.replaceChildren( h('section', {class: 'panel'}, h('div', {class: 'toolbar'}, h('h2', {}, t('Journal')), unitSelect, search, size, live, h('button', {onclick: () => load(true)}, t('Refresh'))), view, h('p', {class: 'hint'}, count, ' · ', t('The journal holds syslite\'s messages and every service\'s output ({0}, the older part in {0}.1).', path || '/var/log/syslite/journal.log')))); } webctl.openJournal = name => { pendingUnit = name; location.hash = '#journal'; }; const page = { render(container) { root = container; if (pendingUnit !== null) { unit = pendingUnit; grep = ''; pendingUnit = null; } cursor = ''; draw(); load(true); setTimer(); return () => { clearInterval(timer); timer = null; }; }, }; webctl.register('journal', page); })();
www/modules/services.js +5 −0 Changes for www/modules/services.js: 5 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -20,6 +20,7 @@ '{0} restarted.': '{0} neu gestartet.', 'Disable': 'Autostart aus', 'Enable': 'Autostart an', '{0} enabled.': 'Autostart für {0} an.', '{0} disabled.': 'Autostart für {0} aus.', 'No service matches the filter.': 'Kein Dienst passt zum Filter.', 'No services.': 'Keine Dienste.', 'Journal': 'Journal', 'The journal lines of {0}': 'Die Journal-Zeilen von {0}', }}); let root; Loading Loading @@ -88,6 +89,10 @@ : u.enabled === 'disabled' ? h('button', {class: 'small', onclick: () => action(u, 'enable', t('{0} enabled.', u.name))}, t('Enable')) : null, webctl.openJournal ? h('button', {class: 'small', title: t('The journal lines of {0}', u.name), onclick: () => webctl.openJournal(u.name)}, t('Journal')) : null, ]}, ], shown, units.length ? t('No service matches the filter.') : t('No services.')))); if (filter) { Loading