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:
parent
2d28d9f800
commit
70b80d4ceb
7 changed files with 61 additions and 18 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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":
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">×</button>
|
<button type="button" class="modal-close" data-inline-modal-close aria-label="Close">×</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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue