diff --git a/crates/trapfalld/build.rs b/crates/trapfalld/build.rs new file mode 100644 index 0000000..02ea0f4 --- /dev/null +++ b/crates/trapfalld/build.rs @@ -0,0 +1,18 @@ +//! Warn when the SvelteKit bundle has not been built. +//! +//! `web/build/` is embedded via rust-embed. Only a placeholder `index.html` +//! is tracked in git, so a fresh clone built without `npm run build` would +//! serve a dashboard whose JS/CSS are missing (blank page). + +use std::path::Path; + +fn main() { + let app_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../web/build/_app"); + println!("cargo:rerun-if-changed=../../web/build/_app"); + if !app_dir.is_dir() { + println!( + "cargo:warning=web/build/_app not found: the embedded dashboard will be blank. \ + Run `cd web && npm ci && npm run build` before `cargo build`." + ); + } +} diff --git a/crates/trapfalld/src/spa.rs b/crates/trapfalld/src/spa.rs index b404fff..627e740 100644 --- a/crates/trapfalld/src/spa.rs +++ b/crates/trapfalld/src/spa.rs @@ -34,6 +34,12 @@ pub async fn spa_handler(uri: axum::http::Uri) -> Response { .into_response(); } + // Missing static assets must 404 rather than return index.html with 200, + // which would be parsed as JS/CSS/image and fail confusingly. + if is_asset_like(path) { + return StatusCode::NOT_FOUND.into_response(); + } + // Fallback to index.html for client-side routing. The embedded asset is // static for the lifetime of the binary, so cache the decoded string once // instead of re-allocating per request. @@ -47,6 +53,16 @@ pub async fn spa_handler(uri: axum::http::Uri) -> Response { } } +/// A path looks like a static asset if it is under `_app/` or its final +/// segment has a file extension. Extension-less paths are client routes. +fn is_asset_like(path: &str) -> bool { + if path.starts_with("_app/") { + return true; + } + let last = path.rsplit('/').next().unwrap_or(""); + last.rsplit_once('.').is_some_and(|(stem, ext)| !stem.is_empty() && !ext.is_empty()) +} + /// Check if path is a hashed/immutable static asset (JS/CSS/fonts/images). fn is_immutable_asset(path: &str) -> bool { path.starts_with("_app/") // SvelteKit build output @@ -60,3 +76,32 @@ fn is_immutable_asset(path: &str) -> bool { || path.ends_with(".svg") || path.ends_with(".ico") } + +#[cfg(test)] +mod tests { + use super::*; + + async fn status(path: &str) -> StatusCode { + spa_handler(path.parse().expect("valid uri")).await.status() + } + + #[tokio::test] + async fn missing_asset_with_extension_is_404() { + assert_eq!(status("/missing-file.png").await, StatusCode::NOT_FOUND); + assert_eq!(status("/nope/app.js").await, StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn missing_app_path_is_404() { + assert_eq!(status("/_app/immutable/entry/missing.hash").await, StatusCode::NOT_FOUND); + assert_eq!(status("/_app/missing.js").await, StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn client_routes_fall_back_to_index() { + assert_eq!(status("/issues/123").await, StatusCode::OK); + assert_eq!(status("/").await, StatusCode::OK); + // dots in a non-final segment don't make it a file + assert_eq!(status("/releases/1.2.3/overview").await, StatusCode::OK); + } +} diff --git a/web/src/lib/project-selection.test.ts b/web/src/lib/project-selection.test.ts new file mode 100644 index 0000000..23f0544 --- /dev/null +++ b/web/src/lib/project-selection.test.ts @@ -0,0 +1,27 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { getStoredProject, resolveProject, storeProject } from './project-selection'; + +const projects = [{ slug: 'alpha' }, { slug: 'beta' }]; + +describe('project selection', () => { + beforeEach(() => localStorage.clear()); + + it('defaults to the first project', () => { + expect(resolveProject(projects, null)).toBe('alpha'); + }); + + it('prefers a valid URL param and stores it', () => { + expect(resolveProject(projects, 'beta')).toBe('beta'); + expect(getStoredProject()).toBe('beta'); + }); + + it('restores the stored selection when the URL has none', () => { + storeProject('beta'); + expect(resolveProject(projects, null)).toBe('beta'); + }); + + it('ignores unknown slugs in URL and storage', () => { + storeProject('gone'); + expect(resolveProject(projects, 'nope')).toBe('alpha'); + }); +}); diff --git a/web/src/lib/project-selection.ts b/web/src/lib/project-selection.ts new file mode 100644 index 0000000..a0edea4 --- /dev/null +++ b/web/src/lib/project-selection.ts @@ -0,0 +1,34 @@ +/** + * Persist the selected project slug across dashboard pages (issue #311). + * + * Resolution order: valid `?project=` URL param, then the last stored + * selection, then the first available project. + */ +const STORAGE_KEY = 'trapfall.selectedProject'; + +export function getStoredProject(): string | null { + try { + return localStorage.getItem(STORAGE_KEY); + } catch { + return null; + } +} + +export function storeProject(slug: string): void { + try { + localStorage.setItem(STORAGE_KEY, slug); + } catch { + // Storage unavailable (private mode, blocked): selection just won't persist. + } +} + +export function resolveProject( + projects: { slug: string }[], + queryProject: string | null | undefined +): string { + const valid = (s: string | null | undefined): s is string => !!s && projects.some((p) => p.slug === s); + const stored = getStoredProject(); + const chosen = valid(queryProject) ? queryProject : valid(stored) ? stored : projects[0].slug; + storeProject(chosen); + return chosen; +} diff --git a/web/src/routes/(dashboard)/issues/+page.svelte b/web/src/routes/(dashboard)/issues/+page.svelte index a6628d7..d1c9077 100644 --- a/web/src/routes/(dashboard)/issues/+page.svelte +++ b/web/src/routes/(dashboard)/issues/+page.svelte @@ -2,6 +2,7 @@ import { onMount } from 'svelte'; import { goto } from '$app/navigation'; import { page } from '$app/state'; + import { resolveProject, storeProject } from '$lib/project-selection'; import { api, type Issue, type Project } from '$lib/api'; import { getWsClient, type ServerMessage } from '$lib/ws'; import { Badge } from '$lib/components/ui/badge/index.js'; @@ -90,6 +91,7 @@ function switchProject(slug: string) { selectedProject = slug; + storeProject(slug); currentPage = 1; navigate(); } @@ -131,7 +133,7 @@ // Restore state from URL const sp = page.url.searchParams; const qp = sp.get('project'); - selectedProject = (qp && projects.some(p => p.slug === qp)) ? qp : projects[0].slug; + selectedProject = resolveProject(projects, qp); filterStatus = sp.get('status') || ''; filterLevel = sp.get('level') || ''; searchQuery = sp.get('q') || ''; diff --git a/web/src/routes/(dashboard)/release-health/+page.svelte b/web/src/routes/(dashboard)/release-health/+page.svelte index 7cdfb47..102e671 100644 --- a/web/src/routes/(dashboard)/release-health/+page.svelte +++ b/web/src/routes/(dashboard)/release-health/+page.svelte @@ -2,6 +2,7 @@ import { onMount } from 'svelte'; import { goto } from '$app/navigation'; import { page } from '$app/state'; + import { resolveProject, storeProject } from '$lib/project-selection'; import { api, type Project, type ReleaseHealthResponse, type CrashRateResponse } from '$lib/api'; import { Badge } from '$lib/components/ui/badge/index.js'; import { Button } from '$lib/components/ui/button/index.js'; @@ -120,6 +121,7 @@ function switchProject(slug: string) { selectedProject = slug; + storeProject(slug); currentPage = 1; navigate(); loadData(); @@ -169,7 +171,7 @@ const sp = page.url.searchParams; const qp = sp.get('project'); - selectedProject = (qp && projects.some((p) => p.slug === qp)) ? qp : projects[0].slug; + selectedProject = resolveProject(projects, qp); filterRelease = sp.get('release') || ''; filterEnv = sp.get('env') || ''; currentPage = parseInt(sp.get('page') || '1', 10) || 1; diff --git a/web/src/routes/(dashboard)/rules/+page.svelte b/web/src/routes/(dashboard)/rules/+page.svelte index 341d0ee..9b04125 100644 --- a/web/src/routes/(dashboard)/rules/+page.svelte +++ b/web/src/routes/(dashboard)/rules/+page.svelte @@ -2,6 +2,7 @@ import { onMount } from 'svelte'; import { goto } from '$app/navigation'; import { page } from '$app/state'; + import { resolveProject, storeProject } from '$lib/project-selection'; import { api, type Project, type AlertRule, type CreateAlertRule } from '$lib/api'; import { Badge } from '$lib/components/ui/badge/index.js'; import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js'; @@ -52,11 +53,7 @@ // Restore from URL or default to first const queryProject = page.url.searchParams.get('project'); - if (queryProject && projects.some(p => p.slug === queryProject)) { - selectedProject = queryProject; - } else { - selectedProject = projects[0].slug; - } + selectedProject = resolveProject(projects, queryProject); await loadRules(); } catch (e: any) { @@ -67,6 +64,7 @@ function switchProject(slug: string) { selectedProject = slug; + storeProject(slug); goto(`/rules?project=${slug}`, { replaceState: true }); loadRules(); } diff --git a/web/src/routes/(dashboard)/transactions/+page.svelte b/web/src/routes/(dashboard)/transactions/+page.svelte index c082969..e4f308f 100644 --- a/web/src/routes/(dashboard)/transactions/+page.svelte +++ b/web/src/routes/(dashboard)/transactions/+page.svelte @@ -2,6 +2,7 @@ import { onMount } from 'svelte'; import { goto } from '$app/navigation'; import { page } from '$app/state'; + import { resolveProject, storeProject } from '$lib/project-selection'; import { api, type Project, type TransactionResponse } from '$lib/api'; import { Badge } from '$lib/components/ui/badge/index.js'; import { Button } from '$lib/components/ui/button/index.js'; @@ -73,6 +74,7 @@ function switchProject(slug: string) { selectedProject = slug; + storeProject(slug); currentPage = 1; navigate(); loadSlowest(); @@ -95,7 +97,7 @@ const sp = page.url.searchParams; const qp = sp.get('project'); - selectedProject = (qp && projects.some((p) => p.slug === qp)) ? qp : projects[0].slug; + selectedProject = resolveProject(projects, qp); currentPage = parseInt(sp.get('page') || '1', 10) || 1; await Promise.all([loadSlowest(), loadTransactions()]);