diff --git a/l4d2web/static/css/components.css b/l4d2web/static/css/components.css index 2f70949..dbac026 100644 --- a/l4d2web/static/css/components.css +++ b/l4d2web/static/css/components.css @@ -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; +} diff --git a/l4d2web/static/js/console-history.js b/l4d2web/static/js/console-history.js new file mode 100644 index 0000000..34015e0 --- /dev/null +++ b/l4d2web/static/js/console-history.js @@ -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); +}); diff --git a/l4d2web/templates/_console_line.html b/l4d2web/templates/_console_line.html index c341b5f..8ee760e 100644 --- a/l4d2web/templates/_console_line.html +++ b/l4d2web/templates/_console_line.html @@ -1,2 +1,8 @@ -> {{ command }} -{{ reply }}{% if is_error %}[ERROR]{% endif %} +
{{ reply }}
+ {% else %}
+