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

42 KiB
Raw Blame History

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 (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:

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"

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:

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.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
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">&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
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-list CSS

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:

  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