- 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>
16 KiB
Server detail page redesign
Context
server_detail.html today renders six tall sections stacked vertically: server-info (config),
actions, server log (SSE), live state (HTMX poll), console (transcript + input), and files
(inline tree). The page reads as a chronological dump of every available subsystem rather
than a focused control surface. Three of those sections — config, actions, live state —
are conceptually one "what is this server right now" cluster but are visually disconnected.
Log, console, and files are all inspection/debugging surfaces but compete for vertical
real estate and force long scrolls.
The redesign groups state at the top, demotes inspection surfaces into a single tabbed strip at the bottom, and reuses the existing modal infrastructure to host the full versions of each surface on demand.
Goals
- Make "is the server running, who's on it, what map?" visible without scrolling.
- Treat Log, Console, and Files as peer inspection surfaces.
- Keep the page short enough that Delete/Rename remain reachable without scrolling on a typical desktop viewport.
- Minimize new components: reuse
_overlay_file_tree.html,_console_line.html, and the existing<dialog>+modals.jspattern._server_actions.htmland_live_state.htmlget targeted edits (described below) rather than rewrites.
Non-goals: changing the server lifecycle, routes, or partial contracts. This is a template-level refactor with small JS/CSS additions.
New page structure
┌─ Heading: Server <name> ─────────────────────────────┐
│ │
│ ┌─ State cluster (single panel) ────────────────────┐ │
│ │ [● Running] Start Stop Reset │ │
│ │ ──────────────────────────────────────────────── │ │
│ │ Players 4/8 Map c1m1_hotel Hibernating: no │ │
│ │ (player avatars row from _live_state.html) │ │
│ │ ──────────────────────────────────────────────── │ │
│ │ Port Blueprint │ │
│ │ 27015 survivor-mix │ │
│ │ RCON Hostname │ │
│ │ ••• (show) [my-server____] │ │
│ └───────────────────────────────────────────────────┘ │
│ │
│ ┌─ Inspection strip (tabs) ─────────────────────────┐ │
│ │ [ Log ] [ Console ] [ Files ] [ ⛶ ] │ │
│ │ ──────────────────────────────────────────────── │ │
│ │ (active tab pane, fixed-height scrollable) │ │
│ └───────────────────────────────────────────────────┘ │
│ │
│ [Delete server] Rename │
└───────────────────────────────────────────────────────┘
State cluster
One <section class="panel state-cluster"> replaces today's three separate sections.
Three sub-blocks separated by horizontal rules:
-
Lifecycle — state badge, start/stop/reset, drift warning, and a one-line "latest job" indicator. The inline job-log SSE stream that currently appears while a job is running (the
<pre class="log-stream job-log">block at the bottom of_server_actions.html) is removed from this subblock entirely — it pops in and out as HTMX re-polls, shifting the rest of the page each time. Users who want the live job log click the latest-job link to open#job-log-modal(see Modals below). Polling is preserved. The HTMX 2s poll on#server-actionswhile a job is running stays in place — it's what keeps the state badge, start/stop/reset buttons, and the "starting since 4s ago" phrase current. The only thing the periodic swap no longer carries is the<pre>SSE element. Net result: the subblock keeps reflecting reality every 2s but its height stops changing, so the rest of the page doesn't jump. -
Live state — player count, map, hibernation, and compact player cards. Still HTMX-polled every 5s.
Current players grid. Fixed 4-column grid (
grid-template-columns: repeat(4, 1fr)), one row per player, max 2 rows (L4D2 caps at 8). No section header — the player cards follow the summary line directly; the avatars + ping meta make the section self-identifying. Each card is a single horizontal item: 22px avatar + two-line text block (name on top, terse meta below). Meta drops prefix words —joined 4m ago · ping 22-31msbecomes4m · 22-31ms. The unitm/s/h/dplus themssuffix carry the meaning.Recent players chips. Fixed 5-column grid (
grid-template-columns: repeat(5, 1fr)), capped to 10 entries inline. Each chip is a single line: 16px avatar + name +· Xd. The "last seen" prefix is dropped — the trailing time unit is unambiguous. Long names gettext-overflow: ellipsisand atitleattribute holding the full name.Header doubles as the modal trigger. The recent section's header is
N Recent(count first, e.g.13 Recent). WhenN > 10it renders as a link/button that opens#recent-players-modal; whenN ≤ 10it renders as plain text (the modal would be redundant). No separate "view all" link. The route exposesrecent_players_overview(sliced to 10) andrecent_players_total_countfor this header.Narrow-viewport rule. Below ~600px viewport width, both grids drop to
repeat(auto-fit, minmax(110px, 1fr))so chips don't crush. One media query, applied to both grids.#recent-players-modal. New modal alongside log/console/files. Body renders the same chip format in a single column, scrollable, full list. Driven byrecent_players(the full unsliced list already in context). Header:Recent players (N). No new route — same context. -
Config — port, blueprint, RCON, hostname. Rendered into a flat auto-fit CSS grid (
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr))) so the sub-block wraps to one or two columns based on available width, and accepts new fields with no layout decisions. Each field is one cell: small uppercase label above the value/control. Hostname keeps its existing inline edit form; other fields render as static text.Field macro. Define
{% macro config_field(label, value, editable=false) %}in_macros.html(create if absent) and call it once per row. Adding a future field becomes a one-line change in the template — no new CSS, no new markup patterns to learn.Expandability path (not in v1). When the field count crosses ~6, or when a natural category emerges (e.g. a second auth credential beyond RCON), promote that subset into a
<fieldset><legend>…</legend>…</fieldset>inside the same outer grid. The grid scaffold is unchanged; this is a CSS-and-grouping migration, not a rewrite. Document this trigger here so future-us doesn't redesign blindly.
No changes to the partials themselves — only the wrapper template changes. The dashed
horizontal rules are a new CSS rule on .state-cluster > * + *.
Inspection strip
One <section class="panel inspection-strip"> containing:
- Tab bar: three
<button role="tab">elements (Log, Console, Files) plus an expand button<button class="strip-expand" aria-label="Expand active tab">. - Tab panes: three
<div role="tabpanel">, one of which has nohiddenattribute.
Tab panes
| Tab | Inline content | Source partial / element |
|---|---|---|
| Log | <pre class="log-stream" data-sse-url=…> (same SSE element as today), capped to ~12rem with overflow:auto |
unchanged |
| Console | <div class="console-transcript"> (read-write — same partial loop) + the existing console <form> |
_console_line.html, inline form |
| Files | The _overlay_file_tree.html include, wrapped to scroll within ~12rem |
unchanged |
Tab switching
Plain CSS + a tiny JS helper. Pattern:
// new file: l4d2web/static/js/tabs.js
function activateTab(strip, name) {
strip.querySelectorAll('[role="tab"]').forEach(t => {
const on = t.dataset.tab === name;
t.setAttribute('aria-selected', on);
t.tabIndex = on ? 0 : -1;
});
strip.querySelectorAll('[role="tabpanel"]').forEach(p => {
p.hidden = p.dataset.tab !== name;
});
strip.dataset.activeTab = name;
}
Bound via data-tab-strip on the container. Default active tab is Log (matches
today's "is the server alive?" affordance). Active tab is not persisted in
localStorage for v1 — can be added later if it proves annoying.
Expand control
One <button class="strip-expand">⛶</button> reads strip.dataset.activeTab and
opens the matching modal via the existing data-inline-modal-open plumbing in
modals.js. Implementation detail: the button has no static data-inline-modal-open;
a small JS handler synthesizes the open call based on the active tab.
New modals
Five new <dialog class="modal"> instances are added alongside the existing
rename/delete/reset modals. All follow the existing pattern — no new CSS for
chrome, only the body content differs.
#log-modal
- Body: a taller
<pre class="log-stream" data-sse-url=…>plus a small toolbar (Download .log link →/servers/{id}/logs/downloadif it exists today; otherwise drop — do not invent a new route in v1). - The inline log pane and modal log pane both stream from the same SSE endpoint. They're independent EventSource connections; that's acceptable (low volume).
#console-modal
- Body: a taller
<div class="console-transcript">and a copy of the console form, posting to the same/servers/{id}/consoleendpoint. - Two console forms exist (inline + modal). To avoid them swapping into each
other's transcripts, each form's
hx-targetreferences its own transcript ID:#console-transcript-inline-{id}and#console-transcript-modal-{id}. - New POSTs go to the form the user submitted; the other transcript stays as it was at last render. Acceptable for v1 — both transcripts derive from the same server history on page load, divergence only occurs within a session.
#files-modal
- Body:
_overlay_file_tree.htmlwithfiles_base_url = "/servers/" ~ server.id, rendered in a much taller container. No new behavior.
#recent-players-modal
- Body: full list of recent players in single-column chip format, scrollable. Triggered by the "N Recent" header (when N > 10) in the live-state subblock.
- Driven by the full
recent_playerslist already in the route context.
#job-log-modal
- Body:
<pre class="log-stream" data-sse-url="/jobs/{latest_job.id}/stream">— same SSE endpoint that drives the current inline stream. Header shows the job phrase ("starting", "stopping", "resetting") and a "open full job →" link to/jobs/{id}for users who want history or related metadata. - Trigger. The
latest_job_phrasetext in_server_actions.html(currently<a href="/jobs/{id}">) becomes a<button>styled like a link, carryingdata-inline-modal-open="job-log-modal". The plain/jobs/{id}page is still reachable via the "show all" link in the same line and via the modal's "open full job →" footer link. - The modal renders the SSE pre regardless of whether the job is running. For completed jobs the endpoint serves the final captured log (existing behavior); for running jobs it streams live. Closing the modal closes the EventSource — same lifecycle the inline pre had before.
Files to modify
| File | Change |
|---|---|
l4d2web/l4d2web/templates/server_detail.html |
Rewritten body: state cluster, inspection strip, five modals |
l4d2web/l4d2web/templates/_server_actions.html |
Remove inline job-log <pre>; latest-job link → modal trigger |
l4d2web/l4d2web/templates/_live_state.html |
4-col current grid (no header) + 5-col recent chips + N Recent header trigger |
l4d2web/l4d2web/templates/_macros.html (new if absent) |
config_field(label, value, editable) macro for config grid |
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/templates/base.html |
Include tabs.js if scripts are listed centrally |
l4d2web/l4d2web/routes/server_routes.py (live_state_fragment) |
Add recent_players_overview (sliced to 10) + total count |
Reused, do not modify
_console_line.html,_overlay_file_tree.htmlstatic/js/modals.js(Esc/backdrop/focus-trap)- SSE log stream route, HTMX live-state route, console POST route
Verification
End-to-end check via the local dev server (see
feedback_textarea_editor_v2_run.md / reference_left4me_dev_server.md):
scripts/dev-server.py
Then in a browser (or via Chromium e2e in tests):
- Open a server detail page (demo seed creates one). The state cluster shows at top with badge, start/stop/reset, players, map, port/blueprint/RCON/hostname — no scroll needed on a 1080p viewport.
- Inspection strip defaults to the Log tab; lines stream live.
- Click Console tab → transcript and input visible; type
status, submit → reply appended. - Click Files tab → file tree visible and navigable.
- Click ⛶ on each tab → matching modal opens with the larger view.
- In
#console-modal, send a command; transcript inside the modal updates. Close modal; inline console transcript still has its prior state. - Live-state subblock shows ≤ 8 current players in a 4-column grid (no header)
and ≤ 10 recent players as 5-column chips under an
N Recentheader. With10 recents,
N Recentbecomes a link that opens#recent-players-modal; with ≤ 10 it stays plain text. - Start the server. The lifecycle subblock shows
starting since 2s agobut no inline streaming log — the page does not shift as the job progresses. Click the job phrase →#job-log-modalopens and streams the live job log. Close → SSE disconnects. - Delete/Rename buttons still trigger their respective modals.
Add or extend a Playwright e2e (see existing fixtures in
l4d2web/tests/e2e/) covering tab switching and expand-to-modal for one tab.
Out of scope (v1)
- Persisting active tab across reloads.
- Download-log action (only if the route already exists; otherwise defer).
- Mobile/narrow-viewport polish beyond the player-grid breakpoint
(
<600px→auto-fit, minmax(110px, 1fr)) described above. The inspection strip and config grid rely on theirauto-fitrules to collapse naturally; no additional bespoke responsive rules in v1. - Replacing HTMX polling on live state with SSE.