Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions crates/trapfalld/build.rs
Original file line number Diff line number Diff line change
@@ -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`."
);
}
}
45 changes: 45 additions & 0 deletions crates/trapfalld/src/spa.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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);
}
}
27 changes: 27 additions & 0 deletions web/src/lib/project-selection.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
34 changes: 34 additions & 0 deletions web/src/lib/project-selection.ts
Original file line number Diff line number Diff line change
@@ -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;
}
4 changes: 3 additions & 1 deletion web/src/routes/(dashboard)/issues/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -90,6 +91,7 @@

function switchProject(slug: string) {
selectedProject = slug;
storeProject(slug);
currentPage = 1;
navigate();
}
Expand Down Expand Up @@ -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') || '';
Expand Down
4 changes: 3 additions & 1 deletion web/src/routes/(dashboard)/release-health/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -120,6 +121,7 @@

function switchProject(slug: string) {
selectedProject = slug;
storeProject(slug);
currentPage = 1;
navigate();
loadData();
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 3 additions & 5 deletions web/src/routes/(dashboard)/rules/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand All @@ -67,6 +64,7 @@

function switchProject(slug: string) {
selectedProject = slug;
storeProject(slug);
goto(`/rules?project=${slug}`, { replaceState: true });
loadRules();
}
Expand Down
4 changes: 3 additions & 1 deletion web/src/routes/(dashboard)/transactions/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -73,6 +74,7 @@

function switchProject(slug: string) {
selectedProject = slug;
storeProject(slug);
currentPage = 1;
navigate();
loadSlowest();
Expand All @@ -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()]);
Expand Down
Loading