%sveltekit.status%
+%sveltekit.error.message%
+ +diff --git a/e2e/error-pages.spec.ts b/e2e/error-pages.spec.ts new file mode 100644 index 00000000..721096bb --- /dev/null +++ b/e2e/error-pages.spec.ts @@ -0,0 +1,55 @@ +import { test, expect } from './support/fixtures.js'; +import { waitForHydration } from './support/helpers'; + +test.describe('Error pages', () => { + test.use({ locale: 'en-US' }); + + test('an unknown page shows a readable 404 inside the app layout', async ({ page }) => { + const response = await page.goto('/does-not-exist'); + expect(response?.status()).toBe(404); + await waitForHydration(page); + + await expect(page).toHaveTitle('Page not found | Stackable'); + await expect(page.getByRole('heading', { level: 1 })).toHaveText('Page not found'); + const main = page.locator('main'); + await expect(main.getByRole('heading', { name: /Page not found/ })).toBeVisible(); + await expect(page.getByRole('navigation', { name: 'Main navigation' })).toBeVisible(); + + await main.getByRole('link', { name: 'Go to dashboard' }).click(); + await expect(page).toHaveURL('/'); + }); + + test('an unknown API path answers with a JSON error', async ({ page }) => { + const res = await page.request.get('/api/does-not-exist'); + + expect(res.status()).toBe(404); + expect(await res.json()).toEqual({ message: 'Not Found' }); + }); + + test.describe('signed out', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test('unknown pages still require sign-in', async ({ page }) => { + for (const path of ['/does-not-exist', '/auth/login/does-not-exist']) { + await page.goto(path); + await expect(page).toHaveURL(/\/auth\/login\?redirectTo=/); + } + }); + + test('only the exact public routes skip sign-in', async ({ page }) => { + for (const path of ['/metrics', '/healthz']) { + expect((await page.request.get(path)).status(), path).toBe(200); + } + const lookalike = await page.request.get('/metricsfoo', { maxRedirects: 0 }); + expect(lookalike.status()).toBe(302); + expect(lookalike.headers().location).toMatch(/^\/auth\/login\?redirectTo=/); + }); + + test('unknown API paths still answer with a JSON 401', async ({ page }) => { + const res = await page.request.get('/api/does-not-exist'); + + expect(res.status()).toBe(401); + expect(await res.json()).toEqual({ message: 'Authentication required' }); + }); + }); +}); diff --git a/e2e/storage/connection.spec.ts b/e2e/storage/connection.spec.ts index d0130613..0d1f8905 100644 --- a/e2e/storage/connection.spec.ts +++ b/e2e/storage/connection.spec.ts @@ -46,6 +46,23 @@ test.describe('Storage S3 — Connection', () => { await expect(page.getByText('Access denied — check your credentials.')).toBeVisible(); }); + test('explains why an active connection cannot be opened', async ({ page }) => { + const credentials = requireGarageCredentials(); + + await connectToStorage(page, credentials); + await expect(page.locator('main').getByRole('heading', { name: 'Buckets' })).toBeVisible(); + + await page.route('**/api/storage/buckets', (route) => + route.fulfill({ status: 502, json: { message: 'Storage error: unreachable' } }) + ); + await page.reload(); + + await expect(page.getByRole('heading', { name: 'Connect to storage' })).toBeVisible(); + await expect( + page.getByText('Could not connect to the selected storage — the endpoint may be unreachable.') + ).toBeVisible(); + }); + test('a cleared port field means the default port, not a validation error', async ({ page }) => { await openConnectForm(page); const port = page.getByLabel('Port'); diff --git a/e2e/storage/connections-management.spec.ts b/e2e/storage/connections-management.spec.ts index 4dea636c..e05a9686 100644 --- a/e2e/storage/connections-management.spec.ts +++ b/e2e/storage/connections-management.spec.ts @@ -325,6 +325,26 @@ test.describe('Storage — Connections management', () => { await waitForHydration(page); await expect(page).toHaveURL('/settings/connections'); }); + + test('shows a translated error when deleting a connection fails', async ({ page }) => { + await page.goto('/settings/connections'); + await waitForHydration(page); + + // The UI only submits valid IDs; post a malformed one directly. + await page.evaluate(() => { + const form = document.createElement('form'); + form.method = 'POST'; + form.action = '?/deleteConnection'; + const input = document.createElement('input'); + input.name = 'connectionId'; + input.value = 'not-a-uuid'; + form.append(input); + document.body.append(form); + form.submit(); + }); + + await expect(page.getByRole('alert')).toHaveText('Connection not found.'); + }); }); test.describe('Storage — Connection edit sidebar', () => { diff --git a/e2e/storage/details.spec.ts b/e2e/storage/details.spec.ts index 8e78b8fb..fcec5179 100644 --- a/e2e/storage/details.spec.ts +++ b/e2e/storage/details.spec.ts @@ -97,6 +97,33 @@ test.describe('Storage S3 — Details Modal', () => { } }); + test('explains a failed size calculation', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'details-size-error'); + const cleanupKeys = [`${prefix}subdir/file.txt`]; + + try { + await putTextObject(client, credentials.bucket, cleanupKeys[0], 'x'); + // A proxy in front of the app answers with its own HTML error page. + await page.route('**/api/storage/directory-size?*', (route) => + route.fulfill({ status: 502, contentType: 'text/html', body: 'Bad Gateway' }) + ); + + await connectAndOpenPrefix(page, credentials, prefix); + await rowByName(page, 'subdir').click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Details' }).click(); + + const modal = modalBox(page); + await modal.getByRole('button', { name: 'Calculate directory size' }).click(); + await expect( + modal.getByText('Failed to calculate the directory size. Please try again.') + ).toBeVisible(); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + test('shows partial results when the key limit is reached', async ({ page }, testInfo) => { const credentials = requireGarageCredentials(); const client = createS3Client(credentials); diff --git a/e2e/trino/catalog-browser.spec.ts b/e2e/trino/catalog-browser.spec.ts index 75ce3581..cf662aed 100644 --- a/e2e/trino/catalog-browser.spec.ts +++ b/e2e/trino/catalog-browser.spec.ts @@ -24,6 +24,27 @@ test.describe('Catalog browser', () => { await expect(browser.getByRole('button', { name: 'system' })).toBeVisible(); }); + test('shows a readable message when loading catalogues fails', async ({ page }) => { + await ensureCatalogBrowserOpen(page); + const browser = page.getByRole('navigation', { name: 'Catalog browser' }); + await expect(browser.getByRole('button', { name: 'tpch' })).toBeVisible(); + + await page.route('**/api/trino/catalog?*', (route) => + route.fulfill({ status: 502, json: { message: 'Trino is not reachable' } }) + ); + await page.getByRole('button', { name: 'Refresh catalog' }).click(); + + await expect(browser.getByText('Failed to load catalog')).toBeVisible(); + await expect(browser.getByText('Trino is not reachable')).toBeVisible(); + await expect(browser.getByText('{')).toHaveCount(0); + + await page.unroute('**/api/trino/catalog?*'); + await page.route('**/api/trino/catalog?*', (route) => route.abort()); + await page.getByRole('button', { name: 'Refresh catalog' }).click(); + + await expect(browser.getByText('The server could not be reached.')).toBeVisible(); + }); + test('catalog browser can be toggled', async ({ page }) => { await ensureCatalogBrowserOpen(page); diff --git a/e2e/trino/query-api.spec.ts b/e2e/trino/query-api.spec.ts index 77878ad0..29babe63 100644 --- a/e2e/trino/query-api.spec.ts +++ b/e2e/trino/query-api.spec.ts @@ -81,7 +81,7 @@ test.describe('Trino API', () => { }); expect(res.status()).toBe(400); - expect(await res.json()).toEqual({ error: expect.any(String) }); + expect(await res.json()).toEqual({ message: expect.any(String) }); }); test('cancelling between statements stops the script', async ({ page }) => { diff --git a/e2e/trino/trino.spec.ts b/e2e/trino/trino.spec.ts index 9ab93760..0f7b2ea1 100644 --- a/e2e/trino/trino.spec.ts +++ b/e2e/trino/trino.spec.ts @@ -142,7 +142,7 @@ test.describe('Trino query editor', () => { // Force the submit endpoint to fail with a server-provided reason. await page.route('**/api/trino/query', async (route, request) => { if (request.method() === 'POST') { - await route.fulfill({ status: 400, json: { error: 'Could not reach Trino' } }); + await route.fulfill({ status: 400, json: { message: 'Could not reach Trino' } }); } else { await route.continue(); } diff --git a/messages/de.json b/messages/de.json index 3c0979e1..232ef271 100644 --- a/messages/de.json +++ b/messages/de.json @@ -19,6 +19,17 @@ "sidebar_favourites": "Favoriten", "sidebar_tools": "Werkzeuge", "skip_to_content": "Zum Hauptinhalt springen", + "error_status": "Fehler {status}", + "error_not_found_title": "Seite nicht gefunden", + "error_not_found_desc": "Die gesuchte Seite existiert nicht oder wurde verschoben.", + "error_forbidden_title": "Zugriff verweigert", + "error_forbidden_desc": "Sie haben keine Berechtigung, diese Seite anzuzeigen.", + "error_generic_title": "Etwas ist schiefgelaufen", + "error_generic_desc": "Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.", + "error_request_id": "Anfrage-ID:", + "error_back_to_dashboard": "Zum Dashboard", + "error_retry": "Erneut versuchen", + "error_server_unreachable": "Der Server ist nicht erreichbar. Prüfen Sie Ihre Netzwerkverbindung und versuchen Sie es erneut.", "header_open_nav": "Navigation öffnen", "header_close_nav": "Navigation schließen", "header_user_menu": "Benutzermenü", @@ -168,6 +179,7 @@ "storage_connect_cancel": "Abbrechen", "storage_connect_error": "Verbindung fehlgeschlagen — Endpunkt und Zugangsdaten prüfen.", "storage_connection_switch_failed": "Wechsel zur ausgewählten Verbindung nicht möglich. Bitte erneut versuchen.", + "storage_connect_error_unreachable": "Verbindung zum ausgewählten Speicher nicht möglich — der Endpunkt ist möglicherweise nicht erreichbar.", "storage_connect_error_access_denied": "Zugriff verweigert — Zugangsdaten prüfen.", "storage_connect_error_not_found": "Speicher-Backend nicht gefunden — Endpunkt prüfen.", "storage_connect_error_server_error": "Das Speicher-Backend hat einen Fehler gemeldet — Endpunkt und Zugangsdaten prüfen.", @@ -639,7 +651,7 @@ "storage_details_error_fetch_file": "Dateidetails konnten nicht abgerufen werden ({status})", "storage_details_error_fetch_bucket": "Bucket-Details konnten nicht abgerufen werden ({status})", "storage_details_error_fetch_dir_meta": "Verzeichnismetadaten konnten nicht abgerufen werden ({status})", - "storage_details_error_calc_size": "Verzeichnisgröße konnte nicht berechnet werden ({status})", + "storage_details_error_calc_size": "Die Verzeichnisgröße konnte nicht berechnet werden. Bitte versuchen Sie es erneut.", "storage_details_error_unknown": "Unbekannter Fehler", "storage_details_error_no_body": "Kein Antworttext", "storage_details_unnamed_rule": "(unbenannt)", diff --git a/messages/en.json b/messages/en.json index 3392087d..00d6bc5d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -19,6 +19,17 @@ "sidebar_favourites": "Favourites", "sidebar_tools": "Tools", "skip_to_content": "Skip to main content", + "error_status": "Error {status}", + "error_not_found_title": "Page not found", + "error_not_found_desc": "The page you are looking for does not exist or has been moved.", + "error_forbidden_title": "Access denied", + "error_forbidden_desc": "You do not have permission to view this page.", + "error_generic_title": "Something went wrong", + "error_generic_desc": "An unexpected error occurred. Please try again later.", + "error_request_id": "Request ID:", + "error_back_to_dashboard": "Go to dashboard", + "error_retry": "Try again", + "error_server_unreachable": "The server could not be reached. Check your network connection and try again.", "header_open_nav": "Open navigation", "header_close_nav": "Close navigation", "header_user_menu": "User menu", @@ -168,6 +179,7 @@ "storage_connect_cancel": "Cancel", "storage_connect_error": "Could not connect — check the endpoint and credentials.", "storage_connection_switch_failed": "Could not switch to the selected connection. Please try again.", + "storage_connect_error_unreachable": "Could not connect to the selected storage — the endpoint may be unreachable.", "storage_connect_error_access_denied": "Access denied — check your credentials.", "storage_connect_error_not_found": "Could not find the storage backend — check the endpoint.", "storage_connect_error_server_error": "The storage backend returned an error — check the endpoint and credentials.", @@ -639,7 +651,7 @@ "storage_details_error_fetch_file": "Failed to fetch file details ({status})", "storage_details_error_fetch_bucket": "Failed to fetch bucket details ({status})", "storage_details_error_fetch_dir_meta": "Failed to fetch directory metadata ({status})", - "storage_details_error_calc_size": "Failed to calculate directory size ({status})", + "storage_details_error_calc_size": "Failed to calculate the directory size. Please try again.", "storage_details_error_unknown": "Unknown error", "storage_details_error_no_body": "No response body", "storage_details_unnamed_rule": "(unnamed)", diff --git a/src/error.html b/src/error.html new file mode 100644 index 00000000..2142fc0b --- /dev/null +++ b/src/error.html @@ -0,0 +1,31 @@ + + +
+ + +%sveltekit.error.message%
+ +{description ?? defaultDescription}
+ {#if requestId} +
+ {m.error_request_id()} {requestId}
+
as fallback.
@@ -14,21 +15,20 @@ vi.mock('$app/paths', () => ({
}));
// Mock storage context — provide an API that delegates to the global fetch
-vi.mock('$lib/storage/context.js', () => ({
- getStorageState: () => ({
- get api() {
- return {
- preview: (...args: unknown[]) =>
- (globalThis.fetch as typeof fetch)(...(args as Parameters)),
- archiveExtract: (...args: unknown[]) =>
- (globalThis.fetch as typeof fetch)(...(args as Parameters)),
- saveText: (...args: unknown[]) =>
- (globalThis.fetch as typeof fetch)(...(args as Parameters))
- };
- },
- bucket: 'test-bucket'
- })
-}));
+// Like the real API, non-OK responses throw a StorageError.
+vi.mock('$lib/storage/context.js', async () => {
+ const { createStorageFetch } = await import('$lib/storage/storage-fetch.js');
+ const storageFetch = createStorageFetch(() => 'conn-1');
+ const call = (...args: unknown[]) => storageFetch(...(args as Parameters));
+ return {
+ getStorageState: () => ({
+ get api() {
+ return { preview: call, archiveExtract: call, saveText: call, download: call };
+ },
+ bucket: 'test-bucket'
+ })
+ };
+});
const defaultProps = {
open: true,
@@ -716,42 +716,32 @@ describe('PreviewModal basics', () => {
await expect.element(page.getByRole('alert')).toBeInTheDocument();
});
- it('should show access denied for 403', async () => {
- vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}', { status: 403 })));
- render(PreviewModal, defaultProps);
-
- await expect.element(page.getByRole('alert')).toBeInTheDocument();
- });
-
- it('should show not found for 404', async () => {
- vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}', { status: 404 })));
+ it.each([
+ [403, '{}', 'You do not have permission to preview this file.'],
+ [404, '{}', 'The file could not be found.'],
+ [500, JSON.stringify({ message: 'Storage error: SlowDown' }), 'An error occurred']
+ ])('should show a readable message for a %i response', async (status, body, text) => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(body, { status })));
render(PreviewModal, defaultProps);
- await expect.element(page.getByRole('alert')).toBeInTheDocument();
+ await expect.element(page.getByRole('alert')).toHaveTextContent(text);
});
- it('should show generic error for other status codes', async () => {
+ it('should report a failed full download with a translated message', async () => {
vi.stubGlobal(
'fetch',
vi
.fn()
- .mockResolvedValue(
- new Response(JSON.stringify({ error: 'Server is down' }), { status: 500 })
- )
+ .mockResolvedValueOnce(mockFetchResponse('Hello', { truncated: true, totalSize: 99 }))
+ .mockResolvedValueOnce(new Response('{"message":"Access Denied"}', { status: 403 }))
);
render(PreviewModal, defaultProps);
- await expect.element(page.getByRole('alert')).toBeInTheDocument();
- });
-
- it('should handle non-JSON error response body', async () => {
- vi.stubGlobal(
- 'fetch',
- vi.fn().mockResolvedValue(new Response('Not JSON at all', { status: 500 }))
- );
- render(PreviewModal, defaultProps);
+ await page.getByRole('button', { name: 'Download full file' }).click();
- await expect.element(page.getByRole('alert')).toBeInTheDocument();
+ await expect
+ .poll(() => toasts.map((t) => t.message))
+ .toContain('Access denied. You do not have permission to download this file.');
});
});
diff --git a/src/lib/components/storage/modals/details/DirectoryDetails.svelte b/src/lib/components/storage/modals/details/DirectoryDetails.svelte
index b2f8e7ef..cad14e71 100644
--- a/src/lib/components/storage/modals/details/DirectoryDetails.svelte
+++ b/src/lib/components/storage/modals/details/DirectoryDetails.svelte
@@ -78,12 +78,6 @@
try {
const res = await storage.api.directorySize({ bucket, prefix, signal: request.signal });
- if (!res.ok) {
- error = m.storage_details_error_calc_size({ status: res.status });
- calculating = false;
- return;
- }
-
const reader = res.body?.getReader();
if (!reader) {
error = m.storage_details_error_no_body();
@@ -118,13 +112,9 @@
}
}
}
- } catch (err) {
+ } catch {
if (request.signal.aborted) return;
- if (err instanceof StorageError) {
- error = err.message;
- } else {
- error = err instanceof Error ? err.message : m.storage_details_error_unknown();
- }
+ error = m.storage_details_error_calc_size();
} finally {
if (sizeRequest === request) {
sizeRequest = null;
diff --git a/src/lib/components/storage/shared/StorageErrorPanel.svelte b/src/lib/components/storage/shared/StorageErrorPanel.svelte
deleted file mode 100644
index 17f27f10..00000000
--- a/src/lib/components/storage/shared/StorageErrorPanel.svelte
+++ /dev/null
@@ -1,57 +0,0 @@
-
-
-
-
-
-
-
-
- {m.storage_error_title()}
-
-
- {status}
-
-
-
- {message}
-
-
-
-
-
diff --git a/src/lib/components/storage/shared/StorageErrorPanel.svelte.spec.ts b/src/lib/components/storage/shared/StorageErrorPanel.svelte.spec.ts
deleted file mode 100644
index f96158c5..00000000
--- a/src/lib/components/storage/shared/StorageErrorPanel.svelte.spec.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { page } from 'vitest/browser';
-import { describe, expect, it } from 'vitest';
-import { render } from 'vitest-browser-svelte';
-import StorageErrorPanel from './StorageErrorPanel.svelte';
-
-describe('StorageErrorPanel', () => {
- it('should render the status code as the heading', async () => {
- render(StorageErrorPanel, { status: 403, message: 'Access denied' });
-
- await expect.element(page.getByRole('heading', { name: '403' })).toBeInTheDocument();
- });
-
- it('should render the supplied message', async () => {
- render(StorageErrorPanel, { status: 404, message: 'No such bucket: reports' });
-
- await expect.element(page.getByText('No such bucket: reports')).toBeInTheDocument();
- });
-
- it('should tolerate an empty message', async () => {
- render(StorageErrorPanel, { status: 500, message: '' });
-
- await expect.element(page.getByRole('heading', { name: '500' })).toBeInTheDocument();
- });
-
- it('should offer a link back to storage', async () => {
- render(StorageErrorPanel, { status: 403, message: 'Access denied' });
-
- const link = page.getByRole('link', { name: /back to storage/i });
- await expect.element(link).toHaveAttribute('href', '/storage');
- });
-
- it('should offer a go-back button', async () => {
- render(StorageErrorPanel, { status: 403, message: 'Access denied' });
-
- await expect.element(page.getByRole('button', { name: /go back/i })).toBeInTheDocument();
- });
-});
diff --git a/src/lib/storage/api.spec.ts b/src/lib/storage/api.spec.ts
index d4b99387..fc4fe445 100644
--- a/src/lib/storage/api.spec.ts
+++ b/src/lib/storage/api.spec.ts
@@ -403,7 +403,7 @@ describe('createFetchStorageApi', () => {
const api = createFetchStorageApi(() => 'conn-1');
vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(jsonResponse({ status: 'done', items: [] }))
- .mockResolvedValueOnce(jsonResponse({ status: 'not_found' }, { status: 404 }));
+ .mockResolvedValueOnce(jsonResponse({ message: 'Job not found' }, { status: 404 }));
expect(await api.readTransferJob('job-1')).toEqual({ status: 'done', items: [] });
expect(await api.readTransferJob('job-2')).toEqual({ status: 'not_found' });
@@ -430,7 +430,7 @@ describe('createFetchStorageApi', () => {
it('reports a missing job as not_found instead of throwing', async () => {
const api = createFetchStorageApi(() => 'conn-1');
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
- jsonResponse({ status: 'not_found' }, { status: 404 })
+ jsonResponse({ message: 'Job not found' }, { status: 404 })
);
await expect(api.pollJob('job-abc')).resolves.toEqual({ status: 'not_found' });
diff --git a/src/lib/storage/storage-fetch.ts b/src/lib/storage/storage-fetch.ts
index 15b49754..195d7cd8 100644
--- a/src/lib/storage/storage-fetch.ts
+++ b/src/lib/storage/storage-fetch.ts
@@ -12,6 +12,7 @@
* const fetch_ = createStorageFetch(() => connectionStore.activeConnectionId);
*/
+import { readErrorMessage } from '$lib/client/api-error.js';
import { STORAGE_CONNECTION_ID_HEADER } from '$lib/storage/connection-id-header.js';
import { StorageError } from '$lib/storage/errors.js';
@@ -55,16 +56,8 @@ export function createStorageFetch(
if (!response.ok) {
const code = mapStatusToCode(response.status);
- const body = await response
- .clone()
- .json()
- .catch(() => null);
const message =
- body && typeof body === 'object' && 'message' in body && typeof body.message === 'string'
- ? body.message
- : body && typeof body === 'object' && 'error' in body && typeof body.error === 'string'
- ? body.error
- : `Request failed with status ${response.status}`;
+ (await readErrorMessage(response)) ?? `Request failed with status ${response.status}`;
throw new StorageError(code, message);
}
diff --git a/src/routes/(app)/+error.svelte b/src/routes/(app)/+error.svelte
new file mode 100644
index 00000000..133e7c9c
--- /dev/null
+++ b/src/routes/(app)/+error.svelte
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte
index 662d6665..4543e18e 100644
--- a/src/routes/(app)/+layout.svelte
+++ b/src/routes/(app)/+layout.svelte
@@ -31,12 +31,16 @@
'/trino': m.page_title_trino
};
- let title = $derived(
- (
+ function pageTitle(): () => string {
+ // Unknown pages render the 404 of the catch-all route.
+ if (page.route.id === '/(app)/[...path]') return m.error_not_found_title;
+ return (
pageTitles[page.url.pathname] ??
(page.url.pathname.startsWith('/storage') ? m.page_title_storage : m.page_title_default)
- )()
- );
+ );
+ }
+
+ let title = $derived(pageTitle()());
let mainEl: HTMLElement | undefined = $state();
diff --git a/src/routes/(app)/[...path]/+page.ts b/src/routes/(app)/[...path]/+page.ts
new file mode 100644
index 00000000..f1cdb4b0
--- /dev/null
+++ b/src/routes/(app)/[...path]/+page.ts
@@ -0,0 +1,8 @@
+import { error } from '@sveltejs/kit';
+import type { PageLoad } from './$types';
+
+// Unknown pages render inside the app layout (with navigation) instead of the
+// bare root error page. The client router now resolves every unknown path to
+// this page, so plain links to `+server.ts` endpoints need `download`,
+// `data-sveltekit-reload` or `rel="external"`.
+export const load: PageLoad = () => error(404, 'Not Found');
diff --git a/src/routes/(app)/api/[...path]/+server.ts b/src/routes/(app)/api/[...path]/+server.ts
new file mode 100644
index 00000000..635ec24b
--- /dev/null
+++ b/src/routes/(app)/api/[...path]/+server.ts
@@ -0,0 +1,6 @@
+import { error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+
+// Without this, unknown API paths fall through to the page catch-all and get
+// an HTML error page instead of a JSON body.
+export const fallback: RequestHandler = () => error(404, 'Not Found');
diff --git a/src/routes/(app)/api/storage/connections/+server.ts b/src/routes/(app)/api/storage/connections/+server.ts
index 8cacb054..dfa3449e 100644
--- a/src/routes/(app)/api/storage/connections/+server.ts
+++ b/src/routes/(app)/api/storage/connections/+server.ts
@@ -1,5 +1,5 @@
import { desc, eq, and } from 'drizzle-orm';
-import { json } from '@sveltejs/kit';
+import { error, json } from '@sveltejs/kit';
import { db } from '$lib/server/db.js';
import { userStorageConnections } from '$lib/server/schema.js';
import { STORAGE_CONNECTION_ID_HEADER } from '$lib/storage/connection-id-header.js';
@@ -72,12 +72,12 @@ export const PATCH: RequestHandler = async ({ request, locals }) => {
const userId = locals.user!.id;
const connectionId = request.headers.get(STORAGE_CONNECTION_ID_HEADER);
if (!connectionId) {
- return json({ error: 'Missing storage connection ID' }, { status: 400 });
+ error(400, 'Missing storage connection ID');
}
const body = AddConnectionBucketBodySchema.safeParse(await request.json().catch(() => null));
if (!body.success) {
- return json({ error: 'Bucket name is required' }, { status: 400 });
+ error(400, 'Bucket name is required');
}
const { bucket } = body.data;
@@ -93,7 +93,7 @@ export const PATCH: RequestHandler = async ({ request, locals }) => {
.limit(1);
if (rows.length === 0) {
- return json({ error: 'Connection not found' }, { status: 404 });
+ error(404, 'Connection not found');
}
const existing = (rows[0].additionalBuckets as string[]) ?? [];
diff --git a/src/routes/(app)/api/storage/copy/job/[jobId]/+server.ts b/src/routes/(app)/api/storage/copy/job/[jobId]/+server.ts
index 0bdd2a39..30d6dd69 100644
--- a/src/routes/(app)/api/storage/copy/job/[jobId]/+server.ts
+++ b/src/routes/(app)/api/storage/copy/job/[jobId]/+server.ts
@@ -7,15 +7,13 @@ export const GET: RequestHandler = async ({ params, locals }) => {
const { jobId } = params;
if (!z.uuid().safeParse(jobId).success) throw error(400, 'Invalid job ID');
const job = getJob(jobId, locals.user?.id ?? 'anonymous');
- if (!job) {
- return json({ status: 'not_found' }, { status: 404 });
- }
+ if (!job) throw error(404, 'Job not found');
return json(job);
};
export const DELETE: RequestHandler = async ({ params, locals }) => {
if (!z.uuid().safeParse(params.jobId).success) throw error(400, 'Invalid job ID');
if (!(await cancelJob(params.jobId, locals.user?.id ?? 'anonymous')))
- return json({ status: 'not_found' }, { status: 404 });
+ throw error(404, 'Job not found');
return json({ status: 'cancelled' });
};
diff --git a/src/routes/(app)/api/storage/directory-size/+server.ts b/src/routes/(app)/api/storage/directory-size/+server.ts
index c8959249..c328bc4f 100644
--- a/src/routes/(app)/api/storage/directory-size/+server.ts
+++ b/src/routes/(app)/api/storage/directory-size/+server.ts
@@ -1,4 +1,4 @@
-import { error } from '@sveltejs/kit';
+import { error, isHttpError } from '@sveltejs/kit';
import type { DirectorySizeEvent } from '$lib/storage/details-types.js';
import {
DIRECTORY_SIZE_MAX_KEYS,
@@ -64,7 +64,13 @@ export const GET: RequestHandler = async (event) => {
}
storageDirectorySizeCalculationsTotal.inc({ outcome: 'error' });
log.warn({ err, bucket, prefix }, 'directory size calculation failed');
- send({ type: 'error', message: err instanceof Error ? err.message : 'Unknown error' });
+ // SvelteKit's HttpError (e.g. a mapped S3 403) is not an Error instance.
+ const message = isHttpError(err)
+ ? err.body.message
+ : err instanceof Error
+ ? err.message
+ : 'Unknown error';
+ send({ type: 'error', message });
controller.close();
return;
}
diff --git a/src/routes/(app)/api/storage/directory-size/server.test.ts b/src/routes/(app)/api/storage/directory-size/server.test.ts
index d0ab2167..a725303e 100644
--- a/src/routes/(app)/api/storage/directory-size/server.test.ts
+++ b/src/routes/(app)/api/storage/directory-size/server.test.ts
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { error } from '@sveltejs/kit';
import type { DirectorySizeEvent } from '$lib/storage/details-types.js';
import type { ProgressiveListOptions } from '$lib/server/storage/provider.js';
@@ -66,6 +67,19 @@ describe('GET /api/storage/directory-size', () => {
expect(events).toEqual([{ type: 'error', message: 'Access Denied' }]);
});
+ it('sends the message of a mapped storage error', async () => {
+ let httpError: unknown;
+ try {
+ error(403, 'Access denied to bucket');
+ } catch (err) {
+ httpError = err;
+ }
+ mockProvider.listAllKeysProgressively.mockRejectedValue(httpError);
+
+ const events = await readEvents(await GET(mockEvent('p/')));
+ expect(events).toEqual([{ type: 'error', message: 'Access denied to bucket' }]);
+ });
+
it('aborts the listing when the client goes away', async () => {
let listingSignal: AbortSignal | undefined;
mockProvider.listAllKeysProgressively.mockImplementation(
diff --git a/src/routes/(app)/api/trino/query/+server.ts b/src/routes/(app)/api/trino/query/+server.ts
index e726f836..187ca5fa 100644
--- a/src/routes/(app)/api/trino/query/+server.ts
+++ b/src/routes/(app)/api/trino/query/+server.ts
@@ -1,4 +1,4 @@
-import { json } from '@sveltejs/kit';
+import { error, json } from '@sveltejs/kit';
import { z } from 'zod';
import { getUserId } from '$lib/server/auth-utils.js';
import {
@@ -18,19 +18,19 @@ export const POST: RequestHandler = async ({ request, locals }) => {
const client = resolveTrinoClient(userId);
if (!client) {
- return json({ error: 'No Trino connection configured' }, { status: 400 });
+ error(400, 'No Trino connection configured');
}
let body: unknown;
try {
body = await request.json();
} catch {
- return json({ error: 'Invalid JSON body' }, { status: 400 });
+ error(400, 'Invalid JSON body');
}
const parsed = StatementRequestSchema.safeParse(body);
if (!parsed.success) {
- return json({ error: z.prettifyError(parsed.error) }, { status: 400 });
+ error(400, z.prettifyError(parsed.error));
}
const user = locals.user?.username ?? 'anonymous';
@@ -53,7 +53,7 @@ export const GET: RequestHandler = async ({ url, locals }) => {
const userId = getUserId(locals);
const parsed = TabIdSchema.safeParse(url.searchParams.get('tabId'));
if (!parsed.success) {
- return json({ error: 'Missing or invalid tabId parameter' }, { status: 400 });
+ error(400, 'Missing or invalid tabId parameter');
}
// Lightweight mode omits rows/columns for completed queries to keep polling payloads small.
@@ -69,7 +69,7 @@ export const DELETE: RequestHandler = async ({ url, locals }) => {
const userId = getUserId(locals);
const parsed = TabIdSchema.safeParse(url.searchParams.get('tabId'));
if (!parsed.success) {
- return json({ error: 'Missing or invalid tabId parameter' }, { status: 400 });
+ error(400, 'Missing or invalid tabId parameter');
}
const tabId = parsed.data;
diff --git a/src/routes/(app)/api/trino/query/server.test.ts b/src/routes/(app)/api/trino/query/server.test.ts
index e60a3160..17ad92e3 100644
--- a/src/routes/(app)/api/trino/query/server.test.ts
+++ b/src/routes/(app)/api/trino/query/server.test.ts
@@ -56,18 +56,17 @@ describe('POST /api/trino/query', () => {
it('returns 400 when no Trino connection is configured', async () => {
vi.mocked(resolveTrinoClient).mockReturnValue(null);
- const res = await POST(postEvent({ statements: ['SELECT 1'], tabId: TAB_ID }));
-
- expect(res.status).toBe(400);
- expect(await res.json()).toEqual({ error: 'No Trino connection configured' });
+ await expect(
+ POST(postEvent({ statements: ['SELECT 1'], tabId: TAB_ID }))
+ ).rejects.toMatchObject({ status: 400, body: { message: 'No Trino connection configured' } });
expect(startScript).not.toHaveBeenCalled();
});
it('returns 400 for a body that is not valid JSON', async () => {
- const res = await POST(postEvent('{not json'));
-
- expect(res.status).toBe(400);
- expect(await res.json()).toEqual({ error: 'Invalid JSON body' });
+ await expect(POST(postEvent('{not json'))).rejects.toMatchObject({
+ status: 400,
+ body: { message: 'Invalid JSON body' }
+ });
});
it.each([
@@ -76,10 +75,10 @@ describe('POST /api/trino/query', () => {
['a non-UUID tabId', { statements: ['SELECT 1'], tabId: 'tab-1' }],
['a missing tabId', { statements: ['SELECT 1'] }]
])('returns 400 for %s', async (_label, body) => {
- const res = await POST(postEvent(body));
-
- expect(res.status).toBe(400);
- expect((await res.json()).error).toEqual(expect.any(String));
+ await expect(POST(postEvent(body))).rejects.toMatchObject({
+ status: 400,
+ body: { message: expect.any(String) }
+ });
expect(startScript).not.toHaveBeenCalled();
});
@@ -142,9 +141,7 @@ describe('GET /api/trino/query', () => {
['missing', ''],
['invalid', 'tabId=not-a-uuid']
])('returns 400 when tabId is %s', async (_label, params) => {
- const res = await GET(urlEvent(params));
-
- expect(res.status).toBe(400);
+ await expect(GET(urlEvent(params))).rejects.toMatchObject({ status: 400 });
expect(getQuerySnapshots).not.toHaveBeenCalled();
});
@@ -173,9 +170,7 @@ describe('DELETE /api/trino/query', () => {
});
it('returns 400 when tabId is invalid', async () => {
- const res = await DELETE(urlEvent('tabId=nope'));
-
- expect(res.status).toBe(400);
+ await expect(DELETE(urlEvent('tabId=nope'))).rejects.toMatchObject({ status: 400 });
expect(cancelQuery).not.toHaveBeenCalled();
});
diff --git a/src/routes/(app)/storage/+error.svelte b/src/routes/(app)/storage/+error.svelte
index 04bbf460..59322814 100644
--- a/src/routes/(app)/storage/+error.svelte
+++ b/src/routes/(app)/storage/+error.svelte
@@ -4,7 +4,7 @@
import IconStorage from 'virtual:icons/material-symbols/storage';
import IconChevronRight from 'virtual:icons/material-symbols/chevron-right';
import * as m from '$lib/paraglide/messages.js';
- import StorageErrorPanel from '$lib/components/storage/shared/StorageErrorPanel.svelte';
+ import ErrorPanel from '$lib/components/ErrorPanel.svelte';
function safeDecode(value: string): string {
try {
@@ -74,5 +74,18 @@
{/if}
-
+
+ {#snippet actions()}
+
+ {m.storage_error_back_to_storage()}
+
+
+ {/snippet}
+
diff --git a/src/routes/(app)/storage/+layout.ts b/src/routes/(app)/storage/+layout.ts
index c82d37b4..22e81c7a 100644
--- a/src/routes/(app)/storage/+layout.ts
+++ b/src/routes/(app)/storage/+layout.ts
@@ -3,10 +3,20 @@ import { STORAGE_CONNECTION_ID_HEADER } from '$lib/storage/connection-id-header.
import { connectionStore } from '$lib/storage/connection-store.svelte.js';
import type { LayoutLoad } from './$types';
+/** Why the active connection could not be opened; the connect form shows it. */
+type BucketsError = 'access_denied' | 'unreachable' | null;
+
+// 401: the session holds no usable connection, which is not an error.
+function bucketsErrorFor(status: number): BucketsError {
+ if (status === 401) return null;
+ return status === 403 ? 'access_denied' : 'unreachable';
+}
+
const DISCONNECTED = {
connected: false,
buckets: [] as string[],
- connectionType: null
+ connectionType: null,
+ bucketsError: null as BucketsError
};
/**
@@ -65,6 +75,7 @@ export const load: LayoutLoad = async ({ fetch, data }) => {
if (!res.ok) {
return {
...DISCONNECTED,
+ bucketsError: bucketsErrorFor(res.status),
connections,
hydrating: false as const,
hasActiveConnection: false as const
@@ -76,6 +87,7 @@ export const load: LayoutLoad = async ({ fetch, data }) => {
connected: true,
buckets,
connectionType: 's3',
+ bucketsError: null,
connections,
hydrating: false as const,
hasActiveConnection: true as const
@@ -83,6 +95,7 @@ export const load: LayoutLoad = async ({ fetch, data }) => {
} catch {
return {
...DISCONNECTED,
+ bucketsError: 'unreachable' as const,
connections,
hydrating: false as const,
hasActiveConnection: false as const
diff --git a/src/routes/(app)/storage/+page.svelte b/src/routes/(app)/storage/+page.svelte
index d27b19e8..90e1b474 100644
--- a/src/routes/(app)/storage/+page.svelte
+++ b/src/routes/(app)/storage/+page.svelte
@@ -25,7 +25,12 @@
let addBucketOpen = $state(false);
const connectError = $derived(
- (page.form as { error?: string } | null)?.error ?? null
+ (page.form as { error?: string } | null)?.error ??
+ (data.bucketsError === 'access_denied'
+ ? m.storage_connect_error_access_denied()
+ : data.bucketsError === 'unreachable'
+ ? m.storage_connect_error_unreachable()
+ : null)
);
let savedTabs = $state(null);
diff --git a/src/routes/(app)/storage/error.svelte.spec.ts b/src/routes/(app)/storage/error.svelte.spec.ts
index db5e9838..098c43e8 100644
--- a/src/routes/(app)/storage/error.svelte.spec.ts
+++ b/src/routes/(app)/storage/error.svelte.spec.ts
@@ -89,6 +89,20 @@ describe('/(app)/storage/+error.svelte', () => {
.not.toBeInTheDocument();
});
+ it('offers storage-specific ways out instead of the dashboard link', async () => {
+ setPage({ pathname: '/storage', status: 502 });
+ render(ErrorPage);
+
+ await expect.element(page.getByRole('heading', { name: /Storage error/ })).toBeInTheDocument();
+ await expect
+ .element(page.getByRole('link', { name: 'Back to storage' }))
+ .toHaveAttribute('href', '/storage');
+ await expect.element(page.getByRole('button', { name: 'Go back' })).toBeInTheDocument();
+ await expect
+ .element(page.getByRole('link', { name: 'Go to dashboard' }))
+ .not.toBeInTheDocument();
+ });
+
it('does not throw on malformed percent-encoding in the URL', async () => {
setPage({ pathname: '/storage/browse/conn-1/bad%E0bucket', params: {}, status: 404 });
render(ErrorPage);
diff --git a/src/routes/(app)/trino/query-runner.svelte.ts b/src/routes/(app)/trino/query-runner.svelte.ts
index 7c485d56..9f054a15 100644
--- a/src/routes/(app)/trino/query-runner.svelte.ts
+++ b/src/routes/(app)/trino/query-runner.svelte.ts
@@ -9,6 +9,7 @@ import {
import type { SqlStatement } from '$lib/editor/split-statements.js';
import { clearCompletionCache } from '$lib/editor/completion/completion-metadata.js';
import * as m from '$lib/paraglide/messages.js';
+import { readErrorMessage } from '$lib/client/api-error.js';
export interface QueryRunner {
readonly state: QueryState;
@@ -202,8 +203,7 @@ function createQueryRunner(tabId: string): QueryRunner {
});
if (!res.ok) {
- const body = await res.json().catch(() => null);
- error = body?.error ?? m.trino_query_connection_lost();
+ error = (await readErrorMessage(res)) ?? m.trino_query_connection_lost();
state = 'FAILED';
return;
}
diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte
new file mode 100644
index 00000000..efa7d4e0
--- /dev/null
+++ b/src/routes/+error.svelte
@@ -0,0 +1,24 @@
+
+
+
+
+ {#if isAuthPage}
+
+ {#snippet actions()}
+
+ {/snippet}
+
+ {:else}
+
+ {/if}
+
diff --git a/src/routes/error.svelte.spec.ts b/src/routes/error.svelte.spec.ts
new file mode 100644
index 00000000..bcf69c58
--- /dev/null
+++ b/src/routes/error.svelte.spec.ts
@@ -0,0 +1,32 @@
+import { page } from 'vitest/browser';
+import { describe, expect, it, vi } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+
+const mockPage = vi.hoisted(() => ({
+ url: new URL('http://localhost/'),
+ status: 500,
+ error: { message: 'An unexpected error occurred' } as App.Error | null
+}));
+
+vi.mock('$app/state', () => ({ page: mockPage }));
+
+import ErrorPage from './+error.svelte';
+
+describe('/+error.svelte', () => {
+ it('does not link a failing sign-in page to the dashboard, which requires sign-in', async () => {
+ mockPage.url = new URL('http://localhost/auth/login');
+ render(ErrorPage);
+
+ await expect.element(page.getByRole('button', { name: 'Try again' })).toBeInTheDocument();
+ await expect
+ .element(page.getByRole('link', { name: 'Go to dashboard' }))
+ .not.toBeInTheDocument();
+ });
+
+ it('links to the dashboard elsewhere', async () => {
+ mockPage.url = new URL('http://localhost/');
+ render(ErrorPage);
+
+ await expect.element(page.getByRole('link', { name: 'Go to dashboard' })).toBeInTheDocument();
+ });
+});