From 6cdf48fc320bcbce85bee2d7be6b708d3d5143ec Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Mon, 29 Dec 2025 20:35:57 +0000 Subject: [PATCH] feat: add health endpoints, static serving, and base template (Step 7.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add /healthz endpoint with DB writable check - Add /metrics endpoint with Prometheus-compatible format (enabled by default) - Configure static file serving at /static/v1/... with immutable cache headers - Create base page template with MonsterUI slate theme - Create industrial farm aesthetic bottom navigation with custom SVG icons - Add StaticCacheMiddleware for adding cache-control headers Changes: - src/animaltrack/web/routes/health.py: Health and metrics endpoints - src/animaltrack/web/templates/: Base template, nav, and icons - src/animaltrack/web/app.py: Integrate theme, routes, static serving - src/animaltrack/config.py: metrics_enabled defaults to True 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/animaltrack/config.py | 2 +- src/animaltrack/static/v1/.gitkeep | 0 src/animaltrack/web/app.py | 78 ++++++++++--- src/animaltrack/web/routes/__init__.py | 6 + src/animaltrack/web/routes/health.py | 56 ++++++++++ src/animaltrack/web/templates/__init__.py | 7 ++ src/animaltrack/web/templates/base.py | 36 ++++++ src/animaltrack/web/templates/icons.py | 93 ++++++++++++++++ src/animaltrack/web/templates/nav.py | 112 +++++++++++++++++++ tests/test_config.py | 2 +- tests/test_web_health.py | 129 ++++++++++++++++++++++ tests/test_web_static.py | 85 ++++++++++++++ 12 files changed, 588 insertions(+), 18 deletions(-) create mode 100644 src/animaltrack/static/v1/.gitkeep create mode 100644 src/animaltrack/web/routes/__init__.py create mode 100644 src/animaltrack/web/routes/health.py create mode 100644 src/animaltrack/web/templates/__init__.py create mode 100644 src/animaltrack/web/templates/base.py create mode 100644 src/animaltrack/web/templates/icons.py create mode 100644 src/animaltrack/web/templates/nav.py create mode 100644 tests/test_web_health.py create mode 100644 tests/test_web_static.py diff --git a/src/animaltrack/config.py b/src/animaltrack/config.py index 739d1e4..538412b 100644 --- a/src/animaltrack/config.py +++ b/src/animaltrack/config.py @@ -36,7 +36,7 @@ class Settings(BaseSettings): # Application behavior seed_on_start: bool = False log_level: str = "INFO" - metrics_enabled: bool = False + metrics_enabled: bool = True @cached_property def trusted_proxy_ips(self) -> list[str]: diff --git a/src/animaltrack/static/v1/.gitkeep b/src/animaltrack/static/v1/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/animaltrack/web/app.py b/src/animaltrack/web/app.py index 3e69018..a3a708f 100644 --- a/src/animaltrack/web/app.py +++ b/src/animaltrack/web/app.py @@ -3,9 +3,12 @@ from __future__ import annotations -from fasthtml.common import Beforeware, fast_app +from pathlib import Path + +from fasthtml.common import H1, Beforeware, P, fast_app +from monsterui.all import Theme +from starlette.middleware import Middleware from starlette.requests import Request -from starlette.responses import PlainTextResponse from animaltrack.config import Settings from animaltrack.db import get_db @@ -14,17 +17,48 @@ from animaltrack.web.middleware import ( csrf_before, request_id_before, ) +from animaltrack.web.routes import register_health_routes +from animaltrack.web.templates import page + +# Default static directory relative to this module +DEFAULT_STATIC_DIR = Path(__file__).parent.parent / "static" + + +class StaticCacheMiddleware: + """Middleware to add immutable cache headers to static file responses.""" + + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + path = scope.get("path", "") + + async def send_with_headers(message): + if message["type"] == "http.response.start" and path.startswith("/static/"): + headers = list(message.get("headers", [])) + # Add immutable cache headers for static files + headers.append((b"cache-control", b"public, max-age=31536000, immutable")) + message = {**message, "headers": headers} + await send(message) + + await self.app(scope, receive, send_with_headers) def create_app( settings: Settings | None = None, db=None, + static_dir: Path | None = None, ): """Create and configure the FastHTML application. Args: settings: Application settings. Loads from env if None. db: Database connection. Creates from settings if None. + static_dir: Directory for static files. Uses default if None. Returns: Tuple of (app, rt) - the FastHTML app and route decorator. @@ -64,34 +98,46 @@ def create_app( r".*\.css", r".*\.js", r"/healthz", + r"/metrics", ], ) - # Create FastHTML app with HTMX extensions + # Determine static files directory + # FastHTML's static_path should point to parent dir since URL path mirrors file path + # e.g., for URL /static/v1/app.css, file should be at static_path/static/v1/app.css + static_base = static_dir if static_dir is not None else DEFAULT_STATIC_DIR + # static_base is typically .../static, parent would be .../ + # but for URL /static/v1/... we want files at static_base/v1/... + # So static_path should be the parent of static_base + static_path_for_fasthtml = str(static_base.parent) if static_base.exists() else "." + + # Create FastHTML app with HTMX extensions, MonsterUI theme, and static path app, rt = fast_app( before=beforeware, + hdrs=Theme.slate.headers(), # Dark industrial theme exts=["head-support", "preload"], + static_path=static_path_for_fasthtml, + middleware=[Middleware(StaticCacheMiddleware)], ) # Store settings and db on app state for access in routes app.state.settings = settings app.state.db = db - # Register healthz route (excluded from beforeware) - @rt("/healthz") - def healthz(): - """Health check endpoint.""" - # Verify database is writable - try: - db.execute("SELECT 1") - return PlainTextResponse("OK", status_code=200) - except Exception as e: - return PlainTextResponse(f"Database error: {e}", status_code=503) + # Register health routes (healthz, metrics) + register_health_routes(rt, app) - # Placeholder index route (will be replaced with real UI later) + # Placeholder index route (will be replaced with Egg Quick Capture later) @rt("/") def index(): - """Placeholder index route.""" - return PlainTextResponse("AnimalTrack", status_code=200) + """Placeholder index route - shows egg capture page.""" + return page( + ( + H1("Egg Quick Capture", cls="text-2xl font-bold mb-4"), + P("Coming soon...", cls="text-stone-400"), + ), + title="Egg - AnimalTrack", + active_nav="egg", + ) return app, rt diff --git a/src/animaltrack/web/routes/__init__.py b/src/animaltrack/web/routes/__init__.py new file mode 100644 index 0000000..6e27ec8 --- /dev/null +++ b/src/animaltrack/web/routes/__init__.py @@ -0,0 +1,6 @@ +# ABOUTME: Routes package for AnimalTrack web application. +# ABOUTME: Contains modular route handlers for different features. + +from animaltrack.web.routes.health import register_health_routes + +__all__ = ["register_health_routes"] diff --git a/src/animaltrack/web/routes/health.py b/src/animaltrack/web/routes/health.py new file mode 100644 index 0000000..fab38bf --- /dev/null +++ b/src/animaltrack/web/routes/health.py @@ -0,0 +1,56 @@ +# ABOUTME: Health and metrics endpoints for AnimalTrack. +# ABOUTME: Provides /healthz for liveness and /metrics for Prometheus. + +from starlette.responses import PlainTextResponse + + +def register_health_routes(rt, app): + """Register health and metrics routes. + + Args: + rt: FastHTML route decorator + app: FastHTML application instance + """ + + @rt("/healthz") + def healthz(): + """Health check endpoint - verifies database is writable.""" + try: + app.state.db.execute("SELECT 1") + return PlainTextResponse("OK", status_code=200) + except Exception as e: + return PlainTextResponse(f"Database error: {e}", status_code=503) + + @rt("/metrics") + def metrics(): + """Prometheus metrics endpoint. + + Returns metrics in Prometheus text format. + Gated by settings.metrics_enabled (default: True). + """ + if not app.state.settings.metrics_enabled: + return PlainTextResponse("Not Found", status_code=404) + + # Check database health for metric + try: + app.state.db.execute("SELECT 1") + db_healthy = 1 + except Exception: + db_healthy = 0 + + # Build Prometheus text format response + lines = [ + "# HELP animaltrack_up Whether the service is up", + "# TYPE animaltrack_up gauge", + "animaltrack_up 1", + "", + "# HELP animaltrack_db_healthy Whether database is healthy", + "# TYPE animaltrack_db_healthy gauge", + f"animaltrack_db_healthy {db_healthy}", + "", + ] + + return PlainTextResponse( + "\n".join(lines), + media_type="text/plain; version=0.0.4; charset=utf-8", + ) diff --git a/src/animaltrack/web/templates/__init__.py b/src/animaltrack/web/templates/__init__.py new file mode 100644 index 0000000..612d2dd --- /dev/null +++ b/src/animaltrack/web/templates/__init__.py @@ -0,0 +1,7 @@ +# ABOUTME: Templates package for AnimalTrack web UI. +# ABOUTME: Contains reusable UI components built with FastHTML and MonsterUI. + +from animaltrack.web.templates.base import page +from animaltrack.web.templates.nav import BottomNav + +__all__ = ["page", "BottomNav"] diff --git a/src/animaltrack/web/templates/base.py b/src/animaltrack/web/templates/base.py new file mode 100644 index 0000000..c7b2d82 --- /dev/null +++ b/src/animaltrack/web/templates/base.py @@ -0,0 +1,36 @@ +# ABOUTME: Base HTML template for AnimalTrack pages. +# ABOUTME: Provides consistent layout with MonsterUI theme and bottom nav. + +from fasthtml.common import Container, Div, Title + +from animaltrack.web.templates.nav import BottomNav, BottomNavStyles + + +def page(content, title: str = "AnimalTrack", active_nav: str = "egg"): + """ + Base page template wrapper with navigation. + + Wraps content with consistent HTML structure including: + - Page title + - Bottom navigation styling + - Content container with nav padding + - Fixed bottom navigation bar + + Args: + content: Page content (FT components) + title: Page title for browser tab + active_nav: Active nav item ID ('egg', 'feed', 'move', 'registry') + + Returns: + Tuple of FT components for the complete page + """ + return ( + Title(title), + BottomNavStyles(), + # Main content with bottom padding for fixed nav + Div( + Container(content), + cls="pb-20 min-h-screen bg-[#0f0f0e] text-stone-100", + ), + BottomNav(active_id=active_nav), + ) diff --git a/src/animaltrack/web/templates/icons.py b/src/animaltrack/web/templates/icons.py new file mode 100644 index 0000000..7fbed9f --- /dev/null +++ b/src/animaltrack/web/templates/icons.py @@ -0,0 +1,93 @@ +# ABOUTME: Custom SVG icons for AnimalTrack navigation. +# ABOUTME: Chunky, recognizable icons designed for outdoor visibility. + +from fasthtml.svg import Circle, Path, Svg + + +def EggIcon(active: bool = False): # noqa: N802 + """Egg collection icon - simple oval form.""" + fill = "#b8860b" if active else "#6b6b63" + return Svg( + Path( + d="M12 2C8 2 5 7 5 12C5 17 8 22 12 22C16 22 19 17 19 12C19 7 16 2 12 2Z", + fill=fill, + stroke=fill, + stroke_width="1.5", + ), + viewBox="0 0 24 24", + width="28", + height="28", + ) + + +def FeedIcon(active: bool = False): # noqa: N802 + """Feed bucket icon - utilitarian container.""" + fill = "#b8860b" if active else "#6b6b63" + return Svg( + Path( + d="M4 6H20L18 20H6L4 6Z", + fill="none", + stroke=fill, + stroke_width="2", + stroke_linejoin="round", + ), + Path(d="M2 6H22", stroke=fill, stroke_width="2", stroke_linecap="round"), + Path( + d="M9 10V16M12 10V16M15 10V16", + stroke=fill, + stroke_width="1.5", + stroke_linecap="round", + ), + viewBox="0 0 24 24", + width="28", + height="28", + ) + + +def MoveIcon(active: bool = False): # noqa: N802 + """Move/relocate icon - directional arrows.""" + fill = "#b8860b" if active else "#6b6b63" + return Svg( + Path( + d="M12 4L12 20M12 4L8 8M12 4L16 8", + stroke=fill, + stroke_width="2", + stroke_linecap="round", + stroke_linejoin="round", + ), + Path( + d="M4 12L20 12M4 12L8 8M4 12L8 16", + stroke=fill, + stroke_width="2", + stroke_linecap="round", + stroke_linejoin="round", + ), + viewBox="0 0 24 24", + width="28", + height="28", + ) + + +def RegistryIcon(active: bool = False): # noqa: N802 + """Registry/list icon - stacked rows like ledger.""" + fill = "#b8860b" if active else "#6b6b63" + return Svg( + Path(d="M4 6H8M12 6H20", stroke=fill, stroke_width="2", stroke_linecap="round"), + Path(d="M4 12H8M12 12H20", stroke=fill, stroke_width="2", stroke_linecap="round"), + Path(d="M4 18H8M12 18H20", stroke=fill, stroke_width="2", stroke_linecap="round"), + Circle(cx="6", cy="6", r="2", fill=fill), + Circle(cx="6", cy="12", r="2", fill=fill), + Circle(cx="6", cy="18", r="2", fill=fill), + viewBox="0 0 24 24", + width="28", + height="28", + ) + + +# Icon mapping by nav item ID +NAV_ICONS = { + "egg": EggIcon, + "feed": FeedIcon, + "move": MoveIcon, + "registry": RegistryIcon, +} diff --git a/src/animaltrack/web/templates/nav.py b/src/animaltrack/web/templates/nav.py new file mode 100644 index 0000000..104d081 --- /dev/null +++ b/src/animaltrack/web/templates/nav.py @@ -0,0 +1,112 @@ +# ABOUTME: Bottom navigation component for AnimalTrack mobile UI. +# ABOUTME: Industrial farm aesthetic with large touch targets and high contrast. + +from fasthtml.common import A, Div, Span, Style + +from animaltrack.web.templates.icons import NAV_ICONS + +# Navigation items configuration +NAV_ITEMS = [ + {"id": "egg", "label": "Egg", "href": "/"}, + {"id": "feed", "label": "Feed", "href": "/feed"}, + {"id": "move", "label": "Move", "href": "/move"}, + {"id": "registry", "label": "Registry", "href": "/registry"}, +] + + +def BottomNavStyles(): # noqa: N802 + """CSS styles for bottom navigation - include in page head.""" + return Style(""" + /* Bottom nav industrial styling */ + #bottom-nav { + box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.5); + } + + /* Safe area for iOS notch devices */ + .safe-area-pb { + padding-bottom: env(safe-area-inset-bottom, 0); + } + + /* Active item subtle glow effect */ + .nav-item-active::after { + content: ''; + position: absolute; + bottom: 0; + left: 50%; + transform: translateX(-50%); + width: 40%; + height: 2px; + background: linear-gradient(90deg, transparent, #b8860b, transparent); + } + + /* Hover state for non-touch devices */ + @media (hover: hover) { + #bottom-nav a:hover { + background-color: rgba(184, 134, 11, 0.1); + } + } + + /* Ensure consistent icon rendering */ + #bottom-nav svg { + flex-shrink: 0; + } + + /* Typography for labels */ + #bottom-nav span { + font-family: system-ui, -apple-system, sans-serif; + letter-spacing: 0.05em; + } + """) + + +def BottomNav(active_id: str = "egg"): # noqa: N802 + """ + Fixed bottom navigation bar for AnimalTrack. + + Args: + active_id: Currently active nav item ('egg', 'feed', 'move', 'registry') + + Returns: + FT component for the bottom nav + """ + + def nav_item(item): + is_active = item["id"] == active_id + icon_fn = NAV_ICONS[item["id"]] + + # Active: golden highlight, inactive: muted stone gray + label_cls = "text-xs font-semibold tracking-wide uppercase mt-1 " + label_cls += "text-amber-600" if is_active else "text-stone-500" + + item_cls = "flex flex-col items-center justify-center py-2 px-4 " + if is_active: + item_cls += "bg-stone-900/50 rounded-lg" + + link_cls = ( + "relative flex-1 flex items-center justify-center min-h-[64px] " + "transition-all duration-150 active:scale-95 " + ) + if is_active: + link_cls += "nav-item-active" + + return A( + Div( + icon_fn(active=is_active), + Span(item["label"], cls=label_cls), + cls=item_cls, + ), + href=item["href"], + cls=link_cls, + ) + + return Div( + # Top border with subtle texture effect + Div(cls="h-px bg-gradient-to-r from-transparent via-stone-700 to-transparent"), + # Nav container + Div( + *[nav_item(item) for item in NAV_ITEMS], + cls="flex items-stretch bg-[#1a1a18] safe-area-pb", + ), + cls="fixed bottom-0 left-0 right-0 z-50", + id="bottom-nav", + ) diff --git a/tests/test_config.py b/tests/test_config.py index 7fd9982..e4c5266 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -81,7 +81,7 @@ class TestConfigDefaults: from animaltrack.config import Settings settings = Settings() - assert settings.metrics_enabled is False + assert settings.metrics_enabled is True class TestConfigEnvOverrides: diff --git a/tests/test_web_health.py b/tests/test_web_health.py new file mode 100644 index 0000000..fa7a7df --- /dev/null +++ b/tests/test_web_health.py @@ -0,0 +1,129 @@ +# ABOUTME: Tests for health and metrics endpoints. +# ABOUTME: Covers /healthz DB check and /metrics Prometheus format. + +import os +from unittest.mock import MagicMock + +import pytest + + +def make_test_settings( + csrf_secret: str = "test-secret", + trusted_proxy_ips: str = "127.0.0.1", + metrics_enabled: bool = True, +): + """Create Settings for testing by setting env vars temporarily.""" + from animaltrack.config import Settings + + old_env = os.environ.copy() + try: + os.environ["CSRF_SECRET"] = csrf_secret + os.environ["TRUSTED_PROXY_IPS"] = trusted_proxy_ips + os.environ["METRICS_ENABLED"] = str(metrics_enabled).lower() + return Settings() + finally: + os.environ.clear() + os.environ.update(old_env) + + +class TestHealthzEndpoint: + """Tests for /healthz endpoint.""" + + @pytest.fixture + def client(self, seeded_db): + """Create a test client for the app.""" + from starlette.testclient import TestClient + + from animaltrack.web.app import create_app + + settings = make_test_settings(trusted_proxy_ips="testclient") + app, rt = create_app(settings=settings, db=seeded_db) + return TestClient(app, raise_server_exceptions=False) + + def test_healthz_returns_200_when_db_healthy(self, client): + """GET /healthz returns 200 OK when database is healthy.""" + resp = client.get("/healthz") + assert resp.status_code == 200 + assert resp.text == "OK" + + def test_healthz_returns_503_when_db_fails(self, seeded_db): + """GET /healthz returns 503 when database query fails.""" + from starlette.testclient import TestClient + + from animaltrack.web.app import create_app + + # Create a mock db that raises on execute + mock_db = MagicMock() + mock_db.execute.side_effect = Exception("Database connection lost") + + settings = make_test_settings(trusted_proxy_ips="testclient") + app, rt = create_app(settings=settings, db=mock_db) + client = TestClient(app, raise_server_exceptions=False) + + resp = client.get("/healthz") + assert resp.status_code == 503 + assert "Database error" in resp.text + + +class TestMetricsEndpoint: + """Tests for /metrics Prometheus endpoint.""" + + @pytest.fixture + def client_metrics_enabled(self, seeded_db): + """Create a test client with metrics enabled.""" + from starlette.testclient import TestClient + + from animaltrack.web.app import create_app + + settings = make_test_settings( + trusted_proxy_ips="testclient", + metrics_enabled=True, + ) + app, rt = create_app(settings=settings, db=seeded_db) + return TestClient(app, raise_server_exceptions=False) + + @pytest.fixture + def client_metrics_disabled(self, seeded_db): + """Create a test client with metrics disabled.""" + from starlette.testclient import TestClient + + from animaltrack.web.app import create_app + + settings = make_test_settings( + trusted_proxy_ips="testclient", + metrics_enabled=False, + ) + app, rt = create_app(settings=settings, db=seeded_db) + return TestClient(app, raise_server_exceptions=False) + + def test_metrics_returns_200_when_enabled(self, client_metrics_enabled): + """GET /metrics returns 200 when metrics_enabled=True.""" + resp = client_metrics_enabled.get("/metrics") + assert resp.status_code == 200 + + def test_metrics_returns_404_when_disabled(self, client_metrics_disabled): + """GET /metrics returns 404 when metrics_enabled=False.""" + resp = client_metrics_disabled.get("/metrics") + assert resp.status_code == 404 + + def test_metrics_returns_prometheus_format(self, client_metrics_enabled): + """GET /metrics returns valid Prometheus text format.""" + resp = client_metrics_enabled.get("/metrics") + + # Check Content-Type + content_type = resp.headers.get("content-type", "") + assert "text/plain" in content_type + + # Check body contains Prometheus format elements + body = resp.text + assert "# HELP" in body + assert "# TYPE" in body + assert "animaltrack_up" in body + + def test_metrics_includes_db_health_metric(self, client_metrics_enabled): + """GET /metrics includes database health gauge.""" + resp = client_metrics_enabled.get("/metrics") + body = resp.text + + assert "animaltrack_db_healthy" in body + assert "# TYPE animaltrack_db_healthy gauge" in body diff --git a/tests/test_web_static.py b/tests/test_web_static.py new file mode 100644 index 0000000..65cd477 --- /dev/null +++ b/tests/test_web_static.py @@ -0,0 +1,85 @@ +# ABOUTME: Tests for static file serving with cache headers. +# ABOUTME: Verifies /static/v1/ path and immutable cache-control. + +import os + +import pytest + + +def make_test_settings( + csrf_secret: str = "test-secret", + trusted_proxy_ips: str = "127.0.0.1", +): + """Create Settings for testing by setting env vars temporarily.""" + from animaltrack.config import Settings + + old_env = os.environ.copy() + try: + os.environ["CSRF_SECRET"] = csrf_secret + os.environ["TRUSTED_PROXY_IPS"] = trusted_proxy_ips + return Settings() + finally: + os.environ.clear() + os.environ.update(old_env) + + +class TestStaticFileServing: + """Tests for static file serving.""" + + @pytest.fixture + def static_dir(self, tmp_path): + """Create a temporary static directory with test files.""" + static_v1 = tmp_path / "static" / "v1" + static_v1.mkdir(parents=True) + + # Create a test CSS file + test_css = static_v1 / "test.css" + test_css.write_text("body { color: red; }") + + # Create a test JS file + test_js = static_v1 / "test.js" + test_js.write_text("console.log('test');") + + return tmp_path / "static" + + @pytest.fixture + def client(self, seeded_db, static_dir): + """Create a test client with static file serving configured.""" + from starlette.testclient import TestClient + + from animaltrack.web.app import create_app + + settings = make_test_settings(trusted_proxy_ips="testclient") + app, rt = create_app( + settings=settings, + db=seeded_db, + static_dir=static_dir, + ) + return TestClient(app, raise_server_exceptions=False) + + def test_static_files_are_served(self, client): + """Static files under /static/v1/ are accessible.""" + resp = client.get("/static/v1/test.css") + assert resp.status_code == 200 + assert "body { color: red; }" in resp.text + + def test_static_files_have_immutable_cache_headers(self, client): + """Static files have Cache-Control: immutable header.""" + resp = client.get("/static/v1/test.css") + assert resp.status_code == 200 + + cache_control = resp.headers.get("cache-control", "") + assert "immutable" in cache_control + assert "max-age=31536000" in cache_control + assert "public" in cache_control + + def test_static_js_files_served(self, client): + """JavaScript files are served correctly.""" + resp = client.get("/static/v1/test.js") + assert resp.status_code == 200 + assert "console.log" in resp.text + + def test_static_404_for_missing_files(self, client): + """Missing static files return 404.""" + resp = client.get("/static/v1/nonexistent.css") + assert resp.status_code == 404