diff --git a/.env.development b/.env.development new file mode 100644 index 00000000..e69de29b diff --git a/AGENTS.md b/AGENTS.md index a747c0e9..a5898272 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,9 +34,14 @@ This is a **single SvelteKit application** (not a monorepo). ├── e2e/ # Playwright E2E tests ├── static/ # Static assets ├── docker/Dockerfile # Production container image -└── CLAUDE.md # AI assistant instructions +├── CLAUDE.md # AI assistant instructions +└── TECH_DEBT.md # Known tech debt and deferred security concerns ``` +## Tech Debt + +When introducing shortcuts, known issues, or deferred security work, add an entry to `TECH_DEBT.md`. Keep entries concise: what the issue is, why it is acceptable now, and what the correct long-term fix is. + ## Development Guidelines ### Browser Compatibility diff --git a/TECH_DEBT.md b/TECH_DEBT.md new file mode 100644 index 00000000..88998f8b --- /dev/null +++ b/TECH_DEBT.md @@ -0,0 +1,79 @@ +# Tech Debt + +Tracked issues that are acceptable at the current early stage but must be addressed before production. + +--- + +## Security + +### No authentication or authorisation on the Trino API route + +**File:** `src/routes/api/trino/query/+server.ts`, `src/hooks.server.ts` + +The `/api/trino/query` endpoint is completely unauthenticated. Any request — from any origin — can execute arbitrary SQL against any Trino instance. OIDC authentication is planned (env vars are wired up, `hooks.server.ts` has the right structure) but not yet implemented. Until auth middleware is in place there is also no per-user rate limiting or query quota. + +--- + +### Trino credentials stored in localStorage + +**File:** `src/routes/(app)/trino/+page.svelte:41–44` + +Username and password are persisted in plaintext localStorage. This is convenient for development (survives page reloads) but violates credential storage best practices — localStorage is accessible to any script on the page and visible in DevTools. Long-term the connection config should be stored server-side (tied to the authenticated session), with credentials never leaving the server after initial setup. + +--- + +### Credentials sent in every request body + +**File:** `src/routes/(app)/trino/+page.svelte:82–84` + +Because there is no server-side session yet, connection credentials (including password) are included in the JSON body of every `/api/trino/query` POST. Once server-side sessions exist the client should send only a session token, not raw credentials. + +--- + +### Raw upstream error messages returned to the client + +**File:** `src/routes/api/trino/query/+server.ts:165, 181, 209` + +Trino error messages and Node.js exception messages are returned to the browser without any sanitisation. Trino errors may expose schema details, table names, or internal query plans. These should be classified (query error vs. infrastructure error) and sanitised before being surfaced to users. + +--- + +## API & Validation + +### API route request body not validated with Zod + +**File:** `src/routes/api/trino/query/+server.ts:83–103` + +`parseConnection` uses manual `typeof` checks instead of a Zod schema. The AGENTS.md guidelines require Zod for all validation. Additionally, `request.json()` is called without a try/catch — a malformed JSON body will throw an unhandled error rather than returning a 400. + +--- + +### In-memory query cache has no total size bound + +**File:** `src/routes/api/trino/query/+server.ts:40–47` + +Each cached query can hold up to `MAX_CACHED_ROWS` (100 000) rows. `evictStale()` is only called when a new query arrives, not on a timer, so a long idle period followed by many concurrent queries could accumulate significant memory before eviction runs. Needs a bounded cache (e.g. LRU with a memory cap) and a periodic eviction timer. + +--- + +### Displayed results not cleared on connection change + +**File:** `src/routes/(app)/trino/+page.svelte:40–46` + +The `$effect` that persists connection settings only resets `queryId`, not `rows`, `columns`, or `error`. After switching to a different Trino instance the previous result set remains visible until a new query is run, which is confusing. + +--- + +## Infrastructure + +### 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. + +--- + +### `allowedHosts: true` in Vite config + +**File:** `vite.config.ts` + +The dev server accepts requests from any host. This enables DNS rebinding attacks against local development environments. Should be restricted to `localhost` / `127.0.0.1` unless remote dev access is explicitly needed. diff --git a/e2e/helpers.ts b/e2e/helpers.ts new file mode 100644 index 00000000..42d1957d --- /dev/null +++ b/e2e/helpers.ts @@ -0,0 +1,11 @@ +import type { Page } from '@playwright/test'; + +/** + * Wait for SvelteKit client-side hydration to complete. + * + * The root layout adds a `hydrated` class to `` inside `onMount`, + * which fires after hydration finishes and the app is fully interactive. + */ +export async function waitForHydration(page: Page) { + await page.locator('body.hydrated').waitFor(); +} diff --git a/e2e/i18n.spec.ts b/e2e/i18n.spec.ts index e8044d1b..e9a9a99c 100644 --- a/e2e/i18n.spec.ts +++ b/e2e/i18n.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test'; +import { waitForHydration } from './helpers'; test.describe('Internationalisation', () => { test.use({ locale: 'en-US' }); @@ -31,9 +32,7 @@ test.describe('Internationalisation', () => { await expect(page.locator('html')).toHaveAttribute('lang', 'en'); // Wait for client hydration; locale switch relies on an attached click handler. - await expect - .poll(() => page.evaluate(() => localStorage.getItem('theme'))) - .toMatch(/^(light|dark)$/); + await waitForHydration(page); await page.getByRole('button', { name: 'Language' }).click(); const englishOption = page.locator('#lang-switcher button[lang="en"]'); diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index bcc29f04..77e97478 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test'; +import { waitForHydration } from './helpers'; test.describe('Smoke tests', () => { test.use({ locale: 'en-US' }); @@ -20,9 +21,10 @@ test.describe('Smoke tests', () => { // Dashboard content is rendered await expect(page.getByText('Welcome back')).toBeVisible(); - // Trino nav item is present but disabled + // Trino nav item is present and navigable const trinoLink = page.getByRole('link', { name: 'Trino' }); - await expect(trinoLink).toHaveAttribute('aria-disabled', 'true'); + await expect(trinoLink).toBeVisible(); + await expect(trinoLink).not.toHaveAttribute('aria-disabled', 'true'); }); test('theme toggle switches between light and dark', async ({ page }) => { @@ -34,9 +36,7 @@ test.describe('Smoke tests', () => { }); // Wait for client hydration/theme initialisation before interacting. - await expect - .poll(() => page.evaluate(() => localStorage.getItem('theme'))) - .toMatch(/^(light|dark)$/); + await waitForHydration(page); await expect(html).toHaveAttribute('data-theme', /^(light|dark)$/); await expect(toggle).toBeVisible(); diff --git a/e2e/trino.spec.ts b/e2e/trino.spec.ts new file mode 100644 index 00000000..089b96d2 --- /dev/null +++ b/e2e/trino.spec.ts @@ -0,0 +1,257 @@ +import { test, expect } from '@playwright/test'; +import * as http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { waitForHydration } from './helpers'; + +const COLUMNS = [ + { name: 'id', type: 'integer' }, + { name: 'name', type: 'varchar' } +]; + +// Starts a lightweight HTTP server that acts as a mock Trino endpoint. +// The server action fetches `{trino_url}/v1/statement` from the SvelteKit server +// (Node.js), so we need a real TCP server reachable by the server process. +// page.route() only intercepts browser-side requests and cannot mock server-side +// Node.js fetch calls. +async function startMockTrinoServer( + handler: (req: http.IncomingMessage, res: http.ServerResponse) => void +): Promise<{ url: string; stop: () => Promise }> { + const server = http.createServer(handler); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as AddressInfo).port; + return { + url: `http://127.0.0.1:${port}`, + stop: () => + new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))) + }; +} + +test.describe('Trino query editor', () => { + test.use({ locale: 'en-US' }); + + test.beforeEach(async ({ page }) => { + // Pre-populate localStorage so the connection config and SQL are ready without + // user interaction — addInitScript runs before any page scripts execute. + await page.addInitScript(() => { + localStorage.setItem('trino_url', 'http://trino.example.com:8080'); + localStorage.setItem('trino_auth_type', 'none'); + localStorage.setItem('trino_sql', 'SELECT id, name FROM users'); + }); + }); + + test('page renders with editor and results sections', async ({ page }) => { + await page.goto('/trino'); + + await expect(page.getByRole('heading', { name: 'Trino' })).toBeVisible(); + await expect(page.getByText('SQL editor')).toBeVisible(); + await expect(page.getByText('Query results')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Run query' })).toBeVisible(); + await expect(page.getByText('No results')).toBeVisible(); + }); + + test('Trino nav item is active when on /trino', async ({ page }) => { + await page.goto('/trino'); + + const trinoLink = page.getByRole('link', { name: 'Trino' }); + await expect(trinoLink).toHaveAttribute('aria-current', 'page'); + }); + + test('running a query displays the results table', async ({ page }) => { + const { url, stop } = await startMockTrinoServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + id: 'test-query-id', + columns: COLUMNS, + data: [ + [1, 'Alice'], + [2, 'Bob'], + [3, 'Carol'] + ], + stats: { state: 'FINISHED' } + }) + ); + }); + await page.addInitScript((trinoUrl) => { + localStorage.setItem('trino_url', trinoUrl); + }, url); + + try { + await page.goto('/trino'); + await waitForHydration(page); + await page.getByRole('button', { name: 'Run query' }).click(); + + const table = page.getByRole('table', { name: 'Query results' }); + await expect(table).toBeVisible(); + await expect(table.getByRole('columnheader', { name: 'id' })).toBeVisible(); + await expect(table.getByRole('columnheader', { name: 'name' })).toBeVisible(); + await expect(table.getByRole('cell', { name: '1' })).toBeVisible(); + await expect(table.getByRole('cell', { name: 'Alice' })).toBeVisible(); + await expect(page.getByText('Rows 1–3 of 3')).toBeVisible(); + } finally { + await stop(); + } + }); + + test('Ctrl+Enter triggers query execution', async ({ page }) => { + let called = false; + const { url, stop } = await startMockTrinoServer((_req, res) => { + called = true; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + id: 'q1', + columns: COLUMNS, + data: [[1, 'Alice']], + stats: { state: 'FINISHED' } + }) + ); + }); + await page.addInitScript((trinoUrl) => { + localStorage.setItem('trino_url', trinoUrl); + }, url); + + try { + await page.goto('/trino'); + await waitForHydration(page); + await page.locator('.monaco-editor').click(); + await page.keyboard.press('Control+Enter'); + + await expect.poll(() => called).toBe(true); + await expect(page.getByRole('table', { name: 'Query results' })).toBeVisible(); + } finally { + await stop(); + } + }); + + test('query error is shown in an alert', async ({ page }) => { + const { url, stop } = await startMockTrinoServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + id: 'q-err', + error: { message: 'syntax error at position 7', errorCode: 1 }, + stats: { state: 'FAILED' } + }) + ); + }); + await page.addInitScript((trinoUrl) => { + localStorage.setItem('trino_url', trinoUrl); + }, url); + + try { + await page.goto('/trino'); + await waitForHydration(page); + await page.getByRole('button', { name: 'Run query' }).click(); + + // Monaco also renders role="alert" nodes for its own accessibility — filter by content. + const alert = page.getByRole('alert').filter({ hasText: 'Query error' }); + await expect(alert).toBeVisible(); + await expect(alert.getByText('syntax error at position 7')).toBeVisible(); + } finally { + await stop(); + } + }); + + test('pagination navigates between pages', async ({ page }) => { + // The server action caches all rows after the first query; + // prev/next page requests are served from the cache without hitting Trino again. + const allRows = Array.from({ length: 30 }, (_, i) => [i + 1, `Row ${i + 1}`]); + const { url, stop } = await startMockTrinoServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + id: 'q-pages', + columns: COLUMNS, + data: allRows, + stats: { state: 'FINISHED' } + }) + ); + }); + await page.addInitScript((trinoUrl) => { + localStorage.setItem('trino_url', trinoUrl); + }, url); + + try { + await page.goto('/trino'); + await waitForHydration(page); + await page.getByRole('button', { name: 'Run query' }).click(); + + await expect(page.getByText('Rows 1–25 of 30')).toBeVisible(); + + const nextBtn = page.getByRole('button', { name: 'Next page' }); + const prevBtn = page.getByRole('button', { name: 'Previous page' }); + await expect(prevBtn).toBeDisabled(); + await expect(nextBtn).toBeEnabled(); + + await nextBtn.click(); + await expect(page.getByText('Rows 26–30 of 30')).toBeVisible(); + await expect(prevBtn).toBeEnabled(); + await expect(nextBtn).toBeDisabled(); + + await prevBtn.click(); + await expect(page.getByText('Rows 1–25 of 30')).toBeVisible(); + } finally { + await stop(); + } + }); + + test('connection section expands to reveal URL and auth controls', async ({ page }) => { + await page.goto('/trino'); + await waitForHydration(page); + + // The DaisyUI collapse uses a visually-hidden checkbox as its toggle. + await page.getByRole('checkbox', { name: 'Connection' }).check({ force: true }); + + const urlInput = page.getByRole('textbox', { name: 'URL' }); + await expect(urlInput).toBeVisible(); + await expect(urlInput).toHaveValue('http://trino.example.com:8080'); + }); + + test('switching to basic auth reveals credential fields', async ({ page }) => { + await page.goto('/trino'); + await waitForHydration(page); + await page.getByRole('checkbox', { name: 'Connection' }).check({ force: true }); + + // No credentials visible for 'none' auth + await expect(page.getByLabel('Username')).not.toBeVisible(); + await expect(page.getByLabel('Password')).not.toBeVisible(); + + // Switch to basic auth — native DOM click reliably triggers Svelte's bind:group + // reactivity in Firefox CI, unlike Playwright's synthesised click/check. + await page.getByRole('radio', { name: 'Basic' }).evaluate((el: HTMLInputElement) => el.click()); + + await expect(page.getByLabel('Username')).toBeVisible(); + await expect(page.getByLabel('Password')).toBeVisible(); + }); + + test('null cell values render as italic null placeholder', async ({ page }) => { + const { url, stop } = await startMockTrinoServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + id: 'q-null', + columns: [{ name: 'value', type: 'varchar' }], + data: [[null], ['hello']], + stats: { state: 'FINISHED' } + }) + ); + }); + await page.addInitScript((trinoUrl) => { + localStorage.setItem('trino_url', trinoUrl); + }, url); + + try { + await page.goto('/trino'); + await waitForHydration(page); + await page.getByRole('button', { name: 'Run query' }).click(); + + const table = page.getByRole('table'); + await expect(table).toBeVisible(); + await expect(table.getByText('null')).toBeVisible(); + await expect(table.getByText('hello')).toBeVisible(); + } finally { + await stop(); + } + }); +}); diff --git a/messages/de.json b/messages/de.json index 9b3b2158..25853685 100644 --- a/messages/de.json +++ b/messages/de.json @@ -33,8 +33,33 @@ "page_title_dashboard": "Dashboard", "page_title_default": "Stackable", "page_title_suffix": "Stackable", + "page_title_trino": "Trino", "setup_steps_label": "Einrichtungsschritte", "language_label": "Sprache", "language_en": "English", - "language_de": "Deutsch" + "language_de": "Deutsch", + "trino_run_query": "Abfrage ausführen", + "trino_results_empty": "Keine Ergebnisse", + "trino_query_error": "Abfragefehler", + "trino_running": "Wird ausgeführt...", + "trino_connection_url_placeholder": "https://trino.example.com:8443", + "trino_editor_label": "SQL-Editor", + "trino_results_label": "Abfrageergebnisse", + "trino_rows_range": "Zeilen {start}–{end} von {total}", + "trino_session_expired": "Abfragesitzung abgelaufen – bitte Abfrage erneut ausführen.", + "trino_page_size": "Zeilen pro Seite", + "trino_prev_page": "Vorherige Seite", + "trino_next_page": "Nächste Seite", + "trino_connection_label": "Verbindung", + "trino_connection_error": "Bitte konfigurieren Sie eine gültige Verbindungs-URL.", + "trino_connection_url": "URL", + "trino_connection_auth": "Authentifizierung", + "trino_auth_none": "Keine", + "trino_auth_basic": "Basic", + "trino_auth_username": "Benutzername", + "trino_auth_password": "Passwort", + "trino_unknown_error": "Unbekannter Fehler", + "trino_no_sql": "Keine SQL-Abfrage angegeben", + "trino_invalid_connection": "Die Verbindungskonfiguration ist ungültig oder fehlt. Bitte eine gültige URL und Authentifizierungsmethode angeben.", + "trino_query_timeout": "Abfrage nach 30 Sekunden abgebrochen" } diff --git a/messages/en.json b/messages/en.json index ca545214..536029d5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -33,8 +33,33 @@ "page_title_dashboard": "Dashboard", "page_title_default": "Stackable", "page_title_suffix": "Stackable", + "page_title_trino": "Trino", "setup_steps_label": "Setup steps", "language_label": "Language", "language_en": "English", - "language_de": "Deutsch" + "language_de": "Deutsch", + "trino_run_query": "Run query", + "trino_results_empty": "No results", + "trino_query_error": "Query error", + "trino_running": "Running...", + "trino_connection_url_placeholder": "https://trino.example.com:8443", + "trino_editor_label": "SQL editor", + "trino_results_label": "Query results", + "trino_rows_range": "Rows {start}–{end} of {total}", + "trino_session_expired": "Query session expired — please re-run the query.", + "trino_page_size": "Rows per page", + "trino_prev_page": "Previous page", + "trino_next_page": "Next page", + "trino_connection_label": "Connection", + "trino_connection_error": "Please configure a valid connection URL.", + "trino_connection_url": "URL", + "trino_connection_auth": "Auth", + "trino_auth_none": "No auth", + "trino_auth_basic": "Basic", + "trino_auth_username": "Username", + "trino_auth_password": "Password", + "trino_unknown_error": "Unknown error", + "trino_no_sql": "No SQL provided", + "trino_invalid_connection": "The connection configuration is invalid or missing. Please provide a valid URL and authentication method.", + "trino_query_timeout": "Query timed out after 30 seconds" } diff --git a/package-lock.json b/package-lock.json index ec1e7a66..fa26cef4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "dependencies": { "@internationalized/date": "^3.11.0", + "monaco-editor": "^0.55.1", "pino": "^10.3.1", "prom-client": "^15.1.3" }, @@ -1977,7 +1978,7 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/validator": { @@ -2986,6 +2987,15 @@ "license": "MIT", "optional": true }, + "node_modules/dompurify": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/effect": { "version": "3.19.19", "resolved": "https://registry.npmjs.org/effect/-/effect-3.19.19.tgz", @@ -4198,6 +4208,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/mdn-data": { "version": "2.23.0", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.23.0.tgz", @@ -4235,6 +4257,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/monaco-editor": { + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", + "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", + "license": "MIT", + "dependencies": { + "dompurify": "3.2.7", + "marked": "14.0.0" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", diff --git a/package.json b/package.json index f3c0a540..91709c1c 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ }, "dependencies": { "@internationalized/date": "^3.11.0", + "monaco-editor": "^0.55.1", "pino": "^10.3.1", "prom-client": "^15.1.3" } diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 7859826e..eb59fe6f 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -2,9 +2,15 @@ import { paraglideMiddleware } from '$lib/paraglide/server'; import { httpRequestDuration } from '$lib/server/metrics'; import { type Handle, type HandleServerError } from '@sveltejs/kit'; import { sequence } from '@sveltejs/kit/hooks'; +import { dev } from '$app/environment'; import { requestLogger } from '$lib/server/logging'; import { logger } from '$lib/server/logging'; +// Allow self-signed TLS certificates in development (e.g. local Trino with self-signed certs). +if (dev) { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; +} + const handleMetrics: Handle = async ({ event, resolve }) => { if (event.route.id === '/metrics') { return resolve(event); diff --git a/src/lib/components/editor/MonacoEditor.svelte b/src/lib/components/editor/MonacoEditor.svelte new file mode 100644 index 00000000..76fff58d --- /dev/null +++ b/src/lib/components/editor/MonacoEditor.svelte @@ -0,0 +1,74 @@ + + +
diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte index 9653898e..b0a9eaff 100644 --- a/src/lib/components/layout/Sidebar.svelte +++ b/src/lib/components/layout/Sidebar.svelte @@ -34,9 +34,7 @@ { label: m.nav_trino(), href: '/trino', - icon: 'database', - badge: m.nav_badge_soon(), - disabled: true + icon: 'database' } ] } diff --git a/src/lib/server/trino.ts b/src/lib/server/trino.ts new file mode 100644 index 00000000..ebbb740e --- /dev/null +++ b/src/lib/server/trino.ts @@ -0,0 +1,67 @@ +export interface TrinoColumn { + name: string; + type: string; +} + +export interface TrinoResponse { + id?: string; + nextUri?: string; + columns?: TrinoColumn[]; + data?: unknown[][]; + stats?: { state: string }; + error?: { message: string; errorCode: number }; +} + +export interface CacheEntry { + columns: TrinoColumn[]; + rows: unknown[][]; + createdAt: number; +} + +export type AuthConfig = { type: 'none' } | { type: 'basic'; username: string; password: string }; + +export const POLL_TIMEOUT_MS = 30_000; +export const MAX_CACHED_ROWS = 100_000; +export const CACHE_TTL_MS = 5 * 60 * 1000; + +export const queryCache = new Map(); + +export function evictStale() { + const cutoff = Date.now() - CACHE_TTL_MS; + for (const [id, entry] of queryCache) { + if (entry.createdAt < cutoff) queryCache.delete(id); + } +} + +export function buildAuthHeaders(auth: AuthConfig): Record { + if (auth.type === 'basic') { + const encoded = Buffer.from(`${auth.username}:${auth.password}`).toString('base64'); + return { + 'X-Trino-User': auth.username, + Authorization: `Basic ${encoded}` + }; + } + return { 'X-Trino-User': 'anonymous' }; +} + +export async function trinoFetch( + url: string, + auth: AuthConfig, + options?: RequestInit +): Promise { + const res = await fetch(url, { + ...options, + headers: { + ...buildAuthHeaders(auth), + 'X-Trino-Source': 'stackable-ui', + ...(options?.headers ?? {}) + } + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Trino HTTP ${res.status}: ${text}`); + } + + return res.json() as Promise; +} diff --git a/src/lib/theme.svelte.ts b/src/lib/theme.svelte.ts new file mode 100644 index 00000000..de0939e1 --- /dev/null +++ b/src/lib/theme.svelte.ts @@ -0,0 +1,5 @@ +import { browser } from '$app/environment'; + +export const theme = $state({ + current: browser ? (document.documentElement.dataset.theme ?? 'dark') : 'dark' +}); diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index 09ab9a1f..6509644a 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -10,7 +10,8 @@ let mobileOpen = $state(false); const pageTitles: Record string> = { - '/': m.page_title_dashboard + '/': m.page_title_dashboard, + '/trino': m.page_title_trino }; let title = $derived((pageTitles[page.url.pathname] ?? m.page_title_default)()); diff --git a/src/routes/(app)/trino/+page.server.ts b/src/routes/(app)/trino/+page.server.ts new file mode 100644 index 00000000..b108885a --- /dev/null +++ b/src/routes/(app)/trino/+page.server.ts @@ -0,0 +1,153 @@ +import { fail } from '@sveltejs/kit'; +import { superValidate, message } from 'sveltekit-superforms'; +import { zod4 as zod } from 'sveltekit-superforms/adapters'; +import * as m from '$lib/paraglide/messages.js'; +import { + trinoFetch, + evictStale, + queryCache, + POLL_TIMEOUT_MS, + MAX_CACHED_ROWS, + type AuthConfig, + type TrinoColumn +} from '$lib/server/trino.js'; +import { QuerySchema, PaginateSchema, type FormMessage } from './schemas.js'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ locals }) => { + locals.logger.debug('loading Trino page'); + const [queryForm, paginateForm] = await Promise.all([ + superValidate(zod(QuerySchema)), + superValidate(zod(PaginateSchema)) + ]); + return { queryForm, paginateForm }; +}; + +export const actions: Actions = { + query: async ({ request, locals }) => { + const log = locals.logger; + const form = await superValidate(request, zod(QuerySchema)); + + if (!form.valid) { + log.debug({ errors: form.errors }, 'query form validation failed'); + return fail(400, { form }); + } + + const { sql, pageSize, connectionUrl, authType, authUsername, authPassword } = form.data; + const auth: AuthConfig = + authType === 'basic' + ? { type: 'basic', username: authUsername, password: authPassword } + : { type: 'none' }; + + evictStale(); + + const queryStart = Date.now(); + log.info({ trino_url: connectionUrl }, 'executing query'); + + const deadline = queryStart + POLL_TIMEOUT_MS; + let columns: TrinoColumn[] = []; + let rows: unknown[][] = []; + + try { + let response = await trinoFetch(`${connectionUrl}/v1/statement`, auth, { + method: 'POST', + body: sql.replace(/;\s*$/, '').trim(), + headers: { 'Content-Type': 'text/plain' } + }); + + if (response.error) { + log.info({ err: response.error }, 'query error'); + return message( + form, + { type: 'error', message: response.error.message } satisfies FormMessage, + { status: 400 } + ); + } + + if (response.columns) columns = response.columns; + if (response.data) rows = rows.concat(response.data); + + while (response.nextUri && rows.length < MAX_CACHED_ROWS) { + if (Date.now() > deadline) { + log.info({ trino_url: connectionUrl, timeout_ms: POLL_TIMEOUT_MS }, 'query timed out'); + return message( + form, + { type: 'error', message: m.trino_query_timeout() } satisfies FormMessage, + { status: 408 } + ); + } + + response = await trinoFetch(response.nextUri, auth); + + if (response.error) { + log.info({ err: response.error }, 'query error'); + return message( + form, + { type: 'error', message: response.error.message } satisfies FormMessage, + { status: 400 } + ); + } + + if (response.columns && columns.length === 0) columns = response.columns; + if (response.data) rows = rows.concat(response.data); + } + + const queryId = crypto.randomUUID(); + queryCache.set(queryId, { columns, rows, createdAt: Date.now() }); + + log.info( + { + query_id: queryId, + rows: rows.length, + cols: columns.length, + duration_ms: Date.now() - queryStart + }, + 'query complete' + ); + + return message(form, { + type: 'result', + queryId, + columns, + rows: rows.slice(0, pageSize), + hasMore: rows.length > pageSize, + totalRows: rows.length + } satisfies FormMessage); + } catch (err) { + log.error({ err, trino_url: connectionUrl }, 'unexpected error'); + const msg = err instanceof Error ? err.message : m.trino_unknown_error(); + return message(form, { type: 'error', message: msg } satisfies FormMessage, { status: 500 }); + } + }, + + paginate: async ({ request, locals }) => { + const log = locals.logger; + const form = await superValidate(request, zod(PaginateSchema)); + + if (!form.valid) { + return fail(400, { form }); + } + + const { queryId, page, pageSize } = form.data; + const cached = queryCache.get(queryId); + + if (!cached) { + log.info({ query_id: queryId }, 'cache miss (session expired)'); + return message(form, { type: 'error', message: 'session_expired' } satisfies FormMessage, { + status: 404 + }); + } + + const start = page * pageSize; + log.debug({ query_id: queryId, page, page_size: pageSize }, 'cache hit'); + + return message(form, { + type: 'result', + queryId, + columns: cached.columns, + rows: cached.rows.slice(start, start + pageSize), + hasMore: start + pageSize < cached.rows.length, + totalRows: cached.rows.length + } satisfies FormMessage); + } +}; diff --git a/src/routes/(app)/trino/+page.svelte b/src/routes/(app)/trino/+page.svelte new file mode 100644 index 00000000..3ba27148 --- /dev/null +++ b/src/routes/(app)/trino/+page.svelte @@ -0,0 +1,411 @@ + + +
+ +
+ + + + + + + + + +
+ +
+ {m.trino_connection_label()} + {connectionSummary} +
+
+ +
+ + { + if (e.key === 'Enter') e.preventDefault(); + }} + /> + {#if $queryErrors.connectionUrl} +

{$queryErrors.connectionUrl?.join(' ')}

+ {/if} +
+ + +
+ {m.trino_connection_auth()} +
+ + +
+
+ + + {#if authType === 'basic'} +
+
+ + { + if (e.key === 'Enter') e.preventDefault(); + }} + /> +
+
+ + { + if (e.key === 'Enter') e.preventDefault(); + }} + /> +
+
+ {/if} +
+
+ + +
+
+ {m.trino_editor_label()} + +
+
+ queryFormEl?.requestSubmit()} + /> +
+
+
+ + + + + +
+
+ {m.trino_results_label()} + {#if rows.length > 0 && totalRows !== null} + + {m.trino_rows_range({ start: rowStart, end: rowEnd, total: totalRows })} + + {/if} +
+ +
+ {#if queryError} + + {:else if columns.length > 0} +
+ + + + {#each columns as col (col.name)} + + {/each} + + + + {#each rows as row, rowIdx (rowIdx)} + + {#each row as cell, cellIdx (cellIdx)} + + {/each} + + {/each} + +
{col.name}
+ {#if cell === null} + null + {:else} + {String(cell)} + {/if} +
+
+ {:else if !running} +

{m.trino_results_empty()}

+ {/if} +
+ + {#if columns.length > 0} +
+
+ + +
+ +
+ + +
+
+ {/if} +
+
diff --git a/src/routes/(app)/trino/schemas.ts b/src/routes/(app)/trino/schemas.ts new file mode 100644 index 00000000..d66fa9c3 --- /dev/null +++ b/src/routes/(app)/trino/schemas.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; + +export const ALLOWED_PAGE_SIZES = [25, 50, 100] as const; +export type PageSize = 25 | 50 | 100; + +export const QuerySchema = z.object({ + sql: z.string().min(1), + pageSize: z.coerce.number().default(25), + connectionUrl: z.string().url(), + authType: z.enum(['none', 'basic']).default('none'), + authUsername: z.string().default(''), + authPassword: z.string().default('') +}); + +export const PaginateSchema = z.object({ + queryId: z.string().min(1), + page: z.coerce.number().int().nonnegative().default(0), + pageSize: z.coerce.number().default(25) +}); + +export type FormMessage = + | { + type: 'result'; + queryId: string; + columns: { name: string; type: string }[]; + rows: unknown[][]; + hasMore: boolean; + totalRows: number; + } + | { type: 'error'; message: string }; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 5d83456e..3b77780c 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,7 +1,22 @@ {@render children()}