- 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>
289 lines
16 KiB
Markdown
289 lines
16 KiB
Markdown
# 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.js` pattern. `_server_actions.html` and
|
|
`_live_state.html` get 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:
|
|
|
|
1. **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-actions` while 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.
|
|
2. **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-31ms` becomes `4m · 22-31ms`. The unit
|
|
`m`/`s`/`h`/`d` plus the `ms` suffix 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 get
|
|
`text-overflow: ellipsis` and a `title` attribute holding the full name.
|
|
|
|
**Header doubles as the modal trigger.** The recent section's header is
|
|
`N Recent` (count first, e.g. `13 Recent`). When `N > 10` it renders as a
|
|
link/button that opens `#recent-players-modal`; when `N ≤ 10` it renders
|
|
as plain text (the modal would be redundant). No separate "view all" link.
|
|
The route exposes `recent_players_overview` (sliced to 10) and
|
|
`recent_players_total_count` for 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 by `recent_players` (the full unsliced list already in context).
|
|
Header: `Recent players (N)`. No new route — same context.
|
|
3. **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 no `hidden` attribute.
|
|
|
|
### 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:
|
|
|
|
```js
|
|
// 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/download` if 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}/console` endpoint.
|
|
- **Two console forms exist** (inline + modal). To avoid them swapping into each
|
|
other's transcripts, each form's `hx-target` references 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.html` with `files_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_players` list 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_phrase` text in `_server_actions.html`
|
|
(currently `<a href="/jobs/{id}">`) becomes a `<button>` styled like a link,
|
|
carrying `data-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.html`
|
|
- `static/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):
|
|
|
|
1. 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.
|
|
2. Inspection strip defaults to the Log tab; lines stream live.
|
|
3. Click Console tab → transcript and input visible; type `status`, submit → reply
|
|
appended.
|
|
4. Click Files tab → file tree visible and navigable.
|
|
5. Click ⛶ on each tab → matching modal opens with the larger view.
|
|
6. In `#console-modal`, send a command; transcript inside the modal updates. Close
|
|
modal; inline console transcript still has its prior state.
|
|
7. 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 Recent` header. With
|
|
>10 recents, `N Recent` becomes a link that opens `#recent-players-modal`;
|
|
with ≤ 10 it stays plain text.
|
|
8. Start the server. The lifecycle subblock shows `starting since 2s ago`
|
|
but **no inline streaming log** — the page does not shift as the job
|
|
progresses. Click the job phrase → `#job-log-modal` opens and streams the
|
|
live job log. Close → SSE disconnects.
|
|
9. 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 their `auto-fit` rules to collapse naturally;
|
|
no additional bespoke responsive rules in v1.
|
|
- Replacing HTMX polling on live state with SSE.
|