heartwood every commit a ring

Restructure server into routes/ and fix several dashboard issues

43087d17 by Isaac Bythewood · 2 days ago

Restructure server into routes/ and fix several dashboard issues

Server layout
- Split main.rs into app.rs (AppState, from_env, router, bg downloads),
  middleware.rs (log_requests, not_found), and render.rs (shared HTML
  render with the standard page context).
- Move per-feature handlers into routes/ as a mirror of blog/darkfurrow:
  home, auth, seo, collector, properties, dashboard. Each module owns a
  pub fn router(). main.rs is now bootstrap + subcommand dispatch.
- Scope the collector CORS layer to /collect only (was applied globally
  via .layer() despite the variable name).
- Drop the moka dashboard cache and dep — TTL was annoying for a single-
  operator self-hosted dashboard. Each render re-queries SQLite (~10ms).

Dashboard fixes
- Make device/browser/platform/screen-size cards count one anonymous
  user_id per row (COUNT DISTINCT) instead of raw events, and filter on
  page_view so returning visitors are counted (collectoruserid cookie
  suppresses session_start after first visit).
- Empty-state placeholder on charts when there is no data, instead of
  rendering a blank Chart.js canvas.
- Fix metric tile layout: label and delta chip share a flex row at the
  top so the chip no longer overlaps the title text.
- Fix custom-cards and is-public toggles: Django-leftover JS was POSTing
  to the wrong URL family and dereferencing a CSRF input that does not
  exist in the rust template, so toggles never persisted.

Collector
- Send screen_width/screen_height on page_view, not just session_start,
  so the screen-size card populates for returning visitors.

Seed
- Bump defaults to 6000 sessions over 90 days. Bias session timestamps
  toward recent (r.powf(1.15)) so the default 28-day window holds ~25k
  events and metric-card deltas land in the +/-20% range instead of
  +1000% from comparing a full window to a barely-populated previous
  one.
- Always rewrite custom_cards on each seed run with three demo events
  (signup, checkout_success, signup_cta_click) and emit those events
  probabilistically per session so the cards have data.

Make / dev ergonomics
- Lazy-build static_maps/world.json on make run via a sentinel file so
  fresh checkouts don't show a broken world map. make maps still
  force-rebuilds.
- property_map.js: check response.ok before .json() so a missing maps
  file gives a clear "HTTP 404" message instead of "Unexpected end of
  JSON input".
modified Cargo.lock
@@ -39,7 +39,6 @@ dependencies = [ "maxminddb", "mime_guess", "minijinja", "moka", "rand 0.8.6", "reqwest", "serde",
@@ -76,17 +75,6 @@ version = "1.0.102"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"[[package]]name = "async-lock"version = "3.4.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"dependencies = [ "event-listener", "event-listener-strategy", "pin-project-lite",][[package]]name = "atoi"version = "2.0.0"
@@ -342,24 +330,6 @@ dependencies = [ "cfg-if",][[package]]name = "crossbeam-channel"version = "0.5.15"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"dependencies = [ "crossbeam-utils",][[package]]name = "crossbeam-epoch"version = "0.9.18"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"dependencies = [ "crossbeam-utils",][[package]]name = "crossbeam-queue"version = "0.3.12"
@@ -494,16 +464,6 @@ dependencies = [ "pin-project-lite",][[package]]name = "event-listener-strategy"version = "0.5.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"dependencies = [ "event-listener", "pin-project-lite",][[package]]name = "fastrand"version = "2.4.1"
@@ -1230,26 +1190,6 @@ dependencies = [ "windows-sys 0.61.2",][[package]]name = "moka"version = "0.12.15"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046"dependencies = [ "async-lock", "crossbeam-channel", "crossbeam-epoch", "crossbeam-utils", "equivalent", "event-listener", "futures-util", "parking_lot", "portable-atomic", "smallvec", "tagptr", "uuid",][[package]]name = "nu-ansi-term"version = "0.50.3"
@@ -1418,12 +1358,6 @@ version = "0.2.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"[[package]]name = "portable-atomic"version = "1.13.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"[[package]]name = "potential_utf"version = "0.1.5"
@@ -2253,12 +2187,6 @@ dependencies = [ "syn",][[package]]name = "tagptr"version = "0.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"[[package]]name = "tar"version = "0.4.45"
modified Cargo.toml
@@ -18,7 +18,6 @@ sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio",uuid = { version = "1", features = ["v4", "serde"] }chrono = { version = "0.4", features = ["serde"] }chrono-tz = "0.10"moka = { version = "0.12", features = ["future"] }maxminddb = "0.24"uaparser = "0.6"hmac = "0.12"
modified Makefile
@@ -5,13 +5,13 @@ PORT  ?= 8000.PHONY: run build start clean push pull maps seed migrate# Dev: Vite watch + cargo run concurrently. Both die on Ctrl+C.run: frontend/node_modules dist/.vite/manifest.jsonrun: frontend/node_modules dist/.vite/manifest.json static_maps/world.json	@trap 'kill 0' EXIT INT TERM; \	(cd frontend && bun run dev) & \	PORT=$(PORT) $(CARGO) run# Production build (Vite assets + release binary)build: frontend/node_modules mapsbuild: frontend/node_modules static_maps/world.json	cd frontend && bun run build	$(CARGO) build --release
@@ -19,10 +19,15 @@ build: frontend/node_modules mapsstart:	PORT=$(PORT) ./target/release/analytics# Build the per-country topojson static_maps/ from natural earth# Force-rebuild the per-country topojson from natural earth.maps: frontend/node_modules	cd frontend && bun run build:maps# Lazy build for `make run`: only fetches maps if world.json is missing.# Use `make maps` to force a refresh.static_maps/world.json: frontend/node_modules	cd frontend && bun run build:mapsclean:	$(CARGO) clean	rm -rf dist frontend/node_modules data/db.sqlite3 data/db.mmdb
modified frontend/static_src/base/styles/base.scss
@@ -244,9 +244,19 @@ main { flex: 1; }  height: 100%;  display: flex;  flex-direction: column;  position: relative;  // Label + delta chip share the top row; label flex-grows and truncates,  // chip stays its natural width on the right.  .metric-header {    display: flex;    align-items: center;    gap: 0.5rem;    min-height: 1.3rem;  }  .metric-label {    flex: 1;    min-width: 0;    font-size: 0.68rem;    font-weight: 600;    letter-spacing: 0.12em;
@@ -272,11 +282,8 @@ main { flex: 1; }    letter-spacing: 0.02em;  }  // Small delta badge pinned in the top-right  .metric-delta {    position: absolute;    top: 0.55rem;    right: 0.7rem;    flex-shrink: 0;    font-family: $font-family-monospace;    font-size: 0.75rem;    font-weight: 600;
modified frontend/static_src/collector/scripts/collector.js
@@ -101,7 +101,9 @@  const query = parse_querystring(window.location.search.substring(1));  // send a page view event  // send a page view event. Screen dimensions are included here too (not just  // session_start) so the dashboard's screen-size breakdown populates for  // returning visitors whose collectoruserid cookie suppresses session_start.  window.collectorQueue.push({    collectorId: window.collectorId,    event: "page_view",
@@ -113,6 +115,8 @@      utm_source: query.utm_source,      utm_medium: query.utm_medium,      utm_campaign: query.utm_campaign,      screen_width: window.screen.width,      screen_height: window.screen.height,    },  });
modified frontend/static_src/properties/scripts/property_custom_cards.js
@@ -1,36 +1,26 @@// Persists which custom-event cards are enabled. The form's `action` attribute// holds the correct endpoint (`/properties/{id}/cards`); we use that directly// rather than reconstruct the URL from window.location.document.addEventListener("DOMContentLoaded", function () {  /**   * If any of the checkboxes in the form "#custom-card-form" are changed then   * get the data from the form in the format:   * [{"event": "<field_name>", "value": "<field_value>"}]   * and send it to the server in the body in JSON format to the URL:   * const url = new URL(window.location.href) + /custom-cards/   */  const form = document.getElementById("custom-card-form");  if (!form) { return; }  if (!form) return;  form.addEventListener("change", function () {    const formData = new FormData(form);    const data = [];    for (const [key, value] of formData.entries()) {      if (key === "csrfmiddlewaretoken") {        continue;      }      data.push({        event: key,        value: value === "on" ? true : false,      });    for (const [key, value] of new FormData(form).entries()) {      data.push({ event: key, value: value === "on" });    }    let url = new URL(window.location.href);    url = url.origin + url.pathname + "cards/";    fetch(url, {    fetch(form.action, {      method: "POST",      headers: { "Content-Type": "application/json" },      body: JSON.stringify(data),      headers: {        "Content-Type": "application/json",        "X-CSRFToken": form.querySelector("input[name=csrfmiddlewaretoken]").value,      },    }).then(function () {      window.location.reload();    });    })      .then((r) => {        if (!r.ok) throw new Error(`${form.action} returned HTTP ${r.status}`);        window.location.reload();      })      .catch((err) => {        console.error("custom cards save failed:", err);      });  });});
modified frontend/static_src/properties/scripts/property_graphs.js
@@ -81,11 +81,41 @@ const doughnutOptions = {  },};// Replace the canvas with a centered "no data yet" placeholder. Keeps the// card's visual footprint roughly the same so the layout doesn't reflow// when one chart has data and another doesn't.function renderEmpty(canvas) {  const placeholder = document.createElement("div");  placeholder.className = "chart-empty";  placeholder.textContent = "no data yet";  Object.assign(placeholder.style, {    display: "flex",    alignItems: "center",    justifyContent: "center",    height: "100%",    minHeight: "120px",    color: "rgba(132, 124, 114, 0.6)",    fontFamily: fontStack,    fontSize: "11px",    letterSpacing: "0.05em",    textTransform: "uppercase",  });  canvas.replaceWith(placeholder);}function hasData(rows) {  return rows.some((d) => (d.count || 0) > 0);}function renderDoughnut(canvasId, dataId) {  document.addEventListener("DOMContentLoaded", function () {    const canvas = document.getElementById(canvasId);    if (!canvas) return;    const raw = JSON.parse(document.getElementById(dataId).innerHTML);    if (!hasData(raw)) {      renderEmpty(canvas);      return;    }    const data = padLabels(raw);    new Chart(canvas.getContext("2d"), {      type: "doughnut",
@@ -109,6 +139,10 @@ document.addEventListener("DOMContentLoaded", function () {  const canvas = document.getElementById("chart-total-events");  if (!canvas) return;  const data = JSON.parse(document.getElementById("chart-total-events-data").innerHTML);  if (!hasData(data)) {    renderEmpty(canvas);    return;  }  const ctx = canvas.getContext("2d");  // Build a subtle vertical gradient fill so the line chart feels lit from
modified frontend/static_src/properties/scripts/property_is_public.js
@@ -1,17 +1,20 @@// Toggles the property's public flag. The form's `action` attribute holds the// correct endpoint (`/properties/{id}/public`).document.addEventListener("DOMContentLoaded", function () {  const form = document.getElementById("is-public-form");  if (!form) { return; }  if (!form) return;  form.addEventListener("change", function () {    let url = new URL(window.location.href);    url = url.origin + url.pathname + "is-public/";    fetch(url, {    fetch(form.action, {      method: "POST",      headers: {        "Content-Type": "application/json",        "X-CSRFToken": form.querySelector("input[name=csrfmiddlewaretoken]").value,      },    }).then(function () {      window.location.reload();    });      headers: { "Content-Type": "application/json" },    })      .then((r) => {        if (!r.ok) throw new Error(`${form.action} returned HTTP ${r.status}`);        window.location.reload();      })      .catch((err) => {        console.error("public toggle failed:", err);      });  });});
modified frontend/static_src/properties/scripts/property_map.js
@@ -37,7 +37,10 @@ document.addEventListener("DOMContentLoaded", () => {  const state = { view: "world", country: null };  fetch(WORLD_URL)    .then((r) => r.json())    .then((r) => {      if (!r.ok) throw new Error(`${WORLD_URL} returned HTTP ${r.status}`);      return r.json();    })    .then((topo) => {      worldData = topo;      renderWorld();
added src/app.rs
@@ -0,0 +1,153 @@use axum::{http::HeaderValue, middleware as axum_middleware, Router};use minijinja::Environment;use sqlx::SqlitePool;use std::path::PathBuf;use std::sync::Arc;use tower_cookies::{CookieManagerLayer, Key};use tower_http::services::ServeDir;use tower_http::set_header::SetResponseHeaderLayer;use crate::geoip::{self, GeoIp};use crate::routes;use crate::ua::{self, UaParser};use crate::{db, middleware, templates};#[derive(Clone)]pub struct AppState {    pub env: Arc<Environment<'static>>,    pub pool: SqlitePool,    pub cookie_key: Key,    pub geoip: Arc<GeoIp>,    pub ua: Arc<UaParser>,    pub config: Arc<Config>,}#[derive(Debug, Clone)]pub struct Config {    pub root: PathBuf,    pub data_dir: PathBuf,    pub password: String,    pub base_url: String,    pub proprium_id: Option<uuid::Uuid>,}impl AppState {    pub async fn from_env() -> anyhow::Result<Self> {        let root: PathBuf = std::env::var("ANALYTICS_ROOT")            .map(PathBuf::from)            .unwrap_or_else(|_| PathBuf::from("."));        let data_dir = std::env::var("ANALYTICS_DATA_DIR")            .map(PathBuf::from)            .unwrap_or_else(|_| root.join("data"));        std::fs::create_dir_all(&data_dir)?;        let password =            std::env::var("ANALYTICS_PASSWORD").unwrap_or_else(|_| "admin".to_string());        let base_url = std::env::var("BASE_URL").unwrap_or_default();        let cookie_secret = std::env::var("ANALYTICS_COOKIE_SECRET").unwrap_or_else(|_| {            // 64+ bytes derived from password if no secret provided. For a single-user            // self-hosted app this is fine; setting ANALYTICS_COOKIE_SECRET is preferred.            use sha2::{Digest, Sha512};            let mut h = Sha512::new();            h.update(b"analytics-cookie:");            h.update(password.as_bytes());            let digest = h.finalize();            base64::Engine::encode(&base64::engine::general_purpose::STANDARD, digest)        });        let cookie_key = Key::from(cookie_secret.as_bytes());        let pool = db::init(&data_dir).await?;        let proprium_id = db::ensure_proprium(&pool).await?;        tracing::info!("Proprium property: {}", proprium_id);        let geoip = Arc::new(GeoIp::load(&data_dir.join("db.mmdb")));        let ua = Arc::new(UaParser::load(&data_dir.join("regexes.yaml")));        let templates_dir = root.join("templates");        let manifest_path = root.join("dist/.vite/manifest.json");        let env = Arc::new(templates::build_env(&templates_dir, &manifest_path));        let config = Arc::new(Config {            root,            data_dir,            password,            base_url,            proprium_id: Some(proprium_id),        });        Ok(Self {            env,            pool,            cookie_key,            geoip,            ua,            config,        })    }}/// Best-effort background downloads. The server boots immediately; once these/// finish the next collector hit picks up the loaded data. Failures are logged/// and ignored so a flaky network doesn't block the server from running.pub fn spawn_background_downloads(state: &AppState) {    let geoip = state.geoip.clone();    let geoip_path = state.config.data_dir.join("db.mmdb");    tokio::spawn(async move {        match geoip::ensure_db(&geoip_path).await {            Ok(true) => {                geoip.reload();            }            Ok(false) => {}            Err(e) => tracing::warn!("geoip download skipped: {e}"),        }    });    let regexes_path = state.config.data_dir.join("regexes.yaml");    tokio::spawn(async move {        if let Err(e) = ua::ensure_regexes(&regexes_path).await {            tracing::warn!("uaparser regexes download skipped: {e}");        }        // Note: hot-reload of UA parser would need RwLock too. For now        // the download primes the file for the next process restart.    });}pub fn router(state: AppState) -> Router {    let dist_dir = state.config.root.join("dist");    let static_maps_dir = state.config.root.join("static_maps");    let static_cache = SetResponseHeaderLayer::if_not_present(        axum::http::header::CACHE_CONTROL,        HeaderValue::from_static("public, max-age=31536000"),    );    Router::new()        // Per-feature routers. CORS lives inside routes::collector so it's        // scoped to /collect (the only endpoint that is cross-origin by        // design). Same-origin routes don't need it.        .merge(routes::home::router())        .merge(routes::auth::router())        .merge(routes::seo::router())        .merge(routes::collector::router())        .merge(routes::properties::router())        // routes::dashboard holds the UUID `/{property_id}` catch-all; merge        // last so named routes win the match.        .merge(routes::dashboard::router())        .nest_service(            "/static",            tower::ServiceBuilder::new()                .layer(static_cache.clone())                .service(ServeDir::new(&dist_dir)),        )        .nest_service(            "/static_maps",            tower::ServiceBuilder::new()                .layer(static_cache)                .service(ServeDir::new(&static_maps_dir)),        )        .fallback(middleware::not_found)        .layer(CookieManagerLayer::new())        .layer(axum_middleware::from_fn(middleware::log_requests))        .with_state(state)}
modified src/bin/seed.rs
@@ -1,11 +1,18 @@// Seeds a "Seed Test" property with realistic-looking fake events.//// Usage://   cargo run --bin seed                  # 500 sessions, last 30 days//   cargo run --bin seed -- 2000 60       # 2000 sessions, last 60 days//   cargo run --bin seed                  # 6000 sessions, last 90 days//   cargo run --bin seed -- 12000 180     # 12000 sessions, last 180 days//// Sessions are timestamped with a gentle bias toward recent days so the// dashboard's default 28-day window shows a small positive delta vs. the// previous 28 days (rather than +1000% from comparing a full window to a// barely-populated one). At default 6000/90 the visible 28-day window// holds ~25k events and most metric-card deltas land in the ±20% range.//// Re-runs reuse the property and wipe its existing events first so the// dashboard URL stays stable.// dashboard URL stays stable. The property's `custom_cards` are also// rewritten on every run so a new seed always shows the demo cards.#[path = "../db.rs"]#[allow(dead_code)]
@@ -136,6 +143,15 @@ const UTM_SOURCES:   &[&str] = &["google", "twitter", "hn", "newsletter", "githuconst UTM_MEDIUMS:   &[&str] = &["cpc", "social", "email", "referral", "organic"];const UTM_CAMPAIGNS: &[&str] = &["launch-2026", "spring-promo", "blog-feature", "rebrand", "retarget"];// Demo custom events emitted alongside page-views. Probabilities are// per-session — at the default 2000 sessions this yields ~100 signups,// ~40 checkouts, and ~160 CTA clicks, plenty to populate the cards.const CUSTOM_EVENTS: &[(&str, f64)] = &[    ("signup",            0.05),    ("checkout_success",  0.02),    ("signup_cta_click",  0.08),];fn weighted<'a, T>(rng: &mut impl Rng, items: &'a [T], weight: impl Fn(&T) -> u32) -> &'a T {    let total: u32 = items.iter().map(&weight).sum();    let mut pick = rng.gen_range(0..total);
@@ -154,8 +170,11 @@ async fn main() -> Result<()> {    let _ = dotenvy::dotenv();    let args: Vec<String> = std::env::args().collect();    let sessions: usize = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(500);    let days: i64       = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(30);    // 6000 sessions over 90 days, biased toward recent. The default 28-day    // dashboard window catches ~34% of sessions (~2000 × ~12 events ≈ 25k).    // Override with `cargo run --bin seed -- <sessions> <days>`.    let sessions: usize = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(6000);    let days: i64       = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(90);    let data_dir = std::env::var("ANALYTICS_DATA_DIR")        .map(PathBuf::from)
@@ -176,6 +195,22 @@ async fn main() -> Result<()> {        .execute(&pool)        .await?;    // Always rewrite custom_cards so re-seeding wipes prior state. The shape    // matches CustomCard in models.rs ([{event, value: bool}]).    let cards_json = serde_json::Value::Array(        CUSTOM_EVENTS            .iter()            .map(|(name, _)| serde_json::json!({ "event": name, "value": true }))            .collect(),    )    .to_string();    sqlx::query("UPDATE properties SET custom_cards = ?, updated_at = ? WHERE id = ?")        .bind(&cards_json)        .bind(Utc::now().timestamp_millis())        .bind(&pid_bytes)        .execute(&pool)        .await?;    let total = generate(&pool, &pid_bytes, sessions, days).await?;    println!("Seeded {} sessions ({} events) into property '{}' ({})", sessions, total, PROPERTY_NAME, property_id);
@@ -222,7 +257,15 @@ async fn generate(pool: &SqlitePool, pid: &[u8], sessions: usize, days: i64) ->        let referrer = if referrer_str.is_empty() { None } else { Some(referrer_str) };        let user_id = format!("{}", rng.gen_range(100_000_000u64..999_999_999u64));        let session_start = now - rng.gen_range(0..window_ms);        // r.powf(1.15) gently biases toward 0, putting more sessions in the        // recent end of the window. Empirically yields ~10–15% growth comparing        // the most-recent 28 days to the previous 28 days, which matches what        // a real site looks like — instead of the +1000% you'd get from a        // uniform 30-day seed where the prev window is mostly empty.        let r: f64 = rng.gen();        let offset_ms = (r.powf(1.15) * window_ms as f64) as i64;        let session_start = now - offset_ms;        let (sw, sh) = if agent.device == "Mobile" {            *SCREENS_MOBILE.choose(&mut rng).unwrap()
@@ -267,6 +310,18 @@ async fn generate(pool: &SqlitePool, pid: &[u8], sessions: usize, days: i64) ->                     referrer, agent, sw, sh, geo, utm_source, utm_medium, utm_campaign, None).await?;        total += 1;        // Emit demo custom events at their per-session probability. Bucketed        // a few seconds after the session start so they fall inside the        // active window and show up on the dashboard's custom-event cards.        for (name, prob) in CUSTOM_EVENTS {            if rng.gen_bool(*prob) {                let offset = rng.gen_range(1_000i64..30_000);                insert_human(&mut tx, pid, name, t + offset, &user_id, url_pick.0, url_pick.1,                             None, agent, sw, sh, geo, None, None, None, None).await?;                total += 1;            }        }        for i in 0..page_count {            let time_on_page = rng.gen_range(2_000i64..120_000i64);            let pv_referrer = if i == 0 { referrer } else { None };
deleted src/cache.rs
@@ -1,35 +0,0 @@use moka::future::Cache;use std::sync::Arc;use std::time::Duration;pub const TTL: Duration = Duration::from_secs(300);#[derive(Clone)]pub struct DashboardCache {    inner: Cache<String, Arc<serde_json::Value>>,}impl DashboardCache {    pub fn new() -> Self {        Self {            inner: Cache::builder()                .max_capacity(256)                .time_to_live(TTL)                .build(),        }    }    pub async fn get(&self, key: &str) -> Option<Arc<serde_json::Value>> {        self.inner.get(key).await    }    pub async fn insert(&self, key: String, value: Arc<serde_json::Value>) {        self.inner.insert(key, value).await;    }}impl Default for DashboardCache {    fn default() -> Self {        Self::new()    }}
modified src/main.rs
@@ -1,60 +1,20 @@mod auth;mod cache;mod collector;mod app;mod db;mod geoip;mod middleware;mod migrate;mod models;mod pages;mod pdf;mod queries;mod render;mod routes;mod templates;mod ua;mod views;use axum::{    extract::Request,    http::{header, HeaderValue, StatusCode},    middleware::{self, Next},    response::{IntoResponse, Response},    routing::{get, post},    Router,};use chrono::Local;use minijinja::Environment;use sqlx::SqlitePool;pub use app::{AppState, Config};use std::net::SocketAddr;use std::path::PathBuf;use std::sync::Arc;use std::time::Instant;use tower_cookies::{CookieManagerLayer, Key};use tower_http::cors::{Any, CorsLayer};use tower_http::services::ServeDir;use tower_http::set_header::SetResponseHeaderLayer;use crate::cache::DashboardCache;use crate::geoip::GeoIp;use crate::ua::UaParser;#[derive(Clone)]pub struct AppState {    pub env: Arc<Environment<'static>>,    pub pool: SqlitePool,    pub cookie_key: Key,    pub geoip: Arc<GeoIp>,    pub ua: Arc<UaParser>,    pub cache: DashboardCache,    pub config: Arc<Config>,}#[derive(Debug, Clone)]pub struct Config {    pub root: PathBuf,    pub data_dir: PathBuf,    pub password: String,    pub base_url: String,    pub proprium_id: Option<uuid::Uuid>,}#[tokio::main]async fn main() -> anyhow::Result<()> {
@@ -84,179 +44,22 @@ async fn main() -> anyhow::Result<()> {        }    }    let root: PathBuf = std::env::var("ANALYTICS_ROOT")        .map(PathBuf::from)        .unwrap_or_else(|_| PathBuf::from("."));    let data_dir = std::env::var("ANALYTICS_DATA_DIR")        .map(PathBuf::from)        .unwrap_or_else(|_| root.join("data"));    std::fs::create_dir_all(&data_dir)?;    let templates_dir = root.join("templates");    let dist_dir = root.join("dist");    let static_maps_dir = root.join("static_maps");    let manifest_path = dist_dir.join(".vite/manifest.json");    let port: u16 = std::env::var("PORT")        .ok()        .and_then(|v| v.parse().ok())        .unwrap_or(8000);    let password = std::env::var("ANALYTICS_PASSWORD").unwrap_or_else(|_| "admin".to_string());    let base_url = std::env::var("BASE_URL").unwrap_or_default();    let cookie_secret = std::env::var("ANALYTICS_COOKIE_SECRET").unwrap_or_else(|_| {        // 64+ bytes derived from password if no secret provided. For a single-user        // self-hosted app this is fine; setting ANALYTICS_COOKIE_SECRET is preferred.        use sha2::{Digest, Sha512};        let mut h = Sha512::new();        h.update(b"analytics-cookie:");        h.update(password.as_bytes());        let digest = h.finalize();        base64::Engine::encode(&base64::engine::general_purpose::STANDARD, digest)    });    let cookie_key = Key::from(cookie_secret.as_bytes());    let pool = db::init(&data_dir).await?;    let proprium_id = db::ensure_proprium(&pool).await?;    tracing::info!("Proprium property: {}", proprium_id);    let geoip_path = data_dir.join("db.mmdb");    let regexes_path = data_dir.join("regexes.yaml");    let geoip = Arc::new(GeoIp::load(&geoip_path));    let ua = Arc::new(UaParser::load(&regexes_path));    // Best-effort background downloads. Server boots immediately; once these    // finish the next collector hit picks up the loaded data.    {        let geoip = geoip.clone();        let geoip_path = geoip_path.clone();        tokio::spawn(async move {            match geoip::ensure_db(&geoip_path).await {                Ok(true) => {                    geoip.reload();                }                Ok(false) => {}                Err(e) => tracing::warn!("geoip download skipped: {e}"),            }        });    }    {        let regexes_path = regexes_path.clone();        tokio::spawn(async move {            if let Err(e) = ua::ensure_regexes(&regexes_path).await {                tracing::warn!("uaparser regexes download skipped: {e}");            }            // Note: hot-reload of UA parser would need RwLock too. For now            // the download primes the file for the next process restart.        });    }    let env = templates::build_env(&templates_dir, &manifest_path);    let config = Arc::new(Config {        root: root.clone(),        data_dir: data_dir.clone(),        password,        base_url,        proprium_id: Some(proprium_id),    });    let cache = DashboardCache::new();    let state = AppState {        env: Arc::new(env),        pool,        cookie_key,        geoip,        ua,        cache,        config: config.clone(),    };    let collector_cors = CorsLayer::new()        .allow_origin(Any)        .allow_methods(Any)        .allow_headers(Any);    let static_cache = SetResponseHeaderLayer::if_not_present(        header::CACHE_CONTROL,        HeaderValue::from_static("public, max-age=31536000"),    );    let app = Router::new()        .route("/", get(pages::home))        .route("/login", get(auth::login_form).post(auth::login_submit))        .route("/logout", post(auth::logout))        .route("/properties", get(views::properties).post(views::properties_create))        .route("/properties/{id}/delete", post(views::property_delete))        .route("/properties/{id}/cards", post(views::property_cards))        .route("/properties/{id}/public", post(views::property_public_toggle))        .route("/changelog", get(pages::changelog))        .route("/documentation", get(pages::documentation))        .route("/favicon.ico", get(pages::favicon))        .route("/robots.txt", get(pages::robots))        .route("/sitemap.xml", get(pages::sitemap))        .route("/collect", post(collector::collect).options(collector::options))        .route("/collect/", post(collector::collect).options(collector::options))        .layer(collector_cors)        // Stable URL for the embed snippet. Resolves the hashed Vite output        // at request time so consumers can hardcode this path forever.        .route("/static/collector.js", get(pages::collector_alias))        // Property dashboard uses a UUID path segment. Keep it last so the named        // routes above take precedence.        .route("/{property_id}", get(views::property))        .nest_service(            "/static",            tower::ServiceBuilder::new()                .layer(static_cache.clone())                .service(ServeDir::new(&dist_dir)),        )        .nest_service(            "/static_maps",            tower::ServiceBuilder::new()                .layer(static_cache)                .service(ServeDir::new(&static_maps_dir)),        )        .fallback(not_found)        .layer(CookieManagerLayer::new())        .layer(middleware::from_fn(log_requests))        .with_state(state);    let state = AppState::from_env().await?;    app::spawn_background_downloads(&state);    let router = app::router(state);    let addr = SocketAddr::from(([0, 0, 0, 0], port));    let listener = tokio::net::TcpListener::bind(addr).await?;    tracing::info!("analytics listening on http://{addr}");    axum::serve(listener, app).await?;    axum::serve(listener, router).await?;    Ok(())}async fn log_requests(req: Request, next: Next) -> Response {    let method = req.method().clone();    let path = req        .uri()        .path_and_query()        .map(|p| p.as_str().to_string())        .unwrap_or_else(|| req.uri().path().to_string());    let start = Instant::now();    let response = next.run(req).await;    let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;    let status = response.status().as_u16();    let now = Local::now().format("%H:%M:%S");    let color = match status {        200..=299 => "\x1b[32m",        300..=399 => "\x1b[36m",        400..=499 => "\x1b[33m",        _ => "\x1b[31m",    };    eprintln!("{now} {method:<5} {color}{status}\x1b[0m {elapsed_ms:>7.2}ms  {path}");    response}async fn not_found() -> Response {    (StatusCode::NOT_FOUND, "404 Not Found").into_response()}fn print_usage() {    eprintln!(        "analytics — single-binary axum analytics server\n\
added src/middleware.rs
@@ -0,0 +1,34 @@use axum::{    extract::Request,    http::StatusCode,    middleware::Next,    response::{IntoResponse, Response},};use chrono::Local;use std::time::Instant;pub async fn log_requests(req: Request, next: Next) -> Response {    let method = req.method().clone();    let path = req        .uri()        .path_and_query()        .map(|p| p.as_str().to_string())        .unwrap_or_else(|| req.uri().path().to_string());    let start = Instant::now();    let response = next.run(req).await;    let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;    let status = response.status().as_u16();    let now = Local::now().format("%H:%M:%S");    let color = match status {        200..=299 => "\x1b[32m",        300..=399 => "\x1b[36m",        400..=499 => "\x1b[33m",        _ => "\x1b[31m",    };    eprintln!("{now} {method:<5} {color}{status}\x1b[0m {elapsed_ms:>7.2}ms  {path}");    response}pub async fn not_found() -> Response {    (StatusCode::NOT_FOUND, "404 Not Found").into_response()}
deleted src/pages.rs
@@ -1,206 +0,0 @@use axum::{    extract::State,    http::{header, HeaderMap, StatusCode},    response::{Html, IntoResponse, Response},};use chrono::Datelike;use tower_cookies::Cookies;use crate::auth::is_authenticated;use crate::AppState;fn render_page(    state: &AppState,    template: &str,    title: &str,    description: &str,    authed: bool,    path: &str,    extra: minijinja::Value,) -> Response {    let tmpl = match state.env.get_template(template) {        Ok(t) => t,        Err(e) => {            tracing::error!("template '{}': {}", template, e);            return (StatusCode::INTERNAL_SERVER_ERROR, "template error").into_response();        }    };    let request = crate::templates::RequestCtx {        url: String::new(),        url_root: "/".to_string(),        base_url: String::new(),        path: path.to_string(),    };    let body = tmpl.render(minijinja::context! {        page => minijinja::context! { title => title, description => description },        user => crate::templates::UserCtx { is_authenticated: authed },        request => &request,        now => minijinja::context! { year => chrono::Local::now().year() },        base_url => &state.config.base_url,        collector_id => state.config.proprium_id.map(|u| u.to_string()),        collector_server => &state.config.base_url,        messages => Vec::<()>::new(),        ..extra    });    match body {        Ok(b) => Html(b).into_response(),        Err(e) => {            tracing::error!("render '{}': {}", template, e);            (StatusCode::INTERNAL_SERVER_ERROR, "render error").into_response()        }    }}pub async fn home(State(state): State<AppState>, cookies: Cookies) -> Response {    let authed = is_authenticated(&cookies, &state);    if authed {        return axum::response::Redirect::to("/properties").into_response();    }    let totals: (i64, i64, Option<i64>) = sqlx::query_as(        "SELECT \           (SELECT COUNT(*) FROM properties), \           (SELECT COUNT(*) FROM events), \           (SELECT MIN(created_at) FROM events)",    )    .fetch_one(&state.pool)    .await    .unwrap_or((0, 0, None));    let first = totals.2.and_then(|ms| {        chrono::DateTime::<chrono::Utc>::from_timestamp_millis(ms)            .map(|d| d.format("%b %-d, %Y").to_string())    });    let extra = minijinja::context! {        total_properties => totals.0,        total_events => totals.1,        total_users => 1,        first_event_created_at => first,    };    render_page(        &state,        "pages/home.html",        "Self-hosted analytics",        "Self-hosted website analytics. Page views, clicks, scrolls, sessions, and custom events.",        authed,        "/",        extra,    )}pub async fn changelog(State(state): State<AppState>, cookies: Cookies) -> Response {    let authed = is_authenticated(&cookies, &state);    render_page(        &state,        "pages/changelog.html",        "Changelog",        "What's new in Analytics.",        authed,        "/changelog",        minijinja::context! {},    )}pub async fn documentation(State(state): State<AppState>, cookies: Cookies) -> Response {    let authed = is_authenticated(&cookies, &state);    render_page(        &state,        "pages/documentation.html",        "Documentation",        "How to embed and operate Analytics.",        authed,        "/documentation",        minijinja::context! {},    )}// Stable URL for the collector embed script. Vite content-hashes the// `collector` entry, but every embed snippet in the wild (and the one shown// to users in the dashboard) hardcodes `/static/collector.js`. This handler// reads the Vite manifest, resolves the hashed asset, and serves it with a// short cache TTL so updates propagate without forcing consumers to re-embed.pub async fn collector_alias(State(state): State<AppState>) -> Response {    let dist_dir = state.config.root.join("dist");    let manifest_path = dist_dir.join(".vite/manifest.json");    let manifest_text = match std::fs::read_to_string(&manifest_path) {        Ok(t) => t,        Err(e) => {            tracing::error!("collector manifest read: {e}");            return (StatusCode::SERVICE_UNAVAILABLE, "collector unavailable").into_response();        }    };    let manifest: serde_json::Value = match serde_json::from_str(&manifest_text) {        Ok(v) => v,        Err(e) => {            tracing::error!("collector manifest parse: {e}");            return (StatusCode::SERVICE_UNAVAILABLE, "collector unavailable").into_response();        }    };    let rel = manifest        .get("static_src/collector/index.js")        .and_then(|c| c.get("file"))        .and_then(|v| v.as_str());    let Some(rel) = rel else {        tracing::error!("collector entry missing from manifest");        return (StatusCode::SERVICE_UNAVAILABLE, "collector unavailable").into_response();    };    let asset_path = dist_dir.join(rel);    let bytes = match std::fs::read(&asset_path) {        Ok(b) => b,        Err(e) => {            tracing::error!("collector read {asset_path:?}: {e}");            return (StatusCode::SERVICE_UNAVAILABLE, "collector unavailable").into_response();        }    };    let mut h = HeaderMap::new();    h.insert(        header::CONTENT_TYPE,        "application/javascript; charset=utf-8".parse().unwrap(),    );    h.insert(        header::CACHE_CONTROL,        "public, max-age=300, must-revalidate".parse().unwrap(),    );    (StatusCode::OK, h, bytes).into_response()}pub async fn favicon() -> Response {    let mut h = HeaderMap::new();    h.insert(header::CONTENT_TYPE, "image/svg+xml".parse().unwrap());    let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">  <rect x="6"  y="38" width="10" height="22" rx="1.5" fill="#6b9e78"/>  <rect x="20" y="28" width="10" height="32" rx="1.5" fill="#6b9e78"/>  <rect x="34" y="18" width="10" height="42" rx="1.5" fill="#6b9e78"/>  <rect x="48" y="8"  width="10" height="52" rx="1.5" fill="#6b9e78"/>  <rect x="48" y="8"  width="10" height="6"  rx="1.5" fill="#c9a84c"/></svg>"##;    (StatusCode::OK, h, svg).into_response()}pub async fn robots() -> Response {    let mut h = HeaderMap::new();    h.insert(header::CONTENT_TYPE, "text/plain".parse().unwrap());    (StatusCode::OK, h, "User-agent: *\nAllow: /\n").into_response()}pub async fn sitemap(State(state): State<AppState>) -> Response {    let mut h = HeaderMap::new();    h.insert(header::CONTENT_TYPE, "application/xml".parse().unwrap());    let base = if state.config.base_url.is_empty() {        "/".to_string()    } else {        format!("{}/", state.config.base_url.trim_end_matches('/'))    };    let now = chrono::Utc::now().format("%Y-%m-%d");    let body = format!(        r##"<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">  <url><loc>{base}</loc><lastmod>{now}</lastmod></url>  <url><loc>{base}documentation</loc><lastmod>{now}</lastmod></url>  <url><loc>{base}changelog</loc><lastmod>{now}</lastmod></url></urlset>"##    );    let _ = chrono::Local::now().year(); // keep chrono::Datelike used    (StatusCode::OK, h, body).into_response()}
modified src/queries.rs
@@ -445,19 +445,31 @@ async fn top_by_column(    column: &str,    event: Option<&str>,    limit: i64,    distinct_users: bool,) -> Vec<LabelCount> {    // For user-property breakdowns (device/browser/platform), count one row    // per anonymous user_id. For everything else, count raw events.    let count_expr = if distinct_users {        "COUNT(DISTINCT user_id)"    } else {        "COUNT(*)"    };    let mut sql = format!(        "SELECT {col}, COUNT(*) FROM events \        "SELECT {col}, {cnt} FROM events \         WHERE property_id = ? AND created_at >= ? AND created_at <= ? \               AND {col} IS NOT NULL AND {col} != ''",        col = column        col = column,        cnt = count_expr,    );    if distinct_users {        sql.push_str(" AND user_id IS NOT NULL");    }    if event.is_some() {        sql.push_str(" AND event = ?");    }    let (extra_sql, extra_bind) = filter_clause(filter_url);    sql.push_str(extra_sql);    sql.push_str(&format!(" GROUP BY {col} ORDER BY COUNT(*) DESC LIMIT ?", col = column));    sql.push_str(&format!(" GROUP BY {col} ORDER BY {cnt} DESC LIMIT ?", col = column, cnt = count_expr));    let mut q = sqlx::query_as::<_, (String, i64)>(&sql)        .bind(property_id.as_bytes().to_vec())
@@ -484,15 +496,20 @@ pub async fn events_by_screen_size(    filter_url: Option<&str>,    limit: i64,) -> Vec<LabelCount> {    // Counts unique anonymous users (cookie-based user_id) per screen size,    // not raw events. Filtered to page_view so returning visitors are counted    // — the collectoruserid cookie suppresses session_start after the first    // visit, but page_view always fires.    let mut sql = String::from(        "SELECT screen_width, screen_height, COUNT(*) FROM events \        "SELECT screen_width, screen_height, COUNT(DISTINCT user_id) FROM events \         WHERE property_id = ? AND created_at >= ? AND created_at <= ? \               AND event = 'session_start' \               AND screen_width IS NOT NULL",               AND event = 'page_view' \               AND screen_width IS NOT NULL \               AND user_id IS NOT NULL",    );    let (extra_sql, extra_bind) = filter_clause(filter_url);    sql.push_str(extra_sql);    sql.push_str(" GROUP BY screen_width, screen_height ORDER BY COUNT(*) DESC LIMIT ?");    sql.push_str(" GROUP BY screen_width, screen_height ORDER BY COUNT(DISTINCT user_id) DESC LIMIT ?");    let mut q = sqlx::query_as::<_, (Option<i64>, Option<i64>, i64)>(&sql)        .bind(property_id.as_bytes().to_vec())        .bind(start_ms)
@@ -512,23 +529,26 @@ pub async fn events_by_screen_size(        .collect()}// Device/browser/platform breakdowns filter on page_view (not session_start)// so they populate for returning visitors too. Server-side UA parsing fills// these columns on every event, so the data is always present.pub async fn events_by_device(pool: &SqlitePool, property_id: &Uuid, start_ms: i64, end_ms: i64, filter_url: Option<&str>, limit: i64) -> Vec<LabelCount> {    top_by_column(pool, property_id, start_ms, end_ms, filter_url, "device", Some("session_start"), limit).await    top_by_column(pool, property_id, start_ms, end_ms, filter_url, "device", Some("page_view"), limit, true).await}pub async fn events_by_browser(pool: &SqlitePool, property_id: &Uuid, start_ms: i64, end_ms: i64, filter_url: Option<&str>, limit: i64) -> Vec<LabelCount> {    top_by_column(pool, property_id, start_ms, end_ms, filter_url, "browser", Some("session_start"), limit).await    top_by_column(pool, property_id, start_ms, end_ms, filter_url, "browser", Some("page_view"), limit, true).await}pub async fn events_by_platform(pool: &SqlitePool, property_id: &Uuid, start_ms: i64, end_ms: i64, filter_url: Option<&str>, limit: i64) -> Vec<LabelCount> {    top_by_column(pool, property_id, start_ms, end_ms, filter_url, "platform", Some("session_start"), limit).await    top_by_column(pool, property_id, start_ms, end_ms, filter_url, "platform", Some("page_view"), limit, true).await}pub async fn events_by_page_url(pool: &SqlitePool, property_id: &Uuid, start_ms: i64, end_ms: i64, filter_url: Option<&str>, limit: i64) -> Vec<LabelCount> {    top_by_column(pool, property_id, start_ms, end_ms, filter_url, "url", None, limit).await    top_by_column(pool, property_id, start_ms, end_ms, filter_url, "url", None, limit, false).await}pub async fn page_views_by_page_url(pool: &SqlitePool, property_id: &Uuid, start_ms: i64, end_ms: i64, filter_url: Option<&str>, limit: i64) -> Vec<LabelCount> {    top_by_column(pool, property_id, start_ms, end_ms, filter_url, "url", Some("page_view"), limit).await    top_by_column(pool, property_id, start_ms, end_ms, filter_url, "url", Some("page_view"), limit, false).await}pub async fn session_starts_by_referrer(pool: &SqlitePool, property_id: &Uuid, start_ms: i64, end_ms: i64, filter_url: Option<&str>, limit: i64) -> Vec<LabelCount> {    top_by_column(pool, property_id, start_ms, end_ms, filter_url, "referrer", Some("session_start"), limit).await    top_by_column(pool, property_id, start_ms, end_ms, filter_url, "referrer", Some("session_start"), limit, false).await}pub async fn page_views_by_utm(
@@ -548,7 +568,7 @@ pub async fn page_views_by_utm(        "content" => "utm_content",        _ => return Vec::new(),    };    top_by_column(pool, property_id, start_ms, end_ms, filter_url, column, Some("page_view"), limit).await    top_by_column(pool, property_id, start_ms, end_ms, filter_url, column, Some("page_view"), limit, false).await}pub async fn events_by_custom_event(
added src/render.rs
@@ -0,0 +1,65 @@use axum::{    http::StatusCode,    response::{Html, IntoResponse, Response},};use chrono::Datelike;use crate::templates::{RequestCtx, UserCtx};use crate::AppState;/// Render a template to a String, with the standard page context injected.////// `extra` is merged on top of the standard context. Templates expect/// `user`, `request`, `now`, `base_url`, `collector_id`, `collector_server`,/// `messages` to be present; callers supply page-specific fields like `page`/// via `extra`.////// Returns the rendered body on success, or a 500 Response with the error/// already logged.pub fn render_to_string(    state: &AppState,    template: &str,    path: &str,    authed: bool,    extra: minijinja::Value,) -> Result<String, Response> {    let tmpl = state.env.get_template(template).map_err(|e| {        tracing::error!("template '{}': {}", template, e);        (StatusCode::INTERNAL_SERVER_ERROR, "template error").into_response()    })?;    tmpl.render(minijinja::context! {        user => UserCtx { is_authenticated: authed },        request => RequestCtx {            url: String::new(),            url_root: "/".to_string(),            base_url: String::new(),            path: path.to_string(),        },        now => minijinja::context! { year => chrono::Local::now().year() },        base_url => &state.config.base_url,        collector_id => state.config.proprium_id.map(|u| u.to_string()),        collector_server => &state.config.base_url,        messages => Vec::<()>::new(),        ..extra    })    .map_err(|e| {        tracing::error!("render '{}': {}", template, e);        (StatusCode::INTERNAL_SERVER_ERROR, "render error").into_response()    })}/// Convenience wrapper around `render_to_string` for HTML responses. Most/// page handlers want this; `render_to_string` is for callers that need the/// raw body (e.g. markdown downloads, PDF print templates).pub fn render(    state: &AppState,    template: &str,    path: &str,    authed: bool,    extra: minijinja::Value,) -> Response {    match render_to_string(state, template, path, authed, extra) {        Ok(body) => Html(body).into_response(),        Err(resp) => resp,    }}
renamed src/auth.rs → src/routes/auth.rs
@@ -1,20 +1,30 @@use axum::{    extract::{Form, State},    http::StatusCode,    response::{Html, IntoResponse, Redirect, Response},    response::{IntoResponse, Redirect, Response},    routing::{get, post},    Router,};use chrono::{Datelike, Utc};use chrono::Utc;use serde::Deserialize;use tower_cookies::{    cookie::{time::Duration, SameSite},    Cookie, Cookies,};use crate::render::render;use crate::AppState;const COOKIE_NAME: &str = "session";// 30 days. Matches the cookie max-age the browser stores.const SESSION_TTL_SECS: i64 = 30 * 24 * 60 * 60;pub fn router() -> Router<AppState> {    Router::new()        .route("/login", get(login_form).post(login_submit))        .route("/logout", post(logout))}#[derive(Debug, Deserialize)]pub struct LoginForm {    pub password: String,
@@ -22,6 +32,8 @@ pub struct LoginForm {    pub next: Option<String>,}/// Returns true if the request carries a valid, unexpired signed session/// cookie. Used by every auth-gated route module.pub fn is_authenticated(cookies: &Cookies, state: &AppState) -> bool {    let signed = cookies.signed(&state.cookie_key);    let Some(c) = signed.get(COOKIE_NAME) else { return false };
@@ -34,50 +46,28 @@ pub fn is_authenticated(cookies: &Cookies, state: &AppState) -> bool {    Utc::now().timestamp() < exp}fn render_login(state: &AppState, error: Option<&str>) -> Result<Html<String>, StatusCode> {    let tmpl = state        .env        .get_template("registration/login.html")        .map_err(|e| {            tracing::error!("template registration/login.html: {e}");            StatusCode::INTERNAL_SERVER_ERROR        })?;    let body = tmpl        .render(minijinja::context! {            user => crate::templates::UserCtx::default(),            request => crate::templates::RequestCtx {                url: String::new(),                url_root: "/".to_string(),                base_url: String::new(),                path: "/login".to_string(),            },            now => minijinja::context! { year => chrono::Local::now().year() },            base_url => &state.config.base_url,            collector_id => state.config.proprium_id.map(|u| u.to_string()),            collector_server => &state.config.base_url,            messages => Vec::<()>::new(),fn render_login(state: &AppState, error: Option<&str>) -> Response {    render(        state,        "registration/login.html",        "/login",        false,        minijinja::context! {            page => minijinja::context! {                title => "Log in",                description => "Log in to your dashboard.",            },            error => error,            next => "/properties",        })        .map_err(|e| {            tracing::error!("render login: {e}");            StatusCode::INTERNAL_SERVER_ERROR        })?;    Ok(Html(body))        },    )}pub async fn login_form(State(state): State<AppState>, cookies: Cookies) -> Response {    if is_authenticated(&cookies, &state) {        return Redirect::to("/properties").into_response();    }    match render_login(&state, None) {        Ok(html) => html.into_response(),        Err(e) => e.into_response(),    }    render_login(&state, None)}pub async fn login_submit(
@@ -86,10 +76,7 @@ pub async fn login_submit(    Form(form): Form<LoginForm>,) -> Response {    if form.password != state.config.password {        let html = match render_login(&state, Some("Invalid password.")) {            Ok(h) => h,            Err(e) => return e.into_response(),        };        let html = render_login(&state, Some("Invalid password."));        return (StatusCode::UNAUTHORIZED, html).into_response();    }    let exp = Utc::now().timestamp() + SESSION_TTL_SECS;
renamed src/collector.rs → src/routes/collector.rs
@@ -2,14 +2,41 @@ use axum::{    extract::State,    http::{header, HeaderMap, StatusCode},    response::{IntoResponse, Response},    routing::{get, post},    Router,};use serde::Deserialize;use serde_json::Value;use std::net::IpAddr;use tower_http::cors::{Any, CorsLayer};use uuid::Uuid;use crate::AppState;pub fn router() -> Router<AppState> {    let cors = CorsLayer::new()        .allow_origin(Any)        .allow_methods(Any)        .allow_headers(Any);    let collect_routes = Router::new()        .route("/collect", post(collect).options(options))        // /collect/ is a compatibility alias for embeds that hardcoded the        // trailing slash. Keep it pointing at the same handlers.        .route("/collect/", post(collect).options(options))        .layer(cors);    let alias_routes = Router::new()        // Stable URL for the collector embed script. Vite content-hashes the        // entry, but every embed snippet in the wild hardcodes        // /static/collector.js. This aliased handler reads the manifest and        // serves the hashed asset by that stable path. CORS does not apply        // here; same-origin browsers fetch the script directly.        .route("/static/collector.js", get(collector_alias));    Router::new().merge(collect_routes).merge(alias_routes)}#[derive(Debug, Deserialize)]struct CollectBody {    #[serde(rename = "collectorId", alias = "collector_id")]
@@ -248,6 +275,53 @@ pub async fn collect(    cors_204(&headers)}pub async fn collector_alias(State(state): State<AppState>) -> Response {    let dist_dir = state.config.root.join("dist");    let manifest_path = dist_dir.join(".vite/manifest.json");    let manifest_text = match std::fs::read_to_string(&manifest_path) {        Ok(t) => t,        Err(e) => {            tracing::error!("collector manifest read: {e}");            return (StatusCode::SERVICE_UNAVAILABLE, "collector unavailable").into_response();        }    };    let manifest: serde_json::Value = match serde_json::from_str(&manifest_text) {        Ok(v) => v,        Err(e) => {            tracing::error!("collector manifest parse: {e}");            return (StatusCode::SERVICE_UNAVAILABLE, "collector unavailable").into_response();        }    };    let rel = manifest        .get("static_src/collector/index.js")        .and_then(|c| c.get("file"))        .and_then(|v| v.as_str());    let Some(rel) = rel else {        tracing::error!("collector entry missing from manifest");        return (StatusCode::SERVICE_UNAVAILABLE, "collector unavailable").into_response();    };    let asset_path = dist_dir.join(rel);    let bytes = match std::fs::read(&asset_path) {        Ok(b) => b,        Err(e) => {            tracing::error!("collector read {asset_path:?}: {e}");            return (StatusCode::SERVICE_UNAVAILABLE, "collector unavailable").into_response();        }    };    let mut h = HeaderMap::new();    h.insert(        header::CONTENT_TYPE,        "application/javascript; charset=utf-8".parse().unwrap(),    );    // 5 minutes. Short enough that an asset re-hash propagates within a deploy    // window, long enough to absorb burst traffic from the embed snippet.    h.insert(        header::CACHE_CONTROL,        "public, max-age=300, must-revalidate".parse().unwrap(),    );    (StatusCode::OK, h, bytes).into_response()}fn cors_204(req_headers: &HeaderMap) -> Response {    let mut h = HeaderMap::new();    let origin = req_headers
renamed src/views.rs → src/routes/dashboard.rs
@@ -1,197 +1,31 @@use axum::{    extract::{Form, Path as AxumPath, Query, State},    extract::{Path as AxumPath, Query, State},    http::StatusCode,    response::{Html, IntoResponse, Json, Redirect, Response},    response::{IntoResponse, Redirect, Response},    routing::get,    Router,};use chrono::Datelike;use serde::Deserialize;use serde_json::json;use tower_cookies::Cookies;use uuid::Uuid;use crate::auth::is_authenticated;use crate::templates::{RequestCtx, UserCtx};use crate::render::{render, render_to_string};use crate::routes::auth::is_authenticated;use crate::AppState;fn now_year() -> i32 {    use chrono::Datelike;    chrono::Local::now().year()}fn render(    state: &AppState,    template: &str,    extra: minijinja::Value,    authed: bool,    path: &str,) -> Result<Html<String>, StatusCode> {    let tmpl = state        .env        .get_template(template)        .map_err(|e| {            tracing::error!("template '{}': {}", template, e);            StatusCode::INTERNAL_SERVER_ERROR        })?;    let request = RequestCtx {        url: String::new(),        url_root: "/".to_string(),        base_url: String::new(),        path: path.to_string(),    };    let body = tmpl        .render(minijinja::context! {            user => UserCtx { is_authenticated: authed },            request => &request,            now => minijinja::context! { year => now_year() },            base_url => &state.config.base_url,            collector_id => state.config.proprium_id.map(|u| u.to_string()),            collector_server => &state.config.base_url,            messages => Vec::<()>::new(),            ..extra        })        .map_err(|e| {            tracing::error!("render '{}': {}", template, e);            StatusCode::INTERNAL_SERVER_ERROR        })?;    Ok(Html(body))}#[derive(Debug, Deserialize)]pub struct PropertiesQuery {    #[serde(default)]    pub q: Option<String>,}#[derive(Debug, Deserialize)]pub struct PropertyCreateForm {    pub name: String,}pub async fn properties(    State(state): State<AppState>,    cookies: Cookies,    Query(q): Query<PropertiesQuery>,) -> Response {    if !is_authenticated(&cookies, &state) {        return Redirect::to("/login").into_response();    }    let search = q.q.as_deref().unwrap_or("").trim().to_string();    let rows = if search.is_empty() {        sqlx::query_as::<_, crate::models::PropertyRow>(            "SELECT id, name, custom_cards, is_protected, is_public, created_at, updated_at \             FROM properties ORDER BY is_protected DESC, created_at ASC",        )        .fetch_all(&state.pool)        .await    } else {        let like = format!("%{}%", search);        sqlx::query_as::<_, crate::models::PropertyRow>(            "SELECT id, name, custom_cards, is_protected, is_public, created_at, updated_at \             FROM properties WHERE name LIKE ? ORDER BY is_protected DESC, created_at ASC",        )        .bind(like)        .fetch_all(&state.pool)        .await    };    let rows = match rows {        Ok(r) => r,        Err(e) => {            tracing::error!("properties query: {e}");            return (StatusCode::INTERNAL_SERVER_ERROR, "db error").into_response();        }    };    let mut props = Vec::with_capacity(rows.len());    let mut total_events = 0i64;    let mut total_page_views = 0i64;    let mut total_sessions = 0i64;    for row in rows {        let id_bytes = row.id.clone();        let p = row.into_property();        let counts: (i64, i64, i64, i64) = sqlx::query_as(            "SELECT \                (SELECT COUNT(*) FROM events WHERE property_id = ?1) AS total, \                (SELECT COUNT(*) FROM events WHERE property_id = ?1 AND event = 'page_view') AS pv, \                (SELECT COUNT(*) FROM events WHERE property_id = ?1 AND event = 'session_start') AS ss, \                (SELECT COUNT(*) FROM events WHERE property_id = ?1 AND created_at >= ?2) AS active",        )        .bind(&id_bytes)        .bind(chrono::Utc::now().timestamp_millis() - 7 * 24 * 60 * 60 * 1000)        .fetch_one(&state.pool)        .await        .unwrap_or((0, 0, 0, 0));        total_events += counts.0;        total_page_views += counts.1;        total_sessions += counts.2;        props.push(json!({            "id": p.id.to_string(),            "name": p.name,            "is_protected": p.is_protected,            "is_public": p.is_public,            "is_active": counts.3 > 0,            "total_events": counts.0,            "total_page_views": counts.1,            "total_session_starts": counts.2,        }));    }    let totals = json!({        "properties": props.len(),        "events": total_events,        "page_views": total_page_views,        "sessions": total_sessions,    });    let extra = minijinja::context! {        page => minijinja::context! {            title => "Properties",            description => "Manage your properties.",        },        properties => &props,        totals => &totals,        q => &search,    };    render(&state, "properties/properties.html", extra, true, "/properties")        .map(IntoResponse::into_response)        .unwrap_or_else(|e| e.into_response())}pub async fn properties_create(    State(state): State<AppState>,    cookies: Cookies,    Form(form): Form<PropertyCreateForm>,) -> Response {    if !is_authenticated(&cookies, &state) {        return Redirect::to("/login").into_response();    }    let name = form.name.trim();    if name.is_empty() {        return Redirect::to("/properties").into_response();    }    let id = Uuid::new_v4();    let now = chrono::Utc::now().timestamp_millis();    let res = sqlx::query(        "INSERT INTO properties (id, name, custom_cards, is_protected, is_public, created_at, updated_at) \         VALUES (?, ?, '[]', 0, 0, ?, ?)",    )    .bind(id.as_bytes().to_vec())    .bind(name)    .bind(now)    .bind(now)    .execute(&state.pool)    .await;    if let Err(e) = res {        tracing::error!("create property: {e}");        return (StatusCode::INTERNAL_SERVER_ERROR, "db error").into_response();    }    Redirect::to("/properties").into_response()// Milliseconds per day. Used to convert between the date-range query// parameter (in days) and the millisecond timestamps stored in events.const DAY_MS: i64 = 24 * 60 * 60 * 1000;// Default look-back when the request omits ?date_range= and gives a custom// start/end. Matches what the dashboard's date selector picks by default.const DEFAULT_DATE_RANGE_DAYS: i64 = 28;pub fn router() -> Router<AppState> {    // The dashboard's UUID path segment is a catch-all; merging this module    // last in app::router keeps named routes (e.g. /login, /properties)    // winning the match (axum prefers literal segments over path parameters    // at the same depth).    Router::new().route("/{property_id}", get(property))}#[derive(Debug, Deserialize)]
@@ -229,9 +63,15 @@ pub async fn property(    use chrono::{Duration, Local};    let today = Local::now().date_naive();    let default_start = today - Duration::days(28);    let date_start = q.date_start.clone().unwrap_or_else(|| default_start.format("%Y-%m-%d").to_string());    let date_end = q.date_end.clone().unwrap_or_else(|| today.format("%Y-%m-%d").to_string());    let default_start = today - Duration::days(DEFAULT_DATE_RANGE_DAYS);    let date_start = q        .date_start        .clone()        .unwrap_or_else(|| default_start.format("%Y-%m-%d").to_string());    let date_end = q        .date_end        .clone()        .unwrap_or_else(|| today.format("%Y-%m-%d").to_string());    let start_ms = match crate::queries::parse_date_to_ms(&date_start, false) {        Some(v) => v,
@@ -245,32 +85,17 @@ pub async fn property(    let date_range: i64 = match q.date_range.as_deref() {        Some("custom") | None => {            // Days between start and end, inclusive of the end-of-day window.            let span = (end_ms - start_ms) / (24 * 60 * 60 * 1000);            let span = (end_ms - start_ms) / DAY_MS;            span.max(1)        }        Some(other) => other.parse::<i64>().unwrap_or(28),        Some(other) => other.parse::<i64>().unwrap_or(DEFAULT_DATE_RANGE_DAYS),    };    let prev_start_ms = start_ms - date_range * 24 * 60 * 60 * 1000;    let prev_end_ms = end_ms - date_range * 24 * 60 * 60 * 1000;    let prev_start_ms = start_ms - date_range * DAY_MS;    let prev_end_ms = end_ms - date_range * DAY_MS;    let filter_url = q.filter_url.as_deref().filter(|s| !s.is_empty());    // Cache key includes property updated_at so card/visibility edits bust it.    let cache_key = format!(        "dash:{}:{}:{}:{}:{}:{}",        p.id,        p.updated_at,        date_start,        date_end,        date_range,        filter_url.unwrap_or("")    );    let bypass_cache = q.report.is_some();    let cached = if bypass_cache { None } else { state.cache.get(&cache_key).await };    let dash_value: serde_json::Value = if let Some(arc) = cached {        (*arc).clone()    } else {    let dash_value: serde_json::Value = {        let pool = &state.pool;        let pid = &p.id;
@@ -317,7 +142,7 @@ pub async fn property(        let bot_traffic =            crate::queries::bot_traffic(pool, pid, start_ms, end_ms, 10).await;        let v = serde_json::json!({        serde_json::json!({            "event_cards": all_cards,            "custom_events": custom_events,            "total_events_graph": total_events_graph,
@@ -335,14 +160,7 @@ pub async fn property(            "session_starts_by_country": session_starts_by_country,            "session_starts_by_country_region": session_starts_by_country_region,            "bot_traffic": bot_traffic,        });        if !bypass_cache {            state                .cache                .insert(cache_key.clone(), std::sync::Arc::new(v.clone()))                .await;        }        v        })    };    let total_live_users = crate::queries::total_live_users(&state.pool, &p.id).await;
@@ -467,34 +285,17 @@ pub async fn property(    // Report exports.    if let Some(fmt) = q.report.as_deref() {        let fmt = if fmt.is_empty() { "pdf" } else { fmt };        let path = format!("/{property_id}");        if fmt == "md" {            let tmpl = match state.env.get_template("properties/property_report.md") {                Ok(t) => t,                Err(e) => {                    tracing::error!("template property_report.md: {e}");                    return (StatusCode::INTERNAL_SERVER_ERROR, "template error").into_response();                }            };            let body = match tmpl.render(minijinja::context! {                user => crate::templates::UserCtx { is_authenticated: authed },                request => crate::templates::RequestCtx {                    url: String::new(),                    url_root: "/".to_string(),                    base_url: String::new(),                    path: format!("/{property_id}"),                },                now => minijinja::context! { year => chrono::Local::now().year() },                base_url => &state.config.base_url,                collector_id => state.config.proprium_id.map(|u| u.to_string()),                collector_server => &state.config.base_url,                messages => Vec::<()>::new(),                ..extra            }) {            let body = match render_to_string(                &state,                "properties/property_report.md",                &path,                authed,                extra,            ) {                Ok(b) => b,                Err(e) => {                    tracing::error!("render md: {e}");                    return (StatusCode::INTERNAL_SERVER_ERROR, "render error").into_response();                }                Err(resp) => return resp,            };            let mut h = axum::http::HeaderMap::new();            h.insert(
@@ -508,40 +309,20 @@ pub async fn property(            return (StatusCode::OK, h, body).into_response();        }        if fmt == "pdf" {            let tmpl = match state.env.get_template("properties/property_print.html") {                Ok(t) => t,                Err(e) => {                    tracing::error!("template property_print.html: {e}");                    return (StatusCode::INTERNAL_SERVER_ERROR, "template error").into_response();                }            };            let html = match tmpl.render(minijinja::context! {                user => crate::templates::UserCtx { is_authenticated: authed },                request => crate::templates::RequestCtx {                    url: String::new(),                    url_root: "/".to_string(),                    base_url: String::new(),                    path: format!("/{property_id}"),                },                now => minijinja::context! { year => chrono::Local::now().year() },                base_url => &state.config.base_url,                collector_id => state.config.proprium_id.map(|u| u.to_string()),                collector_server => &state.config.base_url,                messages => Vec::<()>::new(),                ..extra            }) {            let html = match render_to_string(                &state,                "properties/property_print.html",                &path,                authed,                extra,            ) {                Ok(b) => b,                Err(e) => {                    tracing::error!("render print: {e}");                    return (StatusCode::INTERNAL_SERVER_ERROR, "render error").into_response();                }                Err(resp) => return resp,            };            let server_base = if state.config.base_url.is_empty() {                String::new()            } else {                state.config.base_url.clone()            };            let pdf_res = tokio::task::spawn_blocking(move || crate::pdf::html_to_pdf(&html, &server_base)).await;            let server_base = state.config.base_url.clone();            let pdf_res =                tokio::task::spawn_blocking(move || crate::pdf::html_to_pdf(&html, &server_base))                    .await;            match pdf_res {                Ok(Ok(bytes)) => {                    let mut h = axum::http::HeaderMap::new();
@@ -567,15 +348,15 @@ pub async fn property(    render(        &state,        "properties/property.html",        extra,        authed,        &format!("/{property_id}"),        authed,        extra,    )    .map(IntoResponse::into_response)    .unwrap_or_else(|e| e.into_response())}/// Toner-friendly SVG polyline points for the print template./// Toner-friendly SVG polyline points for the print template. `width`,/// `height`, and `padding` match the SVG viewBox in/// templates/properties/property_print.html — change one and change the other.fn build_chart_polyline(points: &[serde_json::Value]) -> String {    if points.is_empty() {        return String::new();
@@ -607,59 +388,3 @@ fn build_chart_polyline(points: &[serde_json::Value]) -> String {        .collect::<Vec<_>>()        .join(" ")}pub async fn property_delete(    State(state): State<AppState>,    AxumPath(property_id): AxumPath<Uuid>,    cookies: Cookies,) -> Response {    if !is_authenticated(&cookies, &state) {        return Redirect::to("/login").into_response();    }    let _ = sqlx::query("DELETE FROM properties WHERE id = ? AND is_protected = 0")        .bind(property_id.as_bytes().to_vec())        .execute(&state.pool)        .await;    Redirect::to("/properties").into_response()}pub async fn property_cards(    State(state): State<AppState>,    AxumPath(property_id): AxumPath<Uuid>,    cookies: Cookies,    body: String,) -> Response {    if !is_authenticated(&cookies, &state) {        return Redirect::to("/login").into_response();    }    // Body is the raw JSON array of {event,value} objects.    let parsed: serde_json::Value =        serde_json::from_str(&body).unwrap_or(serde_json::json!([]));    let payload = parsed.to_string();    let now = chrono::Utc::now().timestamp_millis();    let _ = sqlx::query("UPDATE properties SET custom_cards = ?, updated_at = ? WHERE id = ?")        .bind(payload)        .bind(now)        .bind(property_id.as_bytes().to_vec())        .execute(&state.pool)        .await;    Json(serde_json::json!({"success": true})).into_response()}pub async fn property_public_toggle(    State(state): State<AppState>,    AxumPath(property_id): AxumPath<Uuid>,    cookies: Cookies,) -> Response {    if !is_authenticated(&cookies, &state) {        return Redirect::to("/login").into_response();    }    let now = chrono::Utc::now().timestamp_millis();    let _ = sqlx::query("UPDATE properties SET is_public = 1 - is_public, updated_at = ? WHERE id = ?")        .bind(now)        .bind(property_id.as_bytes().to_vec())        .execute(&state.pool)        .await;    Json(serde_json::json!({"success": true})).into_response()}
added src/routes/home.rs
@@ -0,0 +1,101 @@use axum::{    extract::State,    response::{IntoResponse, Redirect, Response},    routing::get,    Router,};use tower_cookies::Cookies;use crate::render::render;use crate::routes::auth::is_authenticated;use crate::AppState;pub fn router() -> Router<AppState> {    Router::new()        .route("/", get(home))        .route("/changelog", get(changelog))        .route("/documentation", get(documentation))}fn render_page(    state: &AppState,    template: &str,    title: &str,    description: &str,    authed: bool,    path: &str,    extra: minijinja::Value,) -> Response {    render(        state,        template,        path,        authed,        minijinja::context! {            page => minijinja::context! { title => title, description => description },            ..extra        },    )}pub async fn home(State(state): State<AppState>, cookies: Cookies) -> Response {    let authed = is_authenticated(&cookies, &state);    if authed {        return Redirect::to("/properties").into_response();    }    let totals: (i64, i64, Option<i64>) = sqlx::query_as(        "SELECT \           (SELECT COUNT(*) FROM properties), \           (SELECT COUNT(*) FROM events), \           (SELECT MIN(created_at) FROM events)",    )    .fetch_one(&state.pool)    .await    .unwrap_or((0, 0, None));    let first = totals.2.and_then(|ms| {        chrono::DateTime::<chrono::Utc>::from_timestamp_millis(ms)            .map(|d| d.format("%b %-d, %Y").to_string())    });    render_page(        &state,        "pages/home.html",        "Self-hosted analytics",        "Self-hosted website analytics. Page views, clicks, scrolls, sessions, and custom events.",        authed,        "/",        minijinja::context! {            total_properties => totals.0,            total_events => totals.1,            total_users => 1,            first_event_created_at => first,        },    )}pub async fn changelog(State(state): State<AppState>, cookies: Cookies) -> Response {    let authed = is_authenticated(&cookies, &state);    render_page(        &state,        "pages/changelog.html",        "Changelog",        "What's new in Analytics.",        authed,        "/changelog",        minijinja::context! {},    )}pub async fn documentation(State(state): State<AppState>, cookies: Cookies) -> Response {    let authed = is_authenticated(&cookies, &state);    render_page(        &state,        "pages/documentation.html",        "Documentation",        "How to embed and operate Analytics.",        authed,        "/documentation",        minijinja::context! {},    )}
added src/routes/mod.rs
@@ -0,0 +1,6 @@pub mod auth;pub mod collector;pub mod dashboard;pub mod home;pub mod properties;pub mod seo;
added src/routes/properties.rs
@@ -0,0 +1,218 @@use axum::{    extract::{Form, Path as AxumPath, Query, State},    http::StatusCode,    response::{IntoResponse, Json, Redirect, Response},    routing::{get, post},    Router,};use serde::Deserialize;use serde_json::json;use tower_cookies::Cookies;use uuid::Uuid;use crate::render::render;use crate::routes::auth::is_authenticated;use crate::AppState;pub fn router() -> Router<AppState> {    Router::new()        .route("/properties", get(properties).post(properties_create))        .route("/properties/{id}/delete", post(property_delete))        .route("/properties/{id}/cards", post(property_cards))        .route("/properties/{id}/public", post(property_public_toggle))}#[derive(Debug, Deserialize)]pub struct PropertiesQuery {    #[serde(default)]    pub q: Option<String>,}#[derive(Debug, Deserialize)]pub struct PropertyCreateForm {    pub name: String,}pub async fn properties(    State(state): State<AppState>,    cookies: Cookies,    Query(q): Query<PropertiesQuery>,) -> Response {    if !is_authenticated(&cookies, &state) {        return Redirect::to("/login").into_response();    }    let search = q.q.as_deref().unwrap_or("").trim().to_string();    let rows = if search.is_empty() {        sqlx::query_as::<_, crate::models::PropertyRow>(            "SELECT id, name, custom_cards, is_protected, is_public, created_at, updated_at \             FROM properties ORDER BY is_protected DESC, created_at ASC",        )        .fetch_all(&state.pool)        .await    } else {        let like = format!("%{}%", search);        sqlx::query_as::<_, crate::models::PropertyRow>(            "SELECT id, name, custom_cards, is_protected, is_public, created_at, updated_at \             FROM properties WHERE name LIKE ? ORDER BY is_protected DESC, created_at ASC",        )        .bind(like)        .fetch_all(&state.pool)        .await    };    let rows = match rows {        Ok(r) => r,        Err(e) => {            tracing::error!("properties query: {e}");            return (StatusCode::INTERNAL_SERVER_ERROR, "db error").into_response();        }    };    let mut props = Vec::with_capacity(rows.len());    let mut total_events = 0i64;    let mut total_page_views = 0i64;    let mut total_sessions = 0i64;    // 7 days in ms. Used as the "active in the last week" threshold for    // marking a property as live in the list view.    const ACTIVE_WINDOW_MS: i64 = 7 * 24 * 60 * 60 * 1000;    for row in rows {        let id_bytes = row.id.clone();        let p = row.into_property();        let counts: (i64, i64, i64, i64) = sqlx::query_as(            "SELECT \                (SELECT COUNT(*) FROM events WHERE property_id = ?1) AS total, \                (SELECT COUNT(*) FROM events WHERE property_id = ?1 AND event = 'page_view') AS pv, \                (SELECT COUNT(*) FROM events WHERE property_id = ?1 AND event = 'session_start') AS ss, \                (SELECT COUNT(*) FROM events WHERE property_id = ?1 AND created_at >= ?2) AS active",        )        .bind(&id_bytes)        .bind(chrono::Utc::now().timestamp_millis() - ACTIVE_WINDOW_MS)        .fetch_one(&state.pool)        .await        .unwrap_or((0, 0, 0, 0));        total_events += counts.0;        total_page_views += counts.1;        total_sessions += counts.2;        props.push(json!({            "id": p.id.to_string(),            "name": p.name,            "is_protected": p.is_protected,            "is_public": p.is_public,            "is_active": counts.3 > 0,            "total_events": counts.0,            "total_page_views": counts.1,            "total_session_starts": counts.2,        }));    }    let totals = json!({        "properties": props.len(),        "events": total_events,        "page_views": total_page_views,        "sessions": total_sessions,    });    let extra = minijinja::context! {        page => minijinja::context! {            title => "Properties",            description => "Manage your properties.",        },        properties => &props,        totals => &totals,        q => &search,    };    render(&state, "properties/properties.html", "/properties", true, extra)}pub async fn properties_create(    State(state): State<AppState>,    cookies: Cookies,    Form(form): Form<PropertyCreateForm>,) -> Response {    if !is_authenticated(&cookies, &state) {        return Redirect::to("/login").into_response();    }    let name = form.name.trim();    if name.is_empty() {        return Redirect::to("/properties").into_response();    }    let id = Uuid::new_v4();    let now = chrono::Utc::now().timestamp_millis();    let res = sqlx::query(        "INSERT INTO properties (id, name, custom_cards, is_protected, is_public, created_at, updated_at) \         VALUES (?, ?, '[]', 0, 0, ?, ?)",    )    .bind(id.as_bytes().to_vec())    .bind(name)    .bind(now)    .bind(now)    .execute(&state.pool)    .await;    if let Err(e) = res {        tracing::error!("create property: {e}");        return (StatusCode::INTERNAL_SERVER_ERROR, "db error").into_response();    }    Redirect::to("/properties").into_response()}pub async fn property_delete(    State(state): State<AppState>,    AxumPath(property_id): AxumPath<Uuid>,    cookies: Cookies,) -> Response {    if !is_authenticated(&cookies, &state) {        return Redirect::to("/login").into_response();    }    let _ = sqlx::query("DELETE FROM properties WHERE id = ? AND is_protected = 0")        .bind(property_id.as_bytes().to_vec())        .execute(&state.pool)        .await;    Redirect::to("/properties").into_response()}pub async fn property_cards(    State(state): State<AppState>,    AxumPath(property_id): AxumPath<Uuid>,    cookies: Cookies,    body: String,) -> Response {    if !is_authenticated(&cookies, &state) {        return Redirect::to("/login").into_response();    }    // Body is the raw JSON array of {event,value} objects.    let parsed: serde_json::Value =        serde_json::from_str(&body).unwrap_or(serde_json::json!([]));    let payload = parsed.to_string();    let now = chrono::Utc::now().timestamp_millis();    let _ = sqlx::query("UPDATE properties SET custom_cards = ?, updated_at = ? WHERE id = ?")        .bind(payload)        .bind(now)        .bind(property_id.as_bytes().to_vec())        .execute(&state.pool)        .await;    Json(serde_json::json!({"success": true})).into_response()}pub async fn property_public_toggle(    State(state): State<AppState>,    AxumPath(property_id): AxumPath<Uuid>,    cookies: Cookies,) -> Response {    if !is_authenticated(&cookies, &state) {        return Redirect::to("/login").into_response();    }    let now = chrono::Utc::now().timestamp_millis();    let _ = sqlx::query("UPDATE properties SET is_public = 1 - is_public, updated_at = ? WHERE id = ?")        .bind(now)        .bind(property_id.as_bytes().to_vec())        .execute(&state.pool)        .await;    Json(serde_json::json!({"success": true})).into_response()}
added src/routes/seo.rs
@@ -0,0 +1,56 @@use axum::{    extract::State,    http::{header, HeaderMap, StatusCode},    response::{IntoResponse, Response},    routing::get,    Router,};use crate::AppState;pub fn router() -> Router<AppState> {    Router::new()        .route("/favicon.ico", get(favicon))        .route("/robots.txt", get(robots))        .route("/sitemap.xml", get(sitemap))}pub async fn favicon() -> Response {    let mut h = HeaderMap::new();    h.insert(header::CONTENT_TYPE, "image/svg+xml".parse().unwrap());    let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">  <rect x="6"  y="38" width="10" height="22" rx="1.5" fill="#6b9e78"/>  <rect x="20" y="28" width="10" height="32" rx="1.5" fill="#6b9e78"/>  <rect x="34" y="18" width="10" height="42" rx="1.5" fill="#6b9e78"/>  <rect x="48" y="8"  width="10" height="52" rx="1.5" fill="#6b9e78"/>  <rect x="48" y="8"  width="10" height="6"  rx="1.5" fill="#c9a84c"/></svg>"##;    (StatusCode::OK, h, svg).into_response()}pub async fn robots() -> Response {    let mut h = HeaderMap::new();    h.insert(header::CONTENT_TYPE, "text/plain".parse().unwrap());    (StatusCode::OK, h, "User-agent: *\nAllow: /\n").into_response()}pub async fn sitemap(State(state): State<AppState>) -> Response {    let mut h = HeaderMap::new();    h.insert(header::CONTENT_TYPE, "application/xml".parse().unwrap());    let base = if state.config.base_url.is_empty() {        "/".to_string()    } else {        format!("{}/", state.config.base_url.trim_end_matches('/'))    };    let now = chrono::Utc::now().format("%Y-%m-%d");    let body = format!(        r##"<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">  <url><loc>{base}</loc><lastmod>{now}</lastmod></url>  <url><loc>{base}documentation</loc><lastmod>{now}</lastmod></url>  <url><loc>{base}changelog</loc><lastmod>{now}</lastmod></url></urlset>"##    );    (StatusCode::OK, h, body).into_response()}
modified templates/properties/property.html
@@ -142,15 +142,17 @@    {% for c in event_cards %}    <div class="col-6 col-md-4 col-lg-3">      <div class="metric-tile">        {% if c.percent_change == 0 %}        <span class="metric-delta is-flat" title="No change vs previous period">·</span>        {% elif c.percent_change < 0 %}        <span class="metric-delta is-down">{{ c.percent_change }}%</span>        {% else %}        <span class="metric-delta is-up">+{{ c.percent_change }}%</span>        {% endif %}        <div class="metric-label text-truncate" {% if c.help_text %}data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ c.help_text }}"{% endif %}>          {{ c.name }}        <div class="metric-header">          <div class="metric-label text-truncate" {% if c.help_text %}data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ c.help_text }}"{% endif %}>            {{ c.name }}          </div>          {% if c.percent_change == 0 %}          <span class="metric-delta is-flat" title="No change vs previous period">·</span>          {% elif c.percent_change < 0 %}          <span class="metric-delta is-down">{{ c.percent_change }}%</span>          {% else %}          <span class="metric-delta is-up">+{{ c.percent_change }}%</span>          {% endif %}        </div>        <div class="metric-value">{{ c.value }}</div>      </div>