@@ -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"
@@ -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"
@@ -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();
@@ -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(®exes_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)}
@@ -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 };
@@ -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() }}
@@ -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(®exes_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(®exes_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\
@@ -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()}
@@ -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()}
@@ -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(
@@ -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()}
@@ -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! {}, )}
@@ -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()}
@@ -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>