feat(server-detail): state cluster + inspection strip + five modals

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-17 21:18:38 +02:00
parent 808a59b2db
commit 11142c1d08
No known key found for this signature in database
2 changed files with 193 additions and 49 deletions

View file

@ -1,46 +1,70 @@
{% extends "base.html" %} {% extends "base.html" %}
{% import "_macros.html" as macros %}
{% block title %}Server {{ server.name }} | left4me{% endblock %} {% block title %}Server {{ server.name }} | left4me{% endblock %}
{% block content %} {% block content %}
<section class="panel"> <section class="panel state-cluster">
<div class="page-heading"> <div class="page-heading">
<h1>Server: {{ server.name }}</h1> <h1>Server: {{ server.name }}</h1>
</div> </div>
<dl class="server-info"> {# Lifecycle subblock — uses _server_actions.html which now opens job-log-modal on click #}
<div><dt>Port</dt><dd><a href="steam://run/550//+connect%20{{ connect_host }}:{{ server.port }}">{{ server.port }}</a></dd></div>
<div><dt>Blueprint</dt><dd>{% if blueprint %}<a href="/blueprints/{{ blueprint.id }}">{{ blueprint.name }}</a>{% endif %}</dd></div>
<div><dt>RCON Password</dt><dd><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></dd></div>
<div><dt>Hostname</dt>
<dd>
<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>
<span class="field-hint">Leave empty for auto: "{{ g.user.username }} {{ server.name }}"</span>
</form>
</dd>
</div>
</dl>
<h2 class="section-title">Actions</h2>
{% include "_server_actions.html" %} {% include "_server_actions.html" %}
<h2 class="section-title">Server Log</h2> {# Live state — HTMX-loaded into innerHTML #}
<pre class="log-stream" data-sse-url="/servers/{{ server.id }}/logs/stream"></pre> <section class="live-state"
<section class="panel live-state"
hx-get="/servers/{{ server.id }}/live-state" hx-get="/servers/{{ server.id }}/live-state"
hx-trigger="load, every 5s" hx-trigger="load, every 5s"
hx-swap="innerHTML"> hx-swap="innerHTML"></section>
</section>
<h2 class="section-title">Console</h2> {# Config grid — flat auto-fit; uses config_field macro from _macros.html #}
<section class="panel console-panel"> <div class="config-grid">
<div id="console-transcript-{{ server.id }}" {{ macros.config_field(
class="console-transcript" "Port",
data-autoscroll> ('<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 with expand-to-modal #}
<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 %} {% for h in console_history %}
{% with command=h.command, reply=h.reply, is_error=h.is_error %} {% with command=h.command, reply=h.reply, is_error=h.is_error %}
{% include "_console_line.html" %} {% include "_console_line.html" %}
@ -48,27 +72,20 @@
{% endfor %} {% endfor %}
</div> </div>
<form hx-post="/servers/{{ server.id }}/console" <form hx-post="/servers/{{ server.id }}/console"
hx-target="#console-transcript-{{ server.id }}" hx-target="#console-transcript-inline-{{ server.id }}"
hx-swap="beforeend" hx-swap="beforeend"
hx-indicator=".console-spinner" hx-on::after-request="this.command.value=''; this.command.focus()"
hx-on::after-request="this.command.value=''; this.command.focus(); this.closest('section').querySelector('[data-autoscroll]').scrollTop = 1e9"
class="console-input-form" class="console-input-form"
data-console-form data-server-id="{{ server.id }}"> data-console-form data-server-id="{{ server.id }}">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}"> <input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<span class="console-prompt-glyph">&gt;</span> <span class="console-prompt-glyph">&gt;</span>
<input name="command" autocomplete="off" spellcheck="false" maxlength="1000" <input name="command" autocomplete="off" spellcheck="false" maxlength="1000"
placeholder="status, changelevel c1m1_hotel, sm_kick …"> placeholder="status, changelevel c1m1_hotel, sm_kick …">
<span class="console-spinner" aria-hidden="true"></span>
<button type="submit">Send</button> <button type="submit">Send</button>
</form> </form>
<div class="console-color-legend" aria-label="Autocomplete color legend">
<span class="console-color-legend-swatch swatch-cvar">cvar</span>
<span class="console-color-legend-swatch swatch-command">command</span>
<span class="console-color-legend-swatch swatch-sourcemod">sm_* (SourceMod — only if the plugin is loaded)</span>
</div> </div>
</section>
<h2 class="section-title">Files</h2> <div role="tabpanel" data-tab="files" class="tab-pane" hidden>
{% if not file_tree_root_entries %} {% if not file_tree_root_entries %}
<p class="muted">No files yet — start the server to mount its runtime.</p> <p class="muted">No files yet — start the server to mount its runtime.</p>
{% else %} {% else %}
@ -78,6 +95,7 @@
{% set files_base_url = "/servers/" ~ server.id %} {% set files_base_url = "/servers/" ~ server.id %}
{% include "_overlay_file_tree.html" %} {% include "_overlay_file_tree.html" %}
{% endif %} {% endif %}
</div>
</section> </section>
<div class="page-footer-actions"> <div class="page-footer-actions">
@ -85,6 +103,91 @@
<a href="#" class="link-button" data-inline-modal-open="rename-server-modal">Rename</a> <a href="#" class="link-button" data-inline-modal-open="rename-server-modal">Rename</a>
</div> </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-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"
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"> <dialog id="rename-server-modal" class="modal" aria-labelledby="rename-server-title">
<div class="modal-header"> <div class="modal-header">
<h2 id="rename-server-title">Rename server</h2> <h2 id="rename-server-title">Rename server</h2>

View file

@ -750,3 +750,44 @@ def test_update_server_clears_hostname(user_client_with_blueprints) -> None:
server = session.scalar(select(Server).where(Server.name == "alpha")) server = session.scalar(select(Server).where(Server.name == "alpha"))
assert server is not None assert server is not None
assert server.hostname == "" assert server.hostname == ""
def test_server_detail_renders_state_cluster_and_inspection_strip(user_client_with_blueprints) -> None:
from sqlalchemy import select
from l4d2web.models import Server
client, data = user_client_with_blueprints
with session_scope() as db:
server = Server(
user_id=data["user_id"],
blueprint_id=data["blueprint_id"],
name="redesign",
port=27098,
rcon_password="x",
actual_state="stopped",
)
db.add(server)
db.flush()
server_id = server.id
res = client.get(f"/servers/{server_id}")
assert res.status_code == 200
html = res.get_data(as_text=True)
# State-cluster panel (CSS class order may vary)
assert 'class="panel state-cluster"' in html or 'class="state-cluster panel"' in html
# Inspection strip
assert "data-tab-strip" in html
assert 'data-tab="log"' in html
assert 'data-tab="console"' in html
assert 'data-tab="files"' in html
# Five new modals
assert 'id="log-modal"' in html
assert 'id="console-modal"' in html
assert 'id="files-modal"' in html
assert 'id="recent-players-modal"' in html
assert 'id="job-log-modal"' in html