Compare commits

..

No commits in common. "0307416b92c2de5bb985442c0439f4463cf99be0" and "058acb9c5cde2f9d9e10558d7a51aac16113d15a" have entirely different histories.

9 changed files with 13 additions and 915 deletions

View file

@ -1,540 +0,0 @@
# 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**

View file

@ -1,179 +0,0 @@
# Server detail — console + log autoscroll and inline-history cap
**Date:** 2026-05-20
**Status:** Design approved (pending user review of this spec)
**Touches:** `server_detail` page only (inline tab strip + modals)
## Problem
Three usability issues on the server detail page's inspection strip:
1. **Console transcript doesn't land at the bottom when the Console tab is first opened.** The pane is `hidden` at page load, so the existing `scrollConsolesToBottom` call on `DOMContentLoaded` runs against a `display:none` element and effectively sets `scrollTop` to 0. When the user clicks the Console tab they see the *top* of 50 entries.
2. **Console transcript shows too many past commands inline.** The route loads 50 `CommandHistory` rows; both inline pane and modal render all of them. 50 is right for the modal but heavy for an 18rem inline pane.
3. **Submitting a command doesn't scroll the transcript to the new line.** `htmx:load` fires with `event.detail.elt` set to the newly-inserted `<div class="console-line">`. The current `scrollConsolesToBottom` looks at that element and its descendants — neither matches `[data-autoscroll]`, so nothing is scrolled. (The transcript container is an *ancestor* of the inserted line.)
The Log tab has the same latent bug as #1: if the user switches away from Log and back, the `<pre class="log-stream">` may not be at the bottom even though `sse.js` scrolls on each append, because per-append scroll while the pane is `display:none` is a no-op in practice.
## Goal
When the user opens, switches to, or appends to any auto-pinned scroll region (Log stream or Console transcript) on the server detail page, the region is scrolled to its bottom.
Cap the inline Console pane at 20 entries; keep the modal at 50.
## Non-goals
- No change to the SSE per-append scroll in `sse.js`. It already handles its own case.
- No change to the visual height of `.tab-pane` (stays at 18rem).
- No new endpoint. The existing `/servers/<id>/console/history` paging API is unaffected.
- No change to the recent-players or files tabs.
## Approach (selected: B — single generic attribute)
The Log stream and Console transcript are semantically the same: a scroll-locked terminal that must stay pinned to the bottom on (a) initial render, (b) tab activation, (c) content append. Treat them with one mechanism: an opt-in `data-autoscroll` attribute and a single helper that scrolls any such element.
Rejected: hardcoding `.log-stream` and `.console-transcript` selectors inside `tabs.js` ("Approach A"). That couples the tab subsystem to two unrelated CSS classes and means any future scroll-pinned region needs a third entry.
## Architecture
```
┌──────────────────────────────────────────────────────────┐
│ page_routes.py │
│ loads console_history (50) │
│ passes: │
│ console_history → modal Console pane │
│ console_history_overview → inline Console pane (20) │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ server_detail.html │
│ inline transcript: data-autoscroll, loops overview │
│ modal transcript: data-autoscroll, loops full │
│ both log-stream <pre>s: data-autoscroll added │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ console-history.js (or split into autoscroll.js) │
│ scrollAutoscrollTargets(root): │
│ - if root matches [data-autoscroll]: scroll it │
│ - else scroll any descendants with [data-autoscroll] │
│ - else walk up; if ancestor [data-autoscroll]: scroll │
│ Exposed on window for cross-module use. │
│ Wired to htmx:load. │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ tabs.js │
│ after activateTab(): call window.scrollAutoscrollTargets│
│ on the newly-active tabpanel │
└──────────────────────────────────────────────────────────┘
```
## Detailed design
### 1. Route — `l4d2web/l4d2web/routes/page_routes.py` (~L318-345)
Add:
```python
console_history_overview = console_history[-20:]
```
Pass it to `render_template`:
```python
return render_template(
"server_detail.html",
...
console_history=console_history,
console_history_overview=console_history_overview,
**ctx,
)
```
`console_history` is already chronological (oldest → newest after the `reversed(...)`), so `[-20:]` returns the 20 newest in chronological order — correct for top-down rendering.
### 2. Template — `l4d2web/l4d2web/templates/server_detail.html`
- Inline Console pane (currently iterates `console_history`): switch to `console_history_overview`.
- Modal Console pane: unchanged, still iterates `console_history`.
- Both `<pre class="log-stream">` elements (inline and modal): add `data-autoscroll`.
- Both `.console-transcript` divs already have `data-autoscroll`; no change.
### 3. JS — autoscroll helper
Rename `scrollConsolesToBottom` to `scrollAutoscrollTargets` in `console-history.js` (or extract it into a tiny new `autoscroll.js`; either is fine — pick whichever keeps the diff small). New behavior:
```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 — try walking up. This handles htmx:load firing with
// the inserted child as the root after hx-swap="beforeend".
if (targets.length === 0 && root.closest) {
const up = root.closest("[data-autoscroll]");
if (up) targets.push(up);
}
targets.forEach((el) => {
el.scrollTop = el.scrollHeight;
});
}
window.scrollAutoscrollTargets = scrollAutoscrollTargets;
```
The `htmx:load` listener already calls the helper; it just needs the renamed function.
### 4. JS — `tabs.js`
In `activateTab(strip, name)`, after the loop that toggles `hidden`, add:
```js
const activePane = strip.querySelector('[role="tabpanel"]:not([hidden])');
if (activePane && window.scrollAutoscrollTargets) {
window.scrollAutoscrollTargets(activePane);
}
```
This runs on both initial activation (`initStrips`) and click-driven activation.
### 5. CSS
No changes. The 18rem fixed-height `.tab-pane` and existing `.console-transcript` / `.log-stream` styles already provide the scroll container.
## Tests
**Unit / template render** (`l4d2web/tests/test_servers.py`):
- `test_server_detail_inline_console_pane_caps_at_20_lines` — seed 30 `CommandHistory` rows, render page, assert the inline `console-transcript-inline-<id>` container has exactly 20 `.console-line` children and the modal container has 30.
**e2e** (`l4d2web/tests/e2e/test_server_detail.py`):
- `test_console_tab_scrolled_to_bottom_on_first_activation` — seed > 20 history rows so the transcript overflows, open page, click Console tab, assert `await transcript.evaluate("(el) => Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) < 2")` returns true.
- `test_console_tab_scrolled_to_bottom_after_command_submit` — open Console, send a command via the input, after the swap settles assert the same predicate.
- `test_log_tab_scrolled_to_bottom_on_reactivation` — switch to Console, then back to Log (skipped if no log lines flowed; otherwise assert the predicate against the log-stream).
The "scrolled to bottom" predicate is a 2-pixel tolerance to absorb subpixel rounding across browsers.
## Risk and edge cases
- **Empty transcript / empty log:** `scrollHeight === clientHeight`; setting `scrollTop = scrollHeight` is a no-op. Safe.
- **Modal Console transcript not yet open:** On page load the modal is `display:none` (via `<dialog>` not yet `showModal()`-ed). Setting scrollTop on it does nothing useful — but the modal `<dialog>` Console transcript also has `data-autoscroll`, and HTMX/dialog open events would need a separate hook to pin it on first display. **Scope decision:** modal open already triggers re-pin via the existing `htmx:load` flow when the modal's content is HTMX-loaded; for the Console modal, transcript content is server-rendered into the dialog at page load, so we add a one-line `modal:opened` listener (consistent with the existing `recent-players-modal` pattern that uses `modal:opened` triggers). If `modal:opened` doesn't exist as an event in `modals.js`, fall back to running the helper inside the modal's open hook. **Implementation plan will confirm which.**
- **`window.scrollAutoscrollTargets` undefined at the time `tabs.js` activates a tab:** `tabs.js` and `console-history.js` are both loaded via `base.html` in the same `<script>` block order. As long as `console-history.js` is included before `tabs.js`, the function is defined when needed. Plan will verify and reorder if necessary.
- **20 is a hard-coded magic number:** Acceptable for a UI cap; deliberately not made configurable. If a future change wants it tunable, lift to a Flask config constant in one place.
## Out of scope (for follow-up if desired)
- A "Pause autoscroll if user scrolled up" affordance (common in chat UIs). Not requested; current behavior is "always pin to bottom".
- A "Clear console history" button. Not requested.
- Modal-Console initial scroll-to-bottom on `dialog.showModal()`. Covered conditionally by the modal-opened hook above; if that wiring proves brittle, falls out of scope into a separate task.

View file

@ -328,7 +328,6 @@ def server_detail(server_id: int):
).all()
)
)
console_history_overview = console_history[-20:]
connect_host = request.host.split(":")[0]
file_tree_root_entries, file_tree_truncated_count = _root_server_file_tree(server_id)
@ -344,7 +343,6 @@ def server_detail(server_id: int):
else False,
file_tree_truncated_count=file_tree_truncated_count,
console_history=console_history,
console_history_overview=console_history_overview,
**ctx,
)

View file

@ -156,39 +156,24 @@ function bindAllConsoleForms(root) {
scope.forEach(bindConsoleForm);
}
function scrollAutoscrollTargets(root) {
function scrollConsolesToBottom(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.
const scope = root.matches && root.matches("[data-autoscroll]") ? [root] : [];
if (root.querySelectorAll) {
root.querySelectorAll("[data-autoscroll]").forEach((el) => targets.push(el));
root.querySelectorAll("[data-autoscroll]").forEach((el) => scope.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) => {
scope.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);
scrollConsolesToBottom(document);
bindAllConsoleForms(document);
});
// Support HTMX-injected content (mirrors sse.js pattern).
document.addEventListener("htmx:load", (event) => {
scrollAutoscrollTargets(event.detail.elt);
scrollConsolesToBottom(event.detail.elt);
bindAllConsoleForms(event.detail.elt);
});

View file

@ -16,14 +16,6 @@
p.hidden = p.dataset.tab !== name;
});
strip.dataset.activeTab = name;
// 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);
}
}
function activeTabName(strip) {
@ -54,12 +46,8 @@
const name = strip.dataset.activeTab;
if (!name) return;
const dlg = document.getElementById(`${name}-modal`);
if (dlg && !dlg.open) {
if (window.modals && typeof window.modals.openInline === "function") {
window.modals.openInline(dlg);
} else if (typeof dlg.showModal === "function") {
dlg.showModal();
}
if (dlg && typeof dlg.showModal === "function" && !dlg.open) {
dlg.showModal();
}
});
}

View file

@ -58,12 +58,12 @@
</div>
<div role="tabpanel" data-tab="log" class="tab-pane">
<pre class="log-stream" data-autoscroll data-sse-url="/servers/{{ server.id }}/logs/stream"></pre>
<pre class="log-stream" data-sse-url="/servers/{{ server.id }}/logs/stream"></pre>
</div>
<div role="tabpanel" data-tab="console" class="tab-pane" hidden>
<div id="console-transcript-inline-{{ server.id }}" class="console-transcript" data-autoscroll>
{% for h in console_history_overview %}
{% for h in console_history %}
{% with command=h.command, reply=h.reply, is_error=h.is_error %}
{% include "_console_line.html" %}
{% endwith %}
@ -98,7 +98,7 @@
<button type="button" class="modal-close" data-inline-modal-close aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<pre class="log-stream tall" data-autoscroll data-sse-url="/servers/{{ server.id }}/logs/stream"></pre>
<pre class="log-stream tall" data-sse-url="/servers/{{ server.id }}/logs/stream"></pre>
</div>
</dialog>
@ -156,7 +156,7 @@
</div>
<div class="modal-body">
{% if latest_job %}
<pre class="log-stream tall" data-autoscroll data-sse-url="/jobs/{{ latest_job.id }}/stream"></pre>
<pre class="log-stream tall" data-sse-url="/jobs/{{ latest_job.id }}/stream"></pre>
<p><a href="/jobs/{{ latest_job.id }}">open full job →</a></p>
{% else %}
<p class="muted">No job has run for this server yet.</p>
@ -211,25 +211,4 @@
</form>
</div>
</dialog>
<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", () => {
// Defer one rAF so the browser commits the dialog layout before we
// read scrollHeight and set scrollTop (otherwise both are still 0).
requestAnimationFrame(() => {
if (window.scrollAutoscrollTargets) {
window.scrollAutoscrollTargets(dlg);
}
});
});
})();
</script>
{% endblock %}

View file

@ -269,27 +269,3 @@ def server_with_files(tmp_path, monkeypatch):
}
finally:
shutdown()
@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

View file

@ -108,56 +108,3 @@ def test_expand_opens_matching_modal(page: Page, server_with_files) -> None:
dialog = page.locator("dialog#files-modal")
expect(dialog).to_have_attribute("open", "")
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()
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"
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")
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"

View file

@ -855,59 +855,3 @@ def test_server_detail_renders_state_cluster_and_inspection_strip(user_client_wi
assert 'id="files-modal"' in html
assert 'id="recent-players-modal"' in html
assert 'id="job-log-modal"' in html
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}"