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
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]:

View File

View File

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

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
settings = Settings()
assert settings.metrics_enabled is False
assert settings.metrics_enabled is True
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