From 5e2effeee70229f7c2dec47f90102e131c927e63 Mon Sep 17 00:00:00 2001 From: VibeSWE <338779468+VibeSWE@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:35:31 -0500 Subject: [PATCH 1/8] test(frontend): respect configured origin in authentication checks --- frontend/e2e/auth.spec.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/e2e/auth.spec.ts b/frontend/e2e/auth.spec.ts index 548edce6b..ecadf39b8 100644 --- a/frontend/e2e/auth.spec.ts +++ b/frontend/e2e/auth.spec.ts @@ -21,13 +21,14 @@ test('login has working password visibility and administrator recovery', async ( test('live 401 returns to the requested application path after native sign in', async ({ page, + baseURL, }) => { let signedIn = false; await page.route('**/api/v1/**', (route) => { const request = route.request(); const url = new URL(request.url()); if (url.pathname === '/api/v1/o/login') { - expect(url.searchParams.get('redirect')).toBe('http://127.0.0.1:5173/datasets?view=list'); + expect(url.searchParams.get('redirect')).toBe(new URL('/datasets?view=list', baseURL).href); signedIn = true; return route.fulfill({ status: 200, body: 'ok' }); } @@ -44,11 +45,14 @@ test('live 401 returns to the requested application path after native sign in', await expect(page.getByText('server_logs', { exact: true })).toBeVisible(); }); -test('OAuth is a document navigation with a safe same-origin redirect', async ({ page }) => { +test('OAuth is a document navigation with a safe same-origin redirect', async ({ + page, + baseURL, +}) => { await page.route('**/api/v1/o/login?**', (route) => { expect(route.request().isNavigationRequest()).toBe(true); expect(new URL(route.request().url()).searchParams.get('redirect')).toBe( - 'http://127.0.0.1:5173/', + new URL('/', baseURL).href, ); return route.fulfill({ contentType: 'text/html', body: '

Identity provider handoff

' }); }); From 8b45d253cf3e1564a9290f2a77bbe3994aeb6fd1 Mon Sep 17 00:00:00 2001 From: VibeSWE <338779468+VibeSWE@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:34:52 -0500 Subject: [PATCH 2/8] feat(frontend): convert builder alerts to SQL rules on explicit save Builder alerts were read-only in the native UI. The server already stores and evaluates their generated SQL in `query` (the same path as code alerts), and PUT /alerts/{id} accepts queryType "code" while keeping state, mute and created time. The detail page now offers "Convert to SQL rule", which opens the edit form with the stored SQL shown exactly and every existing setting (threshold, operator, window, frequency, severity, tags, targets, opaque extra fields) prefilled. Opening the form only reads the alert; nothing changes until "Convert and save". Cancel or a failed save leaves the stored builder rule unchanged. Co-Authored-By: Claude Opus 5.5 --- frontend/e2e/alerts.spec.ts | 128 +++++++++++++++++- frontend/src/features/alerts/AlertActions.tsx | 17 ++- frontend/src/features/alerts/AlertDetail.tsx | 3 +- frontend/src/features/alerts/AlertForm.tsx | 28 +++- frontend/src/features/alerts/helpers.test.ts | 45 ++++++ 5 files changed, 199 insertions(+), 22 deletions(-) diff --git a/frontend/e2e/alerts.spec.ts b/frontend/e2e/alerts.spec.ts index 9fbed8c34..475547b68 100644 --- a/frontend/e2e/alerts.spec.ts +++ b/frontend/e2e/alerts.spec.ts @@ -64,6 +64,7 @@ async function mocked( missingType?: boolean; empty?: boolean; failCreate?: boolean; + failUpdate?: boolean; original?: Alert; } = {}, ) { @@ -131,6 +132,8 @@ async function mocked( if (options.missingType && path.endsWith('/0')) return route.fulfill({ status: 400, body: 'Missing alert' }); if (request.method() === 'PUT') { + if (options.failUpdate) + return route.fulfill({ status: 400, body: 'Invalid alert query: Subquery not allowed' }); writes.push(body); current = { ...current, ...body, promqlRuntime: undefined } as Alert; } @@ -336,14 +339,129 @@ test('detail shows firing instances, delivery attempts, disabled banner and cust await expect(page.getByRole('button', { name: 'Disable', exact: true })).toBeFocused(); await expect(page.getByRole('button', { name: 'Evaluate now', exact: true })).toBeEnabled(); }); -test('builder rules are read-only with SQL and no Edit action', async ({ page }) => { +test('builder rules convert to SQL only on a deliberate save, and cancel keeps them', async ({ + page, +}) => { + const sql = 'SELECT COUNT(*) FROM "application_logs" WHERE level = \'ERROR\''; await demo(page, '/alerts/01M4H000000000000000000012'); - await expect( - page.getByText('Editing builder alerts is not supported yet.', { exact: false }), - ).toBeVisible(); - await expect(page.locator('.alerts-query')).toContainText('SELECT COUNT(*)'); + await expect(page.locator('.alerts-query')).toHaveText(sql); + await expect(page.getByText('Builder', { exact: true })).toBeVisible(); await expect(page.getByRole('button', { name: 'Edit', exact: true })).toHaveCount(0); await expect(page.getByRole('button', { name: 'Duplicate', exact: true })).toHaveCount(0); + await page.getByRole('button', { name: 'Convert to SQL rule', exact: true }).click(); + await expect(page).toHaveURL(/\/alerts\/01M4H000000000000000000012\/edit$/); + await expect( + page.getByRole('heading', { name: 'Convert to SQL rule', exact: true }), + ).toBeVisible(); + await expect(page.getByTestId('alert-conversion-notice')).toContainText( + 'Nothing changes until you save.', + ); + await expect(page.getByRole('textbox', { name: 'SQL query', exact: true })).toHaveText(sql); + await expect(page.getByLabel('Alert type')).toHaveValue('code'); + await expect(page.getByLabel('Alert type')).toBeDisabled(); + await expect(page.getByLabel('Threshold value')).toHaveValue('5'); + await expect(page.getByLabel('Evaluation frequency (minutes)')).toHaveValue('5'); + await expect(page.getByLabel('Evaluation window')).toHaveValue('10m'); + await expect(page.getByLabel('Severity')).toHaveValue('high'); + await expect(page.getByRole('checkbox', { name: 'Operations Slack' })).toBeChecked(); + await page.getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(page).toHaveURL(/\/alerts\/01M4H000000000000000000012$/); + await expect(page.getByText('Builder', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Convert to SQL rule', exact: true }).click(); + await page.getByRole('button', { name: 'Convert and save', exact: true }).click(); + await expect( + page.getByRole('heading', { name: 'Checkout errors (builder)', exact: true }), + ).toBeVisible(); + await expect(page.getByText('SQL', { exact: true })).toBeVisible(); + await expect(page.locator('.alerts-query')).toHaveText(sql); + await expect(page.getByText('created with the query builder', { exact: false })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Edit', exact: true })).toBeVisible(); +}); +const builderAlert: Alert = { + ...alertFixture, + title: 'Checkout errors', + severity: 'critical', + query: 'SELECT COUNT(*) FROM "logs" WHERE "status" >= 500', + queryType: 'builder', + datasets: ['logs'], + thresholdConfig: { operator: '>=', value: 12.5 }, + evalConfig: { rollingWindow: { evalStart: '1h30m', evalEnd: 'now', evalFrequency: 15 } }, + notificationConfig: { interval: 5 }, + targets: [targetFixture.id], + tags: ['checkout', 'production'], + state: 'disabled', + notificationState: { mute: 'indefinite' }, + promqlConfig: undefined, + promqlRuntime: undefined, + units: 'requests', +}; +test('builder conversion failure keeps the form and the stored rule unchanged', async ({ + page, +}) => { + const api = await mocked(page, { original: builderAlert, failUpdate: true }); + await page.goto(`${detailPath}/edit`); + await expect(page.getByRole('textbox', { name: 'SQL query', exact: true })).toHaveText( + builderAlert.query, + ); + // Opening the conversion only reads the alert. + expect(api.calls.filter((call) => call.method !== 'GET')).toEqual([]); + await page.getByRole('button', { name: 'Convert and save', exact: true }).click(); + await expect(page.getByText('Subquery not allowed', { exact: false })).toBeVisible(); + await expect(page).toHaveURL(/\/edit$/); + await expect(page.getByRole('button', { name: 'Convert and save', exact: true })).toBeEnabled(); + await expect(page.getByRole('textbox', { name: 'SQL query', exact: true })).toHaveText( + builderAlert.query, + ); + expect(api.writes).toEqual([]); + await page.getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(page.getByText('Builder', { exact: true })).toBeVisible(); + await expect(page.getByText('This alert is disabled.', { exact: false })).toBeVisible(); +}); +test('builder conversion sends the generated SQL with every existing setting', async ({ page }) => { + const api = await mocked(page, { original: builderAlert }); + await page.goto(`${detailPath}/edit`); + await page.getByRole('button', { name: 'Convert and save', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Checkout errors', exact: true })).toBeVisible(); + const puts = api.calls.filter((call) => call.method === 'PUT'); + expect(puts.map((call) => call.path)).toEqual([`/api/v1/alerts/${builderAlert.id}`]); + expect(api.writes).toEqual([ + { + title: 'Checkout errors', + severity: 'critical', + query: builderAlert.query, + queryType: 'code', + datasets: ['logs'], + alertType: 'threshold', + thresholdConfig: { operator: '>=', value: 12.5 }, + evalConfig: { rollingWindow: { evalStart: '1h30m', evalEnd: 'now', evalFrequency: 15 } }, + notificationConfig: { interval: 5 }, + targets: [targetFixture.id], + tags: ['checkout', 'production'], + units: 'requests', + }, + ]); + // Enabled and mute state are server-managed and kept by the update. + await expect(page.getByText('This alert is disabled.', { exact: false })).toBeVisible(); + await expect(page.locator('.alerts-metadata')).toContainText('Muted indefinitely'); + await expect(page.getByText('SQL', { exact: true })).toBeVisible(); +}); +test('SQL rules keep the ordinary edit flow without conversion', async ({ page }) => { + const api = await mocked(page, { + original: { ...builderAlert, queryType: 'code', state: 'not-triggered' }, + }); + await page.goto(detailPath); + await expect(page.getByRole('button', { name: 'Convert to SQL rule' })).toHaveCount(0); + await page.getByRole('button', { name: 'Edit', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Edit alert', exact: true })).toBeVisible(); + await expect(page.getByTestId('alert-conversion-notice')).toHaveCount(0); + await page.getByLabel('Threshold value').fill('20'); + await page.getByRole('button', { name: 'Save alert', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Checkout errors', exact: true })).toBeVisible(); + expect(api.writes[0]).toMatchObject({ + queryType: 'code', + query: builderAlert.query, + thresholdConfig: { operator: '>=', value: 20 }, + }); }); test('title search, tag filtering and sorting distinguish empty searches', async ({ page }) => { await demo(page); diff --git a/frontend/src/features/alerts/AlertActions.tsx b/frontend/src/features/alerts/AlertActions.tsx index 1669fa729..c3872647a 100644 --- a/frontend/src/features/alerts/AlertActions.tsx +++ b/frontend/src/features/alerts/AlertActions.tsx @@ -99,15 +99,14 @@ export function AlertActions({
{detail ? (
- {alert.queryType !== 'builder' && ( - - )} + {/* Opening the form only reads the alert; builder rules convert on save. */} + {[evaluate, toggle, { ...mute, label: muted ? 'Unmute' : 'Mute' }].map((item) => ( +
); } +/** + * Read-only server library of SQL saved through /filters, including classic + * Prism SQL queries. Records are never written from here; copying one creates + * an independent browser-library entry. + */ +function ServerLibrary({ + search, + onOpen, + onCopy, +}: { + search: string; + onOpen?: (item: ServerSavedQuery) => void; + onCopy: (item: ServerSavedQuery) => void; +}) { + const { client, identity, mode } = useApp(); + const owner = useAsync( + useCallback( + async () => (identity?.source === 'server' ? sha256(identity.id) : undefined), + [identity?.id, identity?.source], + ), + ); + const views = useAsync( + useCallback((signal) => client.listLogViews(signal).then(serverSavedQueries), [client]), + ); + const ownerHash = mode === 'demo' ? 'demo' : owner.data; + const forbidden = views.error instanceof ApiError && views.error.status === 403; + const items = (views.data ?? []).filter((item) => + `${item.name} ${item.sql} ${item.datasets.join(' ')}` + .toLowerCase() + .includes(search.toLowerCase()), + ); + return ( + <> +
+

Server saved queries

+ {views.data?.length ?? 0} +
+

+ SQL saved on the server, including classic saved queries and SQL Logs views. Read only here: + open one or copy it to this browser to edit. +

+ {views.loading && ( +

+ Loading server saved queries… +

+ )} + {views.error && ( +
+

+ {forbidden + ? 'You do not have permission to list server saved queries.' + : views.error.message} +

+ {!forbidden && ( + + )} +
+ )} +
+ {items.map((item) => ( +
+ + +
+ ))} + {views.data && !items.length &&

No server saved queries.

} +
+ + ); +} export function SqlSidebar(props: SqlSidebarProps) { const { mode, identity } = useApp(); const scope = sqlStorageScope(mode, identity); @@ -56,6 +152,7 @@ export function SqlSidebar(props: SqlSidebarProps) { items: [], }); const [error, setError] = useState(''); + const [notice, setNotice] = useState(''); const [search, setSearch] = useState(''); const [librarySearch, setLibrarySearch] = useState(''); const [name, setName] = useState(props.currentName); @@ -90,6 +187,7 @@ export function SqlSidebar(props: SqlSidebarProps) { localStorage.setItem(`${scope}:library`, JSON.stringify(next)); setStored({ scope, items: next }); setError(''); + setNotice(''); return true; } catch { setError('Could not save: browser storage is unavailable or full.'); @@ -110,6 +208,22 @@ export function SqlSidebar(props: SqlSidebarProps) { }; if (persist(saveQuery(items, item))) props.onSaved?.(item); }; + const copyServerQuery = (item: ServerSavedQuery) => { + // Read the latest stored library so another tab's saves are never overwritten. + let current = items; + try { + if (scope) current = readSavedQueries(localStorage.getItem(`${scope}:library`)); + } catch { + /* persist reports unavailable storage */ + } + const result = importServerQuery(current, item); + if (!result.imported) { + setError(''); + setNotice(`${item.name} is already in this browser library.`); + return; + } + if (persist(result.items)) setNotice(`Copied ${item.name} to this browser library.`); + }; const canSave = !!scope && !!name.trim() && !!props.currentQuery.trim(); return (
{error &&

{error}

} + {notice &&

{notice}

} No saved queries yet.

} + {scope && ( + // Keyed by account scope: a changed account never shows the previous list. + + )} ); } diff --git a/frontend/src/features/sql/serverSavedQueries.test.ts b/frontend/src/features/sql/serverSavedQueries.test.ts new file mode 100644 index 000000000..af5793854 --- /dev/null +++ b/frontend/src/features/sql/serverSavedQueries.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vitest'; +import { savedLogView } from '../../lib/logsContract'; +import type { SavedLogView } from '../../lib/types'; +import { saveQuery, type SavedQuery } from './savedQueries'; +import { + importServerQuery, + serverSavedQueries, + serverSavedQuery, + sqlEditorHandoff, +} from './serverSavedQueries'; + +// Shapes as stored by classic Prism and returned by GET /api/v1/filters. +const classicSql: SavedLogView = { + version: 'v2', + user_id: 'owner-hash', + stream_name: 'sql', + filter_name: 'Slow requests', + filter_id: '01HCLASSICSQL', + query: { + filter_type: 'sql', + filter_query: 'SELECT * FROM "app" WHERE latency > 500', + filter_builder: null, + }, + time_filter: { from: '2026-10-01T00:00:00.000Z', to: '2026-10-02T00:00:00.000Z' }, + tenant_id: null, +}; +const classicLogsSql: SavedLogView = { + version: 'v1', + user_id: 'other-hash', + stream_name: 'app', + filter_name: 'App errors', + filter_id: '01HCLASSICLOGS', + query: { filter_type: 'sql', filter_query: "SELECT * FROM app WHERE level = 'error'" }, + time_filter: { from: 'now-1h', to: 'now' }, +}; +const classicBuilder: SavedLogView = { + version: 'v2', + user_id: 'owner-hash', + stream_name: 'app', + filter_name: 'Builder', + filter_id: '01HBUILDER', + query: { + filter_type: 'filter', + filter_query: null, + filter_builder: { + id: 'root', + combinator: 'and', + rules: [ + { + id: 'g', + combinator: 'and', + rules: [{ id: 'r', field: 'level', value: 'error', operator: '=' }], + }, + ], + }, + }, + time_filter: null, +}; +const classicSearch: SavedLogView = { + ...classicBuilder, + filter_id: '01HSEARCH', + query: { filter_type: 'search', filter_query: 'timeout' }, +}; + +describe('server saved SQL queries', () => { + it('accepts the real classic fixtures through the client contract', () => { + for (const view of [classicSql, classicLogsSql, classicBuilder, classicSearch]) + expect(savedLogView(view)).toBe(true); + }); + it('reads classic SQL editor queries with their saved bounds and no dataset scope', () => { + expect(serverSavedQuery(classicSql)).toEqual({ + key: JSON.stringify(['owner-hash', '01HCLASSICSQL']), + filterId: '01HCLASSICSQL', + name: 'Slow requests', + sql: 'SELECT * FROM "app" WHERE latency > 500', + range: { startTime: '2026-10-01T00:00:00.000Z', endTime: '2026-10-02T00:00:00.000Z' }, + rangeFallback: false, + datasets: [], + origin: 'sql', + ownerHash: 'owner-hash', + }); + }); + it('marks Logs-origin SQL and falls back from unreadable bounds without changing the SQL', () => { + expect(serverSavedQuery(classicLogsSql)).toMatchObject({ + origin: 'logs', + datasets: ['app'], + range: '1h', + rangeFallback: true, + sql: classicLogsSql.query.filter_query, + }); + // A converted native Logs view keeps explorer_state and stays Logs-origin. + expect(serverSavedQuery({ ...classicSql, explorer_state: '{"version":1}' })?.origin).toBe( + 'logs', + ); + }); + it('never fabricates SQL for builder, search, empty or unidentified records', () => { + expect(serverSavedQuery(classicBuilder)).toBeUndefined(); + expect(serverSavedQuery(classicSearch)).toBeUndefined(); + expect( + serverSavedQuery({ ...classicSql, query: { filter_type: 'sql', filter_query: ' ' } }), + ).toBeUndefined(); + expect(serverSavedQuery({ ...classicSql, filter_id: null })).toBeUndefined(); + }); + it('keeps same-id records of different owners and drops exact duplicates', () => { + const items = serverSavedQueries([ + classicSql, + classicSql, + { ...classicSql, user_id: 'admin-hash' }, + classicBuilder, + classicLogsSql, + ]); + expect(items.map((item) => [item.name, item.ownerHash])).toEqual([ + ['App errors', 'other-hash'], + ['Slow requests', 'admin-hash'], + ['Slow requests', 'owner-hash'], + ]); + }); + it('hands the stored SQL to the editor verbatim', () => { + const item = serverSavedQuery(classicSql)!; + const params = new URL(sqlEditorHandoff(item), 'http://x').searchParams; + expect(params.get('query')).toBe(item.sql); + expect(params.get('start')).toBe('2026-10-01T00:00:00.000Z'); + expect(params.get('end')).toBe('2026-10-02T00:00:00.000Z'); + expect(sqlEditorHandoff(serverSavedQuery(classicLogsSql)!)).not.toContain('start='); + }); + it('copies into the browser library without losing local queries or duplicating', () => { + const local: SavedQuery = { + id: 'local', + name: 'Mine', + sql: 'SELECT 1', + range: '1h', + datasets: ['app'], + updatedAt: '2026-01-01T00:00:00.000Z', + }; + const item = serverSavedQuery(classicSql)!; + const first = importServerQuery([local], item, new Date('2026-10-21T00:00:00Z')); + expect(first.items).toHaveLength(2); + expect(first.items[1]).toBe(local); + expect(first.imported).toMatchObject({ + name: item.name, + sql: item.sql, + range: item.range, + datasets: [], + }); + expect(first.imported!.id).not.toBe(item.filterId); + expect(saveQuery(first.items, local)).toHaveLength(2); + const again = importServerQuery(first.items, item); + expect(again.imported).toBeUndefined(); + expect(again.items).toBe(first.items); + }); +}); diff --git a/frontend/src/features/sql/serverSavedQueries.ts b/frontend/src/features/sql/serverSavedQueries.ts new file mode 100644 index 000000000..427080964 --- /dev/null +++ b/frontend/src/features/sql/serverSavedQueries.ts @@ -0,0 +1,90 @@ +import { timeBounds } from '../../lib/query'; +import type { SavedLogView, TimeRange } from '../../lib/types'; +import type { SavedQuery } from './savedQueries'; + +/** + * A server /filters record that stores runnable SQL. Classic SQL-editor queries + * use stream_name "sql" without an explorer_state; everything else came from a + * Logs view (classic dataset-scoped or native). Builder and search views have no + * SQL and are never translated here. + */ +export type ServerSavedQuery = { + key: string; + filterId: string; + name: string; + sql: string; + range: TimeRange; + /** Saved time bounds were present but unusable; the default range is used. */ + rangeFallback: boolean; + datasets: string[]; + origin: 'sql' | 'logs'; + ownerHash?: string; +}; + +export function serverSavedQuery(view: SavedLogView): ServerSavedQuery | undefined { + const sql = view.query.filter_query; + if (view.query.filter_type !== 'sql' || typeof sql !== 'string' || !sql.trim()) return; + if (typeof view.filter_id !== 'string' || !view.filter_id) return; + let range: TimeRange = '1h'; + let rangeFallback = false; + if (view.time_filter) { + try { + range = timeBounds({ startTime: view.time_filter.from, endTime: view.time_filter.to }); + } catch { + rangeFallback = true; + } + } + const ownerHash = typeof view.user_id === 'string' && view.user_id ? view.user_id : undefined; + return { + key: JSON.stringify([ownerHash ?? null, view.filter_id]), + filterId: view.filter_id, + name: view.filter_name.trim() || 'Untitled query', + sql, + range, + rangeFallback, + datasets: view.stream_name === 'sql' ? [] : [view.stream_name], + origin: view.stream_name === 'sql' && view.explorer_state == null ? 'sql' : 'logs', + ownerHash, + }; +} + +export function serverSavedQueries(views: SavedLogView[]): ServerSavedQuery[] { + const seen = new Set(); + return views + .flatMap((view) => { + const item = serverSavedQuery(view); + if (!item || seen.has(item.key)) return []; + seen.add(item.key); + return [item]; + }) + .sort((a, b) => a.name.localeCompare(b.name) || a.key.localeCompare(b.key)); +} + +/** Opens the stored SQL verbatim; the server record is not read back or changed. */ +export function sqlEditorHandoff(item: Pick): string { + const params = new URLSearchParams({ query: item.sql }); + if (typeof item.range === 'object') { + params.set('start', item.range.startTime); + params.set('end', item.range.endTime); + } + return `/sql-editor?${params}`; +} + +/** A browser-library copy; the server record stays the source of the original. */ +export function importServerQuery( + items: SavedQuery[], + item: ServerSavedQuery, + now = new Date(), +): { items: SavedQuery[]; imported?: SavedQuery } { + if (items.some((existing) => existing.name === item.name && existing.sql === item.sql)) + return { items }; + const imported: SavedQuery = { + id: crypto.randomUUID(), + name: item.name, + sql: item.sql, + range: item.range, + datasets: [...item.datasets], + updatedAt: now.toISOString(), + }; + return { items: [imported, ...items], imported }; +} diff --git a/frontend/src/features/sql/sql-sidebar.css b/frontend/src/features/sql/sql-sidebar.css index b349805b2..18b35a18a 100644 --- a/frontend/src/features/sql/sql-sidebar.css +++ b/frontend/src/features/sql/sql-sidebar.css @@ -62,3 +62,7 @@ font-size: 11px; line-height: 1.5; } +.sql-library-origin { + margin-left: 6px; + font-size: 11px; +} From b56405fe3a8f2f1ca619102929384c2319190d26 Mon Sep 17 00:00:00 2001 From: VibeSWE <338779468+VibeSWE@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:50:35 -0500 Subject: [PATCH 5/8] Document native migration and ship bundled dependency notices --- docs/native-ui-migration.md | 71 +++++++++++++++++++++++++ docs/native-ui.md | 8 +-- frontend/README.md | 13 ++++- frontend/docs/api-contracts.md | 6 +-- frontend/docs/prism-parity.md | 10 ++-- frontend/docs/validation-review.md | 2 + frontend/e2e-live/dashboards.spec.ts | 27 ++++++---- frontend/e2e-live/server.spec.ts | 2 +- frontend/e2e-live/team.spec.ts | 2 +- frontend/e2e/classic-links.spec.ts | 4 +- frontend/e2e/native-regressions.spec.ts | 24 +++------ frontend/src/lib/classicUi.test.ts | 4 +- frontend/src/lib/classicUi.ts | 2 +- frontend/src/lib/config.ts | 4 +- frontend/vite.config.ts | 2 + 15 files changed, 133 insertions(+), 48 deletions(-) create mode 100644 docs/native-ui-migration.md diff --git a/docs/native-ui-migration.md b/docs/native-ui-migration.md new file mode 100644 index 000000000..4f71d244e --- /dev/null +++ b/docs/native-ui-migration.md @@ -0,0 +1,71 @@ +# Moving to the native UI + +The server embeds the React frontend built from `frontend/`. It no longer +downloads or embeds the classic Prism distribution. The backend APIs under +`/api/prism/v1/`, Prism node mode, and existing stored dashboards remain supported. + +For build inputs, deployment settings, `/next` redirects and backend-only debug +mode, see [Native UI builds](native-ui.md). + +## Saved SQL and Logs views + +The SQL editor includes a read-only **Server saved queries** library backed by +the existing `/api/v1/filters` API. Open a classic saved SQL query as a draft, or +copy it explicitly to the existing browser-local library. Opening a query does +not execute it, update its server record, or replace an existing browser query. +Editing and saving a browser copy does not synchronize changes to the original +server record. + +In Logs, **Open in SQL editor** makes the stored SQL of an older saved view +accessible even when its explorer settings cannot be applied by the native UI. +The original view is retained. Saved definitions that contain only a classic +filter-builder expression or search, without stored SQL, cannot be translated +automatically by this migration. Recreate those filters deliberately; do not +assume an unsupported saved view was applied. + +Review saved queries and views before deploying. Permissions to list saved +objects and execute their queries are checked by the server separately. An +unreadable saved time range must be reviewed before executing the query. + +## Builder alerts + +Open a builder alert and select **Convert to SQL rule**. The form displays the +SQL the existing rule already executes and its current settings. **Convert and +save** changes the rule to an editable SQL rule. Opening or cancelling the form +does not convert it. Existing threshold, evaluation, targets, severity and tags +are retained unless edited; the server preserves enabled and mute state. + +Normal server permissions and query validation still apply. A rejected save +leaves the stored rule unchanged. Conversion does not add anomaly or forecast +alerts, which this OSS backend does not implement. + +## Remaining feature differences + +Removing classic assets is not a claim of complete Prism feature parity. The +remaining optional work includes dataset tags/labels and advanced schema or +partition controls; the metrics catalog, visual PromQL operations and histogram +drilldowns; Logs group-by; and the dashboard template gallery. Sample-data +generation controls, MCP connection help, and the richer classic home search +also do not have equivalent native controls. + +Keystone investigations, LLM provider-key management, SQL/dashboard AI generation, +cloud billing and hosted workspace provisioning have no supported management API +in this OSS checkout. Retaining the old assets would not provide those backend +capabilities. + +Existing dashboard formats remain supported. Backend dashboard persistence +changes developed independently of this migration require their own validation; +removing UI assets does not change stored dashboard revisions or deletion rules. + +## Release verification + +Verify login/logout and OIDC, old `/next` links and classic route bookmarks, +legacy saved queries/views, builder-alert conversion, and existing dashboards +against a disposable native-only server. Test missing hashed assets return 404 +and API errors remain API errors rather than an HTML fallback. Check both a +normal frontend build and the explicit backend-only debug mode. + +Build default, debug and Kafka images after changing the embedding contract. +Confirm a clean build has no upstream Prism asset fetch and the resulting +binary contains only native UI assets. Preserve package notices and comply with +the repository's AGPL license; UI replacement does not remove those obligations. diff --git a/docs/native-ui.md b/docs/native-ui.md index 3c9882358..e24800747 100644 --- a/docs/native-ui.md +++ b/docs/native-ui.md @@ -3,7 +3,9 @@ Parseable now builds and embeds only `frontend/dist` and serves it at `/`. The build no longer downloads the classic Prism ZIP or applies a compiled-asset overlay. The release `build.zip` and `build.zip.sha1` artifacts still contain the -native frontend with the same top-level `dist/` layout. +native frontend with the same top-level `dist/` layout. Vite emits +`dist/THIRD_PARTY_LICENSES.md` with bundled dependency notices; that file is +embedded and packaged alongside the frontend. ## Build and deployment @@ -50,8 +52,8 @@ focused tests. Server routing tests cover deep links, conditional caching, missing chunks, rejected paths, `/next` redirects and API precedence when the UI is disabled. OIDC handlers and their existing tests are preserved. -Frontend feature migration (saved SQL, older saved Logs filters and builder -alerts) is tracked separately from removal of the distribution. Do not infer +See [the migration guide](native-ui-migration.md) for server-saved SQL, older +Logs filters, builder-alert conversion and remaining feature gaps. Do not infer full feature equivalence from a successful native build. Historical comparison reports and stored classic-format fixtures remain compatibility evidence; live classic comparisons require an older server version. diff --git a/frontend/README.md b/frontend/README.md index 7838e24b7..04efb9942 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -135,7 +135,16 @@ Set `PARSEABLE_LIVE_USERNAME`, `PARSEABLE_LIVE_PASSWORD` and `PARSEABLE_LIVE_DAT The Alerts live spec (`e2e-live/alerts.spec.ts`) creates a unique OTLP dataset, webhook target and PromQL rule, checks preview values and mutations against the API, and cleans up all three in `afterAll`, including after failures. Run it only against a disposable server. Set `TMPDIR` to a disk-backed directory when `/tmp` is small. The current Alerts checks and counts are recorded in [validation-review.md](docs/validation-review.md). -The Dashboards live spec (`e2e-live/dashboards.spec.ts`) ingests unique logs and OTLP metrics, creates SQL and PromQL tiles with variables through this frontend, verifies their stored classic shapes, and hard-loads the same dashboard on the classic origin. It also renders a literal classic-shaped Report fixture in this frontend, checks unknown-field preservation, and verifies admin ownership rules and `limit=0`. Cleanup removes all test dashboards, datasets, users and roles. The classic comparison portion of this historical test requires a separate older server version that still embeds Prism; current builds have no classic UI mode. Both origins must point at the same disposable server and use the same hostname to share login cookies across ports (use `127.0.0.1` for both; mixing it with `localhost` fails). The spec checks this before ingesting: +The Dashboards live spec (`e2e-live/dashboards.spec.ts`) ingests unique logs and OTLP metrics, creates SQL and PromQL tiles with variables through this frontend and verifies their stored classic-compatible shapes. It also renders a literal classic-shaped Report fixture in this frontend, checks unknown-field preservation, and verifies admin ownership rules and `limit=0`. Cleanup removes all test dashboards, datasets, users and roles. These tests need only `PARSEABLE_LIVE_URL` and run against a current native-only server. + +The historical classic hard-load comparison is skipped unless you set `PARSEABLE_LIVE_CLASSIC=true`. It then also requires `PARSEABLE_LIVE_SERVER_URL` to point at an older Parseable server that still embeds the classic Prism UI at `/dashboards/:id`; current builds have no classic UI mode, so do not opt in against them. Both origins must use the same disposable backend and the same hostname to share login cookies across ports (use `127.0.0.1` for both; mixing it with `localhost` fails). The spec checks this before ingesting. Native-only run: + +```sh +PARSEABLE_LIVE_URL=http://127.0.0.1:8030 PARSEABLE_LIVE_USERNAME=admin PARSEABLE_LIVE_PASSWORD=admin \ + npx playwright test -c playwright.live.config.ts e2e-live/dashboards.spec.ts +``` + +Classic comparison against an older classic-capable server on port 8030, with this frontend proxied to it: ```sh PARSEABLE_PROXY_TARGET=http://127.0.0.1:8030 npx vite --host 127.0.0.1 --port 8271 @@ -146,7 +155,7 @@ flock /home/ajs/.cache/parseable-playwright.lock bash -c ' TMPDIR=/home/ajs/.cache/dash-tmp \ PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium \ PARSEABLE_LIVE_URL=http://127.0.0.1:8271 PARSEABLE_LIVE_BASE= \ - PARSEABLE_LIVE_SERVER_URL=http://127.0.0.1:8030 \ + PARSEABLE_LIVE_CLASSIC=true PARSEABLE_LIVE_SERVER_URL=http://127.0.0.1:8030 \ PARSEABLE_LIVE_USERNAME=admin PARSEABLE_LIVE_PASSWORD=admin \ npx playwright test -c playwright.live.config.ts e2e-live/dashboards.spec.ts ' diff --git a/frontend/docs/api-contracts.md b/frontend/docs/api-contracts.md index 628b5c76f..29adb7fb3 100644 --- a/frontend/docs/api-contracts.md +++ b/frontend/docs/api-contracts.md @@ -16,7 +16,7 @@ The client is React independent and uses the Rust handlers in this repository. ` Rows and histogram issue separate `POST /api/v1/query` requests with one captured UTC start/end pair and identical search/group predicates. Rows default to `LIMIT 100`, with selectable limits 1,000, 5,000 and 10,000; histogram SQL uses `DATE_BIN`, `COUNT(*)`, grouping and ordering without a result-row limit. An explicit half-open timestamp predicate enforces the same window for both reads. Dataset information selects the timestamp field. The chart fills 48 fixed elapsed-time buckets and distinguishes matching totals from loaded rows, with independent loading/error/retry states. `/counts` manifest statistics are not used for exact totals. Refresh captures new bounds; changing filters preserves the current window. Two-level AND/OR expressions allow 12 groups and 50 rules, escape identifiers/literals, ignore empty groups, and support `=`/`!=` with SQL NULL semantics (NULL is excluded). -Saved log views use existing user/tenant-authorized `GET/POST /api/v1/filters` and `PUT/DELETE /api/v1/filters/{encoded id}` through `listLogViews`, `createLogView`, `updateLogView` and `deleteLogView`. Documents keep `stream_name`, `filter_name`, SQL `query {filter_type:"sql",filter_query,filter_builder:null}`, optional classic `time_filter {from,to}`, and flattened `explorer_state` containing validated version-1 JSON. State includes dataset, range, search, grouped filters, columns, timezone, result limit, wrapping and histogram visibility. Saved SQL uses the dataset timestamp partition rather than assuming `p_timestamp`. New views retain dataset-scoped `stream_name` for gradual adoption. Compatible owned views offer **Convert to SQL view**, using a bodyless `POST /api/v1/filters/{encoded id}/convert-to-sql` through `convertLogViewToSql`. Conversion retains the ID, owner, query, explorer state and other metadata, and is idempotent. Canonical `stream_name:"sql"` views resolve their dataset from validated explorer state; malformed or unrelated canonical states do not appear in this dataset’s list. Updates preserve canonical SQL storage and unrelated server metadata. Only verified owners can update/delete/convert a view in this UI; authorized views owned by others are labeled read only and can still be applied or copied with Save new view. Backend authorization remains authoritative and also permits administrators to convert views. Opening the dialog refetches the list so views created in another tab appear. Classic/incompatible views are labeled and cannot be applied or overwritten here. Failed CRUD remains visible, without browser-storage fallback. +Saved log views use existing user/tenant-authorized `GET/POST /api/v1/filters` and `PUT/DELETE /api/v1/filters/{encoded id}` through `listLogViews`, `createLogView`, `updateLogView` and `deleteLogView`. Documents keep `stream_name`, `filter_name`, SQL `query {filter_type:"sql",filter_query,filter_builder:null}`, optional classic `time_filter {from,to}`, and flattened `explorer_state` containing validated version-1 JSON. State includes dataset, range, search, grouped filters, columns, timezone, result limit, wrapping and histogram visibility. Saved SQL uses the dataset timestamp partition rather than assuming `p_timestamp`. New views retain dataset-scoped `stream_name` for gradual adoption. Compatible owned views offer **Convert to SQL view**, using a bodyless `POST /api/v1/filters/{encoded id}/convert-to-sql` through `convertLogViewToSql`. Conversion retains the ID, owner, query, explorer state and other metadata, and is idempotent. Canonical `stream_name:"sql"` views resolve their dataset from validated explorer state; malformed or unrelated canonical states do not appear in this dataset’s list. Updates preserve canonical SQL storage and unrelated server metadata. Only verified owners can update/delete/convert a view in this UI; authorized views owned by others are labeled read only and can still be applied or copied with Save new view. Backend authorization remains authoritative and also permits administrators to convert views. Opening the dialog refetches the list so views created in another tab appear. Classic/incompatible views are labeled and cannot be applied or overwritten here. Views storing SQL offer **Open in SQL editor** without updating the server record; unreadable saved time bounds show a warning before using the default range. The SQL sidebar also lists these server-saved SQL records read only, with explicit copy to the independent browser library. Builder/search records without SQL remain unsupported. Failed CRUD remains visible, without browser-storage fallback. Copy links put raw version-1 JSON in the URLSearchParams `logView` parameter with fixed absolute bounds. Validation bounds state at 64KiB, checks timestamp/timezone/limit/filter constraints, rejects duplicate identifiers/NUL characters and discards unrecognized keys. Clipboard feedback follows successful copying, with a selectable fallback for blocked clipboard access. The native `logView` state is consumed after applying it, preserving later edits on reload; classic `view` retains its saved-view-ID meaning. Classic Logs parameters remain supported; linked datasets remain subject to server authorization. @@ -109,11 +109,11 @@ All alert and target operations use the cookie-authenticated `/api/v1` prefix. D | `updateAlertTarget(id, body)` | `PUT /api/v1/targets/{id}` | Bare masked target; name is immutable | | `deleteAlertTarget(id)` | `DELETE /api/v1/targets/{id}` | Bare masked target | -Summaries include `queryType` (same camelCase values as detail) but omit query and evaluation config. Older servers omit `queryType`; for those threshold rows the list falls back to detail reads with at most six requests in flight, caches resolved types per client by alert id across reloads (id and query type are immutable), retries failed lookups on the next reload and retains other rows when one lookup fails. Non-threshold rows are never read through detail. It does not guess from names or the optional PromQL config. Search, tag filtering, sorting and 25-row pagination are local. Summary dates use chrono display formatting; detail dates are RFC3339. Notification state is `"notify"`, a summary timestamp (including the maximum-year indefinite sentinel), or detail `{mute:"indefinite"|timestamp}`. Expired mutes are displayed as notifications on. Custom mute dates are interpreted in UTC. +Summaries include `queryType` (same camelCase values as detail) but omit query and evaluation config. Older servers omit `queryType`; for those threshold rows the list falls back to detail reads with at most six requests in flight, caches resolved types per client by alert id across reloads (current servers provide authoritative query types in summaries), retries failed lookups on the next reload and retains other rows when one lookup fails. Non-threshold rows are never read through detail. It does not guess from names or the optional PromQL config. Search, tag filtering, sorting and 25-row pagination are local. Summary dates use chrono display formatting; detail dates are RFC3339. Notification state is `"notify"`, a summary timestamp (including the maximum-year indefinite sentinel), or detail `{mute:"indefinite"|timestamp}`. Expired mutes are displayed as notifications on. Custom mute dates are interpreted in UTC. PromQL threshold creation requires `about().capabilities.promqlAlerts === true`; absence means unavailable even on a paid license. Discovery reuses Metrics' OTLP dataset checks and dataset-scoped editor metadata. Frequency is 1–1440 whole **minutes**, hold duration defaults to `0s` and cannot exceed 30 days, threshold values must be finite, and at most 20 targets are allowed. SQL uses `queryType:"code"`; the server derives datasets from SQL and enforces one numeric aggregate and no subqueries. Both SQL and PromQL accept empty targets for state tracking. New rules send `notificationConfig.interval:1`; no recurring-reminder control is exposed. Local validation handles fields and PromQL syntax/type, while engine and policy validation stays with the server. -PUT replaces flattened extras. Payload helpers preserve user extras and resend the complete PromQL config while omitting runtime, execution identity and managed response fields. Editing a PromQL rule resets runtime and captures the editing user as owner. Dataset and type controls are locked on edit. Both query editors become read-only during a pending save and unlock on failure. Tags are individual fields backed by an array; edit and duplicate preserve commas, spaces, ordering and repeated tags exactly. Builder rules are displayed with SQL and a read-only note, without Edit. Duplicate opens a pre-filled form before saving. Dashboard handoff accepts `dataset`, `queryBuilderType=promql|sql`, `alertQuery` and `title` on `/alerts/new`. Classic `/alerts/create` redirects there with its query string intact. React Router's data router blocks dirty-form navigation, including browser Back; document navigation uses `beforeunload`. +PUT replaces flattened extras. Payload helpers preserve user extras and resend the complete PromQL config while omitting runtime, execution identity and managed response fields. Editing a PromQL rule resets runtime and captures the editing user as owner. Dataset and type controls are locked on edit. Both query editors become read-only during a pending save and unlock on failure. Tags are individual fields backed by an array; edit and duplicate preserve commas, spaces, ordering and repeated tags exactly. Builder rules offer **Convert to SQL rule** to users with write permission. The form displays the stored SQL and settings; only **Convert and save** changes `queryType` to `code`. Cancelling or a rejected save leaves the stored builder rule unchanged. The server preserves enabled and mute state. Builder duplication remains unavailable. Duplicate opens a pre-filled form before saving. Dashboard handoff accepts `dataset`, `queryBuilderType=promql|sql`, `alertQuery` and `title` on `/alerts/new`. Classic `/alerts/create` redirects there with its query string intact. React Router's data router blocks dirty-form navigation, including browser Back; document navigation uses `beforeunload`. Preview never calls `evaluate_alert`: PromQL uses the existing instant-query client at `/prometheus/api/v1/query`, compares each finite vector sample locally, and displays labels/value/breached or within. SQL uses the query client with `startTime:evalStart` and `endTime:"now"`, matching the rolling window, and displays returned values. Draft query/dataset/threshold changes clear and abort the previous preview. Preview, instances and deliveries share compact `key=value` label chips. Detail uses a last-hour range query with readable series names and a horizontal threshold reference included in the y range. Displayed alert timestamps and mute deadlines use 24-hour UTC. Evaluate-now feedback refreshes the config again after two seconds. Alert and target rows use a shared keyboard menu; Escape/selection return focus to its trigger. Successful row deletion focuses the next or previous surviving action, falling back to search when the list is empty. diff --git a/frontend/docs/prism-parity.md b/frontend/docs/prism-parity.md index b2c229808..6976de5e7 100644 --- a/frontend/docs/prism-parity.md +++ b/frontend/docs/prism-parity.md @@ -35,7 +35,7 @@ This table records the baseline gaps found before the current iteration. The fin | P2 Explorer depth | Server histogram counts, saved views, grouped filters, related-log context, export/copy-link menus and wide-schema virtualization | “Log context”, “Referenced line”, “Search context data”, `[aria-label="Close row context"]`; separate result-row histogram from total counts | LogsExplore @25583/30777/58962. `/api/v1/counts` and `/api/v1/query/context` are static endpoint evidence until verified | | P2 SQL depth | Implemented: query tabs, multi-dataset picker, visualizations, browser-local saved-query library and resize panels | “New tab”, “Select up to 10 datasets”, “Add to Dashboard”, `#legend-container-sql-editor`, title “Drag to resize sidebar/right panel” | SQL @33593/36909/52928. Static SQL default limit is 500; do not silently pretend existing 100-row explorer is equivalent | | P2 Dataset management | Implemented: create logs/metrics/traces with one custom partition; overview/stats/schema; retention set/clear; hot tier set/disable when configured; typed deletion | “Create dataset”, “Manage {name}”, “Delete dataset”, `[data-dialog-confirm]`; action-specific inline 403 messages | Static schema upload, tags/labels, existing partition edits and index controls remain excluded. Time partitions cannot be set at creation | -| P2 Dashboard depth | Remaining depth: sections UI, drag/resize; every classic chart type renders natively; stored section/style data is preserved | `[data-section-drag-handle=true]`, `[data-section-drop-area=true]`, `#var-form`, `.react-resizable-handle`; “Rename/Delete/Reorder {section}” | Dashboard @218548/229658/245810. These markers should be added only with their behavior | +| P2 Dashboard depth | Implemented: sections, drag/resize and every classic chart type; stored section/style data is preserved | `[data-section-drag-handle=true]`, `[data-section-drop-area=true]`, `#var-form`, `.react-resizable-handle`; “Rename/Delete/Reorder {section}” | Dashboard @218548/229658/245810. These markers should be added only with their behavior | ## Visual and selector rules @@ -72,7 +72,7 @@ Team administration is implemented at `/team` with Prism-compatible `roles`, `us Alerts is native under Observe, after Metrics; the Classic UI sidebar group is removed. `/alerts`, `/alerts/new`, `/alerts/:id`, `/alerts/:id/edit` and `/alerts/targets` cover list/search/tags/sort/pagination, PromQL and SQL thresholds, non-notifying previews, runtime instances/delivery failures, hold duration, optional targets, evaluate/enable/mute/duplicate/delete and target management. Fork behavior survives: explicit `promqlAlerts` capability, independent per-series state, transition-based notifications, three delivery attempts, state-only rules and dashboard handoff URL fields. Frequency uses the server's minute units rather than classic's inconsistent day options. -Alerts differences: builder rules show their SQL read-only; anomaly/forecast, AI, Enterprise upsells, recurring reminders and outbound-policy editing are omitted. There is no history because the server exposes none. Row actions are visible buttons, target selection uses checkboxes, mute presets use a dialog and custom times are UTC. Endpoint edits require re-entry because the server returns masked destinations. PromQL detail uses the existing chart with a threshold note. `/alerts*` comparison links open classic `/alerts`; pixel parity with classic Alerts has not been established. +Alerts differences: builder rules expose their stored SQL and offer explicit conversion to editable SQL rules; anomaly/forecast, AI, Enterprise upsells and recurring reminders are omitted. Network access settings expose the supported alert-target policy. There is no history because the server exposes none. Row actions are visible buttons, target selection uses checkboxes, mute presets use a dialog and custom times are UTC. Endpoint edits require re-entry because the server returns masked destinations. PromQL detail uses the existing chart with a threshold note. Classic comparison links are removed; pixel parity with classic Alerts has not been established. Remaining Team differences: enterprise native-group CRUD and SaaS email invites are out of scope; OAuth users are created by provider sign-in. Creation offers dataset scopes, while existing LLM and unrestricted privileges are displayed and preserved. Forms use native selects and checkbox role lists rather than Prism's comboboxes; row actions are visible keyboard-reachable buttons. A visible Default badge replaces the role asterisk. Header and password warning wording follows this frontend brief, and API-key creation explains that later copying remains available through GET. Root/internal privileges are shown only in existing user data. Visual equality with the original Team screen and interoperability with a real provider have not been established by this implementation; completed validation is recorded separately by the reviewer. @@ -88,7 +88,7 @@ Intentional differences remain: only implemented navigation is shown; the login SQL `code`, stored-SQL `builder` and PromQL tiles render through shared query controls; the editor writes classic `chartQuery`, `dbName`, aligned `promqlQueryType`, config, finite grid layout and tile ULIDs. Six variable types and classic interpolation use URL-only selections; unknown dollar tokens remain untouched and only defined variables gate queries or participate in dependency cycles. Time range is dashboard-specific and persists in classic form on Save. ID repair stays separate from user edits, with an owner-only next-save notice and fresh IDs on Duplicate. Tile moves follow the displayed compacted reading order; chart axis titles precede scrollable legends, and variable errors keep controls aligned. Import/export, query viewing, exploration and concrete Create alert links are implemented. Unknown top-level/tile/config fields, Report type, variables, sections and sectionId are retained. -This scope establishes document interchangeability, not pixel parity. Every chart type classic stores renders natively (see the chart renderer scope below); unknown tile and chart types display named placeholders, with a classic link only when classic is available. Import and Duplicate preserve `dashboardType`, including creating copies of Reports. The Create form makes Dashboard documents. Sections render in classic order with independent vertically compacted grids; section authoring, reordering and collapsing remain excluded. AI, Enterprise/pricing, templates/CDN, visual builder authoring, drag/resize, present mode, PNG export and auto-refresh are also excluded. UTC remains the custom time editor's timezone. Relative ranges stay anchored until a range change or Refresh, matching classic. Interrupted local-import batches and attribution of browser-local copies to individual accounts remain follow-ups. Actual test outcomes and the classic render capture are recorded in [Dashboards validation](validation-review.md#dashboards-validation-2026-10-10). +This scope establishes document interchangeability, not pixel parity. Every chart type classic stores renders natively (see the chart renderer scope below); unknown tile and chart types display named placeholders, without a classic fallback link. Import and Duplicate preserve `dashboardType`, including creating copies of Reports. The Create form makes Dashboard documents. Sections render in classic order with independent vertically compacted grids; section authoring, reordering, collapsing, tile drag/resize, present mode, PNG export and auto-refresh are implemented (see the current README). AI, Enterprise/pricing, templates/CDN and visual builder authoring remain excluded. UTC remains the custom time editor's timezone. Relative ranges stay anchored until a range change or Refresh, matching classic. Interrupted local-import batches and attribution of browser-local copies to individual accounts remain follow-ups. Actual test outcomes and the classic render capture are recorded in [Dashboards validation](validation-review.md#dashboards-validation-2026-10-10). ## Dashboard chart renderers (2026-10-11) @@ -133,8 +133,8 @@ The previously classic-only routes below now resolve to native pages. `classicUi | `/errors`, `/errors/:dataset` | Discover trace and log error datasets; group issues, query full-range frequency, inspect occurrences, sort/page issues and OTLP spans, and open trace or SQL investigations | Exact service/type/message grouping with recognized severity schemas; persistent issue resolution, fuzzy grouping and cross-dataset correlation remain unavailable. See [Errors contracts and tests](error-analytics.md). | | `/onboarding` | Discover accessible datasets, link each to its confirmed explorer, and guide ingestion and team setup | Dataset presence is not presented as proof of event arrival. Existing Add data owns ingestion configuration and verification. | | `/new` | Explain server workspace scope; open connection settings or native setup | No API in this checkout provisions a separate hosted workspace. | -| `/keystone`, `/keystone/:id` | Native availability page and classic investigation handoff where available | No supported investigation/conversation API in this checkout. No claim that an ID exists or is accessible. | -| `/settings/llm-key` | Native availability pages and deployment-dependent classic handoff | No provider-key management API. No credentials are read, stored or submitted. | +| `/keystone`, `/keystone/:id` | Native availability page without a classic handoff | No supported investigation/conversation API in this checkout. No claim that an ID exists or is accessible. | +| `/settings/llm-key` | Native availability page without a classic handoff | No provider-key management API. No credentials are read, stored or submitted. | Network access (`/settings/network-access`) edits the real outbound alert-target policy via `GET` and `PUT /api/v1/alert-target-policy`. It is distinct from ingress access control. The full policy includes private-IP and invalid-TLS switches plus allowed/denied domains and CIDRs. Backend authorization and validation remain authoritative; failed saves retain the draft. The policy cache is per server node and loaded at startup; cross-node propagation is a backend limitation, not asserted by the UI. diff --git a/frontend/docs/validation-review.md b/frontend/docs/validation-review.md index c6bcded21..29ef7d760 100644 --- a/frontend/docs/validation-review.md +++ b/frontend/docs/validation-review.md @@ -423,6 +423,8 @@ flock /home/ajs/.cache/parseable-playwright.lock bash -c ' ' ``` +Current native-only builds require `PARSEABLE_LIVE_CLASSIC=true` to opt into the historical classic-origin comparison above. Set it only when that origin still serves classic Prism against the same disposable backend; native dashboard tests need only `PARSEABLE_LIVE_URL`. + ## Alerts validation (2026-10-09) This section records the frontend fixes and completed checks for the four supplied Opus reviews. diff --git a/frontend/e2e-live/dashboards.spec.ts b/frontend/e2e-live/dashboards.spec.ts index 7920041a0..9fc93e969 100644 --- a/frontend/e2e-live/dashboards.spec.ts +++ b/frontend/e2e-live/dashboards.spec.ts @@ -6,6 +6,9 @@ import classicFixture from './fixtures/classic-dashboard.json' with { type: 'jso import type { Dashboard, DashboardSummary } from '../src/lib/types'; const live = process.env.PARSEABLE_LIVE_URL; const server = process.env.PARSEABLE_LIVE_SERVER_URL; +// Current builds serve only the native UI. The classic hard-load comparison needs an +// older server that still embeds Prism, so it runs only when explicitly requested. +const classic = process.env.PARSEABLE_LIVE_CLASSIC === 'true'; const username = process.env.PARSEABLE_LIVE_USERNAME || 'frontend-smoke', password = process.env.PARSEABLE_LIVE_PASSWORD || 'local-smoke-password'; const base = (process.env.PARSEABLE_LIVE_BASE ?? '').replace(/\/+$/, ''); @@ -31,10 +34,7 @@ let primary: Dashboard, ingestAttempted = false; const sql = `SELECT * FROM "${logs}" ORDER BY "p_timestamp" DESC LIMIT 12`; const promql = `{__name__="${metric}",host=~"$host"}`; -test.skip( - !live || !server, - 'Set PARSEABLE_LIVE_URL and PARSEABLE_LIVE_SERVER_URL to opt in to the classic interchangeability suite.', -); +test.skip(!live, 'Set PARSEABLE_LIVE_URL to opt in to the live dashboards suite.'); // Tests 3 and 4 are independent; a create failure must not skip reverse or ownership checks. async function signIn(page: Page, path: string) { await page.goto(app(`/login?next=${encodeURIComponent(path)}`)); @@ -60,11 +60,14 @@ async function apiCreate(request: APIRequestContext, body: unknown, auth = heade return doc; } test.beforeAll(async ({ request }) => { - // Cookies are shared across ports only when both origins use the same host. - if (new URL(live!).hostname !== new URL(server!).hostname) - throw new Error( - 'The native and classic origins must use the same host to share login cookies.', - ); + if (classic) { + if (!server) throw new Error('PARSEABLE_LIVE_CLASSIC=true requires PARSEABLE_LIVE_SERVER_URL.'); + // Cookies are shared across ports only when both origins use the same host. + if (new URL(live!).hostname !== new URL(server).hostname) + throw new Error( + 'The native and classic origins must use the same host to share login cookies.', + ); + } const now = Math.floor(Date.now() / 1000); ingestAttempted = true; const points = ['node-a', 'node-b'].flatMap((host, hostIndex) => @@ -141,7 +144,7 @@ test.beforeAll(async ({ request }) => { .toBe(12); }); test.afterAll(async ({ request }) => { - if (!live || !server) return; + if (!live) return; test.setTimeout(60000); const errors: Error[] = []; async function cleanup(label: string, operation: () => Promise) { @@ -340,6 +343,10 @@ test('create SQL and PromQL tiles and variables in the native UI; verify exact c test('hard-load the saved dashboard on the classic origin; both tiles render without errors', async ({ page, }) => { + test.skip( + !classic, + 'Set PARSEABLE_LIVE_CLASSIC=true and PARSEABLE_LIVE_SERVER_URL to an older server that still embeds the classic UI.', + ); expect( primary, 'The create test must provide a saved dashboard for the classic check.', diff --git a/frontend/e2e-live/server.spec.ts b/frontend/e2e-live/server.spec.ts index d3432e914..e7b4c88ef 100644 --- a/frontend/e2e-live/server.spec.ts +++ b/frontend/e2e-live/server.spec.ts @@ -5,7 +5,7 @@ const username = process.env.PARSEABLE_LIVE_USERNAME || 'frontend-smoke'; const password = process.env.PARSEABLE_LIVE_PASSWORD || 'local-smoke-password'; const dataset = process.env.PARSEABLE_LIVE_DATASET || 'frontend_smoke'; const datasetPath = `/logs/explore/${encodeURIComponent(dataset)}`; -// Page paths are at the root; set `/next` for a server started with `P_UI_ROOT=classic`. +// Current servers serve page paths at root; override only for explicitly mounted previews. const base = (process.env.PARSEABLE_LIVE_BASE ?? '').replace(/\/+$/, ''); /** Document path for an application route, mirroring `appPath` in src/lib/config.ts. */ diff --git a/frontend/e2e-live/team.spec.ts b/frontend/e2e-live/team.spec.ts index baa39dd58..c3fde2ca9 100644 --- a/frontend/e2e-live/team.spec.ts +++ b/frontend/e2e-live/team.spec.ts @@ -7,7 +7,7 @@ const username = process.env.PARSEABLE_LIVE_USERNAME || 'frontend-smoke'; const password = process.env.PARSEABLE_LIVE_PASSWORD || 'local-smoke-password'; const dataset = process.env.PARSEABLE_LIVE_DATASET || 'frontend_smoke'; const datasetPath = `/logs/explore/${encodeURIComponent(dataset)}`; -// Page paths are at the root; set `/next` for a server started with `P_UI_ROOT=classic`. +// Current servers serve page paths at root; override only for explicitly mounted previews. const base = (process.env.PARSEABLE_LIVE_BASE ?? '').replace(/\/+$/, ''); /** Document path for an application route, mirroring `appPath` in src/lib/config.ts. */ diff --git a/frontend/e2e/classic-links.spec.ts b/frontend/e2e/classic-links.spec.ts index 048c5a4f6..f08c51926 100644 --- a/frontend/e2e/classic-links.spec.ts +++ b/frontend/e2e/classic-links.spec.ts @@ -37,7 +37,7 @@ test('a classic Logs bookmark opens with its range and filters', async ({ page } await expect(page.getByTestId('filter-pill-value')).toHaveText(['error']); await expect(page.getByLabel('Time range', { exact: true })).toHaveValue('custom'); await expect(page.getByRole('status').filter({ hasText: 'Not carried over' })).toHaveText( - /time zone Asia\/Kolkata \(times are shown in UTC\), 1 filter\./, + /time zone Asia\/Kolkata \(not restored from this link\), 1 filter\./, ); await expectLogsURL(page, '/logs/explore/app?keep=1'); await expect.poll(() => queries.length).toBeGreaterThan(0); @@ -316,7 +316,7 @@ test('a classic PromQL link that does not parse opens as classic shows it', asyn await expect(editor).toHaveText('WHERE >'); await expect(page).toHaveURL(/query=WHERE\+%3E/); // Translated once: the notice is the link's, not a second rewrite's "1 filter". - await expect(notice(page)).toHaveText(/time zone Asia\/Kolkata \(times are shown in UTC\)\./); + await expect(notice(page)).toHaveText(/time zone Asia\/Kolkata \(not restored from this link\)\./); }); test('a dismissed Metrics notice stays dismissed after reconnecting', async ({ page }) => { diff --git a/frontend/e2e/native-regressions.spec.ts b/frontend/e2e/native-regressions.spec.ts index f29733ced..6aa41e225 100644 --- a/frontend/e2e/native-regressions.spec.ts +++ b/frontend/e2e/native-regressions.spec.ts @@ -96,39 +96,31 @@ test('demo agents do not attribute synthetic telemetry to an unknown dataset', a await expect(page.getByRole('table')).toHaveCount(0); }); -test('native setup and settings links remain inside a /next mount', async ({ page }) => { - await page.route('**/next/**', async (route) => { - if (route.request().resourceType() !== 'document') return route.continue(); - const response = await route.fetch(); - await route.fulfill({ - response, - body: (await response.text()).replace('', ''), - }); - }); +test('native setup and settings stay at root without classic UI handoffs', async ({ page }) => { await page.route('**/api/**', (route) => route.fulfill({ json: [] })); - await page.goto('/next/new'); + await page.goto('/new'); await expect( page.getByRole('heading', { name: 'Set up a workspace', exact: true }), ).toBeVisible(); await expect(page.getByRole('link', { name: 'Set up this server', exact: true })).toHaveAttribute( 'href', - '/next/onboarding', + '/onboarding', ); await expect( page.getByRole('link', { name: 'open workspace creation in classic UI', exact: true }), ).toHaveCount(0); await page.getByRole('link', { name: 'Set up this server', exact: true }).click(); - await expect(page).toHaveURL(/\/next\/onboarding$/); + await expect(page).toHaveURL(/\/onboarding$/); await expect(page.getByRole('link', { name: 'Set up ingestion', exact: true })).toHaveAttribute( 'href', - '/next/add-data', + '/add-data', ); - await page.goto('/next/keystone/investigation'); + await page.goto('/keystone/investigation'); await expect(page.getByRole('link', { name: 'LLM settings', exact: true })).toHaveAttribute( 'href', - '/next/settings/llm-key', + '/settings/llm-key', ); await expect( page.getByRole('link', { name: 'Open investigation in classic UI', exact: true }), - ).toHaveAttribute('href', '/keystone/investigation'); + ).toHaveCount(0); }); diff --git a/frontend/src/lib/classicUi.test.ts b/frontend/src/lib/classicUi.test.ts index ae9e1f185..6bcb2ea7c 100644 --- a/frontend/src/lib/classicUi.test.ts +++ b/frontend/src/lib/classicUi.test.ts @@ -243,7 +243,7 @@ describe('classic Logs links', () => { "start=1h&end=now&type=fixed&tz=Asia%2FKolkata&groupBy=host&view=f1&query=WHERE+text+msg+ILIKE+'%25x%25'", ).dropped, ).toEqual([ - 'time zone Asia/Kolkata (times are shown in UTC)', + 'time zone Asia/Kolkata (not restored from this link)', '1 filter', 'grouping', 'saved view', @@ -385,7 +385,7 @@ describe('classic Metrics links', () => { ).toEqual({ search: '?step=30s&query=up', dropped: [ - 'time zone Asia/Kolkata (times are shown in UTC)', + 'time zone Asia/Kolkata (not restored from this link)', '1 filter', 'grouping', 'saved view', diff --git a/frontend/src/lib/classicUi.ts b/frontend/src/lib/classicUi.ts index 01569459c..b1edc4c40 100644 --- a/frontend/src/lib/classicUi.ts +++ b/frontend/src/lib/classicUi.ts @@ -283,7 +283,7 @@ function droppedFromLink(params: URLSearchParams, unreadRange: boolean, filters: const dropped: string[] = []; if (unreadRange) dropped.push('time range'); const tz = params.get('tz'); - if (tz && tz !== 'UTC') dropped.push(`time zone ${tz} (times are shown in UTC)`); + if (tz && tz !== 'UTC') dropped.push(`time zone ${tz} (not restored from this link)`); if (filters) dropped.push(filters === 1 ? '1 filter' : `${filters} filters`); if (params.get('groupBy')) dropped.push('grouping'); if (params.get('view')) dropped.push('saved view'); diff --git a/frontend/src/lib/config.ts b/frontend/src/lib/config.ts index db1a2eefd..1cb0ed5e3 100644 --- a/frontend/src/lib/config.ts +++ b/frontend/src/lib/config.ts @@ -3,8 +3,8 @@ export const demoEnabled = import.meta.env.DEV || import.meta.env.VITE_ENABLE_DE /** * Router base without a trailing slash, derived from the document base. - * The build loads its assets relative to ``, which the server points at - * the mount, so one build serves `/` and `/next`. + * The native-only server serves the build at `/`; old `/next` URLs redirect + * there. Reading the document base also supports explicitly mounted previews. */ export const basePath = ( (typeof document === 'undefined' ? null : document.querySelector('base')?.getAttribute('href')) || diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index eec1e70ee..1c24415fa 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -4,6 +4,8 @@ export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), ''); return { plugins: [react()], + // Keep bundled dependency notices in the embedded assets and release ZIP. + build: { license: { fileName: 'THIRD_PARTY_LICENSES.md' } }, server: { port: 5173, strictPort: true, From f23bba1b3d79da60f8e32fe3f4e03ff20198c727 Mon Sep 17 00:00:00 2001 From: VibeSWE <338779468+VibeSWE@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:52:28 -0500 Subject: [PATCH 6/8] Preserve saved query ranges and distinguish browser copies --- frontend/e2e/sql-server-saved-queries.spec.ts | 135 +++++++++++++++++- frontend/src/features/sql/SqlPage.tsx | 9 +- frontend/src/features/sql/SqlSidebar.tsx | 11 +- .../features/sql/serverSavedQueries.test.ts | 115 ++++++++++++++- .../src/features/sql/serverSavedQueries.ts | 108 ++++++++++++-- frontend/src/features/sql/sql-sidebar.css | 12 ++ 6 files changed, 370 insertions(+), 20 deletions(-) diff --git a/frontend/e2e/sql-server-saved-queries.spec.ts b/frontend/e2e/sql-server-saved-queries.spec.ts index 477de4f7e..179b764f5 100644 --- a/frontend/e2e/sql-server-saved-queries.spec.ts +++ b/frontend/e2e/sql-server-saved-queries.spec.ts @@ -271,7 +271,10 @@ test('classic Logs views open their stored SQL read only; builder views stay una baseURL, }) => { await context.addCookies([{ name: 'user_id', value: 'reader', url: baseURL! }]); - const views = [{ ...classicLogs, user_id: hash('reader') }, classicBuilder]; + const views = [ + { ...classicLogs, user_id: hash('reader'), time_filter: { from: 'now-2d', to: 'now' } }, + classicBuilder, + ]; const before = structuredClone(views); const writes = await serve(page, async () => ({ json: views })); await page.goto('/logs/explore/events'); @@ -294,3 +297,133 @@ test('classic Logs views open their stored SQL read only; builder views stay una expect(writes).toEqual([]); expect(views).toEqual(before); }); + +const nativeWeek: SavedLogView = { + version: 'v2', + user_id: hash('reader'), + stream_name: 'sql', + filter_name: 'Native week', + filter_id: '01HNATIVE', + query: { filter_type: 'sql', filter_query: 'SELECT * FROM "events" LIMIT 500' }, + explorer_state: JSON.stringify({ + version: 1, + dataset: 'events', + range: '7d', + search: '', + expression: { combinator: 'and', groups: [] }, + timezone: 'UTC', + limit: 500, + wrap: false, + showHistogram: true, + }), + time_filter: null, +}; + +test('classic relative windows keep supported presets and warn before using a fallback', async ({ + page, + context, + baseURL, +}) => { + await context.addCookies([{ name: 'user_id', value: 'reader', url: baseURL! }]); + const writes = await serve(page, async () => ({ + json: [ + { ...classicLogs, filter_name: 'Week errors', time_filter: { from: 'now-7d', to: 'now' } }, + { + ...classicLogs, + filter_id: '01HTWODAYS', + filter_name: 'Two day errors', + query: { filter_type: 'sql', filter_query: 'SELECT 2' }, + time_filter: { from: 'now-2d', to: 'now' }, + }, + ], + })); + await page.goto('/sql-editor'); + const list = serverList(page); + await expect(list.getByRole('note')).toHaveCount(1); + await expect(list.getByRole('note')).toHaveText( + 'Saved time range now-2d to now is not supported; opening or copying uses the last hour.', + ); + await list.getByRole('button', { name: /^Week errors/ }).click(); + await expect(page.getByRole('textbox', { name: 'SQL query' })).toHaveText( + classicLogs.query.filter_query!, + ); + await expect(page.getByLabel('Time range', { exact: true })).toHaveValue('7d'); + await list.getByRole('button', { name: /^Two day errors/ }).click(); + await expect(page.getByRole('textbox', { name: 'SQL query' })).toHaveText('SELECT 2'); + await expect(page.getByLabel('Time range', { exact: true })).toHaveValue('1h'); + expect(writes).toEqual([]); +}); + +test('native Logs views open in SQL with their saved relative range', async ({ + page, + context, + baseURL, +}) => { + await context.addCookies([{ name: 'user_id', value: 'reader', url: baseURL! }]); + const views = [nativeWeek]; + const before = structuredClone(views); + const writes = await serve(page, async () => ({ json: views })); + await page.goto('/logs/explore/events'); + await page.getByRole('button', { name: 'Saved views', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: 'Saved log views' }); + await dialog.getByLabel('Saved log view').selectOption({ label: 'Native week' }); + await dialog.getByRole('button', { name: 'Open in SQL editor' }).click(); + await expect(page.getByRole('textbox', { name: 'SQL query' })).toHaveText( + nativeWeek.query.filter_query!, + ); + await expect(page.getByLabel('Time range', { exact: true })).toHaveValue('7d'); + // The consumed handoff leaves no query, range or bounds in the URL. + await expect(page).toHaveURL(/\/sql-editor$/); + + await serverList(page) + .getByRole('button', { name: /^Native week/ }) + .click(); + await expect(page.getByRole('tab', { name: 'Native week', exact: true })).toHaveCount(1); + await expect(page.getByLabel('Time range', { exact: true })).toHaveValue('7d'); + expect(writes).toEqual([]); + expect(views).toEqual(before); +}); + +test('copies same-named server queries with different windows or datasets separately', async ({ + page, + context, + baseURL, +}) => { + await context.addCookies([{ name: 'user_id', value: 'reader', url: baseURL! }]); + const writes = await serve(page, async () => ({ + json: [ + classicSql('reader'), + classicSql('reader', { + filter_id: '01HOCTOBER5', + time_filter: { from: '2026-10-05T00:00:00.000Z', to: '2026-10-06T00:00:00.000Z' }, + }), + classicSql('reader', { filter_id: '01HSCOPED', stream_name: 'events' }), + ], + })); + await page.goto('/sql-editor'); + const copies = page.getByRole('button', { + name: 'Copy server query Slow requests to this browser', + }); + await expect(copies).toHaveCount(3); + for (const index of [0, 1, 2]) { + await copies.nth(index).click(); + await expect(page.getByText('Copied Slow requests to this browser library.')).toBeVisible(); + } + await copies.nth(1).click(); + await expect(page.getByText('Slow requests is already in this browser library.')).toBeVisible(); + const stored = await page.evaluate( + (key) => JSON.parse(localStorage.getItem(key) || '[]'), + libraryKey(new URL(baseURL!).origin, 'reader'), + ); + expect( + stored.map((item: { range: unknown; datasets: string[] }) => [item.range, item.datasets]), + ).toEqual( + expect.arrayContaining([ + [{ startTime: '2026-10-01T00:00:00.000Z', endTime: '2026-10-02T00:00:00.000Z' }, []], + [{ startTime: '2026-10-05T00:00:00.000Z', endTime: '2026-10-06T00:00:00.000Z' }, []], + [{ startTime: '2026-10-01T00:00:00.000Z', endTime: '2026-10-02T00:00:00.000Z' }, ['events']], + ]), + ); + expect(stored).toHaveLength(3); + expect(writes).toEqual([]); +}); diff --git a/frontend/src/features/sql/SqlPage.tsx b/frontend/src/features/sql/SqlPage.tsx index fc1ebb902..080e5c55a 100644 --- a/frontend/src/features/sql/SqlPage.tsx +++ b/frontend/src/features/sql/SqlPage.tsx @@ -22,9 +22,10 @@ import { SqlChartPanel, readSqlChartConfig } from './SqlChartPanel'; import { createQueryTab, datasetQuery, hydrateQueryTabs, type QueryTab } from './workspace'; import { useApp } from '../../app/AppProvider'; import { useAsync } from '../../hooks/useAsync'; -import { paramsRange, timeBounds } from '../../lib/query'; +import { timeBounds } from '../../lib/query'; import type { LogRecord } from '../../lib/types'; import { readSavedQueries, sqlStorageScope } from './savedQueries'; +import { sqlHandoffRange } from './serverSavedQueries'; import './sql.css'; // Session storage isolates draft workspaces per browser tab while surviving reloads. // The saved-query library separately uses persistent local storage. @@ -84,7 +85,7 @@ function SqlWorkspace() { const drafts = restoreDrafts(scope); if (params.has('query')) { const query = params.get('query') || ''; - const range = paramsRange(params); + const range = sqlHandoffRange(params); const index = drafts.findIndex( (item) => item.query === query && JSON.stringify(item.range) === JSON.stringify(range), ); @@ -97,7 +98,7 @@ function SqlWorkspace() { createQueryTab( 'Query 1', mode === 'demo' ? datasetQuery(['application_logs']) : '', - paramsRange(params), + sqlHandoffRange(params), ), ]; }); @@ -194,7 +195,7 @@ function SqlWorkspace() { // An edit can rerun this effect while params still contains the old query. previousUrl.current = url; const query = params.get('query') || ''; - const range = paramsRange(params); + const range = sqlHandoffRange(params); const existing = tabs.find( (item) => item.query === query && JSON.stringify(item.range) === JSON.stringify(range), ); diff --git a/frontend/src/features/sql/SqlSidebar.tsx b/frontend/src/features/sql/SqlSidebar.tsx index 56590042f..e29ea2c67 100644 --- a/frontend/src/features/sql/SqlSidebar.tsx +++ b/frontend/src/features/sql/SqlSidebar.tsx @@ -114,7 +114,10 @@ function ServerLibrary({ )}
{items.map((item) => ( -
+
+ {item.rangeFallback && ( +

+ Saved time range {item.savedRange} is not supported; opening or copying uses the + last hour. +

+ )}
))} {views.data && !items.length &&

No server saved queries.

} diff --git a/frontend/src/features/sql/serverSavedQueries.test.ts b/frontend/src/features/sql/serverSavedQueries.test.ts index af5793854..9ecd2ff09 100644 --- a/frontend/src/features/sql/serverSavedQueries.test.ts +++ b/frontend/src/features/sql/serverSavedQueries.test.ts @@ -1,12 +1,15 @@ import { describe, expect, it } from 'vitest'; import { savedLogView } from '../../lib/logsContract'; import type { SavedLogView } from '../../lib/types'; +import { encodeLogView, type LogViewState } from '../logs/logViewState'; import { saveQuery, type SavedQuery } from './savedQueries'; import { + classicTimeRange, importServerQuery, serverSavedQueries, serverSavedQuery, sqlEditorHandoff, + sqlHandoffRange, } from './serverSavedQueries'; // Shapes as stored by classic Prism and returned by GET /api/v1/filters. @@ -33,6 +36,32 @@ const classicLogsSql: SavedLogView = { query: { filter_type: 'sql', filter_query: "SELECT * FROM app WHERE level = 'error'" }, time_filter: { from: 'now-1h', to: 'now' }, }; +const nativeState: LogViewState = { + version: 1, + dataset: 'app', + range: '7d', + search: '', + expression: { combinator: 'and', groups: [] }, + timezone: 'UTC', + limit: 500, + wrap: false, + showHistogram: true, +}; +// A native Logs view saved with a relative range keeps it only in explorer_state. +const nativeLogsSql: SavedLogView = { + version: 'v2', + user_id: 'owner-hash', + stream_name: 'sql', + filter_name: 'Native week', + filter_id: '01HNATIVE', + query: { + filter_type: 'sql', + filter_query: 'SELECT * FROM "app" LIMIT 500', + filter_builder: null, + }, + explorer_state: encodeLogView(nativeState), + time_filter: null, +}; const classicBuilder: SavedLogView = { version: 'v2', user_id: 'owner-hash', @@ -64,7 +93,7 @@ const classicSearch: SavedLogView = { describe('server saved SQL queries', () => { it('accepts the real classic fixtures through the client contract', () => { - for (const view of [classicSql, classicLogsSql, classicBuilder, classicSearch]) + for (const view of [classicSql, classicLogsSql, nativeLogsSql, classicBuilder, classicSearch]) expect(savedLogView(view)).toBe(true); }); it('reads classic SQL editor queries with their saved bounds and no dataset scope', () => { @@ -80,12 +109,12 @@ describe('server saved SQL queries', () => { ownerHash: 'owner-hash', }); }); - it('marks Logs-origin SQL and falls back from unreadable bounds without changing the SQL', () => { + it('marks Logs-origin SQL and keeps a supported classic relative window', () => { expect(serverSavedQuery(classicLogsSql)).toMatchObject({ origin: 'logs', datasets: ['app'], range: '1h', - rangeFallback: true, + rangeFallback: false, sql: classicLogsSql.query.filter_query, }); // A converted native Logs view keeps explorer_state and stays Logs-origin. @@ -93,6 +122,53 @@ describe('server saved SQL queries', () => { 'logs', ); }); + it('maps classic relative presets and flags unsupported windows instead of guessing', () => { + expect(classicTimeRange('now-7d', 'now')).toBe('7d'); + expect(classicTimeRange('now-1w', 'now')).toBe('7d'); + expect(classicTimeRange('now-24h', 'now')).toBe('24h'); + expect(classicTimeRange('now-1d', 'now')).toBe('1d'); + expect(classicTimeRange('now-60m', 'now')).toBe('1h'); + expect(classicTimeRange('now-2d', 'now')).toBeUndefined(); + expect(classicTimeRange('now-7d', 'now-1d')).toBeUndefined(); + const week = serverSavedQuery({ + ...classicLogsSql, + time_filter: { from: 'now-7d', to: 'now' }, + }); + expect(week).toMatchObject({ range: '7d', rangeFallback: false }); + expect(week).not.toHaveProperty('savedRange'); + const twoDays = { ...classicLogsSql, time_filter: { from: 'now-2d', to: 'now' } }; + expect(serverSavedQuery(twoDays)).toMatchObject({ + range: '1h', + rangeFallback: true, + savedRange: 'now-2d to now', + sql: classicLogsSql.query.filter_query, + }); + }); + it('honors a valid native explorer state range and dataset over the empty time filter', () => { + expect(serverSavedQuery(nativeLogsSql)).toMatchObject({ + origin: 'logs', + range: '7d', + rangeFallback: false, + datasets: ['app'], + sql: nativeLogsSql.query.filter_query, + }); + const absolute = { + ...nativeState, + range: { startTime: '2026-10-01T00:00:00.000Z', endTime: '2026-10-03T00:00:00.000Z' }, + }; + expect( + serverSavedQuery({ + ...nativeLogsSql, + explorer_state: encodeLogView(absolute), + time_filter: { from: absolute.range.startTime, to: absolute.range.endTime }, + })?.range, + ).toEqual(absolute.range); + // A dataset-scoped record whose state names another dataset is not trusted. + expect(serverSavedQuery({ ...nativeLogsSql, stream_name: 'other' })).toMatchObject({ + range: '1h', + datasets: ['other'], + }); + }); it('never fabricates SQL for builder, search, empty or unidentified records', () => { expect(serverSavedQuery(classicBuilder)).toBeUndefined(); expect(serverSavedQuery(classicSearch)).toBeUndefined(); @@ -115,13 +191,21 @@ describe('server saved SQL queries', () => { ['Slow requests', 'owner-hash'], ]); }); - it('hands the stored SQL to the editor verbatim', () => { + it('hands the stored SQL and its range to the editor verbatim', () => { const item = serverSavedQuery(classicSql)!; const params = new URL(sqlEditorHandoff(item), 'http://x').searchParams; expect(params.get('query')).toBe(item.sql); expect(params.get('start')).toBe('2026-10-01T00:00:00.000Z'); expect(params.get('end')).toBe('2026-10-02T00:00:00.000Z'); - expect(sqlEditorHandoff(serverSavedQuery(classicLogsSql)!)).not.toContain('start='); + expect(sqlHandoffRange(params)).toEqual(item.range); + const native = new URL(sqlEditorHandoff(serverSavedQuery(nativeLogsSql)!), 'http://x') + .searchParams; + expect(native.has('start')).toBe(false); + expect(native.get('range')).toBe('7d'); + expect(native.get('query')).toBe(nativeLogsSql.query.filter_query); + expect(sqlHandoffRange(native)).toBe('7d'); + expect(sqlHandoffRange(new URLSearchParams({ range: 'forever' }))).toBe('1h'); + expect(sqlHandoffRange(new URLSearchParams())).toBe('1h'); }); it('copies into the browser library without losing local queries or duplicating', () => { const local: SavedQuery = { @@ -147,5 +231,26 @@ describe('server saved SQL queries', () => { const again = importServerQuery(first.items, item); expect(again.imported).toBeUndefined(); expect(again.items).toBe(first.items); + // Equivalent bounds in another ISO spelling are still the same copy. + const respelled = importServerQuery(first.items, { + ...item, + range: { startTime: '2026-10-01T00:00:00Z', endTime: '2026-10-02T00:00:00+00:00' }, + }); + expect(respelled.imported).toBeUndefined(); + }); + it('keeps same-named copies whose window or dataset scope differ', () => { + const item = serverSavedQuery(classicSql)!; + const first = importServerQuery([], item).items; + const otherWindow = importServerQuery(first, { + ...item, + range: { startTime: '2026-10-05T00:00:00.000Z', endTime: '2026-10-06T00:00:00.000Z' }, + }); + expect(otherWindow.imported).toBeDefined(); + const relative = importServerQuery(otherWindow.items, { ...item, range: '7d' }); + expect(relative.imported).toBeDefined(); + const scoped = importServerQuery(relative.items, { ...item, datasets: ['app'] }); + expect(scoped.imported).toBeDefined(); + expect(scoped.items).toHaveLength(4); + expect(importServerQuery(scoped.items, { ...item, range: '7d' }).imported).toBeUndefined(); }); }); diff --git a/frontend/src/features/sql/serverSavedQueries.ts b/frontend/src/features/sql/serverSavedQueries.ts index 427080964..9e1035c05 100644 --- a/frontend/src/features/sql/serverSavedQueries.ts +++ b/frontend/src/features/sql/serverSavedQueries.ts @@ -1,5 +1,6 @@ -import { timeBounds } from '../../lib/query'; +import { paramsRange, timeBounds } from '../../lib/query'; import type { SavedLogView, TimeRange } from '../../lib/types'; +import { decodeLogView } from '../logs/logViewState'; import type { SavedQuery } from './savedQueries'; /** @@ -16,22 +17,85 @@ export type ServerSavedQuery = { range: TimeRange; /** Saved time bounds were present but unusable; the default range is used. */ rangeFallback: boolean; + /** The stored bounds as saved, for explaining a fallback. */ + savedRange?: string; datasets: string[]; origin: 'sql' | 'logs'; ownerHash?: string; }; +const presetMs: [Extract, number][] = [ + ['10m', 10 * 60_000], + ['15m', 15 * 60_000], + ['30m', 30 * 60_000], + ['1h', 60 * 60_000], + ['5h', 5 * 60 * 60_000], + ['6h', 6 * 60 * 60_000], + ['24h', 24 * 60 * 60_000], + ['1d', 24 * 60 * 60_000], + ['3d', 3 * 24 * 60 * 60_000], + ['7d', 7 * 24 * 60 * 60_000], +]; +const unitMs: Record = { + m: 60_000, + h: 60 * 60_000, + d: 24 * 60 * 60_000, + w: 7 * 24 * 60 * 60_000, +}; + +export function isRangePreset(value: unknown): value is Extract { + return presetMs.some(([preset]) => preset === value); +} + +/** + * Classic Prism stores relative windows as `now-` to `now`. Only + * windows matching a native preset are kept; anything else is unsupported. + */ +export function classicTimeRange(from: string, to: string): TimeRange | undefined { + const relative = /^now-(\d{1,4})([mhdw])$/.exec(from.trim()); + if (relative) { + if (to.trim() !== 'now') return; + const label = `${Number(relative[1])}${relative[2]}`; + const ms = Number(relative[1]) * unitMs[relative[2]]; + return ( + presetMs.find(([preset]) => preset === label)?.[0] ?? + presetMs.find(([, duration]) => duration === ms)?.[0] + ); + } + try { + return timeBounds({ startTime: from, endTime: to }); + } catch { + return; + } +} + +/** A validated native explorer state is authoritative for range and dataset. */ +function nativeState(view: SavedLogView) { + if (typeof view.explorer_state !== 'string') return; + try { + const state = decodeLogView(view.explorer_state); + if (view.stream_name !== 'sql' && state.dataset !== view.stream_name) return; + return state; + } catch { + return; + } +} + export function serverSavedQuery(view: SavedLogView): ServerSavedQuery | undefined { const sql = view.query.filter_query; if (view.query.filter_type !== 'sql' || typeof sql !== 'string' || !sql.trim()) return; if (typeof view.filter_id !== 'string' || !view.filter_id) return; + const state = nativeState(view); let range: TimeRange = '1h'; let rangeFallback = false; - if (view.time_filter) { - try { - range = timeBounds({ startTime: view.time_filter.from, endTime: view.time_filter.to }); - } catch { + let savedRange: string | undefined; + if (state) range = state.range; + else if (view.time_filter) { + const saved = classicTimeRange(view.time_filter.from, view.time_filter.to); + if (saved) range = saved; + else { rangeFallback = true; + savedRange = `${view.time_filter.from} to ${view.time_filter.to}`; } } const ownerHash = typeof view.user_id === 'string' && view.user_id ? view.user_id : undefined; @@ -42,7 +106,8 @@ export function serverSavedQuery(view: SavedLogView): ServerSavedQuery | undefin sql, range, rangeFallback, - datasets: view.stream_name === 'sql' ? [] : [view.stream_name], + ...(savedRange ? { savedRange } : {}), + datasets: state ? [state.dataset] : view.stream_name === 'sql' ? [] : [view.stream_name], origin: view.stream_name === 'sql' && view.explorer_state == null ? 'sql' : 'logs', ownerHash, }; @@ -66,18 +131,43 @@ export function sqlEditorHandoff(item: Pick): if (typeof item.range === 'object') { params.set('start', item.range.startTime); params.set('end', item.range.endTime); - } + } else params.set('range', item.range); return `/sql-editor?${params}`; } +/** Reads a SQL editor handoff range: absolute bounds first, then a relative preset. */ +export function sqlHandoffRange(params: URLSearchParams): TimeRange { + if (params.get('start') && params.get('end')) return paramsRange(params); + const preset = params.get('range'); + return isRangePreset(preset) ? preset : '1h'; +} + +function rangeKey(range: TimeRange): string { + if (typeof range === 'string') return range; + try { + return JSON.stringify(timeBounds(range)); + } catch { + return JSON.stringify(range); + } +} + +/** Same query, window and dataset scope; the name alone does not make a copy equal. */ +function sameQuery(a: Pick, b: typeof a) { + return ( + a.name === b.name && + a.sql === b.sql && + rangeKey(a.range) === rangeKey(b.range) && + JSON.stringify([...a.datasets].sort()) === JSON.stringify([...b.datasets].sort()) + ); +} + /** A browser-library copy; the server record stays the source of the original. */ export function importServerQuery( items: SavedQuery[], item: ServerSavedQuery, now = new Date(), ): { items: SavedQuery[]; imported?: SavedQuery } { - if (items.some((existing) => existing.name === item.name && existing.sql === item.sql)) - return { items }; + if (items.some((existing) => sameQuery(existing, item))) return { items }; const imported: SavedQuery = { id: crypto.randomUUID(), name: item.name, diff --git a/frontend/src/features/sql/sql-sidebar.css b/frontend/src/features/sql/sql-sidebar.css index 18b35a18a..f1a7aebd4 100644 --- a/frontend/src/features/sql/sql-sidebar.css +++ b/frontend/src/features/sql/sql-sidebar.css @@ -52,6 +52,18 @@ text-overflow: ellipsis; white-space: nowrap; } +.sql-library-item-warned { + flex-wrap: wrap; +} +.sql-library-item-warned > button:first-child { + flex-basis: 0; +} +.sql-library-warning { + flex-basis: 100%; + margin: 0 0 4px; + color: var(--muted); + font-size: 11px; +} .sql-library-save { display: flex; flex-wrap: wrap; From 705683468c809a02131972e9fc261e66c1fae2c0 Mon Sep 17 00:00:00 2001 From: VibeSWE <338779468+VibeSWE@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:59:53 -0500 Subject: [PATCH 7/8] Keep server query draft dataset scopes distinct --- docs/native-ui-migration.md | 5 +++ frontend/docs/validation-review.md | 12 ++++++ frontend/e2e/classic-links.spec.ts | 4 +- frontend/e2e/sql-server-saved-queries.spec.ts | 38 +++++++++++++++++++ frontend/src/features/sql/SqlPage.tsx | 4 +- 5 files changed, 61 insertions(+), 2 deletions(-) diff --git a/docs/native-ui-migration.md b/docs/native-ui-migration.md index 4f71d244e..bd9c6f454 100644 --- a/docs/native-ui-migration.md +++ b/docs/native-ui-migration.md @@ -16,6 +16,11 @@ not execute it, update its server record, or replace an existing browser query. Editing and saving a browser copy does not synchronize changes to the original server record. +Supported classic relative ranges and validated native Logs ranges are retained. +Unsupported saved ranges show a warning before opening or copying with the +default last-hour range. Copies with different time windows or dataset selections +remain separate, even when their names and SQL match. + In Logs, **Open in SQL editor** makes the stored SQL of an older saved view accessible even when its explorer settings cannot be applied by the native UI. The original view is retained. Saved definitions that contain only a classic diff --git a/frontend/docs/validation-review.md b/frontend/docs/validation-review.md index 29ef7d760..775f00c07 100644 --- a/frontend/docs/validation-review.md +++ b/frontend/docs/validation-review.md @@ -1,5 +1,17 @@ # Frontend validation and independent review +## Native-only migration (2026-10-21) + +The [migration guide](../../docs/native-ui-migration.md) records current behavior and remaining gaps. Historical commands below that select `P_UI_ROOT=classic` or `LOCAL_ASSETS_PATH` apply only to older builds. + +- Formatting, TypeScript/production build and the full unit suite pass: **2,403 passed, 14 skipped**. +- The application browser run completed **593 passed, 2 skipped**, with three Chromium resource failures. Traces contained `ERR_INSUFFICIENT_RESOURCES` and a crashed target while the user's `/tmp` quota had only 414 MiB headroom. All three affected checks passed in a five-test isolated rerun using disk-backed `TMPDIR`, with zero retries or console errors. +- After the final draft dataset-selection fix, all **10 saved-query browser tests** pass. They cover permissions/session recovery, exact SQL, no server writes, relative ranges, distinct windows/datasets, browser copies and Logs handoffs. +- All **25 production browser tests** pass, including `/next` redirects and stale assets. The capability Storybook checks pass, including accessibility and absence of classic links. +- Full `cargo check --locked --tests` passes with native assets and separately with `P_BUILD_NO_UI=1`. Focused build/routing tests were also exercised during implementation. Native ZIP contents/checksum and bundled third-party notices were verified. + +These checks do not include linked Rust test execution, Docker image builds, live ingestion/query checks, or real-provider OIDC interoperability. The historical classic dashboard browser comparison is now explicitly opt-in; native dashboard live tests remain independently runnable. + ## PR #32 review fixes (2026-10-12) The seven review findings are addressed. Trace-focused Logs share links retain their encoded time window, and Traces Refresh reloads fixed windows while advancing preset windows with one request per active list/detail. Agents search keeps typing and caret updates synchronous with URL navigation, and searches literal recorded values, including backslashes and quotes. diff --git a/frontend/e2e/classic-links.spec.ts b/frontend/e2e/classic-links.spec.ts index f08c51926..84f88f14f 100644 --- a/frontend/e2e/classic-links.spec.ts +++ b/frontend/e2e/classic-links.spec.ts @@ -316,7 +316,9 @@ test('a classic PromQL link that does not parse opens as classic shows it', asyn await expect(editor).toHaveText('WHERE >'); await expect(page).toHaveURL(/query=WHERE\+%3E/); // Translated once: the notice is the link's, not a second rewrite's "1 filter". - await expect(notice(page)).toHaveText(/time zone Asia\/Kolkata \(not restored from this link\)\./); + await expect(notice(page)).toHaveText( + /time zone Asia\/Kolkata \(not restored from this link\)\./, + ); }); test('a dismissed Metrics notice stays dismissed after reconnecting', async ({ page }) => { diff --git a/frontend/e2e/sql-server-saved-queries.spec.ts b/frontend/e2e/sql-server-saved-queries.spec.ts index 179b764f5..6521f9eaf 100644 --- a/frontend/e2e/sql-server-saved-queries.spec.ts +++ b/frontend/e2e/sql-server-saved-queries.spec.ts @@ -103,6 +103,44 @@ async function seedLocal(page: Page, user: string, items: unknown[]) { const serverList = (page: Page) => page.getByLabel('SQL datasets and saved queries').getByLabel('Server saved queries'); +test('opening identical server SQL with different datasets preserves each draft scope', async ({ + page, + context, + baseURL, +}) => { + await context.addCookies([{ name: 'user_id', value: 'reader', url: baseURL! }]); + const writes = await serve(page, async () => ({ + json: [ + classicSql('reader'), + classicSql('reader', { filter_id: '01HSCOPED', stream_name: 'events' }), + ], + })); + await page.goto('/sql-editor'); + const opens = serverList(page).getByRole('button', { name: /^Slow requests/ }); + await expect(opens).toHaveCount(2); + await opens.nth(0).click(); + await expect( + page.getByRole('checkbox', { name: 'Select events', exact: true }), + ).not.toBeChecked(); + await opens.nth(1).click(); + await expect(page.getByRole('checkbox', { name: 'Select events', exact: true })).toBeChecked(); + await expect(page.getByRole('tab', { name: 'Slow requests', exact: true })).toHaveCount(2); + await opens.nth(0).click(); + await expect( + page.getByRole('checkbox', { name: 'Select events', exact: true }), + ).not.toBeChecked(); + await opens.nth(1).click(); + await expect(page.getByRole('tab', { name: 'Slow requests', exact: true })).toHaveCount(2); + await page.getByRole('button', { name: 'Save as new', exact: true }).click(); + const stored = await page.evaluate( + (key) => JSON.parse(localStorage.getItem(key) || '[]'), + libraryKey(new URL(baseURL!).origin, 'reader'), + ); + expect(stored).toHaveLength(1); + expect(stored[0].datasets).toEqual(['events']); + expect(writes).toEqual([]); +}); + test('lists classic server SQL read only, opens it and copies it without losing local queries', async ({ page, context, diff --git a/frontend/src/features/sql/SqlPage.tsx b/frontend/src/features/sql/SqlPage.tsx index 080e5c55a..ecc9c6281 100644 --- a/frontend/src/features/sql/SqlPage.tsx +++ b/frontend/src/features/sql/SqlPage.tsx @@ -500,7 +500,9 @@ function SqlWorkspace() { !tab.savedId && tab.name === item.name && tab.query === item.sql && - JSON.stringify(tab.range) === JSON.stringify(item.range), + JSON.stringify(tab.range) === JSON.stringify(item.range) && + JSON.stringify([...tab.datasets].sort()) === + JSON.stringify([...item.datasets].sort()), ); if (existing) { setActiveId(existing.id); From ec72f63e5a86f3c919666705ffb463927ef05975 Mon Sep 17 00:00:00 2001 From: VibeSWE <338779468+VibeSWE@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:07:41 -0500 Subject: [PATCH 8/8] test(ui): validate native migration against a real backend --- frontend/README.md | 6 + frontend/docs/validation-review.md | 18 +- frontend/e2e-live/dashboards.spec.ts | 15 +- frontend/e2e-live/native-migration.spec.ts | 518 +++++++++++++++++++++ frontend/e2e-live/server.spec.ts | 7 +- 5 files changed, 559 insertions(+), 5 deletions(-) create mode 100644 frontend/e2e-live/native-migration.spec.ts diff --git a/frontend/README.md b/frontend/README.md index 04efb9942..cc253ba1f 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -135,6 +135,12 @@ Set `PARSEABLE_LIVE_USERNAME`, `PARSEABLE_LIVE_PASSWORD` and `PARSEABLE_LIVE_DAT The Alerts live spec (`e2e-live/alerts.spec.ts`) creates a unique OTLP dataset, webhook target and PromQL rule, checks preview values and mutations against the API, and cleans up all three in `afterAll`, including after failures. Run it only against a disposable server. Set `TMPDIR` to a disk-backed directory when `/tmp` is small. The current Alerts checks and counts are recorded in [validation-review.md](docs/validation-review.md). +The native migration live spec (`e2e-live/native-migration.spec.ts`) seeds classic SQL queries, a dataset-scoped SQL view and a disabled, muted builder alert through the real API. Browser checks open, copy and execute the saved SQL, follow the Logs handoff, cancel and save alert conversion, then edit the converted rule. They verify exact query results, time windows, unchanged server views and preserved alert settings. Fixtures use unique names, no notification targets, and cleanup after failures. Run it against a disposable native-only server with the same live environment variables: + +```sh +npx playwright test -c playwright.live.config.ts e2e-live/native-migration.spec.ts +``` + The Dashboards live spec (`e2e-live/dashboards.spec.ts`) ingests unique logs and OTLP metrics, creates SQL and PromQL tiles with variables through this frontend and verifies their stored classic-compatible shapes. It also renders a literal classic-shaped Report fixture in this frontend, checks unknown-field preservation, and verifies admin ownership rules and `limit=0`. Cleanup removes all test dashboards, datasets, users and roles. These tests need only `PARSEABLE_LIVE_URL` and run against a current native-only server. The historical classic hard-load comparison is skipped unless you set `PARSEABLE_LIVE_CLASSIC=true`. It then also requires `PARSEABLE_LIVE_SERVER_URL` to point at an older Parseable server that still embeds the classic Prism UI at `/dashboards/:id`; current builds have no classic UI mode, so do not opt in against them. Both origins must use the same disposable backend and the same hostname to share login cookies across ports (use `127.0.0.1` for both; mixing it with `localhost` fails). The spec checks this before ingesting. Native-only run: diff --git a/frontend/docs/validation-review.md b/frontend/docs/validation-review.md index 775f00c07..03c91b214 100644 --- a/frontend/docs/validation-review.md +++ b/frontend/docs/validation-review.md @@ -10,7 +10,23 @@ The [migration guide](../../docs/native-ui-migration.md) records current behavio - All **25 production browser tests** pass, including `/next` redirects and stale assets. The capability Storybook checks pass, including accessibility and absence of classic links. - Full `cargo check --locked --tests` passes with native assets and separately with `P_BUILD_NO_UI=1`. Focused build/routing tests were also exercised during implementation. Native ZIP contents/checksum and bundled third-party notices were verified. -These checks do not include linked Rust test execution, Docker image builds, live ingestion/query checks, or real-provider OIDC interoperability. The historical classic dashboard browser comparison is now explicitly opt-in; native dashboard live tests remain independently runnable. +The historical classic dashboard browser comparison is now explicitly opt-in; native dashboard live tests remain independently runnable. + +### Additional UI validation against the native-only binary + +Sol 6.1 and Opus 5.5 ran independent browser checks of PR #55 at `70568346`. A full `cargo build --locked -j 4` linked successfully with native assets, and `/api/v1/about` identified the disposable local-store server as that commit with `uiVersion: native-3.2.5`. The live browser runs used the embedded production UI and real ingestion, query and persistence APIs, without mocked responses. + +- **25 production-route checks and 14 focused/exploratory alert-conversion checks passed.** Conversion coverage includes 390px layout, axe, keyboard submission, cancellation and unsaved-change prompts, pending-save double activation, error/retry and reload. These particular checks use demo or mocked API data. +- The fresh full application run completed **580 passed, 2 skipped and 17 failed** with four workers and zero retries. All 17 original failures passed in a bounded two-worker rerun (**29 passed, 1 failed**, including parameterized siblings). The additional dark-theme failure showed `ERR_NETWORK_CHANGED` and a failed dynamic module import; it passed with one worker. The full run overlapped the native build and heavy host load. No reproducible UI regression was established, but this was not a clean uninterrupted full-suite run. Saved SQL checks passed **10/10** and Logs-to-SQL checks **8/8**. The skips are optional screenshot-export cases. +- **11 existing live checks passed across the completed runs:** login/logout, full return-URL preservation, invalidated-session recovery, missing-provider OIDC fallback, Logs filtering and event details, real SQL success/error, dataset inventory/schema, dataset creation/settings/deletion, SQL/PromQL dashboard creation, classic-shaped dashboard editing and dashboard ownership/list behavior. The historical classic browser comparison and real-provider SSO were not exercised. +- Live testing corrected two stale assertions: exact dashboard config expectations now include the native editor's grouping/table-stacking defaults, and login recovery verifies the full intended URL including its observation context. No runtime code was changed for these assertions. One subsequent Logs check exceeded its five-second loading assertion under host load; the trace showed successful query responses and the unchanged test passed on isolated rerun. +- The new **three live migration checks pass**, covering classic SQL/Logs views and disabled/muted builder-alert conversion. The first and final runs passed; repeated execution also exposed the backend finding below. Query results and stored records are checked exactly, with no result retries. On a result mismatch the spec attaches an identical direct API replay for diagnosis before failing. Fixtures are removed afterward. + +**Unresolved backend finding:** one repeated migration run returned HTTP 200 with `[]` for the correct saved SQL and absolute window. At `2026-10-21T15:57:00.354Z`, the server log for dataset `migration_live_mviap0uj_0779ee2a` shows a query plan with `file_groups={1 group: [[]]}` immediately after flush and Arrow-to-Parquet conversion. A separate API probe on `probe_vis_mviaprp1` caught a staging Parquet file-not-found 500 and transient stream-access 400 responses. This points to a staging/query visibility race. The exact aggregate replay produced no anomalies in 361 requests over 150 seconds, so the empty result is not deterministically reproducible. Query, staging, storage and sync code is unchanged from `origin/main c5e1e08f`; no baseline server was run, so its presence on main is not independently confirmed. This finding remains open and is not hidden by retries or weaker expectations. Evidence is in `~/.cache/prism-ui-followup/server.log` and `~/.cache/prism-ui-followup-live/probe/`. + +Browser artifacts and logs are under `~/.cache/prism-ui-followup/`, `~/.cache/prism-ui-followup-sol/`, `~/.cache/prism-ui-followup-opus/` and `~/.cache/prism-ui-followup-live/`. Runs used isolated ports and disk-backed `TMPDIR`. The collaborative preview timed out twice on snapshot inspection, so these checks used the repository's Playwright workflow. + +Linked Rust test execution, Docker image builds, cross-browser coverage and real-provider OIDC interoperability remain outside this validation. ## PR #32 review fixes (2026-10-12) diff --git a/frontend/e2e-live/dashboards.spec.ts b/frontend/e2e-live/dashboards.spec.ts index 9fc93e969..4eed84dc7 100644 --- a/frontend/e2e-live/dashboards.spec.ts +++ b/frontend/e2e-live/dashboards.spec.ts @@ -289,9 +289,16 @@ test('create SQL and PromQL tiles and variables in the native UI; verify exact c .attach('stored-dashboard.json', { body: verbatim, contentType: 'application/json' }); expect(primary.tiles).toHaveLength(2); const [sqlTile, promTile] = primary.tiles!; + // The native editor adds explicit grouping and table stacking defaults to + // the historical classic config; keep checking the complete stored shape. expect(sqlTile).toEqual({ tile_id: expect.any(String), - config: { ...classicFixture.tiles[0].config, type: 'table' }, + config: { + ...classicFixture.tiles[0].config, + type: 'table', + axes: { ...classicFixture.tiles[0].config.axes, groupBy: { field: [] } }, + layout: { ...classicFixture.tiles[0].config.layout, stacked: false }, + }, layout: { x: 0, y: 0, w: 12, h: 4 }, title: 'Live log events', tileType: 'code', @@ -302,7 +309,11 @@ test('create SQL and PromQL tiles and variables in the native UI; verify exact c }); expect(promTile).toEqual({ tile_id: expect.any(String), - config: { ...classicFixture.tiles[0].config, type: 'timeseries' }, + config: { + ...classicFixture.tiles[0].config, + type: 'timeseries', + axes: { ...classicFixture.tiles[0].config.axes, groupBy: { field: [] } }, + }, layout: { x: 0, y: 4, w: 12, h: 4 }, title: 'Live host load', tileType: 'promql', diff --git a/frontend/e2e-live/native-migration.spec.ts b/frontend/e2e-live/native-migration.spec.ts new file mode 100644 index 000000000..04d1bff1c --- /dev/null +++ b/frontend/e2e-live/native-migration.spec.ts @@ -0,0 +1,518 @@ +import { randomUUID } from 'node:crypto'; +import { + test, + expect, + type APIRequestContext, + type Page, + type Request, + type Response, +} from '@playwright/test'; +import type { Alert, AlertSummary, SavedLogView } from '../src/lib/types'; + +// Records created by classic Prism are seeded through the real API, then opened +// and converted in the native UI. Nothing here is mocked. +const live = process.env.PARSEABLE_LIVE_URL; +const username = process.env.PARSEABLE_LIVE_USERNAME || 'frontend-smoke', + password = process.env.PARSEABLE_LIVE_PASSWORD || 'local-smoke-password'; +const base = (process.env.PARSEABLE_LIVE_BASE ?? '').replace(/\/+$/, ''); +const app = (route: string) => `${base}${route}`, + appUrl = (route: string) => new URL(app(route), live).href; +const headers = { + Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`, +}; +const suffix = `${Date.now().toString(36)}_${randomUUID().slice(0, 8)}`; +const dataset = `migration_live_${suffix}`, + quoted = `"${dataset}"`; +const classicName = `Classic SQL ${suffix}`, + logsViewName = `Classic logs view ${suffix}`, + alertTitle = `Builder alert ${suffix}`; +const events = [ + { level: 'ERROR', latency_ms: 100, message: `checkout failed a ${suffix}` }, + { level: 'ERROR', latency_ms: 200, message: `checkout failed b ${suffix}` }, + { level: 'ERROR', latency_ms: 600, message: `checkout failed c ${suffix}` }, + { level: 'INFO', latency_ms: 20, message: `checkout ok a ${suffix}` }, + { level: 'INFO', latency_ms: 40, message: `checkout ok b ${suffix}` }, + { level: 'WARN', latency_ms: 80, message: `checkout slow ${suffix}` }, +]; +const classicSql = `SELECT level, COUNT(*) AS events, SUM(latency_ms) AS total_latency FROM ${quoted} GROUP BY level ORDER BY level`; +const logsViewSql = `SELECT message, latency_ms FROM ${quoted} WHERE level = 'ERROR' ORDER BY latency_ms`; +const alertSql = `SELECT AVG(latency_ms) FROM ${quoted} WHERE level = 'ERROR'`; +const editedAlertSql = `SELECT COUNT(*) FROM ${quoted} WHERE level = 'ERROR'`; +// Absolute bounds are kept exactly; set in beforeAll around the ingest time. +let classicWindow: { from: string; to: string }; +let classicId: string | undefined, + logsViewId: string | undefined, + alertId: string | undefined, + attemptedIngest = false; + +test.skip(!live, 'Set PARSEABLE_LIVE_URL to opt in to the real-server suite.'); +test.describe.configure({ mode: 'serial' }); + +async function signIn(page: Page, path: string) { + await page.goto(app(`/login?next=${encodeURIComponent(path)}`)); + await page.getByLabel('Username', { exact: true }).fill(username); + await page.getByLabel('Password', { exact: true }).fill(password); + await page.getByRole('button', { name: 'Login', exact: true }).click(); + await expect(page).toHaveURL((url) => url.pathname === new URL(appUrl(path)).pathname); +} +async function getJson(request: APIRequestContext, path: string): Promise { + const response = await request.get(path, { headers, timeout: 10_000 }); + expect(response.status(), await response.text()).toBe(200); + return response.json(); +} +async function send( + request: APIRequestContext, + method: 'POST' | 'PATCH', + path: string, + data: unknown, +): Promise { + const response = await request.fetch(path, { method, headers, data, timeout: 15_000 }); + expect(response.ok(), `${method} ${path}: ${await response.text()}`).toBe(true); + return response.json(); +} +async function runSql( + request: APIRequestContext, + query: string, + startTime: string, + endTime: string, +) { + const response = await request.post('/api/v1/query', { + headers, + data: { query, startTime, endTime, sendNull: true }, + timeout: 15_000, + }); + expect(response.ok(), await response.text()).toBe(true); + return (await response.json()) as Record[]; +} +/** + * Asserts the UI's real query result exactly. On a mismatch the identical request body is + * replayed directly so a failure distinguishes a client payload fault from server visibility. + */ +async function expectUiResult(request: APIRequestContext, response: Response, expected: unknown) { + const body = response.request().postDataJSON(); + const actual = await response.json(); + if (JSON.stringify(actual) === JSON.stringify(expected)) return; + const replay = await request.post('/api/v1/query', { headers, data: body, timeout: 15_000 }); + const evidence = { + at: new Date().toISOString(), + browserRequest: body, + browserResponse: actual, + directReplay: { status: replay.status(), body: await replay.text() }, + }; + await test.info().attach('query-mismatch.json', { + body: JSON.stringify(evidence, null, 2), + contentType: 'application/json', + }); + expect(actual, `UI query result differs; ${JSON.stringify(evidence)}`).toEqual(expected); +} +/** Records every non-read request the page makes against server saved views or alerts. */ +function watchWrites(page: Page, prefix: string) { + const writes: string[] = []; + page.on('request', (request: Request) => { + const path = new URL(request.url()).pathname; + if (path.startsWith(prefix) && request.method() !== 'GET') + writes.push(`${request.method()} ${path}`); + }); + return writes; +} +const isQuery = (response: Response) => + response.request().method() === 'POST' && new URL(response.url()).pathname === '/api/v1/query'; +const serverList = (page: Page) => + page.getByLabel('SQL datasets and saved queries').getByLabel('Server saved queries'); +async function browserLibrary(page: Page) { + return page.evaluate(() => + Object.keys(localStorage) + .filter((key) => key.startsWith('parseable-sql:v1:') && key.endsWith(':library')) + .flatMap((key) => JSON.parse(localStorage.getItem(key) || '[]')), + ) as Promise< + Array<{ name: string; sql: string; range: unknown; datasets: string[]; id: string }> + >; +} + +test.beforeAll(async ({ request }) => { + test.setTimeout(90_000); + attemptedIngest = true; + const ingest = await request.post('/api/v1/ingest', { + headers: { ...headers, 'X-P-Stream': dataset }, + data: events.map((event) => ({ ...event, service: 'native-migration-live' })), + timeout: 15_000, + }); + expect(ingest.ok(), await ingest.text()).toBe(true); + const now = Date.now(); + classicWindow = { + from: new Date(now - 2 * 60 * 60_000).toISOString(), + to: new Date(now + 60 * 60_000).toISOString(), + }; + await expect + .poll( + async () => { + const response = await request.post('/api/v1/query', { + headers, + data: { + query: `SELECT COUNT(*) AS n FROM ${quoted}`, + startTime: classicWindow.from, + endTime: classicWindow.to, + }, + timeout: 5000, + }); + return response.ok() ? (await response.json())[0]?.n : undefined; + }, + { timeout: 30_000, intervals: [500, 1000] }, + ) + .toBe(events.length); + + // A classic Prism SQL-editor query: stream_name "sql", no explorer_state. + const classic = await send(request, 'POST', '/api/v1/filters', { + stream_name: 'sql', + filter_name: classicName, + query: { filter_type: 'sql', filter_query: classicSql, filter_builder: null }, + time_filter: classicWindow, + }); + classicId = classic.filter_id ?? undefined; + // An older dataset-scoped Logs view: stream_name is the dataset, relative window. + const logsView = await send(request, 'POST', '/api/v1/filters', { + stream_name: dataset, + filter_name: logsViewName, + query: { filter_type: 'sql', filter_query: logsViewSql, filter_builder: null }, + time_filter: { from: 'now-7d', to: 'now' }, + }); + logsViewId = logsView.filter_id ?? undefined; + expect(classicId).toBeTruthy(); + expect(logsViewId).toBeTruthy(); + + // A builder threshold alert as classic Prism stored it, without notification + // targets so evaluation can never send anything. + const alert = await send(request, 'POST', '/api/v1/alerts', { + title: alertTitle, + severity: 'critical', + query: alertSql, + queryType: 'builder', + alertType: 'threshold', + thresholdConfig: { operator: '>=', value: 5000.5 }, + evalConfig: { rollingWindow: { evalStart: '2h', evalEnd: 'now', evalFrequency: 15 } }, + notificationConfig: { interval: 5 }, + targets: [], + tags: ['migration', `live-${suffix}`], + units: 'ms', + migrationTicket: `fixture-${suffix}`, + }); + alertId = alert.id; + expect(alert.queryType).toBe('builder'); + expect(alert.datasets).toEqual([dataset]); + await send(request, 'PATCH', `/api/v1/alerts/${alertId}/disable`, {}); + await send(request, 'PATCH', `/api/v1/alerts/${alertId}/update_notification_state`, { + state: 'indefinite', + }); + await expect + .poll(async () => { + const stored = await getJson(request, `/api/v1/alerts/${alertId}`); + return [stored.state, stored.notificationState]; + }) + .toEqual(['disabled', { mute: 'indefinite' }]); +}); + +test.afterAll(async ({ request }) => { + if (!live) return; + test.setTimeout(60_000); + const errors: Error[] = []; + async function cleanup(label: string, operation: () => Promise) { + try { + await operation(); + } catch (error) { + errors.push(new Error(`Could not clean up ${label}`, { cause: error })); + } + } + async function remove(path: string, missing: number[]) { + const response = await request.delete(path, { headers, timeout: 10_000 }); + expect(response.ok() || missing.includes(response.status()), await response.text()).toBe(true); + } + // Discover by unique name too, so records are removed if an ID assertion failed. + await cleanup('alerts', async () => { + const ids = new Set(alertId ? [alertId] : []); + for (let offset = 0; ; offset += 1000) { + const rows = await getJson( + request, + `/api/v1/alerts?limit=1000&offset=${offset}`, + ); + rows.filter((row) => row.title === alertTitle).forEach((row) => ids.add(row.id)); + if (rows.length < 1000) break; + } + for (const id of ids) + await cleanup(`alert ${id}`, () => + remove(`/api/v1/alerts/${encodeURIComponent(id)}`, [400, 404]), + ); + }); + await cleanup('filters', async () => { + const ids = new Set([classicId, logsViewId].filter((id): id is string => !!id)); + (await getJson(request, '/api/v1/filters')) + .filter((view) => [classicName, logsViewName].includes(view.filter_name) && view.filter_id) + .forEach((view) => ids.add(view.filter_id!)); + for (const id of ids) + await cleanup(`filter ${id}`, () => + remove(`/api/v1/filters/${encodeURIComponent(id)}`, [400, 404]), + ); + }); + if (attemptedIngest) + await cleanup('dataset', () => + remove(`/api/v1/logstream/${encodeURIComponent(dataset)}`, [404]), + ); + if (errors.length) throw new AggregateError(errors, 'Native migration live-test cleanup failed.'); +}); + +test('classic SQL and dataset-scoped SQL views list, open, copy and run without changing the server', async ({ + page, + request, +}) => { + const before = await Promise.all( + [classicId, logsViewId].map((id) => getJson(request, `/api/v1/filters/${id}`)), + ); + expect(before[0]).toMatchObject({ stream_name: 'sql', time_filter: classicWindow }); + expect(before[0].explorer_state ?? null).toBeNull(); + expect(before[1]).toMatchObject({ stream_name: dataset }); + const writes = watchWrites(page, '/api/v1/filters'); + await signIn(page, '/sql-editor'); + + const list = serverList(page); + const classic = list.getByRole('button', { name: new RegExp(`^${classicName}`) }); + const scoped = list.getByRole('button', { name: new RegExp(`^${logsViewName}`) }); + await expect(classic).toHaveCount(1); + await expect(classic).toContainText('SQL'); + // Created by this user, so not marked shared; a classic view is labelled as such. + await expect(classic).not.toContainText('shared'); + await expect(scoped).toContainText('Logs view'); + await expect(scoped).not.toContainText('shared'); + // Both stored windows are usable, so neither shows a fallback warning. + await expect( + list.getByRole('note').filter({ hasText: new RegExp(`${classicName}|${logsViewName}`) }), + ).toHaveCount(0); + + // Opening keeps the exact SQL and absolute window and runs it on the server. + await classic.click(); + await expect(page.getByRole('tab', { name: classicName, exact: true })).toBeVisible(); + await expect(page.getByRole('textbox', { name: 'SQL query' })).toHaveText(classicSql); + await expect(page.getByLabel('Time range', { exact: true })).toHaveValue('custom'); + const classicRun = page.waitForResponse(isQuery); + await page.getByRole('button', { name: 'Run query', exact: true }).click(); + const classicResponse = await classicRun; + expect(classicResponse.ok(), await classicResponse.text()).toBe(true); + expect(classicResponse.request().postDataJSON()).toMatchObject({ + query: classicSql, + startTime: classicWindow.from, + endTime: classicWindow.to, + }); + const expectedCounts = [ + { level: 'ERROR', events: 3, total_latency: 900 }, + { level: 'INFO', events: 2, total_latency: 60 }, + { level: 'WARN', events: 1, total_latency: 80 }, + ]; + await expectUiResult(request, classicResponse, expectedCounts); + expect(await runSql(request, classicSql, classicWindow.from, classicWindow.to)).toEqual( + expectedCounts, + ); + const results = page.getByRole('table'); + await expect(results.locator('tbody tr')).toHaveCount(3); + await expect(results.getByRole('row').filter({ hasText: 'ERROR' })).toContainText('900'); + + // The dataset-scoped view opens with its dataset selected and its relative window. + await scoped.click(); + await expect(page.getByRole('tab', { name: logsViewName, exact: true })).toBeVisible(); + await expect(page.getByRole('textbox', { name: 'SQL query' })).toHaveText(logsViewSql); + await expect(page.getByLabel('Time range', { exact: true })).toHaveValue('7d'); + await expect( + page.getByRole('checkbox', { name: `Select ${dataset}`, exact: true }), + ).toBeChecked(); + const scopedRun = page.waitForResponse(isQuery); + await page.getByRole('button', { name: 'Run query', exact: true }).click(); + const scopedResponse = await scopedRun; + expect(scopedResponse.ok(), await scopedResponse.text()).toBe(true); + const scopedBody = scopedResponse.request().postDataJSON(); + expect(scopedBody.query).toBe(logsViewSql); + expect(Date.parse(scopedBody.endTime) - Date.parse(scopedBody.startTime)).toBe( + 7 * 24 * 60 * 60_000, + ); + await expectUiResult( + request, + scopedResponse, + events + .filter((event) => event.level === 'ERROR') + .map(({ message, latency_ms }) => ({ message, latency_ms })), + ); + await expect(results.locator('tbody tr')).toHaveCount(3); + await expect(results).toContainText(`checkout failed c ${suffix}`); + await expect(results).not.toContainText(`checkout ok a ${suffix}`); + await test.info().attach('sql-results.png', { + body: await page.screenshot({ fullPage: true }), + contentType: 'image/png', + }); + + // Reopening focuses the existing draft instead of duplicating it. + await classic.click(); + await expect(page.getByRole('tab', { name: classicName, exact: true })).toHaveCount(1); + + // Copies go to this browser only and keep SQL, window and dataset scope. + await page + .getByRole('button', { name: `Copy server query ${classicName} to this browser` }) + .click(); + await expect(page.getByText(`Copied ${classicName} to this browser library.`)).toBeVisible(); + await page + .getByRole('button', { name: `Copy server query ${logsViewName} to this browser` }) + .click(); + await expect(page.getByText(`Copied ${logsViewName} to this browser library.`)).toBeVisible(); + await page + .getByRole('button', { name: `Copy server query ${classicName} to this browser` }) + .click(); + await expect(page.getByText(`${classicName} is already in this browser library.`)).toBeVisible(); + const library = await browserLibrary(page); + expect(library.filter((item) => item.name === classicName)).toEqual([ + expect.objectContaining({ + sql: classicSql, + range: { startTime: classicWindow.from, endTime: classicWindow.to }, + datasets: [], + }), + ]); + expect(library.filter((item) => item.name === logsViewName)).toEqual([ + expect.objectContaining({ sql: logsViewSql, range: '7d', datasets: [dataset] }), + ]); + // The copy is an ordinary editable browser query, separate from the server list. + await page.getByRole('button', { name: classicName, exact: true }).click(); + await expect(page.getByRole('textbox', { name: 'SQL query' })).toHaveText(classicSql); + + expect(writes).toEqual([]); + const after = await Promise.all( + [classicId, logsViewId].map((id) => getJson(request, `/api/v1/filters/${id}`)), + ); + expect(after).toEqual(before); +}); + +test('Logs saved views hand an older dataset-scoped SQL view to the SQL editor unchanged', async ({ + page, + request, +}) => { + const before = await getJson(request, `/api/v1/filters/${logsViewId}`); + const writes = watchWrites(page, '/api/v1/filters'); + const logsPath = `/logs/explore/${encodeURIComponent(dataset)}`; + await signIn(page, logsPath); + await expect(page.getByRole('table')).toContainText(`checkout failed a ${suffix}`); + await page.getByRole('button', { name: 'Saved views', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: 'Saved log views' }); + const select = dialog.getByLabel('Saved log view'); + // The classic SQL query is not scoped to this dataset, so only the Logs view is offered. + await expect(select.locator('option', { hasText: classicName })).toHaveCount(0); + await select.selectOption({ label: `${logsViewName} (classic or incompatible)` }); + await expect( + dialog.getByText(/This classic view cannot be applied or overwritten here/), + ).toBeVisible(); + await expect(dialog.getByText(/saved time range is unreadable/)).toHaveCount(0); + await expect(dialog.getByRole('button', { name: 'Apply view', exact: true })).toBeDisabled(); + await expect(dialog.getByRole('button', { name: 'Update selected', exact: true })).toBeDisabled(); + await expect(dialog.getByRole('button', { name: 'Convert to SQL view' })).toBeDisabled(); + await dialog.getByRole('button', { name: 'Open in SQL editor' }).click(); + + await expect(page.getByRole('textbox', { name: 'SQL query' })).toHaveText(logsViewSql); + await expect(page.getByLabel('Time range', { exact: true })).toHaveValue('7d'); + // The consumed handoff leaves no query or range in the URL. + await expect(page).toHaveURL((url) => url.pathname === app('/sql-editor') && !url.search); + const run = page.waitForResponse(isQuery); + await page.getByRole('button', { name: 'Run query', exact: true }).click(); + const response = await run; + expect(response.ok(), await response.text()).toBe(true); + expect(response.request().postDataJSON().query).toBe(logsViewSql); + await expectUiResult( + request, + response, + events + .filter((event) => event.level === 'ERROR') + .map(({ message, latency_ms }) => ({ message, latency_ms })), + ); + await expect(page.getByRole('table').locator('tbody tr')).toHaveCount(3); + await expect(page.getByRole('table')).toContainText(`checkout failed b ${suffix}`); + + expect(writes).toEqual([]); + expect(await getJson(request, `/api/v1/filters/${logsViewId}`)).toEqual(before); +}); + +test('a builder alert converts to SQL only on explicit save, keeping settings, disabled and mute', async ({ + page, + request, +}) => { + const detailPath = `/alerts/${alertId}`; + const before = await getJson(request, `/api/v1/alerts/${alertId}`); + expect(before).toMatchObject({ + queryType: 'builder', + query: alertSql, + state: 'disabled', + notificationState: { mute: 'indefinite' }, + targets: [], + }); + const writes = watchWrites(page, '/api/v1/alerts'); + await signIn(page, detailPath); + await expect(page.getByRole('heading', { name: alertTitle, exact: true })).toBeVisible(); + await expect(page.locator('.alerts-query')).toHaveText(alertSql); + await expect(page.getByText('Builder', { exact: true })).toBeVisible(); + await expect(page.getByText('This alert is disabled.', { exact: false })).toBeVisible(); + await expect(page.locator('.alerts-metadata')).toContainText('Muted indefinitely'); + await expect(page.getByRole('button', { name: 'Edit', exact: true })).toHaveCount(0); + + // Cancel leaves the stored builder rule untouched. + await page.getByRole('button', { name: 'Convert to SQL rule', exact: true }).click(); + await expect(page).toHaveURL((url) => url.pathname === app(`${detailPath}/edit`)); + await expect(page.getByTestId('alert-conversion-notice')).toContainText( + 'Nothing changes until you save.', + ); + const sqlEditor = page.getByRole('textbox', { name: 'SQL query', exact: true }); + await expect(sqlEditor).toHaveText(alertSql); + await expect(page.getByLabel('Alert type')).toHaveValue('code'); + await expect(page.getByLabel('Threshold operator')).toHaveValue('>='); + await expect(page.getByLabel('Threshold value')).toHaveValue('5000.5'); + await expect(page.getByLabel('Evaluation frequency (minutes)')).toHaveValue('15'); + await expect(page.getByLabel('Evaluation window')).toHaveValue('2h'); + await expect(page.getByLabel('Severity')).toHaveValue('critical'); + // The stored SQL really runs: AVG of the three ERROR latencies. + await page.getByRole('button', { name: 'Preview SQL (no notifications)' }).click(); + await expect(page.getByText('Preview completed: 1 rows. No notifications sent.')).toBeVisible(); + await expect(page.getByRole('region', { name: 'Preview values' })).toContainText('300'); + await page.getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(page).toHaveURL((url) => url.pathname === app(detailPath)); + await expect(page.getByText('Builder', { exact: true })).toBeVisible(); + expect(writes).toEqual([]); + expect(await getJson(request, `/api/v1/alerts/${alertId}`)).toEqual(before); + + // Explicit conversion stores the same SQL and every setting as a code rule. + await page.getByRole('button', { name: 'Convert to SQL rule', exact: true }).click(); + await expect(sqlEditor).toHaveText(alertSql); + const conversion = page.waitForResponse( + (response) => + response.request().method() === 'PUT' && + new URL(response.url()).pathname === `/api/v1/alerts/${alertId}`, + ); + await page.getByRole('button', { name: 'Convert and save', exact: true }).click(); + const converted = await conversion; + expect(converted.ok(), await converted.text()).toBe(true); + await expect(page).toHaveURL((url) => url.pathname === app(detailPath)); + await expect(page.getByText('SQL', { exact: true })).toBeVisible(); + await expect(page.locator('.alerts-query')).toHaveText(alertSql); + await expect(page.getByText('This alert is disabled.', { exact: false })).toBeVisible(); + await expect(page.locator('.alerts-metadata')).toContainText('Muted indefinitely'); + expect(writes).toEqual([`PUT /api/v1/alerts/${alertId}`]); + const stored = await getJson(request, `/api/v1/alerts/${alertId}`); + expect(stored).toEqual({ ...before, queryType: 'code' }); + await test.info().attach('converted-alert.png', { + body: await page.screenshot({ fullPage: true }), + contentType: 'image/png', + }); + + // The converted rule uses the ordinary SQL edit flow and its SQL can be changed. + await page.getByRole('button', { name: 'Edit', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Edit alert', exact: true })).toBeVisible(); + await expect(page.getByTestId('alert-conversion-notice')).toHaveCount(0); + await expect(sqlEditor).toHaveText(alertSql); + await sqlEditor.fill(editedAlertSql); + await expect(sqlEditor).toHaveText(editedAlertSql); + await page.getByRole('button', { name: 'Preview SQL (no notifications)' }).click(); + await expect(page.getByRole('region', { name: 'Preview values' })).toContainText('3'); + await page.getByRole('button', { name: 'Save alert', exact: true }).click(); + await expect(page).toHaveURL((url) => url.pathname === app(detailPath)); + await expect(page.locator('.alerts-query')).toHaveText(editedAlertSql); + const edited = await getJson(request, `/api/v1/alerts/${alertId}`); + expect(edited).toEqual({ ...before, queryType: 'code', query: editedAlertSql }); +}); diff --git a/frontend/e2e-live/server.spec.ts b/frontend/e2e-live/server.spec.ts index e7b4c88ef..aa11a5ab9 100644 --- a/frontend/e2e-live/server.spec.ts +++ b/frontend/e2e-live/server.spec.ts @@ -80,14 +80,17 @@ test('missing session asks for login and preserves the intended route', async ({ }) => { await signIn(page); await expect(page.getByRole('table')).toContainText('Live smoke'); + const intendedUrl = new URL(page.url()); await context.clearCookies(); await page.reload(); await expect(page).toHaveURL((url) => atLogin(url) && url.searchParams.has('next')); - expect(new URL(page.url()).searchParams.get('next')).toBe(datasetPath); + expect(new URL(page.url()).searchParams.get('next')).toBe( + intendedUrl.pathname + intendedUrl.search, + ); await page.getByLabel('Username', { exact: true }).fill(username); await page.getByLabel('Password', { exact: true }).fill(password); await page.getByRole('button', { name: 'Login', exact: true }).click(); - await expect(page).toHaveURL(appUrl(datasetPath)); + await expect(page).toHaveURL(intendedUrl.href); await expect(page.getByRole('table')).toContainText('Live smoke'); });