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_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")),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
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='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>
|
||||
|
|
|
|||
|
|
@ -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">×</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 %}
|
||||
|
|
|
|||
|
|
@ -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">×</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 %}
|
||||
|
|
|
|||
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">
|
||||
<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">×</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 %}
|
||||
|
|
|
|||
|
|
@ -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">×</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 %}
|
||||
|
|
|
|||
|
|
@ -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">×</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 %}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue