Commit 00ec663b authored by jan.koester's avatar jan.koester
Browse files

test

parent 6131c316
Loading
Loading
Loading
Loading
+5 −1
Changes for CMakeLists.txt: 5 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -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
@@ -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
@@ -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)
+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});
+11 −0
Changes for www/app.css: 11 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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);
})();
+5 −0
Changes for www/modules/services.js: 5 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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;
@@ -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) {