prop/lib/microwaveprop_web/router.ex
Graham McIntire 6a90d153ca
perf(map): /scores/cells binary endpoint, client-side fetch + band/time prefetch
Apply the same overlay refactor we did for /weather to the propagation
map. Score data used to flow as JSON via LiveView push_event
(update_scores + the 18-hour preload_forecast batch); switching bands
or scrubbing time meant a synchronous server roundtrip + ~MB JSON push
per change.

Now:

1. New /scores/cells endpoint returns a "PSCR" binary cell-pack —
   columnar f32 lats/lons + u8 scores, ~3-4× smaller than JSON and
   parsed via DataView + typed-array views with no JSON.parse.

2. JS hook owns score fetching. mount/moveend/band/time changes
   trigger HTTP fetches against /scores/cells. After the initial
   paint, every other forecast hour for the current band is fetched
   in parallel in the background — timeline scrubs after the prefetch
   finishes are pure cache hits with no network at all.

3. LiveView no longer pushes scores. select_band emits update_band_info
   with band_mhz so the client knows what to refetch; select_time and
   set_selected_time only update server-side state (URL, point detail
   bbox). map_bounds, propagation_updated, and advance_now_cursor all
   skip the score push — pack_scores, schedule_bounds_update,
   schedule_preload_forecast, the :flush_bounds and :preload_forecast
   handle_info clauses, and the start_async :initial_scores lifecycle
   (incl. retry_initial_scores event) are all deleted.

Net result: instant band toggles after the first paint, no LiveView
WebSocket payloads larger than the timeline metadata, and ~370 fewer
lines in MapLive.
2026-04-29 14:27:20 -05:00

276 lines
10 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule MicrowavepropWeb.Router do
use MicrowavepropWeb, :router
import MicrowavepropWeb.UserAuth
import Oban.Web.Router
import Phoenix.LiveDashboard.Router
pipeline :browser do
plug :serve_markdown_if_requested
plug :accepts, ["html"]
plug :fetch_session
plug :store_remote_ip
plug :store_cf_geo
plug :fetch_live_flash
plug :put_root_layout, html: {MicrowavepropWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :put_agent_link_headers
plug :fetch_current_scope_for_user
end
# RFC 8288 Link headers advertising resources for agent/crawler discovery.
# `/algo` is the human-readable algorithm/service documentation, rendered
# from `algo.md`. `/sitemap.xml` is the registered "sitemap" rel. About
# and privacy use their IANA-registered rels.
@agent_link_header Enum.join(
[
~s(</algo>; rel="service-doc"),
~s(</about>; rel="about"),
~s(</privacy>; rel="privacy-policy"),
~s(</sitemap.xml>; rel="sitemap")
],
", "
)
defp put_agent_link_headers(conn, _opts) do
Plug.Conn.put_resp_header(conn, "link", @agent_link_header)
end
# Markdown for Agents: if the client prefers `text/markdown`, serve real
# markdown for the paths where we have markdown source. Other paths fall
# through to the `:accepts` plug and 406 — we don't synthesize markdown
# from LiveView HTML on the fly, since a lossy conversion would mislead
# agents about site content.
@external_resource "algo.md"
@algo_markdown File.read!("algo.md")
@homepage_markdown """
# NTMS Propagation Prediction
The North Texas Microwave Society's propagation prediction service for
amateur radio bands 10241 GHz. Scores propagation conditions across
CONUS using HRRR numerical weather prediction, sounding data, ITU-R
atmospheric models, and calibration against 57,000+ recorded QSOs.
## Primary Resources
- [Propagation map](/map) — real-time CONUS propagation scores + 18-hour forecast.
- [Algorithm documentation](/algo) — full methodology, factor weights, ITU-R references (also available as `text/markdown`).
- [Contact database](/contacts) — 58,000+ historical QSOs used for calibration.
- [Contact map](/contacts/map) — geographic view of all recorded contacts.
- [Path calculator](/path) — point-to-point propagation prediction.
- [Beacons](/beacons) — microwave beacon directory.
- [Submit a contact](/submit) — add a QSO to the calibration dataset.
## Meta
- [About](/about)
- [Privacy](/privacy)
- [Sitemap](/sitemap.xml)
## API
- `GET /api/contacts/map` — JSON tuples of every contact on the map (lat/lon/band/callsigns/mode/distance/timestamp/id). Pre-gzipped, ~5 MB decompressed.
"""
defp serve_markdown_if_requested(conn, _opts) do
if wants_markdown?(conn) do
case markdown_for_path(conn.request_path) do
nil ->
conn
body ->
conn
|> Plug.Conn.put_resp_content_type("text/markdown", "utf-8")
|> Plug.Conn.put_resp_header("x-markdown-tokens", estimate_tokens(body))
|> Plug.Conn.send_resp(200, body)
|> Plug.Conn.halt()
end
else
conn
end
end
defp wants_markdown?(conn) do
conn
|> Plug.Conn.get_req_header("accept")
|> Enum.any?(&String.contains?(&1, "text/markdown"))
end
defp markdown_for_path("/"), do: @homepage_markdown
defp markdown_for_path("/algo"), do: @algo_markdown
defp markdown_for_path(_), do: nil
# Rough token estimate: ~4 chars per token is the widely-used OpenAI
# heuristic. Good enough for the `x-markdown-tokens` hint.
defp estimate_tokens(body), do: body |> byte_size() |> div(4) |> Integer.to_string()
defp store_remote_ip(conn, _opts) do
ip_str = conn.remote_ip |> :inet.ntoa() |> to_string()
Plug.Conn.put_session(conn, :remote_ip, ip_str)
end
# Extract Cloudflare visitor geolocation headers (set by a Cloudflare
# Managed Transform) and stash them in the session so LiveViews can use
# them to center maps on the visitor's approximate location.
defp store_cf_geo(conn, _opts) do
lat = conn |> Plug.Conn.get_req_header("cf-iplatitude") |> List.first()
lon = conn |> Plug.Conn.get_req_header("cf-iplongitude") |> List.first()
with {lat_f, ""} when is_float(lat_f) <- lat && Float.parse(lat),
{lon_f, ""} when is_float(lon_f) <- lon && Float.parse(lon) do
conn
|> Plug.Conn.put_session(:cf_lat, lat_f)
|> Plug.Conn.put_session(:cf_lon, lon_f)
else
_ -> conn
end
end
pipeline :api do
plug :accepts, ["json"]
end
# Health checks — no pipeline, minimal overhead.
# /live = liveness, BEAM only. /health = readiness, also pings Repo.
# Pods that serve a BEAM-alive /live but a failing /health get taken
# out of Service endpoints without SIGKILL cascading across replicas.
get "/live", MicrowavepropWeb.HealthController, :live, log: false
get "/health", MicrowavepropWeb.HealthController, :check, log: false
# Prometheus scrape endpoint. No pipeline so there's no session /
# CSRF overhead — the intent is a dedicated external Prometheus
# server polling every 15-30s. If `PROMETHEUS_AUTH_TOKEN` is set
# (runtime.exs), the endpoint requires a matching bearer token;
# otherwise it's unauthenticated and expected to be restricted at
# the ingress / Cloudflare Access layer.
forward "/metrics", MicrowavepropWeb.MetricsPlug
# API discovery (RFC 9727 / RFC 9264). These are JSON-only, no pipeline,
# no session — agents and crawlers fetch them without auth.
get "/.well-known/api-catalog", MicrowavepropWeb.ApiCatalogController, :catalog
get "/openapi.json", MicrowavepropWeb.ApiCatalogController, :openapi
# Agent Skills Discovery (v0.2.0). Index + individual skill documents.
get "/.well-known/agent-skills/index.json", MicrowavepropWeb.AgentSkillsController, :index
get "/.well-known/agent-skills/:name/SKILL.md",
MicrowavepropWeb.AgentSkillsController,
:skill
# Admin-only routes must come first so that literal segments like
# `/beacons/:id/edit` are registered before the public `/beacons/:id`.
scope "/", MicrowavepropWeb do
pipe_through [:browser, :require_authenticated_user]
live_session :admin, on_mount: [{MicrowavepropWeb.UserAuth, :require_admin}] do
live "/beacons/:id/edit", BeaconLive.Form, :edit
live "/status", StatusLive
live "/admin/contact-edits", Admin.ContactEditLive
live "/users", UserManagementLive.Index, :index
live "/users/:id/edit", UserManagementLive.Edit, :edit
end
end
scope "/", MicrowavepropWeb do
pipe_through :browser
get "/", PageController, :home
get "/api/contacts/map", ContactMapController, :show
get "/weather/cells", WeatherTileController, :cells
get "/scores/cells", ScoresController, :cells
live_session :public, on_mount: [{MicrowavepropWeb.UserAuth, :default}] do
live "/submit", SubmitLive
live "/imports/:id", ImportLive
live "/map", MapLive
live "/weather", WeatherMapLive
live "/contacts", ContactLive.Index
live "/contacts/map", ContactMapLive
live "/contacts/:id", ContactLive.Show
live "/path", PathLive
live "/eme", EmeLive
live "/skewt", SkewtLive
live "/rover", RoverLive
live "/rover-locations", RoverLocationsLive
live "/algo", AlgoLive
live "/about", AboutLive
live "/privacy", PrivacyLive
# Beacons: public read + submit (pending admin approval)
live "/beacons", BeaconLive.Index, :index
live "/beacons/new", BeaconLive.Form, :new
live "/beacons/:id", BeaconLive.Show, :show
# Public per-user profile (contributions: contacts + beacons submitted)
live "/u/:callsign", UserProfileLive
end
# Redirect old /qsos routes
get "/qsos", PageController, :redirect_contacts
get "/qsos/:id", PageController, :redirect_contact
end
# Other scopes may use custom stacks.
# scope "/api", MicrowavepropWeb do
# pipe_through :api
# end
scope "/" do
pipe_through [:browser, :require_authenticated_user]
live_dashboard "/admin/dashboard",
metrics: MicrowavepropWeb.Telemetry,
ecto_repos: [Microwaveprop.Repo],
ecto_psql_extras_options: [long_running_queries: [threshold: "200 milliseconds"]],
on_mount: [{MicrowavepropWeb.UserAuth, :require_admin}]
oban_dashboard("/admin/oban", on_mount: [{MicrowavepropWeb.UserAuth, :require_admin}])
end
# Enable Swoosh mailbox preview in development
if Application.compile_env(:microwaveprop, :dev_routes) do
scope "/dev" do
pipe_through :browser
forward "/mailbox", Plug.Swoosh.MailboxPreview
end
end
## Authentication routes
scope "/", MicrowavepropWeb do
pipe_through [:browser, :redirect_if_user_is_authenticated]
get "/users/register", UserRegistrationController, :new
post "/users/register", UserRegistrationController, :create
get "/users/reset-password", UserResetPasswordController, :new
post "/users/reset-password", UserResetPasswordController, :create
get "/users/reset-password/:token", UserResetPasswordController, :edit
put "/users/reset-password/:token", UserResetPasswordController, :update
end
scope "/", MicrowavepropWeb do
pipe_through [:browser, :require_authenticated_user]
get "/users/settings", UserSettingsController, :edit
put "/users/settings", UserSettingsController, :update
get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email
post "/users/beacon-monitors", BeaconMonitorController, :create
delete "/users/beacon-monitors/:id", BeaconMonitorController, :delete
end
scope "/", MicrowavepropWeb do
pipe_through [:browser]
get "/users/log-in", UserSessionController, :new
get "/users/confirm/:token", UserSessionController, :confirm
post "/users/log-in", UserSessionController, :create
delete "/users/log-out", UserSessionController, :delete
end
end