Compare commits
7 commits
058acb9c5c
...
0307416b92
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0307416b92 | ||
|
|
06a358943e | ||
|
|
c50b6bff29 | ||
|
|
02e44a04d3 | ||
|
|
35dfb6dd1f | ||
|
|
39963db2e3 | ||
|
|
2415885d30 |
9 changed files with 916 additions and 14 deletions
|
|
@ -0,0 +1,540 @@
|
|||
# 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**
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
# 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.
|
||||
|
|
@ -328,6 +328,7 @@ 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)
|
||||
|
|
@ -343,6 +344,7 @@ 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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -156,24 +156,39 @@ function bindAllConsoleForms(root) {
|
|||
scope.forEach(bindConsoleForm);
|
||||
}
|
||||
|
||||
function scrollConsolesToBottom(root) {
|
||||
function scrollAutoscrollTargets(root) {
|
||||
if (!root) return;
|
||||
const scope = root.matches && root.matches("[data-autoscroll]") ? [root] : [];
|
||||
if (root.querySelectorAll) {
|
||||
root.querySelectorAll("[data-autoscroll]").forEach((el) => scope.push(el));
|
||||
const targets = [];
|
||||
// Case 1: root itself opts in.
|
||||
if (root.matches && root.matches("[data-autoscroll]")) {
|
||||
targets.push(root);
|
||||
}
|
||||
scope.forEach((el) => {
|
||||
// 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", () => {
|
||||
scrollConsolesToBottom(document);
|
||||
scrollAutoscrollTargets(document);
|
||||
bindAllConsoleForms(document);
|
||||
});
|
||||
|
||||
// Support HTMX-injected content (mirrors sse.js pattern).
|
||||
document.addEventListener("htmx:load", (event) => {
|
||||
scrollConsolesToBottom(event.detail.elt);
|
||||
scrollAutoscrollTargets(event.detail.elt);
|
||||
bindAllConsoleForms(event.detail.elt);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,6 +16,14 @@
|
|||
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) {
|
||||
|
|
@ -46,8 +54,12 @@
|
|||
const name = strip.dataset.activeTab;
|
||||
if (!name) return;
|
||||
const dlg = document.getElementById(`${name}-modal`);
|
||||
if (dlg && typeof dlg.showModal === "function" && !dlg.open) {
|
||||
dlg.showModal();
|
||||
if (dlg && !dlg.open) {
|
||||
if (window.modals && typeof window.modals.openInline === "function") {
|
||||
window.modals.openInline(dlg);
|
||||
} else if (typeof dlg.showModal === "function") {
|
||||
dlg.showModal();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,12 +58,12 @@
|
|||
</div>
|
||||
|
||||
<div role="tabpanel" data-tab="log" class="tab-pane">
|
||||
<pre class="log-stream" data-sse-url="/servers/{{ server.id }}/logs/stream"></pre>
|
||||
<pre class="log-stream" data-autoscroll 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 %}
|
||||
{% for h in console_history_overview %}
|
||||
{% 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">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<pre class="log-stream tall" data-sse-url="/servers/{{ server.id }}/logs/stream"></pre>
|
||||
<pre class="log-stream tall" data-autoscroll 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-sse-url="/jobs/{{ latest_job.id }}/stream"></pre>
|
||||
<pre class="log-stream tall" data-autoscroll 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,4 +211,25 @@
|
|||
</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 %}
|
||||
|
|
|
|||
|
|
@ -269,3 +269,27 @@ 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
|
||||
|
|
|
|||
|
|
@ -108,3 +108,56 @@ 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"
|
||||
|
|
|
|||
|
|
@ -855,3 +855,59 @@ 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}"
|
||||
|
|
|
|||
Loading…
Reference in a new issue