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>
1150 lines
42 KiB
Markdown
1150 lines
42 KiB
Markdown
# 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">></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**
|
||
|
||
```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
|