@@ -201,6 +201,9 @@ async fn main() -> anyhow::Result<()> { .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))
@@ -114,6 +114,56 @@ pub async fn documentation(State(state): State<AppState>, cookies: Cookies) -> R )}// 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());
modified
templates/includes/collector.html
@@ -3,7 +3,7 @@ (function(m,e,t,r,i,c,s){m.collectorQueue = m.collectorQueue || r; m.collectorServer = c; m.collectorId = s; var script = e.createElement(t); script.src = c + i; e.head.appendChild(script); })(window,document,'script',[],'{{ vite_asset("static_src/collector/index.js") }}', })(window,document,'script',[],'/static/collector.js', '{{ collector_server }}','{{ collector_id }}');</script>{% endif %}
modified
templates/pages/documentation.html
@@ -79,9 +79,10 @@ </h2> <div id="collapseThree" class="accordion-collapse collapse" aria-labelledby="headingThree" data-bs-parent="#docsAccordion"> <div class="accordion-body"> <p>Every event is stored as JSON in a <code>data</code> field. Add whatever keys you need — no migration required, query with Django ORM <code>data__user_segment</code> lookups later.</p> <p>Every event is stored as JSON in an <code>extra</code> column. Add whatever keys you need, no migration required, query with SQLite JSON1 <code>json_extract(extra, '$.user_segment')</code> later.</p> <textarea class="codebox" rows="3" readonly><script>collectorQueue.push({event: 'admin_page_view', data: {user_segment: 'admins'}});</script></textarea> </div> </div>
modified
templates/pages/home.html
@@ -124,9 +124,9 @@ <div class="feature-label">geo + device</div> <div class="feature-title">Maps, browsers, platforms</div> <p class="feature-desc"> A US state choropleth, plus doughnut breakdowns for device, browser, platform, and screen size. GeoIP runs against a local MaxMind DB — IPs never leave your host. A Natural Earth world map with click-through admin-1 drill-down, plus doughnut breakdowns for device, browser, platform, and screen size. GeoIP runs locally, IPs never leave your host. </p> </div> </div>
@@ -144,22 +144,25 @@ <div class="col-12 col-md-6 col-lg-4"> <div class="feature"> <div class="feature-label">schema</div> <div class="feature-title">JSON-first event store</div> <div class="feature-title">Typed schema, JSON extras</div> <p class="feature-desc"> Every event lands in a single JSONField. New attributes need no migrations — send them from the client and query them with Django ORM lookups. Bot traffic is filtered before it ever hits the DB. Hot fields (url, referrer, geo, UA, UTMs) live in typed columns for fast queries. Anything custom lands in an <code>extra</code> JSON column, no migration required, queryable with SQLite JSON1 (json_extract). Bot traffic is routed to a separate table so human dashboards never have to filter it. </p> </div> </div> <div class="col-12 col-md-6 col-lg-4"> <div class="feature"> <div class="feature-label">ownership</div> <div class="feature-title">BSD, single container</div> <div class="feature-label">stack</div> <div class="feature-title">Built in Rust, ultralight</div> <p class="feature-desc"> Django + SQLite + one collector endpoint. Ships as a Docker image behind your own reverse proxy. Your domain, your retention policy, your compliance story. No vendor, no pixel, no lock-in. A single statically linked Rust binary plus SQLite. Ultra performant and very resource friendly, the whole stack runs as a tiny Docker image behind your own reverse proxy. Your domain, your retention policy. No vendor, no pixel, no lock-in. </p> </div> </div>