Log power events, QEMU stderr and firmware serial; show them in a fixed pane

Three sources into one buffer: power events from the supervisor, QEMU's stderr
(which run.sh and the tests used to discard), and firmware serial, which the
machine model tags SERIAL. default_launcher now captures stderr rather than
sending it to DEVNULL, which is what made the last two reachable.

The pane is a fixed-height scroll box as asked: 180px with overflow-y:auto, so it
never grows with content -- older lines move up out of view and you scroll back to
read them.

Two details that make that usable rather than annoying:

- Autoscroll only sticks when you are already at the bottom. Otherwise a new line
  arriving would yank the view away from whatever you had scrolled up to read.
- MAX_LOG_LINES caps the <pre> as well. The box is fixed-height either way, but an
  unbounded DOM node would still grow memory across a long session.

Verified on the live server: power on produced power/qemu/serial lines including
"UV-K5 Firmware, EGZUMER-F4HWN+NR7Y c91cec95", each Reset logs the event and the
banner reappearing, and since= never resent a line. A capacity-500 buffer fed 2000
lines keeps exactly 500 and does not replay evicted entries to a stale cursor.
This commit is contained in:
mckero committed 2026-08-28 10:06:18 +01:00
1 parent 7db89231b6
commit c164917632
4 files changed
+187 -11

No files matched your search

+48
View File
@@ -506,5 +506,53 @@ class TestOptimisticSend(unittest.TestCase):
self.assertLess(webui.TAP_MS, 400)
class TestLogsEndpoint(unittest.TestCase):
def setUp(self):
self.client, self.http = make_app()
def test_logs_endpoint_returns_entries(self):
resp = self.http.get("/api/logs")
self.assertEqual(resp.status_code, 200)
body = resp.get_json()
self.assertIn("entries", body)
self.assertIn("cursor", body)
def test_logs_accept_a_since_cursor(self):
resp = self.http.get("/api/logs?since=0")
self.assertEqual(resp.status_code, 200)
def test_logs_exposes_the_buffer(self):
_, sup, http = make_supervised()
app_log = http.application.config.get("LOG")
self.assertIsNotNone(app_log)
class TestLogPane(unittest.TestCase):
def setUp(self):
_, http = make_app()
self.body = http.get("/").get_data(as_text=True)
def test_page_has_a_log_pane(self):
self.assertIn('id="logpane"', self.body)
self.assertIn("/api/logs", self.body)
def test_pane_has_a_fixed_height(self):
"""The container must not grow with content."""
self.assertIn("#logtext", self.body)
self.assertIn("height:", self.body)
def test_pane_scrolls_rather_than_expanding(self):
self.assertIn("overflow-y:auto", self.body)
def test_pane_is_capped_in_line_count(self):
"""Even a scrollable pane needs a cap, or the DOM grows forever."""
self.assertIn("MAX_LOG_LINES", self.body)
def test_autoscroll_yields_to_manual_scrolling(self):
"""Scrolling up to read must not be yanked back by the next line."""
self.assertIn("scrollHeight", self.body)
self.assertIn("clientHeight", self.body)
if __name__ == "__main__":
unittest.main()