docs(server-detail): spec — console/log autoscroll + inline-history cap
Three usability fixes for the inspection strip on server_detail: 1. Pin transcripts/logs to bottom on tab activation. 2. Cap inline Console to 20 entries; modal keeps 50. 3. Pin to bottom after a console-line is appended via HTMX. Approach B: single data-autoscroll opt-in attribute + one helper. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
058acb9c5c
commit
2415885d30
1 changed files with 179 additions and 0 deletions
|
|
@ -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.
|
||||||
Loading…
Reference in a new issue