feat(web): forms in modals, edit/delete on detail pages, port auto-assign

- Native <dialog> modal infra (CSS + ~30 LOC JS, no framework) used for
  create forms and delete confirmations.
- Index pages become listing-only: + Create button opens a modal; the
  broken blueprint Actions column and inline overlay edit cells are gone.
- Server detail gains a blueprint reassignment form; existing Delete
  button now opens a confirmation modal before tearing down the runtime.
- Blueprint detail gains a Delete button + confirmation modal (was
  unreachable from the UI before).
- New overlay detail page at /overlays/<id> with edit form, "Used by"
  blueprints list, and delete (admin only).
- Server create: port field is now optional; backend auto-assigns the
  next free port from LEFT4ME_PORT_RANGE_START/_END (default
  27015-27115). 409 on range exhaustion.
- New routes: POST /blueprints/<id>/delete (form sentinel matching
  overlays pattern), POST /servers/<id> (form-friendly blueprint
  reassign), GET /overlays/<id>.
- Server delete operation now redirects to /servers; overlay update
  redirects to /overlays/<id>.

Server rename remains unsupported pending an id-vs-name design pass for
l4d2host (the runtime directory is name-keyed; renaming would orphan
files).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-07 01:30:33 +02:00
parent 7d9939c71d
commit 923a1840f4
No known key found for this signature in database
17 changed files with 733 additions and 85 deletions

View file

@ -10,6 +10,8 @@ DEFAULT_CONFIG: dict[str, object] = {
"JOB_WORKER_POLL_SECONDS": 1,
"JOB_LOG_REPLAY_LIMIT": 2000,
"JOB_LOG_LINE_MAX_CHARS": 4096,
"PORT_RANGE_START": 27015,
"PORT_RANGE_END": 27115,
}
@ -27,4 +29,6 @@ def load_config() -> dict[str, object]:
"JOB_WORKER_POLL_SECONDS": float(os.getenv("JOB_WORKER_POLL_SECONDS", "1")),
"JOB_LOG_REPLAY_LIMIT": int(os.getenv("JOB_LOG_REPLAY_LIMIT", "2000")),
"JOB_LOG_LINE_MAX_CHARS": int(os.getenv("JOB_LOG_LINE_MAX_CHARS", "4096")),
"PORT_RANGE_START": int(os.getenv("LEFT4ME_PORT_RANGE_START", "27015")),
"PORT_RANGE_END": int(os.getenv("LEFT4ME_PORT_RANGE_END", "27115")),
}

View file

@ -96,6 +96,27 @@ def update_blueprint_form(blueprint_id: int) -> Response:
return redirect(f"/blueprints/{blueprint_id}")
def _delete_blueprint(db, user_id: int, blueprint_id: int) -> Response | None:
blueprint = db.scalar(
select(BlueprintModel).where(
BlueprintModel.id == blueprint_id,
BlueprintModel.user_id == user_id,
)
)
if blueprint is None:
return Response(status=404)
linked_count = db.scalar(
select(func.count(Server.id)).where(Server.blueprint_id == blueprint.id)
) or 0
if linked_count > 0:
return Response("blueprint is in use", status=409)
db.execute(delete(BlueprintOverlay).where(BlueprintOverlay.blueprint_id == blueprint.id))
db.delete(blueprint)
return None
@bp.delete("/blueprints/<int:blueprint_id>")
@require_login
def delete_blueprint(blueprint_id: int) -> Response:
@ -103,22 +124,22 @@ def delete_blueprint(blueprint_id: int) -> Response:
assert user is not None
with session_scope() as db:
blueprint = db.scalar(
select(BlueprintModel).where(
BlueprintModel.id == blueprint_id,
BlueprintModel.user_id == user.id,
)
)
if blueprint is None:
return Response(status=404)
linked_count = db.scalar(
select(func.count(Server.id)).where(Server.blueprint_id == blueprint.id)
) or 0
if linked_count > 0:
return Response("blueprint is in use", status=409)
db.execute(delete(BlueprintOverlay).where(BlueprintOverlay.blueprint_id == blueprint.id))
db.delete(blueprint)
error = _delete_blueprint(db, user.id, blueprint_id)
if error is not None:
return error
return Response(status=204)
@bp.post("/blueprints/<int:blueprint_id>/delete")
@require_login
def delete_blueprint_form(blueprint_id: int) -> Response:
user = current_user()
assert user is not None
with session_scope() as db:
error = _delete_blueprint(db, user.id, blueprint_id)
if error is not None:
return error
return redirect("/blueprints")

View file

@ -55,7 +55,7 @@ def update_overlay(overlay_id: int) -> Response:
overlay.name = name
overlay.path = overlay_ref
return redirect("/overlays")
return redirect(f"/overlays/{overlay_id}")
@bp.post("/overlays/<int:overlay_id>/delete")

View file

@ -84,6 +84,11 @@ def server_detail(server_id: int):
if server is None:
return Response(status=404)
blueprint = db.scalar(select(BlueprintModel).where(BlueprintModel.id == server.blueprint_id))
blueprints = db.scalars(
select(BlueprintModel)
.where(BlueprintModel.user_id == user.id)
.order_by(BlueprintModel.name)
).all()
recent_job_rows = db.execute(
select(Job, User, Server)
.join(User, User.id == Job.user_id)
@ -97,6 +102,7 @@ def server_detail(server_id: int):
"server_detail.html",
server=server,
blueprint=blueprint,
blueprints=blueprints,
recent_job_rows=recent_job_rows,
)
@ -130,6 +136,26 @@ def overlays() -> str:
return render_template("overlays.html", overlays=overlays)
@bp.get("/overlays/<int:overlay_id>")
@require_login
def overlay_detail(overlay_id: int):
with session_scope() as db:
overlay = db.scalar(select(Overlay).where(Overlay.id == overlay_id))
if overlay is None:
return Response(status=404)
using_blueprints = db.scalars(
select(BlueprintModel)
.join(BlueprintOverlay, BlueprintOverlay.blueprint_id == BlueprintModel.id)
.where(BlueprintOverlay.overlay_id == overlay.id)
.order_by(BlueprintModel.name)
).all()
return render_template(
"overlay_detail.html",
overlay=overlay,
using_blueprints=using_blueprints,
)
@bp.get("/blueprints")
@require_login
def blueprints_page() -> str:

View file

@ -1,4 +1,4 @@
from flask import Blueprint, Response, jsonify, redirect, request
from flask import Blueprint, Response, current_app, jsonify, redirect, request
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
@ -12,6 +12,36 @@ from l4d2web.services.security import validate_instance_name
bp = Blueprint("server", __name__)
def _allocate_next_port(db) -> int | None:
start = int(current_app.config["PORT_RANGE_START"])
end = int(current_app.config["PORT_RANGE_END"])
used = set(
db.scalars(
select(Server.port).where(Server.port >= start, Server.port <= end)
).all()
)
for port in range(start, end + 1):
if port not in used:
return port
return None
def _resolve_port(payload, db) -> tuple[int | None, Response | None]:
raw = payload.get("port")
if raw is None or (isinstance(raw, str) and raw.strip() == ""):
port = _allocate_next_port(db)
if port is None:
return None, Response("no free port available", status=409)
return port, None
try:
port = int(raw)
except (TypeError, ValueError):
return None, Response("invalid port", status=400)
if not 1 <= port <= 65535:
return None, Response("invalid port", status=400)
return port, None
@bp.post("/servers")
@require_login
def create_server() -> Response:
@ -35,23 +65,27 @@ def create_server() -> Response:
if blueprint is None:
return Response("blueprint not found", status=404)
port, error = _resolve_port(payload, db)
if error is not None:
return error
server = Server(
user_id=user.id,
blueprint_id=blueprint.id,
name=name,
port=int(payload["port"]),
port=port,
desired_state="stopped",
actual_state="unknown",
last_error="",
)
db.add(server)
try:
db.flush()
except IntegrityError:
db.rollback()
return Response("port already in use", status=409)
server_id = server.id
if json_response:
@ -85,6 +119,36 @@ def update_server(server_id: int) -> Response:
return jsonify({"id": server_id}), 200
@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:
blueprint_id = int(request.form["blueprint_id"])
except (KeyError, TypeError, ValueError):
return Response("blueprint_id is required", 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)
blueprint = db.scalar(
select(BlueprintModel).where(
BlueprintModel.id == blueprint_id,
BlueprintModel.user_id == user.id,
)
)
if blueprint is None:
return Response("blueprint not found", status=404)
server.blueprint_id = blueprint.id
return redirect(f"/servers/{server_id}")
LIFECYCLE_OPERATIONS = {"initialize", "start", "stop", "delete"}
@ -106,4 +170,6 @@ def enqueue_server_operation(server_id: int, operation: str) -> Response:
if operation in {"stop", "delete"}:
server.desired_state = "stopped"
if operation == "delete":
return redirect("/servers")
return redirect(f"/servers/{server_id}")

View file

@ -88,3 +88,99 @@ button.danger {
.auth-panel {
max-width: 28rem;
}
dialog.modal {
background: var(--color-surface);
color: var(--color-text);
border: var(--line);
border-radius: var(--radius-m);
padding: 0;
width: min(32rem, 90vw);
max-height: 90vh;
}
dialog.modal::backdrop {
background: rgba(0, 0, 0, 0.45);
}
.modal-header,
.modal-body,
.modal-footer {
padding: var(--space-l);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: var(--line);
}
.modal-header h2 {
margin: 0;
font-size: 1.1rem;
}
.modal-body {
display: grid;
gap: var(--space-m);
overflow-y: auto;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: var(--space-m);
border-top: var(--line);
}
.modal-close {
background: none;
color: var(--color-muted);
padding: var(--space-xs) var(--space-s);
font-size: 1.25rem;
line-height: 1;
}
.button-secondary {
background: var(--color-surface);
color: var(--color-text);
border: var(--line);
}
.field-hint {
color: var(--color-muted);
font-size: 0.875rem;
}
.page-heading {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-m);
margin-bottom: var(--space-l);
}
.page-heading h1,
.page-heading h2 {
margin: 0;
}
.button-row {
display: flex;
gap: var(--space-s);
flex-wrap: wrap;
}
.detail-grid {
display: grid;
gap: var(--space-l);
}
.used-by-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: var(--space-s);
}

View file

@ -0,0 +1,27 @@
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll("[data-modal-open]").forEach((trigger) => {
trigger.addEventListener("click", (event) => {
const targetId = trigger.getAttribute("data-modal-open");
const dialog = document.getElementById(targetId);
if (dialog && typeof dialog.showModal === "function") {
event.preventDefault();
dialog.showModal();
}
});
});
document.querySelectorAll("dialog.modal").forEach((dialog) => {
dialog.querySelectorAll("[data-modal-close]").forEach((closer) => {
closer.addEventListener("click", (event) => {
event.preventDefault();
dialog.close();
});
});
dialog.addEventListener("click", (event) => {
if (event.target === dialog) {
dialog.close();
}
});
});
});

View file

@ -39,5 +39,6 @@
<script src="{{ url_for('static', filename='vendor/htmx.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/csrf.js') }}"></script>
<script src="{{ url_for('static', filename='js/sse.js') }}"></script>
<script src="{{ url_for('static', filename='js/modal.js') }}"></script>
</body>
</html>

View file

@ -4,7 +4,10 @@
{% block content %}
<section class="panel">
<h1>Blueprint: {{ blueprint.name }}</h1>
<div class="page-heading">
<h1>Blueprint: {{ blueprint.name }}</h1>
<button type="button" class="danger" data-modal-open="delete-blueprint-modal">Delete</button>
</div>
<form method="post" action="/blueprints/{{ blueprint.id }}" class="stack">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<label>Name <input name="name" value="{{ blueprint.name }}" required></label>
@ -28,4 +31,21 @@
<button type="submit">Save blueprint</button>
</form>
</section>
<dialog id="delete-blueprint-modal" class="modal" aria-labelledby="delete-blueprint-title">
<div class="modal-header">
<h2 id="delete-blueprint-title">Delete blueprint "{{ blueprint.name }}"?</h2>
<button type="button" class="modal-close" data-modal-close aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<p>This cannot be undone. Blueprints in use by a server cannot be deleted.</p>
</div>
<div class="modal-footer">
<button type="button" class="button-secondary" data-modal-close>Cancel</button>
<form method="post" action="/blueprints/{{ blueprint.id }}/delete" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button class="danger" type="submit">Delete</button>
</form>
</div>
</dialog>
{% endblock %}

View file

@ -4,33 +4,42 @@
{% block content %}
<section class="panel">
<h1>Blueprints</h1>
<form method="post" action="/blueprints" class="stack form-panel">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<label>Name <input name="name" required></label>
<label>Arguments <textarea name="arguments"></textarea></label>
<label>Config <textarea name="config"></textarea></label>
<button type="submit">Create blueprint</button>
</form>
<div class="page-heading">
<h1>Blueprints</h1>
<button type="button" data-modal-open="create-blueprint-modal">+ Create</button>
</div>
<table class="table">
<thead><tr><th>Name</th><th>Created</th><th>Updated</th><th>Actions</th></tr></thead>
<thead><tr><th>Name</th><th>Created</th><th>Updated</th></tr></thead>
<tbody>
{% for blueprint in blueprints %}
<tr>
<td><a href="/blueprints/{{ blueprint.id }}">{{ blueprint.name }}</a></td>
<td>{{ blueprint.created_at }}</td>
<td>{{ blueprint.updated_at }}</td>
<td>
<form method="post" action="/blueprints/{{ blueprint.id }}/delete" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button class="danger" type="submit">Delete</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="4" class="muted">No blueprints configured.</td></tr>
<tr><td colspan="3" class="muted">No blueprints configured.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
<dialog id="create-blueprint-modal" class="modal" aria-labelledby="create-blueprint-title">
<form method="post" action="/blueprints" class="stack">
<div class="modal-header">
<h2 id="create-blueprint-title">Create blueprint</h2>
<button type="button" class="modal-close" data-modal-close aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<label>Name <input name="name" required></label>
<label>Arguments <textarea name="arguments"></textarea></label>
<label>Config <textarea name="config"></textarea></label>
</div>
<div class="modal-footer">
<button type="button" class="button-secondary" data-modal-close>Cancel</button>
<button type="submit">Create blueprint</button>
</div>
</form>
</dialog>
{% endblock %}

View file

@ -0,0 +1,64 @@
{% extends "base.html" %}
{% block title %}Overlay {{ overlay.name }} | left4me{% endblock %}
{% block content %}
<section class="panel">
<div class="page-heading">
<h1>Overlay: {{ overlay.name }}</h1>
{% if g.user.admin %}
<button type="button" class="danger" data-modal-open="delete-overlay-modal">Delete</button>
{% endif %}
</div>
{% if g.user.admin %}
<form method="post" action="/overlays/{{ overlay.id }}" class="stack">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<label>Name <input name="name" value="{{ overlay.name }}" required></label>
<label>Path <input name="path" value="{{ overlay.path }}" required></label>
<div>
<button type="submit">Save</button>
</div>
</form>
{% else %}
<table class="definition-table">
<tbody>
<tr><th>Name</th><td>{{ overlay.name }}</td></tr>
<tr><th>Path</th><td>{{ overlay.path }}</td></tr>
</tbody>
</table>
{% endif %}
</section>
<section class="panel">
<h2>Used by</h2>
{% if using_blueprints %}
<ul class="used-by-list">
{% for blueprint in using_blueprints %}
<li><a href="/blueprints/{{ blueprint.id }}">{{ blueprint.name }}</a></li>
{% endfor %}
</ul>
{% else %}
<p class="muted">Not used by any blueprint.</p>
{% endif %}
</section>
{% if g.user.admin %}
<dialog id="delete-overlay-modal" class="modal" aria-labelledby="delete-overlay-title">
<div class="modal-header">
<h2 id="delete-overlay-title">Delete overlay "{{ overlay.name }}"?</h2>
<button type="button" class="modal-close" data-modal-close aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<p>This cannot be undone. Overlays in use by a blueprint cannot be deleted.</p>
</div>
<div class="modal-footer">
<button type="button" class="button-secondary" data-modal-close>Cancel</button>
<form method="post" action="/overlays/{{ overlay.id }}/delete" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button class="danger" type="submit">Delete</button>
</form>
</div>
</dialog>
{% endif %}
{% endblock %}

View file

@ -6,43 +6,43 @@
<section class="panel">
<div class="page-heading">
<h1>Overlays</h1>
{% if g.user.admin %}
<button type="button" data-modal-open="create-overlay-modal">+ Create</button>
{% endif %}
</div>
{% if g.user.admin %}
<form method="post" action="/overlays" class="stack form-panel">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<label>Name <input name="name" required></label>
<label>Path <input name="path" required placeholder="/opt/l4d2/overlays/example"></label>
<button type="submit">Add overlay</button>
</form>
{% endif %}
<table class="table">
<thead><tr><th>Name</th><th>Path</th>{% if g.user.admin %}<th>Actions</th>{% endif %}</tr></thead>
<thead><tr><th>Name</th><th>Path</th></tr></thead>
<tbody>
{% for overlay in overlays %}
<tr>
<td>{{ overlay.name }}</td>
<td><a href="/overlays/{{ overlay.id }}">{{ overlay.name }}</a></td>
<td class="muted">{{ overlay.path }}</td>
{% if g.user.admin %}
<td>
<form method="post" action="/overlays/{{ overlay.id }}" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<input name="name" value="{{ overlay.name }}" required>
<input name="path" value="{{ overlay.path }}" required>
<button type="submit">Save</button>
</form>
<form method="post" action="/overlays/{{ overlay.id }}/delete" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button class="danger" type="submit">Delete</button>
</form>
</td>
{% endif %}
</tr>
{% else %}
<tr><td colspan="{% if g.user.admin %}3{% else %}2{% endif %}" class="muted">No overlays configured.</td></tr>
<tr><td colspan="2" class="muted">No overlays configured.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
{% if g.user.admin %}
<dialog id="create-overlay-modal" class="modal" aria-labelledby="create-overlay-title">
<form method="post" action="/overlays" class="stack">
<div class="modal-header">
<h2 id="create-overlay-title">Add overlay</h2>
<button type="button" class="modal-close" data-modal-close aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<label>Name <input name="name" required></label>
<label>Path <input name="path" required placeholder="standard"></label>
</div>
<div class="modal-footer">
<button type="button" class="button-secondary" data-modal-close>Cancel</button>
<button type="submit">Add overlay</button>
</div>
</form>
</dialog>
{% endif %}
{% endblock %}

View file

@ -13,10 +13,7 @@
<button type="submit">{{ operation }}</button>
</form>
{% endfor %}
<form method="post" action="/servers/{{ server.id }}/delete" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button class="danger" type="submit">delete</button>
</form>
<button type="button" class="danger" data-modal-open="delete-server-modal">delete</button>
</div>
</div>
@ -32,6 +29,24 @@
</table>
</section>
<section class="panel">
<h2>Reassign blueprint</h2>
<form method="post" action="/servers/{{ server.id }}" class="stack">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<label>Blueprint
<select name="blueprint_id" required>
{% for option in blueprints %}
<option value="{{ option.id }}"{% if option.id == server.blueprint_id %} selected{% endif %}>{{ option.name }}</option>
{% endfor %}
</select>
</label>
<p class="field-hint">Changes apply on the next server action.</p>
<div>
<button type="submit">Save</button>
</div>
</form>
</section>
<section class="panel">
<div class="page-heading">
<h2>Recent Jobs</h2>
@ -49,4 +64,21 @@
<h2>Server Log</h2>
<pre class="log-stream" data-sse-url="/servers/{{ server.id }}/logs/stream"></pre>
</section>
<dialog id="delete-server-modal" class="modal" aria-labelledby="delete-server-title">
<div class="modal-header">
<h2 id="delete-server-title">Delete server "{{ server.name }}"?</h2>
<button type="button" class="modal-close" data-modal-close aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<p>This stops the server and tears down its runtime files. This cannot be undone.</p>
</div>
<div class="modal-footer">
<button type="button" class="button-secondary" data-modal-close>Cancel</button>
<form method="post" action="/servers/{{ server.id }}/delete" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button class="danger" type="submit">Delete</button>
</form>
</div>
</dialog>
{% endblock %}

View file

@ -4,22 +4,11 @@
{% block content %}
<section class="panel">
<h1>Servers</h1>
{% if blueprints %}
<form method="post" action="/servers" class="stack form-panel">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<label>Name <input name="name" required></label>
<label>Port <input name="port" type="number" min="1" max="65535" value="27015" required></label>
<label>Blueprint
<select name="blueprint_id" required>
{% for blueprint in blueprints %}
<option value="{{ blueprint.id }}">{{ blueprint.name }}</option>
{% endfor %}
</select>
</label>
<button type="submit">Create server</button>
</form>
{% else %}
<div class="page-heading">
<h1>Servers</h1>
<button type="button" data-modal-open="create-server-modal"{% if not blueprints %} disabled{% endif %}>+ Create</button>
</div>
{% if not blueprints %}
<p class="muted">Create a blueprint before adding servers.</p>
{% endif %}
<table class="table">
@ -39,4 +28,34 @@
</tbody>
</table>
</section>
{% if blueprints %}
<dialog id="create-server-modal" class="modal" aria-labelledby="create-server-title">
<form method="post" action="/servers" class="stack">
<div class="modal-header">
<h2 id="create-server-title">Create server</h2>
<button type="button" class="modal-close" data-modal-close aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<label>Name <input name="name" required></label>
<label>Port
<input name="port" type="number" min="1" max="65535" placeholder="auto">
<span class="field-hint">Leave empty for the next available port.</span>
</label>
<label>Blueprint
<select name="blueprint_id" required>
{% for blueprint in blueprints %}
<option value="{{ blueprint.id }}">{{ blueprint.name }}</option>
{% endfor %}
</select>
</label>
</div>
<div class="modal-footer">
<button type="button" class="button-secondary" data-modal-close>Cancel</button>
<button type="submit">Create server</button>
</div>
</form>
</dialog>
{% endif %}
{% endblock %}

View file

@ -85,6 +85,58 @@ def test_delete_blueprint_blocked_when_in_use(linked_blueprint) -> None:
assert response.status_code == 409
def test_post_delete_blueprint_redirects_to_index(user_client) -> None:
create = user_client.post(
"/blueprints",
data={"name": "doomed", "arguments": "", "config": ""},
headers={"X-CSRF-Token": "test-token"},
)
assert create.status_code == 302
from sqlalchemy import select
from l4d2web.models import Blueprint as BlueprintModel
with session_scope() as session:
blueprint_id = session.scalars(select(BlueprintModel.id)).one()
response = user_client.post(
f"/blueprints/{blueprint_id}/delete",
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 302
assert response.headers["Location"] == "/blueprints"
with session_scope() as session:
assert session.scalars(select(BlueprintModel)).all() == []
def test_post_delete_blueprint_blocked_when_in_use(linked_blueprint) -> None:
client, blueprint_id = linked_blueprint
response = client.post(
f"/blueprints/{blueprint_id}/delete",
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 409
def test_post_delete_blueprint_returns_404_for_other_user(user_client, tmp_path) -> None:
with session_scope() as session:
other = User(username="mallory", password_digest=hash_password("secret"), admin=False)
session.add(other)
session.flush()
foreign = Blueprint(user_id=other.id, name="foreign", arguments="[]", config="[]")
session.add(foreign)
session.flush()
foreign_id = foreign.id
response = user_client.post(
f"/blueprints/{foreign_id}/delete",
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 404
def test_form_update_preserves_ordered_overlays_and_multiline_fields(user_client) -> None:
create = user_client.post(
"/blueprints",

View file

@ -148,6 +148,64 @@ def test_update_overlay_rejects_duplicate_name(admin_client) -> None:
assert response.status_code == 409
def test_overlay_detail_page_lists_using_blueprints(admin_client) -> None:
create = admin_client.post(
"/overlays",
data={"name": "shared", "path": "shared"},
headers={"X-CSRF-Token": "test-token"},
)
assert create.status_code == 302
with session_scope() as session:
admin = session.query(User).filter_by(username="admin").one()
bp_one = Blueprint(user_id=admin.id, name="alpha-bp", arguments="[]", config="[]")
bp_two = Blueprint(user_id=admin.id, name="beta-bp", arguments="[]", config="[]")
session.add_all([bp_one, bp_two])
session.flush()
session.add(BlueprintOverlay(blueprint_id=bp_one.id, overlay_id=1, position=0))
session.add(BlueprintOverlay(blueprint_id=bp_two.id, overlay_id=1, position=0))
response = admin_client.get("/overlays/1")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert "alpha-bp" in text
assert "beta-bp" in text
assert "Used by" in text
def test_overlay_detail_page_404_when_missing(admin_client) -> None:
response = admin_client.get("/overlays/999")
assert response.status_code == 404
def test_overlay_detail_hides_edit_for_non_admin(user_client_with_overlay) -> None:
response = user_client_with_overlay.get("/overlays/1")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert "standard" in text
assert 'action="/overlays/1"' not in text
assert "delete-overlay-modal" not in text
def test_overlay_update_redirects_to_detail(admin_client) -> None:
create = admin_client.post(
"/overlays",
data={"name": "standard", "path": "standard"},
headers={"X-CSRF-Token": "test-token"},
)
assert create.status_code == 302
response = admin_client.post(
"/overlays/1",
data={"name": "renamed", "path": "renamed"},
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 302
assert response.headers["Location"] == "/overlays/1"
def test_delete_overlay_rejects_in_use_overlay(admin_client) -> None:
create = admin_client.post(
"/overlays",

View file

@ -132,6 +132,159 @@ def test_create_server_rejects_unsafe_names(user_client_with_blueprints, bad_nam
assert session.scalars(select(Server)).all() == []
def test_create_server_with_empty_port_auto_assigns(user_client_with_blueprints) -> None:
client, data = user_client_with_blueprints
response = client.post(
"/servers",
data={"name": "alpha", "port": "", "blueprint_id": str(data["blueprint_id"])},
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 302
from sqlalchemy import select
from l4d2web.models import Server
with session_scope() as session:
server = session.scalars(select(Server)).one()
assert server.port == 27015
def test_create_server_auto_assign_skips_taken_ports(user_client_with_blueprints) -> None:
client, data = user_client_with_blueprints
first = client.post(
"/servers",
data={"name": "alpha", "port": "27015", "blueprint_id": str(data["blueprint_id"])},
headers={"X-CSRF-Token": "test-token"},
)
assert first.status_code == 302
second = client.post(
"/servers",
data={"name": "beta", "blueprint_id": str(data["blueprint_id"])},
headers={"X-CSRF-Token": "test-token"},
)
assert second.status_code == 302
from sqlalchemy import select
from l4d2web.models import Server
with session_scope() as session:
ports = sorted(session.scalars(select(Server.port)).all())
assert ports == [27015, 27016]
def test_create_server_returns_409_when_port_range_exhausted(tmp_path, monkeypatch) -> None:
db_url = f"sqlite:///{tmp_path/'exhausted.db'}"
monkeypatch.setenv("DATABASE_URL", db_url)
monkeypatch.setenv("LEFT4ME_PORT_RANGE_START", "30000")
monkeypatch.setenv("LEFT4ME_PORT_RANGE_END", "30000")
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
init_db()
with session_scope() as session:
user = User(username="alice", password_digest=hash_password("secret"), admin=False)
session.add(user)
session.flush()
blueprint = Blueprint(user_id=user.id, name="bp", arguments="[]", config="[]")
session.add(blueprint)
session.flush()
user_id = user.id
blueprint_id = blueprint.id
client = app.test_client()
with client.session_transaction() as sess:
sess["user_id"] = user_id
sess["csrf_token"] = "test-token"
first = client.post(
"/servers",
data={"name": "alpha", "blueprint_id": str(blueprint_id)},
headers={"X-CSRF-Token": "test-token"},
)
assert first.status_code == 302
second = client.post(
"/servers",
data={"name": "beta", "blueprint_id": str(blueprint_id)},
headers={"X-CSRF-Token": "test-token"},
)
assert second.status_code == 409
def test_update_server_form_reassigns_blueprint(user_client_with_blueprints) -> None:
client, data = user_client_with_blueprints
create = client.post(
"/servers",
data={"name": "alpha", "port": "27015", "blueprint_id": str(data["blueprint_id"])},
headers={"X-CSRF-Token": "test-token"},
)
assert create.status_code == 302
server_id = int(create.headers["Location"].rsplit("/", 1)[-1])
response = client.post(
f"/servers/{server_id}",
data={"blueprint_id": str(data["other_blueprint_id"])},
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 302
assert response.headers["Location"] == f"/servers/{server_id}"
from sqlalchemy import select
from l4d2web.models import Server
with session_scope() as session:
server = session.scalars(select(Server)).one()
assert server.blueprint_id == data["other_blueprint_id"]
def test_update_server_form_rejects_foreign_blueprint(user_client_with_blueprints, tmp_path) -> None:
client, data = user_client_with_blueprints
create = client.post(
"/servers",
data={"name": "alpha", "port": "27015", "blueprint_id": str(data["blueprint_id"])},
headers={"X-CSRF-Token": "test-token"},
)
server_id = int(create.headers["Location"].rsplit("/", 1)[-1])
with session_scope() as session:
other = User(username="bob", password_digest=hash_password("secret"), admin=False)
session.add(other)
session.flush()
foreign = Blueprint(user_id=other.id, name="foreign", arguments="[]", config="[]")
session.add(foreign)
session.flush()
foreign_id = foreign.id
response = client.post(
f"/servers/{server_id}",
data={"blueprint_id": str(foreign_id)},
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 404
def test_delete_operation_redirects_to_index(user_client_with_blueprints) -> None:
client, data = user_client_with_blueprints
create = client.post(
"/servers",
data=json.dumps({"name": "alpha", "port": 27015, "blueprint_id": data["blueprint_id"]}),
content_type="application/json",
headers={"X-CSRF-Token": "test-token"},
)
server_id = create.get_json()["id"]
response = client.post(
f"/servers/{server_id}/delete",
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 302
assert response.headers["Location"] == "/servers"
def test_lifecycle_form_creates_queued_job(user_client_with_blueprints) -> None:
client, data = user_client_with_blueprints
create_response = client.post(