left4me/l4d2web/routes/server_routes.py
mwiegand 923a1840f4
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>
2026-05-07 01:30:33 +02:00

175 lines
5.4 KiB
Python

from flask import Blueprint, Response, current_app, jsonify, redirect, request
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from l4d2web.auth import current_user, require_login
from l4d2web.db import session_scope
from l4d2web.models import Blueprint as BlueprintModel
from l4d2web.models import Job, Server
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:
user = current_user()
assert user is not None
json_response = request.is_json
payload = request.get_json(silent=True) if json_response else request.form
try:
name = validate_instance_name(str(payload["name"]))
except (KeyError, TypeError, ValueError):
return Response("invalid server name", status=400)
with session_scope() as db:
blueprint = db.scalar(
select(BlueprintModel).where(
BlueprintModel.id == int(payload["blueprint_id"]),
BlueprintModel.user_id == user.id,
)
)
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=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:
return jsonify({"id": server_id}), 201
return redirect(f"/servers/{server_id}")
@bp.patch("/servers/<int:server_id>")
@require_login
def update_server(server_id: int) -> Response:
user = current_user()
assert user is not None
payload = request.get_json(silent=True) or {}
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 == int(payload["blueprint_id"]),
BlueprintModel.user_id == user.id,
)
)
if blueprint is None:
return Response("blueprint not found", status=404)
server.blueprint_id = blueprint.id
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"}
@bp.post("/servers/<int:server_id>/<operation>")
@require_login
def enqueue_server_operation(server_id: int, operation: str) -> Response:
user = current_user()
assert user is not None
if operation not in LIFECYCLE_OPERATIONS:
return Response(status=404)
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)
db.add(Job(user_id=user.id, server_id=server.id, operation=operation, state="queued"))
if operation == "start":
server.desired_state = "running"
if operation in {"stop", "delete"}:
server.desired_state = "stopped"
if operation == "delete":
return redirect("/servers")
return redirect(f"/servers/{server_id}")