left4me/docs/superpowers/plans/2026-05-17-server-detail-page-redesign.md
mwiegand 8558120ef8
docs(server-detail): archive 2026-05-17 redesign plan
The plan that drove the state-cluster + inspection-strip + modals
redesign that landed on 2026-05-17. Tasks have already shipped (see
the commits between 'feat(templates): add _macros.html' and the
post-redesign style polish on 2026-05-19). Committing the plan itself
so the project's docs/superpowers/plans/ history is complete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:59:16 +02:00

1150 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Server detail page redesign — 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:** Reshape `server_detail.html` into a top "state cluster" (lifecycle + live state + config) and a bottom tabbed inspection strip (Log / Console / Files) with expand-to-modal, removing inline page-shifting elements like the streaming job log.
**Architecture:** Pure template + CSS + small JS refactor. Reuses existing partials (`_console_line.html`, `_overlay_file_tree.html`), the existing `<dialog class="modal">` + `modals.js` infrastructure, and the existing SSE/HTMX endpoints. Two routes are touched only to add precomputed slice/count fields; no new endpoints.
**Tech Stack:** Jinja2 templates, plain CSS (custom properties under `var(--space-*)` etc., no Tailwind), HTMX, vanilla JS, Playwright for e2e.
**Reference spec:** `docs/superpowers/specs/2026-05-17-server-detail-page-redesign-design.md`
---
## File map
| Path | Action | Responsibility |
|------|--------|----------------|
| `l4d2web/l4d2web/templates/_macros.html` | **create** | `config_field(label, value, editable=False)` Jinja macro |
| `l4d2web/l4d2web/routes/server_routes.py` | modify (`live_state_fragment`, ~L262-274) | Slice `recent_rows` to 10 for overview, pass total count |
| `l4d2web/l4d2web/templates/_live_state.html` | rewrite | 4-col current grid (no header), 5-col recent chips, `N Recent` trigger header |
| `l4d2web/l4d2web/templates/_server_actions.html` | modify (remove L33-35; modify L26-32) | Delete inline `<pre>`; convert latest_job link to `<button>` modal trigger |
| `l4d2web/l4d2web/static/css/components.css` | append | `.state-cluster`, `.inspection-strip`, player grids/chips, tabbar, divider |
| `l4d2web/l4d2web/static/js/tabs.js` | **create** | Tab activation + expand-to-active-modal handler |
| `l4d2web/l4d2web/templates/base.html` | modify | `<script>` include for `tabs.js` |
| `l4d2web/l4d2web/templates/server_detail.html` | rewrite body | State cluster wrapper, inspection strip, five new modals |
| `l4d2web/tests/test_status_and_server_logs.py` | extend | Unit-test new slice/count fields on `/live-state` route |
| `l4d2web/tests/e2e/test_server_detail.py` | extend | Playwright: tab switching, expand-to-modal, job-log modal trigger |
---
### Task 1: Add `config_field` Jinja macro
**Files:**
- Create: `l4d2web/l4d2web/templates/_macros.html`
Foundational. Other template tasks will call this macro.
- [ ] **Step 1: Create the macro file**
Create `l4d2web/l4d2web/templates/_macros.html`:
```jinja
{# Reusable field-cell for the server-detail config grid and any future
key/value layouts. `value` is rendered as-is (caller can pass safe
HTML via `|safe` if needed). `editable` is a flag the caller may
use to switch rendering — currently informational only. #}
{% macro config_field(label, value, editable=False) %}
<div class="config-field">
<div class="config-field-label">{{ label }}</div>
<div class="config-field-value">{{ value }}</div>
</div>
{% endmacro %}
```
- [ ] **Step 2: Commit**
```bash
git add l4d2web/l4d2web/templates/_macros.html
git commit -m "feat(templates): add _macros.html with config_field macro"
```
---
### Task 2: Live-state route — slice recents and expose total count
**Files:**
- Modify: `l4d2web/l4d2web/routes/server_routes.py:262-274` (the `recent_rows` query and `render_template` call inside `live_state_fragment`)
- Test: `l4d2web/tests/test_status_and_server_logs.py`
The HTMX poll on `_live_state.html` is what brings player data into the page. We need the inline render to use a sliced list and expose the total count for the `N Recent` header.
- [ ] **Step 1: Write the failing test**
Append to `l4d2web/tests/test_status_and_server_logs.py`:
```python
from datetime import UTC, datetime, timedelta
from l4d2web.db import session_scope
from l4d2web.models import Server, ServerPlayerSession, User
def test_live_state_fragment_exposes_sliced_recents_and_total(client, login_alice):
"""live_state_fragment must pass recent_players_overview (max 10
rows) and recent_players_total_count to the template so the
'N Recent' header can render correctly with N > 10."""
with session_scope() as db:
user = db.scalar(User.query_by_username("alice"))
server = Server(user_id=user.id, blueprint_id=None, name="srv", port=27100)
db.add(server)
db.flush()
sid = server.id
# 13 distinct recent (left_at IS NOT NULL) sessions
for i in range(13):
db.add(ServerPlayerSession(
server_id=sid,
steam_id_64=f"7656119800000{i:04d}",
name_at_join=f"P{i}",
joined_at=datetime.now(UTC) - timedelta(hours=i + 1),
left_at=datetime.now(UTC) - timedelta(minutes=i + 1),
min_ping=20, max_ping=30,
))
resp = client.get(f"/servers/{sid}/live-state")
assert resp.status_code == 200
body = resp.get_data(as_text=True)
# Header shows the *full* count
assert "13 Recent" in body or 'data-recent-total="13"' in body
# Inline grid shows at most 10 chips
assert body.count('class="recent-chip"') <= 10
```
- [ ] **Step 2: Run test to verify it fails**
```bash
cd l4d2web && pytest tests/test_status_and_server_logs.py::test_live_state_fragment_exposes_sliced_recents_and_total -v
```
Expected: FAIL (template assertion or attribute error — neither the slice nor the header exist yet)
- [ ] **Step 3: Modify the route**
Edit `l4d2web/l4d2web/routes/server_routes.py`. Replace lines 262-274 (the `.limit(20)` query tail and the `render_template` call) with:
```python
).order_by(func.max(ServerPlayerSession.left_at).desc()).all()
recent_total = len(recent_rows)
recent_overview = recent_rows[:10]
return render_template(
"_live_state.html",
server=server,
snapshot=latest,
snapshot_fresh=(latest is not None and latest.last_seen_at >= cutoff),
current_players=current_rows,
recent_players=recent_rows,
recent_players_overview=recent_overview,
recent_players_total_count=recent_total,
poll_seconds=max(1, int(current_app.config.get("LIVE_STATE_POLL_SECONDS", 5))),
)
```
Also drop the `.limit(20)` from the query (now line ~263); we want the full list to render inside the recent-players modal, and we still slice the inline render. If unbounded growth is a concern, replace with `.limit(50)` — but the existing per-user usage caps make 20-50 indistinguishable in practice.
- [ ] **Step 4: Run test to verify it passes**
```bash
cd l4d2web && pytest tests/test_status_and_server_logs.py::test_live_state_fragment_exposes_sliced_recents_and_total -v
```
Expected: PASS (template still has to render the count; if it fails on the `"13 Recent"` assertion that's covered in Task 3 — for now, assert the route exposes the new context keys instead:)
If the route-level change alone is what you want to validate first, replace the body assertions with a context probe via Flask's `with client.application.test_request_context(...)`. Simpler: skip the body assertions in this step and let Task 3 finalize them. **Mark this test `@pytest.mark.xfail(strict=True)` until Task 3 is complete, then remove the marker.**
- [ ] **Step 5: Commit**
```bash
git add l4d2web/l4d2web/routes/server_routes.py l4d2web/tests/test_status_and_server_logs.py
git commit -m "feat(live-state): expose sliced recents + total count to template"
```
---
### Task 3: Rewrite `_live_state.html` — new grids and `N Recent` header
**Files:**
- Modify: `l4d2web/l4d2web/templates/_live_state.html` (replace whole file)
Removes the `<h2>Live state</h2>` heading (the parent state-cluster panel implies it), drops the `<h3>Current players</h3>` sub-header, and adds the new `N Recent` header trigger.
- [ ] **Step 1: Write the failing assertion (extension of Task 2's test)**
Edit the test you added in Task 2 to drop the `xfail` marker (if you added it) and ensure both body assertions are active:
```python
assert "13 Recent" in body
# 10 chips inline (5 cols × 2 rows)
assert body.count('class="recent-chip"') == 10
```
Also add a sparse-case test in the same file:
```python
def test_live_state_fragment_recent_header_not_clickable_when_le_10(client, login_alice):
"""N ≤ 10: header is plain text, no modal-open attribute."""
with session_scope() as db:
user = db.scalar(User.query_by_username("alice"))
server = Server(user_id=user.id, blueprint_id=None, name="srv", port=27101)
db.add(server)
db.flush()
sid = server.id
for i in range(4):
db.add(ServerPlayerSession(
server_id=sid,
steam_id_64=f"7656119800001{i:04d}",
name_at_join=f"P{i}",
joined_at=datetime.now(UTC) - timedelta(hours=i + 1),
left_at=datetime.now(UTC) - timedelta(minutes=i + 1),
min_ping=20, max_ping=30,
))
resp = client.get(f"/servers/{sid}/live-state")
body = resp.get_data(as_text=True)
assert "4 Recent" in body
assert 'data-inline-modal-open="recent-players-modal"' not in body
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
cd l4d2web && pytest tests/test_status_and_server_logs.py -k "live_state_fragment" -v
```
Expected: both new tests FAIL
- [ ] **Step 3: Replace `_live_state.html`**
Overwrite `l4d2web/l4d2web/templates/_live_state.html` with:
```jinja
{# Live-state partial — HTMX-polled into the state cluster. The parent
.state-cluster section provides the heading context, so there is no
<h2> here. Current players have no sub-header; they sit directly
under the summary line. Recent players' header is "N Recent" and
doubles as the modal trigger when N > 10. #}
{% if not snapshot or not snapshot_fresh %}
<p class="muted">No data — server is not currently reporting.</p>
{% else %}
<p class="server-live-summary">
{{ snapshot.players }}/{{ snapshot.max_players }}
{% if snapshot.hibernating %}· idle{% endif %}
· {{ snapshot.map }}
<small class="muted">polled {{ snapshot.last_seen_at | timeago }}</small>
</p>
{% endif %}
{% if current_players %}
<ul class="player-grid current">
{% for session, profile in current_players %}
<li class="player-card current-card">
<a class="player-link"
href="https://steamcommunity.com/profiles/{{ session.steam_id_64 }}"
target="_blank" rel="noopener noreferrer">
{% if profile and profile.avatar_url %}
<img class="avatar" src="{{ profile.avatar_url }}" alt="">
{% else %}
<span class="avatar placeholder" aria-hidden="true"></span>
{% endif %}
<span class="name" title="{{ (profile and profile.persona_name) or session.name_at_join }}">{{ (profile and profile.persona_name) or session.name_at_join }}</span>
</a>
<span class="meta">
{{ session.joined_at | timeago }} · {{ session.min_ping }}-{{ session.max_ping }}ms
</span>
</li>
{% endfor %}
</ul>
{% endif %}
{% if recent_players_overview %}
<h3 class="recent-header">
{% if recent_players_total_count > 10 %}
<button type="button" class="recent-header-trigger"
data-inline-modal-open="recent-players-modal">
{{ recent_players_total_count }} Recent
</button>
{% else %}
{{ recent_players_total_count }} Recent
{% endif %}
</h3>
<ul class="player-grid recent">
{% for row in recent_players_overview %}
<li class="player-card recent-chip">
<a class="player-link"
href="https://steamcommunity.com/profiles/{{ row.steam_id_64 }}"
target="_blank" rel="noopener noreferrer">
{% if row.avatar_url %}
<img class="avatar" src="{{ row.avatar_url }}" alt="">
{% else %}
<span class="avatar placeholder" aria-hidden="true"></span>
{% endif %}
<span class="name" title="{{ row.persona_name or row.name_at_join }}">{{ row.persona_name or row.name_at_join }}</span>
</a>
<span class="meta">· {{ row.last_seen | timeago }}</span>
</li>
{% endfor %}
</ul>
{% endif %}
```
The `timeago` filter already returns `"4m ago"` style strings; the meta line keeps just the relative time + ping unit. The "ago" suffix is consciously kept — dropping it would require a new filter; the visual compactness from removing the `joined`/`last seen` prefixes is sufficient.
- [ ] **Step 4: Run tests to verify they pass**
```bash
cd l4d2web && pytest tests/test_status_and_server_logs.py -k "live_state_fragment" -v
```
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add l4d2web/l4d2web/templates/_live_state.html l4d2web/tests/test_status_and_server_logs.py
git commit -m "feat(live-state): compact 4-col current + 5-col recent chips + N Recent trigger"
```
---
### Task 4: `_server_actions.html` — remove inline job-log pre, convert link to modal trigger
**Files:**
- Modify: `l4d2web/l4d2web/templates/_server_actions.html` (lines 26-35)
- Test: `l4d2web/tests/test_pages.py` or `test_servers.py` (find the existing test that renders server detail with a running job; otherwise add one)
- [ ] **Step 1: Write the failing test**
Append to `l4d2web/tests/test_servers.py`:
```python
def test_server_detail_no_inline_job_log_pre(client, login_alice, db_session):
"""When a job is running, _server_actions.html must not render the
streaming <pre class="log-stream job-log"> — the user opens
#job-log-modal instead."""
# ... build a Server + running Job for alice ...
# (use existing helpers in this file; pattern: build_server_with_job(state="starting"))
server = build_server_with_job(db_session, owner="alice", job_state="running")
resp = client.get(f"/servers/{server.id}")
body = resp.get_data(as_text=True)
assert 'class="log-stream job-log"' not in body
# The latest_job_phrase is now a modal-trigger button, not an anchor:
assert 'data-inline-modal-open="job-log-modal"' in body
```
(If `build_server_with_job` doesn't exist, build inline. Use the patterns already present near `test_server_detail_renders_*` tests in `test_servers.py`.)
- [ ] **Step 2: Run test to verify it fails**
```bash
cd l4d2web && pytest tests/test_servers.py::test_server_detail_no_inline_job_log_pre -v
```
Expected: FAIL (log-stream pre still present)
- [ ] **Step 3: Modify `_server_actions.html`**
Replace lines 25-35 of `l4d2web/l4d2web/templates/_server_actions.html`:
```jinja
{% if latest_job %}
<p class="last-job">
<button type="button" class="link-button"
data-inline-modal-open="job-log-modal">{{ latest_job_phrase }}</button>
{% if latest_job_is_running %}since{% endif %}
{{ latest_job_at | timeago }}
(<a href="/servers/{{ server.id }}/jobs">show all</a>)
</p>
{% endif %}
</div>
```
That replaces both the `<a href="/jobs/{{ latest_job.id }}">` (which now opens the modal) AND removes the trailing `{% if latest_job_is_running %}<pre class="log-stream job-log" ...></pre>{% endif %}` block.
- [ ] **Step 4: Run test to verify it passes**
```bash
cd l4d2web && pytest tests/test_servers.py::test_server_detail_no_inline_job_log_pre -v
```
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add l4d2web/l4d2web/templates/_server_actions.html l4d2web/tests/test_servers.py
git commit -m "feat(server-actions): remove inline job-log; link → job-log-modal trigger"
```
---
### Task 5: Add CSS — state cluster, inspection strip, player grids, tabbar
**Files:**
- Modify: `l4d2web/l4d2web/static/css/components.css` (append; replace existing `.live-state .player-*` block)
CSS-only. No test — visual verification via dev server in the final integration task.
- [ ] **Step 1: Replace the existing `.live-state .player-*` block**
Find lines 875-933 in `components.css` (the `/* Live-state panel — current + recent players ... */` block). Replace the entire block (875-933) with:
```css
/* ============================================================
State cluster (top of server detail)
============================================================ */
.state-cluster {
display: flex;
flex-direction: column;
gap: var(--space-m);
}
.state-cluster > * + * {
border-top: 1px dashed var(--color-border-muted, rgba(255, 255, 255, 0.12));
padding-top: var(--space-m);
}
/* Config grid — auto-fit so it scales with field count */
.config-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--space-s) var(--space-m);
}
.config-field-label {
font-size: 0.75em;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-muted);
margin-bottom: 0.15em;
}
.config-field-value { font-size: 0.95em; }
/* ============================================================
Live-state — compact player grids
============================================================ */
.player-grid {
list-style: none;
padding: 0;
margin: var(--space-s) 0 0;
display: grid;
gap: var(--space-xs);
}
.player-grid.current { grid-template-columns: repeat(4, 1fr); }
.player-grid.recent { grid-template-columns: repeat(5, 1fr); }
.player-card {
display: grid;
grid-template-columns: auto 1fr;
column-gap: var(--space-xs);
align-items: center;
padding: var(--space-xs);
}
.player-link {
display: contents;
color: inherit;
text-decoration: none;
}
.player-link:hover .name { text-decoration: underline; }
.player-card .avatar {
border-radius: var(--radius-s);
object-fit: cover;
}
.current-card .avatar { width: 22px; height: 22px; grid-row: 1 / span 2; }
.recent-chip .avatar { width: 16px; height: 16px; }
.player-card .name {
grid-column: 2;
font-size: 0.9em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.current-card .name { font-weight: 600; }
.recent-chip .name { font-weight: 500; }
.player-card .meta {
grid-column: 2;
color: var(--color-muted);
font-size: 0.75em;
}
.recent-chip .meta {
grid-column: auto;
font-size: 0.8em;
}
.recent-header {
margin: var(--space-m) 0 var(--space-xs);
font-size: 0.75em;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-muted);
font-weight: 600;
}
.recent-header-trigger {
background: transparent;
border: 0;
padding: 0;
font: inherit;
color: var(--color-link, #7ab0ff);
text-transform: inherit;
letter-spacing: inherit;
text-decoration: underline dotted;
text-underline-offset: 2px;
cursor: pointer;
}
.recent-header-trigger:hover { text-decoration-style: solid; }
.server-live-summary { font-size: 1.05em; }
/* Narrow viewports — collapse fixed grids so chips don't crush */
@media (max-width: 600px) {
.player-grid.current,
.player-grid.recent {
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
}
}
/* ============================================================
Inspection strip — tabbed Log / Console / Files
============================================================ */
.inspection-strip {
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden;
}
.tab-bar {
display: flex;
align-items: center;
border-bottom: 1px solid var(--color-border-muted, rgba(255, 255, 255, 0.12));
padding: 0 var(--space-s);
}
.tab-bar [role="tab"] {
background: transparent;
border: 0;
padding: var(--space-xs) var(--space-m);
color: var(--color-muted);
cursor: pointer;
font: inherit;
border-bottom: 2px solid transparent;
}
.tab-bar [role="tab"][aria-selected="true"] {
color: var(--color-text);
border-bottom-color: var(--color-link, #7ab0ff);
}
.tab-bar .strip-expand {
margin-left: auto;
background: transparent;
border: 0;
padding: var(--space-xs) var(--space-s);
color: var(--color-muted);
cursor: pointer;
}
.tab-bar .strip-expand:hover { color: var(--color-text); }
.tab-pane {
padding: var(--space-s);
max-height: 12rem;
overflow: auto;
}
.tab-pane[hidden] { display: none; }
.tab-pane .log-stream { max-height: none; } /* let pane handle scrolling */
```
If `--color-border-muted`, `--color-link`, `--space-xs`, or `--space-m` are not defined in `tokens.css`, add them there as one-line additions before this commit. Quickly verify with:
```bash
grep -E "--color-border-muted|--color-link|--space-xs|--space-m" \
l4d2web/l4d2web/static/css/tokens.css
```
Add any missing token with a sensible default (`--space-xs: 0.25rem;`, `--color-link: #7ab0ff;`, `--color-border-muted: rgba(255,255,255,0.12);`).
- [ ] **Step 2: Smoke-load the dev server**
```bash
scripts/dev-server.py
```
Visit `http://127.0.0.1:5000`, log in as the demo user, click a server. The page will look broken (state cluster + strip aren't wired yet) but **no CSS errors should appear in the browser console.**
- [ ] **Step 3: Commit**
```bash
git add l4d2web/l4d2web/static/css/components.css l4d2web/l4d2web/static/css/tokens.css
git commit -m "feat(css): state-cluster, inspection-strip, compact player grids"
```
---
### Task 6: New `tabs.js` — tab activation + expand-to-active-modal
**Files:**
- Create: `l4d2web/l4d2web/static/js/tabs.js`
Pure DOM; no test framework. We exercise it via Task 9 (Playwright e2e).
- [ ] **Step 1: Create `tabs.js`**
```js
// l4d2web/l4d2web/static/js/tabs.js
// Tabbed strips: any element with [data-tab-strip] activates the first
// [role="tab"] in DOM order (or the one carrying [aria-selected="true"]
// if present) on load, and switches panes on click. The strip's
// [data-active-tab] attribute mirrors the active tab name and is read
// by the expand-to-modal handler.
(function () {
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;
}
function activeTabName(strip) {
const selected = strip.querySelector('[role="tab"][aria-selected="true"]');
if (selected) return selected.dataset.tab;
const first = strip.querySelector('[role="tab"]');
return first ? first.dataset.tab : null;
}
function initStrips(root) {
(root || document).querySelectorAll("[data-tab-strip]").forEach((strip) => {
// Initialise active tab on load.
const name = activeTabName(strip);
if (name) activateTab(strip, name);
// Bind tab clicks.
strip.addEventListener("click", (ev) => {
const tab = ev.target.closest('[role="tab"]');
if (tab && strip.contains(tab)) {
activateTab(strip, tab.dataset.tab);
}
});
// Bind expand button: opens <dialog id="<tabname>-modal">.
const expand = strip.querySelector(".strip-expand");
if (expand) {
expand.addEventListener("click", () => {
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 (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => initStrips());
} else {
initStrips();
}
})();
```
- [ ] **Step 2: Commit**
```bash
git add l4d2web/l4d2web/static/js/tabs.js
git commit -m "feat(js): tabs.js — tab activation + expand-to-active-modal"
```
---
### Task 7: Include `tabs.js` in `base.html`
**Files:**
- Modify: `l4d2web/l4d2web/templates/base.html`
- [ ] **Step 1: Find the existing `<script>` includes**
```bash
grep -n "static/js" l4d2web/l4d2web/templates/base.html
```
- [ ] **Step 2: Add `tabs.js` next to the other static includes**
Add `<script src="{{ url_for('static', filename='js/tabs.js') }}"></script>` adjacent to the existing `modals.js` include — same `<script>` form (no `defer`, no `type=module` unless that's what siblings use; match the surrounding file).
- [ ] **Step 3: Smoke-verify the script loads**
```bash
scripts/dev-server.py
```
Open browser devtools → Network → reload page → check `tabs.js` returns 200.
- [ ] **Step 4: Commit**
```bash
git add l4d2web/l4d2web/templates/base.html
git commit -m "feat(base): include tabs.js"
```
---
### Task 8: Rewrite `server_detail.html` body
**Files:**
- Rewrite: `l4d2web/l4d2web/templates/server_detail.html`
This is the integration task — wires everything together.
- [ ] **Step 1: Write the failing integration test**
Append to `l4d2web/tests/test_servers.py`:
```python
def test_server_detail_renders_state_cluster_and_inspection_strip(client, login_alice, db_session):
"""The redesigned page must contain the state-cluster section, the
tab-strip with all three tabs, and the four new modals."""
server = build_server(db_session, owner="alice")
resp = client.get(f"/servers/{server.id}")
body = resp.get_data(as_text=True)
assert 'class="panel state-cluster"' in body
assert 'data-tab-strip' in body
for tab in ("log", "console", "files"):
assert f'data-tab="{tab}"' in body
for modal in ("log-modal", "console-modal", "files-modal",
"recent-players-modal", "job-log-modal"):
assert f'id="{modal}"' in body
```
- [ ] **Step 2: Run the test to verify it fails**
```bash
cd l4d2web && pytest tests/test_servers.py::test_server_detail_renders_state_cluster_and_inspection_strip -v
```
Expected: FAIL
- [ ] **Step 3: Replace `server_detail.html`**
Replace `l4d2web/l4d2web/templates/server_detail.html` with:
```jinja
{% extends "base.html" %}
{% import "_macros.html" as macros %}
{% block title %}Server {{ server.name }} | left4me{% endblock %}
{% block content %}
<section class="panel state-cluster">
<div class="page-heading">
<h1>Server: {{ server.name }}</h1>
</div>
{# Lifecycle subblock #}
{% include "_server_actions.html" %}
{# Live state — HTMX-loaded #}
<section class="live-state"
hx-get="/servers/{{ server.id }}/live-state"
hx-trigger="load, every 5s"
hx-swap="innerHTML"></section>
{# Config grid #}
<div class="config-grid">
{{ macros.config_field("Port",
'<a href="steam://run/550//+connect%20' ~ connect_host ~ ':' ~ server.port ~ '">' ~ server.port ~ '</a>' | safe) }}
{{ macros.config_field("Blueprint",
('<a href="/blueprints/' ~ blueprint.id ~ '">' ~ blueprint.name ~ '</a>') | safe if blueprint else "—") }}
{{ macros.config_field("RCON",
('<span class="password-mask" data-password-field="' ~ server.id ~ '">••••••••••••</span>'
'<span class="password-value" data-password-field="' ~ server.id ~ '" hidden>' ~ server.rcon_password ~ '</span> '
'<button class="link-button" data-password-toggle="' ~ server.id ~ '" aria-label="Show RCON password">show</button>') | safe) }}
{{ macros.config_field("Hostname",
('<form method="post" action="/servers/' ~ server.id ~ '" class="inline-save">'
'<input type="hidden" name="csrf_token" value="' ~ session.get('csrf_token', '') ~ '">'
'<input name="hostname" value="' ~ server.hostname ~ '" placeholder="' ~ g.user.username ~ ' ' ~ server.name ~ '" maxlength="128">'
'<button type="submit">Save</button>'
'</form>') | safe, editable=True) }}
</div>
</section>
{# Inspection strip — Log / Console / Files #}
<section class="panel inspection-strip" data-tab-strip data-active-tab="log">
<div class="tab-bar">
<button type="button" role="tab" data-tab="log" aria-selected="true">Log</button>
<button type="button" role="tab" data-tab="console" aria-selected="false">Console</button>
<button type="button" role="tab" data-tab="files" aria-selected="false">Files</button>
<button type="button" class="strip-expand" aria-label="Expand active tab"></button>
</div>
<div role="tabpanel" data-tab="log" class="tab-pane">
<pre class="log-stream" data-sse-url="/servers/{{ server.id }}/logs/stream"></pre>
</div>
<div role="tabpanel" data-tab="console" class="tab-pane" hidden>
<div id="console-transcript-inline-{{ server.id }}" class="console-transcript" data-autoscroll>
{% for h in console_history %}
{% with command=h.command, reply=h.reply, is_error=h.is_error %}
{% include "_console_line.html" %}
{% endwith %}
{% endfor %}
</div>
<form hx-post="/servers/{{ server.id }}/console"
hx-target="#console-transcript-inline-{{ server.id }}"
hx-swap="beforeend"
hx-on::after-request="this.command.value=''; this.command.focus()"
class="console-input-form"
data-console-form data-server-id="{{ server.id }}">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<span class="console-prompt-glyph">&gt;</span>
<input name="command" autocomplete="off" spellcheck="false" maxlength="1000"
placeholder="status, changelevel c1m1_hotel, sm_kick …">
<button type="submit">Send</button>
</form>
</div>
<div role="tabpanel" data-tab="files" class="tab-pane" hidden>
{% if not file_tree_root_entries %}
<p class="muted">No files yet — start the server to mount its runtime.</p>
{% else %}
{% set entries = file_tree_root_entries %}
{% set truncated = file_tree_truncated %}
{% set truncated_count = file_tree_truncated_count %}
{% set files_base_url = "/servers/" ~ server.id %}
{% include "_overlay_file_tree.html" %}
{% endif %}
</div>
</section>
<div class="page-footer-actions">
<button type="button" class="danger-outline" data-inline-modal-open="delete-server-modal">Delete server</button>
<a href="#" class="link-button" data-inline-modal-open="rename-server-modal">Rename</a>
</div>
{# ===== Modals ===== #}
<dialog id="log-modal" class="modal" aria-labelledby="log-modal-title">
<div class="modal-header">
<h2 id="log-modal-title">Server log</h2>
<button type="button" class="modal-close" data-inline-modal-close aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<pre class="log-stream tall" data-sse-url="/servers/{{ server.id }}/logs/stream"></pre>
</div>
</dialog>
<dialog id="console-modal" class="modal" aria-labelledby="console-modal-title">
<div class="modal-header">
<h2 id="console-modal-title">Console</h2>
<button type="button" class="modal-close" data-inline-modal-close aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<div id="console-transcript-modal-{{ server.id }}" class="console-transcript tall" data-autoscroll>
{% for h in console_history %}
{% with command=h.command, reply=h.reply, is_error=h.is_error %}
{% include "_console_line.html" %}
{% endwith %}
{% endfor %}
</div>
<form hx-post="/servers/{{ server.id }}/console"
hx-target="#console-transcript-modal-{{ server.id }}"
hx-swap="beforeend"
hx-on::after-request="this.command.value=''; this.command.focus()"
class="console-input-form"
data-console-form data-server-id="{{ server.id }}">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<span class="console-prompt-glyph">&gt;</span>
<input name="command" autocomplete="off" spellcheck="false" maxlength="1000">
<button type="submit">Send</button>
</form>
</div>
</dialog>
<dialog id="files-modal" class="modal" aria-labelledby="files-modal-title">
<div class="modal-header">
<h2 id="files-modal-title">Files</h2>
<button type="button" class="modal-close" data-inline-modal-close aria-label="Close">&times;</button>
</div>
<div class="modal-body">
{% if file_tree_root_entries %}
{% set entries = file_tree_root_entries %}
{% set truncated = file_tree_truncated %}
{% set truncated_count = file_tree_truncated_count %}
{% set files_base_url = "/servers/" ~ server.id %}
{% include "_overlay_file_tree.html" %}
{% else %}
<p class="muted">No files yet — start the server to mount its runtime.</p>
{% endif %}
</div>
</dialog>
<dialog id="recent-players-modal" class="modal" aria-labelledby="recent-players-modal-title">
<div class="modal-header">
<h2 id="recent-players-modal-title">Recent players</h2>
<button type="button" class="modal-close" data-inline-modal-close aria-label="Close">&times;</button>
</div>
<div class="modal-body">
{# Re-render the full list via HTMX so the modal stays in sync with
polling without us duplicating the row markup here. #}
<div hx-get="/servers/{{ server.id }}/live-state?view=recent-modal"
hx-trigger="revealed"
hx-swap="innerHTML"></div>
</div>
</dialog>
<dialog id="job-log-modal" class="modal" aria-labelledby="job-log-modal-title">
<div class="modal-header">
<h2 id="job-log-modal-title">Job log</h2>
<button type="button" class="modal-close" data-inline-modal-close aria-label="Close">&times;</button>
</div>
<div class="modal-body">
{% if latest_job %}
<pre class="log-stream tall" data-sse-url="/jobs/{{ latest_job.id }}/stream"></pre>
<p><a href="/jobs/{{ latest_job.id }}">open full job →</a></p>
{% else %}
<p class="muted">No job has run for this server yet.</p>
{% endif %}
</div>
</dialog>
<dialog id="rename-server-modal" class="modal" aria-labelledby="rename-server-title">
<div class="modal-header">
<h2 id="rename-server-title">Rename server</h2>
<button type="button" class="modal-close" data-inline-modal-close aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<form method="post" action="/servers/{{ server.id }}" class="inline-save">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<input name="name" value="{{ server.name }}" required autofocus>
<button type="submit">Save</button>
</form>
</div>
</dialog>
<dialog id="reset-server-modal" class="modal" aria-labelledby="reset-server-title">
<div class="modal-header">
<h2 id="reset-server-title">Reset server "{{ server.name }}"?</h2>
<button type="button" class="modal-close" data-inline-modal-close aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<p>This stops the server and wipes its runtime state. The blueprint is preserved.</p>
</div>
<div class="modal-footer">
<button type="button" class="button-secondary" data-inline-modal-close>Cancel</button>
<form method="post" action="/servers/{{ server.id }}/reset" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button class="danger" type="submit">Reset</button>
</form>
</div>
</dialog>
<dialog id="delete-server-modal" class="modal" aria-labelledby="delete-server-title">
<div class="modal-header">
<h2 id="delete-server-title">Delete server "{{ server.name }}"?</h2>
<button type="button" class="modal-close" data-inline-modal-close aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<p>Stops the server and tears down its runtime. This cannot be undone.</p>
</div>
<div class="modal-footer">
<button type="button" class="button-secondary" data-inline-modal-close>Cancel</button>
<form method="post" action="/servers/{{ server.id }}/delete" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button class="danger" type="submit">Delete</button>
</form>
</div>
</dialog>
{% endblock %}
```
Note the `recent-players-modal` uses `?view=recent-modal` to ask the live-state route for the full list. If you don't want to add that branch right now, simplify the modal to render the unsliced `recent_players` list directly via a small inline include — but it would require passing `recent_players` (the full list, separate from the overview) into the page template, which currently lives only in the live-state fragment context. **Decision: keep the HTMX `revealed` approach for v1; it's cleaner and keeps page-template context lean.** Add the `view` branch in Task 8b.
- [ ] **Step 4: Run all relevant tests to verify integration**
```bash
cd l4d2web && pytest tests/test_servers.py tests/test_status_and_server_logs.py -v
```
Expected: PASS (including the integration test from Step 1)
- [ ] **Step 5: Commit**
```bash
git add l4d2web/l4d2web/templates/server_detail.html
git commit -m "feat(server-detail): state cluster + inspection strip + five modals"
```
---
### Task 8b: Live-state route — `?view=recent-modal` branch
**Files:**
- Modify: `l4d2web/l4d2web/routes/server_routes.py` (`live_state_fragment`)
- Create: `l4d2web/l4d2web/templates/_recent_players_modal_body.html`
The recent-players modal asks for a different render of the same data. One route, two views.
- [ ] **Step 1: Create the partial**
Create `l4d2web/l4d2web/templates/_recent_players_modal_body.html`:
```jinja
<ul class="player-grid recent recent-modal-list">
{% for row in recent_players %}
<li class="player-card recent-chip">
<a class="player-link"
href="https://steamcommunity.com/profiles/{{ row.steam_id_64 }}"
target="_blank" rel="noopener noreferrer">
{% if row.avatar_url %}
<img class="avatar" src="{{ row.avatar_url }}" alt="">
{% else %}
<span class="avatar placeholder" aria-hidden="true"></span>
{% endif %}
<span class="name" title="{{ row.persona_name or row.name_at_join }}">{{ row.persona_name or row.name_at_join }}</span>
</a>
<span class="meta">· {{ row.last_seen | timeago }}</span>
</li>
{% endfor %}
</ul>
```
- [ ] **Step 2: Branch in the route**
In `live_state_fragment` (last lines), wrap the `render_template` call:
```python
if request.args.get("view") == "recent-modal":
return render_template(
"_recent_players_modal_body.html",
recent_players=recent_rows,
)
return render_template(
"_live_state.html",
...
)
```
- [ ] **Step 3: Add `.recent-modal-list` CSS**
Append to `components.css`:
```css
.recent-modal-list {
grid-template-columns: 1fr !important;
max-height: 60vh;
overflow: auto;
}
```
- [ ] **Step 4: Smoke-verify**
Run `scripts/dev-server.py`. Visit a server detail page, click the `N Recent` header (only appears if seeded data has >10 recents). Modal opens, full list scrolls. Close → SSE/HTMX connection ends.
- [ ] **Step 5: Commit**
```bash
git add l4d2web/l4d2web/routes/server_routes.py \
l4d2web/l4d2web/templates/_recent_players_modal_body.html \
l4d2web/l4d2web/static/css/components.css
git commit -m "feat(live-state): ?view=recent-modal branch for the recent-players modal body"
```
---
### Task 9: Playwright e2e — tab switching + expand + job-log modal
**Files:**
- Modify: `l4d2web/tests/e2e/test_server_detail.py` (append tests)
- [ ] **Step 1: Write the failing tests**
Append to `l4d2web/tests/e2e/test_server_detail.py`:
```python
def test_tabs_switch_between_log_console_files(page: Page, server_with_files) -> None:
base = server_with_files["base_url"]
sid = server_with_files["server_id"]
login(page, base)
page.goto(f"{base}/servers/{sid}")
strip = page.locator("[data-tab-strip]")
expect(strip).to_be_visible()
# Default tab is log
expect(strip).to_have_attribute("data-active-tab", "log")
# Click Console
strip.locator('[role="tab"][data-tab="console"]').click()
expect(strip).to_have_attribute("data-active-tab", "console")
expect(strip.locator('[role="tabpanel"][data-tab="console"]')).to_be_visible()
expect(strip.locator('[role="tabpanel"][data-tab="log"]')).to_be_hidden()
# Click Files
strip.locator('[role="tab"][data-tab="files"]').click()
expect(strip).to_have_attribute("data-active-tab", "files")
expect(strip.locator('[role="tabpanel"][data-tab="files"]')).to_be_visible()
def test_expand_opens_matching_modal(page: Page, server_with_files) -> None:
base = server_with_files["base_url"]
sid = server_with_files["server_id"]
login(page, base)
page.goto(f"{base}/servers/{sid}")
strip = page.locator("[data-tab-strip]")
strip.locator('[role="tab"][data-tab="files"]').click()
strip.locator(".strip-expand").click()
# files-modal dialog now open
dialog = page.locator("dialog#files-modal")
expect(dialog).to_have_attribute("open", "")
```
- [ ] **Step 2: Run tests to verify they fail (initially) or pass (after wiring is done)**
```bash
cd l4d2web && pytest tests/e2e/test_server_detail.py -v -m e2e
```
Expected on first run after all prior tasks land: PASS. If they fail, the tab-strip or expand wiring needs a debug pass against the actual DOM.
- [ ] **Step 3: Commit**
```bash
git add l4d2web/tests/e2e/test_server_detail.py
git commit -m "test(e2e): tab switching + expand-to-modal on server detail"
```
---
## Final verification (manual)
After all tasks are merged, run the dev server (see `reference_left4me_dev_server.md`):
```bash
scripts/dev-server.py
```
In a browser:
1. State cluster visible above the fold on 1080p — badge, start/stop/reset, players/map, config grid all present without scrolling.
2. Inspection strip defaults to **Log** tab; lines stream live.
3. Click **Console** tab → transcript and input visible; type `status`, submit → reply appended.
4. Click **Files** tab → file tree visible and navigable.
5. Click ⛶ on each tab → matching modal opens with the larger view.
6. In `#console-modal`, send a command; the modal transcript updates. Close → inline console transcript still has its prior state (independent transcripts is acceptable for v1).
7. Player grids: ≤ 8 current in 4-col grid (no header); ≤ 10 recent chips in 5-col grid under `N Recent` header. With >10 recents, header is clickable and opens `#recent-players-modal`.
8. Start a server. The lifecycle subblock shows `starting since 2s ago` but **no inline streaming log** — the page does not shift as the job progresses. Click the job phrase → `#job-log-modal` opens and streams. Close → SSE disconnects.
9. Delete/Rename still trigger their respective modals.
---
## Spec coverage check
- ✅ State cluster (lifecycle / live state / config) — Tasks 5, 8
- ✅ Config grid + `config_field` macro — Tasks 1, 5, 8
- ✅ Inspection strip with tabs + expand — Tasks 5, 6, 7, 8
-`#log-modal`, `#console-modal`, `#files-modal` — Task 8
- ✅ Live-state route slice + `N Recent` header — Tasks 2, 3
-`#recent-players-modal` — Tasks 8, 8b
-`#job-log-modal` + removal of inline job-log pre — Tasks 4, 8
- ✅ Player chip ellipsis + narrow-viewport breakpoint — Task 5
- ✅ E2E coverage — Task 9