Skip to content

feat: readable server logs, a page to read them, and an agent shell launcher - #17

Merged
aCard0s0 merged 4 commits into
mainfrom
feat/server-logs-and-logging-cleanup
Aug 20, 2026
Merged

feat: readable server logs, a page to read them, and an agent shell launcher#17
aCard0s0 merged 4 commits into
mainfrom
feat/server-logs-and-logging-cleanup

Conversation

@aCard0s0

@aCard0s0 aCard0s0 commented Aug 20, 2026

Copy link
Copy Markdown
Owner

Two features. The logging work is the bulk of it; the agent-shell launcher was already in the tree and rides along in its own commit.

Why

The running container emitted 612 log lines in 48 minutes, and 572 of them (93%) were the same two messages. ContainerInventory reports why it hides a container from the fleet view — but that describes a standing property of a foreign container, and the fleet view polls every 10s, so it re-fired forever and buried the upgrade and terminal failures those warnings exist to surface.

Levels were inverted too — 33 warn, 4 error, 2 info. Almost no INFO meant no record of what the application actually did.

What changed

The flood. Exclusions are reported once, keyed hostId/name, pruned against the live listing so a container that goes away and returns is reported again. Verified: 12 polls → each exclusion logged once, not 24 times.

Missing levels. A deploy is a multi-minute transaction over a volume, bootstrap containers and a pull — it logged nothing on success or rollback. Container removal drops the data volume holding profiles, souls, skills and credentials and logged nothing at all (now WARN, before the fact, naming the volume). The MCP Compose lifecycle runs on its own executor, so its failures reach no request thread and no exception advice — that one is now ERROR with the cause attached, because nothing else records it.

Format. Dropped the PID, application name and --- separator (all constant in a one-process container) and traded the 40-char logger for the simple class name. Spring's banner, startup-info and the Tomcat/Hikari/servlet-context chatter are off; StartupSummary replaces them with one block naming the port, the Docker endpoint and whether it answered, the database, the stack dir and the key mode.

2026-08-20 03:02:29.698 INFO  StartupSummary  : Mission Control 0.1.0 listening on port 8080
2026-08-20 03:02:29.833 INFO  StartupSummary  :   docker    unix:///var/run/docker.sock — Docker 29.6.1 (api 1.55, 34ms)
2026-08-20 03:02:29.833 INFO  StartupSummary  :   secrets   built-in dev key — template secrets are NOT protected

Startup is 11 lines, each carrying information.

A 500 that should have been a 404. The SPA resolver returned classpath:/static/index.html without checking it exists. A resolver must return null when it cannot resolve — ResourceHttpRequestHandler calls lastModified() on whatever comes back, so a build without the Angular bundle answered every unknown path with a 500 and a 45-line trace at ERROR. The existing test asserted only getFilename(), so it passed against a resource that was not there. Now covered both ways.

One log renderer. The tail rows were duplicated in four places, which is how three of them ended up with no date and why the level filter existed on only one — and there without info. LogView renders all of them now: date stamp, full level set with per-level counts, and a height that is remembered per placement (buttons + drag grip, reusing PanelHeight).

New Server Logs page at /server-logs, over GET /api/server/logs. Served from an in-memory ring rather than a second on-disk copy of what stdout already owns — what falls out of the ring is still in docker logs.

Testing

  • Backend 1146 tests, coverage gate met
  • Frontend 1110 tests across 67 files, lint clean (no new warnings)
  • Verified live against a real daemon: the flood fix, the startup block, the 404 fix, and both new endpoints

Notes for review

  • ApiContractTest caught /api/server/info returning an ad-hoc Map with no record to pin — fixed with a real ServerInfoDto. Good test.
  • PanelHeight.drag gained an edge param: the terminal is docked at the bottom and grows upward, an inline log card grows downward.
  • Not visually verified — the browser tooling was unavailable, so page rendering is covered by specs rather than a screenshot.
  • The running container needs a rebuild to pick any of this up.

🤖 Generated with Claude Code


Follow-up: the duplicated prompt on resize is upstream, and already fixed

Investigated with the dashboard taken out of the path entirely — a bare docker exec -it in a real PTY, resized with the same TIOCSWINSZ ioctl any native terminal sends. The double-paint reproduces identically there, so it is not the web terminal:

resize native PTY web terminal
100×20 1 render 2
100×21 2 renders 2
100×21 again (identical) 0 0

It is NousResearch/hermes-agent issue #26316 ("input text can be replayed after resize, causing duplicated prompt text"), fixed by #45586 fix(cli): preserve resize renderer state, whose docstring names the symptom exactly — "under- or over-allocating the TextArea height leaves stale prompt/input cells visible at the bottom of the terminal."

That PR merged 2026-06-13. The running Hermes container is revision 88dbf95 from 2026-06-12 — one day short of the fix. No issue filed: it would be a duplicate. The container wants an upgrade.

What stays in this PR is still worth having on its own terms: a drag now costs one reflow and one resize instead of roughly thirty. An identical resize was separately measured to cost zero renders, so the dedupe is free insurance.

aCard0s0 and others added 4 commits August 20, 2026 10:51
Every page that offered a terminal assembled the request itself, so the
agent key and the session command could drift apart. They have to travel
together: the key is what lets a repeat click focus the tab a profile
already has instead of stacking a second shell, and the command is what
makes that tab the profile's session rather than the container's prompt.

TerminalRequestStore.openAgentShell owns both. The `>_` glyph becomes
mc-terminal-icon so the places offering a terminal cannot drift into two
different icons.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The running container emitted 612 log lines in 48 minutes and 572 of them
(93%) were two messages. ContainerInventory reports why it hides a
container from the fleet, and the fleet view polls every 10 seconds, so a
standing property of a foreign container was re-reported forever — burying
the upgrade and terminal failures those warnings exist to surface. They are
now reported once per host/container and re-armed only when the container
goes away and comes back.

Levels were inverted: 33 warn, 4 error, 2 info. Almost no INFO meant no
record of what the application did. A deploy is a multi-minute transaction
over a volume, bootstrap containers and a pull, and it logged nothing on
success or rollback; container removal drops the data volume holding
profiles, souls, skills and credentials and logged nothing at all; the MCP
Compose lifecycle runs on its own executor, so its failures reached no
request thread and no exception advice. That one is now ERROR with the
cause attached, because nothing else records it.

The console pattern drops the PID, the application name and the `---`
separator, all constant in a one-process container, and trades the 40-char
logger for the simple class name. Spring's banner, its startup-info lines
and the Tomcat/Hikari/servlet-context chatter are off; StartupSummary
replaces them with one block naming the port, the Docker endpoint and
whether it answered, the database, the stack dir and the key mode. Startup
is 11 lines, each carrying information.

Separately: the SPA resolver returned classpath:/static/index.html without
checking it exists. A resolver must return null when it cannot resolve —
ResourceHttpRequestHandler calls lastModified() on whatever comes back, so
a build without the Angular bundle answered every unknown path with a 500
and a 45-line trace instead of a 404. The existing test asserted only
getFilename(), so it passed against a resource that was not there.

The tail rows were duplicated in four places, which is how three of them
ended up without a date and why the level filter existed on only one — and
there without `info`. One LogView now renders all of them, with a date, the
full level set, per-level counts and a remembered height.

New Server Logs page over GET /api/server/logs, served from an in-memory
ring rather than a second on-disk copy of what stdout already owns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Resize frames are SIGWINCH at the far end, and the shell answers each one
by redrawing its prompt. The ResizeObserver fired per pointer frame during
a drag and fitNow reported unconditionally, so one drag sent ~30 identical
resize frames and the prompt accumulated across the input line as
`qa > qa > qa >`.

Two guards: the observer's bursts collapse into one fit per animation
frame, and a fit only reports when the character grid actually changed —
most pixel steps of a height drag land on the same rows, and telling the
pty about a size it already has costs a redraw for nothing. connect()
clears the remembered grid, because a new socket has been told nothing.

Measured by the regression test: 31 frames before, 1 after.

Also moves the resize handle to the panel's own top edge, spanning the
full width, so the handle is the boundary being moved and grabbing it
needs no aim. LogView loses its +/- stepper for the same full-width grip
on its bottom edge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fitting reflows xterm's buffer and the far end repaints on the SIGWINCH
that follows, so doing it per pointer frame meant the app repainted
against a buffer that had moved underneath it. The panel now suspends
fits for the length of the drag and fits once when it settles, so a drag
costs one reflow and one resize instead of roughly thirty.

This does not fully fix the duplicated prompt, and the remainder is not
ours. Driving /ws/terminal directly against a real container shows bash
repainting with `\r\x1b[K\r` — it erases the line first, so any number of
resizes overwrite in place. The Hermes REPL (prompt_toolkit) emits no
erase-line at all: it moves the cursor back by the row count it believes
it occupies and erases with `\x1b[J`. When rows change that count is
stale, the cursor lands too low, the erase clears nothing, and the redraw
is drawn below the old copy. One resize frame produces two copies of the
prompt — measured, not inferred.

So the client's remaining job is to send as few resizes as possible, which
is what this does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@aCard0s0
aCard0s0 merged commit 9c27780 into main Aug 20, 2026
4 checks passed
@aCard0s0
aCard0s0 deleted the feat/server-logs-and-logging-cleanup branch August 20, 2026 10:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant