Compare commits

...

3 commits

Author SHA1 Message Date
mwiegand
fa686f11e3
feat(l4d2-web): server + overlay detail — live-refresh via HTMX, restructured
Vendors HTMX 2.0.4 (the prior file was a 1-line stub) and uses it to poll
two new partials on a 2s tick while a job is in flight:

- /servers/<id>/actions → state badge, filtered action buttons,
  last-job sentence, live job log (SSE) while a Start/Stop/Reset job
  is running. When the job is terminal the partial re-renders without
  hx-trigger and polling stops.
- /overlays/<id>/build-status → build state badge, last-build
  sentence, live job log while a build_overlay job is running. Same
  terminal-state stop behavior.

Server detail restructure:
- Editable name moves out of the page body into a Rename modal
  triggered from a link next to Delete in the page footer.
- Compact dl with Port (linked as steam://run/550//+connect <host>:<port>)
  and Blueprint.
- Actions row: state badge + state-filtered buttons (start/stop, reset)
  + last-job sentence. Drift warning when desired ≠ actual.
- Recent Jobs table removed.

Overlay detail restructure:
- Single panel, dl Type/Scope, no separate Last build row, no Builds
  section.
- Script form gets two compound submits: "Save and build" and
  "Save, reset and rebuild". Standalone Rebuild/Wipe gone.
- Build status state badge + last-build sentence under the editor;
  action buttons hide while a build is in flight.
- Rename modal in the page footer next to Delete.

sse.js binds on htmx:load (covers initial document and post-swap inserts)
and closes EventSources on htmx:beforeCleanupElement to avoid leaking
streams across swaps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:27:30 +02:00
mwiegand
3c4bd6880a
refactor(l4d2-web): detail-page UI — single panel, soft border, footer Delete
- Detail panels: softer (color-mix --line-soft) border. h2 sub-section
  spacing inside a single outer panel. admin and job_detail collapse to
  one panel each.
- Color tokens: --color-button-primary / --color-button-danger stay
  saturated in dark mode so white text on filled buttons stays readable.
- Site header: transparent, no full-width bar; aligned with panel-content
  width. No more sticky.
- Page-level Delete: low-contrast outline button at the page footer
  (left side, justify-content flex-start). Save buttons no longer
  full-width (.stack > button { justify-self: end }).
- form-actions-inline helper for right-aligned button rows.
- New service: l4d2web.services.timeago.humanize_delta — used by the
  upcoming server / overlay live-status partials.
- Server route: POST /servers/<id> renames the server (mirrors the
  overlay update pattern, returns 409 on per-user duplicate).
- Overlay route: POST /overlays/<id>/script handles `action` form value
  — `save_build` (default) or `save_reset_build` (wipes overlay dir
  before queuing build). Redirect lands on /overlays/<id> instead of
  the job page so users see the live status.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:26:57 +02:00
mwiegand
985df970f8
feat(l4d2-web): per-overlay server.cfg aliases — expose checkbox + auto-exec
Each linked overlay gets a checkbox on the blueprint detail page that opts
its server.cfg in as exec server_overlay_<id>. The web app builds the
spec with {path, alias} per overlay and prepends exec server_overlay_<id>
lines to the blueprint config in lowest-overlay-first order. The host
stages those copies in the overlayfs upper layer before mounting (avoids
copy-up writes against a sandbox-uid file). A live preview block above the
Config textarea shows what gets auto-executed.

Schema:
- alembic 0007: BlueprintOverlay.expose_server_cfg BOOLEAN

Spec contract:
- l4d2host OverlayRef(path, alias?). load_spec accepts both bare-string
  and {path, alias} entries.

Side effects folded in (same file in l4d2_facade):
- start_server auto-initializes; the manual Initialize step is no longer
  needed before Start.
- initialize_server no longer runs blueprint builders — builds happen on
  overlay save, not on every server Start.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:26:31 +02:00
31 changed files with 1300 additions and 252 deletions

View file

@ -0,0 +1,140 @@
# Per-overlay `server.cfg` aliases — opt-in via blueprint checkbox
## Context
L4D2 overlays stack via kernel overlayfs. When two overlays both ship `left4dead2/cfg/server.cfg`, the topmost wins; lower-layer copies become unreachable. On top of that, the blueprint's own `server.cfg` is copied into `merged/.../cfg/server.cfg` at instance start (`l4d2host/instances.py:112-115`), so the merged view's `server.cfg` is always the blueprint's.
We want a per-blueprint opt-in mechanism: for each linked overlay, the blueprint owner can check a box to expose that overlay's `server.cfg` as a reloadable alias under a known name. The alias is identified by overlay id (`server_overlay_<id>.cfg`), so it's stable across overlay renames and namespaced.
Trade-off accepted: only checked overlays are addressable in the in-game console. That's intentional — explicit opt-in beats automatic exposure of every overlay's config.
Earlier rounds of this plan considered (and rejected):
- Doing it in each script overlay: too easy to forget, doesn't scale.
- A name-as-slug constraint with auto-aliasing for every overlay: more invasive (regex on names, blueprint-wide collision checks) and exposes everything by default.
## Approach
- New boolean column `expose_server_cfg` on `BlueprintOverlay` (per-blueprint, per-overlay state).
- Blueprint detail page: each linked overlay row gets a checkbox labeled with its alias (`exec server_overlay_<id>`).
- Spec yaml carries an optional `alias` per overlay; web app sets it to `overlay_<id>` when the box is checked, otherwise omits it.
- Host copies `<lowerdir>/left4dead2/cfg/server.cfg``merged/left4dead2/cfg/server_<alias>.cfg` at instance start, only for entries with `alias` set and an existing source. Pre-sweep removes stale aliases from prior starts.
- **Auto-inject `exec` lines into the blueprint's final `server.cfg`**: for each opted-in overlay, prepend `exec server_overlay_<id>` to the config list, in `BlueprintOverlay.position` ascending order (lowest overlay first, highest last), with the user's custom config lines appended after. Source-style cfg semantics: later lines override earlier ones, so this gives "lowest overlay's settings → higher overlay's settings → blueprint customizations" in the right precedence.
No constraint on `Overlay.name`. No alias / slug column on `Overlay`. The previously-added manual `cp` in `competitive_rework.sh` gets reverted (the framework will do it when checked).
## Changes
### 1. Schema
`l4d2web/models.py``BlueprintOverlay` gets:
```python
expose_server_cfg: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=text("0"))
```
`l4d2web/alembic/versions/0007_blueprint_overlay_expose_server_cfg.py` — new Alembic migration:
- `op.add_column("blueprint_overlays", sa.Column("expose_server_cfg", sa.Boolean(), nullable=False, server_default=sa.text("0")))`
- Downgrade drops the column.
### 2. Spec contract (host ↔ web)
`l4d2host/spec.py`: replace `overlays: list[str]` with typed refs.
```python
@dataclass(slots=True)
class OverlayRef:
path: str
alias: str | None = None # if set, copy server.cfg to server_<alias>.cfg in merged
@dataclass(slots=True)
class InstanceSpec:
port: int
overlays: list[OverlayRef] = field(default_factory=list)
arguments: list[str] = field(default_factory=list)
config: list[str] = field(default_factory=list)
```
`load_spec` accepts both shapes per overlay entry: a bare string is treated as `OverlayRef(path=string)` (back-compat for hand-written specs and existing tests); a dict carries `path` and optional `alias`.
`l4d2web/services/l4d2_facade.py`:
- `load_server_blueprint_bundle`: change select to `select(Overlay.id, Overlay.path, BlueprintOverlay.expose_server_cfg)`, ordered by `BlueprintOverlay.position` ascending (already the case). Returns the raw list of (id, path, expose) tuples to the caller.
- `build_server_spec_payload`:
- Emit overlays as dicts: `{"path": p}` if not exposed, `{"path": p, "alias": f"overlay_{i}"}` if exposed.
- Build `exec_lines = [f"exec server_overlay_{i}" for i, _, expose in rows if expose]` — same ordering as overlays (lowest first).
- Set `config = exec_lines + json.loads(blueprint.config)`. Net effect: `exec` lines appear at the top of the written `instance_dir/server.cfg`, blueprint custom lines follow.
### 3. Lowerdir construction (host)
`l4d2host/instances.py:44`:
```python
lowerdirs = [str(overlay_path(o.path, root=root)) for o in spec.overlays]
```
### 4. Per-overlay copy in `start_instance` (host)
`l4d2host/instances.py`, after the existing main `server.cfg` copy. New block:
```python
emit_step("copying per-overlay server.cfg aliases...", on_stdout, passthrough)
cfg_dir = runtime_dir / "merged" / "left4dead2" / "cfg"
for stale in cfg_dir.glob("server_*.cfg"):
stale.unlink()
for o in spec.overlays:
if not o.alias:
continue
src = root / "overlays" / o.path / "left4dead2" / "cfg" / "server.cfg"
if not src.exists():
continue
shutil.copy2(src, cfg_dir / f"server_{o.alias}.cfg")
```
- Sweep first: prevents orphans when a checkbox is unticked or an overlay is removed from the blueprint.
- Skip overlays with no `alias` (not opted in) and overlays whose lower dir has no `server.cfg` (workshop overlays etc.).
- Writes go to the upper layer of the overlayfs mount; lower dirs untouched.
### 5. Blueprint detail UI
`l4d2web/templates/blueprint_detail.html` — extend each linked-overlay `<li>` with a checkbox + label showing the alias inline.
`l4d2web/routes/page_routes.py` `blueprint_page`: also pass an `overlay_expose_state: dict[int, bool]` keyed by overlay_id so the template can read the current `expose_server_cfg` value.
`l4d2web/routes/blueprint_routes.py` (`replace_blueprint_overlays` and its callers): also read `expose_server_cfg_ids` from the form (`request.form.getlist("expose_server_cfg_ids")`), convert to `set[int]`, and set `BlueprintOverlay.expose_server_cfg = (overlay_id in expose_set)` per row.
### 6. Revert the manual cp
`examples/script-overlays/competitive_rework.sh`: remove the `cp "$DEST/cfg/server.cfg" "$DEST/cfg/server_competitive.cfg"` block added in the previous round. The framework handles this on demand now.
## Critical files
- `l4d2web/models.py``BlueprintOverlay.expose_server_cfg`
- `l4d2web/alembic/versions/0007_*.py` — new Alembic migration
- `l4d2web/routes/blueprint_routes.py` — read checkbox set on save, persist expose flag
- `l4d2web/routes/page_routes.py` — pass overlay state map to template
- `l4d2web/templates/blueprint_detail.html` — checkbox + alias display
- `l4d2web/services/l4d2_facade.py` — emit alias per overlay in spec payload + prepend exec lines
- `l4d2host/spec.py``OverlayRef` dataclass + spec deserialization
- `l4d2host/instances.py` — lowerdir construction + per-overlay copy step + sweep
- `examples/script-overlays/competitive_rework.sh` — remove manual `cp`
## Out of scope
- No constraint on `Overlay.name`.
- No `cfg_alias` / `slug` column on `Overlay`.
- No per-blueprint custom alias text (id-based naming is fixed: `overlay_<id>`).
- No automatic detection of which overlays ship a `server.cfg` to gate the checkbox in UI — checkbox is always available; the host silently skips at start time if the source doesn't exist.
## Verification
1. **Unit tests**:
- `l4d2host/tests/`: `start_instance` test where two overlays exist on disk — one with `server.cfg`, one without; spec marks both with aliases; assert only the one with a source produces `server_<alias>.cfg` in merged. Pre-existing `server_old.cfg` in merged is swept.
- `l4d2host/tests/`: spec yaml round-trip test for `OverlayRef` with and without `alias`; back-compat test for bare-string entries.
- `l4d2web/tests/`: blueprint payload build asserts overlays without `expose_server_cfg` produce no `alias`; with, produce `overlay_<id>`.
- `l4d2web/tests/`: blueprint payload `config` field equals `["exec server_overlay_<id_low>", "exec server_overlay_<id_high>", *blueprint_custom_lines]``exec` lines in `BlueprintOverlay.position` ascending order, custom lines last, no exec lines for unchecked overlays.
- `l4d2web/tests/`: form submit with `expose_server_cfg_ids=[6, 8]` updates the matching `BlueprintOverlay` rows; unchecked rows reset to false.
- Run: `pytest l4d2host/tests -q`, `pytest l4d2web/tests -q`.
2. **End-to-end on the test server (`ckn@10.0.4.128`)**:
- Deploy via `deploy/deploy-test-server.sh`.
- Blueprint detail: each linked overlay shows a checkbox with its alias label.
- Tick the box for `competitive_rework`; save; reload; checkbox stays checked.
- Start a server using that blueprint: `ls /var/lib/left4me/runtime/<name>/merged/left4dead2/cfg/server_*.cfg` → shows `server_overlay_<id>.cfg` for the checked overlay only.
- Inspect the written `server.cfg`: `head -n 5 /var/lib/left4me/instances/<name>/server.cfg` → top lines are `exec server_overlay_<id>` for each checked overlay in lowest-first order, followed by the blueprint's custom lines.
- In-game console: server boot should auto-load the per-overlay configs.
- Untick the box, restart the server → `server_overlay_<id>.cfg` no longer present in merged, and the corresponding `exec` line is no longer in the written `server.cfg`.
3. **Negative**: tick an overlay that doesn't ship a `server.cfg` (e.g. a workshop overlay) → start succeeds, no alias file produced (host skipped silently).

View file

@ -41,7 +41,7 @@ def initialize_instance(
(runtime_dir / "merged").mkdir(parents=True, exist_ok=True)
instance_dir.mkdir(parents=True, exist_ok=True)
lowerdirs = [str(overlay_path(overlay, root=root)) for overlay in spec.overlays]
lowerdirs = [str(overlay_path(o.path, root=root)) for o in spec.overlays]
lowerdirs.append(str(root / "installation"))
emit_step("writing instance.env...", on_stdout, passthrough)
@ -57,6 +57,9 @@ def initialize_instance(
emit_step("writing server.cfg...", on_stdout, passthrough)
server_cfg = "\n".join(spec.config) if spec.config else ""
(instance_dir / "server.cfg").write_text(server_cfg)
emit_step("persisting spec...", on_stdout, passthrough)
shutil.copy2(spec_path, instance_dir / "spec.yaml")
emit_step("initialization complete.", on_stdout, passthrough)
@ -97,6 +100,27 @@ def start_instance(
stderr=f"runtime overlay already mounted at {merged}; refusing to double-mount",
)
# Stage cfg files in the upper layer BEFORE mounting. Writing through
# merged after the mount triggers overlayfs copy-up, which preserves the
# lower file's ownership — and a script-sandbox-built `server.cfg` is
# owned by `l4d2-sandbox`, not the worker. Pre-mount writes go straight to
# upper with the worker's uid; the kernel just shows them at the top of
# the merged stack once mounted.
emit_step("staging server.cfg + per-overlay aliases in upper layer...", on_stdout, passthrough)
upper_cfg_dir = runtime_dir / "upper" / "left4dead2" / "cfg"
upper_cfg_dir.mkdir(parents=True, exist_ok=True)
for stale in upper_cfg_dir.glob("server*.cfg"):
stale.unlink()
shutil.copy2(instance_dir / "server.cfg", upper_cfg_dir / "server.cfg")
spec = load_spec(instance_dir / "spec.yaml")
for o in spec.overlays:
if not o.alias:
continue
src = root / "overlays" / o.path / "left4dead2" / "cfg" / "server.cfg"
if not src.exists():
continue
shutil.copy2(src, upper_cfg_dir / f"server_{o.alias}.cfg")
emit_step("mounting runtime overlay...", on_stdout, passthrough)
_mounter.mount(
lowerdirs=env["L4D2_LOWERDIRS"],
@ -109,11 +133,6 @@ def start_instance(
should_cancel=should_cancel,
)
emit_step("copying server.cfg to runtime...", on_stdout, passthrough)
target_cfg = runtime_dir / "merged" / "left4dead2" / "cfg" / "server.cfg"
target_cfg.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(instance_dir / "server.cfg", target_cfg)
emit_step("starting systemd service...", on_stdout, passthrough)
start_service(
name,

View file

@ -4,19 +4,38 @@ from pathlib import Path
import yaml
@dataclass(slots=True)
class OverlayRef:
path: str
alias: str | None = None
@dataclass(slots=True)
class InstanceSpec:
port: int
overlays: list[str] = field(default_factory=list)
overlays: list[OverlayRef] = field(default_factory=list)
arguments: list[str] = field(default_factory=list)
config: list[str] = field(default_factory=list)
def _parse_overlay(item) -> OverlayRef:
if isinstance(item, str):
return OverlayRef(path=item)
if isinstance(item, dict):
path = item.get("path")
if not isinstance(path, str) or not path:
raise ValueError(f"overlay entry missing 'path': {item!r}")
raw_alias = item.get("alias")
alias = str(raw_alias) if raw_alias not in (None, "") else None
return OverlayRef(path=path, alias=alias)
raise ValueError(f"unsupported overlay entry type: {type(item).__name__}")
def load_spec(path: Path) -> InstanceSpec:
raw = yaml.safe_load(path.read_text()) or {}
return InstanceSpec(
port=int(raw["port"]),
overlays=[str(item) for item in raw.get("overlays", [])],
overlays=[_parse_overlay(item) for item in raw.get("overlays", [])],
arguments=[str(item) for item in raw.get("arguments", [])],
config=[str(item) for item in raw.get("config", [])],
)

View file

@ -46,5 +46,22 @@ def test_initialize_instance_emits_steps(tmp_path: Path) -> None:
"Step: creating instance directories...",
"Step: writing instance.env...",
"Step: writing server.cfg...",
"Step: persisting spec...",
"Step: initialization complete.",
]
def test_initialize_persists_spec_to_instance_dir(tmp_path: Path) -> None:
spec = tmp_path / "spec.yaml"
spec.write_text(
"port: 27015\n"
"overlays:\n"
" - {path: '5', alias: overlay_5}\n"
" - path: '6'\n"
)
initialize_instance("alpha", spec, root=tmp_path)
persisted = tmp_path / "instances" / "alpha" / "spec.yaml"
assert persisted.exists()
assert "overlay_5" in persisted.read_text()

View file

@ -27,6 +27,7 @@ def test_start_order(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"L4D2_PORT=27015\nL4D2_ARGS=-tickrate 100\nL4D2_LOWERDIRS=/x:/y\n"
)
(instance_dir / "server.cfg").write_text("sv_consistency 1")
(instance_dir / "spec.yaml").write_text("port: 27015\noverlays: [x, y]\n")
monkeypatch.setattr("l4d2host.fs.kernel_overlayfs.run_command", fake_run_command)
monkeypatch.setattr("l4d2host.service_control.run_command", fake_run_command)
@ -43,6 +44,49 @@ def test_start_order(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
assert calls[1] == ["sudo", "-n", "/usr/local/libexec/left4me/left4me-systemctl", "start", "alpha"]
def test_start_copies_per_overlay_aliases_and_sweeps_stale(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
def fake_run_command(cmd, **kwargs):
del cmd, kwargs
instance_dir = tmp_path / "instances" / "alpha"
runtime_dir = tmp_path / "runtime" / "alpha"
upper_cfg_dir = runtime_dir / "upper" / "left4dead2" / "cfg"
upper_cfg_dir.mkdir(parents=True, exist_ok=True)
(runtime_dir / "merged").mkdir(parents=True, exist_ok=True)
instance_dir.mkdir(parents=True, exist_ok=True)
(instance_dir / "instance.env").write_text(
"L4D2_PORT=27015\nL4D2_ARGS=\nL4D2_LOWERDIRS=/x\n"
)
(instance_dir / "server.cfg").write_text("exec server_overlay_5\n")
(instance_dir / "spec.yaml").write_text(
"port: 27015\n"
"overlays:\n"
" - {path: '5', alias: overlay_5}\n"
" - {path: '6', alias: overlay_6}\n"
" - path: '7'\n"
)
src_5 = tmp_path / "overlays" / "5" / "left4dead2" / "cfg"
src_5.mkdir(parents=True, exist_ok=True)
(src_5 / "server.cfg").write_text("sv_consistency 1\n")
src_7 = tmp_path / "overlays" / "7" / "left4dead2" / "cfg"
src_7.mkdir(parents=True, exist_ok=True)
(src_7 / "server.cfg").write_text("ignored: alias not set\n")
(upper_cfg_dir / "server_orphan.cfg").write_text("from previous start\n")
monkeypatch.setattr("l4d2host.fs.kernel_overlayfs.run_command", fake_run_command)
monkeypatch.setattr("l4d2host.service_control.run_command", fake_run_command)
start_instance("alpha", root=tmp_path)
assert (upper_cfg_dir / "server.cfg").read_text() == "exec server_overlay_5\n"
assert (upper_cfg_dir / "server_overlay_5.cfg").read_text() == "sv_consistency 1\n"
assert not (upper_cfg_dir / "server_overlay_6.cfg").exists(), "no source server.cfg → no alias"
assert not (upper_cfg_dir / "server_orphan.cfg").exists(), "stale alias must be swept"
assert not (upper_cfg_dir / "server_overlay_7.cfg").exists(), "no alias in spec → no copy"
def test_start_refuses_to_double_mount(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[list[str]] = []

View file

@ -2,15 +2,30 @@ from pathlib import Path
import pytest
from l4d2host.spec import load_spec
from l4d2host.spec import OverlayRef, load_spec
def test_minimal_spec_parses(tmp_path: Path) -> None:
def test_minimal_spec_parses_string_shorthand(tmp_path: Path) -> None:
path = tmp_path / "server.yaml"
path.write_text("port: 27015\noverlays: [standard]\n")
spec = load_spec(path)
assert spec.port == 27015
assert spec.overlays == ["standard"]
assert spec.overlays == [OverlayRef(path="standard")]
def test_overlay_dict_with_alias(tmp_path: Path) -> None:
path = tmp_path / "server.yaml"
path.write_text(
"port: 27015\n"
"overlays:\n"
" - {path: '6', alias: overlay_6}\n"
" - path: '7'\n"
)
spec = load_spec(path)
assert spec.overlays == [
OverlayRef(path="6", alias="overlay_6"),
OverlayRef(path="7", alias=None),
]
def test_defaults_are_empty_lists(tmp_path: Path) -> None:
@ -34,3 +49,10 @@ def test_unknown_keys_ignored(tmp_path: Path) -> None:
path.write_text("port: 27015\nfoo: bar\n")
spec = load_spec(path)
assert spec.port == 27015
def test_overlay_dict_missing_path_rejected(tmp_path: Path) -> None:
path = tmp_path / "server.yaml"
path.write_text("port: 27015\noverlays:\n - {alias: overlay_6}\n")
with pytest.raises(ValueError):
load_spec(path)

View file

@ -0,0 +1,33 @@
"""blueprint_overlays.expose_server_cfg
Revision ID: 0007_blueprint_overlay_expose_server_cfg
Revises: 0006_server_name_per_user
Create Date: 2026-05-08
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0007_blueprint_overlay_expose_server_cfg"
down_revision: Union[str, Sequence[str], None] = "0006_server_name_per_user"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"blueprint_overlays",
sa.Column(
"expose_server_cfg",
sa.Boolean(),
nullable=False,
server_default=sa.text("0"),
),
)
def downgrade() -> None:
with op.batch_alter_table("blueprint_overlays") as batch_op:
batch_op.drop_column("expose_server_cfg")

View file

@ -117,6 +117,9 @@ class BlueprintOverlay(Base):
blueprint_id: Mapped[int] = mapped_column(ForeignKey("blueprints.id"), nullable=False)
overlay_id: Mapped[int] = mapped_column(ForeignKey("overlays.id"), nullable=False)
position: Mapped[int] = mapped_column(Integer, nullable=False)
expose_server_cfg: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default=text("0")
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)

View file

@ -31,10 +31,35 @@ def ordered_overlay_ids_from_form() -> list[int]:
return [overlay_id for _, _, overlay_id in sorted(ordered)]
def replace_blueprint_overlays(db, blueprint_id: int, overlay_ids: list[int]) -> None:
def replace_blueprint_overlays(
db,
blueprint_id: int,
overlay_ids: list[int],
expose_ids: set[int] | None = None,
) -> None:
expose_ids = expose_ids or set()
db.execute(delete(BlueprintOverlay).where(BlueprintOverlay.blueprint_id == blueprint_id))
for position, overlay_id in enumerate(overlay_ids):
db.add(BlueprintOverlay(blueprint_id=blueprint_id, overlay_id=overlay_id, position=position))
db.add(
BlueprintOverlay(
blueprint_id=blueprint_id,
overlay_id=overlay_id,
position=position,
expose_server_cfg=overlay_id in expose_ids,
)
)
def expose_overlay_ids_from_form() -> set[int]:
out: set[int] = set()
for value in request.form.getlist("expose_server_cfg_ids"):
if not value:
continue
try:
out.add(int(value))
except ValueError:
continue
return out
def overlay_ids_authorized(db, overlay_ids: list[int], user_id: int) -> bool:
@ -62,12 +87,14 @@ def create_blueprint() -> Response:
arguments = [str(item) for item in payload.get("arguments", [])]
config = [str(item) for item in payload.get("config", [])]
overlay_ids = [int(item) for item in payload.get("overlay_ids", [])]
expose_ids = {int(item) for item in payload.get("expose_server_cfg_ids", [])}
json_response = True
else:
name = request.form.get("name", "").strip()
arguments = split_textarea_lines(request.form.get("arguments", ""))
config = split_textarea_lines(request.form.get("config", ""))
overlay_ids = ordered_overlay_ids_from_form()
expose_ids = expose_overlay_ids_from_form()
json_response = False
if not name:
@ -79,7 +106,7 @@ def create_blueprint() -> Response:
blueprint = BlueprintModel(user_id=user.id, name=name, arguments=json.dumps(arguments), config=json.dumps(config))
db.add(blueprint)
db.flush()
replace_blueprint_overlays(db, blueprint.id, overlay_ids)
replace_blueprint_overlays(db, blueprint.id, overlay_ids, expose_ids)
blueprint_id = blueprint.id
if json_response:
@ -103,13 +130,14 @@ def update_blueprint_form(blueprint_id: int) -> Response:
if blueprint is None:
return Response(status=404)
overlay_ids = ordered_overlay_ids_from_form()
expose_ids = expose_overlay_ids_from_form()
if not overlay_ids_authorized(db, overlay_ids, user.id):
return Response("overlay not authorized", status=403)
blueprint.name = name
blueprint.arguments = json.dumps(split_textarea_lines(request.form.get("arguments", "")))
blueprint.config = json.dumps(split_textarea_lines(request.form.get("config", "")))
replace_blueprint_overlays(db, blueprint.id, overlay_ids)
replace_blueprint_overlays(db, blueprint.id, overlay_ids, expose_ids)
return redirect(f"/blueprints/{blueprint_id}")

View file

@ -150,14 +150,30 @@ def update_script(overlay_id: int) -> Response:
# Normalize to LF before storage so the script is well-formed when written
# to the sandbox tmpfile.
script_text = request.form.get("script", "").replace("\r\n", "\n").replace("\r", "\n")
action = request.form.get("action", "save_build")
with session_scope() as db:
overlay, err = _load_script_overlay(db, overlay_id, user)
if err is not None:
return err
overlay.script = script_text
if action == "save_reset_build":
# Wipe the overlay's working dir before queuing the rebuild so the
# next build runs against a clean tree. The wipe runs synchronously
# in the same sandbox; it's cheap (a `find … -delete`).
overlay_builders.run_sandboxed_script(
overlay_id,
WIPE_SCRIPT,
on_stdout=lambda _line: None,
on_stderr=lambda _line: None,
should_cancel=lambda: False,
)
with session_scope() as db:
job = enqueue_build_overlay(db, overlay_id=overlay_id, user_id=user.id)
job_id = job.id
return redirect(f"/jobs/{job_id}")
return redirect(f"/overlays/{overlay_id}")
@bp.post("/overlays/<int:overlay_id>/build")

View file

@ -101,6 +101,86 @@ def servers_page() -> str:
)
_OPERATION_GERUND = {
"start": "starting",
"stop": "stopping",
"reset": "resetting",
"delete": "deleting",
"initialize": "initializing",
}
_TERMINAL_JOB_STATES = {"succeeded", "failed", "cancelled"}
def _build_server_actions_context(db, server) -> dict:
from l4d2web.services.timeago import humanize_delta
latest_job = db.scalar(
select(Job)
.where(Job.server_id == server.id)
.order_by(Job.created_at.desc())
.limit(1)
)
if latest_job is not None:
db.expunge(latest_job)
actual_state = server.actual_state
desired_state = server.desired_state
active_operation = (
latest_job.operation
if latest_job is not None and latest_job.state not in _TERMINAL_JOB_STATES
else None
)
has_active_job = active_operation is not None
if has_active_job:
display_state = _OPERATION_GERUND.get(active_operation, active_operation) + ""
state_class = "state-transient"
elif actual_state == "running":
display_state = "running"
state_class = "state-running"
elif actual_state == "stopped":
display_state = "stopped"
state_class = "state-stopped"
else:
display_state = actual_state or "unknown"
state_class = "state-unknown"
visible_buttons: list[str] = []
if not has_active_job:
if actual_state == "running":
visible_buttons.append("stop")
else:
visible_buttons.append("start")
visible_buttons.append("reset")
drift = (not has_active_job) and desired_state != actual_state
latest_job_phrase: str | None = None
latest_job_when: str | None = None
latest_job_is_running = False
if latest_job is not None:
if latest_job.state in _TERMINAL_JOB_STATES:
latest_job_phrase = f"{latest_job.operation} {latest_job.state}"
ref_time = latest_job.finished_at or latest_job.created_at
else:
latest_job_phrase = _OPERATION_GERUND.get(latest_job.operation, latest_job.operation)
latest_job_is_running = True
ref_time = latest_job.started_at or latest_job.created_at
latest_job_when = humanize_delta(ref_time)
return {
"display_state": display_state,
"state_class": state_class,
"visible_buttons": visible_buttons,
"drift": drift,
"latest_job": latest_job,
"latest_job_phrase": latest_job_phrase,
"latest_job_when": latest_job_when,
"latest_job_is_running": latest_job_is_running,
}
@bp.get("/servers/<int:server_id>")
@require_login
def server_detail(server_id: int):
@ -112,23 +192,32 @@ def server_detail(server_id: int):
if server is None:
return Response(status=404)
blueprint = db.scalar(select(BlueprintModel).where(BlueprintModel.id == server.blueprint_id))
recent_job_rows = db.execute(
select(Job, User, Server)
.outerjoin(User, User.id == Job.user_id)
.outerjoin(Server, Server.id == Job.server_id)
.where(Job.server_id == server.id)
.order_by(Job.created_at.desc())
.limit(5)
).all()
ctx = _build_server_actions_context(db, server)
connect_host = request.host.split(":")[0]
return render_template(
"server_detail.html",
server=server,
blueprint=blueprint,
recent_job_rows=recent_job_rows,
connect_host=connect_host,
**ctx,
)
@bp.get("/servers/<int:server_id>/actions")
@require_login
def server_actions_fragment(server_id: int):
user = current_user()
assert user is not None
with session_scope() as db:
server = db.scalar(select(Server).where(Server.id == server_id, Server.user_id == user.id))
if server is None:
return Response(status=404)
ctx = _build_server_actions_context(db, server)
return render_template("_server_actions.html", server=server, **ctx)
@bp.get("/servers/<int:server_id>/jobs")
@require_login
def server_jobs_page(server_id: int):
@ -186,6 +275,58 @@ def overlay_jobs_page(overlay_id: int):
return render_template("overlay_jobs.html", overlay=overlay, rows=rows)
_BUILD_STATE_LABELS = {
"ok": ("ok", "state-running"),
"failed": ("failed", "state-stopped"),
"": ("never built", "state-unknown"),
}
def _build_overlay_build_status_context(db, overlay) -> dict:
from l4d2web.services.timeago import humanize_delta
latest_build = db.scalar(
select(Job)
.where(Job.operation == "build_overlay", Job.overlay_id == overlay.id)
.order_by(Job.created_at.desc())
.limit(1)
)
if latest_build is not None:
db.expunge(latest_build)
is_running = (
latest_build is not None and latest_build.state not in _TERMINAL_JOB_STATES
)
if is_running:
build_state_label = "building…"
build_state_class = "state-transient"
else:
build_state_label, build_state_class = _BUILD_STATE_LABELS.get(
overlay.last_build_status or "", _BUILD_STATE_LABELS[""]
)
latest_build_phrase: str | None = None
latest_build_when: str | None = None
if latest_build is not None:
if latest_build.state in _TERMINAL_JOB_STATES:
latest_build_phrase = f"{latest_build.operation} {latest_build.state}"
ref_time = latest_build.finished_at or latest_build.created_at
else:
latest_build_phrase = "building"
ref_time = latest_build.started_at or latest_build.created_at
latest_build_when = humanize_delta(ref_time)
return {
"latest_build": latest_build,
"latest_build_is_running": is_running,
"latest_build_phrase": latest_build_phrase,
"latest_build_when": latest_build_when,
"build_state_label": build_state_label,
"build_state_class": build_state_class,
}
@bp.get("/overlays/<int:overlay_id>")
@require_login
def overlay_detail(overlay_id: int):
@ -217,12 +358,7 @@ def overlay_detail(overlay_id: int):
.where(OverlayWorkshopItem.overlay_id == overlay.id)
.order_by(WorkshopItem.created_at)
).all()
latest_build_job = db.scalar(
select(Job)
.where(Job.operation == "build_overlay", Job.overlay_id == overlay.id)
.order_by(Job.created_at.desc())
.limit(1)
)
build_ctx = _build_overlay_build_status_context(db, overlay)
file_tree_root_entries, file_tree_truncated_count = _root_file_tree(overlay)
@ -231,15 +367,30 @@ def overlay_detail(overlay_id: int):
overlay=overlay,
using_blueprints=using_blueprints,
workshop_items=workshop_items,
latest_build_job=latest_build_job,
file_tree_root_entries=file_tree_root_entries,
file_tree_truncated=file_tree_truncated_count > 0
if file_tree_root_entries is not None
else False,
file_tree_truncated_count=file_tree_truncated_count,
**build_ctx,
)
@bp.get("/overlays/<int:overlay_id>/build-status")
@require_login
def overlay_build_status_fragment(overlay_id: int):
user = current_user()
assert user is not None
with session_scope() as db:
overlay = db.scalar(select(Overlay).where(Overlay.id == overlay_id))
if overlay is None:
return Response(status=404)
if not user.admin and overlay.user_id is not None and overlay.user_id != user.id:
return Response(status=403)
ctx = _build_overlay_build_status_context(db, overlay)
return render_template("_overlay_build_status.html", overlay=overlay, **ctx)
def _root_file_tree(overlay: Overlay) -> tuple[list[dict] | None, int]:
"""Return (entries, truncated_count) for the overlay's runtime directory,
or (None, 0) if the directory doesn't exist or the path is unresolvable
@ -290,6 +441,10 @@ def blueprint_page(blueprint_id: int):
select(BlueprintOverlay.overlay_id, BlueprintOverlay.position)
.where(BlueprintOverlay.blueprint_id == blueprint.id)
).all()
expose_rows = db.execute(
select(BlueprintOverlay.overlay_id, BlueprintOverlay.expose_server_cfg)
.where(BlueprintOverlay.blueprint_id == blueprint.id)
).all()
all_overlays = db.scalars(
select(Overlay)
.where(Overlay.user_id.is_(None) | (Overlay.user_id == user.id))
@ -297,6 +452,7 @@ def blueprint_page(blueprint_id: int):
).all()
overlay_positions = {overlay_id: position + 1 for overlay_id, position in position_rows}
overlay_expose_state = {overlay_id: bool(expose) for overlay_id, expose in expose_rows}
selected_ids = {overlay.id for overlay in selected_overlays}
available_overlays = [overlay for overlay in all_overlays if overlay.id not in selected_ids]
return render_template(
@ -307,6 +463,7 @@ def blueprint_page(blueprint_id: int):
all_overlays=all_overlays,
selected_overlay_ids=selected_ids,
overlay_positions=overlay_positions,
overlay_expose_state=overlay_expose_state,
arguments=json.loads(blueprint.arguments),
config_lines=json.loads(blueprint.config),
)

View file

@ -109,6 +109,33 @@ def create_server() -> Response:
return redirect(f"/servers/{server_id}")
@bp.post("/servers/<int:server_id>")
@require_login
def update_server_form(server_id: int) -> Response:
user = current_user()
assert user is not None
try:
name = _validate_display_name(request.form.get("name", ""))
except ValueError:
return Response("invalid server name", status=400)
with session_scope() as db:
server = db.scalar(select(Server).where(Server.id == server_id, Server.user_id == user.id))
if server is None:
return Response(status=404)
server.name = name
try:
db.flush()
except IntegrityError as exc:
db.rollback()
detail = str(exc.orig) if exc.orig is not None else str(exc)
if "servers" in detail and "name" in detail:
return Response("name already in use", status=409)
raise
return redirect(f"/servers/{server_id}")
@bp.patch("/servers/<int:server_id>")
@require_login
def update_server(server_id: int) -> Response:

View file

@ -26,16 +26,35 @@ class ServerStatus:
raw_sub_state: str
def build_server_spec_payload(server: Server, blueprint: Blueprint, overlay_refs: list[str]) -> dict:
def build_server_spec_payload(
server: Server,
blueprint: Blueprint,
overlay_rows: list[tuple[int, str, bool]],
) -> dict:
overlays: list[dict] = []
for overlay_id, path, expose in overlay_rows:
if expose:
overlays.append({"path": path, "alias": f"overlay_{overlay_id}"})
else:
overlays.append({"path": path})
# Source `exec` is last-wins. First list entry = topmost overlay = highest
# precedence, so its exec runs LAST. Emit in reverse position order.
exec_lines = [
f"exec server_overlay_{overlay_id}"
for overlay_id, _, expose in reversed(overlay_rows)
if expose
]
return {
"port": server.port,
"overlays": overlay_refs,
"overlays": overlays,
"arguments": json.loads(blueprint.arguments),
"config": json.loads(blueprint.config),
"config": exec_lines + json.loads(blueprint.config),
}
def load_server_blueprint_bundle(server_id: int) -> tuple[Server, Blueprint, list[str]]:
def load_server_blueprint_bundle(
server_id: int,
) -> tuple[Server, Blueprint, list[tuple[int, str, bool]]]:
with session_scope() as db:
server = db.scalar(select(Server).where(Server.id == server_id))
if server is None:
@ -46,13 +65,13 @@ def load_server_blueprint_bundle(server_id: int) -> tuple[Server, Blueprint, lis
raise ValueError("blueprint not found")
rows = db.execute(
select(Overlay.path)
select(Overlay.id, Overlay.path, BlueprintOverlay.expose_server_cfg)
.join(BlueprintOverlay, BlueprintOverlay.overlay_id == Overlay.id)
.where(BlueprintOverlay.blueprint_id == blueprint.id)
.order_by(BlueprintOverlay.position)
).all()
overlay_refs = [row[0] for row in rows]
return server, blueprint, overlay_refs
overlay_rows = [(int(i), str(p), bool(e)) for i, p, e in rows]
return server, blueprint, overlay_rows
def install_runtime(on_stdout=None, on_stderr=None, should_cancel=None) -> None:
@ -65,23 +84,16 @@ def install_runtime(on_stdout=None, on_stderr=None, should_cancel=None) -> None:
def initialize_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None:
server, blueprint, overlay_refs = load_server_blueprint_bundle(server_id)
server, blueprint, overlay_rows = load_server_blueprint_bundle(server_id)
# Run each overlay's builder synchronously so symlinks/dirs are present
# before l4d2ctl initialize composes the lowerdirs.
_run_blueprint_builders(
blueprint_id=blueprint.id,
on_stdout=on_stdout,
on_stderr=on_stderr,
should_cancel=should_cancel,
)
# Builders are NOT run here. Overlays rebuild from their own save/build
# flows; doing it on every Start is expensive and redundant.
# Workshop overlays may have items not yet downloaded. The builders skip
# them, but we don't want to mount a partial overlay silently — fail
# loudly with the missing IDs.
# Workshop overlays may have items not yet downloaded. Fail fast rather
# than mount a partial overlay (would silently leave maps missing in-game).
_check_workshop_overlay_caches(blueprint_id=blueprint.id)
spec_path = write_temp_spec(build_server_spec_payload(server, blueprint, overlay_refs))
spec_path = write_temp_spec(build_server_spec_payload(server, blueprint, overlay_rows))
try:
host_commands.run_command(
["l4d2ctl", "initialize", server_unit_name(server.id), "-f", str(spec_path)],
@ -175,6 +187,9 @@ def _check_workshop_overlay_caches(*, blueprint_id: int) -> None:
def start_server(server_id: int, on_stdout=None, on_stderr=None, should_cancel=None) -> None:
# Always initialize before starting so blueprint edits and overlay rebuilds
# take effect on the next start without a manual two-step.
initialize_server(server_id, on_stdout=on_stdout, on_stderr=on_stderr, should_cancel=should_cancel)
server, _, _ = load_server_blueprint_bundle(server_id)
host_commands.run_command(
["l4d2ctl", "start", server_unit_name(server.id)],

View file

@ -0,0 +1,29 @@
from datetime import UTC, datetime
def humanize_delta(then: datetime, now: datetime | None = None) -> str:
if now is None:
now = datetime.now(UTC)
if then.tzinfo is None:
then = then.replace(tzinfo=UTC)
if now.tzinfo is None:
now = now.replace(tzinfo=UTC)
seconds = int((now - then).total_seconds())
if seconds < 0:
seconds = 0
if seconds < 45:
return "just now"
if seconds < 90:
return "1 minute ago"
minutes = seconds // 60
if minutes < 60:
return f"{minutes} minutes ago"
hours = minutes // 60
if hours < 24:
return "1 hour ago" if hours == 1 else f"{hours} hours ago"
days = hours // 24
if days < 7:
return "1 day ago" if days == 1 else f"{days} days ago"
return then.date().isoformat()

View file

@ -1,12 +1,22 @@
.panel,
.card {
background: var(--color-surface);
border: var(--line);
border: var(--line-soft);
border-radius: var(--radius-m);
padding: var(--space-l);
margin-bottom: var(--space-l);
}
.panel > h2,
.panel > .page-heading {
margin-top: var(--space-xl);
}
.panel > h2:first-child,
.panel > .page-heading:first-child {
margin-top: 0;
}
.table {
width: 100%;
border-collapse: collapse;
@ -69,7 +79,7 @@ a:focus-visible {
button,
a.button {
background: var(--color-primary);
background: var(--color-button-primary);
border: none;
border-radius: var(--radius-s);
color: #fff;
@ -81,7 +91,7 @@ a.button {
button.danger,
a.button.danger {
background: var(--color-danger);
background: var(--color-button-danger);
}
.link-button {
@ -328,6 +338,19 @@ dialog.modal::backdrop {
flex: 1;
}
.overlay-picker-expose {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
color: var(--color-muted);
white-space: nowrap;
font-size: 0.9em;
}
.overlay-picker-expose code {
font-size: inherit;
}
.overlay-picker-remove {
background: none;
color: var(--color-muted);
@ -353,3 +376,137 @@ dialog.modal::backdrop {
.overlay-picker-add select {
flex: 1;
}
.section-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--color-text);
line-height: 1.3;
}
.stack > button {
justify-self: end;
}
.page-footer-actions {
display: flex;
justify-content: flex-start;
align-items: center;
gap: var(--space-s);
margin-top: var(--space-l);
}
.form-actions-inline {
display: flex;
justify-content: flex-end;
gap: var(--space-s);
}
.danger-outline,
button.danger-outline {
background: transparent;
color: var(--color-button-danger);
border: 1px solid var(--color-button-danger);
}
button.danger-outline:hover {
background: color-mix(in srgb, var(--color-button-danger) 15%, transparent);
}
.inline-save {
display: flex;
gap: var(--space-s);
align-items: stretch;
}
.inline-save > input {
flex: 1;
}
.server-info {
display: grid;
grid-template-columns: max-content 1fr;
column-gap: var(--space-l);
row-gap: var(--space-xs);
margin: var(--space-l) 0;
}
.server-info > div {
display: contents;
}
.server-info dt {
font-weight: 600;
}
.server-info dd {
margin: 0;
}
.server-actions {
display: flex;
align-items: center;
gap: var(--space-s);
flex-wrap: wrap;
}
.state-badge {
padding: var(--space-xs) var(--space-s);
border-radius: var(--radius-s);
font-size: 0.85em;
}
.state-running {
background: color-mix(in srgb, var(--color-success) 20%, transparent);
color: var(--color-success);
}
.state-stopped {
background: color-mix(in srgb, var(--color-muted) 20%, transparent);
color: var(--color-muted);
}
.state-unknown {
background: color-mix(in srgb, var(--color-muted) 15%, transparent);
color: var(--color-muted);
}
.state-transient {
background: color-mix(in srgb, var(--color-warning) 25%, transparent);
color: var(--color-warning);
}
.state-drift {
color: var(--color-warning);
margin: var(--space-s) 0 0;
}
.last-job {
color: var(--color-muted);
margin: var(--space-xs) 0 0;
}
.config-shell {
display: grid;
}
.config-preview {
margin: 0;
padding: var(--space-s) var(--space-m);
background: var(--color-surface);
color: var(--color-muted);
border: var(--line);
border-bottom: none;
border-radius: var(--radius-s) var(--radius-s) 0 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.875rem;
line-height: 1.5;
white-space: pre;
overflow-x: auto;
}
.config-preview + textarea {
border-top: none;
border-top-left-radius: 0;
border-top-right-radius: 0;
}

View file

@ -10,16 +10,13 @@ body {
}
.site-header {
background: var(--color-surface);
border-bottom: var(--line);
position: sticky;
top: 0;
background: transparent;
}
.site-header-inner {
max-width: 960px;
margin: 0 auto;
padding: var(--space-l);
padding: var(--space-l) var(--space-2xl);
display: flex;
justify-content: space-between;
align-items: center;
@ -41,5 +38,5 @@ body {
.container {
max-width: 960px;
margin: 0 auto;
padding: var(--space-2xl) var(--space-l);
padding: 0 var(--space-l) var(--space-2xl);
}

View file

@ -27,6 +27,12 @@
--radius-m: calc(var(--radius-base) * 2);
--line: 1px solid var(--color-border);
--line-soft: 1px solid color-mix(in srgb, var(--color-border) 50%, transparent);
/* Filled buttons stay saturated in both themes white text needs a deep
background to read. Don't redefine these in the dark-mode block. */
--color-button-primary: #1d4ed8;
--color-button-danger: #b42318;
}
@media (prefers-color-scheme: dark) {

View file

@ -41,6 +41,18 @@
name.className = "overlay-picker-name";
name.textContent = overlayName;
const exposeLabel = document.createElement("label");
exposeLabel.className = "overlay-picker-expose";
exposeLabel.title = "Auto-load this overlay's server.cfg before your blueprint config";
const exposeInput = document.createElement("input");
exposeInput.type = "checkbox";
exposeInput.name = "expose_server_cfg_ids";
exposeInput.value = overlayId;
const exposeText = document.createTextNode(" exec ");
const exposeCode = document.createElement("code");
exposeCode.textContent = "server.cfg";
exposeLabel.append(exposeInput, exposeText, exposeCode);
const remove = document.createElement("button");
remove.type = "button";
remove.className = "overlay-picker-remove";
@ -48,7 +60,7 @@
remove.setAttribute("aria-label", `Remove ${overlayName}`);
remove.textContent = "×";
li.append(handle, name, remove, buildHiddenInput(overlayId));
li.append(handle, name, exposeLabel, remove, buildHiddenInput(overlayId));
return li;
};

View file

@ -1,10 +1,14 @@
function streamTextToElement(element) {
if (element.dataset.sseBound === "true") {
return;
}
const url = element.dataset.sseUrl;
if (!url) {
return;
}
const source = new EventSource(url);
element._sseSource = source;
element.dataset.sseBound = "true";
const appendLine = (line) => {
element.textContent += `${line}\n`;
@ -24,6 +28,37 @@ function streamTextToElement(element) {
});
}
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll("[data-sse-url]").forEach(streamTextToElement);
});
function bindSseIn(root) {
if (!root) return;
const scope = root.matches?.("[data-sse-url]") ? [root] : [];
if (root.querySelectorAll) {
root.querySelectorAll("[data-sse-url]").forEach((el) => scope.push(el));
}
scope.forEach(streamTextToElement);
}
function closeSseIn(root) {
if (!root) return;
const scope = root.matches?.("[data-sse-url]") ? [root] : [];
if (root.querySelectorAll) {
root.querySelectorAll("[data-sse-url]").forEach((el) => scope.push(el));
}
scope.forEach((el) => {
if (el._sseSource) {
el._sseSource.close();
el._sseSource = null;
delete el.dataset.sseBound;
}
});
}
document.addEventListener("DOMContentLoaded", () => bindSseIn(document));
// HTMX fires `htmx:load` for the initial document and after every swap, so
// dynamically inserted log-stream elements get bound. `htmx:beforeCleanupElement`
// fires for elements about to be removed; close their EventSources first to
// stop the previous stream and avoid leaking sockets.
document.addEventListener("htmx:load", (event) => bindSseIn(event.detail.elt));
document.addEventListener("htmx:beforeCleanupElement", (event) =>
closeSseIn(event.detail.elt),
);

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,18 @@
<div id="overlay-build-status"
{% if latest_build_is_running %}hx-get="/overlays/{{ overlay.id }}/build-status"
hx-trigger="every 2s" hx-swap="outerHTML"{% endif %}>
<div class="server-actions">
<span class="state-badge {{ build_state_class }}">{{ build_state_label }}</span>
</div>
{% if latest_build %}
<p class="last-job">
<a href="/jobs/{{ latest_build.id }}">{{ latest_build_phrase }}</a>
{% if latest_build_is_running %}since{% endif %}
{{ latest_build_when }}
(<a href="/overlays/{{ overlay.id }}/jobs">show all</a>)
</p>
{% endif %}
{% if latest_build_is_running %}
<pre class="log-stream job-log" data-sse-url="/jobs/{{ latest_build.id }}/stream"></pre>
{% endif %}
</div>

View file

@ -0,0 +1,36 @@
<div id="server-actions"
{% if latest_job_is_running %}hx-get="/servers/{{ server.id }}/actions"
hx-trigger="every 2s" hx-swap="outerHTML"{% endif %}>
<div class="server-actions">
<span class="state-badge {{ state_class }}">{{ display_state }}</span>
{% if 'start' in visible_buttons %}
<form method="post" action="/servers/{{ server.id }}/start" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button type="submit">start</button>
</form>
{% endif %}
{% if 'stop' in visible_buttons %}
<form method="post" action="/servers/{{ server.id }}/stop" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button type="submit">stop</button>
</form>
{% endif %}
{% if 'reset' in visible_buttons %}
<button type="button" class="danger" data-modal-open="reset-server-modal">reset</button>
{% endif %}
</div>
{% if drift %}
<p class="state-drift"><strong>Warning:</strong> server is {{ server.actual_state }} but requested state is {{ server.desired_state }}.</p>
{% endif %}
{% if latest_job %}
<p class="last-job">
<a href="/jobs/{{ latest_job.id }}">{{ latest_job_phrase }}</a>
{% if latest_job_is_running %}since{% endif %}
{{ latest_job_when }}
(<a href="/servers/{{ server.id }}/jobs">show all</a>)
</p>
{% endif %}
{% if latest_job_is_running %}
<pre class="log-stream job-log" data-sse-url="/jobs/{{ latest_job.id }}/stream"></pre>
{% endif %}
</div>

View file

@ -9,18 +9,14 @@
<li><a href="/admin/users">Users</a></li>
<li><a href="/admin/jobs">Jobs</a></li>
</ul>
</section>
<section class="panel">
<h2>Runtime</h2>
<p class="muted">Queue a Steam runtime install/update job for the local host.</p>
<form method="post" action="/admin/install">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button type="submit">Install or update runtime</button>
</form>
</section>
<section class="panel">
<h2>Workshop</h2>
<p class="muted">Re-fetch metadata for every workshop item; re-download those with newer versions on Steam, then enqueue rebuilds for the affected overlays.</p>
<form method="post" action="/admin/workshop/refresh">

View file

@ -6,40 +6,60 @@
<section class="panel">
<div class="page-heading">
<h1>Blueprint: {{ blueprint.name }}</h1>
<button type="button" class="danger" data-modal-open="delete-blueprint-modal">Delete</button>
</div>
<form method="post" action="/blueprints/{{ blueprint.id }}" class="stack">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<label>Name <input name="name" value="{{ blueprint.name }}" required></label>
<p class="muted">Overlay order matters: the first overlay has highest precedence.</p>
<label><span class="section-title">Name</span><input name="name" value="{{ blueprint.name }}" required></label>
<label><span class="section-title">Arguments</span><textarea name="arguments" rows="2" spellcheck="false">{{ arguments | join('\n') }}</textarea></label>
<span class="section-title">Overlays</span>
<div class="overlay-picker">
<ol class="overlay-picker-list" data-overlay-list>
{% for overlay in selected_overlays %}
<li class="overlay-picker-row" draggable="true" data-overlay-id="{{ overlay.id }}" data-overlay-name="{{ overlay.name }}">
<span class="overlay-picker-handle" aria-hidden="true">⋮⋮</span>
<span class="overlay-picker-name">{{ overlay.name }}</span>
<label class="overlay-picker-expose" title="Auto-load this overlay's server.cfg before your blueprint config">
<input type="checkbox" name="expose_server_cfg_ids" value="{{ overlay.id }}"
{% if overlay_expose_state.get(overlay.id) %}checked{% endif %}>
exec <code>server.cfg</code>
</label>
<button type="button" class="overlay-picker-remove" data-action="remove" aria-label="Remove {{ overlay.name }}">×</button>
<input type="hidden" name="overlay_ids" value="{{ overlay.id }}">
</li>
{% endfor %}
</ol>
<p class="overlay-picker-empty muted" data-overlay-empty {% if selected_overlays %}hidden{% endif %}>No overlays selected. Pick one below to add.</p>
<label class="overlay-picker-add">
<span>Add overlay</span>
<select data-overlay-add>
<option value="">Pick a name…</option>
<div class="overlay-picker-add">
<select data-overlay-add aria-label="Add overlay">
<option value="">Add overlay…</option>
{% for overlay in available_overlays %}
<option value="{{ overlay.id }}" data-overlay-name="{{ overlay.name }}">{{ overlay.name }}</option>
{% endfor %}
</select>
</label>
</div>
<label>Arguments <textarea name="arguments" rows="8" spellcheck="false">{{ arguments | join('\n') }}</textarea></label>
<label>Config <textarea name="config" rows="8" spellcheck="false">{{ config_lines | join('\n') }}</textarea></label>
</div>
{% set exposed = [] %}
{# Source `exec` is last-wins. First overlay in the list = topmost =
highest precedence, so its exec runs LAST. Iterate the picker list in
reverse to render the preview in actual execution order. #}
{% for overlay in selected_overlays | reverse %}{% if overlay_expose_state.get(overlay.id) %}{{ exposed.append(overlay) or '' }}{% endif %}{% endfor %}
<label><span class="section-title">Config</span>
<div class="config-shell">
{% if exposed %}
<pre class="config-preview" aria-label="Auto-loaded overlay configs">{% for o in exposed %}exec {{ o.name }}.cfg
{% endfor %}</pre>
{% endif %}
<textarea name="config" rows="8" spellcheck="false">{{ config_lines | join('\n') }}</textarea>
</div>
</label>
<button type="submit">Save blueprint</button>
</form>
</section>
<div class="page-footer-actions">
<button type="button" class="danger-outline" data-modal-open="delete-blueprint-modal">Delete blueprint</button>
</div>
<dialog id="delete-blueprint-modal" class="modal" aria-labelledby="delete-blueprint-title">
<div class="modal-header">
<h2 id="delete-blueprint-title">Delete blueprint "{{ blueprint.name }}"?</h2>

View file

@ -27,9 +27,7 @@
<tr><th>Exit code</th><td>{{ job.exit_code if job.exit_code is not none else "-" }}</td></tr>
</tbody>
</table>
</section>
<section class="panel">
<h2>Job Logs</h2>
<pre class="log-stream" data-sse-url="/jobs/{{ job.id }}/stream"></pre>
</section>

View file

@ -3,61 +3,19 @@
{% block title %}Overlay {{ overlay.name }} | left4me{% endblock %}
{% block content %}
{% set can_edit = g.user.admin or (overlay.type in ['workshop', 'script'] and overlay.user_id == g.user.id) %}
<section class="panel">
<div class="page-heading">
<h1>Overlay: {{ overlay.name }}</h1>
{% set can_edit = g.user.admin or (overlay.type in ['workshop', 'script'] and overlay.user_id == g.user.id) %}
{% if can_edit %}
<button type="button" class="danger" data-modal-open="delete-overlay-modal">Delete</button>
{% endif %}
</div>
{% if can_edit %}
<form method="post" action="/overlays/{{ overlay.id }}" class="stack">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<label>Name <input name="name" value="{{ overlay.name }}" required></label>
<div>
<button type="submit">Save</button>
</div>
</form>
{% endif %}
<table class="definition-table">
<tbody>
<tr><th>Type</th><td>{{ overlay.type }}</td></tr>
<tr><th>Scope</th><td>{% if overlay.user_id %}private{% else %}system{% endif %}</td></tr>
<tr><th>Path</th><td class="muted">{{ overlay.path }}</td></tr>
<tr>
<th>Last build</th>
<td>
{% if overlay.last_build_status == 'ok' %}
<span class="badge badge-ok">ok</span>
{% elif overlay.last_build_status == 'failed' %}
<span class="badge badge-error">failed</span>
{% else %}
<span class="badge badge-muted">never</span>
{% endif %}
</td>
</tr>
</tbody>
</table>
</section>
{% if overlay.type == 'script' %}
<section class="panel">
<div class="page-heading">
<h2>Script</h2>
{% if can_edit %}
<div class="inline-form-group">
<form method="post" action="/overlays/{{ overlay.id }}/build" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button type="submit" class="button-secondary">Rebuild</button>
</form>
<button type="button" class="danger" data-modal-open="wipe-overlay-modal">Wipe</button>
</div>
{% endif %}
</div>
<dl class="server-info">
<div><dt>Type</dt><dd>{{ overlay.type }}</dd></div>
<div><dt>Scope</dt><dd>{% if overlay.user_id %}private{% else %}system{% endif %}</dd></div>
</dl>
{% if overlay.type == 'script' %}
<h2 class="section-title">Script</h2>
{% if can_edit %}
<form method="post" action="/overlays/{{ overlay.id }}/script" class="stack">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
@ -65,38 +23,22 @@
<textarea name="script" rows="20" spellcheck="false">{{ overlay.script or "" }}</textarea>
</label>
<p class="muted">Runs sandboxed against the overlay directory mounted at <code>/overlay</code>.</p>
<div>
<button type="submit">Save and build</button>
{% if not latest_build_is_running %}
<div class="form-actions-inline">
<button type="submit" name="action" value="save_build">Save and build</button>
<button type="submit" name="action" value="save_reset_build">Save, reset and rebuild</button>
</div>
{% endif %}
</form>
{% else %}
<pre class="script-preview">{{ overlay.script or "" }}</pre>
{% endif %}
<p>
{% if latest_build_job %}
Latest build: <a href="/jobs/{{ latest_build_job.id }}">job #{{ latest_build_job.id }}</a>
— state: <strong>{{ latest_build_job.state }}</strong>
·
{% include "_overlay_build_status.html" %}
{% endif %}
<a href="/overlays/{{ overlay.id }}/jobs">all builds →</a>
</p>
</section>
{% endif %}
{% if overlay.type == 'workshop' %}
<section class="panel">
<div class="page-heading">
<h2>Workshop items</h2>
{% if can_edit %}
<form method="post" action="/overlays/{{ overlay.id }}/build" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button type="submit" class="button-secondary">Rebuild</button>
</form>
{% endif %}
</div>
{% if can_edit %}
{% if overlay.type == 'workshop' %}
<h2 class="section-title">Workshop items</h2>
{% if can_edit and not latest_build_is_running %}
<form method="post" action="/overlays/{{ overlay.id }}/items" class="stack">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<fieldset class="workshop-input-mode">
@ -105,32 +47,18 @@
<label><input type="radio" name="input_mode" value="collection"> Collection (one ID or URL)</label>
</fieldset>
<label>Workshop input <textarea name="input" rows="3" placeholder="123456789&#10;https://steamcommunity.com/sharedfiles/filedetails/?id=987654321"></textarea></label>
<div>
<button type="submit">Add</button>
</div>
</form>
{% endif %}
<div id="overlay-item-table">
{% include "_overlay_item_table.html" with context %}
</div>
</section>
<section class="panel">
<h2>Builds</h2>
<p>
{% if latest_build_job %}
Latest build: <a href="/jobs/{{ latest_build_job.id }}">job #{{ latest_build_job.id }}</a>
— state: <strong>{{ latest_build_job.state }}</strong>
·
{% include "_overlay_build_status.html" %}
{% endif %}
<a href="/overlays/{{ overlay.id }}/jobs">all builds →</a>
</p>
</section>
{% endif %}
<section class="panel">
<h2>Files</h2>
<h2 class="section-title">Files</h2>
{% if not file_tree_root_entries %}
<p class="muted">No files yet — build this overlay to populate it.</p>
{% else %}
@ -139,10 +67,8 @@
{% set truncated_count = file_tree_truncated_count %}
{% include "_overlay_file_tree.html" %}
{% endif %}
</section>
<section class="panel">
<h2>Used by</h2>
<h2 class="section-title">Used by</h2>
{% if using_blueprints %}
<ul class="used-by-list">
{% for blueprint in using_blueprints %}
@ -155,6 +81,25 @@
</section>
{% if can_edit %}
<div class="page-footer-actions">
<button type="button" class="danger-outline" data-modal-open="delete-overlay-modal">Delete overlay</button>
<a href="#" class="link-button" data-modal-open="rename-overlay-modal">Rename</a>
</div>
<dialog id="rename-overlay-modal" class="modal" aria-labelledby="rename-overlay-title">
<div class="modal-header">
<h2 id="rename-overlay-title">Rename overlay</h2>
<button type="button" class="modal-close" data-modal-close aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<form method="post" action="/overlays/{{ overlay.id }}" class="inline-save">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<input name="name" value="{{ overlay.name }}" required autofocus>
<button type="submit">Save</button>
</form>
</div>
</dialog>
<dialog id="delete-overlay-modal" class="modal" aria-labelledby="delete-overlay-title">
<div class="modal-header">
<h2 id="delete-overlay-title">Delete overlay "{{ overlay.name }}"?</h2>
@ -171,24 +116,5 @@
</form>
</div>
</dialog>
{% if overlay.type == 'script' %}
<dialog id="wipe-overlay-modal" class="modal" aria-labelledby="wipe-overlay-title">
<div class="modal-header">
<h2 id="wipe-overlay-title">Wipe overlay "{{ overlay.name }}"?</h2>
<button type="button" class="modal-close" data-modal-close aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<p>Empties the overlay directory. Use Rebuild afterward to repopulate.</p>
</div>
<div class="modal-footer">
<button type="button" class="button-secondary" data-modal-close>Cancel</button>
<form method="post" action="/overlays/{{ overlay.id }}/wipe" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button class="danger" type="submit">Wipe</button>
</form>
</div>
</dialog>
{% endif %}
{% endif %}
{% endblock %}

View file

@ -6,48 +6,39 @@
<section class="panel">
<div class="page-heading">
<h1>Server: {{ server.name }}</h1>
<div class="button-row">
{% for operation in ["initialize", "start", "stop"] %}
<form method="post" action="/servers/{{ server.id }}/{{ operation }}" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button type="submit">{{ operation }}</button>
</form>
{% endfor %}
<button type="button" class="danger" data-modal-open="reset-server-modal">reset</button>
<button type="button" class="danger" data-modal-open="delete-server-modal">delete</button>
</div>
</div>
<table class="definition-table">
<tbody>
<tr><th>Name</th><td>{{ server.name }}</td></tr>
<tr><th>Port</th><td>{{ server.port }}</td></tr>
<tr><th>Blueprint</th><td>{% if blueprint %}<a href="/blueprints/{{ blueprint.id }}">{{ blueprint.name }}</a>{% endif %}</td></tr>
<tr><th>Desired state</th><td>{{ server.desired_state }}</td></tr>
<tr><th>Actual state</th><td>{{ server.actual_state }}</td></tr>
<tr><th>Last error</th><td>{{ server.last_error or "-" }}</td></tr>
</tbody>
</table>
</section>
<dl class="server-info">
<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>
</dl>
<section class="panel">
<div class="page-heading">
<h2>Recent Jobs</h2>
<a href="/servers/{{ server.id }}/jobs">View all jobs</a>
</div>
{% set rows = recent_job_rows %}
{% set show_user = false %}
{% set show_server = false %}
{% set show_cancel = true %}
{% set cancel_next = "/servers/" ~ server.id %}
{% include "_job_table.html" %}
</section>
<h2 class="section-title">Actions</h2>
{% include "_server_actions.html" %}
<section class="panel">
<h2>Server Log</h2>
<h2 class="section-title">Server Log</h2>
<pre class="log-stream" data-sse-url="/servers/{{ server.id }}/logs/stream"></pre>
</section>
<div class="page-footer-actions">
<button type="button" class="danger-outline" data-modal-open="delete-server-modal">Delete server</button>
<a href="#" class="link-button" data-modal-open="rename-server-modal">Rename</a>
</div>
<dialog id="rename-server-modal" class="modal" aria-labelledby="rename-server-title">
<div class="modal-header">
<h2 id="rename-server-title">Rename server</h2>
<button type="button" class="modal-close" data-modal-close aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<form method="post" action="/servers/{{ server.id }}" class="inline-save">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<input name="name" value="{{ server.name }}" required autofocus>
<button type="submit">Save</button>
</form>
</div>
</dialog>
<dialog id="reset-server-modal" class="modal" aria-labelledby="reset-server-title">
<div class="modal-header">
<h2 id="reset-server-title">Reset server "{{ server.name }}"?</h2>

View file

@ -272,6 +272,121 @@ def test_blueprint_detail_renders_picker_list_and_select(user_client) -> None:
assert '<option value="1"' not in body
def test_form_save_persists_expose_server_cfg_set(user_client) -> None:
create = user_client.post(
"/blueprints",
data={
"name": "comp",
"arguments": "",
"config": "",
"overlay_ids": ["1", "2"],
"overlay_position_1": "1",
"overlay_position_2": "2",
"expose_server_cfg_ids": ["2"],
},
headers={"X-CSRF-Token": "test-token"},
)
assert create.status_code == 302
with session_scope() as session:
rows = session.execute(
select(BlueprintOverlay.overlay_id, BlueprintOverlay.expose_server_cfg)
.order_by(BlueprintOverlay.position)
).all()
assert sorted(rows) == [(1, False), (2, True)]
update = user_client.post(
"/blueprints/1",
data={
"name": "comp",
"arguments": "",
"config": "",
"overlay_ids": ["1", "2"],
"overlay_position_1": "1",
"overlay_position_2": "2",
"expose_server_cfg_ids": ["1"],
},
headers={"X-CSRF-Token": "test-token"},
)
assert update.status_code == 302
with session_scope() as session:
rows = session.execute(
select(BlueprintOverlay.overlay_id, BlueprintOverlay.expose_server_cfg)
.order_by(BlueprintOverlay.position)
).all()
assert sorted(rows) == [(1, True), (2, False)]
def test_blueprint_detail_renders_expose_checkbox_compact_label(user_client) -> None:
with session_scope() as session:
user = session.scalar(select(User).where(User.username == "alice"))
blueprint = Blueprint(user_id=user.id, name="bp", arguments="[]", config="[]")
session.add(blueprint)
session.flush()
session.add(BlueprintOverlay(
blueprint_id=blueprint.id, overlay_id=1, position=0, expose_server_cfg=True,
))
session.add(BlueprintOverlay(
blueprint_id=blueprint.id, overlay_id=2, position=1, expose_server_cfg=False,
))
blueprint_id = blueprint.id
response = user_client.get(f"/blueprints/{blueprint_id}")
assert response.status_code == 200
body = response.get_data(as_text=True)
assert 'name="expose_server_cfg_ids" value="1"' in body
assert 'name="expose_server_cfg_ids" value="2"' in body
assert "exec <code>server.cfg</code>" in body
# The technical alias must NOT appear in the row label anymore.
assert "server_overlay_1" not in body
assert "server_overlay_2" not in body
assert "checked" in body # row 1 is exposed
def test_blueprint_detail_shows_config_preview_for_exposed_overlays(user_client) -> None:
with session_scope() as session:
user = session.scalar(select(User).where(User.username == "alice"))
comp = Overlay(name="comp", path="/opt/l4d2/overlays/comp", user_id=user.id)
plain = Overlay(name="plain", path="/opt/l4d2/overlays/plain", user_id=user.id)
session.add_all([comp, plain])
session.flush()
blueprint = Blueprint(user_id=user.id, name="bp", arguments="[]", config="[]")
session.add(blueprint)
session.flush()
session.add(BlueprintOverlay(
blueprint_id=blueprint.id, overlay_id=comp.id, position=0, expose_server_cfg=True,
))
session.add(BlueprintOverlay(
blueprint_id=blueprint.id, overlay_id=plain.id, position=1, expose_server_cfg=False,
))
blueprint_id = blueprint.id
response = user_client.get(f"/blueprints/{blueprint_id}")
assert response.status_code == 200
body = response.get_data(as_text=True)
assert 'class="config-preview"' in body
assert "exec comp.cfg" in body
assert "exec plain.cfg" not in body
def test_blueprint_detail_omits_preview_when_nothing_exposed(user_client) -> None:
with session_scope() as session:
user = session.scalar(select(User).where(User.username == "alice"))
blueprint = Blueprint(user_id=user.id, name="bp", arguments="[]", config="[]")
session.add(blueprint)
session.flush()
session.add(BlueprintOverlay(
blueprint_id=blueprint.id, overlay_id=1, position=0, expose_server_cfg=False,
))
blueprint_id = blueprint.id
response = user_client.get(f"/blueprints/{blueprint_id}")
assert response.status_code == 200
body = response.get_data(as_text=True)
assert 'class="config-preview"' not in body
def test_blueprint_detail_picker_emits_hidden_overlay_ids_in_order(user_client) -> None:
with session_scope() as session:
user = session.scalar(select(User).where(User.username == "alice"))

View file

@ -123,7 +123,12 @@ def test_install_and_lifecycle_commands_use_l4d2ctl(
delete_server(server_id)
unit = str(server_id)
assert calls == [
# start now auto-initializes before launching, so the call list interleaves
# an `initialize` before the `start`.
initialize_call = [c for c in calls if c[:2] == ["l4d2ctl", "initialize"]]
assert len(initialize_call) == 1
operational = [c for c in calls if c[:2] != ["l4d2ctl", "initialize"]]
assert operational == [
["l4d2ctl", "install"],
["l4d2ctl", "start", unit],
["l4d2ctl", "stop", unit],
@ -220,9 +225,78 @@ def _attach_workshop_overlay_to_blueprint(
return overlay_id, "1001"
def test_initialize_runs_overlay_builders_synchronously(
def test_build_payload_emits_alias_and_exec_lines_for_exposed_overlays(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
db_url = f"sqlite:///{tmp_path/'payload.db'}"
monkeypatch.setenv("DATABASE_URL", db_url)
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
init_db()
with app.app_context():
with session_scope() as session:
user = User(username="alice", password_digest=hash_password("secret"), admin=False)
session.add(user)
session.flush()
low = Overlay(name="low", path="42", type="script", user_id=user.id)
mid = Overlay(name="mid", path="43", type="script", user_id=user.id)
high = Overlay(name="high", path="44", type="script", user_id=user.id)
session.add_all([low, mid, high])
session.flush()
blueprint = Blueprint(
user_id=user.id,
name="bp",
arguments='[]',
config='["sv_consistency 1", "mp_disable_autokick 1"]',
)
session.add(blueprint)
session.flush()
session.add(BlueprintOverlay(
blueprint_id=blueprint.id, overlay_id=low.id, position=0, expose_server_cfg=True,
))
session.add(BlueprintOverlay(
blueprint_id=blueprint.id, overlay_id=mid.id, position=1, expose_server_cfg=False,
))
session.add(BlueprintOverlay(
blueprint_id=blueprint.id, overlay_id=high.id, position=2, expose_server_cfg=True,
))
server = Server(user_id=user.id, blueprint_id=blueprint.id, name="alpha", port=27020)
session.add(server)
session.flush()
server_id = server.id
low_id, high_id = low.id, high.id
from l4d2web.services.l4d2_facade import (
build_server_spec_payload,
load_server_blueprint_bundle,
)
with app.app_context():
server, blueprint, overlay_rows = load_server_blueprint_bundle(server_id)
payload = build_server_spec_payload(server, blueprint, overlay_rows)
assert payload["overlays"] == [
{"path": "42", "alias": f"overlay_{low_id}"},
{"path": "43"},
{"path": "44", "alias": f"overlay_{high_id}"},
]
# First in list = topmost = highest precedence, so its exec runs LAST.
# Position 0 (low_id) is first in list → exec'd last; position 2 (high_id)
# is last in list → exec'd first.
assert payload["config"] == [
f"exec server_overlay_{high_id}",
f"exec server_overlay_{low_id}",
"sv_consistency 1",
"mp_disable_autokick 1",
]
def test_initialize_does_not_run_overlay_builders(
monkeypatch: pytest.MonkeyPatch, server_with_blueprint, tmp_path
) -> None:
"""Initialize must NOT run overlay builders — those run on overlay save.
Running them on every server Start is expensive and redundant.
"""
server_id, user_id = server_with_blueprint
overlay_id, _steam_id = _attach_workshop_overlay_to_blueprint(
server_id, user_id, item_cached=True, tmp_path=tmp_path
@ -238,7 +312,7 @@ def test_initialize_runs_overlay_builders_synchronously(
initialize_server(server_id)
addons = tmp_path / "overlays" / str(overlay_id) / "left4dead2" / "addons"
assert (addons / "1001.vpk").is_symlink(), "workshop builder must run before l4d2ctl initialize"
assert not (addons / "1001.vpk").exists(), "initialize must not trigger workshop builder"
def test_initialize_fails_fast_on_uncached_workshop_items(

View file

@ -129,17 +129,50 @@ def test_server_detail_shows_operations_and_logs(auth_client_with_server) -> Non
assert response.status_code == 200
assert "Server: alpha" in text
# Default actual_state is "unknown", no active job → only `start` and modal `delete`.
assert 'action="/servers/1/start"' in text
assert 'action="/servers/1/stop"' in text
assert 'action="/servers/1/initialize"' in text
assert 'action="/servers/1/delete"' in text
assert 'action="/servers/1/stop"' not in text
assert 'action="/servers/1/initialize"' not in text # UI dropped this; start auto-inits.
assert 'action="/servers/1/delete"' in text # inside the delete modal
assert 'href="/blueprints/1"' in text
assert "<h2>Blueprint</h2>" not in text
assert "standard" not in text
assert 'data-sse-url="/servers/1/logs/stream"' in text
# Steam launch-and-connect link (run + appid 550 = L4D2)
assert 'href="steam://run/550//+connect%20' in text
def test_server_detail_shows_recent_jobs(auth_client_with_server) -> None:
def test_server_actions_fragment_polls_while_running(auth_client_with_server) -> None:
with session_scope() as session:
job = Job(user_id=1, server_id=1, operation="start", state="queued")
session.add(job)
session.flush()
job_id = job.id
response = auth_client_with_server.get("/servers/1/actions")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert 'id="server-actions"' in text
assert "<html" not in text # fragment, not a full page
assert 'hx-trigger="every 2s"' in text
assert f'data-sse-url="/jobs/{job_id}/stream"' in text
def test_server_actions_fragment_settles_when_terminal(auth_client_with_server) -> None:
with session_scope() as session:
session.add(Job(user_id=1, server_id=1, operation="start", state="succeeded"))
response = auth_client_with_server.get("/servers/1/actions")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert 'id="server-actions"' in text
# No polling once the latest job is terminal.
assert "hx-trigger" not in text
# No live job-log streaming either.
assert "data-sse-url=\"/jobs/" not in text
def test_server_detail_shows_last_job_summary(auth_client_with_server) -> None:
with session_scope() as session:
job = Job(user_id=1, server_id=1, operation="start", state="queued")
session.add(job)
@ -150,10 +183,13 @@ def test_server_detail_shows_recent_jobs(auth_client_with_server) -> None:
text = response.get_data(as_text=True)
assert response.status_code == 200
assert "Recent Jobs" in text
assert 'href="/servers/1/jobs"' in text
# Active job → no Start/Stop/Reset, only the last-job sentence.
assert 'action="/servers/1/start"' not in text
assert f'href="/jobs/{job_id}"' in text
assert 'action="/jobs/' in text
assert 'href="/servers/1/jobs"' in text # "show all" link
assert "starting" in text # gerund form for in-flight start
# Recent Jobs table is gone.
assert "Recent Jobs" not in text
def test_server_jobs_page_lists_server_jobs(auth_client_with_server) -> None:
@ -455,7 +491,7 @@ def test_blueprint_detail_has_ordered_overlay_form(auth_client_with_server) -> N
text = response.get_data(as_text=True)
assert response.status_code == 200
assert "Overlay order matters" in text
assert ">Overlays<" in text
assert 'name="arguments"' in text
assert 'name="config"' in text
assert 'name="overlay_ids"' in text
@ -545,9 +581,66 @@ def test_overlay_detail_script_section(auth_client_with_server) -> None:
assert response.status_code == 200
assert '<textarea name="script"' in text
assert "Rebuild" in text
assert "Wipe" in text
assert "Last build" in text
# Two compound submits replaced standalone Rebuild/Wipe.
assert "Save and build" in text
assert "Save, reset and rebuild" in text
assert "Rebuild" not in text # no standalone Rebuild button
assert "Last build" not in text # state-badge replaces this label
# Build-status badge is rendered (initial state for a fresh overlay).
assert "never built" in text
def test_overlay_build_status_fragment_polls_while_running(auth_client_with_server) -> None:
with session_scope() as s:
user_id = s.query(User).filter_by(username="alice").one().id
overlay_id = _seed_overlay("polling", "script", user_id)
with session_scope() as s:
job = Job(user_id=user_id, overlay_id=overlay_id, operation="build_overlay", state="running")
s.add(job)
s.flush()
job_id = job.id
response = auth_client_with_server.get(f"/overlays/{overlay_id}/build-status")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert 'id="overlay-build-status"' in text
assert "<html" not in text # fragment, not a full page
assert 'hx-trigger="every 2s"' in text
assert f'data-sse-url="/jobs/{job_id}/stream"' in text
def test_overlay_build_status_fragment_settles_when_terminal(auth_client_with_server) -> None:
with session_scope() as s:
user_id = s.query(User).filter_by(username="alice").one().id
overlay_id = _seed_overlay("settled", "script", user_id)
with session_scope() as s:
s.add(Job(user_id=user_id, overlay_id=overlay_id, operation="build_overlay", state="succeeded"))
response = auth_client_with_server.get(f"/overlays/{overlay_id}/build-status")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert "hx-trigger" not in text
assert "data-sse-url=\"/jobs/" not in text
def test_overlay_action_buttons_hidden_during_build(auth_client_with_server) -> None:
with session_scope() as s:
user_id = s.query(User).filter_by(username="alice").one().id
overlay_id = _seed_overlay("inflight", "script", user_id)
with session_scope() as s:
s.add(Job(user_id=user_id, overlay_id=overlay_id, operation="build_overlay", state="running"))
response = auth_client_with_server.get(f"/overlays/{overlay_id}")
text = response.get_data(as_text=True)
assert response.status_code == 200
# Submit buttons hidden while a build is in flight.
assert "Save and build" not in text
assert "Save, reset and rebuild" not in text
# Status partial renders the building badge.
assert "building…" in text
def test_overlay_detail_workshop_section_unchanged(auth_client_with_server) -> None:
@ -566,6 +659,10 @@ def test_overlay_detail_links_to_overlay_jobs_page(auth_client_with_server) -> N
with session_scope() as s:
user_id = s.query(User).filter_by(username="alice").one().id
overlay_id = _seed_overlay("scripted", "script", user_id)
# The "show all" link only appears once at least one build has happened
# (otherwise the build-status partial has nothing to summarize).
with session_scope() as s:
s.add(Job(user_id=user_id, overlay_id=overlay_id, operation="build_overlay", state="succeeded"))
response = auth_client_with_server.get(f"/overlays/{overlay_id}")
text = response.get_data(as_text=True)

View file

@ -121,13 +121,14 @@ def test_update_script_body_enqueues_build(app, alice_id) -> None:
headers={"X-CSRF-Token": "test-token"},
)
assert r1.status_code == 302
assert r1.headers["Location"].startswith("/jobs/")
# Redirect lands on the overlay page so the user sees the build progress
# via the live build-status partial.
assert r1.headers["Location"] == f"/overlays/{overlay_id}"
with session_scope() as s:
overlay = s.query(Overlay).filter_by(id=overlay_id).one()
assert overlay.script == "echo new"
jobs = s.query(Job).filter_by(operation="build_overlay", overlay_id=overlay_id).all()
assert len(jobs) == 1
assert r1.headers["Location"] == f"/jobs/{jobs[0].id}"
# Coalesce against pending.
r2 = client.post(
@ -136,7 +137,7 @@ def test_update_script_body_enqueues_build(app, alice_id) -> None:
headers={"X-CSRF-Token": "test-token"},
)
assert r2.status_code == 302
assert r2.headers["Location"] == r1.headers["Location"]
assert r2.headers["Location"] == f"/overlays/{overlay_id}"
with session_scope() as s:
jobs = s.query(Job).filter_by(operation="build_overlay", overlay_id=overlay_id).all()
assert len(jobs) == 1