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) (runtime_dir / "merged").mkdir(parents=True, exist_ok=True)
instance_dir.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")) lowerdirs.append(str(root / "installation"))
emit_step("writing instance.env...", on_stdout, passthrough) emit_step("writing instance.env...", on_stdout, passthrough)
@ -57,6 +57,9 @@ def initialize_instance(
emit_step("writing server.cfg...", on_stdout, passthrough) emit_step("writing server.cfg...", on_stdout, passthrough)
server_cfg = "\n".join(spec.config) if spec.config else "" server_cfg = "\n".join(spec.config) if spec.config else ""
(instance_dir / "server.cfg").write_text(server_cfg) (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) 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", 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) emit_step("mounting runtime overlay...", on_stdout, passthrough)
_mounter.mount( _mounter.mount(
lowerdirs=env["L4D2_LOWERDIRS"], lowerdirs=env["L4D2_LOWERDIRS"],
@ -109,11 +133,6 @@ def start_instance(
should_cancel=should_cancel, 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) emit_step("starting systemd service...", on_stdout, passthrough)
start_service( start_service(
name, name,

View file

@ -4,19 +4,38 @@ from pathlib import Path
import yaml import yaml
@dataclass(slots=True)
class OverlayRef:
path: str
alias: str | None = None
@dataclass(slots=True) @dataclass(slots=True)
class InstanceSpec: class InstanceSpec:
port: int port: int
overlays: list[str] = field(default_factory=list) overlays: list[OverlayRef] = field(default_factory=list)
arguments: list[str] = field(default_factory=list) arguments: list[str] = field(default_factory=list)
config: 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: def load_spec(path: Path) -> InstanceSpec:
raw = yaml.safe_load(path.read_text()) or {} raw = yaml.safe_load(path.read_text()) or {}
return InstanceSpec( return InstanceSpec(
port=int(raw["port"]), 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", [])], arguments=[str(item) for item in raw.get("arguments", [])],
config=[str(item) for item in raw.get("config", [])], 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: creating instance directories...",
"Step: writing instance.env...", "Step: writing instance.env...",
"Step: writing server.cfg...", "Step: writing server.cfg...",
"Step: persisting spec...",
"Step: initialization complete.", "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" "L4D2_PORT=27015\nL4D2_ARGS=-tickrate 100\nL4D2_LOWERDIRS=/x:/y\n"
) )
(instance_dir / "server.cfg").write_text("sv_consistency 1") (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.fs.kernel_overlayfs.run_command", fake_run_command)
monkeypatch.setattr("l4d2host.service_control.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"] 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: def test_start_refuses_to_double_mount(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[list[str]] = [] calls: list[list[str]] = []

View file

@ -2,15 +2,30 @@ from pathlib import Path
import pytest 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 = tmp_path / "server.yaml"
path.write_text("port: 27015\noverlays: [standard]\n") path.write_text("port: 27015\noverlays: [standard]\n")
spec = load_spec(path) spec = load_spec(path)
assert spec.port == 27015 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: 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") path.write_text("port: 27015\nfoo: bar\n")
spec = load_spec(path) spec = load_spec(path)
assert spec.port == 27015 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) blueprint_id: Mapped[int] = mapped_column(ForeignKey("blueprints.id"), nullable=False)
overlay_id: Mapped[int] = mapped_column(ForeignKey("overlays.id"), nullable=False) overlay_id: Mapped[int] = mapped_column(ForeignKey("overlays.id"), nullable=False)
position: Mapped[int] = mapped_column(Integer, 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) created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
updated_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)] 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)) db.execute(delete(BlueprintOverlay).where(BlueprintOverlay.blueprint_id == blueprint_id))
for position, overlay_id in enumerate(overlay_ids): 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: 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", [])] arguments = [str(item) for item in payload.get("arguments", [])]
config = [str(item) for item in payload.get("config", [])] config = [str(item) for item in payload.get("config", [])]
overlay_ids = [int(item) for item in payload.get("overlay_ids", [])] 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 json_response = True
else: else:
name = request.form.get("name", "").strip() name = request.form.get("name", "").strip()
arguments = split_textarea_lines(request.form.get("arguments", "")) arguments = split_textarea_lines(request.form.get("arguments", ""))
config = split_textarea_lines(request.form.get("config", "")) config = split_textarea_lines(request.form.get("config", ""))
overlay_ids = ordered_overlay_ids_from_form() overlay_ids = ordered_overlay_ids_from_form()
expose_ids = expose_overlay_ids_from_form()
json_response = False json_response = False
if not name: 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)) blueprint = BlueprintModel(user_id=user.id, name=name, arguments=json.dumps(arguments), config=json.dumps(config))
db.add(blueprint) db.add(blueprint)
db.flush() db.flush()
replace_blueprint_overlays(db, blueprint.id, overlay_ids) replace_blueprint_overlays(db, blueprint.id, overlay_ids, expose_ids)
blueprint_id = blueprint.id blueprint_id = blueprint.id
if json_response: if json_response:
@ -103,13 +130,14 @@ def update_blueprint_form(blueprint_id: int) -> Response:
if blueprint is None: if blueprint is None:
return Response(status=404) return Response(status=404)
overlay_ids = ordered_overlay_ids_from_form() overlay_ids = ordered_overlay_ids_from_form()
expose_ids = expose_overlay_ids_from_form()
if not overlay_ids_authorized(db, overlay_ids, user.id): if not overlay_ids_authorized(db, overlay_ids, user.id):
return Response("overlay not authorized", status=403) return Response("overlay not authorized", status=403)
blueprint.name = name blueprint.name = name
blueprint.arguments = json.dumps(split_textarea_lines(request.form.get("arguments", ""))) blueprint.arguments = json.dumps(split_textarea_lines(request.form.get("arguments", "")))
blueprint.config = json.dumps(split_textarea_lines(request.form.get("config", ""))) 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}") 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 # Normalize to LF before storage so the script is well-formed when written
# to the sandbox tmpfile. # to the sandbox tmpfile.
script_text = request.form.get("script", "").replace("\r\n", "\n").replace("\r", "\n") 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: with session_scope() as db:
overlay, err = _load_script_overlay(db, overlay_id, user) overlay, err = _load_script_overlay(db, overlay_id, user)
if err is not None: if err is not None:
return err return err
overlay.script = script_text 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 = enqueue_build_overlay(db, overlay_id=overlay_id, user_id=user.id)
job_id = job.id job_id = job.id
return redirect(f"/jobs/{job_id}") return redirect(f"/overlays/{overlay_id}")
@bp.post("/overlays/<int:overlay_id>/build") @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>") @bp.get("/servers/<int:server_id>")
@require_login @require_login
def server_detail(server_id: int): def server_detail(server_id: int):
@ -112,23 +192,32 @@ def server_detail(server_id: int):
if server is None: if server is None:
return Response(status=404) return Response(status=404)
blueprint = db.scalar(select(BlueprintModel).where(BlueprintModel.id == server.blueprint_id)) blueprint = db.scalar(select(BlueprintModel).where(BlueprintModel.id == server.blueprint_id))
recent_job_rows = db.execute( ctx = _build_server_actions_context(db, server)
select(Job, User, Server)
.outerjoin(User, User.id == Job.user_id) connect_host = request.host.split(":")[0]
.outerjoin(Server, Server.id == Job.server_id)
.where(Job.server_id == server.id)
.order_by(Job.created_at.desc())
.limit(5)
).all()
return render_template( return render_template(
"server_detail.html", "server_detail.html",
server=server, server=server,
blueprint=blueprint, 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") @bp.get("/servers/<int:server_id>/jobs")
@require_login @require_login
def server_jobs_page(server_id: int): 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) 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>") @bp.get("/overlays/<int:overlay_id>")
@require_login @require_login
def overlay_detail(overlay_id: int): def overlay_detail(overlay_id: int):
@ -217,12 +358,7 @@ def overlay_detail(overlay_id: int):
.where(OverlayWorkshopItem.overlay_id == overlay.id) .where(OverlayWorkshopItem.overlay_id == overlay.id)
.order_by(WorkshopItem.created_at) .order_by(WorkshopItem.created_at)
).all() ).all()
latest_build_job = db.scalar( build_ctx = _build_overlay_build_status_context(db, overlay)
select(Job)
.where(Job.operation == "build_overlay", Job.overlay_id == overlay.id)
.order_by(Job.created_at.desc())
.limit(1)
)
file_tree_root_entries, file_tree_truncated_count = _root_file_tree(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, overlay=overlay,
using_blueprints=using_blueprints, using_blueprints=using_blueprints,
workshop_items=workshop_items, workshop_items=workshop_items,
latest_build_job=latest_build_job,
file_tree_root_entries=file_tree_root_entries, file_tree_root_entries=file_tree_root_entries,
file_tree_truncated=file_tree_truncated_count > 0 file_tree_truncated=file_tree_truncated_count > 0
if file_tree_root_entries is not None if file_tree_root_entries is not None
else False, else False,
file_tree_truncated_count=file_tree_truncated_count, 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]: def _root_file_tree(overlay: Overlay) -> tuple[list[dict] | None, int]:
"""Return (entries, truncated_count) for the overlay's runtime directory, """Return (entries, truncated_count) for the overlay's runtime directory,
or (None, 0) if the directory doesn't exist or the path is unresolvable 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) select(BlueprintOverlay.overlay_id, BlueprintOverlay.position)
.where(BlueprintOverlay.blueprint_id == blueprint.id) .where(BlueprintOverlay.blueprint_id == blueprint.id)
).all() ).all()
expose_rows = db.execute(
select(BlueprintOverlay.overlay_id, BlueprintOverlay.expose_server_cfg)
.where(BlueprintOverlay.blueprint_id == blueprint.id)
).all()
all_overlays = db.scalars( all_overlays = db.scalars(
select(Overlay) select(Overlay)
.where(Overlay.user_id.is_(None) | (Overlay.user_id == user.id)) .where(Overlay.user_id.is_(None) | (Overlay.user_id == user.id))
@ -297,6 +452,7 @@ def blueprint_page(blueprint_id: int):
).all() ).all()
overlay_positions = {overlay_id: position + 1 for overlay_id, position in position_rows} 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} selected_ids = {overlay.id for overlay in selected_overlays}
available_overlays = [overlay for overlay in all_overlays if overlay.id not in selected_ids] available_overlays = [overlay for overlay in all_overlays if overlay.id not in selected_ids]
return render_template( return render_template(
@ -307,6 +463,7 @@ def blueprint_page(blueprint_id: int):
all_overlays=all_overlays, all_overlays=all_overlays,
selected_overlay_ids=selected_ids, selected_overlay_ids=selected_ids,
overlay_positions=overlay_positions, overlay_positions=overlay_positions,
overlay_expose_state=overlay_expose_state,
arguments=json.loads(blueprint.arguments), arguments=json.loads(blueprint.arguments),
config_lines=json.loads(blueprint.config), config_lines=json.loads(blueprint.config),
) )

View file

@ -109,6 +109,33 @@ def create_server() -> Response:
return redirect(f"/servers/{server_id}") 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>") @bp.patch("/servers/<int:server_id>")
@require_login @require_login
def update_server(server_id: int) -> Response: def update_server(server_id: int) -> Response:

View file

@ -26,16 +26,35 @@ class ServerStatus:
raw_sub_state: str 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 { return {
"port": server.port, "port": server.port,
"overlays": overlay_refs, "overlays": overlays,
"arguments": json.loads(blueprint.arguments), "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: with session_scope() as db:
server = db.scalar(select(Server).where(Server.id == server_id)) server = db.scalar(select(Server).where(Server.id == server_id))
if server is None: 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") raise ValueError("blueprint not found")
rows = db.execute( rows = db.execute(
select(Overlay.path) select(Overlay.id, Overlay.path, BlueprintOverlay.expose_server_cfg)
.join(BlueprintOverlay, BlueprintOverlay.overlay_id == Overlay.id) .join(BlueprintOverlay, BlueprintOverlay.overlay_id == Overlay.id)
.where(BlueprintOverlay.blueprint_id == blueprint.id) .where(BlueprintOverlay.blueprint_id == blueprint.id)
.order_by(BlueprintOverlay.position) .order_by(BlueprintOverlay.position)
).all() ).all()
overlay_refs = [row[0] for row in rows] overlay_rows = [(int(i), str(p), bool(e)) for i, p, e in rows]
return server, blueprint, overlay_refs return server, blueprint, overlay_rows
def install_runtime(on_stdout=None, on_stderr=None, should_cancel=None) -> None: 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: 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 # Builders are NOT run here. Overlays rebuild from their own save/build
# before l4d2ctl initialize composes the lowerdirs. # flows; doing it on every Start is expensive and redundant.
_run_blueprint_builders(
blueprint_id=blueprint.id,
on_stdout=on_stdout,
on_stderr=on_stderr,
should_cancel=should_cancel,
)
# Workshop overlays may have items not yet downloaded. The builders skip # Workshop overlays may have items not yet downloaded. Fail fast rather
# them, but we don't want to mount a partial overlay silently — fail # than mount a partial overlay (would silently leave maps missing in-game).
# loudly with the missing IDs.
_check_workshop_overlay_caches(blueprint_id=blueprint.id) _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: try:
host_commands.run_command( host_commands.run_command(
["l4d2ctl", "initialize", server_unit_name(server.id), "-f", str(spec_path)], ["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: 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) server, _, _ = load_server_blueprint_bundle(server_id)
host_commands.run_command( host_commands.run_command(
["l4d2ctl", "start", server_unit_name(server.id)], ["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, .panel,
.card { .card {
background: var(--color-surface); background: var(--color-surface);
border: var(--line); border: var(--line-soft);
border-radius: var(--radius-m); border-radius: var(--radius-m);
padding: var(--space-l); padding: var(--space-l);
margin-bottom: 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 { .table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
@ -69,7 +79,7 @@ a:focus-visible {
button, button,
a.button { a.button {
background: var(--color-primary); background: var(--color-button-primary);
border: none; border: none;
border-radius: var(--radius-s); border-radius: var(--radius-s);
color: #fff; color: #fff;
@ -81,7 +91,7 @@ a.button {
button.danger, button.danger,
a.button.danger { a.button.danger {
background: var(--color-danger); background: var(--color-button-danger);
} }
.link-button { .link-button {
@ -328,6 +338,19 @@ dialog.modal::backdrop {
flex: 1; 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 { .overlay-picker-remove {
background: none; background: none;
color: var(--color-muted); color: var(--color-muted);
@ -353,3 +376,137 @@ dialog.modal::backdrop {
.overlay-picker-add select { .overlay-picker-add select {
flex: 1; 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 { .site-header {
background: var(--color-surface); background: transparent;
border-bottom: var(--line);
position: sticky;
top: 0;
} }
.site-header-inner { .site-header-inner {
max-width: 960px; max-width: 960px;
margin: 0 auto; margin: 0 auto;
padding: var(--space-l); padding: var(--space-l) var(--space-2xl);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@ -41,5 +38,5 @@ body {
.container { .container {
max-width: 960px; max-width: 960px;
margin: 0 auto; 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); --radius-m: calc(var(--radius-base) * 2);
--line: 1px solid var(--color-border); --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) { @media (prefers-color-scheme: dark) {

View file

@ -41,6 +41,18 @@
name.className = "overlay-picker-name"; name.className = "overlay-picker-name";
name.textContent = overlayName; 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"); const remove = document.createElement("button");
remove.type = "button"; remove.type = "button";
remove.className = "overlay-picker-remove"; remove.className = "overlay-picker-remove";
@ -48,7 +60,7 @@
remove.setAttribute("aria-label", `Remove ${overlayName}`); remove.setAttribute("aria-label", `Remove ${overlayName}`);
remove.textContent = "×"; remove.textContent = "×";
li.append(handle, name, remove, buildHiddenInput(overlayId)); li.append(handle, name, exposeLabel, remove, buildHiddenInput(overlayId));
return li; return li;
}; };

View file

@ -1,10 +1,14 @@
function streamTextToElement(element) { function streamTextToElement(element) {
if (element.dataset.sseBound === "true") {
return;
}
const url = element.dataset.sseUrl; const url = element.dataset.sseUrl;
if (!url) { if (!url) {
return; return;
} }
const source = new EventSource(url); const source = new EventSource(url);
element._sseSource = source;
element.dataset.sseBound = "true";
const appendLine = (line) => { const appendLine = (line) => {
element.textContent += `${line}\n`; element.textContent += `${line}\n`;
@ -24,6 +28,37 @@ function streamTextToElement(element) {
}); });
} }
document.addEventListener("DOMContentLoaded", () => { function bindSseIn(root) {
document.querySelectorAll("[data-sse-url]").forEach(streamTextToElement); 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/users">Users</a></li>
<li><a href="/admin/jobs">Jobs</a></li> <li><a href="/admin/jobs">Jobs</a></li>
</ul> </ul>
</section>
<section class="panel">
<h2>Runtime</h2> <h2>Runtime</h2>
<p class="muted">Queue a Steam runtime install/update job for the local host.</p> <p class="muted">Queue a Steam runtime install/update job for the local host.</p>
<form method="post" action="/admin/install"> <form method="post" action="/admin/install">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}"> <input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button type="submit">Install or update runtime</button> <button type="submit">Install or update runtime</button>
</form> </form>
</section>
<section class="panel">
<h2>Workshop</h2> <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> <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"> <form method="post" action="/admin/workshop/refresh">

View file

@ -6,40 +6,60 @@
<section class="panel"> <section class="panel">
<div class="page-heading"> <div class="page-heading">
<h1>Blueprint: {{ blueprint.name }}</h1> <h1>Blueprint: {{ blueprint.name }}</h1>
<button type="button" class="danger" data-modal-open="delete-blueprint-modal">Delete</button>
</div> </div>
<form method="post" action="/blueprints/{{ blueprint.id }}" class="stack"> <form method="post" action="/blueprints/{{ blueprint.id }}" class="stack">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}"> <input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<label>Name <input name="name" value="{{ blueprint.name }}" required></label> <label><span class="section-title">Name</span><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">Arguments</span><textarea name="arguments" rows="2" spellcheck="false">{{ arguments | join('\n') }}</textarea></label>
<span class="section-title">Overlays</span>
<div class="overlay-picker"> <div class="overlay-picker">
<ol class="overlay-picker-list" data-overlay-list> <ol class="overlay-picker-list" data-overlay-list>
{% for overlay in selected_overlays %} {% for overlay in selected_overlays %}
<li class="overlay-picker-row" draggable="true" data-overlay-id="{{ overlay.id }}" data-overlay-name="{{ overlay.name }}"> <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-handle" aria-hidden="true">⋮⋮</span>
<span class="overlay-picker-name">{{ overlay.name }}</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> <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 }}"> <input type="hidden" name="overlay_ids" value="{{ overlay.id }}">
</li> </li>
{% endfor %} {% endfor %}
</ol> </ol>
<p class="overlay-picker-empty muted" data-overlay-empty {% if selected_overlays %}hidden{% endif %}>No overlays selected. Pick one below to add.</p> <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"> <div class="overlay-picker-add">
<span>Add overlay</span> <select data-overlay-add aria-label="Add overlay">
<select data-overlay-add> <option value="">Add overlay…</option>
<option value="">Pick a name…</option>
{% for overlay in available_overlays %} {% for overlay in available_overlays %}
<option value="{{ overlay.id }}" data-overlay-name="{{ overlay.name }}">{{ overlay.name }}</option> <option value="{{ overlay.id }}" data-overlay-name="{{ overlay.name }}">{{ overlay.name }}</option>
{% endfor %} {% endfor %}
</select> </select>
</label> </div>
</div> </div>
<label>Arguments <textarea name="arguments" rows="8" spellcheck="false">{{ arguments | join('\n') }}</textarea></label> {% set exposed = [] %}
<label>Config <textarea name="config" rows="8" spellcheck="false">{{ config_lines | join('\n') }}</textarea></label> {# 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> <button type="submit">Save blueprint</button>
</form> </form>
</section> </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"> <dialog id="delete-blueprint-modal" class="modal" aria-labelledby="delete-blueprint-title">
<div class="modal-header"> <div class="modal-header">
<h2 id="delete-blueprint-title">Delete blueprint "{{ blueprint.name }}"?</h2> <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> <tr><th>Exit code</th><td>{{ job.exit_code if job.exit_code is not none else "-" }}</td></tr>
</tbody> </tbody>
</table> </table>
</section>
<section class="panel">
<h2>Job Logs</h2> <h2>Job Logs</h2>
<pre class="log-stream" data-sse-url="/jobs/{{ job.id }}/stream"></pre> <pre class="log-stream" data-sse-url="/jobs/{{ job.id }}/stream"></pre>
</section> </section>

View file

@ -3,61 +3,19 @@
{% block title %}Overlay {{ overlay.name }} | left4me{% endblock %} {% block title %}Overlay {{ overlay.name }} | left4me{% endblock %}
{% block content %} {% block content %}
{% set can_edit = g.user.admin or (overlay.type in ['workshop', 'script'] and overlay.user_id == g.user.id) %}
<section class="panel"> <section class="panel">
<div class="page-heading"> <div class="page-heading">
<h1>Overlay: {{ overlay.name }}</h1> <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> </div>
{% if can_edit %} <dl class="server-info">
<form method="post" action="/overlays/{{ overlay.id }}" class="stack"> <div><dt>Type</dt><dd>{{ overlay.type }}</dd></div>
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}"> <div><dt>Scope</dt><dd>{% if overlay.user_id %}private{% else %}system{% endif %}</dd></div>
<label>Name <input name="name" value="{{ overlay.name }}" required></label> </dl>
<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>
{% if overlay.type == 'script' %}
<h2 class="section-title">Script</h2>
{% if can_edit %} {% if can_edit %}
<form method="post" action="/overlays/{{ overlay.id }}/script" class="stack"> <form method="post" action="/overlays/{{ overlay.id }}/script" class="stack">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}"> <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> <textarea name="script" rows="20" spellcheck="false">{{ overlay.script or "" }}</textarea>
</label> </label>
<p class="muted">Runs sandboxed against the overlay directory mounted at <code>/overlay</code>.</p> <p class="muted">Runs sandboxed against the overlay directory mounted at <code>/overlay</code>.</p>
<div> {% if not latest_build_is_running %}
<button type="submit">Save and build</button> <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> </div>
{% endif %}
</form> </form>
{% else %} {% else %}
<pre class="script-preview">{{ overlay.script or "" }}</pre> <pre class="script-preview">{{ overlay.script or "" }}</pre>
{% endif %} {% endif %}
{% include "_overlay_build_status.html" %}
{% endif %}
<p> {% if overlay.type == 'workshop' %}
{% if latest_build_job %} <h2 class="section-title">Workshop items</h2>
Latest build: <a href="/jobs/{{ latest_build_job.id }}">job #{{ latest_build_job.id }}</a> {% if can_edit and not latest_build_is_running %}
— state: <strong>{{ latest_build_job.state }}</strong>
·
{% 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 %}
<form method="post" action="/overlays/{{ overlay.id }}/items" class="stack"> <form method="post" action="/overlays/{{ overlay.id }}/items" class="stack">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}"> <input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<fieldset class="workshop-input-mode"> <fieldset class="workshop-input-mode">
@ -105,32 +47,18 @@
<label><input type="radio" name="input_mode" value="collection"> Collection (one ID or URL)</label> <label><input type="radio" name="input_mode" value="collection"> Collection (one ID or URL)</label>
</fieldset> </fieldset>
<label>Workshop input <textarea name="input" rows="3" placeholder="123456789&#10;https://steamcommunity.com/sharedfiles/filedetails/?id=987654321"></textarea></label> <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>
<button type="submit">Add</button>
</div>
</form> </form>
{% endif %} {% endif %}
<div id="overlay-item-table"> <div id="overlay-item-table">
{% include "_overlay_item_table.html" with context %} {% include "_overlay_item_table.html" with context %}
</div> </div>
</section>
<section class="panel"> {% include "_overlay_build_status.html" %}
<h2>Builds</h2> {% 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>
·
{% endif %}
<a href="/overlays/{{ overlay.id }}/jobs">all builds →</a>
</p>
</section>
{% endif %}
<section class="panel"> <h2 class="section-title">Files</h2>
<h2>Files</h2>
{% if not file_tree_root_entries %} {% if not file_tree_root_entries %}
<p class="muted">No files yet — build this overlay to populate it.</p> <p class="muted">No files yet — build this overlay to populate it.</p>
{% else %} {% else %}
@ -139,10 +67,8 @@
{% set truncated_count = file_tree_truncated_count %} {% set truncated_count = file_tree_truncated_count %}
{% include "_overlay_file_tree.html" %} {% include "_overlay_file_tree.html" %}
{% endif %} {% endif %}
</section>
<section class="panel"> <h2 class="section-title">Used by</h2>
<h2>Used by</h2>
{% if using_blueprints %} {% if using_blueprints %}
<ul class="used-by-list"> <ul class="used-by-list">
{% for blueprint in using_blueprints %} {% for blueprint in using_blueprints %}
@ -155,6 +81,25 @@
</section> </section>
{% if can_edit %} {% 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"> <dialog id="delete-overlay-modal" class="modal" aria-labelledby="delete-overlay-title">
<div class="modal-header"> <div class="modal-header">
<h2 id="delete-overlay-title">Delete overlay "{{ overlay.name }}"?</h2> <h2 id="delete-overlay-title">Delete overlay "{{ overlay.name }}"?</h2>
@ -171,24 +116,5 @@
</form> </form>
</div> </div>
</dialog> </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 %} {% endif %}
{% endblock %} {% endblock %}

View file

@ -6,48 +6,39 @@
<section class="panel"> <section class="panel">
<div class="page-heading"> <div class="page-heading">
<h1>Server: {{ server.name }}</h1> <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> </div>
<table class="definition-table"> <dl class="server-info">
<tbody> <div><dt>Port</dt><dd><a href="steam://run/550//+connect%20{{ connect_host }}:{{ server.port }}">{{ server.port }}</a></dd></div>
<tr><th>Name</th><td>{{ server.name }}</td></tr> <div><dt>Blueprint</dt><dd>{% if blueprint %}<a href="/blueprints/{{ blueprint.id }}">{{ blueprint.name }}</a>{% endif %}</dd></div>
<tr><th>Port</th><td>{{ server.port }}</td></tr> </dl>
<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>
<section class="panel"> <h2 class="section-title">Actions</h2>
<div class="page-heading"> {% include "_server_actions.html" %}
<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>
<section class="panel"> <h2 class="section-title">Server Log</h2>
<h2>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>
</section> </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"> <dialog id="reset-server-modal" class="modal" aria-labelledby="reset-server-title">
<div class="modal-header"> <div class="modal-header">
<h2 id="reset-server-title">Reset server "{{ server.name }}"?</h2> <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 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: def test_blueprint_detail_picker_emits_hidden_overlay_ids_in_order(user_client) -> None:
with session_scope() as session: with session_scope() as session:
user = session.scalar(select(User).where(User.username == "alice")) 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) delete_server(server_id)
unit = str(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", "install"],
["l4d2ctl", "start", unit], ["l4d2ctl", "start", unit],
["l4d2ctl", "stop", unit], ["l4d2ctl", "stop", unit],
@ -220,9 +225,78 @@ def _attach_workshop_overlay_to_blueprint(
return overlay_id, "1001" 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 monkeypatch: pytest.MonkeyPatch, server_with_blueprint, tmp_path
) -> None: ) -> 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 server_id, user_id = server_with_blueprint
overlay_id, _steam_id = _attach_workshop_overlay_to_blueprint( overlay_id, _steam_id = _attach_workshop_overlay_to_blueprint(
server_id, user_id, item_cached=True, tmp_path=tmp_path 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) initialize_server(server_id)
addons = tmp_path / "overlays" / str(overlay_id) / "left4dead2" / "addons" 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( 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 response.status_code == 200
assert "Server: alpha" in text 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/start"' in text
assert 'action="/servers/1/stop"' in text assert 'action="/servers/1/stop"' not in text
assert 'action="/servers/1/initialize"' in text assert 'action="/servers/1/initialize"' not in text # UI dropped this; start auto-inits.
assert 'action="/servers/1/delete"' in text assert 'action="/servers/1/delete"' in text # inside the delete modal
assert 'href="/blueprints/1"' in text 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 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: with session_scope() as session:
job = Job(user_id=1, server_id=1, operation="start", state="queued") job = Job(user_id=1, server_id=1, operation="start", state="queued")
session.add(job) 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) text = response.get_data(as_text=True)
assert response.status_code == 200 assert response.status_code == 200
assert "Recent Jobs" in text # Active job → no Start/Stop/Reset, only the last-job sentence.
assert 'href="/servers/1/jobs"' in text assert 'action="/servers/1/start"' not in text
assert f'href="/jobs/{job_id}"' 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: 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) text = response.get_data(as_text=True)
assert response.status_code == 200 assert response.status_code == 200
assert "Overlay order matters" in text assert ">Overlays<" in text
assert 'name="arguments"' in text assert 'name="arguments"' in text
assert 'name="config"' in text assert 'name="config"' in text
assert 'name="overlay_ids"' 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 response.status_code == 200
assert '<textarea name="script"' in text assert '<textarea name="script"' in text
assert "Rebuild" in text # Two compound submits replaced standalone Rebuild/Wipe.
assert "Wipe" in text assert "Save and build" in text
assert "Last 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: 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: with session_scope() as s:
user_id = s.query(User).filter_by(username="alice").one().id user_id = s.query(User).filter_by(username="alice").one().id
overlay_id = _seed_overlay("scripted", "script", user_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}") response = auth_client_with_server.get(f"/overlays/{overlay_id}")
text = response.get_data(as_text=True) 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"}, headers={"X-CSRF-Token": "test-token"},
) )
assert r1.status_code == 302 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: with session_scope() as s:
overlay = s.query(Overlay).filter_by(id=overlay_id).one() overlay = s.query(Overlay).filter_by(id=overlay_id).one()
assert overlay.script == "echo new" assert overlay.script == "echo new"
jobs = s.query(Job).filter_by(operation="build_overlay", overlay_id=overlay_id).all() jobs = s.query(Job).filter_by(operation="build_overlay", overlay_id=overlay_id).all()
assert len(jobs) == 1 assert len(jobs) == 1
assert r1.headers["Location"] == f"/jobs/{jobs[0].id}"
# Coalesce against pending. # Coalesce against pending.
r2 = client.post( r2 = client.post(
@ -136,7 +137,7 @@ def test_update_script_body_enqueues_build(app, alice_id) -> None:
headers={"X-CSRF-Token": "test-token"}, headers={"X-CSRF-Token": "test-token"},
) )
assert r2.status_code == 302 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: with session_scope() as s:
jobs = s.query(Job).filter_by(operation="build_overlay", overlay_id=overlay_id).all() jobs = s.query(Job).filter_by(operation="build_overlay", overlay_id=overlay_id).all()
assert len(jobs) == 1 assert len(jobs) == 1