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>
42 KiB
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:
{# 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
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(therecent_rowsquery andrender_templatecall insidelive_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:
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
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:
).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
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
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:
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:
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
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:
{# 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
cd l4d2web && pytest tests/test_status_and_server_logs.py -k "live_state_fragment" -v
Expected: PASS
- Step 5: Commit
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.pyortest_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:
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
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:
{% 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
cd l4d2web && pytest tests/test_servers.py::test_server_detail_no_inline_job_log_pre -v
Expected: PASS
- Step 5: Commit
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:
/* ============================================================
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:
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
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
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
// 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
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
grep -n "static/js" l4d2web/l4d2web/templates/base.html
- Step 2: Add
tabs.jsnext 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
scripts/dev-server.py
Open browser devtools → Network → reload page → check tabs.js returns 200.
- Step 4: Commit
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:
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
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:
{% 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">></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">×</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">×</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">></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">×</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">×</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">×</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">×</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">×</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">×</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
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
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:
<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:
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-listCSS
Append to components.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
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:
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)
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
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):
scripts/dev-server.py
In a browser:
- State cluster visible above the fold on 1080p — badge, start/stop/reset, players/map, config grid all present without scrolling.
- Inspection strip defaults to Log tab; lines stream live.
- Click Console tab → transcript and input visible; type
status, submit → reply appended. - Click Files tab → file tree visible and navigable.
- Click ⛶ on each tab → matching modal opens with the larger view.
- 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). - Player grids: ≤ 8 current in 4-col grid (no header); ≤ 10 recent chips in 5-col grid under
N Recentheader. With >10 recents, header is clickable and opens#recent-players-modal. - Start a server. The lifecycle subblock shows
starting since 2s agobut no inline streaming log — the page does not shift as the job progresses. Click the job phrase →#job-log-modalopens and streams. Close → SSE disconnects. - Delete/Rename still trigger their respective modals.
Spec coverage check
- ✅ State cluster (lifecycle / live state / config) — Tasks 5, 8
- ✅ Config grid +
config_fieldmacro — 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 Recentheader — 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