Compare commits
No commits in common. "36e4b6158122689f955e998ee029ad88924ddd07" and "b45adcd81959658a2c0b036b52dc19a78ebdb20c" have entirely different histories.
36e4b61581
...
b45adcd819
15 changed files with 115 additions and 1084 deletions
|
|
@ -1,289 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -260,26 +260,9 @@ def live_state_fragment(server_id: int) -> Response:
|
||||||
ServerPlayerSession.name_at_join,
|
ServerPlayerSession.name_at_join,
|
||||||
)
|
)
|
||||||
.order_by(func.max(ServerPlayerSession.left_at).desc())
|
.order_by(func.max(ServerPlayerSession.left_at).desc())
|
||||||
.limit(50)
|
.limit(20)
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
recent_total = db.scalar(
|
|
||||||
select(func.count(func.distinct(ServerPlayerSession.steam_id_64)))
|
|
||||||
.where(
|
|
||||||
ServerPlayerSession.server_id == server.id,
|
|
||||||
ServerPlayerSession.left_at.is_not(None),
|
|
||||||
ServerPlayerSession.left_at >= recent_cutoff,
|
|
||||||
~ServerPlayerSession.steam_id_64.in_(current_ids) if current_ids else True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
recent_overview = recent_rows[:10]
|
|
||||||
|
|
||||||
if request.args.get("view") == "recent-modal":
|
|
||||||
return render_template(
|
|
||||||
"_recent_players_modal_body.html",
|
|
||||||
recent_players=recent_rows,
|
|
||||||
)
|
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"_live_state.html",
|
"_live_state.html",
|
||||||
server=server,
|
server=server,
|
||||||
|
|
@ -287,7 +270,5 @@ def live_state_fragment(server_id: int) -> Response:
|
||||||
snapshot_fresh=(latest is not None and latest.last_seen_at >= cutoff),
|
snapshot_fresh=(latest is not None and latest.last_seen_at >= cutoff),
|
||||||
current_players=current_rows,
|
current_players=current_rows,
|
||||||
recent_players=recent_rows,
|
recent_players=recent_rows,
|
||||||
recent_players_overview=recent_overview,
|
|
||||||
recent_players_total_count=recent_total,
|
|
||||||
poll_seconds=max(1, int(current_app.config.get("LIVE_STATE_POLL_SECONDS", 5))),
|
poll_seconds=max(1, int(current_app.config.get("LIVE_STATE_POLL_SECONDS", 5))),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -873,195 +873,62 @@ div.modal.modal-wide {
|
||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* Live-state panel — current + recent players on the server detail page.
|
||||||
State cluster (top of server detail)
|
Avatars are fetched as the 184x184 Steam "full" size and downscaled in
|
||||||
============================================================ */
|
CSS so high-DPI screens render crisply. */
|
||||||
|
|
||||||
.state-cluster {
|
.live-state .player-grid {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: var(--space-m) 0 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-wrap: wrap;
|
||||||
gap: var(--space-m);
|
gap: var(--space-m);
|
||||||
}
|
}
|
||||||
|
|
||||||
.state-cluster > * + * {
|
.live-state .player-card {
|
||||||
border-top: 1px dashed var(--color-border-muted);
|
|
||||||
padding-top: var(--space-m);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Config grid — auto-fit so it scales with field count */
|
|
||||||
.config-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
||||||
gap: var(--space-s) var(--space-m);
|
|
||||||
}
|
|
||||||
.config-field-label {
|
|
||||||
font-size: 0.75em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
color: var(--color-muted);
|
|
||||||
margin-bottom: 0.15em;
|
|
||||||
}
|
|
||||||
.config-field-value { font-size: 0.95em; }
|
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
Live-state — compact player grids
|
|
||||||
============================================================ */
|
|
||||||
|
|
||||||
.player-grid {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: var(--space-s) 0 0;
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
}
|
|
||||||
.player-grid.current { grid-template-columns: repeat(4, 1fr); }
|
|
||||||
.player-grid.recent { grid-template-columns: repeat(5, 1fr); }
|
|
||||||
|
|
||||||
.player-card {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
column-gap: var(--space-xs);
|
column-gap: var(--space-s);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: var(--space-xs);
|
|
||||||
}
|
}
|
||||||
.player-link {
|
|
||||||
|
.live-state .player-link {
|
||||||
display: contents;
|
display: contents;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.player-link:hover .name { text-decoration: underline; }
|
|
||||||
|
|
||||||
.player-card .avatar {
|
.live-state .player-link:hover .name {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-state .avatar {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
border-radius: var(--radius-s);
|
border-radius: var(--radius-s);
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
grid-row: 1 / span 2;
|
||||||
.current-card .avatar { width: 22px; height: 22px; grid-row: 1 / span 2; }
|
|
||||||
.recent-chip .avatar { width: 16px; height: 16px; }
|
|
||||||
|
|
||||||
.player-card .name {
|
|
||||||
grid-column: 2;
|
|
||||||
font-size: 0.9em;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.current-card .name { font-weight: 600; }
|
|
||||||
.recent-chip .name { font-weight: 500; }
|
|
||||||
|
|
||||||
.player-card .meta {
|
|
||||||
grid-column: 2;
|
|
||||||
color: var(--color-muted);
|
|
||||||
font-size: 0.75em;
|
|
||||||
}
|
|
||||||
.recent-chip .meta {
|
|
||||||
grid-column: auto;
|
|
||||||
font-size: 0.8em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar.placeholder {
|
.live-state .avatar.placeholder {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
background: color-mix(in srgb, var(--color-muted) 30%, transparent);
|
background: color-mix(in srgb, var(--color-muted) 30%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.recent-header {
|
.live-state .name {
|
||||||
margin: var(--space-m) 0 var(--space-xs);
|
grid-column: 2;
|
||||||
font-size: 0.75em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
color: var(--color-muted);
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.recent-header-trigger {
|
|
||||||
background: transparent;
|
|
||||||
border: 0;
|
|
||||||
padding: 0;
|
|
||||||
font: inherit;
|
|
||||||
color: var(--color-link);
|
|
||||||
text-transform: inherit;
|
|
||||||
letter-spacing: inherit;
|
|
||||||
text-decoration: underline dotted;
|
|
||||||
text-underline-offset: 2px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.recent-header-trigger:hover { text-decoration-style: solid; }
|
|
||||||
|
|
||||||
.server-live-summary { font-size: 1.05em; }
|
.live-state .meta {
|
||||||
|
grid-column: 2;
|
||||||
/* Narrow viewports — collapse fixed grids so chips don't crush */
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.player-grid.current,
|
|
||||||
.player-grid.recent {
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
Inspection strip — tabbed Log / Console / Files
|
|
||||||
============================================================ */
|
|
||||||
|
|
||||||
.inspection-strip {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.tab-bar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
border-bottom: 1px solid var(--color-border-muted);
|
|
||||||
padding: 0 var(--space-s);
|
|
||||||
}
|
|
||||||
.tab-bar [role="tab"] {
|
|
||||||
background: transparent;
|
|
||||||
border: 0;
|
|
||||||
padding: var(--space-s) calc(var(--space-m) * 1.5);
|
|
||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
cursor: pointer;
|
font-size: 0.85em;
|
||||||
font: inherit;
|
}
|
||||||
|
|
||||||
|
.live-state .server-live-summary {
|
||||||
font-size: 1.05em;
|
font-size: 1.05em;
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
}
|
|
||||||
.tab-bar [role="tab"][aria-selected="true"] {
|
|
||||||
color: var(--color-text);
|
|
||||||
border-bottom-color: var(--color-link);
|
|
||||||
}
|
|
||||||
.tab-bar .strip-expand {
|
|
||||||
margin-left: auto;
|
|
||||||
background: transparent;
|
|
||||||
border: 0;
|
|
||||||
padding: var(--space-s) var(--space-m);
|
|
||||||
color: var(--color-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.tab-bar .strip-expand:hover { color: var(--color-text); }
|
|
||||||
|
|
||||||
.tab-pane {
|
|
||||||
padding: var(--space-s);
|
|
||||||
max-height: 12rem;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
.tab-pane[hidden] { display: none; }
|
|
||||||
.tab-pane .log-stream { max-height: none; } /* let pane handle scrolling */
|
|
||||||
|
|
||||||
/* Console tab pane — input pinned at bottom, transcript scrolls.
|
|
||||||
:not([hidden]) keeps .tab-pane[hidden] { display: none } winning
|
|
||||||
when the tab is inactive (otherwise these rules would tie on
|
|
||||||
specificity and the later rule would defeat the hidden state). */
|
|
||||||
.tab-pane[data-tab="console"]:not([hidden]) {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
/* inherits max-height: 12rem from .tab-pane */
|
|
||||||
overflow: hidden; /* clip here; transcript scrolls internally */
|
|
||||||
}
|
|
||||||
.tab-pane[data-tab="console"] .console-transcript {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-height: 0; /* allow flex item to shrink below content size */
|
|
||||||
max-height: none; /* override the global 400px cap — parent caps the area */
|
|
||||||
overflow: auto;
|
|
||||||
margin-bottom: var(--space-s); /* tighter gap inside fixed-height pane */
|
|
||||||
}
|
|
||||||
.tab-pane[data-tab="console"] .console-input-form {
|
|
||||||
flex: 0 0 auto; /* never shrink the input row */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
|
|
@ -1142,39 +1009,3 @@ div.modal.modal-wide {
|
||||||
.console-input-form.htmx-request .console-spinner {
|
.console-input-form.htmx-request .console-spinner {
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recent-modal-list {
|
|
||||||
/* Force a single column inside the modal regardless of the 5-col
|
|
||||||
default on .player-grid.recent. !important is acceptable here:
|
|
||||||
the only way to override the more-specific class selector
|
|
||||||
chain is via specificity or !important; modal-only override
|
|
||||||
is local enough that long-term maintainability is fine. */
|
|
||||||
grid-template-columns: 1fr !important;
|
|
||||||
max-height: 60vh;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modal-specific overrides — the log/console modals are meant to give
|
|
||||||
the user *more* room than the inline tab. The .tall modifier opts
|
|
||||||
into that extra height when the same element is rendered inside a
|
|
||||||
.modal. */
|
|
||||||
.modal .log-stream.tall,
|
|
||||||
.modal .console-transcript.tall {
|
|
||||||
max-height: 60vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Console modal body — input pinned at bottom, transcript scrolls. */
|
|
||||||
#console-modal .modal-body {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
max-height: 70vh;
|
|
||||||
}
|
|
||||||
#console-modal .modal-body .console-transcript {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-height: 0;
|
|
||||||
max-height: none; /* override global 400px / .tall 60vh — modal-body caps it */
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
#console-modal .modal-body .console-input-form {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@
|
||||||
--color-text: #18181b;
|
--color-text: #18181b;
|
||||||
--color-muted: #60646c;
|
--color-muted: #60646c;
|
||||||
--color-border: #d4d4d8;
|
--color-border: #d4d4d8;
|
||||||
--color-border-muted: rgba(0, 0, 0, 0.12);
|
|
||||||
--color-link: #1d4ed8;
|
--color-link: #1d4ed8;
|
||||||
--color-primary: #1d4ed8;
|
--color-primary: #1d4ed8;
|
||||||
--color-danger: #b42318;
|
--color-danger: #b42318;
|
||||||
|
|
@ -59,7 +58,6 @@
|
||||||
--color-text: #f4f4f5;
|
--color-text: #f4f4f5;
|
||||||
--color-muted: #a1a1aa;
|
--color-muted: #a1a1aa;
|
||||||
--color-border: #3f3f46;
|
--color-border: #3f3f46;
|
||||||
--color-border-muted: rgba(255, 255, 255, 0.12);
|
|
||||||
--color-link: #93c5fd;
|
--color-link: #93c5fd;
|
||||||
--color-primary: #93c5fd;
|
--color-primary: #93c5fd;
|
||||||
--color-danger: #fca5a5;
|
--color-danger: #fca5a5;
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,6 @@
|
||||||
const dialog = typeof idOrEl === "string" ? document.getElementById(idOrEl) : idOrEl;
|
const dialog = typeof idOrEl === "string" ? document.getElementById(idOrEl) : idOrEl;
|
||||||
if (dialog && typeof dialog.showModal === "function" && !dialog.open) {
|
if (dialog && typeof dialog.showModal === "function" && !dialog.open) {
|
||||||
dialog.showModal();
|
dialog.showModal();
|
||||||
dialog.dispatchEvent(new CustomEvent("modal:opened", { bubbles: true }));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -71,10 +70,7 @@
|
||||||
// this swap; the newer click will win.
|
// this swap; the newer click will win.
|
||||||
if (currentRoutedPath !== path) return;
|
if (currentRoutedPath !== path) return;
|
||||||
const dlg = document.getElementById("modal-container");
|
const dlg = document.getElementById("modal-container");
|
||||||
if (dlg && !dlg.open) {
|
if (dlg && !dlg.open) dlg.showModal();
|
||||||
dlg.showModal();
|
|
||||||
dlg.dispatchEvent(new CustomEvent("modal:opened", { bubbles: true }));
|
|
||||||
}
|
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
console.error("[modals] routed fetch failed", err);
|
console.error("[modals] routed fetch failed", err);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
// l4d2web/l4d2web/static/js/tabs.js
|
|
||||||
// Tabbed strips: any element with [data-tab-strip] activates the first
|
|
||||||
// [role="tab"] in DOM order (or the one carrying [aria-selected="true"]
|
|
||||||
// if present) on load, and switches panes on click. The strip's
|
|
||||||
// [data-active-tab] attribute mirrors the active tab name and is read
|
|
||||||
// by the expand-to-modal handler.
|
|
||||||
|
|
||||||
(function () {
|
|
||||||
function activateTab(strip, name) {
|
|
||||||
strip.querySelectorAll('[role="tab"]').forEach((t) => {
|
|
||||||
const on = t.dataset.tab === name;
|
|
||||||
t.setAttribute("aria-selected", on ? "true" : "false");
|
|
||||||
t.tabIndex = on ? 0 : -1;
|
|
||||||
});
|
|
||||||
strip.querySelectorAll('[role="tabpanel"]').forEach((p) => {
|
|
||||||
p.hidden = p.dataset.tab !== name;
|
|
||||||
});
|
|
||||||
strip.dataset.activeTab = name;
|
|
||||||
}
|
|
||||||
|
|
||||||
function activeTabName(strip) {
|
|
||||||
const selected = strip.querySelector('[role="tab"][aria-selected="true"]');
|
|
||||||
if (selected) return selected.dataset.tab;
|
|
||||||
const first = strip.querySelector('[role="tab"]');
|
|
||||||
return first ? first.dataset.tab : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function initStrips(root) {
|
|
||||||
(root || document).querySelectorAll("[data-tab-strip]").forEach((strip) => {
|
|
||||||
// Initialise active tab on load.
|
|
||||||
const name = activeTabName(strip);
|
|
||||||
if (name) activateTab(strip, name);
|
|
||||||
|
|
||||||
// Bind tab clicks.
|
|
||||||
strip.addEventListener("click", (ev) => {
|
|
||||||
const tab = ev.target.closest('[role="tab"]');
|
|
||||||
if (tab && strip.contains(tab)) {
|
|
||||||
activateTab(strip, tab.dataset.tab);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Bind expand button: opens <dialog id="<tabname>-modal">.
|
|
||||||
const expand = strip.querySelector(".strip-expand");
|
|
||||||
if (expand) {
|
|
||||||
expand.addEventListener("click", () => {
|
|
||||||
const name = strip.dataset.activeTab;
|
|
||||||
if (!name) return;
|
|
||||||
const dlg = document.getElementById(`${name}-modal`);
|
|
||||||
if (dlg && typeof dlg.showModal === "function" && !dlg.open) {
|
|
||||||
dlg.showModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.readyState === "loading") {
|
|
||||||
document.addEventListener("DOMContentLoaded", () => initStrips());
|
|
||||||
} else {
|
|
||||||
initStrips();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
@ -1,9 +1,4 @@
|
||||||
{# Live-state partial — HTMX-polled into the state cluster on server_detail.html.
|
<h2 class="section-title">Live state</h2>
|
||||||
The parent .state-cluster section provides the heading context, so there is
|
|
||||||
no <h2> here. Current players have no sub-header; they sit directly under
|
|
||||||
the summary line. Recent players' header is "N Recent" and doubles as the
|
|
||||||
modal trigger when N > 10. #}
|
|
||||||
|
|
||||||
{% if not snapshot or not snapshot_fresh %}
|
{% if not snapshot or not snapshot_fresh %}
|
||||||
<p class="muted">No data — server is not currently reporting.</p>
|
<p class="muted">No data — server is not currently reporting.</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
@ -11,15 +6,17 @@
|
||||||
{{ snapshot.players }}/{{ snapshot.max_players }}
|
{{ snapshot.players }}/{{ snapshot.max_players }}
|
||||||
{% if snapshot.hibernating %}· idle{% endif %}
|
{% if snapshot.hibernating %}· idle{% endif %}
|
||||||
· {{ snapshot.map }}
|
· {{ snapshot.map }}
|
||||||
<small class="muted">polled {{ snapshot.last_seen_at | timeago }}</small>
|
<small class="muted">
|
||||||
|
polled {{ snapshot.last_seen_at | timeago }}
|
||||||
|
</small>
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if current_players %}
|
{% if current_players %}
|
||||||
<ul class="player-grid current">
|
<h3 class="section-subtitle">Current players</h3>
|
||||||
|
<ul class="player-grid">
|
||||||
{% for session, profile in current_players %}
|
{% for session, profile in current_players %}
|
||||||
{% set display_name = (profile and profile.persona_name) or session.name_at_join %}
|
<li class="player-card">
|
||||||
<li class="player-card current-card">
|
|
||||||
<a class="player-link"
|
<a class="player-link"
|
||||||
href="https://steamcommunity.com/profiles/{{ session.steam_id_64 }}"
|
href="https://steamcommunity.com/profiles/{{ session.steam_id_64 }}"
|
||||||
target="_blank" rel="noopener noreferrer">
|
target="_blank" rel="noopener noreferrer">
|
||||||
|
|
@ -28,31 +25,22 @@
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="avatar placeholder" aria-hidden="true"></span>
|
<span class="avatar placeholder" aria-hidden="true"></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="name" title="{{ display_name }}">{{ display_name }}</span>
|
<span class="name">{{ (profile and profile.persona_name) or session.name_at_join }}</span>
|
||||||
</a>
|
</a>
|
||||||
<span class="meta">
|
<span class="meta">
|
||||||
{{ session.joined_at | timeago }} · {{ session.min_ping }}-{{ session.max_ping }}ms
|
joined {{ session.joined_at | timeago }}
|
||||||
|
· ping {{ session.min_ping }}-{{ session.max_ping }}ms
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if recent_players_overview %}
|
{% if recent_players %}
|
||||||
<h3 class="recent-header">
|
<h3 class="section-subtitle">Recent players</h3>
|
||||||
{% if recent_players_total_count > 10 %}
|
|
||||||
<button type="button" class="recent-header-trigger"
|
|
||||||
data-inline-modal-open="recent-players-modal">
|
|
||||||
{{ recent_players_total_count }} Recent
|
|
||||||
</button>
|
|
||||||
{% else %}
|
|
||||||
{{ recent_players_total_count }} Recent
|
|
||||||
{% endif %}
|
|
||||||
</h3>
|
|
||||||
<ul class="player-grid recent">
|
<ul class="player-grid recent">
|
||||||
{% for row in recent_players_overview %}
|
{% for row in recent_players %}
|
||||||
{% set display_name = row.persona_name or row.name_at_join %}
|
<li class="player-card">
|
||||||
<li class="player-card recent-chip">
|
|
||||||
<a class="player-link"
|
<a class="player-link"
|
||||||
href="https://steamcommunity.com/profiles/{{ row.steam_id_64 }}"
|
href="https://steamcommunity.com/profiles/{{ row.steam_id_64 }}"
|
||||||
target="_blank" rel="noopener noreferrer">
|
target="_blank" rel="noopener noreferrer">
|
||||||
|
|
@ -61,9 +49,11 @@
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="avatar placeholder" aria-hidden="true"></span>
|
<span class="avatar placeholder" aria-hidden="true"></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="name" title="{{ display_name }}">{{ display_name }}</span>
|
<span class="name">{{ row.persona_name or row.name_at_join }}</span>
|
||||||
</a>
|
</a>
|
||||||
<span class="meta">· {{ row.last_seen | timeago }}</span>
|
<span class="meta">
|
||||||
|
last seen {{ row.last_seen | timeago }}
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
{# Block form of config_field: lets the caller write the value as a Jinja
|
|
||||||
template body, which keeps auto-escaping active for interpolated values. #}
|
|
||||||
{% macro config_field_block(label) %}
|
|
||||||
<div class="config-field">
|
|
||||||
<div class="config-field-label">{{ label }}</div>
|
|
||||||
<div class="config-field-value">{{ caller() }}</div>
|
|
||||||
</div>
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{# Console form — used by both the inline tab and the modal. The transcript
|
|
||||||
ID differs per location to keep HTMX swaps independent. #}
|
|
||||||
{% macro console_form(server, transcript_id) %}
|
|
||||||
<form hx-post="/servers/{{ server.id }}/console"
|
|
||||||
hx-target="#{{ transcript_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"
|
|
||||||
placeholder="status, changelevel c1m1_hotel, sm_kick …">
|
|
||||||
<button type="submit">Send</button>
|
|
||||||
</form>
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
{# Full single-column list of recent players for the
|
|
||||||
#recent-players-modal. Rendered via /servers/<id>/live-state?view=recent-modal.
|
|
||||||
Reuses the same chip markup as the inline grid in _live_state.html. #}
|
|
||||||
<ul class="player-grid recent recent-modal-list">
|
|
||||||
{% for row in recent_players %}
|
|
||||||
{% set display_name = row.persona_name or row.name_at_join %}
|
|
||||||
<li class="player-card recent-chip">
|
|
||||||
<a class="player-link"
|
|
||||||
href="https://steamcommunity.com/profiles/{{ row.steam_id_64 }}"
|
|
||||||
target="_blank" rel="noopener noreferrer">
|
|
||||||
{% if row.avatar_url %}
|
|
||||||
<img class="avatar" src="{{ row.avatar_url }}" alt="">
|
|
||||||
{% else %}
|
|
||||||
<span class="avatar placeholder" aria-hidden="true"></span>
|
|
||||||
{% endif %}
|
|
||||||
<span class="name" title="{{ display_name }}">{{ display_name }}</span>
|
|
||||||
</a>
|
|
||||||
<span class="meta">· {{ row.last_seen | timeago }}</span>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
|
|
@ -24,11 +24,13 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if latest_job %}
|
{% if latest_job %}
|
||||||
<p class="last-job">
|
<p class="last-job">
|
||||||
<button type="button" class="link-button" data-inline-modal-open="job-log-modal">{{ latest_job_phrase }}</button>
|
<a href="/jobs/{{ latest_job.id }}">{{ latest_job_phrase }}</a>
|
||||||
{% if latest_job_is_running %}since{% endif %}
|
{% if latest_job_is_running %}since{% endif %}
|
||||||
{{ latest_job_at | timeago }}
|
{{ latest_job_at | timeago }}
|
||||||
(<a href="/servers/{{ server.id }}/jobs">show all</a>)
|
(<a href="/servers/{{ server.id }}/jobs">show all</a>)
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if latest_job_is_running %}
|
||||||
|
<pre class="log-stream job-log" data-sse-url="/jobs/{{ latest_job.id }}/stream"></pre>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,6 @@
|
||||||
<script src="{{ url_for('static', filename='js/csrf.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/csrf.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/sse.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/sse.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/modals.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/modals.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/tabs.js') }}"></script>
|
|
||||||
<script src="{{ url_for('static', filename='js/file-tree.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 src="{{ url_for('static', filename='js/password-reveal.js') }}"></script>
|
||||||
<script defer src="{{ url_for('static', filename='js/console-history.js') }}"></script>
|
<script defer src="{{ url_for('static', filename='js/console-history.js') }}"></script>
|
||||||
|
|
|
||||||
|
|
@ -1,78 +1,74 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% import "_macros.html" as macros %}
|
|
||||||
|
|
||||||
{% block title %}Server {{ server.name }} | left4me{% endblock %}
|
{% block title %}Server {{ server.name }} | left4me{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="panel state-cluster">
|
<section class="panel">
|
||||||
<div class="page-heading">
|
<div class="page-heading">
|
||||||
<h1>Server: {{ server.name }}</h1>
|
<h1>Server: {{ server.name }}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Lifecycle subblock — uses _server_actions.html which now opens job-log-modal on click #}
|
<dl class="server-info">
|
||||||
{% include "_server_actions.html" %}
|
<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>
|
||||||
{# Live state — HTMX-loaded into innerHTML #}
|
<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>
|
||||||
<section class="live-state"
|
<div><dt>Hostname</dt>
|
||||||
hx-get="/servers/{{ server.id }}/live-state"
|
<dd>
|
||||||
hx-trigger="load, every 5s"
|
|
||||||
hx-swap="innerHTML"></section>
|
|
||||||
|
|
||||||
{# Config grid — flat auto-fit; uses config_field_block macro from _macros.html #}
|
|
||||||
<div class="config-grid">
|
|
||||||
{% call macros.config_field_block("Port") %}
|
|
||||||
<a href="steam://run/550//+connect%20{{ connect_host }}:{{ server.port }}">{{ server.port }}</a>
|
|
||||||
{% endcall %}
|
|
||||||
|
|
||||||
{% call macros.config_field_block("Blueprint") %}
|
|
||||||
{% if blueprint %}
|
|
||||||
<a href="/blueprints/{{ blueprint.id }}">{{ blueprint.name }}</a>
|
|
||||||
{% else %}
|
|
||||||
—
|
|
||||||
{% endif %}
|
|
||||||
{% endcall %}
|
|
||||||
|
|
||||||
{% call macros.config_field_block("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>
|
|
||||||
{% endcall %}
|
|
||||||
|
|
||||||
{% call macros.config_field_block("Hostname") %}
|
|
||||||
<form method="post" action="/servers/{{ server.id }}" class="inline-save">
|
<form method="post" action="/servers/{{ server.id }}" class="inline-save">
|
||||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
<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">
|
<input name="hostname" value="{{ server.hostname }}" placeholder="{{ g.user.username }} {{ server.name }}" maxlength="128">
|
||||||
<button type="submit">Save</button>
|
<button type="submit">Save</button>
|
||||||
|
<span class="field-hint">Leave empty for auto: "{{ g.user.username }} {{ server.name }}"</span>
|
||||||
</form>
|
</form>
|
||||||
{% endcall %}
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</dl>
|
||||||
|
|
||||||
{# Inspection strip — Log / Console / Files with expand-to-modal #}
|
<h2 class="section-title">Actions</h2>
|
||||||
<section class="panel inspection-strip" data-tab-strip data-active-tab="log">
|
{% include "_server_actions.html" %}
|
||||||
<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">
|
<h2 class="section-title">Server Log</h2>
|
||||||
<pre class="log-stream" data-sse-url="/servers/{{ server.id }}/logs/stream"></pre>
|
<pre class="log-stream" data-sse-url="/servers/{{ server.id }}/logs/stream"></pre>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div role="tabpanel" data-tab="console" class="tab-pane" hidden>
|
<section class="panel live-state"
|
||||||
<div id="console-transcript-inline-{{ server.id }}" class="console-transcript" data-autoscroll>
|
hx-get="/servers/{{ server.id }}/live-state"
|
||||||
|
hx-trigger="load, every 5s"
|
||||||
|
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 %}
|
{% for h in console_history %}
|
||||||
{% with command=h.command, reply=h.reply, is_error=h.is_error %}
|
{% with command=h.command, reply=h.reply, is_error=h.is_error %}
|
||||||
{% include "_console_line.html" %}
|
{% include "_console_line.html" %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{{ macros.console_form(server, "console-transcript-inline-" ~ server.id) }}
|
<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>
|
||||||
|
<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>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div role="tabpanel" data-tab="files" class="tab-pane" hidden>
|
<h2 class="section-title">Files</h2>
|
||||||
{% if not file_tree_root_entries %}
|
{% if not file_tree_root_entries %}
|
||||||
<p class="muted">No files yet — start the server to mount its runtime.</p>
|
<p class="muted">No files yet — start the server to mount its runtime.</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
@ -82,7 +78,6 @@
|
||||||
{% set files_base_url = "/servers/" ~ server.id %}
|
{% set files_base_url = "/servers/" ~ server.id %}
|
||||||
{% include "_overlay_file_tree.html" %}
|
{% include "_overlay_file_tree.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="page-footer-actions">
|
<div class="page-footer-actions">
|
||||||
|
|
@ -90,80 +85,6 @@
|
||||||
<a href="#" class="link-button" data-inline-modal-open="rename-server-modal">Rename</a>
|
<a href="#" class="link-button" data-inline-modal-open="rename-server-modal">Rename</a>
|
||||||
</div>
|
</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>
|
|
||||||
{{ macros.console_form(server, "console-transcript-modal-" ~ server.id) }}
|
|
||||||
</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">
|
|
||||||
<div hx-get="/servers/{{ server.id }}/live-state?view=recent-modal"
|
|
||||||
hx-trigger="modal:opened from:closest dialog"
|
|
||||||
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">
|
<dialog id="rename-server-modal" class="modal" aria-labelledby="rename-server-title">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 id="rename-server-title">Rename server</h2>
|
<h2 id="rename-server-title">Rename server</h2>
|
||||||
|
|
|
||||||
|
|
@ -43,17 +43,10 @@ def test_hover_download_initiates_file_download(page: Page, server_with_files) -
|
||||||
login(page, base)
|
login(page, base)
|
||||||
page.goto(f"{base}/servers/{server_id}")
|
page.goto(f"{base}/servers/{server_id}")
|
||||||
|
|
||||||
# The file tree lives inside the Files tab pane (hidden by default).
|
|
||||||
# Switch to it first so the row is visible and hover-clickable.
|
|
||||||
strip = page.locator("[data-tab-strip]")
|
|
||||||
strip.locator('[role="tab"][data-tab="files"]').click()
|
|
||||||
|
|
||||||
# On server detail, files_overlay=False in the template, so the
|
# On server detail, files_overlay=False in the template, so the
|
||||||
# row <li> has no data-target-path. Scope to the tab pane (not the
|
# row <li> has no data-target-path. Match by row class + visible
|
||||||
# expand modal which also contains a copy of the file tree) and match
|
# filename text instead.
|
||||||
# by row class + visible filename text.
|
row = page.locator("li.file-tree-row-file", has_text="server.cfg")
|
||||||
files_pane = strip.locator('[role="tabpanel"][data-tab="files"]')
|
|
||||||
row = files_pane.locator("li.file-tree-row-file", has_text="server.cfg")
|
|
||||||
expect(row).to_be_visible(timeout=5000)
|
expect(row).to_be_visible(timeout=5000)
|
||||||
row.hover()
|
row.hover()
|
||||||
|
|
||||||
|
|
@ -65,46 +58,3 @@ def test_hover_download_initiates_file_download(page: Page, server_with_files) -
|
||||||
assert download.suggested_filename == "server.cfg"
|
assert download.suggested_filename == "server.cfg"
|
||||||
# Playwright auto-saves to a temp dir we can read back from.
|
# Playwright auto-saves to a temp dir we can read back from.
|
||||||
assert download.path().read_bytes() == expected_bytes
|
assert download.path().read_bytes() == expected_bytes
|
||||||
|
|
||||||
|
|
||||||
def test_tabs_switch_between_log_console_files(page: Page, server_with_files) -> None:
|
|
||||||
"""The inspection strip on /servers/<id> defaults to the Log tab and
|
|
||||||
switches tab + tabpane visibility on click. Active-tab state is
|
|
||||||
mirrored on the strip via data-active-tab.
|
|
||||||
"""
|
|
||||||
base = server_with_files["base_url"]
|
|
||||||
sid = server_with_files["server_id"]
|
|
||||||
login(page, base)
|
|
||||||
page.goto(f"{base}/servers/{sid}")
|
|
||||||
|
|
||||||
strip = page.locator("[data-tab-strip]")
|
|
||||||
expect(strip).to_be_visible()
|
|
||||||
expect(strip).to_have_attribute("data-active-tab", "log")
|
|
||||||
|
|
||||||
# Click Console
|
|
||||||
strip.locator('[role="tab"][data-tab="console"]').click()
|
|
||||||
expect(strip).to_have_attribute("data-active-tab", "console")
|
|
||||||
expect(strip.locator('[role="tabpanel"][data-tab="console"]')).to_be_visible()
|
|
||||||
expect(strip.locator('[role="tabpanel"][data-tab="log"]')).to_be_hidden()
|
|
||||||
|
|
||||||
# Click Files
|
|
||||||
strip.locator('[role="tab"][data-tab="files"]').click()
|
|
||||||
expect(strip).to_have_attribute("data-active-tab", "files")
|
|
||||||
expect(strip.locator('[role="tabpanel"][data-tab="files"]')).to_be_visible()
|
|
||||||
|
|
||||||
|
|
||||||
def test_expand_opens_matching_modal(page: Page, server_with_files) -> None:
|
|
||||||
"""Clicking ⛶ on the inspection strip opens the <dialog> whose id
|
|
||||||
matches the active tab name (data-active-tab="files" → #files-modal).
|
|
||||||
"""
|
|
||||||
base = server_with_files["base_url"]
|
|
||||||
sid = server_with_files["server_id"]
|
|
||||||
login(page, base)
|
|
||||||
page.goto(f"{base}/servers/{sid}")
|
|
||||||
|
|
||||||
strip = page.locator("[data-tab-strip]")
|
|
||||||
strip.locator('[role="tab"][data-tab="files"]').click()
|
|
||||||
strip.locator(".strip-expand").click()
|
|
||||||
|
|
||||||
dialog = page.locator("dialog#files-modal")
|
|
||||||
expect(dialog).to_have_attribute("open", "")
|
|
||||||
|
|
|
||||||
|
|
@ -571,40 +571,6 @@ def test_live_state_fragment_renders_current_and_recent(user_client_with_bluepri
|
||||||
assert "steamcommunity.com/profiles/76561198021234567" in html
|
assert "steamcommunity.com/profiles/76561198021234567" in html
|
||||||
|
|
||||||
|
|
||||||
def test_live_state_total_count_reflects_truth_above_limit(user_client_with_blueprints) -> None:
|
|
||||||
"""recent_players_total_count must reflect all matching distinct steam IDs,
|
|
||||||
not just the 50 returned by the capped query."""
|
|
||||||
from datetime import timedelta
|
|
||||||
from l4d2web.models import Server, ServerPlayerSession
|
|
||||||
|
|
||||||
client, data = user_client_with_blueprints
|
|
||||||
now = datetime.now(UTC)
|
|
||||||
with session_scope() as db:
|
|
||||||
srv = Server(
|
|
||||||
user_id=data["user_id"], blueprint_id=data["blueprint_id"],
|
|
||||||
name="bigsrv", port=27820, rcon_password="x", actual_state="running",
|
|
||||||
)
|
|
||||||
db.add(srv); db.flush()
|
|
||||||
srv_id = srv.id
|
|
||||||
for i in range(60):
|
|
||||||
steam_id = f"7656119800000{i:04d}"
|
|
||||||
db.add(ServerPlayerSession(
|
|
||||||
server_id=srv_id,
|
|
||||||
steam_id_64=steam_id,
|
|
||||||
joined_at=now - timedelta(hours=i + 2),
|
|
||||||
left_at=now - timedelta(hours=i + 1),
|
|
||||||
name_at_join=f"Player{i}",
|
|
||||||
min_ping=10,
|
|
||||||
max_ping=50,
|
|
||||||
))
|
|
||||||
|
|
||||||
res = client.get(f"/servers/{srv_id}/live-state")
|
|
||||||
assert res.status_code == 200
|
|
||||||
html = res.get_data(as_text=True)
|
|
||||||
assert "60 Recent" in html, "expected true count (60), not capped count (50)"
|
|
||||||
assert "50 Recent" not in html
|
|
||||||
|
|
||||||
|
|
||||||
def test_reset_operation_enqueues_job_and_stops_desired_state(user_client_with_blueprints) -> None:
|
def test_reset_operation_enqueues_job_and_stops_desired_state(user_client_with_blueprints) -> None:
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
|
@ -719,41 +685,6 @@ def test_rename_preserves_hostname(user_client_with_blueprints) -> None:
|
||||||
assert server.hostname == "My Cool Server", "rename must not wipe hostname"
|
assert server.hostname == "My Cool Server", "rename must not wipe hostname"
|
||||||
|
|
||||||
|
|
||||||
def test_server_detail_no_inline_job_log_pre(user_client_with_blueprints) -> None:
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from l4d2web.models import Job, 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="jobserver",
|
|
||||||
port=27099,
|
|
||||||
rcon_password="x",
|
|
||||||
actual_state="running",
|
|
||||||
)
|
|
||||||
db.add(server)
|
|
||||||
db.flush()
|
|
||||||
job = Job(
|
|
||||||
user_id=data["user_id"],
|
|
||||||
server_id=server.id,
|
|
||||||
operation="start",
|
|
||||||
state="running",
|
|
||||||
)
|
|
||||||
db.add(job)
|
|
||||||
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)
|
|
||||||
assert 'class="log-stream job-log"' not in html
|
|
||||||
assert 'data-inline-modal-open="job-log-modal"' in html
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_server_clears_hostname(user_client_with_blueprints) -> None:
|
def test_update_server_clears_hostname(user_client_with_blueprints) -> None:
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from l4d2web.models import Server
|
from l4d2web.models import Server
|
||||||
|
|
@ -784,74 +715,3 @@ def test_update_server_clears_hostname(user_client_with_blueprints) -> None:
|
||||||
server = session.scalar(select(Server).where(Server.name == "alpha"))
|
server = session.scalar(select(Server).where(Server.name == "alpha"))
|
||||||
assert server is not None
|
assert server is not None
|
||||||
assert server.hostname == ""
|
assert server.hostname == ""
|
||||||
|
|
||||||
|
|
||||||
def test_server_detail_escapes_hostname(user_client_with_blueprints) -> None:
|
|
||||||
"""Hostname containing HTML must be escaped, not rendered as markup.
|
|
||||||
Regression guard for the macro-call refactor that ensures Jinja
|
|
||||||
auto-escaping is preserved on the config grid."""
|
|
||||||
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="xss-test",
|
|
||||||
port=27299,
|
|
||||||
rcon_password="x",
|
|
||||||
hostname='<script>alert("xss")</script>',
|
|
||||||
)
|
|
||||||
db.add(server)
|
|
||||||
db.flush()
|
|
||||||
server_id = server.id
|
|
||||||
|
|
||||||
resp = client.get(f"/servers/{server_id}")
|
|
||||||
assert resp.status_code == 200
|
|
||||||
body = resp.get_data(as_text=True)
|
|
||||||
# The literal tag must not appear; the escaped form must.
|
|
||||||
assert "<script>alert(" not in body
|
|
||||||
assert "<script>" in body
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
|
||||||
|
|
@ -71,103 +71,3 @@ def test_status_precedence() -> None:
|
||||||
from l4d2web.services.status import compute_display_state
|
from l4d2web.services.status import compute_display_state
|
||||||
|
|
||||||
assert compute_display_state("start", "stopped") == "starting"
|
assert compute_display_state("start", "stopped") == "starting"
|
||||||
|
|
||||||
|
|
||||||
def test_live_state_exposes_recent_overview_and_total_count(owner_client_with_server) -> None:
|
|
||||||
"""Route must expose recent_players_overview (≤10) and recent_players_total_count.
|
|
||||||
|
|
||||||
The body-text assertions depend on the template change in Task 3.
|
|
||||||
The xfail marker will be removed once Task 3 lands.
|
|
||||||
"""
|
|
||||||
from datetime import timedelta
|
|
||||||
from l4d2web.models import ServerPlayerSession
|
|
||||||
|
|
||||||
client, server_id = owner_client_with_server
|
|
||||||
|
|
||||||
now = datetime.now(UTC)
|
|
||||||
total = 13
|
|
||||||
|
|
||||||
with session_scope() as db:
|
|
||||||
for i in range(total):
|
|
||||||
db.add(ServerPlayerSession(
|
|
||||||
server_id=server_id,
|
|
||||||
steam_id_64=str(76561190000000000 + i),
|
|
||||||
joined_at=now - timedelta(hours=i + 2),
|
|
||||||
left_at=now - timedelta(hours=i + 1),
|
|
||||||
name_at_join=f"Player{i}",
|
|
||||||
min_ping=10,
|
|
||||||
max_ping=50,
|
|
||||||
))
|
|
||||||
|
|
||||||
res = client.get(f"/servers/{server_id}/live-state")
|
|
||||||
assert res.status_code == 200
|
|
||||||
html = res.get_data(as_text=True)
|
|
||||||
|
|
||||||
# These assertions depend on the Task 3 template changes.
|
|
||||||
assert "13 Recent" in html
|
|
||||||
assert html.count("recent-chip") <= 10
|
|
||||||
|
|
||||||
|
|
||||||
def test_live_state_recent_header_not_clickable_when_le_10(owner_client_with_server) -> None:
|
|
||||||
"""When recent player count is ≤ 10, the header must be plain text, not a modal trigger."""
|
|
||||||
from datetime import timedelta
|
|
||||||
from l4d2web.models import ServerPlayerSession
|
|
||||||
|
|
||||||
client, server_id = owner_client_with_server
|
|
||||||
|
|
||||||
now = datetime.now(UTC)
|
|
||||||
total = 4
|
|
||||||
|
|
||||||
with session_scope() as db:
|
|
||||||
for i in range(total):
|
|
||||||
db.add(ServerPlayerSession(
|
|
||||||
server_id=server_id,
|
|
||||||
steam_id_64=str(76561190000000000 + i),
|
|
||||||
joined_at=now - timedelta(hours=i + 2),
|
|
||||||
left_at=now - timedelta(hours=i + 1),
|
|
||||||
name_at_join=f"Player{i}",
|
|
||||||
min_ping=10,
|
|
||||||
max_ping=50,
|
|
||||||
))
|
|
||||||
|
|
||||||
res = client.get(f"/servers/{server_id}/live-state")
|
|
||||||
assert res.status_code == 200
|
|
||||||
html = res.get_data(as_text=True)
|
|
||||||
|
|
||||||
assert "4 Recent" in html
|
|
||||||
assert 'data-inline-modal-open="recent-players-modal"' not in html
|
|
||||||
|
|
||||||
|
|
||||||
def test_live_state_recent_modal_view_returns_single_column_chip_list(owner_client_with_server) -> None:
|
|
||||||
"""?view=recent-modal renders all recent players (not just the
|
|
||||||
10-item overview) in a single-column scrollable list using the
|
|
||||||
same chip markup."""
|
|
||||||
from datetime import timedelta
|
|
||||||
from l4d2web.models import ServerPlayerSession
|
|
||||||
|
|
||||||
client, server_id = owner_client_with_server
|
|
||||||
|
|
||||||
now = datetime.now(UTC)
|
|
||||||
total = 13
|
|
||||||
|
|
||||||
with session_scope() as db:
|
|
||||||
for i in range(total):
|
|
||||||
db.add(ServerPlayerSession(
|
|
||||||
server_id=server_id,
|
|
||||||
steam_id_64=str(76561190000000000 + i),
|
|
||||||
joined_at=now - timedelta(hours=i + 2),
|
|
||||||
left_at=now - timedelta(hours=i + 1),
|
|
||||||
name_at_join=f"Player{i}",
|
|
||||||
min_ping=10,
|
|
||||||
max_ping=50,
|
|
||||||
))
|
|
||||||
|
|
||||||
resp = client.get(f"/servers/{server_id}/live-state?view=recent-modal")
|
|
||||||
assert resp.status_code == 200
|
|
||||||
body = resp.get_data(as_text=True)
|
|
||||||
# All 13 chips present (no slice in the modal view).
|
|
||||||
assert body.count('class="player-card recent-chip"') == 13
|
|
||||||
# Has the modal-list wrapper class.
|
|
||||||
assert 'class="player-grid recent recent-modal-list"' in body
|
|
||||||
# No "Current" or "N Recent" header — modal view is just the list.
|
|
||||||
assert "Recent" not in body or "Recent</button>" not in body # accept either: the test is about the list rendering, not headers
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue