feat: add health endpoints, static serving, and base template (Step 7.2)

- 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 <noreply@anthropic.com>
This commit is contained in:
2025-12-29 20:35:57 +00:00
parent 52bc33891c
commit 6cdf48fc32
12 changed files with 588 additions and 18 deletions

View File

@@ -36,7 +36,7 @@ class Settings(BaseSettings):
# Application behavior # Application behavior
seed_on_start: bool = False seed_on_start: bool = False
log_level: str = "INFO" log_level: str = "INFO"
metrics_enabled: bool = False metrics_enabled: bool = True
@cached_property @cached_property
def trusted_proxy_ips(self) -> list[str]: def trusted_proxy_ips(self) -> list[str]:

View File

View File

@@ -3,9 +3,12 @@
from __future__ import annotations 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.requests import Request
from starlette.responses import PlainTextResponse
from animaltrack.config import Settings from animaltrack.config import Settings
from animaltrack.db import get_db from animaltrack.db import get_db
@@ -14,17 +17,48 @@ from animaltrack.web.middleware import (
csrf_before, csrf_before,
request_id_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( def create_app(
settings: Settings | None = None, settings: Settings | None = None,
db=None, db=None,
static_dir: Path | None = None,
): ):
"""Create and configure the FastHTML application. """Create and configure the FastHTML application.
Args: Args:
settings: Application settings. Loads from env if None. settings: Application settings. Loads from env if None.
db: Database connection. Creates from settings if None. db: Database connection. Creates from settings if None.
static_dir: Directory for static files. Uses default if None.
Returns: Returns:
Tuple of (app, rt) - the FastHTML app and route decorator. Tuple of (app, rt) - the FastHTML app and route decorator.
@@ -64,34 +98,46 @@ def create_app(
r".*\.css", r".*\.css",
r".*\.js", r".*\.js",
r"/healthz", 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( app, rt = fast_app(
before=beforeware, before=beforeware,
hdrs=Theme.slate.headers(), # Dark industrial theme
exts=["head-support", "preload"], exts=["head-support", "preload"],
static_path=static_path_for_fasthtml,
middleware=[Middleware(StaticCacheMiddleware)],
) )
# Store settings and db on app state for access in routes # Store settings and db on app state for access in routes
app.state.settings = settings app.state.settings = settings
app.state.db = db app.state.db = db
# Register healthz route (excluded from beforeware) # Register health routes (healthz, metrics)
@rt("/healthz") register_health_routes(rt, app)
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)
# Placeholder index route (will be replaced with real UI later) # Placeholder index route (will be replaced with Egg Quick Capture later)
@rt("/") @rt("/")
def index(): def index():
"""Placeholder index route.""" """Placeholder index route - shows egg capture page."""
return PlainTextResponse("AnimalTrack", status_code=200) 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 return app, rt

View File

@@ -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"]

View File

@@ -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",
)

View File

@@ -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"]

View File

@@ -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),
)

View File

@@ -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,
}

View File

@@ -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",
)

View File

@@ -81,7 +81,7 @@ class TestConfigDefaults:
from animaltrack.config import Settings from animaltrack.config import Settings
settings = Settings() settings = Settings()
assert settings.metrics_enabled is False assert settings.metrics_enabled is True
class TestConfigEnvOverrides: class TestConfigEnvOverrides:

129
tests/test_web_health.py Normal file
View File

@@ -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

85
tests/test_web_static.py Normal file
View File

@@ -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