diff --git a/deploy/helm/cockpit/README.md b/deploy/helm/cockpit/README.md index 175859de..f8410f7c 100644 --- a/deploy/helm/cockpit/README.md +++ b/deploy/helm/cockpit/README.md @@ -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"`. | `""` | diff --git a/deploy/helm/cockpit/templates/configmap.yaml b/deploy/helm/cockpit/templates/configmap.yaml index 7df6c989..14b74443 100644 --- a/deploy/helm/cockpit/templates/configmap.yaml +++ b/deploy/helm/cockpit/templates/configmap.yaml @@ -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 }} diff --git a/deploy/helm/cockpit/templates/deployment.yaml b/deploy/helm/cockpit/templates/deployment.yaml index e9553548..a0f02654 100644 --- a/deploy/helm/cockpit/templates/deployment.yaml +++ b/deploy/helm/cockpit/templates/deployment.yaml @@ -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: diff --git a/deploy/helm/cockpit/values.yaml b/deploy/helm/cockpit/values.yaml index c8e6f1c7..4a701861 100644 --- a/deploy/helm/cockpit/values.yaml +++ b/deploy/helm/cockpit/values.yaml @@ -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). diff --git a/e2e/trino/catalog-browser.spec.ts b/e2e/trino/catalog-browser.spec.ts index be0180ed..be0e0d7e 100644 --- a/e2e/trino/catalog-browser.spec.ts +++ b/e2e/trino/catalog-browser.spec.ts @@ -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 }); diff --git a/e2e/trino/trino.spec.ts b/e2e/trino/trino.spec.ts index 4d5180db..514794d1 100644 --- a/e2e/trino/trino.spec.ts +++ b/e2e/trino/trino.spec.ts @@ -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'); diff --git a/messages/de.json b/messages/de.json index c54247fe..8b0bc0b1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", @@ -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", diff --git a/messages/en.json b/messages/en.json index 55907765..a6ded56d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", @@ -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", diff --git a/src/lib/components/catalog/CatalogBrowser.svelte b/src/lib/components/catalog/CatalogBrowser.svelte index f73931d8..4ce670a5 100644 --- a/src/lib/components/catalog/CatalogBrowser.svelte +++ b/src/lib/components/catalog/CatalogBrowser.svelte @@ -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 = []; + }); } }); }); diff --git a/src/lib/components/catalog/CatalogTree.svelte b/src/lib/components/catalog/CatalogTree.svelte index b9640542..68f05977 100644 --- a/src/lib/components/catalog/CatalogTree.svelte +++ b/src/lib/components/catalog/CatalogTree.svelte @@ -192,7 +192,7 @@ {#if node.type === 'table' || node.type === 'view' || node.type === 'materialized_view'} @@ -845,6 +867,9 @@ {stateBadgeClass}">{stateLabel} {/if} + {#if runner.state === 'FAILED' && runner.error} + {runner.error} + {/if} {#if runner.state === 'RUNNING'} {Math.round(runner.progress.progressPercentage)}%([]); let scriptProgress = $state(null); let currentTrinoQueryUrl = $state(null); + let error = $state(null); let totalStatements = 0; let polling = false; @@ -58,6 +61,7 @@ function createQueryRunner(tabId: string): QueryRunner { results = []; scriptProgress = null; currentTrinoQueryUrl = null; + error = null; totalStatements = 0; stopPolling(); } @@ -108,6 +112,7 @@ function createQueryRunner(tabId: string): QueryRunner { const res = await fetch(`/api/trino/query?tabId=${encodeURIComponent(tabId)}`, { signal }); if (!res.ok) { + error = m.trino_query_connection_lost(); state = 'FAILED'; stopPolling(); return; @@ -149,6 +154,7 @@ function createQueryRunner(tabId: string): QueryRunner { } catch (err) { if (signal.aborted) return; console.error('Query poll failed', err); + error = m.trino_query_connection_lost(); state = 'FAILED'; stopPolling(); return; @@ -193,6 +199,7 @@ function createQueryRunner(tabId: string): QueryRunner { if (!res.ok) { const body = await res.json().catch(() => null); console.error('Query submit failed', body?.error ?? `HTTP ${res.status}`); + error = body?.error ?? m.trino_query_connection_lost(); state = 'FAILED'; return; } @@ -200,6 +207,7 @@ function createQueryRunner(tabId: string): QueryRunner { state = 'QUEUED'; pollStatus(); } catch { + error = m.trino_query_connection_lost(); state = 'FAILED'; } } @@ -275,6 +283,9 @@ function createQueryRunner(tabId: string): QueryRunner { get currentTrinoQueryUrl() { return currentTrinoQueryUrl; }, + get error() { + return error; + }, executeScript, cancel, reset,