# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What This Is

Self-hosted uptime monitoring with status pages. HTTP checks every 3 minutes, Lighthouse audits daily, SEO crawler weekly. Alerts via direct-MX email + Discord webhooks on state transitions. Single-binary axum service backed by SQLite. Originally a Django + thread-pool scheduler service; data from that era can be migrated in via `./status migrate <django.sqlite3>` and the Django code has been retired.

## Commands

- **Dev server:** `make run` (Vite watch + cargo run on port 8000, plus background scheduler)
- **Production build:** `make build` (Vite assets + release binary)
- **Run release binary:** `make start`
- **Pull production data:** `make pull` (rsync `db.sqlite3` from `git remote server`)
- **Import a Django DB:** `make migrate FROM=<path-to-django.sqlite3> [FORCE=1]` (preserves Property UUIDs so existing public status URLs keep working). Same logic exposed on the binary as `./status migrate <path> [--force]`.
- **Preview an alert email:** `./status preview-email <down|recovery>` renders the themed email HTML to stdout for visual QA.
- **Docker build:** `sudo docker build .`

There are no tests or linters configured.

## Architecture

**Backend:** Single-binary axum app. `src/main.rs` is a tiny entry point; `src/app.rs` builds `AppState` and the `Router`. Per-feature route modules live in `src/routes/{home,auth,seo,properties,dashboard}.rs` and each exposes its own `Router::new()`. Shared helpers: `src/render.rs` (template render that injects user/request/messages context), `src/middleware.rs` (request log + 404), `src/templates.rs` (minijinja env + filters). sqlx + sqlite (WAL, `synchronous=NORMAL`, `busy_timeout=5s`, foreign keys on) for both reads and writes. Schema lives in `migrations/0001_initial.sql` and is applied automatically on boot. State is shared via `AppState` (template env, db pool, cookie key, config, embedded Typst PDF renderer).

**Auth:** Single password from `STATUS_PASSWORD` in `.env`. Login form posts to `/login`; on success a signed cookie (`SameSite=Strict`, 30-day max-age) is set. The cookie payload is just `1:<exp-timestamp>`, signed with `tower-cookies`. The cookie key is `STATUS_COOKIE_SECRET` if set, else derived from the password via SHA-512 (so changing the password invalidates sessions). No CSRF tokens; `SameSite=Strict` is the protection.

**Data model:** Two main tables. `properties` holds a tracked URL plus all monitoring state (last/next run for HTTP, lighthouse, crawler; lighthouse_scores, lighthouse_details, crawler_insights as JSON; alert state machine). `checks` stores individual HTTP probe results (status code, response_ms, headers JSON). `meta` is a key-value table for one-off settings.

**Scheduler (`src/scheduler.rs`):** Tokio task that runs every 30 seconds with two semaphores so slow lighthouse/crawler work can't starve quick HTTP pings. On boot, wedged `running`/`queued` rows are reset. Each cycle enqueues HTTP checks (3-min intervals), lighthouse (daily), crawler (weekly), runs wedge-reset (5-min lighthouse cutoff, 15-min crawler cutoff), and once a day deletes checks older than 3 days.

**Alert state machine (`src/checker.rs`):** Two states, `up` (default) and `down`. UP→DOWN requires 2 consecutive non-200 checks (avoids false positives); DOWN→UP is immediate on 200. State is committed inside a transaction before notifications fire, so a crash mid-alert can't cause duplicate sends.

**SEO crawler (`src/crawler/`):** In-process spider built on reqwest + scraper, modeled directly on the original Python crawler. `mod.rs` runs the BFS up to PAGE_CAP (500) with CONCURRENCY (4) and a 9-minute deadline; `fetcher.rs` handles robots.txt/sitemap loading; `parser.rs` extracts title/meta/headings/links/images/forms/json-ld/text-hash; `checks.rs` is the same 38 checks as the Python version (SEO, links, sitemap, accessibility, content, performance, security).

**Lighthouse (`src/lighthouse.rs`):** Subprocess invokes `bun run --bun node_modules/.bin/lighthouse` (the `--bun` flag symlinks `node` → bun so the shim's `#!/usr/bin/env node` shebang resolves to bun's runtime, which lets the runtime image drop nodejs/npm). Returns the four category scores plus a "performance details" breakdown (top weighted metrics + top opportunities by savings). 180s outer timeout against chromium hangs. Chromium is located via `CHROMIUM_BIN`, then PATH search, then a `/opt/playwright-browsers/` glob fallback (so the webdev container Just Works without per-shell env vars).

**Alerts (`src/alerts.rs`):** Email is sent direct-to-MX: hickory-resolver looks up the recipient domain's MX records, sorted by preference, and lettre opens an opportunistic STARTTLS SMTP connection on port 25. Discord webhooks are plain HTTP POST. Both are best-effort; failures are logged and don't break the check loop. Recipient is `ALERT_EMAIL`; webhook is `DISCORD_WEBHOOK_URL`.

**PDF generation (`src/pdf.rs`):** `?report=pdf` on the property page renders through the embedded Typst compiler (no chromium subprocess, no temp files). `PdfRenderer::new` runs typst-kit's font searcher once at startup and shares the resulting library/book/font slots across renders. The dashboard route renders `templates/properties/property_report.typ` through minijinja first (using the `typst_md` and `typst_str` filters in `templates.rs` to escape user data into Typst-safe markup), then passes the resulting source to `PdfRenderer::render` inside `tokio::task::spawn_blocking` (Typst compilation is CPU-bound and synchronous). The visual style mirrors `analytics/templates/properties/property_report.typ`: monochrome, hairline rules, tracking-letterspaced uppercase section headers, JetBrains Mono for technical strings. `?report=md` returns the markdown variant directly.

**Templates (`templates/`):** Jinja2 templates rendered by minijinja with a Jinja2-faithful HTML formatter so `/` is not escaped to `&#x2f;` (matches analytics/blog/darkfurrow). Custom functions: `vite_asset`, `url_for`, `naturaltime`, `urlencode`, `intcomma`, plus `typst_md` and `typst_str` for the PDF template. The `vite_asset` global resolves hashed asset names by reading `dist/.vite/manifest.json`: re-read per call in debug builds, cached at startup in release.

**Frontend pipeline (`frontend/`):** Vite (run with `bun`) builds three entry points (`base`, `pages`, `properties`) into `dist/`. Output filenames are content-hashed and served at `/static/`. Bootstrap 5 SCSS, Chart.js, monaspace font.

**Request logging:** `src/main.rs::log_requests` middleware prints `time METHOD STATUS latency path` per request with ANSI-colored status codes.

## Layout

```
status/
├── Cargo.toml, Cargo.lock        # rust deps
├── package.json, bun.lock        # only for the lighthouse CLI at repo root
├── Makefile, README.md           # top-level
├── migrations/                   # sqlx migrations (0001_initial.sql)
├── src/                          # rust source
│   ├── main.rs        # tiny entry: env init, subcommand dispatch, server boot
│   ├── app.rs         # AppState::from_env + router() (assembles per-feature routes)
│   ├── render.rs      # render() / render_to_string() helpers (inject standard ctx)
│   ├── middleware.rs  # request log + 404 handler
│   ├── routes/        # per-feature route modules, each exposing fn router()
│   │   ├── mod.rs
│   │   ├── auth.rs        # /login, /logout, is_authenticated()
│   │   ├── home.rs        # /, /changelog
│   │   ├── seo.rs         # /favicon.ico, /robots.txt, /sitemap.xml
│   │   ├── properties.rs  # /properties (list/create/delete/public toggle)
│   │   └── dashboard.rs   # /<id> dashboard, /<id>/{status,recrawl,rerun-lighthouse}, build_property_context
│   ├── models.rs      # PropertyRow + queries (list/get/create/delete/toggle_public)
│   ├── checker.rs     # HTTP probe + alert state machine
│   ├── lighthouse.rs  # subprocess to bun run --bun node_modules/.bin/lighthouse
│   ├── crawler/       # SEO spider (mod, fetcher, parser, checks)
│   ├── scheduler.rs   # 30s tokio loop with fast/slow semaphores
│   ├── alerts.rs      # direct-MX email via lettre + Discord webhook (+ render_preview_html)
│   ├── pdf.rs         # embedded Typst renderer + json-to-typst dict serializer
│   ├── migrate.rs     # `./status migrate <django.sqlite3>`
│   ├── db.rs          # sqlx pool init + migrate
│   └── templates.rs   # minijinja env, vite_asset, url_for, jinja2-compat formatter
├── templates/                    # minijinja-compatible jinja2 + typst
│   ├── base.html              # layout
│   ├── property_report.typ    # PDF template (typst markup, called from src/pdf.rs)
│   ├── emails/                # property_email_base.html (themed dark card)
│   ├── includes/              # messages
│   ├── registration/          # login form
│   ├── properties/            # properties list, dashboard, md report
│   └── pages/                 # home, changelog
├── frontend/                     # JS pipeline (package.json, vite.config.js, static_src/)
│   ├── static_src/
│   │   ├── base/       # bootstrap scss + base scripts
│   │   ├── pages/      # marketing styles
│   │   └── properties/ # dashboard chart + crawl-status polling JS
├── dist/                         # vite build output (gitignored, served at /static/)
├── data/                         # sqlite db at runtime (gitignored)
├── target/                       # cargo build output (gitignored)
├── Dockerfile, docker-compose.yml
└── samplefiles/                  # Caddyfile.sample, env.sample, post-receive.sample
```

The binary reads `templates/`, `dist/`, and `migrations/` from cwd by default. Override the project root with `STATUS_ROOT=<path>` and the data directory with `STATUS_DATA_DIR=<path>` (production sets the latter to `/data`).

## Key Routes

- `/`: marketing home (redirects to `/properties` when authenticated)
- `/login`, `/logout`: single-password auth
- `/properties`: list + create + delete (auth required)
- `/<property-id>`: dashboard (auth required unless property is public). Accepts `?report=pdf|md`.
- `/properties/<id>/public`: POST toggles is_public
- `/properties/<id>/status`: JSON crawler/lighthouse status (used by polling JS)
- `/properties/<id>/recrawl`, `/properties/<id>/rerun-lighthouse`: POST to advance next-run-at
- `/changelog`, `/favicon.ico`, `/robots.txt`, `/sitemap.xml`
- `/static/*`: Vite assets (1y cache header)

## Tooling

- **Rust deps:** managed with `cargo` (`Cargo.toml`, `Cargo.lock`)
- **JS deps:** managed with `bun` everywhere. Frontend deps live in `frontend/`; the lighthouse CLI is installed at the repo root with `bun install` (lockfile `bun.lock`) so its `node_modules/.bin/lighthouse` shim resolves correctly. The rust binary invokes that shim through `bun run --bun`, so no nodejs/npm is needed at any stage.
- **Production:** Docker (`rust:alpine` builder + `alpine:3.23` runtime, `chromium` apk package plus bun copied from `oven/bun:alpine`: chromium is needed for lighthouse only, since PDF runs through embedded Typst). Runtime image also installs `font-jetbrains-mono`, `ttf-dejavu`, `ttf-liberation`, and `fontconfig` so the Typst renderer can find a body sans, mono, and fallback fonts. Deployed via `git push server master` triggering a post-receive hook that runs `docker compose up --build --detach`. Data persisted to `/srv/data/status/`.
