feat(l4d2-web): console panel UI on server detail page
- _console_line.html: command + reply, error variant, "(no reply)" placeholder. - server_detail.html: console section between Live State and Files, replays last 50 history rows server-side; HTMX form appends new lines via hx-swap. - console-history.js: ArrowUp/Down recall against /console/history JSON; scroll-to-bottom on load and after each new line. - CSS: fixed-height scrolling transcript, terminal-ish styling, spinner via HTMX in-flight class. - test_console_routes.py: update 4 assertions from legacy [ERROR] literal to console-error CSS class (matches new semantic markup). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ecc4aa28c6
commit
6f49efd44a
6 changed files with 281 additions and 6 deletions
|
|
@ -912,3 +912,82 @@ dialog.modal.modal-wide {
|
|||
.live-state .server-live-summary {
|
||||
font-size: 1.05em;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
RCON console panel — server detail page
|
||||
============================================================ */
|
||||
|
||||
.console-transcript {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
background: var(--color-log-bg);
|
||||
color: var(--color-log-text);
|
||||
border-radius: var(--radius-s);
|
||||
padding: var(--space-m);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
margin-bottom: var(--space-m);
|
||||
}
|
||||
|
||||
.console-line {
|
||||
margin: var(--space-s) 0;
|
||||
}
|
||||
|
||||
.console-line:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.console-prompt {
|
||||
font-weight: 600;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.console-reply {
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
white-space: pre-wrap;
|
||||
color: var(--color-muted);
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.console-error .console-prompt {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.console-error {
|
||||
border-left: 3px solid var(--color-danger);
|
||||
padding-left: var(--space-s);
|
||||
}
|
||||
|
||||
.console-input-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-s);
|
||||
}
|
||||
|
||||
.console-prompt-glyph {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-weight: 600;
|
||||
color: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.console-input-form input[name="command"] {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.console-spinner {
|
||||
display: none;
|
||||
color: var(--color-muted);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.console-input-form.htmx-request .console-spinner {
|
||||
display: inline;
|
||||
}
|
||||
|
|
|
|||
162
l4d2web/static/js/console-history.js
Normal file
162
l4d2web/static/js/console-history.js
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
// console-history.js
|
||||
// Binds ArrowUp/Down history recall to [data-console-form] elements.
|
||||
// Mirrors the style of sse.js: vanilla JS, dataset attributes, no framework.
|
||||
|
||||
function bindConsoleForm(form) {
|
||||
if (form.dataset.consoleHistoryBound === "true") {
|
||||
return;
|
||||
}
|
||||
form.dataset.consoleHistoryBound = "true";
|
||||
|
||||
const serverId = form.dataset.serverId;
|
||||
const input = form.querySelector("input[name='command']");
|
||||
if (!input || !serverId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// --- History cache state ---
|
||||
// Entries are stored newest-first to match the API response shape.
|
||||
let cache = [];
|
||||
let cacheLoaded = false;
|
||||
let cursor = -1; // -1 = "not in history" (at the live input)
|
||||
let snapshot = ""; // saved input value for restoring via ArrowDown
|
||||
let oldestId = null; // id of the oldest cached entry, used for pagination
|
||||
let exhausted = false; // true when we've fetched all available history
|
||||
|
||||
async function loadHistory(params) {
|
||||
const url = new URL(`/servers/${serverId}/console/history`, location.origin);
|
||||
url.searchParams.set("limit", "50");
|
||||
if (params && params.before != null) {
|
||||
url.searchParams.set("before", params.before);
|
||||
}
|
||||
try {
|
||||
const res = await fetch(url.toString());
|
||||
if (!res.ok) return [];
|
||||
return await res.json();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureLoaded() {
|
||||
if (cacheLoaded) return;
|
||||
cacheLoaded = true;
|
||||
const entries = await loadHistory();
|
||||
cache = entries; // newest-first from API
|
||||
if (cache.length > 0) {
|
||||
oldestId = cache[cache.length - 1].id;
|
||||
}
|
||||
if (entries.length < 50) {
|
||||
exhausted = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchOlderPage() {
|
||||
if (exhausted || oldestId == null) return;
|
||||
const entries = await loadHistory({ before: oldestId });
|
||||
if (entries.length === 0) {
|
||||
exhausted = true;
|
||||
return;
|
||||
}
|
||||
cache = cache.concat(entries); // append older entries at the end
|
||||
oldestId = cache[cache.length - 1].id;
|
||||
if (entries.length < 50) {
|
||||
exhausted = true;
|
||||
}
|
||||
}
|
||||
|
||||
input.addEventListener("focus", () => {
|
||||
ensureLoaded();
|
||||
}, { once: true });
|
||||
|
||||
input.addEventListener("keydown", async (event) => {
|
||||
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
|
||||
// Ensure we have history before navigating.
|
||||
await ensureLoaded();
|
||||
|
||||
if (event.key === "ArrowUp") {
|
||||
if (cursor === -1) {
|
||||
// Entering history from live input — save whatever is typed.
|
||||
snapshot = input.value;
|
||||
}
|
||||
|
||||
const nextCursor = cursor + 1;
|
||||
|
||||
// If we've reached the end of cache, try fetching an older page.
|
||||
if (nextCursor >= cache.length) {
|
||||
await fetchOlderPage();
|
||||
}
|
||||
|
||||
if (nextCursor < cache.length) {
|
||||
cursor = nextCursor;
|
||||
input.value = cache[cursor].command;
|
||||
}
|
||||
// else: already at oldest end, stay put.
|
||||
|
||||
} else {
|
||||
// ArrowDown
|
||||
if (cursor <= -1) {
|
||||
// Already at live input, nothing to do.
|
||||
return;
|
||||
}
|
||||
|
||||
const nextCursor = cursor - 1;
|
||||
if (nextCursor < 0) {
|
||||
// Return to live input, restore snapshot.
|
||||
cursor = -1;
|
||||
input.value = snapshot;
|
||||
} else {
|
||||
cursor = nextCursor;
|
||||
input.value = cache[cursor].command;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// After a successful HTMX POST: prepend the sent command to cache.
|
||||
form.addEventListener("htmx:afterRequest", (event) => {
|
||||
if (!event.detail.successful) return;
|
||||
const params = event.detail.requestConfig && event.detail.requestConfig.parameters;
|
||||
const command = (params && params.command) || snapshot || "";
|
||||
if (!command) return;
|
||||
// Prepend as the newest entry (id=null is a placeholder).
|
||||
cache.unshift({ id: null, command });
|
||||
// Reset cursor so ArrowUp immediately recalls this command.
|
||||
cursor = -1;
|
||||
snapshot = "";
|
||||
});
|
||||
}
|
||||
|
||||
function bindAllConsoleForms(root) {
|
||||
if (!root) return;
|
||||
const scope = root.matches && root.matches("[data-console-form]") ? [root] : [];
|
||||
if (root.querySelectorAll) {
|
||||
root.querySelectorAll("[data-console-form]").forEach((el) => scope.push(el));
|
||||
}
|
||||
scope.forEach(bindConsoleForm);
|
||||
}
|
||||
|
||||
function scrollConsolesToBottom(root) {
|
||||
if (!root) return;
|
||||
const scope = root.matches && root.matches("[data-autoscroll]") ? [root] : [];
|
||||
if (root.querySelectorAll) {
|
||||
root.querySelectorAll("[data-autoscroll]").forEach((el) => scope.push(el));
|
||||
}
|
||||
scope.forEach((el) => {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
scrollConsolesToBottom(document);
|
||||
bindAllConsoleForms(document);
|
||||
});
|
||||
|
||||
// Support HTMX-injected content (mirrors sse.js pattern).
|
||||
document.addEventListener("htmx:load", (event) => {
|
||||
scrollConsolesToBottom(event.detail.elt);
|
||||
bindAllConsoleForms(event.detail.elt);
|
||||
});
|
||||
|
|
@ -1,2 +1,8 @@
|
|||
> {{ command }}
|
||||
{{ reply }}{% if is_error %}[ERROR]{% endif %}
|
||||
<div class="console-line{% if is_error %} console-error{% endif %}">
|
||||
<div class="console-prompt">> {{ command }}</div>
|
||||
{% if reply %}
|
||||
<pre class="console-reply">{{ reply }}</pre>
|
||||
{% else %}
|
||||
<div class="console-reply muted">(no reply)</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -42,5 +42,6 @@
|
|||
<script src="{{ url_for('static', filename='js/modal.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/file-tree.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/password-reveal.js') }}"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/console-history.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -36,6 +36,33 @@
|
|||
hx-swap="innerHTML">
|
||||
</section>
|
||||
|
||||
<h2 class="section-title">Console</h2>
|
||||
<section class="panel console-panel">
|
||||
<div id="console-transcript-{{ 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-{{ 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"
|
||||
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>
|
||||
</section>
|
||||
|
||||
<h2 class="section-title">Files</h2>
|
||||
{% if not file_tree_root_entries %}
|
||||
<p class="muted">No files yet — start the server to mount its runtime.</p>
|
||||
|
|
|
|||
|
|
@ -206,7 +206,7 @@ def test_rcon_error_inserts_error_row(tmp_path, monkeypatch):
|
|||
|
||||
assert resp.status_code == 200
|
||||
assert "connection refused" in resp.get_data(as_text=True)
|
||||
assert "[ERROR]" in resp.get_data(as_text=True)
|
||||
assert "console-error" in resp.get_data(as_text=True)
|
||||
|
||||
with session_scope() as s:
|
||||
row = s.query(CommandHistory).one()
|
||||
|
|
@ -233,7 +233,7 @@ def test_rcon_auth_error_inserts_error_row(tmp_path, monkeypatch):
|
|||
|
||||
assert resp.status_code == 200
|
||||
assert "bad rcon password" in resp.get_data(as_text=True)
|
||||
assert "[ERROR]" in resp.get_data(as_text=True)
|
||||
assert "console-error" in resp.get_data(as_text=True)
|
||||
|
||||
with session_scope() as s:
|
||||
row = s.query(CommandHistory).one()
|
||||
|
|
@ -261,7 +261,7 @@ def test_empty_command_no_history_row(tmp_path, monkeypatch):
|
|||
resp = _post_command(client, data["server_id"], "")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert "[ERROR]" in resp.get_data(as_text=True)
|
||||
assert "console-error" in resp.get_data(as_text=True)
|
||||
assert _history_count() == 0
|
||||
|
||||
|
||||
|
|
@ -283,7 +283,7 @@ def test_oversized_command_no_history_row(tmp_path, monkeypatch):
|
|||
resp = _post_command(client, data["server_id"], "x" * 1001)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert "[ERROR]" in resp.get_data(as_text=True)
|
||||
assert "console-error" in resp.get_data(as_text=True)
|
||||
assert _history_count() == 0
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue