- Add vitest ^4.0.8, jsdom, @vitest/coverage-v8 to devDependencies - Configure vitest.config.ts for jsdom environment with src/**/*.spec.ts pattern - Simplify test-setup.ts (no TestBed needed for standalone unit tests) - Add example spec file src/app/app.spec.ts (5 tests) covering App component logic - Add npm scripts: test:unit, test:unit:watch, test:unit:coverage - Verified: 5/5 frontend tests pass, 86/86 backend pytest tests still pass
KMountain Flower Radio Station
A three-tier web application for a community radio station: Angular 21 frontend, FastAPI backend, PostgreSQL database.
Renders a hero page, about page, broadcast schedule, donation tiers, upcoming events, and a contact form — styled with a warm mountain/nature aesthetic.
Quick Start
Option A — Dev Container (recommended)
Open the repo in VS Code and click Reopen in Container (or run Dev Containers: Reopen in Container from the command palette). The dev container starts both the backend (port 8000) and frontend (port 4200) automatically.
Open http://localhost:4200.
Option B — Manual (frontend + backend separately)
# Backend (uses SQLite for local dev — no database server needed)
cd backend
pip install -r requirements.txt
export KMTN_DATABASE_URL="sqlite+aiosqlite:///./kmountain.db"
uvicorn app.main:app --reload
# Frontend (new terminal)
npm install && ng serve
Open http://localhost:4200 and ensure the backend is running on http://localhost:8000.
Prerequisites
| Requirement | Version | Notes |
|---|---|---|
| Node.js | 20+ | For frontend development |
| npm | 11+ | |
| Python | 3.12+ | For backend development |
| PostgreSQL | 16+ | For production only (dev uses SQLite) |
Architecture
┌──────────────┐
│ Browser │
│ :4200 / :80│
└──────┬───────┘
│
┌────────────┴────────────┐
│ │
┌────▼─────┐ ┌──────▼─────┐
│ Nginx │ /api/* │ FastAPI │
│ (static) │─ proxy ──▶ │ :8000 │
└──────────┘ └──────┬─────┘
│
┌──────▼─────┐
│ PostgreSQL │
│ :5432 │
└────────────┘
Three tiers:
- Frontend — Angular 21 SPA. In production, served by Nginx. In development, served by the Angular dev server on port 4200.
- Backend — Python FastAPI with async SQLAlchemy. Provides CRUD REST endpoints for programs (broadcast schedule), events (upcoming events), and donation tiers. Auto-generates Swagger/OpenAPI docs at
/docs. - Database — PostgreSQL 16. Schema contains three tables:
programs,events,donation_tiers. Initial data is loaded via seed.py. Local development uses SQLite for convenience.
Project Structure
.
├── docker-compose.yml # Parameterized base template
├── .devcontainer/ # VS Code dev container
├── docker-entrypoint.sh # Renders nginx config + config.json from env vars
├── nginx.conf.template # Nginx config template (envsubst)
├── config.json.template # Runtime API config template (envsubst)
├── .devcontainer/ # VS Code dev container
│ ├── Dockerfile # Alpine Python 3.12 + Node.js + Angular CLI
│ └── devcontainer.json # Dev container config
│
├── src/ # Angular 21 frontend
│ ├── app/ # Standalone components
│ │ ├── app.routes.ts # Lazy-loaded route map
│ │ ├── app.config.ts # App providers + APP_INITIALIZER (loads config.json)
│ │ ├── hero/ about/ schedule/ donate/ events/ contact/ navbar/ footer/
│ │ ├── services/ # HTTP services (program, event, tier, app-config)
│ │ └── interfaces/ # TypeScript types (Program, Event, Tier)
│ ├── environments/ # dev/prod config (apiBaseUrl)
│ └── styles/ # SCSS design tokens
│ ├── _variables.scss # Colors, spacing, breakpoints
│ ├── _mixins.scss # Card, button, responsive mixins
│ └── _themes.scss # SCSS → CSS custom properties
│
└── backend/ # FastAPI backend
├── Dockerfile # Python 3.12-slim + gunicorn/uvicorn
├── requirements.txt
├── seed.py # Initial data for all models
├── inject_test_logs.py # Test data injector for visitor stats pipeline
├── geo/ # MaxMind GeoLite2 City database
└── app/
├── main.py # FastAPI app, CORS, router registration
├── config.py # ENV-based settings (KMTN_* prefix)
├── database.py # async engine + session factory
├── models.py # SQLAlchemy ORM (Program, Event, DonationTier)
├── schemas.py # Pydantic schemas (Create/Update/Response)
└── api/
├── programs.py # CRUD /api/programs
├── events.py # CRUD /api/events
├── tiers.py # CRUD /api/tiers
└── stats.py # Visitor stats endpoints (summary, timeseries, geo, parse)
Local Development
Dev Container (recommended)
Open the repo in VS Code → Reopen in Container. The dev container starts both services automatically. See .devcontainer/ for the configuration.
Manual Development
Run both layers in parallel — development uses SQLite (no database server needed):
# 1. Backend (new terminal)
cd backend
pip install -r requirements.txt
export KMTN_DATABASE_URL="sqlite+aiosqlite:///./kmountain.db"
export KMTN_CORS_ORIGINS='["http://localhost:4200"]'
uvicorn app.main:app --reload
# 2. Frontend (another terminal)
npm install
ng serve
The database auto-seeds on first startup. To reset and re-seed at any time:
curl -X POST http://localhost:8000/api/admin/reset
Setting up the bootstrap admin user
The backend supports a hardcoded "bootstrap admin" login via two environment variables. When both are set, anyone with those credentials can authenticate at POST /api/auth/login and receives a JWT token with admin privileges.
# Enable bootstrap admin login
export KMTN_ADMIN_USERNAME="admin"
export KMTN_ADMIN_PASSWORD="my-secret-password"
To log in, send a POST request to /api/auth/login:
curl -X POST http://localhost:8000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "admin", "password": "my-secret-password"}'
This returns a JWT access token you can use to access protected endpoints:
curl http://localhost:8000/api/auth/me \
-H "Authorization: Bearer <access_token>"
If either variable is left empty (the default), login is disabled and returns a 503 error. For production, also set KMTN_JWT_SECRET_KEY to a strong random value.
Configuration
| Variable | Default | Where | Description |
|---|---|---|---|
| Variable | Default | Where | Description |
| --- | --- | --- | --- |
KMTN_DATABASE_URL |
postgresql+asyncpg://... (prod) / sqlite+aiosqlite:///./kmountain.db (dev) |
backend/app/config.py | Database connection string |
KMTN_CORS_ORIGINS |
["http://localhost:4200"] |
backend/app/config.py | CORS allowlist (JSON array) |
KMTN_ENV |
development |
backend/app/config.py | Set to production to disable admin endpoints |
KMTN_ADMIN_USERNAME |
(empty) | backend/app/config.py | Bootstrap admin username. Leave empty to disable login. |
KMTN_ADMIN_PASSWORD |
(empty) | backend/app/config.py | Bootstrap admin password. Both username and password must be set to enable login. |
KMTN_JWT_SECRET_KEY |
change-me-in-production |
backend/app/config.py | Secret key used to sign and verify JWT tokens. If leaked, anyone can forge admin tokens. Change in production. |
API_UPSTREAM |
http://api:8000 |
Dockerfile | Internal API target for Nginx proxy (Docker DNS) |
API_PUBLIC_URL |
"" (empty) |
Dockerfile | Browser-facing API URL. Empty = proxy mode; set to direct URL when ingress breaks proxy chain |
Environment variables are prefixed with KMTN_ in the backend. For production, set KMTN_DATABASE_URL to your managed PostgreSQL connection and update KMTN_CORS_ORIGINS to your actual domain.
Building
# Frontend (produces dist/radio-station/browser/)
ng build --configuration production
For production deployment, serve the built static files with Nginx and run the backend behind it.
Testing
ng test # Angular + Vitest
The backend API is self-documented at http://localhost:8000/docs (Swagger UI). The database auto-seeds on first startup — visit that URL to interactively test endpoints.
Admin Dashboard — Visitor Stats
A visitor analytics dashboard built on a log parsing pipeline: Nginx writes access logs to a shared volume that the FastAPI backend reads and processes. No extra services required (no Logstash, Prometheus, or message queues).
How it works
- Nginx writes structured JSON access logs to daily log files on a shared mount:
/logs/access-YYYY-MM-DD.log. Amapdirective extracts the date from the ISO timestamp, so Nginx rotates to a new file at midnight each day. - FastAPI (via log_parser.py) scans
/logs/for daily files that haven't been parsed yet, enriches each entry with IP geolocation (MaxMind GeoLite2 City), and stores aggregates in PostgreSQL. - PostgreSQL tables (
visitor_stats_daily,visitor_stats_geo,log_parse_state) hold the aggregated data and a "last parsed date" marker. - Angular admin dashboard reads from the stats API endpoints to render charts and a world map.
Why daily files
The currently-being-written file is never read — no partial lines, no race conditions, no file locking needed. Stats have a minimum one-day lag.
Stats API endpoints
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/stats/summary |
Total unique visitors for a date range |
GET |
/api/stats/timeseries |
Daily unique visitor counts (chart data) |
GET |
/api/stats/geo |
Geographic breakdown by country (map data) |
GET |
/api/stats/parse |
Trigger log parsing (SSE progress stream) |
All endpoints require admin authentication (JWT token). Date range is optional; defaults to the last 30 days.
GeoLite2 setup
The log parser requires a MaxMind GeoLite2 City database for IP geolocation:
- Download from https://dev.maxmind.com/geoip/geolite2-free-geolocation-data
- Place the
.mmdbfile atbackend/geo/GeoLite2-City.mmdb - The Dockerfile copies it into the container at
/app/geo/GeoLite2-City.mmdb
Injecting test data
To verify the pipeline works (e.g., after deploying or before a demo), use the test log injector script at backend/inject_test_logs.py. It writes realistic traffic from 25 well-known public IPs across multiple past days — data is only aggregated a day in arrears, so all dates are strictly before today.
# Run inside the api container (writes to shared /logs volume)
docker compose exec api python /app/inject_test_logs.py /logs 14
# Or run locally (dev mode)
cd backend
python inject_test_logs.py /logs 14
The second argument controls how many days of data to generate (default: 14). Each day gets a deterministic mix of visitors from the IP pool, so re-running produces the same output (idempotent — files are appended, so check first).
After injecting, trigger parsing via the admin dashboard or:
curl -X GET "http://localhost:8000/api/parse?token=YOUR_JWT"
Shared volume setup
For docker-compose, the logs named volume is mounted in both the frontend (Nginx) and api containers at /logs:
volumes:
logs:/logs
For Kubernetes (minikube), use a hostPath PersistentVolume with ReadWriteMany access mode, claimed by a PersistentVolumeClaim and mounted in both pods.
API Reference
The backend exposes CRUD endpoints for three models:
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/programs |
List all programs (?day=1..7 to filter by day of week) |
GET |
/api/programs/:id |
Single program |
POST |
/api/programs |
Create program |
PUT |
/api/programs/:id |
Update program |
DELETE |
/api/programs/:id |
Delete program |
GET |
/api/events |
List events (?active=true to filter active events) |
GET |
/api/events/:id |
Single event |
POST |
/api/events |
Create event |
PUT |
/api/events/:id |
Update event |
DELETE |
/api/events/:id |
Delete event |
GET |
/api/tiers |
List donation tiers (ordered by display_order) |
GET |
/api/tiers/:id |
Single tier |
POST |
/api/tiers |
Create tier |
PUT |
/api/tiers/:id |
Update tier |
DELETE |
/api/tiers/:id |
Delete tier |
Full interactive API docs at http://localhost:8000/docs.
Deployment
To deploy a customer environment, use the deploy-all.sh script to deploy everyone:
./deploy-all.sh
Or deploy a specific customer using their .env file:
docker compose --env-file customers/.env.<customer_name> -p <customer_name> up -d --build
The docker-compose.yml file is a parameterized template.
# Frontend
docker build -t <registry>/kmtnflower:latest .
docker push <registry>/kmtnflower:latest
# Backend
docker build -t <registry>/kmtnflower-api:latest ./backend
docker push <registry>/kmtnflower-api:latest
Deploy
On the target machine, create a Docker Compose file (see docker-compose.test.yml for a reference) that pulls the images and sets the required environment variables:
services:
db:
image: docker.io/library/postgres:16-alpine
environment:
POSTGRES_DB: kmountain
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
api:
image: <registry>/kmtnflower-api:latest
environment:
KMTN_DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/kmountain
KMTN_CORS_ORIGINS: '["http://yourdomain.com"]'
KMTN_ENV: production
KMTN_ADMIN_USERNAME: admin
KMTN_ADMIN_PASSWORD: change-me
KMTN_JWT_SECRET_KEY: change-me-to-a-random-string
depends_on:
db:
condition: service_healthy
frontend:
image: <registry>/kmtnflower:latest
environment:
API_UPSTREAM: http://api:8000
API_PUBLIC_URL: ""
ports:
- "4200:80"
depends_on:
- api
volumes:
pgdata:
Then start:
docker compose up -d
API_PUBLIC_URL
The frontend container renders a config.json at startup from the API_PUBLIC_URL environment variable. This controls how the Angular app reaches the API:
| Mode | Value | When to use |
|---|---|---|
| Proxy (default) | "" (empty) |
Browser calls /api/* relative paths; Nginx forwards to API_UPSTREAM. Use when no external ingress interferes. |
| Direct | "http://yourhost:8000" |
Browser calls the API container directly. Use when an external ingress layer (e.g., TrueNAS middleware, Kubernetes ingress) breaks the Nginx proxy chain. Requires CORS to be configured on the backend. |
Example: TrueNAS Scale
For a TrueNAS Scale deployment where the built-in ingress layer may redirect traffic:
services:
db:
image: docker.io/library/postgres:16-alpine
environment:
POSTGRES_DB: kmountain
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
api:
image: truenas.local:35000/kmtnflower-api:latest
environment:
KMTN_DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/kmountain
KMTN_CORS_ORIGINS: '["http://truenas.local:34200", "http://truenas.local"]'
KMTN_ENV: production
KMTN_ADMIN_USERNAME: admin
KMTN_ADMIN_PASSWORD: change-me
KMTN_JWT_SECRET_KEY: change-me-to-a-random-string
ports:
- "8000:8000"
depends_on:
db:
condition: service_healthy
frontend:
image: truenas.local:35000/kmtnflower:latest
environment:
API_UPSTREAM: http://api:8000
API_PUBLIC_URL: "http://truenas.local:8000"
ports:
- "34200:80"
depends_on:
- api
volumes:
pgdata:
Note: The database auto-seeds with initial data on first startup. To re-seed at any time (dev only):
curl -X POST http://localhost:8000/api/admin/reset.
Bare-metal production
For production, you'll need to set up:
- A PostgreSQL database (managed or self-hosted)
- The FastAPI backend (gunicorn/uvicorn)
- Nginx to serve the Angular build and proxy
/api/*to the backend
# 1. Build the frontend
ng build --configuration production
# 2. Configure the backend
export KMTN_DATABASE_URL="postgresql+asyncpg://user:pass@host:5432/kmountain"
export KMTN_CORS_ORIGINS='["https://yourdomain.com"]'
export KMTN_ENV="production"
export KMTN_ADMIN_USERNAME="admin"
export KMTN_ADMIN_PASSWORD="change-me"
export KMTN_JWT_SECRET_KEY="change-me-to-a-random-string"
# 3. Start the backend
cd backend
pip install -r requirements.txt
uvicorn app.main:app --host 0.0.0.0 --port 8000
# 4. Serve the frontend with Nginx (or your preferred web server),
# pointing /api/* requests to http://localhost:8000
The database auto-seeds on first startup.
Production hardening checklist
- Use a managed PostgreSQL database (RDS, Cloud SQL, etc.)
- Update
KMTN_CORS_ORIGINSto your actual domain (notlocalhost) - Set
KMTN_ENV="production"to disable admin endpoints - Set
KMTN_ADMIN_USERNAMEandKMTN_ADMIN_PASSWORDto strong values - Set
KMTN_JWT_SECRET_KEYto a strong random value - Add a reverse proxy (Caddy, Traefik) in front of Nginx for HTTPS
- Set up log rotation
- Configure a process manager (systemd, PM2) for the backend
Development Tools
- Dev container — Open the repo in VS Code → "Reopen in Container" for a pre-configured environment with Angular CLI, Node.js, Python 3, and Git (.devcontainer/).
- VS Code workspace — workspace file in the repo root configures both Angular and Python extensions.
- Prettier — Run
npx prettier --write .to format the codebase. - Prettier config — .prettierrc (100 char width, single quotes, Angular HTML parser).
Tech Stack
| Layer | Technology |
|---|---|
| Frontend | Angular 21 (standalone components, signals-ready) |
| Backend | FastAPI 0.115, Python 3.12 |
| Database | PostgreSQL 16 (async via SQLAlchemy 2.0) |
| Styling | SCSS with design tokens (variables → mixins → CSS custom properties) |
| Routing | Angular Router (lazy loaded) + Nginx SPA fallback |
| HTTP | RxJS observables (frontend → FastAPI) |
| Containers | Dev container (VS Code Remote - Containers) |
| CI/CD | (not yet configured) |
| Testing | Vitest (via Angular CLI) |