From c9b3623de9c66cd926677b32b8d0efc3cb476269 Mon Sep 17 00:00:00 2001 From: Harsh Pandhe Date: Wed, 20 May 2026 11:40:15 +0530 Subject: [PATCH] =?UTF-8?q?feat(phase-14):=20htmx=20shareable=20web=20clie?= =?UTF-8?q?nt=20=E2=80=94=20server-rendered=20optimizer=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 14 adds a minimal, dependency-free server-rendered web client that drives the optimizer through plain HTML + htmx attributes. The point is shareable result links: every run lands at /htmx/run/{rid} and that URL can be sent to anyone with network access to the API. Routes (registered in create_app, unauthenticated by design — synthetic terrain only, no DEM upload, no project leakage): GET / landing page (length, seed, system, generations) POST /htmx/run run synthetic optimize, return HTML fragment with metrics + a permanent run link GET /htmx/run/{rid} shareable read-only view of a stored run The implementation is a single ~200 line module (src/ropeway/server/htmx.py) with no template engine — inline HTML strings with a small CSS block, htmx attributes for the form post + result swap. Runs are stored in process memory; the link is valid for the lifetime of the server. The authenticated FastAPI surface (/auth, /projects, /optimize/dem) is untouched. Tests: 5 new in tests/test_server_htmx.py (landing form, run returns metrics + permalink, unknown system 400, share link returns stored run, unknown rid 404). Suite 157 → 162. --- src/ropeway/server/api.py | 4 + src/ropeway/server/htmx.py | 227 +++++++++++++++++++++++++++++++++++++ tests/test_server_htmx.py | 84 ++++++++++++++ 3 files changed, 315 insertions(+) create mode 100644 src/ropeway/server/htmx.py create mode 100644 tests/test_server_htmx.py diff --git a/src/ropeway/server/api.py b/src/ropeway/server/api.py index d134fd3..7d408cc 100644 --- a/src/ropeway/server/api.py +++ b/src/ropeway/server/api.py @@ -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(): diff --git a/src/ropeway/server/htmx.py b/src/ropeway/server/htmx.py new file mode 100644 index 0000000..8cb1ca0 --- /dev/null +++ b/src/ropeway/server/htmx.py @@ -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 = """ + + + +Ropeway Alignment Optimizer + + + + +

Autonomous Ropeway Alignment

+

Server-rendered optimizer demo. Synthetic terrain only; +share any run with the link it produces.

+
+ + + + + + Running… +
+
+ + +""" + + +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""" +
+ {status_text} — {run.system.upper()} system, + {run.corridor_length_m:.0f} m corridor. +
+
Intermediate towers
+
{run.intermediate_towers}
+
Cable length
+
{run.cable_length_m:.0f} m
+
Min clearance
+
{run.min_clearance_m:.2f} m
+
Max tension
+
{run.max_tension_kn:.1f} kN
+
Max break-over
+
{run.max_break_over_deg:.1f}°
+
Cost
+
{run.cost:,.0f}
+
+
Permalink: + {permalink} +
+
+""" + + +@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""" + + + +Ropeway run {rid} + + + +

Shared run · {rid}

+

Read-only view of a previously-completed optimization. +Run your own →

+{fragment} + + +""" + + +__all__ = ["router"] diff --git a/tests/test_server_htmx.py b/tests/test_server_htmx.py new file mode 100644 index 0000000..36c43f7 --- /dev/null +++ b/tests/test_server_htmx.py @@ -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 "