Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
3c76aad
#224 add tooltips to many locations
F3l1x1vo Aug 10, 2026
4d3b8ce
convert toast to portal
F3l1x1vo Aug 10, 2026
cdafe55
Merge branch 'feat/s3-file-browser-v1' into feat/s3-file-browser-v1-t…
F3l1x1vo Aug 10, 2026
9645d4a
fix merge conflict
F3l1x1vo Aug 10, 2026
d58c4a6
Merge branch 'feat/s3-file-browser-v1' into feat/s3-file-browser-v2
F3l1x1vo Aug 13, 2026
fab1ea7
feat: Add basic search Modal (#288)
dklOrdix Sep 1, 2026
36ffd37
Feat/s3 file browser v2 visual improvements (#297)
dklOrdix Sep 1, 2026
65b2baa
Feat/s3 file browser v2 recent search tabs (#292)
dklOrdix Sep 1, 2026
7b717d9
fix merge regressions that were lost from commit 92773d6
F3l1x1vo Sep 1, 2026
e04a225
Multipart Download and Archived Download (#287)
F3l1x1vo Sep 1, 2026
49c7d1d
fix: Allow running as arbitrary UID for (OpenShift) (#296)
labrenbe Aug 27, 2026
1628632
use tabs by default, fix tooltips, rename storage routes, operations …
F3l1x1vo Aug 31, 2026
2fdfcc2
fix tab and bucket switching behavior
F3l1x1vo Aug 31, 2026
3604c4e
remove close button
F3l1x1vo Aug 31, 2026
dd5a02e
fix lint
F3l1x1vo Aug 31, 2026
3ed1e0e
fix history.pushState
F3l1x1vo Aug 31, 2026
d09e86e
add nvm version
F3l1x1vo Aug 31, 2026
5e76450
pull from v1
F3l1x1vo Sep 1, 2026
4a24e5b
Merge remote-tracking branch 'origin/feat/s3-file-browser-v1' into fe…
F3l1x1vo Sep 2, 2026
e00cb8a
fix: hide parallel search button when not on search tab
dklOrdix Sep 2, 2026
8213130
fix: hide browser downloads from operations
dklOrdix Sep 2, 2026
148ed68
fix: improve download history entries
dklOrdix Sep 2, 2026
81f5802
fix: report partial storage search failures
dklOrdix Sep 2, 2026
9e02aec
#180 add pagination to search
F3l1x1vo Sep 3, 2026
03f57a7
#180 add search term history cycling
F3l1x1vo Sep 3, 2026
a210f38
fix lint and unit test
F3l1x1vo Sep 3, 2026
0e50eb5
#180 stream search results
F3l1x1vo Sep 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 16 additions & 8 deletions TECH_DEBT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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.
Expand All @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 80 additions & 0 deletions e2e/storage/download.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
});
5 changes: 3 additions & 2 deletions e2e/storage/file-operations.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
}
Expand Down
1 change: 1 addition & 0 deletions e2e/storage/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand Down
178 changes: 178 additions & 0 deletions e2e/storage/search.spec.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
});
});
3 changes: 2 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ export default ts.config(
'menu-disabled',
'tab-active',
'tab-strip',
'preview-scroll'
'preview-scroll',
'modal-top-search'
]
}
]
Expand Down
Loading
Loading