feat(server-detail): state cluster + inspection strip + five modals
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
808a59b2db
commit
11142c1d08
2 changed files with 193 additions and 49 deletions
|
|
@ -1,46 +1,70 @@
|
|||
{% extends "base.html" %}
|
||||
{% import "_macros.html" as macros %}
|
||||
|
||||
{% block title %}Server {{ server.name }} | left4me{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="panel">
|
||||
<section class="panel state-cluster">
|
||||
<div class="page-heading">
|
||||
<h1>Server: {{ server.name }}</h1>
|
||||
</div>
|
||||
|
||||
<dl class="server-info">
|
||||
<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>
|
||||
{# Lifecycle subblock — uses _server_actions.html which now opens job-log-modal on click #}
|
||||
{% include "_server_actions.html" %}
|
||||
|
||||
<h2 class="section-title">Server Log</h2>
|
||||
<pre class="log-stream" data-sse-url="/servers/{{ server.id }}/logs/stream"></pre>
|
||||
|
||||
<section class="panel live-state"
|
||||
{# Live state — HTMX-loaded into innerHTML #}
|
||||
<section class="live-state"
|
||||
hx-get="/servers/{{ server.id }}/live-state"
|
||||
hx-trigger="load, every 5s"
|
||||
hx-swap="innerHTML">
|
||||
hx-swap="innerHTML"></section>
|
||||
|
||||
{# Config grid — flat auto-fit; uses config_field macro from _macros.html #}
|
||||
<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>
|
||||
|
||||
<h2 class="section-title">Console</h2>
|
||||
<section class="panel console-panel">
|
||||
<div id="console-transcript-{{ server.id }}"
|
||||
class="console-transcript"
|
||||
data-autoscroll>
|
||||
{# 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 %}
|
||||
{% with command=h.command, reply=h.reply, is_error=h.is_error %}
|
||||
{% include "_console_line.html" %}
|
||||
|
|
@ -48,27 +72,20 @@
|
|||
{% endfor %}
|
||||
</div>
|
||||
<form hx-post="/servers/{{ server.id }}/console"
|
||||
hx-target="#console-transcript-{{ server.id }}"
|
||||
hx-target="#console-transcript-inline-{{ server.id }}"
|
||||
hx-swap="beforeend"
|
||||
hx-indicator=".console-spinner"
|
||||
hx-on::after-request="this.command.value=''; this.command.focus(); this.closest('section').querySelector('[data-autoscroll]').scrollTop = 1e9"
|
||||
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 …">
|
||||
<span class="console-spinner" aria-hidden="true">…</span>
|
||||
<button type="submit">Send</button>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
<h2 class="section-title">Files</h2>
|
||||
<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 %}
|
||||
|
|
@ -78,6 +95,7 @@
|
|||
{% set files_base_url = "/servers/" ~ server.id %}
|
||||
{% include "_overlay_file_tree.html" %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="page-footer-actions">
|
||||
|
|
@ -85,6 +103,91 @@
|
|||
<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-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">×</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>
|
||||
|
|
|
|||
|
|
@ -750,3 +750,44 @@ def test_update_server_clears_hostname(user_client_with_blueprints) -> None:
|
|||
server = session.scalar(select(Server).where(Server.name == "alpha"))
|
||||
assert server is not None
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue