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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions deploy/helm/cockpit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ Optional pre-configured Trino endpoint. When `trino.url` is set, the in-app conn
| Parameter | Description | Default |
| --- | --- | --- |
| `trino.url` | Trino coordinator URL. | `""` |
| `trino.publicUrl` | Browser-facing URL for "View in Trino" deep links. Defaults to `trino.url`. | `""` |
| `trino.userImpersonation.enabled` | Forward the logged-in user to Trino as `X-Trino-User`. When `false`, all queries run as `trino.auth.username` (no per-user authorization/audit in Trino). | `true` |
| `trino.userImpersonation.userClaim` | OIDC claim used as the Trino user. Only consumed when impersonation is enabled and OIDC is configured. | `preferred_username` |
| `trino.auth.type` | `"none"` or `"basic"`. | `""` |
Expand Down
3 changes: 3 additions & 0 deletions deploy/helm/cockpit/templates/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ data:
{{- with .Values.trino.url }}
trino-url: {{ . | quote }}
{{- end }}
{{- with .Values.trino.publicUrl }}
trino-public-url: {{ . | quote }}
{{- end }}
{{- with .Values.trino.auth.type }}
trino-auth-type: {{ . | quote }}
{{- end }}
Expand Down
7 changes: 7 additions & 0 deletions deploy/helm/cockpit/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,13 @@ spec:
name: {{ include "cockpit.fullname" . }}
key: trino-url
{{- end }}
{{- if .Values.trino.publicUrl }}
- name: STACKABLE_COCKPIT_TRINO_PUBLIC_URL
valueFrom:
configMapKeyRef:
name: {{ include "cockpit.fullname" . }}
key: trino-public-url
{{- end }}
{{- if .Values.trino.auth.type }}
- name: STACKABLE_COCKPIT_TRINO_AUTH_TYPE
valueFrom:
Expand Down
2 changes: 2 additions & 0 deletions deploy/helm/cockpit/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,8 @@ auth:
trino:
# Trino coordinator URL (e.g. https://trino.example.com:8443)
url: ""
# Browser-facing URL for "View in Trino" deep links. Defaults to trino.url.
publicUrl: ""
userImpersonation:
# Forward the logged-in user to Trino as X-Trino-User. When false, all queries
# run as trino.auth.username instead (requires basic auth).
Expand Down
57 changes: 57 additions & 0 deletions e2e/trino/catalog-browser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,63 @@ test.describe('Catalog browser', () => {
await schemaSelect.selectOption('sf1');
});

test('long schema lists scroll instead of being clipped', async ({ page }) => {
// Inject many schemas so the tree must scroll within its bounded panel height.
await page.route(
(url) =>
url.pathname.endsWith('/api/trino/catalog') && url.searchParams.get('level') === 'schemas',
async (route) => {
const schemas = Array.from({ length: 60 }, (_, i) => [
`schema_${String(i).padStart(2, '0')}`
]);
await route.fulfill({ json: schemas });
}
);

await ensureCatalogBrowserOpen(page);
const browser = page.getByRole('navigation', { name: 'Catalog browser' });

await browser.getByRole('button', { name: 'tpch' }).click();
await expect(browser.getByText('schema_00', { exact: true })).toBeVisible();
await expect(browser.getByText('schema_59', { exact: true })).toBeAttached();

// The tree container must have a bounded height and actually scroll.
const scrollable = browser.locator('div.overflow-auto').first();
const canScroll = await scrollable.evaluate((el) => el.scrollHeight > el.clientHeight + 1);
expect(canScroll).toBe(true);

const scrolled = await scrollable.evaluate((el) => {
el.scrollTop = el.scrollHeight;
return el.scrollTop > 0;
});
expect(scrolled).toBe(true);
});

test('catalog browser width is resizable and persists across reload', async ({ page }) => {
await ensureCatalogBrowserOpen(page);

const handle = page.getByRole('separator', { name: 'Resize catalog browser' });
await expect(handle).toBeVisible();

const before = Number(await handle.getAttribute('aria-valuenow'));
await handle.focus();
await page.keyboard.press('Shift+ArrowRight'); // +20px

await expect(handle).toHaveAttribute('aria-valuenow', String(before + 20));

const stored = await page.evaluate(() => localStorage.getItem('trino_catalog_browser_width'));
expect(Number(stored)).toBe(before + 20);

// Width persists across reload.
await page.reload();
await waitForHydration(page);
await ensureCatalogBrowserOpen(page);
await expect(page.getByRole('separator', { name: 'Resize catalog browser' })).toHaveAttribute(
'aria-valuenow',
String(before + 20)
);
});

test('browser is hidden by default on mobile', async ({ page }) => {
// Set mobile viewport.
await page.setViewportSize({ width: 375, height: 667 });
Expand Down
18 changes: 18 additions & 0 deletions e2e/trino/trino.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,24 @@ test.describe('Trino query editor', () => {
await expect(alert.getByText('syntax error at position 7')).toBeVisible();
});

test('a transport-level submit failure surfaces the reason, not just a Failed badge', async ({
page
}) => {
// 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' } });
} else {
await route.continue();
}
});

await page.getByRole('button', { name: 'Run', exact: true }).click();

// The reason is shown in the status display.
await expect(page.getByText('Could not reach Trino')).toBeVisible();
});

test('pagination navigates between pages', async ({ page }) => {
await setTabSql(page, 'SELECT id, name FROM large_table');
await page.goto('/trino');
Expand Down
2 changes: 2 additions & 0 deletions messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"header_sign_out": "Abmelden",
"trino_results_empty": "Keine Ergebnisse",
"trino_query_error": "Abfragefehler",
"trino_query_connection_lost": "Verbindung zum Server verloren.",
"trino_running": "Wird ausgeführt...",
"trino_connection_url_placeholder": "https://trino.example.com:8443",
"trino_editor_label": "SQL-Editor",
Expand Down Expand Up @@ -89,6 +90,7 @@
"trino_table_type_view": "View",
"trino_table_type_materialized_view": "Mat. View",
"trino_catalog_refresh": "Katalog aktualisieren",
"trino_catalog_resize_handle": "Katalogbrowser-Breite anpassen",
"trino_catalog_error": "Katalog konnte nicht geladen werden",
"trino_catalog_load_children_error": "Laden fehlgeschlagen",
"trino_view_in_trino": "In Trino anzeigen",
Expand Down
2 changes: 2 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"header_sign_out": "Sign out",
"trino_results_empty": "No results",
"trino_query_error": "Query error",
"trino_query_connection_lost": "Lost connection to the server.",
"trino_running": "Running...",
"trino_connection_url_placeholder": "https://trino.example.com:8443",
"trino_editor_label": "SQL editor",
Expand Down Expand Up @@ -89,6 +90,7 @@
"trino_table_type_view": "View",
"trino_table_type_materialized_view": "Mat. view",
"trino_catalog_refresh": "Refresh catalog",
"trino_catalog_resize_handle": "Resize catalog browser",
"trino_catalog_error": "Failed to load catalog",
"trino_catalog_load_children_error": "Failed to load",
"trino_view_in_trino": "View in Trino",
Expand Down
18 changes: 12 additions & 6 deletions src/lib/components/catalog/CatalogBrowser.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -187,12 +187,18 @@
const catalog = defaultCatalog;
untrack(() => {
if (catalog) {
loadSchemas(catalog).then((schemaNodes) => {
availableSchemas = schemaNodes.map((n) => n.name);
if (defaultSchema && !availableSchemas.includes(defaultSchema)) {
defaultSchema = '';
}
});
loadSchemas(catalog)
.then((schemaNodes) => {
availableSchemas = schemaNodes.map((n) => n.name);
if (defaultSchema && !availableSchemas.includes(defaultSchema)) {
defaultSchema = '';
}
})
.catch((err) => {
// Leave the dropdown empty rather than throwing an unhandled rejection.
console.error('Failed to load schemas for context selector', err);
availableSchemas = [];
});
}
});
});
Expand Down
4 changes: 2 additions & 2 deletions src/lib/components/catalog/CatalogTree.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@

{#if node.type === 'table' || node.type === 'view' || node.type === 'materialized_view'}
<button
class="hover:text-primary truncate text-left hover:underline"
class="hover:text-primary min-w-0 truncate text-left hover:underline"
onclick={(e) => {
e.stopPropagation();
handleInsert(node);
Expand All @@ -212,7 +212,7 @@
{/if}
</span>
{:else}
<span class="truncate">{node.name}</span>
<span class="min-w-0 truncate">{node.name}</span>
{/if}

{#if leaf && node.dataType}
Expand Down
6 changes: 4 additions & 2 deletions src/lib/components/storage/sidebar/ResizeHandle.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,19 @@

interface Props {
panel: ResizablePanel;
/** Accessible label for the handle. Defaults to the storage-sidebar label. */
label?: string;
}

let { panel }: Props = $props();
let { panel, label }: Props = $props();
</script>

<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
role="separator"
aria-orientation="vertical"
aria-label={m.storage_sidebar_resize_handle()}
aria-label={label ?? m.storage_sidebar_resize_handle()}
aria-valuenow={panel.width}
aria-valuemin={panel.minWidth}
aria-valuemax={panel.maxWidth}
Expand Down
145 changes: 141 additions & 4 deletions src/lib/server/trino/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

// Empty env so importing client.ts does not build the env-configured singleton.
vi.mock('$env/dynamic/private', () => ({ env: {} }));
// Mutable mock env — empty by default so importing client.ts does not build the
// env-configured singleton. Tests that need env vars populate it then re-import.
// vi.hoisted ensures the object exists before the hoisted vi.mock factory runs.
const { mockEnv } = vi.hoisted(() => ({ mockEnv: {} as Record<string, string | undefined> }));
vi.mock('$env/dynamic/private', () => ({ env: mockEnv }));
vi.mock('$lib/server/logging', () => ({
logger: { child: () => ({ info: vi.fn(), warn: vi.fn(), debug: vi.fn() }) }
}));

import { TrinoClient } from './client.js';
import { TrinoClient, trinoMetadataQuery } from './client.js';

describe('TrinoClient X-Trino-User header', () => {
let fetchMock: ReturnType<typeof vi.fn>;
Expand Down Expand Up @@ -42,3 +45,137 @@ describe('TrinoClient X-Trino-User header', () => {
expect(submittedHeaders()['X-Trino-User']).toBe('anonymous');
});
});

describe('TrinoClient.cancelViaUri', () => {
afterEach(() => vi.unstubAllGlobals());

it('sends a DELETE to the given nextUri', async () => {
const fetchMock = vi.fn(async () => ({ ok: true, status: 200, text: async () => '' }));
vi.stubGlobal('fetch', fetchMock);
const client = new TrinoClient({ serverUrl: 'http://trino:8080' });
const uri = 'http://trino:8080/v1/statement/executing/q1/slug/1';
await client.cancelViaUri(uri);
expect(fetchMock).toHaveBeenCalledWith(uri, expect.objectContaining({ method: 'DELETE' }));
});

it('forwards X-Trino-User when impersonating', async () => {
const fetchMock: ReturnType<typeof vi.fn> = vi.fn(async () => ({
ok: true,
status: 200,
text: async () => ''
}));
vi.stubGlobal('fetch', fetchMock);
const client = new TrinoClient({ serverUrl: 'http://trino:8080', authorization: 'Basic abc' });
await client.cancelViaUri('http://trino:8080/next', 'alice');
expect(fetchMock.mock.calls[0][1].headers['X-Trino-User']).toBe('alice');
});

it('omits X-Trino-User when impersonation is disabled', async () => {
const fetchMock: ReturnType<typeof vi.fn> = vi.fn(async () => ({
ok: true,
status: 200,
text: async () => ''
}));
vi.stubGlobal('fetch', fetchMock);
const client = new TrinoClient({
serverUrl: 'http://trino:8080',
authorization: 'Basic abc',
impersonate: false
});
await client.cancelViaUri('http://trino:8080/next', 'alice');
expect(fetchMock.mock.calls[0][1].headers).not.toHaveProperty('X-Trino-User');
});

it('does not throw when the URI is already gone (404/410)', async () => {
for (const status of [404, 410]) {
const fetchMock = vi.fn(async () => ({ ok: false, status, text: async () => '' }));
vi.stubGlobal('fetch', fetchMock);
const client = new TrinoClient({ serverUrl: 'http://trino:8080' });
await expect(client.cancelViaUri('http://trino:8080/next')).resolves.toBeUndefined();
}
});

it('throws on other non-ok responses', async () => {
const fetchMock = vi.fn(async () => ({ ok: false, status: 500, text: async () => 'boom' }));
vi.stubGlobal('fetch', fetchMock);
const client = new TrinoClient({ serverUrl: 'http://trino:8080' });
await expect(client.cancelViaUri('http://trino:8080/next')).rejects.toThrow(/500/);
});
});

describe('TrinoClient AbortSignal forwarding', () => {
afterEach(() => vi.unstubAllGlobals());

it('forwards the signal to fetch on submit', async () => {
const fetchMock: ReturnType<typeof vi.fn> = vi.fn(async () => ({
ok: true,
json: async () => ({ id: 'q1' })
}));
vi.stubGlobal('fetch', fetchMock);
const client = new TrinoClient({ serverUrl: 'http://trino:8080' });
const ac = new AbortController();
await client.submit('SELECT 1', { user: 'alice', signal: ac.signal });
expect(fetchMock.mock.calls[0][1].signal).toBe(ac.signal);
});

it('forwards the signal to fetch on poll', async () => {
const fetchMock: ReturnType<typeof vi.fn> = vi.fn(async () => ({
ok: true,
json: async () => ({ id: 'q1' })
}));
vi.stubGlobal('fetch', fetchMock);
const client = new TrinoClient({ serverUrl: 'http://trino:8080' });
const ac = new AbortController();
await client.poll('http://trino:8080/next', { signal: ac.signal });
expect(fetchMock.mock.calls[0][1].signal).toBe(ac.signal);
});
});

describe('trinoMetadataQuery (connection test path)', () => {
afterEach(() => vi.unstubAllGlobals());

it('resolves rows for a successful single-page query', async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({ id: 'q1', columns: [{ name: '_col0', type: 'integer' }], data: [[1]] })
}));
vi.stubGlobal('fetch', fetchMock);
const client = new TrinoClient({ serverUrl: 'http://trino:8080' });
const { rows } = await trinoMetadataQuery(client, 'SELECT 1', { user: 'alice' });
expect(rows).toEqual([[1]]);
});

it('throws when the query returns an error (bad connection/credentials)', async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({ id: 'q1', error: { message: 'Access Denied' } })
}));
vi.stubGlobal('fetch', fetchMock);
const client = new TrinoClient({ serverUrl: 'http://trino:8080' });
await expect(trinoMetadataQuery(client, 'SELECT 1', { user: 'alice' })).rejects.toThrow(
'Access Denied'
);
});
});

describe('resolveTrinoPublicUrl', () => {
afterEach(() => {
for (const key of Object.keys(mockEnv)) delete mockEnv[key];
vi.resetModules();
});

it('returns the trimmed public URL when configured', async () => {
mockEnv.STACKABLE_COCKPIT_TRINO_URL = 'http://internal:8080';
mockEnv.STACKABLE_COCKPIT_TRINO_PUBLIC_URL = 'https://public.example.com/';
vi.resetModules();
const mod = await import('./client.js');
expect(mod.resolveTrinoPublicUrl('user1')).toBe('https://public.example.com');
});

it('falls back to the server URL when no public URL is set', async () => {
mockEnv.STACKABLE_COCKPIT_TRINO_URL = 'http://internal:8080';
vi.resetModules();
const mod = await import('./client.js');
expect(mod.resolveTrinoPublicUrl('user1')).toBe('http://internal:8080');
});
});
Loading