fix(server-detail): tall modal heights, true recent count, re-fetch on reopen, drop dead macro + arg

- Fix 1: add .modal .log-stream.tall / .console-transcript.tall → max-height 60vh so
  log and console modals render taller than the compact inline tab
- Fix 2: replace len(recent_rows) with a select(func.count(func.distinct(...))) so
  recent_players_total_count reflects all matching players, not the .limit(50) cap;
  add test_live_state_total_count_reflects_truth_above_limit (60 sessions → "60 Recent")
- Fix 3: dispatch custom modal:opened event after showModal() in both openInline and
  fetchAndShowRouted; switch recent-players-modal hx-trigger from "revealed" to
  "modal:opened from:closest dialog" so HTMX re-fetches on every open, not just first.
  Manual smoke-test not performed — relies on JS event dispatch + test suite; no JS
  test framework in repo.
- Fix 4: remove dead config_field macro (value-form, never called; config_field_block
  is the one actually used)
- Fix 5: drop unused editable parameter from config_field_block macro definition and
  the editable=True call on the Hostname field

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-17 21:40:20 +02:00
parent 2d28d9f800
commit 70b80d4ceb
No known key found for this signature in database
7 changed files with 61 additions and 18 deletions

View file

@ -236,7 +236,7 @@ chrome, only the body content differs.
| `l4d2web/l4d2web/static/css/components.css` | `.state-cluster`, `.inspection-strip`, player-grid/chip, tabbar | | `l4d2web/l4d2web/static/css/components.css` | `.state-cluster`, `.inspection-strip`, player-grid/chip, tabbar |
| `l4d2web/l4d2web/static/js/tabs.js` (new) | Tab activation + expand-to-modal handler | | `l4d2web/l4d2web/static/js/tabs.js` (new) | Tab activation + expand-to-modal handler |
| `l4d2web/l4d2web/templates/base.html` | Include `tabs.js` if scripts are listed centrally | | `l4d2web/l4d2web/templates/base.html` | Include `tabs.js` if scripts are listed centrally |
| `l4d2web/l4d2web/routes/page_routes.py` (`server_detail`) | Add `recent_players_overview` (sliced to 10) + total count | | `l4d2web/l4d2web/routes/server_routes.py` (`live_state_fragment`) | Add `recent_players_overview` (sliced to 10) + total count |
## Reused, do not modify ## Reused, do not modify

View file

@ -263,7 +263,15 @@ def live_state_fragment(server_id: int) -> Response:
.limit(50) .limit(50)
).all() ).all()
recent_total = len(recent_rows) recent_total = db.scalar(
select(func.count(func.distinct(ServerPlayerSession.steam_id_64)))
.where(
ServerPlayerSession.server_id == server.id,
ServerPlayerSession.left_at.is_not(None),
ServerPlayerSession.left_at >= recent_cutoff,
~ServerPlayerSession.steam_id_64.in_(current_ids) if current_ids else True,
)
)
recent_overview = recent_rows[:10] recent_overview = recent_rows[:10]
if request.args.get("view") == "recent-modal": if request.args.get("view") == "recent-modal":

View file

@ -1131,3 +1131,12 @@ div.modal.modal-wide {
max-height: 60vh; max-height: 60vh;
overflow: auto; overflow: auto;
} }
/* Modal-specific overrides the log/console modals are meant to give
the user *more* room than the inline tab. The .tall modifier opts
into that extra height when the same element is rendered inside a
.modal. */
.modal .log-stream.tall,
.modal .console-transcript.tall {
max-height: 60vh;
}

View file

@ -32,6 +32,7 @@
const dialog = typeof idOrEl === "string" ? document.getElementById(idOrEl) : idOrEl; const dialog = typeof idOrEl === "string" ? document.getElementById(idOrEl) : idOrEl;
if (dialog && typeof dialog.showModal === "function" && !dialog.open) { if (dialog && typeof dialog.showModal === "function" && !dialog.open) {
dialog.showModal(); dialog.showModal();
dialog.dispatchEvent(new CustomEvent("modal:opened", { bubbles: true }));
} }
} }
@ -70,7 +71,10 @@
// this swap; the newer click will win. // this swap; the newer click will win.
if (currentRoutedPath !== path) return; if (currentRoutedPath !== path) return;
const dlg = document.getElementById("modal-container"); const dlg = document.getElementById("modal-container");
if (dlg && !dlg.open) dlg.showModal(); if (dlg && !dlg.open) {
dlg.showModal();
dlg.dispatchEvent(new CustomEvent("modal:opened", { bubbles: true }));
}
}).catch((err) => { }).catch((err) => {
console.error("[modals] routed fetch failed", err); console.error("[modals] routed fetch failed", err);
}); });

View file

@ -1,17 +1,6 @@
{# 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 %}
{# Block form of config_field: lets the caller write the value as a Jinja {# Block form of config_field: lets the caller write the value as a Jinja
template body, which keeps auto-escaping active for interpolated values. #} template body, which keeps auto-escaping active for interpolated values. #}
{% macro config_field_block(label, editable=False) %} {% macro config_field_block(label) %}
<div class="config-field"> <div class="config-field">
<div class="config-field-label">{{ label }}</div> <div class="config-field-label">{{ label }}</div>
<div class="config-field-value">{{ caller() }}</div> <div class="config-field-value">{{ caller() }}</div>

View file

@ -38,7 +38,7 @@
<button class="link-button" data-password-toggle="{{ server.id }}" aria-label="Show RCON password">show</button> <button class="link-button" data-password-toggle="{{ server.id }}" aria-label="Show RCON password">show</button>
{% endcall %} {% endcall %}
{% call macros.config_field_block("Hostname", editable=True) %} {% call macros.config_field_block("Hostname") %}
<form method="post" action="/servers/{{ server.id }}" class="inline-save"> <form method="post" action="/servers/{{ server.id }}" class="inline-save">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}"> <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"> <input name="hostname" value="{{ server.hostname }}" placeholder="{{ g.user.username }} {{ server.name }}" maxlength="128">
@ -143,9 +143,8 @@
<button type="button" class="modal-close" data-inline-modal-close aria-label="Close">&times;</button> <button type="button" class="modal-close" data-inline-modal-close aria-label="Close">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
{# Re-renders via HTMX when modal first becomes visible (Task 8b adds the ?view=recent-modal branch). #}
<div hx-get="/servers/{{ server.id }}/live-state?view=recent-modal" <div hx-get="/servers/{{ server.id }}/live-state?view=recent-modal"
hx-trigger="revealed" hx-trigger="modal:opened from:closest dialog"
hx-swap="innerHTML"></div> hx-swap="innerHTML"></div>
</div> </div>
</dialog> </dialog>

View file

@ -571,6 +571,40 @@ def test_live_state_fragment_renders_current_and_recent(user_client_with_bluepri
assert "steamcommunity.com/profiles/76561198021234567" in html assert "steamcommunity.com/profiles/76561198021234567" in html
def test_live_state_total_count_reflects_truth_above_limit(user_client_with_blueprints) -> None:
"""recent_players_total_count must reflect all matching distinct steam IDs,
not just the 50 returned by the capped query."""
from datetime import timedelta
from l4d2web.models import Server, ServerPlayerSession
client, data = user_client_with_blueprints
now = datetime.now(UTC)
with session_scope() as db:
srv = Server(
user_id=data["user_id"], blueprint_id=data["blueprint_id"],
name="bigsrv", port=27820, rcon_password="x", actual_state="running",
)
db.add(srv); db.flush()
srv_id = srv.id
for i in range(60):
steam_id = f"7656119800000{i:04d}"
db.add(ServerPlayerSession(
server_id=srv_id,
steam_id_64=steam_id,
joined_at=now - timedelta(hours=i + 2),
left_at=now - timedelta(hours=i + 1),
name_at_join=f"Player{i}",
min_ping=10,
max_ping=50,
))
res = client.get(f"/servers/{srv_id}/live-state")
assert res.status_code == 200
html = res.get_data(as_text=True)
assert "60 Recent" in html, "expected true count (60), not capped count (50)"
assert "50 Recent" not in html
def test_reset_operation_enqueues_job_and_stops_desired_state(user_client_with_blueprints) -> None: def test_reset_operation_enqueues_job_and_stops_desired_state(user_client_with_blueprints) -> None:
from sqlalchemy import select from sqlalchemy import select