Skip to content

Commit fa55c9c

Browse files
committed
error display page + fixes
1 parent 5921f94 commit fa55c9c

File tree

5 files changed

+162
-23
lines changed

5 files changed

+162
-23
lines changed

frontend/src/App.svelte

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,22 @@
1818
import ToastContainer from "./components/ToastContainer.svelte";
1919
import ProtectedRoute from "./components/ProtectedRoute.svelte";
2020
import Spinner from "./components/Spinner.svelte";
21+
import ErrorDisplay from "./components/ErrorDisplay.svelte";
2122
import { theme } from './stores/theme';
2223
import { initializeAuth } from './lib/auth-init';
24+
import { appError } from './stores/errorStore';
2325
2426
// Theme value derived from store with proper cleanup
2527
let themeValue = $state('auto');
2628
const unsubscribeTheme = theme.subscribe(value => { themeValue = value; });
2729
30+
// Global error state
31+
let globalError = $state<{ error: Error | string; title?: string } | null>(null);
32+
const unsubscribeError = appError.subscribe(value => { globalError = value; });
33+
2834
onDestroy(() => {
2935
unsubscribeTheme();
36+
unsubscribeError();
3037
});
3138
3239
let authInitialized = $state(false);
@@ -186,7 +193,9 @@
186193
<AdminSettings/>
187194
{/snippet}
188195

189-
{#if !authInitialized}
196+
{#if globalError}
197+
<ErrorDisplay error={globalError.error} title={globalError.title} />
198+
{:else if !authInitialized}
190199
<div class="flex items-center justify-center min-h-screen bg-bg-default dark:bg-dark-bg-default">
191200
<Spinner size="large" />
192201
</div>
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<script lang="ts">
2+
let { error, title = 'Application Error', showDetails = true }: {
3+
error: Error | string;
4+
title?: string;
5+
showDetails?: boolean;
6+
} = $props();
7+
8+
const errorMessage = $derived(error instanceof Error ? error.message : String(error));
9+
const errorStack = $derived(error instanceof Error ? error.stack : null);
10+
11+
function reload() {
12+
window.location.reload();
13+
}
14+
15+
function goHome() {
16+
window.location.href = '/';
17+
}
18+
</script>
19+
20+
<div class="min-h-screen bg-bg-default dark:bg-dark-bg-default flex items-center justify-center p-4">
21+
<div class="max-w-lg w-full bg-bg-alt dark:bg-dark-bg-alt rounded-xl border border-red-200 dark:border-red-900/50 shadow-lg p-6 sm:p-8">
22+
<!-- Error Icon -->
23+
<div class="flex justify-center mb-6">
24+
<div class="h-16 w-16 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
25+
<svg class="h-8 w-8 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
26+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
27+
</svg>
28+
</div>
29+
</div>
30+
31+
<!-- Title -->
32+
<h1 class="text-xl sm:text-2xl font-bold text-center text-fg-default dark:text-dark-fg-default mb-2">
33+
{title}
34+
</h1>
35+
36+
<!-- Error Message -->
37+
<p class="text-center text-red-600 dark:text-red-400 mb-6">
38+
{errorMessage}
39+
</p>
40+
41+
<!-- Stack Trace (collapsible) -->
42+
{#if showDetails && errorStack}
43+
<details class="mb-6">
44+
<summary class="cursor-pointer text-sm text-fg-muted dark:text-dark-fg-muted hover:text-fg-default dark:hover:text-dark-fg-default transition-colors">
45+
Show technical details
46+
</summary>
47+
<pre class="mt-2 p-3 bg-code-bg rounded-lg text-xs text-gray-300 overflow-x-auto max-h-48 overflow-y-auto font-mono">{errorStack}</pre>
48+
</details>
49+
{/if}
50+
51+
<!-- Actions -->
52+
<div class="flex flex-col sm:flex-row gap-3">
53+
<button
54+
onclick={reload}
55+
class="flex-1 btn btn-primary py-2.5 text-sm font-medium"
56+
>
57+
Reload Page
58+
</button>
59+
<button
60+
onclick={goHome}
61+
class="flex-1 btn py-2.5 text-sm font-medium bg-bg-default dark:bg-dark-bg-default border border-border-default dark:border-dark-border-default text-fg-default dark:text-dark-fg-default hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
62+
>
63+
Go to Home
64+
</button>
65+
</div>
66+
67+
<!-- Help Text -->
68+
<p class="mt-6 text-xs text-center text-fg-muted dark:text-dark-fg-muted">
69+
If this problem persists, please check the browser console for more details.
70+
</p>
71+
</div>
72+
</div>

frontend/src/main.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { client } from './lib/api/client.gen';
22
import { mount } from 'svelte';
33
import App from './App.svelte';
4+
import ErrorDisplay from './components/ErrorDisplay.svelte';
5+
import { appError } from './stores/errorStore';
46
import './app.css';
57

68
// Configure the API client with credentials
@@ -10,8 +12,34 @@ client.setConfig({
1012
credentials: 'include',
1113
});
1214

13-
const app = mount(App, {
14-
target: document.body,
15-
});
15+
// Global error handlers to catch unhandled errors
16+
window.onerror = (message, source, lineno, colno, error) => {
17+
console.error('[Global Error]', { message, source, lineno, colno, error });
18+
appError.setError(error || String(message), 'Unexpected Error');
19+
return true; // Prevent default browser error handling
20+
};
21+
22+
window.onunhandledrejection = (event) => {
23+
console.error('[Unhandled Promise Rejection]', event.reason);
24+
appError.setError(event.reason, 'Unhandled Promise Error');
25+
};
26+
27+
// Mount the app with error handling
28+
let app;
29+
try {
30+
app = mount(App, {
31+
target: document.body,
32+
});
33+
} catch (error) {
34+
console.error('[Mount Error]', error);
35+
// If App fails to mount, show error display directly
36+
app = mount(ErrorDisplay, {
37+
target: document.body,
38+
props: {
39+
error: error instanceof Error ? error : new Error(String(error)),
40+
title: 'Failed to Load Application'
41+
}
42+
});
43+
}
1644

1745
export default app;

frontend/src/stores/__tests__/notificationStore.test.ts

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,17 @@ vi.mock('../../lib/api', () => ({
1414
deleteNotificationApiV1NotificationsNotificationIdDelete: (...args: unknown[]) => mockDeleteNotification(...args),
1515
}));
1616

17-
const createMockNotification = (overrides = {}) => ({
17+
const createMockNotification = (overrides: Record<string, unknown> = {}) => ({
1818
notification_id: `notif-${Math.random().toString(36).slice(2)}`,
19-
title: 'Test Notification',
20-
message: 'Test message',
21-
status: 'unread' as const,
19+
channel: 'in_app' as const,
20+
status: 'pending' as const,
21+
subject: 'Test Notification',
22+
body: 'Test message body',
23+
action_url: null,
2224
created_at: new Date().toISOString(),
23-
tags: [],
25+
read_at: null,
26+
severity: 'medium' as const,
27+
tags: [] as string[],
2428
...overrides,
2529
});
2630

@@ -74,8 +78,8 @@ describe('notificationStore', () => {
7478

7579
it('populates notifications on success', async () => {
7680
const notifications = [
77-
createMockNotification({ notification_id: 'n1', title: 'First' }),
78-
createMockNotification({ notification_id: 'n2', title: 'Second' }),
81+
createMockNotification({ notification_id: 'n1', subject: 'First' }),
82+
createMockNotification({ notification_id: 'n2', subject: 'Second' }),
7983
];
8084
mockGetNotifications.mockResolvedValue({
8185
data: { notifications },
@@ -86,7 +90,7 @@ describe('notificationStore', () => {
8690
await notificationStore.load();
8791

8892
expect(get(notificationStore).notifications).toHaveLength(2);
89-
expect(get(notificationStore).notifications[0].title).toBe('First');
93+
expect(get(notificationStore).notifications[0].subject).toBe('First');
9094
});
9195

9296
it('sets loading false after success', async () => {
@@ -186,7 +190,7 @@ describe('notificationStore', () => {
186190
const { notificationStore } = await import('../notificationStore');
187191
await notificationStore.load();
188192

189-
const newNotification = createMockNotification({ notification_id: 'new', title: 'New' });
193+
const newNotification = createMockNotification({ notification_id: 'new', subject: 'New' });
190194
notificationStore.add(newNotification);
191195

192196
const notifications = get(notificationStore).notifications;
@@ -218,7 +222,7 @@ describe('notificationStore', () => {
218222
mockGetNotifications.mockResolvedValue({
219223
data: {
220224
notifications: [
221-
createMockNotification({ notification_id: 'n1', status: 'unread' }),
225+
createMockNotification({ notification_id: 'n1', status: 'pending' }),
222226
],
223227
},
224228
error: null,
@@ -247,7 +251,7 @@ describe('notificationStore', () => {
247251
const result = await notificationStore.markAsRead('n1');
248252

249253
expect(result).toBe(false);
250-
expect(get(notificationStore).notifications[0].status).toBe('unread');
254+
expect(get(notificationStore).notifications[0].status).toBe('pending');
251255
});
252256

253257
it('calls API with correct notification ID', async () => {
@@ -267,8 +271,8 @@ describe('notificationStore', () => {
267271
mockGetNotifications.mockResolvedValue({
268272
data: {
269273
notifications: [
270-
createMockNotification({ notification_id: 'n1', status: 'unread' }),
271-
createMockNotification({ notification_id: 'n2', status: 'unread' }),
274+
createMockNotification({ notification_id: 'n1', status: 'pending' }),
275+
createMockNotification({ notification_id: 'n2', status: 'pending' }),
272276
],
273277
},
274278
error: null,
@@ -390,9 +394,9 @@ describe('notificationStore', () => {
390394
mockGetNotifications.mockResolvedValue({
391395
data: {
392396
notifications: [
393-
createMockNotification({ notification_id: 'n1', status: 'unread' }),
397+
createMockNotification({ notification_id: 'n1', status: 'pending' }),
394398
createMockNotification({ notification_id: 'n2', status: 'read' }),
395-
createMockNotification({ notification_id: 'n3', status: 'unread' }),
399+
createMockNotification({ notification_id: 'n3', status: 'pending' }),
396400
],
397401
},
398402
error: null,
@@ -424,7 +428,7 @@ describe('notificationStore', () => {
424428
mockGetNotifications.mockResolvedValue({
425429
data: {
426430
notifications: [
427-
createMockNotification({ notification_id: 'n1', status: 'unread' }),
431+
createMockNotification({ notification_id: 'n1', status: 'pending' }),
428432
],
429433
},
430434
error: null,
@@ -443,8 +447,8 @@ describe('notificationStore', () => {
443447
describe('notifications derived store', () => {
444448
it('exposes notifications array', async () => {
445449
const notifs = [
446-
createMockNotification({ notification_id: 'n1', title: 'First' }),
447-
createMockNotification({ notification_id: 'n2', title: 'Second' }),
450+
createMockNotification({ notification_id: 'n1', subject: 'First' }),
451+
createMockNotification({ notification_id: 'n2', subject: 'Second' }),
448452
];
449453
mockGetNotifications.mockResolvedValue({
450454
data: { notifications: notifs },
@@ -455,7 +459,7 @@ describe('notificationStore', () => {
455459
await notificationStore.load();
456460

457461
expect(get(notifications)).toHaveLength(2);
458-
expect(get(notifications)[0].title).toBe('First');
462+
expect(get(notifications)[0].subject).toBe('First');
459463
});
460464
});
461465
});

frontend/src/stores/errorStore.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { writable } from 'svelte/store';
2+
3+
export interface AppError {
4+
error: Error | string;
5+
title?: string;
6+
timestamp: number;
7+
}
8+
9+
function createErrorStore() {
10+
const { subscribe, set, update } = writable<AppError | null>(null);
11+
12+
return {
13+
subscribe,
14+
setError: (error: Error | string, title?: string) => {
15+
console.error('[ErrorStore]', title || 'Error:', error);
16+
set({
17+
error,
18+
title,
19+
timestamp: Date.now()
20+
});
21+
},
22+
clear: () => set(null)
23+
};
24+
}
25+
26+
export const appError = createErrorStore();

0 commit comments

Comments
 (0)