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:
parent
7d9939c71d
commit
923a1840f4
17 changed files with 733 additions and 85 deletions
|
|
@ -10,6 +10,8 @@ DEFAULT_CONFIG: dict[str, object] = {
|
||||||
"JOB_WORKER_POLL_SECONDS": 1,
|
"JOB_WORKER_POLL_SECONDS": 1,
|
||||||
"JOB_LOG_REPLAY_LIMIT": 2000,
|
"JOB_LOG_REPLAY_LIMIT": 2000,
|
||||||
"JOB_LOG_LINE_MAX_CHARS": 4096,
|
"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_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_REPLAY_LIMIT": int(os.getenv("JOB_LOG_REPLAY_LIMIT", "2000")),
|
||||||
"JOB_LOG_LINE_MAX_CHARS": int(os.getenv("JOB_LOG_LINE_MAX_CHARS", "4096")),
|
"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")),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,27 @@ def update_blueprint_form(blueprint_id: int) -> Response:
|
||||||
return redirect(f"/blueprints/{blueprint_id}")
|
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>")
|
@bp.delete("/blueprints/<int:blueprint_id>")
|
||||||
@require_login
|
@require_login
|
||||||
def delete_blueprint(blueprint_id: int) -> Response:
|
def delete_blueprint(blueprint_id: int) -> Response:
|
||||||
|
|
@ -103,22 +124,22 @@ def delete_blueprint(blueprint_id: int) -> Response:
|
||||||
assert user is not None
|
assert user is not None
|
||||||
|
|
||||||
with session_scope() as db:
|
with session_scope() as db:
|
||||||
blueprint = db.scalar(
|
error = _delete_blueprint(db, user.id, blueprint_id)
|
||||||
select(BlueprintModel).where(
|
if error is not None:
|
||||||
BlueprintModel.id == blueprint_id,
|
return error
|
||||||
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 Response(status=204)
|
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")
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ def update_overlay(overlay_id: int) -> Response:
|
||||||
overlay.name = name
|
overlay.name = name
|
||||||
overlay.path = overlay_ref
|
overlay.path = overlay_ref
|
||||||
|
|
||||||
return redirect("/overlays")
|
return redirect(f"/overlays/{overlay_id}")
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/overlays/<int:overlay_id>/delete")
|
@bp.post("/overlays/<int:overlay_id>/delete")
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,11 @@ 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))
|
||||||
|
blueprints = db.scalars(
|
||||||
|
select(BlueprintModel)
|
||||||
|
.where(BlueprintModel.user_id == user.id)
|
||||||
|
.order_by(BlueprintModel.name)
|
||||||
|
).all()
|
||||||
recent_job_rows = db.execute(
|
recent_job_rows = db.execute(
|
||||||
select(Job, User, Server)
|
select(Job, User, Server)
|
||||||
.join(User, User.id == Job.user_id)
|
.join(User, User.id == Job.user_id)
|
||||||
|
|
@ -97,6 +102,7 @@ def server_detail(server_id: int):
|
||||||
"server_detail.html",
|
"server_detail.html",
|
||||||
server=server,
|
server=server,
|
||||||
blueprint=blueprint,
|
blueprint=blueprint,
|
||||||
|
blueprints=blueprints,
|
||||||
recent_job_rows=recent_job_rows,
|
recent_job_rows=recent_job_rows,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -130,6 +136,26 @@ def overlays() -> str:
|
||||||
return render_template("overlays.html", overlays=overlays)
|
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")
|
@bp.get("/blueprints")
|
||||||
@require_login
|
@require_login
|
||||||
def blueprints_page() -> str:
|
def blueprints_page() -> str:
|
||||||
|
|
|
||||||
|
|
@ -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 import select
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
|
@ -12,6 +12,36 @@ from l4d2web.services.security import validate_instance_name
|
||||||
bp = Blueprint("server", __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")
|
@bp.post("/servers")
|
||||||
@require_login
|
@require_login
|
||||||
def create_server() -> Response:
|
def create_server() -> Response:
|
||||||
|
|
@ -35,23 +65,27 @@ def create_server() -> Response:
|
||||||
if blueprint is None:
|
if blueprint is None:
|
||||||
return Response("blueprint not found", status=404)
|
return Response("blueprint not found", status=404)
|
||||||
|
|
||||||
|
port, error = _resolve_port(payload, db)
|
||||||
|
if error is not None:
|
||||||
|
return error
|
||||||
|
|
||||||
server = Server(
|
server = Server(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
blueprint_id=blueprint.id,
|
blueprint_id=blueprint.id,
|
||||||
name=name,
|
name=name,
|
||||||
port=int(payload["port"]),
|
port=port,
|
||||||
desired_state="stopped",
|
desired_state="stopped",
|
||||||
actual_state="unknown",
|
actual_state="unknown",
|
||||||
last_error="",
|
last_error="",
|
||||||
)
|
)
|
||||||
db.add(server)
|
db.add(server)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
db.flush()
|
db.flush()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
return Response("port already in use", status=409)
|
return Response("port already in use", status=409)
|
||||||
|
|
||||||
server_id = server.id
|
server_id = server.id
|
||||||
|
|
||||||
if json_response:
|
if json_response:
|
||||||
|
|
@ -85,6 +119,36 @@ def update_server(server_id: int) -> Response:
|
||||||
return jsonify({"id": server_id}), 200
|
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"}
|
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"}:
|
if operation in {"stop", "delete"}:
|
||||||
server.desired_state = "stopped"
|
server.desired_state = "stopped"
|
||||||
|
|
||||||
|
if operation == "delete":
|
||||||
|
return redirect("/servers")
|
||||||
return redirect(f"/servers/{server_id}")
|
return redirect(f"/servers/{server_id}")
|
||||||
|
|
|
||||||
|
|
@ -88,3 +88,99 @@ button.danger {
|
||||||
.auth-panel {
|
.auth-panel {
|
||||||
max-width: 28rem;
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
27
l4d2web/static/js/modal.js
Normal file
27
l4d2web/static/js/modal.js
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -39,5 +39,6 @@
|
||||||
<script src="{{ url_for('static', filename='vendor/htmx.min.js') }}"></script>
|
<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/csrf.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/sse.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/sse.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/modal.js') }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,10 @@
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="panel">
|
<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">
|
<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>Name <input name="name" value="{{ blueprint.name }}" required></label>
|
||||||
|
|
@ -28,4 +31,21 @@
|
||||||
<button type="submit">Save blueprint</button>
|
<button type="submit">Save blueprint</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</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">×</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 %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -4,33 +4,42 @@
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h1>Blueprints</h1>
|
<div class="page-heading">
|
||||||
<form method="post" action="/blueprints" class="stack form-panel">
|
<h1>Blueprints</h1>
|
||||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
<button type="button" data-modal-open="create-blueprint-modal">+ Create</button>
|
||||||
<label>Name <input name="name" required></label>
|
</div>
|
||||||
<label>Arguments <textarea name="arguments"></textarea></label>
|
|
||||||
<label>Config <textarea name="config"></textarea></label>
|
|
||||||
<button type="submit">Create blueprint</button>
|
|
||||||
</form>
|
|
||||||
<table class="table">
|
<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>
|
<tbody>
|
||||||
{% for blueprint in blueprints %}
|
{% for blueprint in blueprints %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="/blueprints/{{ blueprint.id }}">{{ blueprint.name }}</a></td>
|
<td><a href="/blueprints/{{ blueprint.id }}">{{ blueprint.name }}</a></td>
|
||||||
<td>{{ blueprint.created_at }}</td>
|
<td>{{ blueprint.created_at }}</td>
|
||||||
<td>{{ blueprint.updated_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>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="4" class="muted">No blueprints configured.</td></tr>
|
<tr><td colspan="3" class="muted">No blueprints configured.</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</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">×</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 %}
|
{% endblock %}
|
||||||
|
|
|
||||||
64
l4d2web/templates/overlay_detail.html
Normal file
64
l4d2web/templates/overlay_detail.html
Normal 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">×</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 %}
|
||||||
|
|
@ -6,43 +6,43 @@
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="page-heading">
|
<div class="page-heading">
|
||||||
<h1>Overlays</h1>
|
<h1>Overlays</h1>
|
||||||
|
{% if g.user.admin %}
|
||||||
|
<button type="button" data-modal-open="create-overlay-modal">+ Create</button>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</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">
|
<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>
|
<tbody>
|
||||||
{% for overlay in overlays %}
|
{% for overlay in overlays %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ overlay.name }}</td>
|
<td><a href="/overlays/{{ overlay.id }}">{{ overlay.name }}</a></td>
|
||||||
<td class="muted">{{ overlay.path }}</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>
|
</tr>
|
||||||
{% else %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</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">×</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 %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,7 @@
|
||||||
<button type="submit">{{ operation }}</button>
|
<button type="submit">{{ operation }}</button>
|
||||||
</form>
|
</form>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<form method="post" action="/servers/{{ server.id }}/delete" class="inline-form">
|
<button type="button" class="danger" data-modal-open="delete-server-modal">delete</button>
|
||||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
|
||||||
<button class="danger" type="submit">delete</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -32,6 +29,24 @@
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</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">
|
<section class="panel">
|
||||||
<div class="page-heading">
|
<div class="page-heading">
|
||||||
<h2>Recent Jobs</h2>
|
<h2>Recent Jobs</h2>
|
||||||
|
|
@ -49,4 +64,21 @@
|
||||||
<h2>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>
|
||||||
|
|
||||||
|
<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">×</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 %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -4,22 +4,11 @@
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h1>Servers</h1>
|
<div class="page-heading">
|
||||||
{% if blueprints %}
|
<h1>Servers</h1>
|
||||||
<form method="post" action="/servers" class="stack form-panel">
|
<button type="button" data-modal-open="create-server-modal"{% if not blueprints %} disabled{% endif %}>+ Create</button>
|
||||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
</div>
|
||||||
<label>Name <input name="name" required></label>
|
{% if not blueprints %}
|
||||||
<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 %}
|
|
||||||
<p class="muted">Create a blueprint before adding servers.</p>
|
<p class="muted">Create a blueprint before adding servers.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<table class="table">
|
<table class="table">
|
||||||
|
|
@ -39,4 +28,34 @@
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</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">×</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 %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,58 @@ def test_delete_blueprint_blocked_when_in_use(linked_blueprint) -> None:
|
||||||
assert response.status_code == 409
|
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:
|
def test_form_update_preserves_ordered_overlays_and_multiline_fields(user_client) -> None:
|
||||||
create = user_client.post(
|
create = user_client.post(
|
||||||
"/blueprints",
|
"/blueprints",
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,64 @@ def test_update_overlay_rejects_duplicate_name(admin_client) -> None:
|
||||||
assert response.status_code == 409
|
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:
|
def test_delete_overlay_rejects_in_use_overlay(admin_client) -> None:
|
||||||
create = admin_client.post(
|
create = admin_client.post(
|
||||||
"/overlays",
|
"/overlays",
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,159 @@ def test_create_server_rejects_unsafe_names(user_client_with_blueprints, bad_nam
|
||||||
assert session.scalars(select(Server)).all() == []
|
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:
|
def test_lifecycle_form_creates_queued_job(user_client_with_blueprints) -> None:
|
||||||
client, data = user_client_with_blueprints
|
client, data = user_client_with_blueprints
|
||||||
create_response = client.post(
|
create_response = client.post(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue