Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/ropeway/server/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ def create_app(settings: Settings | None = None) -> FastAPI:
allow_headers=["*"],
)

# ---- Phase 14: htmx shareable-link demo (unauthenticated, synthetic) ----
from .htmx import router as htmx_router
app.include_router(htmx_router)

# ---- health ----
@app.get("/health")
def health():
Expand Down
227 changes: 227 additions & 0 deletions src/ropeway/server/htmx.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
"""htmx web client — Phase 14.

A minimal, dependency-free server-rendered UI that drives the optimizer
through plain HTML + htmx attributes (no React, no build step, no extra
template-engine dependency). The whole point is *shareable result
links*: every run lands at ``/htmx/run/{run_id}`` and that URL can be
sent to anyone with network access to the API.

Routes
------
* ``GET /`` — landing page (form: corridor length, seed,
system, generations)
* ``POST /htmx/run`` — kick off a synthetic optimize, store the
result by run_id, return an HTML fragment
with the headline metrics + a permalink
* ``GET /htmx/run/{rid}`` — shareable view of a stored run

Runs are stored in process memory; the link is valid for the lifetime
of the server. The endpoint is unauthenticated by design — it operates
on synthetic terrain only, so there is no DEM upload and no project
data to leak. The authenticated FastAPI surface (``/auth``,
``/projects``, ``/optimize/dem``) is untouched.
"""

from __future__ import annotations

import uuid
from dataclasses import dataclass
from typing import Optional

from fastapi import APIRouter, Form, HTTPException
from fastapi.responses import HTMLResponse

from ..dem import synthetic_profile
from ..multi_rope import RopewaySystemType, system_defaults
from ..optimizer import GAConfig, optimize


router = APIRouter(tags=["htmx"])


@dataclass
class _StoredRun:
"""In-memory record of one htmx-driven optimization run."""
corridor_length_m: float
system: str
intermediate_towers: int
cable_length_m: float
min_clearance_m: float
max_tension_kn: float
max_break_over_deg: float
cost: float
feasible: bool


_RUNS: dict[str, _StoredRun] = {}


_BASE_CSS = """
body { font-family: system-ui, sans-serif; max-width: 760px;
margin: 2.5em auto; padding: 0 1em; color: #1a1a1a; }
h1 { font-size: 1.6rem; margin-bottom: 0.2em; }
.tagline { color: #666; margin-top: 0; }
form { display: grid; grid-template-columns: 1fr 1fr; gap: 0.8em 1em;
background: #f7f7f8; padding: 1em 1.2em; border-radius: 8px; }
label { display: flex; flex-direction: column; font-size: 0.9rem; }
input, select { padding: 0.35em 0.5em; font-size: 1rem;
border: 1px solid #ccc; border-radius: 4px; }
button { grid-column: 1 / -1; padding: 0.6em 1.2em; font-size: 1rem;
background: #2563eb; color: white; border: 0; border-radius: 4px;
cursor: pointer; }
button:hover { background: #1d4ed8; }
.result { margin-top: 1.5em; padding: 1em 1.2em; background: #f0fdf4;
border-left: 4px solid #16a34a; border-radius: 4px; }
.result.infeasible { background: #fef2f2; border-left-color: #dc2626; }
.metrics { display: grid; grid-template-columns: repeat(2, 1fr);
gap: 0.4em 1em; margin: 0.8em 0; }
.metric-label { color: #666; font-size: 0.85rem; }
.metric-value { font-weight: 600; }
.permalink { font-family: ui-monospace, monospace; font-size: 0.85rem;
background: #eef; padding: 0.4em 0.6em; border-radius: 4px;
word-break: break-all; }
"""


_LANDING_HTML = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Ropeway Alignment Optimizer</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<style>__CSS__</style>
</head>
<body>
<h1>Autonomous Ropeway Alignment</h1>
<p class="tagline">Server-rendered optimizer demo. Synthetic terrain only;
share any run with the link it produces.</p>
<form hx-post="/htmx/run" hx-target="#result" hx-swap="innerHTML"
hx-indicator="#spinner">
<label>Corridor length [m]
<input type="number" name="length_m" value="3000" min="500"
max="8000" step="100">
</label>
<label>Terrain seed
<input type="number" name="seed_terrain" value="42">
</label>
<label>System
<select name="system">
<option value="mgd">MGD (urban gondola)</option>
<option value="jigback">Jig-back (aerial tram)</option>
<option value="bgd">BGD (bi-cable gondola)</option>
<option value="3s">3S (tri-cable)</option>
<option value="funitel">Funitel</option>
<option value="chair">Chairlift</option>
</select>
</label>
<label>Generations
<input type="number" name="generations" value="60" min="10"
max="200" step="10">
</label>
<button type="submit">Run optimization</button>
<span id="spinner" class="htmx-indicator">Running…</span>
</form>
<div id="result"></div>
</body>
</html>
"""


def _result_fragment(rid: str, run: _StoredRun, *, base_url: str = "") -> str:
permalink = f"{base_url}/htmx/run/{rid}"
status_cls = "result" if run.feasible else "result infeasible"
status_text = "Feasible" if run.feasible else "Infeasible"
return f"""
<div class="{status_cls}">
<strong>{status_text}</strong> — {run.system.upper()} system,
{run.corridor_length_m:.0f} m corridor.
<div class="metrics">
<div class="metric-label">Intermediate towers</div>
<div class="metric-value">{run.intermediate_towers}</div>
<div class="metric-label">Cable length</div>
<div class="metric-value">{run.cable_length_m:.0f} m</div>
<div class="metric-label">Min clearance</div>
<div class="metric-value">{run.min_clearance_m:.2f} m</div>
<div class="metric-label">Max tension</div>
<div class="metric-value">{run.max_tension_kn:.1f} kN</div>
<div class="metric-label">Max break-over</div>
<div class="metric-value">{run.max_break_over_deg:.1f}°</div>
<div class="metric-label">Cost</div>
<div class="metric-value">{run.cost:,.0f}</div>
</div>
<div>Permalink:
<span class="permalink">{permalink}</span>
</div>
</div>
"""


@router.get("/", response_class=HTMLResponse, include_in_schema=False)
def landing() -> str:
return _LANDING_HTML.replace("__CSS__", _BASE_CSS)


@router.post("/htmx/run", response_class=HTMLResponse, include_in_schema=False)
def run_optimize(
length_m: float = Form(...),
seed_terrain: int = Form(42),
system: str = Form("mgd"),
generations: int = Form(60),
) -> str:
try:
sys_type = RopewaySystemType(system)
except ValueError as exc:
raise HTTPException(status_code=400,
detail=f"unknown system {system!r}") from exc
cfg = system_defaults(sys_type)
profile = synthetic_profile(length_m=float(length_m),
seed=int(seed_terrain))
result = optimize(
profile.as_function(), profile.total_length, cfg=cfg,
ga=GAConfig(max_intermediate_towers=10,
generations=int(generations),
population_size=80, seed=int(seed_terrain)),
verbose=False,
)
align = result.best_alignment
rep = result.best_result.report
rid = uuid.uuid4().hex[:12]
_RUNS[rid] = _StoredRun(
corridor_length_m=profile.total_length,
system=system,
intermediate_towers=max(0, len(align.towers) - 2),
cable_length_m=rep.total_cable_length_m,
min_clearance_m=rep.min_clearance_m,
max_tension_kn=rep.max_tension_n / 1e3,
max_break_over_deg=rep.max_break_over_deg,
cost=result.best_result.cost,
feasible=result.best_result.feasible,
)
return _result_fragment(rid, _RUNS[rid])


@router.get("/htmx/run/{rid}", response_class=HTMLResponse,
include_in_schema=False)
def view_run(rid: str) -> str:
run = _RUNS.get(rid)
if run is None:
raise HTTPException(status_code=404, detail="run not found")
fragment = _result_fragment(rid, run)
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Ropeway run {rid}</title>
<style>{_BASE_CSS}</style>
</head>
<body>
<h1>Shared run · {rid}</h1>
<p class="tagline">Read-only view of a previously-completed optimization.
<a href="/">Run your own →</a></p>
{fragment}
</body>
</html>
"""


__all__ = ["router"]
84 changes: 84 additions & 0 deletions tests/test_server_htmx.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""Phase 14 — htmx shareable web client tests."""

from __future__ import annotations

import os
import tempfile

import pytest
from fastapi.testclient import TestClient


@pytest.fixture()
def client(tmp_path, monkeypatch):
# Force a per-test SQLite file so the server's auth tables don't clash
# with other server tests sharing the singleton engine.
db_file = tmp_path / "htmx_test.sqlite"
monkeypatch.setenv("ROPEWAY_DATABASE_URL", f"sqlite:///{db_file}")
monkeypatch.setenv("ROPEWAY_JWT_SECRET", "test-secret-htmx")
# Reload the module so create_app picks up the env vars.
import importlib

from ropeway.server import api as api_module
importlib.reload(api_module)
return TestClient(api_module.app)


def test_landing_page_returns_html_form(client):
r = client.get("/")
assert r.status_code == 200
body = r.text
# The landing page is a self-contained htmx form.
assert "<form" in body
assert "hx-post=\"/htmx/run\"" in body
assert "name=\"system\"" in body
assert "name=\"length_m\"" in body


def test_run_returns_metrics_fragment_with_permalink(client):
r = client.post("/htmx/run", data={
"length_m": "1500", "seed_terrain": "7",
"system": "mgd", "generations": "20",
})
assert r.status_code == 200
body = r.text
# Result fragment carries the headline metrics + a shareable permalink.
assert "Cable length" in body
assert "Min clearance" in body
assert "/htmx/run/" in body
# The fragment is HTML but not a full document (it goes into #result).
assert "<!doctype" not in body.lower()


def test_run_with_unknown_system_returns_400(client):
r = client.post("/htmx/run", data={
"length_m": "1000", "seed_terrain": "1",
"system": "monorail", "generations": "10",
})
assert r.status_code == 400
assert "unknown system" in r.json()["detail"]


def test_share_link_returns_stored_run(client):
# First run — capture the run_id from the permalink.
r = client.post("/htmx/run", data={
"length_m": "1200", "seed_terrain": "11",
"system": "jigback", "generations": "20",
})
assert r.status_code == 200
body = r.text
marker = "/htmx/run/"
start = body.index(marker) + len(marker)
rid = body[start : start + 12]
# Sharing the link returns a full HTML page with the same metrics.
shared = client.get(f"/htmx/run/{rid}")
assert shared.status_code == 200
page = shared.text
assert "<!doctype" in page.lower()
assert rid in page
assert "Cable length" in page


def test_share_link_for_unknown_id_returns_404(client):
r = client.get("/htmx/run/deadbeefcafe")
assert r.status_code == 404
Loading