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
39 changes: 39 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,42 @@ jobs:
fail-build: true
severity-cutoff: high
only-fixed: true

push:
name: Push image to Docker Hub
runs-on: ubuntu-latest
needs: [container-scan]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
concurrency:
group: push-image
cancel-in-progress: false
steps:
- uses: actions/checkout@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Image tags
id: meta
uses: docker/metadata-action@v5
with:
images: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/bto
tags: |
type=sha,prefix=
type=raw,value=latest

- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
23 changes: 20 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,21 +29,38 @@ tap-through drilldown:
With Docker Compose (both services, hot-reload):

```bash
docker compose up --build # UI on :5173, API on :8000
docker compose up --build # UI on :5173/tides/, API on :8000/api/tides
```

Or run the two halves yourself:

```bash
cd backend && uv run uvicorn app.main:app --reload # :8000
cd frontend && npm install && npm run dev # :5173, proxies /api
cd frontend && npm install && npm run dev # :5173/tides/, proxies /api
```

For a single production container — frontend built and served as static files by
FastAPI:

```bash
docker build -t bto . && docker run -p 8000:8000 bto # :8000
docker build -t bto . && docker run -p 8000:8000 bto # :8000/tides
```

Everything is served under `/tides` (UI) and `/api/tides` (API) so it can share
the sean-michael.dev domain with the main site.

## Deploy

Pushes to `main` that pass CI publish `<dockerhub user>/bto:latest` to Docker Hub
(needs the `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` repo secrets). The container
runs on the main site's EC2 box: its `tides` service is in
[Sean-Michael.dev](https://github.com/Sean-Michael/Sean-Michael.dev)'s
`docker-compose.yaml`, and Traefik routes `/tides` and `/api/tides` to it. To turn
it on, add to `/opt/app/.env` on the box:

```bash
COMPOSE_PROFILES=tides
TIDES_IMAGE=<dockerhub user>/bto:latest
```

## How it works
Expand Down
17 changes: 12 additions & 5 deletions backend/app/api.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Bellingham Tidal Observatory REST API.

A single ``/api/bootstrap`` call returns everything the dashboard needs: station
Mounted under ``/api/tides`` (the site routes that prefix here). A single
``/api/tides/bootstrap`` call returns everything the dashboard needs: station
metadata plus real NOAA hi/lo predictions (extrema) spanning a wide window, plus
sun/moon facts. The frontend interpolates the smooth tide curve from those
extrema client-side, so range/density/hover changes need no further round trips.
Expand All @@ -17,7 +18,7 @@

from app import astro, noaa, stations

router = APIRouter(prefix="/api")
router = APIRouter(prefix="/api/tides")

_TZ = ZoneInfo("America/Los_Angeles")

Expand Down Expand Up @@ -61,9 +62,15 @@ async def _bootstrap() -> dict[str, Any]:
begin = (now - timedelta(days=_PAST_DAYS)).strftime("%Y%m%d")
end = (now + timedelta(days=_FUTURE_DAYS)).strftime("%Y%m%d")

payloads = await asyncio.gather(
*(_station_payload(s, begin, end) for s in stations.STATIONS)
# Don't let one flaky station sink the whole dashboard: gather, then drop
# any that failed (each station already falls back to last-known-good data).
results = await asyncio.gather(
*(_station_payload(s, begin, end) for s in stations.STATIONS),
return_exceptions=True,
)
payloads = [r for r in results if not isinstance(r, BaseException)]
if not payloads:
raise next(r for r in results if isinstance(r, BaseException))

sm = astro.sun_moon(now.date())
return {
Expand All @@ -83,7 +90,7 @@ async def _bootstrap() -> dict[str, Any]:
"moon_illum": sm.moon_illum,
"moon_glyph": sm.moon_glyph,
},
"stations": list(payloads),
"stations": payloads,
}


Expand Down
37 changes: 28 additions & 9 deletions backend/app/main.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles

from app.api import router as api_router
from app.config import Settings, get_settings

# Everything lives under /tides (UI) and /api/tides (API) so the app can sit
# behind sean-michael.dev's Traefik next to the main site without path clashes.
BASE = "/tides"


def create_app(settings: Settings | None = None) -> FastAPI:
settings = settings or get_settings()

app = FastAPI(title="Bellingham Tidal Observatory", version="0.1.0")
app = FastAPI(title="Bellingham Tidal Observatory", version="0.2.0")

app.add_middleware(
CORSMiddleware,
Expand All @@ -20,7 +24,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
allow_headers=["*"],
)

@app.get("/api/health")
@app.get("/api/tides/health")
def health() -> dict[str, str]:
return {"status": "ok"}

Expand All @@ -31,14 +35,29 @@ def health() -> dict[str, str]:
if settings.static_dir and settings.static_dir.is_dir():
assets = settings.static_dir / "assets"
if assets.is_dir():
app.mount("/assets", StaticFiles(directory=assets), name="assets")
app.mount(f"{BASE}/assets", StaticFiles(directory=assets), name="assets")

static_root = settings.static_dir.resolve()
index_file = static_root / "index.html"

index_file = settings.static_dir / "index.html"
@app.get("/", include_in_schema=False)
def root() -> RedirectResponse:
return RedirectResponse(BASE)

@app.get("/{full_path:path}")
def spa(full_path: str) -> FileResponse:
# Single-page app fallback: any non-API route returns index.html.
return FileResponse(index_file)
@app.get(BASE, include_in_schema=False)
@app.get(BASE + "/{full_path:path}", include_in_schema=False)
def spa(full_path: str = "") -> FileResponse:
# Top-level files from frontend/public (favicon, background image).
candidate = (static_root / full_path).resolve()
if (
full_path
and candidate.is_relative_to(static_root)
and candidate.is_file()
):
return FileResponse(candidate)
# Single-page app fallback. index.html points at the current hashed
# bundles, so always revalidate it.
return FileResponse(index_file, headers={"Cache-Control": "no-cache"})

return app

Expand Down
78 changes: 51 additions & 27 deletions backend/app/noaa.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

``noaa_coops`` does blocking HTTP + pandas work, so every call is dispatched to
a worker thread via :func:`asyncio.to_thread`. Results are memoized in-process
with a TTL so the dashboard's polling doesn't hammer the NOAA API.
with a TTL so the dashboard's polling doesn't hammer the NOAA API, and the
last-known-good data is served if NOAA is down (stale-if-error).
"""

from __future__ import annotations
Expand All @@ -26,42 +27,65 @@ class Extremum:
kind: str # "H" | "L"


_CACHE: dict[tuple[str, str, str], tuple[float, list[Extremum]]] = {}
# Keyed by station id (the begin/end window is always ~now±20d, so the last
# successful fetch stays usable). Holds the last-known-good extrema indefinitely
# so we can serve stale data when NOAA is flaky (predictions change very slowly).
_CACHE: dict[str, tuple[float, list[Extremum]]] = {}

_RETRIES = 2 # extra attempts on transient NOAA failures


def _fetch_blocking(station_id: str, begin: str, end: str) -> list[Extremum]:
station = CoopsStation(id=station_id)
df = station.get_data(
begin_date=begin,
end_date=end,
product="predictions",
datum="MLLW",
units="english",
time_zone="lst_ldt",
interval="hilo",
)
out: list[Extremum] = []
for ts, row in df.iterrows():
out.append(
Extremum(
t=ts.to_pydatetime(), height=float(row["v"]), kind=str(row["type"])
last_err: Exception | None = None
for attempt in range(_RETRIES + 1):
try:
station = CoopsStation(id=station_id)
df = station.get_data(
begin_date=begin,
end_date=end,
product="predictions",
datum="MLLW",
units="english",
time_zone="lst_ldt",
interval="hilo",
)
)
return out
out: list[Extremum] = []
for ts, row in df.iterrows():
out.append(
Extremum(
t=ts.to_pydatetime(),
height=float(row["v"]),
kind=str(row["type"]),
)
)
return out
except Exception as err: # noqa: BLE001 — retry any upstream hiccup
last_err = err
if attempt < _RETRIES:
time.sleep(0.6 * (attempt + 1))
assert last_err is not None
raise last_err


async def fetch_extrema(station_id: str, begin: str, end: str) -> list[Extremum]:
"""Return real NOAA hi/lo predictions for ``[begin, end]`` (YYYYMMDD)."""
key = (station_id, begin, end)
ttl = get_settings().cache_ttl
"""Return real NOAA hi/lo predictions for ``[begin, end]`` (YYYYMMDD).

Serves fresh data within the TTL, refetches after, and falls back to the
last-known-good extrema if NOAA is unavailable (stale-if-error).
"""
now = time.monotonic()
cached = _CACHE.get(key)
if cached is not None and now - cached[0] < ttl:
cached = _CACHE.get(station_id)
if cached is not None and now - cached[0] < get_settings().cache_ttl:
return cached[1]

extrema = await asyncio.to_thread(_fetch_blocking, station_id, begin, end)
_CACHE[key] = (now, extrema)
return extrema
try:
extrema = await asyncio.to_thread(_fetch_blocking, station_id, begin, end)
_CACHE[station_id] = (now, extrema)
return extrema
except Exception:
if cached is not None:
return cached[1] # stale-if-error
raise


def clear_cache() -> None:
Expand Down
7 changes: 6 additions & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,13 @@ dependencies = [
"astral>=3.2",
"fastapi>=0.136.3",
"noaa-coops>=0.4.0",
"pydantic-settings>=2.14.1",
"pydantic-settings>=2.14.2",
"uvicorn[standard]>=0.48.0",
# Floors for transitive deps flagged by the image scan (grype).
"anyio>=4.14.2",
"starlette>=1.3.1",
"urllib3>=2.8.0",
"zeep>=4.3.3",
]

[dependency-groups]
Expand Down
1 change: 1 addition & 0 deletions backend/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ def client() -> TestClient:
def static_dir(tmp_path: Path) -> Path:
"""A minimal built-frontend directory: index.html + one asset."""
(tmp_path / "index.html").write_text("<!doctype html><title>BTO</title>")
(tmp_path / "favicon.svg").write_text("<svg/>")
assets = tmp_path / "assets"
assets.mkdir()
(assets / "app.js").write_text("console.log('bto')")
Expand Down
59 changes: 57 additions & 2 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,15 @@ async def _fake(station_id: str, begin: str, end: str) -> list[noaa.Extremum]:


def test_list_stations_returns_all_six(client: TestClient) -> None:
res = client.get("/api/stations")
res = client.get("/api/tides/stations")
assert res.status_code == 200
body = res.json()
assert len(body) == len(stations.STATIONS) == 6
assert {s["id"] for s in body} == {s.id for s in stations.STATIONS}


def test_bootstrap_shape(client: TestClient, fake_noaa: None) -> None:
res = client.get("/api/bootstrap")
res = client.get("/api/tides/bootstrap")
assert res.status_code == 200
body = res.json()
assert body["datum"] == "MLLW"
Expand All @@ -47,3 +47,58 @@ def test_bootstrap_shape(client: TestClient, fake_noaa: None) -> None:
ex = stn["extrema"][0]
assert {"t", "height", "kind"} <= ex.keys()
assert isinstance(ex["t"], int) # epoch ms


def test_bootstrap_drops_failed_station(
client: TestClient, monkeypatch: pytest.MonkeyPatch, fake_noaa: None
) -> None:
ok = noaa.fetch_extrema

async def flaky(station_id: str, begin: str, end: str) -> list[noaa.Extremum]:
if station_id == stations.STATIONS[0].id:
raise RuntimeError("NOAA down")
return await ok(station_id, begin, end)

monkeypatch.setattr(noaa, "fetch_extrema", flaky)
body = client.get("/api/tides/bootstrap").json()
assert len(body["stations"]) == len(stations.STATIONS) - 1


def test_bootstrap_502_when_every_station_fails(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
async def down(station_id: str, begin: str, end: str) -> list[noaa.Extremum]:
raise RuntimeError("NOAA down")

monkeypatch.setattr(noaa, "fetch_extrema", down)
assert client.get("/api/tides/bootstrap").status_code == 502


def test_fetch_extrema_serves_stale_on_error(monkeypatch: pytest.MonkeyPatch) -> None:
import asyncio

from app.config import Settings

noaa.clear_cache()
calls = {"n": 0}
good = [noaa.Extremum(t=datetime(2026, 5, 27, 1, 0), height=8.0, kind="H")]

def fake_blocking(station_id: str, begin: str, end: str) -> list[noaa.Extremum]:
calls["n"] += 1
if calls["n"] == 1:
return good
raise RuntimeError("NOAA down")

monkeypatch.setattr(noaa, "_fetch_blocking", fake_blocking)
# cache_ttl=0 forces a refetch on the 2nd call
monkeypatch.setattr(noaa, "get_settings", lambda: Settings(cache_ttl=0))

async def run() -> tuple[list[noaa.Extremum], list[noaa.Extremum]]:
first = await noaa.fetch_extrema("9449211", "20260501", "20260601")
second = await noaa.fetch_extrema("9449211", "20260501", "20260601")
return first, second

first, second = asyncio.run(run())
assert first == good
assert second == good # stale-if-error: last-known-good despite the failure
noaa.clear_cache()
Loading
Loading