Compare commits
3 commits
c2cf723911
...
fa686f11e3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa686f11e3 | ||
|
|
3c4bd6880a | ||
|
|
985df970f8 |
31 changed files with 1300 additions and 252 deletions
140
docs/superpowers/plans/2026-05-08-overlay-server-cfg-aliases.md
Normal file
140
docs/superpowers/plans/2026-05-08-overlay-server-cfg-aliases.md
Normal 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).
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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", [])],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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]] = []
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)],
|
||||||
|
|
|
||||||
29
l4d2web/services/timeago.py
Normal file
29
l4d2web/services/timeago.py
Normal 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()
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
);
|
||||||
|
|
|
||||||
2
l4d2web/static/vendor/htmx.min.js
vendored
2
l4d2web/static/vendor/htmx.min.js
vendored
File diff suppressed because one or more lines are too long
18
l4d2web/templates/_overlay_build_status.html
Normal file
18
l4d2web/templates/_overlay_build_status.html
Normal 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>
|
||||||
36
l4d2web/templates/_server_actions.html
Normal file
36
l4d2web/templates/_server_actions.html
Normal 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>
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
<label>Arguments <textarea name="arguments" rows="8" spellcheck="false">{{ arguments | join('\n') }}</textarea></label>
|
</div>
|
||||||
<label>Config <textarea name="config" rows="8" spellcheck="false">{{ config_lines | join('\n') }}</textarea></label>
|
{% set exposed = [] %}
|
||||||
|
{# Source `exec` is last-wins. First overlay in the list = topmost =
|
||||||
|
highest precedence, so its exec runs LAST. Iterate the picker list in
|
||||||
|
reverse to render the preview in actual execution order. #}
|
||||||
|
{% for overlay in selected_overlays | reverse %}{% if overlay_expose_state.get(overlay.id) %}{{ exposed.append(overlay) or '' }}{% endif %}{% endfor %}
|
||||||
|
<label><span class="section-title">Config</span>
|
||||||
|
<div class="config-shell">
|
||||||
|
{% if exposed %}
|
||||||
|
<pre class="config-preview" aria-label="Auto-loaded overlay configs">{% for o in exposed %}exec {{ o.name }}.cfg
|
||||||
|
{% endfor %}</pre>
|
||||||
|
{% endif %}
|
||||||
|
<textarea name="config" rows="8" spellcheck="false">{{ config_lines | join('\n') }}</textarea>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
<button type="submit">Save blueprint</button>
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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" %}
|
||||||
<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 %}
|
{% endif %}
|
||||||
<a href="/overlays/{{ overlay.id }}/jobs">all builds →</a>
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if overlay.type == 'workshop' %}
|
{% if overlay.type == 'workshop' %}
|
||||||
<section class="panel">
|
<h2 class="section-title">Workshop items</h2>
|
||||||
<div class="page-heading">
|
{% if can_edit and not latest_build_is_running %}
|
||||||
<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 https://steamcommunity.com/sharedfiles/filedetails/?id=987654321"></textarea></label>
|
<label>Workshop input <textarea name="input" rows="3" placeholder="123456789 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>
|
|
||||||
<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 %}
|
{% 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">×</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">×</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 %}
|
||||||
|
|
|
||||||
|
|
@ -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">×</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>
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue