diff --git a/TECH_DEBT.md b/TECH_DEBT.md index f85c17cc..a66e3252 100644 --- a/TECH_DEBT.md +++ b/TECH_DEBT.md @@ -70,14 +70,6 @@ All server-side query state (progress, rows, status) is held in a module-level ` --- -### Single-file download limit - -**File:** `src/lib/storage/download.ts`, `src/lib/components/storage/FileExplorer.svelte` - -The Download action is intentionally restricted to a single file at a time. Multi-file or folder downloads (e.g. zipping selected items on the fly) are deferred to a future ticket. Until then, the Download button is disabled whenever more than one item is selected and is always disabled for directories. - ---- - ### Completed query results are ephemeral (default 30-minute TTL) **File:** `src/lib/server/trino/queries.ts:20` @@ -122,6 +114,14 @@ Mobile viewport tests (393×851, touch-enabled) are excluded from CI runs to red ## Infrastructure +### Storage search is a bounded provider scan, not an index + +**Files:** `src/lib/server/storage/s3-provider.ts`, `src/routes/(app)/api/storage/search/+server.ts` + +Bucket-scoped object search recursively lists provider keys and matches paths in process. The 50-result and 10,000-scanned-key caps, plus request abort support, bound resource use for now but can miss later matches in very large buckets. The long-term fix is an optional, provider-aware search index or asynchronously maintained object catalogue. + +--- + ### No Content Security Policy headers No CSP headers are set anywhere. This leaves the app exposed to XSS in ways that a strict CSP would mitigate. Should be added in a SvelteKit hook once the app stabilises. @@ -136,6 +136,14 @@ The dev server accepts requests from any host. This enables DNS rebinding attack --- +### Archived downloads are sent without Content-Length + +**File:** `src/lib/server/storage/download-manifests.ts` + +The exact byte size of a streamed ZIP archive is only known after it has been produced, so the server no longer pre-computes or persists an archive size (`computeZipArchiveSize` and the `archive_size` column were removed). Archive part responses are therefore sent chunked without `Content-Length`; browsers show an indeterminate download size for `.zip` files. The download history UI reports the uncompressed payload total instead. Acceptable now because ZIP archives use the STORE method (no compression) and progress display is not critical. Long-term fix, if size reporting is wanted: compute the exact archive length server-side in a single pass before responding, e.g. by streaming to a temp file, at the cost of doubled I/O. + +--- + ### Archive browsing downloads entire file before parsing **File:** `src/lib/server/storage/archive.ts` diff --git a/docker/Dockerfile b/docker/Dockerfile index 534e789c..4ca76a94 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -28,7 +28,7 @@ RUN NODEJS_VERSION=$(cat /tmp/.node-version) && \ *) echo "Unsupported architecture: ${TARGETARCH}" >&2; exit 1 ;; \ esac && \ microdnf update -y && \ - microdnf install -y curl tar gzip && \ + microdnf install -y curl tar gzip zip && \ microdnf clean all && \ curl -fsSL "https://nodejs.org/dist/v${NODEJS_VERSION}/node-v${NODEJS_VERSION}-linux-${NODEJS_ARCH}.tar.gz" \ | tar -xzf - --strip-components=1 -C /usr/local diff --git a/e2e/storage/download.spec.ts b/e2e/storage/download.spec.ts index 5c9f1037..050bd157 100644 --- a/e2e/storage/download.spec.ts +++ b/e2e/storage/download.spec.ts @@ -80,4 +80,84 @@ test.describe('Storage S3 — Download', () => { await deleteKnownKeys(client, credentials.bucket, cleanupKeys); } }); + + test('downloads up to three selected files individually', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'dl-multiple'); + const cleanupKeys = ['one.txt', 'two.txt', 'three.txt'].map((name) => `${prefix}${name}`); + + try { + await Promise.all( + cleanupKeys.map((key) => putTextObject(client, credentials.bucket, key, key)) + ); + await connectAndOpenPrefix(page, credentials, prefix); + await page.getByRole('button', { name: 'Toggle selection mode' }).click(); + for (const name of ['one.txt', 'two.txt', 'three.txt']) { + await page.getByLabel(`Select ${name}`).check(); + } + + const downloads: string[] = []; + page.on('download', (download) => downloads.push(download.suggestedFilename())); + await page.getByRole('button', { name: 'Download', exact: true }).click(); + await expect.poll(() => downloads.sort()).toEqual(['one.txt', 'three.txt', 'two.txt']); + expect(downloads.sort()).toEqual(['one.txt', 'three.txt', 'two.txt']); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('archives a selection larger than three files', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'dl-archive'); + const cleanupKeys = ['one.txt', 'two.txt', 'three.txt', 'four.txt'].map( + (name) => `${prefix}${name}` + ); + + try { + await Promise.all( + cleanupKeys.map((key) => putTextObject(client, credentials.bucket, key, key)) + ); + await connectAndOpenPrefix(page, credentials, prefix); + await page.getByRole('button', { name: 'Toggle selection mode' }).click(); + for (const name of ['one.txt', 'two.txt', 'three.txt', 'four.txt']) { + await page.getByLabel(`Select ${name}`).check(); + } + + const [download] = await Promise.all([ + page.waitForEvent('download'), + page.getByRole('button', { name: 'Download', exact: true }).click() + ]); + expect(download.suggestedFilename()).toMatch(/\.zip$/); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); + + test('downloads a folder via its context menu when not in selection mode', async ({ + page + }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'dl-folder'); + const cleanupKeys = [`${prefix}folder1/one.txt`, `${prefix}folder1/two.txt`]; + + try { + await Promise.all( + cleanupKeys.map((key) => putTextObject(client, credentials.bucket, key, key)) + ); + await connectAndOpenPrefix(page, credentials, prefix); + + await rowByName(page, 'folder1').click({ button: 'right' }); + + const downloadBtn = page.getByRole('menuitem', { name: 'Download' }); + await expect(downloadBtn).not.toHaveAttribute('disabled'); + + const [download] = await Promise.all([page.waitForEvent('download'), downloadBtn.click()]); + expect(download.suggestedFilename()).toMatch(/\.zip$/); + } finally { + await deleteKnownKeys(client, credentials.bucket, cleanupKeys); + } + }); }); diff --git a/e2e/storage/file-operations.spec.ts b/e2e/storage/file-operations.spec.ts index 383bbc8d..ed313167 100644 --- a/e2e/storage/file-operations.spec.ts +++ b/e2e/storage/file-operations.spec.ts @@ -353,7 +353,8 @@ test.describe('Storage S3 — File Operations', () => { const prefix = uniquePrefix(testInfo, 'kb-cut-paste'); const srcKey = `${prefix}cutsrc/`; const srcFile = `${srcKey}cut-kb.txt`; - const cleanupKeys = [srcFile]; + const destFile = `${prefix}cut-kb.txt`; + const cleanupKeys = [srcFile, destFile]; try { await putDirectoryMarker(client, credentials.bucket, srcKey); @@ -379,7 +380,7 @@ test.describe('Storage S3 — File Operations', () => { await page.waitForTimeout(1000); // File should exist at destination - expect(await objectExists(client, credentials.bucket, `${prefix}cut-kb.txt`)).toBe(true); + expect(await objectExists(client, credentials.bucket, destFile)).toBe(true); } finally { await deleteKnownKeys(client, credentials.bucket, cleanupKeys); } diff --git a/e2e/storage/helpers.ts b/e2e/storage/helpers.ts index 0feb1a15..b1d6a855 100644 --- a/e2e/storage/helpers.ts +++ b/e2e/storage/helpers.ts @@ -159,6 +159,7 @@ export async function connectAndOpenPrefix( */ export async function waitForObjectsLoaded(page: Page) { await waitForHydration(page); + await page.getByLabel('Loading…').waitFor({ state: 'hidden', timeout: 15_000 }); await page .locator('tbody tr') .or(page.getByText('This bucket is empty')) diff --git a/e2e/storage/search.spec.ts b/e2e/storage/search.spec.ts new file mode 100644 index 00000000..195f9643 --- /dev/null +++ b/e2e/storage/search.spec.ts @@ -0,0 +1,178 @@ +import { test, expect } from '@playwright/test'; +import { + createS3Client, + hasGarageCredentials, + requireGarageCredentials +} from '../support/garage.js'; +import { + bucketRoute, + connectAndOpenPrefix, + connectToStorage, + deleteKnownKeys, + putDirectoryMarker, + putTextObject, + uniquePrefix +} from './helpers.js'; + +test.describe('Storage Search', () => { + test.use({ locale: 'en-US' }); + + test.beforeEach(() => { + test.skip( + !hasGarageCredentials(), + 'Skipped: no s3-config.json found (requires a running Garage instance)' + ); + }); + + test('searches the current bucket, runs parallel sessions, and opens matching results', async ({ + page + }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'search'); + const directory = `${prefix}reports/`; + const file = `${directory}final-report.txt`; + + try { + await putDirectoryMarker(client, credentials.bucket, directory); + await putTextObject(client, credentials.bucket, file, 'search preview'); + await connectToStorage(page, credentials); + + await page.getByRole('button', { name: 'Open search' }).first().click(); + await page.getByLabel('Search query').fill('report'); + await page.getByRole('button', { name: 'Search', exact: true }).click(); + + await expect(page.getByText(/2 results/)).toBeVisible(); + await page.getByRole('button', { name: /reports.*\/reports\// }).click(); + await expect(page).toHaveURL(bucketRoute(credentials.bucket, directory)); + + await page.getByRole('button', { name: 'Open search' }).first().click(); + await page.getByRole('button', { name: 'Parallel search' }).click(); + await expect(page.getByRole('navigation', { name: 'Search sessions' })).toBeVisible(); + await page.getByLabel('Search query').fill('final-report'); + await page.getByRole('button', { name: 'Search', exact: true }).click(); + await page + .getByRole('button', { name: /final-report\.txt.*\/reports\/final-report\.txt/ }) + .click(); + const previewDialog = page.getByRole('dialog').filter({ hasText: 'final-report.txt' }); + await expect(previewDialog).toContainText('final-report.txt'); + await previewDialog.getByLabel('Close', { exact: true }).click(); + } finally { + await deleteKnownKeys(client, credentials.bucket, [directory, file]); + } + }); + + test('searches all buckets from the landing page', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'landing-search'); + const file = `${prefix}landing-report.txt`; + + try { + await putTextObject(client, credentials.bucket, file, 'landing search'); + await connectToStorage(page, credentials); + + await page.getByRole('button', { name: 'Open search' }).first().click(); + await page.getByLabel('Search query').fill('landing-report'); + await page.getByRole('button', { name: 'Search', exact: true }).click(); + + await expect(page.getByRole('button', { name: 'landing-report.txt' })).toBeVisible(); + } finally { + await deleteKnownKeys(client, credentials.bucket, [file]); + } + }); + + test('paginates search results', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'paginate-search'); + const keys: string[] = []; + + try { + for (let index = 1; index <= 30; index += 1) { + const key = `${prefix}report-${String(index).padStart(2, '0')}.txt`; + keys.push(key); + await putTextObject(client, credentials.bucket, key, `report ${index}`); + } + await connectToStorage(page, credentials); + + await page.getByRole('button', { name: 'Open search' }).first().click(); + await page.getByLabel('Search query').fill('report-'); + await page.getByRole('button', { name: 'Search', exact: true }).click(); + await page.getByLabel('Items per page').selectOption('25'); + + await expect(page.getByText('Page 1', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'report-01.txt' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'report-26.txt' })).not.toBeVisible(); + + await page.getByRole('button', { name: 'Next page' }).click(); + await expect(page.getByText('Page 2', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'report-26.txt' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'report-01.txt' })).not.toBeVisible(); + } finally { + await deleteKnownKeys(client, credentials.bucket, keys); + } + }); + + test('filters and selects buckets via the bucket dropdown', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'bucket-scope'); + const file = `${prefix}scoped-report.txt`; + + try { + await putTextObject(client, credentials.bucket, file, 'scoped search'); + await connectToStorage(page, credentials); + + await page.getByRole('button', { name: 'Open search' }).first().click(); + + const trigger = page.getByRole('button', { name: 'all buckets', exact: true }); + await expect(trigger).toBeVisible(); + await trigger.click(); + + await page.getByLabel('Filter buckets').fill(credentials.bucket); + await page + .getByRole('button', { name: `Include ${credentials.bucket} in search`, exact: true }) + .click(); + await expect( + page.getByRole('button', { name: credentials.bucket, exact: true }) + ).toBeVisible(); + + await page.getByLabel('Search query').fill('scoped-report'); + await page.getByRole('button', { name: 'Search', exact: true }).click(); + await expect(page.getByRole('button', { name: 'scoped-report.txt' })).toBeVisible(); + } finally { + await deleteKnownKeys(client, credentials.bucket, [file]); + } + }); + + test('shows recent searches in the recent view and reuses one', async ({ page }, testInfo) => { + const credentials = requireGarageCredentials(); + const client = createS3Client(credentials); + const prefix = uniquePrefix(testInfo, 'recent'); + const file = `${prefix}recent-report.txt`; + + try { + await putTextObject(client, credentials.bucket, file, 'recent search'); + await connectAndOpenPrefix(page, credentials, prefix); + + await page.getByRole('button', { name: 'Open search' }).first().click(); + await page.getByLabel('Search query').fill('recent-report'); + await page.getByRole('button', { name: 'Search', exact: true }).click(); + await expect(page.getByRole('button', { name: 'recent-report.txt' }).first()).toBeVisible(); + + await page.getByRole('button', { name: 'Close search' }).click(); + await page.getByRole('button', { name: 'Open search' }).first().click(); + await page.getByRole('tab', { name: 'Recent', exact: true }).click(); + await expect(page.getByRole('button', { name: /recent-report/ }).first()).toBeVisible(); + + await page + .getByRole('button', { name: /recent-report/ }) + .first() + .click(); + await expect(page.getByLabel('Search query')).toHaveValue('recent-report'); + } finally { + await deleteKnownKeys(client, credentials.bucket, [file]); + } + }); +}); diff --git a/eslint.config.js b/eslint.config.js index 543938a6..12b85c09 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -62,7 +62,8 @@ export default ts.config( 'menu-disabled', 'tab-active', 'tab-strip', - 'preview-scroll' + 'preview-scroll', + 'modal-top-search' ] } ] diff --git a/messages/de.json b/messages/de.json index 26b678a7..b0fec1ed 100644 --- a/messages/de.json +++ b/messages/de.json @@ -253,6 +253,7 @@ "storage_paste": "{count} Element einfügen", "storage_paste_plural": "{count} Elemente einfügen", "storage_selected": "{count} ausgewählt", + "storage_selected_files_size": "Ausgewählte Dateien: {size}", "storage_action_preview": "Vorschau", "storage_action_actions_for": "Aktionen für {name}", "storage_action_refresh": "Aktualisieren", @@ -310,6 +311,17 @@ "storage_download_error_server_error": "Beim Herunterladen der Datei ist ein Serverfehler aufgetreten. Bitte versuchen Sie es erneut.", "storage_download_error_unknown": "Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut.", "storage_download_dismiss": "Schließen", + "storage_download_history_title": "Download-Verlauf", + "storage_download_history_empty": "Keine Downloads in den letzten 30 Tagen.", + "storage_download_history_file_count": "{count} Dateien", + "storage_download_history_zip": "ZIP", + "storage_download_history_direct": "Einzelne Dateien", + "storage_download_history_show_details": "Details anzeigen", + "storage_download_history_hide_details": "Details ausblenden", + "storage_download_history_file_not_found": "Fehlgeschlagen: Datei \"{path}\" wurde nicht gefunden", + "storage_download_history_selected_count": "{count} ausgewählt", + "storage_download_history_download_selected": "Ausgewählte herunterladen", + "storage_download_history_download_all": "Alle herunterladen", "storage_delete_confirm_title_one": "„{name}“ löschen?", "storage_delete_confirm_title_many": "{count} Elemente löschen?", "storage_delete_confirm_message": "Diese Aktion kann nicht rückgängig gemacht werden.", @@ -668,6 +680,87 @@ "storage_rename_error_access_denied": "Zugriff verweigert. Sie haben keine Berechtigung zum Umbenennen.", "storage_rename_error_not_found": "Das Element wurde nicht gefunden. Es wurde möglicherweise verschoben oder gelöscht.", "storage_rename_error_conflict": "Eine Datei oder ein Ordner namens \"{name}\" ist an diesem Ort bereits vorhanden.", + "storage_search_open": "Suche öffnen", + "storage_search_title": "Speicher durchsuchen", + "storage_search_close": "Suche schließen", + "storage_search_query_label": "Suchbegriff", + "storage_search_query_placeholder": "Dateien und Verzeichnisse durchsuchen", + "storage_search_scope_label": "Bucket", + "storage_search_submit": "Suchen", + "storage_search_cancel": "Abbrechen", + "storage_search_recent_heading": "Letzte Suchen", + "storage_search_recent_empty": "Ihre letzten Suchen erscheinen hier.", + "storage_search_recent_clear": "Letzte Suchen löschen", + "storage_search_recent_clear_confirm_title": "Letzte Suchen löschen?", + "storage_search_recent_clear_confirm_message": "Dadurch werden alle letzten Suchen für diese Verbindung entfernt. Diese Aktion kann nicht rückgängig gemacht werden.", + "storage_search_recent_clear_cancel": "Abbrechen", + "storage_search_recent_clear_confirm": "Löschen", + "storage_search_view_label": "Suchansicht", + "storage_search_tab_search": "Suchen", + "storage_search_tab_recent": "Letzte", + "storage_search_no_results": "Keine Ergebnisse gefunden.", + "storage_search_failure_access_denied": "Der Zugriff auf einen oder mehrere ausgewählte Buckets wurde verweigert.", + "storage_search_failure_generic": "Ein oder mehrere ausgewählte Buckets konnten nicht durchsucht werden. Bitte versuchen Sie es erneut.", + "storage_search_session_label": "Suche {number}", + "storage_search_running": "{count} aktiv", + "storage_search_completed": "{count} abgeschlossen", + "storage_search_partial_count": "{count} teilweise", + "storage_search_add_session": "Parallele Suche", + "storage_search_sessions_label": "Suchsitzungen", + "storage_search_remove_session": "{name} entfernen", + "storage_search_status_running": "Aktiv", + "storage_search_status_done": "Fertig", + "storage_search_status_partial": "Teilweise", + "storage_search_status_error": "Fehler", + "storage_search_regex_placeholder": "Regulärer Ausdruck", + "storage_search_regex_badge": ".*", + "storage_search_regex_invalid": "Dieser reguläre Ausdruck ist ungültig oder verwendet nicht unterstützte Konstrukte.", + "storage_search_all_buckets": "alle Buckets", + "storage_search_scope_all": "Alle Buckets", + "storage_search_scope_filter_label": "Buckets filtern", + "storage_search_scope_filter_placeholder": "Buckets durchsuchen", + "storage_search_scope_no_results": "Keine Buckets gefunden", + "storage_search_scope_toggle": "{bucket} in die Suche einbeziehen", + "storage_search_recent_bucket_count": "{count} Buckets", + "storage_search_recent_more_buckets": "{count} weitere Buckets", + "storage_search_recent_toggle_buckets": "Buckets auswählen", + "storage_search_recent_bucket_all": "Alle Buckets", + "storage_search_recent_bucket_toggle": "{bucket} in diese Suche einbeziehen", + "storage_search_recent_use_selected": "Ausgewählte Buckets durchsuchen", + "storage_search_advanced_options": "Erweiterte Optionen", + "storage_search_regex_label": "Suche mit regulärem Ausdruck", + "storage_search_exclude_patterns": "Ausschlussmuster", + "storage_search_exclude_placeholder": "z. B. _temp", + "storage_search_remove_exclude": "{pattern} entfernen", + "storage_search_filters_label": "Filter", + "storage_search_filter_add": "Filter hinzufügen", + "storage_search_path_prefix": "Suchpfad-Präfix", + "storage_search_path_placeholder": "z. B. events/2024/", + "storage_search_max_depth": "Maximale Ordnertiefe", + "storage_search_depth_placeholder": "unbegrenzt", + "storage_search_searching": "Suche läuft...", + "storage_search_results_elapsed": "{count} Ergebnisse · {elapsed} ms", + "storage_search_result_type": "Typ", + "storage_search_keyboard_hint": "Eingabe: suchen · Strg/Cmd+T: neue Sitzung · Esc: schließen", + "storage_search_key_enter": "Eingabe", + "storage_search_key_escape": "Esc", + "storage_search_shortcut_search": "suchen", + "storage_search_shortcut_close": "schließen", + "storage_search_filter_field_label": "Filterfeld", + "storage_search_filter_date": "Datum", + "storage_search_filter_size": "Größe", + "storage_search_filter_invalid": "Geben Sie einen gültigen Filterwert ein", + "storage_search_filter_operator": "Filteroperator", + "storage_search_filter_value_size": "Größenfilterwert", + "storage_search_filter_size_placeholder": "z. B. 10 MB", + "storage_search_filter_value_date": "Datumsfilterwert", + "storage_search_filter_date_placeholder": "z. B. 2024-01-31", + "storage_search_filter_remove": "Filter entfernen", + "datepicker_open": "Datumsauswahl öffnen", + "datepicker_label": "Datumsauswahl", + "datepicker_previous_month": "Vorheriger Monat", + "datepicker_next_month": "Nächster Monat", + "datepicker_day_select": "{date} auswählen", "timestamp_just_now": "gerade eben", "timestamp_minutes_ago": [ { diff --git a/messages/en.json b/messages/en.json index 9f0592c9..35c9847f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -253,6 +253,7 @@ "storage_paste": "Paste {count} item", "storage_paste_plural": "Paste {count} items", "storage_selected": "{count} selected", + "storage_selected_files_size": "Selected files: {size}", "storage_action_preview": "Preview", "storage_action_actions_for": "Actions for {name}", "storage_action_refresh": "Refresh", @@ -310,6 +311,17 @@ "storage_download_error_server_error": "A server error occurred while downloading the file. Please try again.", "storage_download_error_unknown": "An unexpected error occurred. Please try again.", "storage_download_dismiss": "Dismiss", + "storage_download_history_title": "Download history", + "storage_download_history_empty": "No downloads in the last 30 days.", + "storage_download_history_file_count": "{count} files", + "storage_download_history_zip": "ZIP", + "storage_download_history_direct": "Individual files", + "storage_download_history_show_details": "Show details", + "storage_download_history_hide_details": "Hide details", + "storage_download_history_file_not_found": "Failed: File \"{path}\" cannot be found", + "storage_download_history_selected_count": "{count} selected", + "storage_download_history_download_selected": "Download selected", + "storage_download_history_download_all": "Download all", "storage_delete_confirm_title_one": "Delete \"{name}\"?", "storage_delete_confirm_title_many": "Delete {count} items?", "storage_delete_confirm_message": "This action cannot be undone.", @@ -668,6 +680,87 @@ "storage_rename_error_access_denied": "Access denied. You do not have permission to rename.", "storage_rename_error_not_found": "The item could not be found. It may have been moved or deleted.", "storage_rename_error_conflict": "A file or folder named \"{name}\" already exists in this location.", + "storage_search_open": "Open search", + "storage_search_title": "Search storage", + "storage_search_close": "Close search", + "storage_search_query_label": "Search query", + "storage_search_query_placeholder": "Search files and directories", + "storage_search_scope_label": "Bucket", + "storage_search_submit": "Search", + "storage_search_cancel": "Cancel", + "storage_search_recent_heading": "Recent searches", + "storage_search_recent_empty": "Your recent searches will appear here.", + "storage_search_recent_clear": "Clear recent searches", + "storage_search_recent_clear_confirm_title": "Clear recent searches?", + "storage_search_recent_clear_confirm_message": "This will remove all recent searches for this connection. This action cannot be undone.", + "storage_search_recent_clear_cancel": "Cancel", + "storage_search_recent_clear_confirm": "Clear", + "storage_search_view_label": "Search view", + "storage_search_tab_search": "Search", + "storage_search_tab_recent": "Recent", + "storage_search_no_results": "No results found.", + "storage_search_failure_access_denied": "Access to one or more selected buckets was denied.", + "storage_search_failure_generic": "One or more selected buckets could not be searched. Please try again.", + "storage_search_session_label": "Search {number}", + "storage_search_running": "{count} running", + "storage_search_completed": "{count} completed", + "storage_search_partial_count": "{count} partial", + "storage_search_add_session": "Parallel search", + "storage_search_sessions_label": "Search sessions", + "storage_search_remove_session": "Remove {name}", + "storage_search_status_running": "Running", + "storage_search_status_done": "Done", + "storage_search_status_partial": "Partial", + "storage_search_status_error": "Error", + "storage_search_regex_placeholder": "Regular expression", + "storage_search_regex_badge": ".*", + "storage_search_regex_invalid": "This regular expression is invalid or uses unsupported constructs.", + "storage_search_all_buckets": "all buckets", + "storage_search_scope_all": "All buckets", + "storage_search_scope_filter_label": "Filter buckets", + "storage_search_scope_filter_placeholder": "Search buckets", + "storage_search_scope_no_results": "No buckets found", + "storage_search_scope_toggle": "Include {bucket} in search", + "storage_search_recent_bucket_count": "{count} buckets", + "storage_search_recent_more_buckets": "{count} more buckets", + "storage_search_recent_toggle_buckets": "Choose buckets", + "storage_search_recent_bucket_all": "All buckets", + "storage_search_recent_bucket_toggle": "Include {bucket} in this search", + "storage_search_recent_use_selected": "Search selected buckets", + "storage_search_advanced_options": "Advanced options", + "storage_search_regex_label": "Regular expression search", + "storage_search_exclude_patterns": "Exclude patterns", + "storage_search_exclude_placeholder": "e.g. _temp", + "storage_search_remove_exclude": "Remove {pattern}", + "storage_search_filters_label": "Filters", + "storage_search_filter_add": "Add filter", + "storage_search_path_prefix": "Search path prefix", + "storage_search_path_placeholder": "e.g. events/2024/", + "storage_search_max_depth": "Max folder depth", + "storage_search_depth_placeholder": "unlimited", + "storage_search_searching": "Searching...", + "storage_search_results_elapsed": "{count} results · {elapsed}ms", + "storage_search_result_type": "Type", + "storage_search_keyboard_hint": "Enter: search · Ctrl/Cmd+T: new session · Esc: close", + "storage_search_key_enter": "Enter", + "storage_search_key_escape": "Esc", + "storage_search_shortcut_search": "search", + "storage_search_shortcut_close": "close", + "storage_search_filter_field_label": "Filter field", + "storage_search_filter_date": "Date", + "storage_search_filter_size": "Size", + "storage_search_filter_invalid": "Enter a valid filter value", + "storage_search_filter_operator": "Filter operator", + "storage_search_filter_value_size": "Size filter value", + "storage_search_filter_size_placeholder": "e.g. 10 MB", + "storage_search_filter_value_date": "Date filter value", + "storage_search_filter_date_placeholder": "e.g. 2024-01-31", + "storage_search_filter_remove": "Remove filter", + "datepicker_open": "Open date picker", + "datepicker_label": "Date picker", + "datepicker_previous_month": "Previous month", + "datepicker_next_month": "Next month", + "datepicker_day_select": "Select {date}", "timestamp_just_now": "just now", "timestamp_minutes_ago": [ { diff --git a/package-lock.json b/package-lock.json index 042815d3..c0f93269 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "pino": "^10.3.1", "pretty-bytes": "^7.1.0", "prom-client": "^15.1.3", + "regexpp": "^3.2.0", "tar-stream": "^3.2.0", "undici": "^7.24.4" }, @@ -4355,6 +4356,8 @@ }, "node_modules/archiver": { "version": "7.0.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", + "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9773,6 +9776,18 @@ "regexp-tree": "bin/regexp-tree" } }, + "node_modules/regexpp": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", + "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + } + }, "node_modules/require-dir": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/require-dir/-/require-dir-1.2.0.tgz", diff --git a/package.json b/package.json index aa72cf0c..c14c0786 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "pino": "^10.3.1", "pretty-bytes": "^7.1.0", "prom-client": "^15.1.3", + "regexpp": "^3.2.0", "tar-stream": "^3.2.0", "undici": "^7.24.4" }, diff --git a/src/app.css b/src/app.css index cda5b74d..fe7c028a 100644 --- a/src/app.css +++ b/src/app.css @@ -61,11 +61,10 @@ progress.progress.progress-smooth::-moz-progress-bar { background-color: color-mix(in oklch, currentColor 50%, transparent); } -@keyframes nav-progress-slide { - 0% { - transform: translateX(-110%); - } - 100% { - transform: translateX(350%); - } +/* Align a modal to the top of the viewport like a command palette while + keeping it horizontally centred. DaisyUI's .modal uses a grid with + place-items:center, so only the vertical alignment and top inset change. */ +dialog.modal-top-search { + align-items: start; + padding-top: 2vh; } diff --git a/src/architecture/i18n-compliance.spec.ts b/src/architecture/i18n-compliance.spec.ts index 8a1b7de3..d00f43d6 100644 --- a/src/architecture/i18n-compliance.spec.ts +++ b/src/architecture/i18n-compliance.spec.ts @@ -10,12 +10,100 @@ * same top-level keys — missing translations break the German locale. * b) Svelte components must not use hardcoded English strings in static * aria-label="..." attributes (dynamic bindings are allowed). + * c) Every message key must be referenced somewhere in `src/` so dead keys + * are caught at CI time rather than accumulating in the locale files. + * Pre-existing unused keys are tracked in KNOWN_UNUSED_KEYS; remove an + * entry from that list as soon as the key is used or deleted. */ import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; import { findFiles } from './helpers'; +/** + * Keys present in messages/*.json but not referenced anywhere in src/. + * These predate the unused-key rule and belong to planned features or + * removed UI. As keys are used or removed, delete them from this list — it + * must never grow. + */ +const KNOWN_UNUSED_KEYS = [ + 'nav_badge_soon', + 'trino_first_page', + 'trino_prev_page', + 'trino_next_page', + 'trino_last_page', + 'trino_connection_error', + 'trino_catalog_label', + 'trino_schema_label', + 'storage_connect_reconnecting', + 'storage_connect_timeout', + 'storage_connect_cancel', + 'storage_connect_forget_confirm', + 'storage_connect_forget_cancel', + 'storage_connect_name', + 'storage_connect_name_placeholder', + 'storage_connect_additional_buckets', + 'storage_connect_additional_buckets_hint', + 'storage_connect_duplicate_warning', + 'storage_connect_delete_label', + 'storage_connect_delete_confirm_title', + 'storage_connect_delete_confirm_message', + 'storage_connect_delete_confirm_button', + 'storage_connect_delete_cancel', + 'storage_connect_add_new', + 'storage_connect_testing', + 'storage_connection_edit_success', + 'storage_connection_edit_not_found', + 'storage_folders', + 'storage_paste', + 'storage_paste_plural', + 'storage_danger_zone', + 'storage_download_error_title', + 'storage_preview_fetch_more', + 'storage_preview_csv_columns', + 'storage_preview_pdf_too_large', + 'storage_upload_selected', + 'storage_upload_uploading', + 'storage_upload_success', + 'storage_upload_overwrite_title', + 'storage_upload_overwrite_message', + 'storage_upload_overwrite_replace', + 'storage_upload_overwrite_rename', + 'storage_upload_rename_confirm', + 'storage_upload_retry', + 'storage_preview_parquet_row_group', + 'storage_archive_exit', + 'storage_details_title', + 'storage_details_property', + 'storage_details_value', + 'storage_details_tree_visualization', + 'storage_details_lifecycle_rule_id', + 'storage_details_lifecycle_status', + 'storage_details_error_not_connected', + 'storage_details_error_fetch_file', + 'storage_details_error_fetch_bucket', + 'storage_details_error_fetch_dir_meta', + 'storage_details_tab_overview', + 'storage_details_tab_lifecycle', + 'storage_details_bucket_name', + 'storage_details_object_lock', + 'storage_action_move_error_not_connected', + 'storage_action_move_error_access_denied', + 'storage_operations_progress', + 'storage_operations_error_generic', + 'storage_rename_error_not_connected' +]; + +/** Every message key referenced as `m.(...)` in src/** (excluding the generated paraglide output). */ +function collectUsedKeys(): Set { + const used = new Set(); + for (const file of findFiles('src', /\.(ts|svelte)$/, [/paraglide/])) { + const content = readFileSync(file, 'utf-8'); + for (const match of content.matchAll(/\bm\.([a-zA-Z0-9_]+)\b/g)) used.add(match[1]); + } + return used; +} + describe('i18n Compliance', () => { it('messages/en.json and messages/de.json must have the same top-level keys', () => { const en: Record = JSON.parse(readFileSync('messages/en.json', 'utf-8')); @@ -31,6 +119,18 @@ describe('i18n Compliance', () => { expect(missingInEn).toStrictEqual([]); }); + it('every message key must be referenced in the source code', () => { + const en: Record = JSON.parse(readFileSync('messages/en.json', 'utf-8')); + const used = collectUsedKeys(); + + const unused = Object.keys(en) + .filter((key) => !key.startsWith('$')) + .filter((key) => !used.has(key)); + + const newUnused = unused.filter((key) => !KNOWN_UNUSED_KEYS.includes(key)); + expect(newUnused).toStrictEqual([]); + }); + it('Svelte components must not use static English strings in aria-label attributes', () => { const KNOWN_VIOLATIONS = [ 'ToastHost.svelte', diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 731f4d06..a8f44920 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -89,13 +89,14 @@ const handleStorageConnection: Handle = async ({ event, resolve }) => { event.locals.storageConfig = null; } // The connections management endpoint itself does not require a connection header — - // it is used to list/create connections before one is selected. - // The copy/job polling endpoint also does not require a connection header — - // it reads job status from the server-side job store. + // it is used to list/create connections before one is selected. Manifest stream and + // re-download routes load the manifest's owned connection at request time instead. const requiresConnectionHeader = event.route.id?.startsWith('/(app)/api/storage/') && !event.route.id?.startsWith('/(app)/api/storage/connections') && - !event.route.id?.startsWith('/(app)/api/storage/copy/job/'); + !event.route.id?.startsWith('/(app)/api/storage/copy/job/') && + !event.route.id?.startsWith('/(app)/api/storage/download/manifests/[manifestId]/') && + event.route.id !== '/(app)/api/storage/download/manifests'; if (event.locals.storageConfig === null && requiresConnectionHeader) { throw error(401, 'No storage connection configured'); } diff --git a/src/lib/components/DateTimePicker.svelte b/src/lib/components/DateTimePicker.svelte new file mode 100644 index 00000000..fb87b58a --- /dev/null +++ b/src/lib/components/DateTimePicker.svelte @@ -0,0 +1,200 @@ + + +
+ { + value = event.currentTarget.value; + onchange?.(value); + }} + aria-invalid={invalid || undefined} + {placeholder} + /> + +
+ + diff --git a/src/lib/components/Modal.svelte b/src/lib/components/Modal.svelte index 111d81de..423d7ef6 100644 --- a/src/lib/components/Modal.svelte +++ b/src/lib/components/Modal.svelte @@ -3,12 +3,14 @@ open = $bindable(false), children, class: className = '', - closeguard + closeguard, + ...restProps }: { open: boolean; children: import('svelte').Snippet; class?: string; closeguard?: () => boolean; + [key: string]: unknown; } = $props(); let dialogEl = $state(undefined); @@ -62,6 +64,7 @@ window.innerHeight - GAP) { + top = Math.max(GAP, triggerRect.top - popoverHeight - GAP); + } + + const naturalLeft = align === 'end' ? triggerRect.right - popoverWidth : triggerRect.left; + const left = Math.min(Math.max(GAP, naturalLeft), window.innerWidth - popoverWidth - GAP); + + popover.style.position = 'fixed'; + popover.style.inset = 'auto'; + popover.style.margin = '0'; + popover.style.top = `${top}px`; + popover.style.left = `${left}px`; + if (matchWidth) popover.style.width = `${triggerRect.width}px`; +} diff --git a/src/lib/components/storage/BucketSelect.svelte b/src/lib/components/storage/BucketSelect.svelte new file mode 100644 index 00000000..b5160012 --- /dev/null +++ b/src/lib/components/storage/BucketSelect.svelte @@ -0,0 +1,173 @@ + + +
+ + + +
diff --git a/src/lib/components/storage/SearchFilterRow.svelte b/src/lib/components/storage/SearchFilterRow.svelte new file mode 100644 index 00000000..386e5593 --- /dev/null +++ b/src/lib/components/storage/SearchFilterRow.svelte @@ -0,0 +1,107 @@ + + +
+
+ + +
+ +
+ + + + + {#if filter.field === 'size'} + + onupdate({ value: event.currentTarget.value })} + placeholder={m.storage_search_filter_size_placeholder()} + aria-invalid={hasError || undefined} + /> + {:else} + + onupdate({ value })} + /> + {/if} +
+ + +
diff --git a/src/lib/components/storage/StorageSearch.svelte b/src/lib/components/storage/StorageSearch.svelte new file mode 100644 index 00000000..d4daab58 --- /dev/null +++ b/src/lib/components/storage/StorageSearch.svelte @@ -0,0 +1,189 @@ + + + + + + + + + diff --git a/src/lib/components/storage/StorageSearchForm.svelte b/src/lib/components/storage/StorageSearchForm.svelte new file mode 100644 index 00000000..8d4b5d57 --- /dev/null +++ b/src/lib/components/storage/StorageSearchForm.svelte @@ -0,0 +1,238 @@ + + +
{ + event.preventDefault(); + void state.run(session.id); + }} +> +
+
+
+ + {#if session.status === 'running'} + + {:else} + + {/if} +
+ + {#if !regexValid} + + {/if} + +
+
+ {m.storage_search_scope_label()} + +
+
+ +
(state.advancedOpen = event.currentTarget.open)} + > + {m.storage_search_advanced_options()} +
+
+ {m.storage_search_filters_label()} +
+ {#each session.filters as filter (filter.id)} + updateFilter(filter.id, patch)} + onremove={() => state.removeFilter(filter.id)} + /> + {/each} +
+ +
+
+ +
+ {#each session.excludePatterns as pattern (pattern)}{pattern}{/each} (state.excludeInput = event.currentTarget.value)} + placeholder={m.storage_search_exclude_placeholder()} + onkeydown={(event) => { + if (event.key === 'Enter' || event.key === ',') { + event.preventDefault(); + state.addExcludePattern(); + } + }} + onblur={() => state.excludeInput.trim() && state.addExcludePattern()} + /> +
+
+
+
+ update({ searchPath: event.currentTarget.value })} + placeholder={m.storage_search_path_placeholder()} + /> +
+
+ + update({ maxDepth: event.currentTarget.valueAsNumber || undefined })} + placeholder={m.storage_search_depth_placeholder()} + /> +
+
+
+
+
diff --git a/src/lib/components/storage/StorageSearchHistory.svelte b/src/lib/components/storage/StorageSearchHistory.svelte new file mode 100644 index 00000000..45458907 --- /dev/null +++ b/src/lib/components/storage/StorageSearchHistory.svelte @@ -0,0 +1,69 @@ + + +
+
+

{m.storage_search_recent_heading()}

+ {#if entries.length > 0} + + {/if} +
+ + {#if entries.length === 0} +

{m.storage_search_recent_empty()}

+ {:else} +
    + {#each entries as entry (entry.buckets.join(',') + entry.query + String(entry.useRegex) + entry.excludePatterns.join(',') + entry.searchPath + (entry.maxDepth ?? '') + entry.buckets.length)} +
  • + onUse(e, buckets)} /> +
  • + {/each} +
+ {/if} +
+ + + + diff --git a/src/lib/components/storage/StorageSearchHistoryEntry.svelte b/src/lib/components/storage/StorageSearchHistoryEntry.svelte new file mode 100644 index 00000000..2296df6d --- /dev/null +++ b/src/lib/components/storage/StorageSearchHistoryEntry.svelte @@ -0,0 +1,165 @@ + + +
+
+ + {#if multipleBuckets} + + + + {/if} +
+ {#if multipleBuckets && expanded} +
+ +
    + {#each entry.buckets as bucket (bucket)} + {@const checked = selected.includes(bucket)} +
  • + + + +
  • + {/each} +
+ +
+ {/if} +
diff --git a/src/lib/components/storage/StorageSearchResults.svelte b/src/lib/components/storage/StorageSearchResults.svelte new file mode 100644 index 00000000..df374cde --- /dev/null +++ b/src/lib/components/storage/StorageSearchResults.svelte @@ -0,0 +1,235 @@ + + +{#snippet SortIcon(key: SortKey)} + {#if sortKey === key} + {#if sortDir === 'asc'} +