left4me/docs/superpowers/plans/2026-05-20-server-console-log-autoscroll.md
mwiegand 39963db2e3
docs(server-detail): implementation plan for console/log autoscroll
5 tasks, TDD style:
 1. server-side slice console_history_overview = console_history[-20:]
 2. generalise scrollConsolesToBottom -> scrollAutoscrollTargets (ancestor walk)
 3. tabs.js pins autoscroll targets on tab activation
 4. console-modal pin on modal:opened event
 5. e2e: pin-to-bottom on tab + on command submit

Bug confirmed in live browser before plan was written: after switching
to the Console tab on /servers/1, transcript scrollTop=0 with
bottomDistance=1873px (top-of-history visible, not newest).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 22:41:42 +02:00

540 lines
21 KiB
Markdown

# Server detail — console + log autoscroll Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make the Log and Console transcripts on `/servers/<id>` stay pinned to their bottom on initial load, tab activation, and command append; cap the inline Console to the 20 newest entries while the modal keeps all 50.
**Architecture:** Single opt-in `data-autoscroll` attribute on any scroll-pinned region. One helper (`scrollAutoscrollTargets`) handles root, descendants, *and* ancestors so HTMX `beforeend` swaps that fire `htmx:load` on the inserted child still find and scroll the parent transcript. `tabs.js` calls the helper after activating a tab. `modals.js` already dispatches `modal:opened` on `showModal()`, so the Console modal hooks that event to scroll on first open.
**Tech Stack:** Flask + Jinja2 templates, vanilla JS, HTMX, Playwright for e2e, Claude-in-Chrome for live-browser verification.
**Reference spec:** `docs/superpowers/specs/2026-05-20-server-console-log-autoscroll-design.md`
---
## File map
| Path | Action | Responsibility |
|------|--------|----------------|
| `l4d2web/l4d2web/routes/page_routes.py` | modify (~L318-345) | Add `console_history_overview = console_history[-20:]` to the render context |
| `l4d2web/l4d2web/templates/server_detail.html` | modify (L60-73, L101, L111-117, L159) | Inline loop uses `console_history_overview`; add `data-autoscroll` to both `<pre class="log-stream">`; Console modal transcript wires `modal:opened` → scroll |
| `l4d2web/l4d2web/static/js/console-history.js` | modify (L159-179) | Rename and generalise `scrollConsolesToBottom``scrollAutoscrollTargets` with ancestor walk; expose on `window` |
| `l4d2web/l4d2web/static/js/tabs.js` | modify (~L9-19) | After `activateTab` toggles `hidden`, scroll any `[data-autoscroll]` in the newly-active pane |
| `l4d2web/tests/test_servers.py` | extend | Server-side: assert inline pane caps at 20 and modal keeps 50 |
| `l4d2web/tests/e2e/test_server_detail.py` | extend | E2E: Console tab pinned to bottom on activation; Console pane pinned to bottom after command submit |
---
### Task 1: Server-side slice for inline Console history
**Files:**
- Modify: `l4d2web/l4d2web/routes/page_routes.py:318-347`
- Test: `l4d2web/tests/test_servers.py`
- [ ] **Step 1: Write the failing test**
`test_servers.py` uses the `user_client_with_blueprints` fixture (returns `(client, data)` where `data` carries `user_id` and `blueprint_id`) plus direct DB writes via `session_scope()`. Mirror that pattern:
Append to `l4d2web/tests/test_servers.py`:
```python
def test_server_detail_inline_console_caps_at_20_modal_keeps_all(user_client_with_blueprints) -> None:
"""When > 20 CommandHistory rows exist for the server, the inline
Console transcript renders only the 20 newest (chronological order),
while the modal transcript renders the full set (capped at the
route-level 50)."""
import re
from datetime import UTC, datetime, timedelta
from l4d2web.models import CommandHistory, Server
client, data = user_client_with_blueprints
with session_scope() as db:
server = Server(
user_id=data["user_id"],
blueprint_id=data["blueprint_id"],
name="console-cap",
port=27123,
rcon_password="x",
)
db.add(server)
db.flush()
sid = server.id
for i in range(35):
db.add(CommandHistory(
user_id=data["user_id"],
server_id=sid,
command=f"cmd_{i:02d}",
reply=f"reply {i}",
is_error=False,
created_at=datetime.now(UTC) - timedelta(minutes=40 - i),
))
resp = client.get(f"/servers/{sid}")
assert resp.status_code == 200
body = resp.get_data(as_text=True)
inline_match = re.search(
rf'<div id="console-transcript-inline-{sid}"[^>]*>(.*?)</div>\s*<form',
body,
re.DOTALL,
)
assert inline_match, "inline transcript container not found"
inline_lines = inline_match.group(1).count('class="console-line')
assert inline_lines == 20, f"inline expected 20, got {inline_lines}"
modal_match = re.search(
rf'<div id="console-transcript-modal-{sid}"[^>]*>(.*?)</div>\s*<form',
body,
re.DOTALL,
)
assert modal_match, "modal transcript container not found"
modal_lines = modal_match.group(1).count('class="console-line')
assert modal_lines == 35, f"modal expected 35, got {modal_lines}"
```
- [ ] **Step 2: Run the test to verify it fails**
```bash
cd l4d2web && pytest tests/test_servers.py::test_server_detail_inline_console_caps_at_20_modal_keeps_all -v
```
Expected: FAIL (inline returns 35, not 20)
- [ ] **Step 3: Modify the route**
In `l4d2web/l4d2web/routes/page_routes.py`, locate `server_detail` (~L306) and the `console_history = list(reversed(...))` block (~L318-330). After it, add:
```python
console_history_overview = console_history[-20:]
```
Then in the `return render_template("server_detail.html", …)` call (~L335-347), add the new kwarg:
```python
return render_template(
"server_detail.html",
server=server,
blueprint=blueprint,
connect_host=connect_host,
file_tree_root_entries=file_tree_root_entries,
file_tree_truncated=file_tree_truncated_count > 0
if file_tree_root_entries is not None
else False,
file_tree_truncated_count=file_tree_truncated_count,
console_history=console_history,
console_history_overview=console_history_overview,
**ctx,
)
```
- [ ] **Step 4: Update the template to use the new variable**
In `l4d2web/l4d2web/templates/server_detail.html` at the **inline** Console pane (~L65-71, inside `<div role="tabpanel" data-tab="console">`):
Change:
```jinja
{% for h in console_history %}
```
to:
```jinja
{% for h in console_history_overview %}
```
Leave the **modal** Console transcript (~L112-116) iterating over `console_history` unchanged.
- [ ] **Step 5: Run the test to verify it passes**
```bash
cd l4d2web && pytest tests/test_servers.py::test_server_detail_inline_console_caps_at_20_modal_keeps_all -v
```
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add l4d2web/l4d2web/routes/page_routes.py \
l4d2web/l4d2web/templates/server_detail.html \
l4d2web/tests/test_servers.py
git commit -m "feat(server-detail): cap inline console to 20 newest; modal keeps 50"
```
---
### Task 2: Generalise `scrollConsolesToBottom` to walk ancestors
**Files:**
- Modify: `l4d2web/l4d2web/static/js/console-history.js:159-179`
The current helper only matches `root` and its descendants. When `htmx:load` fires after `hx-swap="beforeend"`, `event.detail.elt` is the newly inserted child line — neither it nor its descendants match `[data-autoscroll]`, so the transcript never scrolls. Adding an ancestor walk fixes this case without affecting the existing one.
- [ ] **Step 1: Rewrite the helper**
Replace lines 159-179 of `l4d2web/l4d2web/static/js/console-history.js` with:
```js
function scrollAutoscrollTargets(root) {
if (!root) return;
const targets = [];
// Case 1: root itself opts in.
if (root.matches && root.matches("[data-autoscroll]")) {
targets.push(root);
}
// Case 2: descendants opt in.
if (root.querySelectorAll) {
root.querySelectorAll("[data-autoscroll]").forEach((el) => targets.push(el));
}
// Case 3: neither — walk up. Handles htmx:load firing with the inserted
// child as the root after hx-swap="beforeend" on a console line.
if (targets.length === 0 && root.closest) {
const up = root.closest("[data-autoscroll]");
if (up) targets.push(up);
}
targets.forEach((el) => {
el.scrollTop = el.scrollHeight;
});
}
// Expose for tabs.js (and any future cross-module consumer). The script
// is `defer`red in base.html, so it runs before DOMContentLoaded and the
// global is defined by the time tabs.js's DCL-deferred initStrips runs.
window.scrollAutoscrollTargets = scrollAutoscrollTargets;
document.addEventListener("DOMContentLoaded", () => {
scrollAutoscrollTargets(document);
bindAllConsoleForms(document);
});
document.addEventListener("htmx:load", (event) => {
scrollAutoscrollTargets(event.detail.elt);
bindAllConsoleForms(event.detail.elt);
});
```
That is: rename the function, add the third (ancestor-walk) case, expose on `window`, and update the two listeners to call the new name. Behavior preserved for the existing two cases.
- [ ] **Step 2: Smoke-verify in a browser**
Start the dev server (or use a running one). Open `/servers/<id>` and run in the devtools console:
```js
typeof window.scrollAutoscrollTargets
```
Expected: `"function"`.
Then with a Console tab that already has > clientHeight of content, click into it and verify (manual eye check) that the transcript is no longer at the top. (It still won't be — tabs.js doesn't call the helper yet; that's Task 3. The smoke-check here is only that the helper is defined and reachable.)
- [ ] **Step 3: Commit**
```bash
git add l4d2web/l4d2web/static/js/console-history.js
git commit -m "feat(console): scrollAutoscrollTargets walks ancestors; expose on window"
```
---
### Task 3: `tabs.js` — scroll on tab activation
**Files:**
- Modify: `l4d2web/l4d2web/static/js/tabs.js:9-19`
- [ ] **Step 1: Edit `activateTab`**
In `l4d2web/l4d2web/static/js/tabs.js`, at the end of the `activateTab(strip, name)` function, after `strip.dataset.activeTab = name;`, append:
```js
// Pin any scroll-locked regions (log streams, console transcripts) in
// the newly-visible pane to the bottom. While the pane was hidden,
// their scrollHeight was 0 so previous appends couldn't anchor.
const activePane = strip.querySelector('[role="tabpanel"]:not([hidden])');
if (activePane && window.scrollAutoscrollTargets) {
window.scrollAutoscrollTargets(activePane);
}
```
The full block now looks like:
```js
function activateTab(strip, name) {
strip.querySelectorAll('[role="tab"]').forEach((t) => {
const on = t.dataset.tab === name;
t.setAttribute("aria-selected", on ? "true" : "false");
t.tabIndex = on ? 0 : -1;
});
strip.querySelectorAll('[role="tabpanel"]').forEach((p) => {
p.hidden = p.dataset.tab !== name;
});
strip.dataset.activeTab = name;
const activePane = strip.querySelector('[role="tabpanel"]:not([hidden])');
if (activePane && window.scrollAutoscrollTargets) {
window.scrollAutoscrollTargets(activePane);
}
}
```
- [ ] **Step 2: Add `data-autoscroll` to the log-stream elements**
In `l4d2web/l4d2web/templates/server_detail.html`:
- Inline log (~L61):
```jinja
<pre class="log-stream" data-autoscroll data-sse-url="/servers/{{ server.id }}/logs/stream"></pre>
```
- Modal log (~L101):
```jinja
<pre class="log-stream tall" data-autoscroll data-sse-url="/servers/{{ server.id }}/logs/stream"></pre>
```
- Modal job-log (~L159):
```jinja
<pre class="log-stream tall" data-autoscroll data-sse-url="/jobs/{{ latest_job.id }}/stream"></pre>
```
The Console transcripts already carry `data-autoscroll` and don't need editing.
- [ ] **Step 3: Live-browser verification**
This is the moment to confirm the user-visible bug is fixed. Start (or reuse) the dev server and seed > 20 console rows for `demo-server` (the dev seed has only 9; bring that to 30+):
```bash
sqlite3 .tmp/dev-server/l4d2web.db "
WITH RECURSIVE seq(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM seq WHERE n<30)
INSERT INTO command_history (user_id, server_id, command, reply, is_error, created_at)
SELECT 1, 1, 'verify_cmd_' || printf('%02d', n),
'reply ' || n, 0, datetime('now', '-'||(35-n)||' minutes') FROM seq;"
```
Then in a browser, log into `/login` as `dev` / `devdevdev`, open `/servers/1`, click the **Console** tab, and run in devtools:
```js
(() => {
const t = document.querySelector('[id^="console-transcript-inline-"]');
return { scrollTop: t.scrollTop, scrollHeight: t.scrollHeight, clientHeight: t.clientHeight,
bottomDistance: t.scrollHeight - t.scrollTop - t.clientHeight,
inline_lines: t.querySelectorAll('.console-line').length };
})();
```
Expected: `bottomDistance < 2` (pinned to bottom), `inline_lines == 20`.
Same exercise on the **Log** tab: switch to Console, then back to Log; the SSE-streamed log should be pinned to bottom. If the log stream has no content yet (server isn't running and never has been), this assertion vacuously passes (`scrollHeight === clientHeight`).
- [ ] **Step 4: Commit**
```bash
git add l4d2web/l4d2web/static/js/tabs.js \
l4d2web/l4d2web/templates/server_detail.html
git commit -m "feat(server-detail): pin transcripts/logs to bottom on tab activation"
```
---
### Task 4: Pin Console-modal transcript on `modal:opened`
**Files:**
- Modify: `l4d2web/l4d2web/templates/server_detail.html:105-120` (Console modal)
When the Console modal opens via `data-inline-modal-open="console-modal"`, the dialog goes from `display:none` to displayed. The transcript inside it has `scrollHeight=0` while hidden, so any earlier autoscroll attempt did nothing. `modals.js:35` dispatches a `modal:opened` CustomEvent on `dialog.showModal()`; we listen for it on the dialog and re-pin.
- [ ] **Step 1: Add the inline listener via `hx-on` (no JS file changes)**
In `l4d2web/l4d2web/templates/server_detail.html`, change the Console modal opening tag (~L105):
```jinja
<dialog id="console-modal" class="modal" aria-labelledby="console-modal-title"
onmodal-opened="if(window.scrollAutoscrollTargets){window.scrollAutoscrollTargets(this)}">
```
The non-standard `onmodal-opened` attribute only works through an `addEventListener` registration — `dialog.dispatchEvent` doesn't invoke `onevent` handlers for custom events. So instead add a small DOMContentLoaded hook inside the template (one-off, not worth a new JS file):
Replace the opening `<dialog id="console-modal" …>` line with:
```jinja
<dialog id="console-modal" class="modal" aria-labelledby="console-modal-title">
```
…and **immediately before `{% endblock %}`** (end of file), add:
```jinja
<script>
// Pin the Console modal transcript to its bottom each time the modal
// opens. While the <dialog> is closed, its descendants have scrollHeight=0,
// so neither the page-load autoscroll nor htmx:load can anchor them.
// The 'modal:opened' CustomEvent is dispatched by modals.js on
// dialog.showModal().
(() => {
const dlg = document.getElementById("console-modal");
if (!dlg) return;
dlg.addEventListener("modal:opened", () => {
if (window.scrollAutoscrollTargets) {
window.scrollAutoscrollTargets(dlg);
}
});
})();
</script>
```
- [ ] **Step 2: Live-browser verification**
Reload `/servers/1` (after seeding from Task 3 Step 3). Click the ⛶ expand button on the Console tab to open `#console-modal`. In devtools:
```js
(() => {
const t = document.querySelector('[id^="console-transcript-modal-"]');
return { scrollTop: t.scrollTop, scrollHeight: t.scrollHeight,
clientHeight: t.clientHeight,
bottomDistance: t.scrollHeight - t.scrollTop - t.clientHeight };
})();
```
Expected: `bottomDistance < 2`.
- [ ] **Step 3: Commit**
```bash
git add l4d2web/l4d2web/templates/server_detail.html
git commit -m "feat(server-detail): pin Console-modal transcript on modal:opened"
```
---
### Task 5: e2e — Console tab pinned on activation; pinned after submit
**Files:**
- Modify: `l4d2web/tests/e2e/test_server_detail.py` (append)
- Possibly: `l4d2web/tests/e2e/conftest.py` (add seeded-history fixture if helpful)
The dev server seed has too few rows to trigger overflow; the e2e fixture seeds its own. Reusing the `server_with_files` fixture is fine — it already builds a Server; we just need to insert `CommandHistory` rows before navigating.
- [ ] **Step 1: Add a fixture that seeds console history**
Append to `l4d2web/tests/e2e/conftest.py`:
```python
@pytest.fixture(scope="function")
def server_with_console_history(server_with_files):
"""server_with_files + 30 seeded CommandHistory rows for that server,
so the inline Console transcript exceeds its visible height and the
autoscroll behaviour is observable."""
from datetime import UTC, datetime, timedelta
from l4d2web.models import CommandHistory
sid = server_with_files["server_id"]
uid = server_with_files["user_id"]
with session_scope() as session:
for i in range(30):
session.add(CommandHistory(
user_id=uid,
server_id=sid,
command=f"seed_{i:02d}",
reply=f"reply {i}",
is_error=False,
created_at=datetime.now(UTC) - timedelta(minutes=35 - i),
))
return server_with_files
```
- [ ] **Step 2: Write the failing e2e tests**
Append to `l4d2web/tests/e2e/test_server_detail.py`:
```python
def test_console_tab_pinned_to_bottom_on_activation(page: Page, server_with_console_history) -> None:
"""Clicking the Console tab leaves the transcript scrolled to its
bottom — the newest seeded command must be visible, not the oldest."""
base = server_with_console_history["base_url"]
sid = server_with_console_history["server_id"]
login(page, base)
page.goto(f"{base}/servers/{sid}")
strip = page.locator("[data-tab-strip]")
strip.locator('[role="tab"][data-tab="console"]').click()
transcript = page.locator(f"#console-transcript-inline-{sid}")
expect(transcript).to_be_visible()
# Pinned to bottom: |scrollHeight - scrollTop - clientHeight| < 2
bottom_distance = transcript.evaluate(
"(el) => el.scrollHeight - el.scrollTop - el.clientHeight"
)
assert abs(bottom_distance) < 2, f"transcript not pinned to bottom: {bottom_distance}px"
# Inline pane caps at 20 lines.
line_count = transcript.locator(".console-line").count()
assert line_count == 20, f"inline expected 20 lines, got {line_count}"
def test_console_pane_pinned_after_command_submit(page: Page, server_with_console_history) -> None:
"""After submitting a command, the transcript scrolls so the new line
is visible at the bottom.
The dev server has no live RCON, but the POST still records a
CommandHistory row and HTMX appends a console-line to the transcript;
that's enough to exercise the autoscroll path.
"""
base = server_with_console_history["base_url"]
sid = server_with_console_history["server_id"]
login(page, base)
page.goto(f"{base}/servers/{sid}")
strip = page.locator("[data-tab-strip]")
strip.locator('[role="tab"][data-tab="console"]').click()
transcript = page.locator(f"#console-transcript-inline-{sid}")
pane = page.locator('[role="tabpanel"][data-tab="console"]')
cmd_input = pane.locator('input[name="command"]')
cmd_input.fill("verify_submit")
cmd_input.press("Enter")
# Wait for the new line to appear in the DOM.
expect(transcript.locator(".console-line", has_text="verify_submit")).to_be_visible()
bottom_distance = transcript.evaluate(
"(el) => el.scrollHeight - el.scrollTop - el.clientHeight"
)
assert abs(bottom_distance) < 2, f"transcript not pinned after submit: {bottom_distance}px"
```
- [ ] **Step 3: Run e2e**
```bash
cd l4d2web && pytest tests/e2e/test_server_detail.py -m e2e -v
```
If the dev-machine doesn't have Chromium installed: `playwright install chromium` first.
Expected: PASS for both new tests; existing tests still pass.
- [ ] **Step 4: Commit**
```bash
git add l4d2web/tests/e2e/conftest.py l4d2web/tests/e2e/test_server_detail.py
git commit -m "test(e2e): console transcript pinned to bottom on tab + submit"
```
---
## Final verification (live browser)
After all tasks merge:
1. Reset / re-seed the dev DB if you used the `verify_cmd_*` seed above:
```bash
sqlite3 .tmp/dev-server/l4d2web.db "DELETE FROM command_history WHERE command LIKE 'verify_cmd_%' OR command LIKE 'seed_cmd_%';"
```
2. Restart `scripts/dev-server.py`.
3. In a browser, log in as `dev` / `devdevdev`, open `/servers/1`.
4. Click **Console** — transcript shows ≤20 most-recent commands, scrolled to bottom.
5. Submit any command (e.g. `status`) — the response error appends as a new line and the transcript scrolls to keep it visible.
6. Click ⛶ to open the Console modal — modal transcript shows all 50 most-recent (or however many exist), scrolled to bottom.
7. Switch to **Log**, then between Console and Log a few times — each transition leaves the active tab's transcript pinned to its bottom.
## Spec coverage check
- ✅ Server-side slice to 20 newest inline; modal keeps 50 — **Task 1**
- ✅ Both log-stream `<pre>`s get `data-autoscroll`**Task 3 Step 2**
- ✅ Helper walks ancestors to handle htmx:load on appended child — **Task 2**
- ✅ Helper exposed on `window`**Task 2**
-`tabs.js` pins on activation — **Task 3 Step 1**
- ✅ Console-modal pin on `modal:opened`**Task 4**
- ✅ Unit/template coverage for cap — **Task 1**
- ✅ e2e coverage for tab activation + submit pin — **Task 5**