feat(web): blueprint-prefilled create-server flow + empty-state CTA

- Per-row "Create server" link on /blueprints navigates to
  /servers?blueprint_id=<id>; that page validates the param against
  the user's owned blueprints, pre-selects the option, and auto-opens
  the create modal.
- /servers empty-blueprint state now shows an actionable
  "Create a blueprint first ->" link (styled like the primary button)
  pointing at /blueprints, replacing the silent disabled "+ Create"
  button + muted hint.
- Drop the "Reassign blueprint" form on the server detail page
  along with the unused POST /servers/<id> form route. The JSON
  PATCH /servers/<id> endpoint is retained.

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

View file

@ -1,6 +1,6 @@
import json import json
from flask import Blueprint, Response, redirect, render_template from flask import Blueprint, Response, redirect, render_template, request
from sqlalchemy import select from sqlalchemy import select
from l4d2web.auth import current_user, require_admin, require_login from l4d2web.auth import current_user, require_admin, require_login
@ -70,7 +70,23 @@ def servers_page() -> str:
blueprints = db.scalars( blueprints = db.scalars(
select(BlueprintModel).where(BlueprintModel.user_id == user.id).order_by(BlueprintModel.name) select(BlueprintModel).where(BlueprintModel.user_id == user.id).order_by(BlueprintModel.name)
).all() ).all()
return render_template("servers.html", rows=rows, blueprints=blueprints)
prefill_blueprint_id: int | None = None
raw_prefill = request.args.get("blueprint_id")
if raw_prefill:
try:
candidate = int(raw_prefill)
except ValueError:
candidate = None
if candidate is not None and any(b.id == candidate for b in blueprints):
prefill_blueprint_id = candidate
return render_template(
"servers.html",
rows=rows,
blueprints=blueprints,
prefill_blueprint_id=prefill_blueprint_id,
)
@bp.get("/servers/<int:server_id>") @bp.get("/servers/<int:server_id>")
@ -84,11 +100,6 @@ 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)
@ -102,7 +113,6 @@ 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,
) )

View file

@ -119,36 +119,6 @@ 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"}

View file

@ -59,16 +59,20 @@ a:focus-visible {
outline-offset: 2px; outline-offset: 2px;
} }
button { button,
a.button {
background: var(--color-primary); background: var(--color-primary);
border: none; border: none;
border-radius: var(--radius-s); border-radius: var(--radius-s);
color: #fff; color: #fff;
padding: var(--space-s) var(--space-l); padding: var(--space-s) var(--space-l);
cursor: pointer; cursor: pointer;
display: inline-block;
text-decoration: none;
} }
button.danger { button.danger,
a.button.danger {
background: var(--color-danger); background: var(--color-danger);
} }

View file

@ -9,16 +9,17 @@
<button type="button" data-modal-open="create-blueprint-modal">+ Create</button> <button type="button" data-modal-open="create-blueprint-modal">+ Create</button>
</div> </div>
<table class="table"> <table class="table">
<thead><tr><th>Name</th><th>Created</th><th>Updated</th></tr></thead> <thead><tr><th>Name</th><th>Created</th><th>Updated</th><th>Actions</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><a href="/servers?blueprint_id={{ blueprint.id }}">Create server</a></td>
</tr> </tr>
{% else %} {% else %}
<tr><td colspan="3" class="muted">No blueprints configured.</td></tr> <tr><td colspan="4" class="muted">No blueprints configured.</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View file

@ -29,24 +29,6 @@
</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>

View file

@ -6,11 +6,12 @@
<section class="panel"> <section class="panel">
<div class="page-heading"> <div class="page-heading">
<h1>Servers</h1> <h1>Servers</h1>
<button type="button" data-modal-open="create-server-modal"{% if not blueprints %} disabled{% endif %}>+ Create</button> {% if blueprints %}
<button type="button" data-modal-open="create-server-modal">+ Create</button>
{% else %}
<a class="button" href="/blueprints">Create a blueprint first &rarr;</a>
{% endif %}
</div> </div>
{% if not blueprints %}
<p class="muted">Create a blueprint before adding servers.</p>
{% endif %}
<table class="table"> <table class="table">
<thead><tr><th>Name</th><th>Port</th><th>Blueprint</th><th>Desired</th><th>Actual</th></tr></thead> <thead><tr><th>Name</th><th>Port</th><th>Blueprint</th><th>Desired</th><th>Actual</th></tr></thead>
<tbody> <tbody>
@ -46,7 +47,7 @@
<label>Blueprint <label>Blueprint
<select name="blueprint_id" required> <select name="blueprint_id" required>
{% for blueprint in blueprints %} {% for blueprint in blueprints %}
<option value="{{ blueprint.id }}">{{ blueprint.name }}</option> <option value="{{ blueprint.id }}"{% if blueprint.id == prefill_blueprint_id %} selected{% endif %}>{{ blueprint.name }}</option>
{% endfor %} {% endfor %}
</select> </select>
</label> </label>
@ -58,4 +59,15 @@
</form> </form>
</dialog> </dialog>
{% endif %} {% endif %}
{% if prefill_blueprint_id %}
<script>
document.addEventListener("DOMContentLoaded", () => {
const dialog = document.getElementById("create-server-modal");
if (dialog && typeof dialog.showModal === "function") {
dialog.showModal();
}
});
</script>
{% endif %}
{% endblock %} {% endblock %}

View file

@ -37,6 +37,32 @@ def user_client_with_blueprints(tmp_path, monkeypatch):
return client, payload return client, payload
def test_servers_page_without_blueprints_shows_create_blueprint_cta(tmp_path, monkeypatch) -> None:
db_url = f"sqlite:///{tmp_path/'no_blueprints.db'}"
monkeypatch.setenv("DATABASE_URL", db_url)
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
init_db()
with session_scope() as session:
user = User(username="solo", password_digest=hash_password("secret"), admin=False)
session.add(user)
session.flush()
user_id = user.id
client = app.test_client()
with client.session_transaction() as sess:
sess["user_id"] = user_id
sess["csrf_token"] = "test-token"
response = client.get("/servers")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert 'href="/blueprints"' in text
assert "Create a blueprint first" in text
assert "disabled" not in text
def test_create_server_from_blueprint(user_client_with_blueprints) -> None: def test_create_server_from_blueprint(user_client_with_blueprints) -> None:
client, data = user_client_with_blueprints client, data = user_client_with_blueprints
payload = {"name": "alpha", "port": 27015, "blueprint_id": data["blueprint_id"]} payload = {"name": "alpha", "port": 27015, "blueprint_id": data["blueprint_id"]}
@ -213,59 +239,6 @@ def test_create_server_returns_409_when_port_range_exhausted(tmp_path, monkeypat
assert second.status_code == 409 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: def test_delete_operation_redirects_to_index(user_client_with_blueprints) -> None:
client, data = user_client_with_blueprints client, data = user_client_with_blueprints
create = client.post( create = client.post(
@ -285,6 +258,54 @@ def test_delete_operation_redirects_to_index(user_client_with_blueprints) -> Non
assert response.headers["Location"] == "/servers" assert response.headers["Location"] == "/servers"
def test_servers_page_prefills_blueprint_when_owned(user_client_with_blueprints) -> None:
client, data = user_client_with_blueprints
response = client.get(f"/servers?blueprint_id={data['other_blueprint_id']}")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert f'<option value="{data["other_blueprint_id"]}" selected>' in text
assert "showModal" in text
def test_servers_page_ignores_foreign_blueprint_id(user_client_with_blueprints) -> None:
client, _ = user_client_with_blueprints
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.get(f"/servers?blueprint_id={foreign_id}")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert "selected" not in text
assert "showModal" not in text
def test_servers_page_ignores_non_integer_blueprint_id(user_client_with_blueprints) -> None:
client, _ = user_client_with_blueprints
response = client.get("/servers?blueprint_id=abc")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert "showModal" not in text
def test_servers_page_without_param_does_not_auto_open(user_client_with_blueprints) -> None:
client, _ = user_client_with_blueprints
response = client.get("/servers")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert "showModal" not in text
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(