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()
|
).all()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
console_history_overview = console_history[-20:]
|
||||||
|
|
||||||
connect_host = request.host.split(":")[0]
|
connect_host = request.host.split(":")[0]
|
||||||
file_tree_root_entries, file_tree_truncated_count = _root_server_file_tree(server_id)
|
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,
|
else False,
|
||||||
file_tree_truncated_count=file_tree_truncated_count,
|
file_tree_truncated_count=file_tree_truncated_count,
|
||||||
console_history=console_history,
|
console_history=console_history,
|
||||||
|
console_history_overview=console_history_overview,
|
||||||
**ctx,
|
**ctx,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -156,24 +156,39 @@ function bindAllConsoleForms(root) {
|
||||||
scope.forEach(bindConsoleForm);
|
scope.forEach(bindConsoleForm);
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollConsolesToBottom(root) {
|
function scrollAutoscrollTargets(root) {
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
const scope = root.matches && root.matches("[data-autoscroll]") ? [root] : [];
|
const targets = [];
|
||||||
if (root.querySelectorAll) {
|
// Case 1: root itself opts in.
|
||||||
root.querySelectorAll("[data-autoscroll]").forEach((el) => scope.push(el));
|
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;
|
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", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
scrollConsolesToBottom(document);
|
scrollAutoscrollTargets(document);
|
||||||
bindAllConsoleForms(document);
|
bindAllConsoleForms(document);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Support HTMX-injected content (mirrors sse.js pattern).
|
|
||||||
document.addEventListener("htmx:load", (event) => {
|
document.addEventListener("htmx:load", (event) => {
|
||||||
scrollConsolesToBottom(event.detail.elt);
|
scrollAutoscrollTargets(event.detail.elt);
|
||||||
bindAllConsoleForms(event.detail.elt);
|
bindAllConsoleForms(event.detail.elt);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,14 @@
|
||||||
p.hidden = p.dataset.tab !== name;
|
p.hidden = p.dataset.tab !== name;
|
||||||
});
|
});
|
||||||
strip.dataset.activeTab = 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) {
|
function activeTabName(strip) {
|
||||||
|
|
@ -46,8 +54,12 @@
|
||||||
const name = strip.dataset.activeTab;
|
const name = strip.dataset.activeTab;
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
const dlg = document.getElementById(`${name}-modal`);
|
const dlg = document.getElementById(`${name}-modal`);
|
||||||
if (dlg && typeof dlg.showModal === "function" && !dlg.open) {
|
if (dlg && !dlg.open) {
|
||||||
dlg.showModal();
|
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>
|
||||||
|
|
||||||
<div role="tabpanel" data-tab="log" class="tab-pane">
|
<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>
|
||||||
|
|
||||||
<div role="tabpanel" data-tab="console" class="tab-pane" hidden>
|
<div role="tabpanel" data-tab="console" class="tab-pane" hidden>
|
||||||
<div id="console-transcript-inline-{{ server.id }}" class="console-transcript" data-autoscroll>
|
<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 %}
|
{% with command=h.command, reply=h.reply, is_error=h.is_error %}
|
||||||
{% include "_console_line.html" %}
|
{% include "_console_line.html" %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
@ -98,7 +98,7 @@
|
||||||
<button type="button" class="modal-close" data-inline-modal-close aria-label="Close">×</button>
|
<button type="button" class="modal-close" data-inline-modal-close aria-label="Close">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<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>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
|
@ -156,7 +156,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
{% if latest_job %}
|
{% 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>
|
<p><a href="/jobs/{{ latest_job.id }}">open full job →</a></p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="muted">No job has run for this server yet.</p>
|
<p class="muted">No job has run for this server yet.</p>
|
||||||
|
|
@ -211,4 +211,25 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</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 %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -269,3 +269,27 @@ def server_with_files(tmp_path, monkeypatch):
|
||||||
}
|
}
|
||||||
finally:
|
finally:
|
||||||
shutdown()
|
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")
|
dialog = page.locator("dialog#files-modal")
|
||||||
expect(dialog).to_have_attribute("open", "")
|
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="files-modal"' in html
|
||||||
assert 'id="recent-players-modal"' in html
|
assert 'id="recent-players-modal"' in html
|
||||||
assert 'id="job-log-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