diff --git a/frontend/README.md b/frontend/README.md index 78969237f..abbcd410b 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -76,6 +76,10 @@ This creates `build.zip` with a top-level `dist/` directory, including `dist/ind The default log query reads at most 100 rows, with choices up to 10,000. Message search assumes a `message` field. The histogram separately aggregates every matching event through `/query`, using the same captured bounds and predicates as the rows. Its 48 buckets divide elapsed time evenly; they are not calendar-day buckets. AND/OR filters have two levels, at most 12 groups and 50 rules, and support `=`/`!=`; SQL NULL values are excluded by both operators. Saved log views use server `/filters` CRUD and support dataset-scoped and canonical SQL views. New saves stay dataset scoped; owners can explicitly convert compatible views to SQL while retaining their query and dataset. Copy links encode versioned explorer state with fixed absolute bounds. Context uses the existing `/query/context` endpoint with a 101-event bound and requires `p_timestamp` plus a string `message`, `body` or `log`. Large/wide results virtualize rows and columns; sorting and export cover loaded rows only. Wrapped virtual rows show a 96px preview; event details retain the full value. Timezones change display and wall-clock editing while requests retain UTC instants; DST gaps are rejected and repeated times require an earlier/later choice. Demo SQL intentionally supports only a small SELECT subset and rejects unsupported syntax. Full SQL requires a server. SQL multi-dataset generation uses `UNION ALL BY NAME`, fills missing columns with NULL and requires compatible types for common columns, and keeps the 100-row default; demo does not support unions. Incoming query links open or focus a tab while preserving other drafts. Reopening an already-open saved query focuses that tab and preserves its edits. The saved SQL library uses persistent browser local storage, while draft workspaces use session storage independent to each browser tab and survive navigation/reload. Both are scoped to the server and signed-in user (with a separate demo scope); they are not server-synced, and result rows are not persisted. Query handoff parameters are consumed after opening the tab so reloading an edited draft does not reopen the original query. SQL chart previews use the executed query and captured time bounds. Adding a chart to an existing dashboard preserves that dashboard’s time range; a new dashboard starts with the executed range. +Field tables in Logs, SQL, Metrics and dashboard tiles support dragging the grip beside a column heading. Focus a grip and use arrow keys to reorder with the keyboard. **Add columns** in Logs and SQL also lists selected fields in table order, with draggable grips and earlier/later buttons. Details and log-summary Data columns stay fixed. Reordering preserves row sorting. + +Log field selection and order are remembered per dataset in browser local storage, scoped to the server and signed-in user (demo has its own scope). Explicit saved views and shared links take precedence, and include column order. SQL choices survive reruns and draft reloads; saving or updating a query includes them in the saved library. Metrics tables and individual dashboard tiles remember order in the same browser; these preferences do not modify the shared dashboard. New dashboard/metrics fields append after remembered fields and removed fields are ignored. Storage being unavailable does not prevent reordering during the current visit. + Logs implementation keeps persisted settings in `logViewReducer.ts`, with explicit dataset reset, saved-view and classic-link transitions. `logFilters.ts` owns expression limits and additions; adding within a group and narrowing the whole expression remain distinct operations. `logQuery.ts` compiles canonical criteria once for the page's row and histogram queries. Changes to client-only filter IDs or empty groups do not refresh the captured time window. Histogram responses carry their bucket geometry to the renderer. Both table modes use `logTable.tsx` for sorting, labels and cells. `logsContract.ts` validates server payloads; `demoLogs.ts` implements the demo methods separately. Saved-view instants, server timestamps and timezone wall-clock inputs retain separate validators because their accepted formats differ. Server-synced SQL saved queries, SQL AI assistance, and original Prism pixel parity remain future iterations. Traces/APM/ingestion contracts, reference screenshots, deliberate limits and test commands are documented in [observability validation](docs/observability-validation.md). Native routes for Keystone and LLM settings explain unsupported management capabilities and link to classic UI when mounted alongside it; they do not expose upgrade controls or simulated writes. diff --git a/frontend/e2e/app.spec.ts b/frontend/e2e/app.spec.ts index a7dfff59f..dc0252ce4 100644 --- a/frontend/e2e/app.spec.ts +++ b/frontend/e2e/app.spec.ts @@ -219,7 +219,7 @@ test('native login switches from demo to live and sends backend query contracts' const redirect = new URL(new URL(request.url()).searchParams.get('redirect')!); // The current Logs view may carry its preserved navigation context. redirect.searchParams.delete('observe'); - expect(redirect.href).toBe('http://127.0.0.1:5173/logs'); + expect(redirect.href).toBe(new URL('/logs', test.info().project.use.baseURL!).href); authenticated = true; return route.fulfill({ status: 200, body: 'ok' }); } diff --git a/frontend/e2e/column-order.spec.ts b/frontend/e2e/column-order.spec.ts new file mode 100644 index 000000000..9218875c1 --- /dev/null +++ b/frontend/e2e/column-order.spec.ts @@ -0,0 +1,307 @@ +import { test, expect, type Page } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; +import classic from '../src/features/dashboards/__fixtures__/classic.json' with { type: 'json' }; +import tableTile from './fixtures-cartesian/table.json' with { type: 'json' }; + +async function mockServer(page: Page) { + const dashboard = { + ...classic, + dashboardType: 'Dashboard', + variables: [], + sections: [], + tiles: [ + tableTile, + { + ...tableTile, + tile_id: '01K7A000000000000000000009', + title: 'Other events', + layout: { x: 6, y: 0, w: 6, h: 6 }, + }, + ], + }; + await page.context().addCookies( + ['user_id', 'username'].map((name) => ({ + name, + value: 'admin', + url: test.info().project.use.baseURL!, + })), + ); + await page.route('**/api/**', (route) => { + const path = new URL(route.request().url()).pathname; + if (path.endsWith('/users/admin')) + return route.fulfill({ json: { id: 'admin', username: 'admin' } }); + if (path.endsWith('/role')) + return route.fulfill({ + json: { + roles: { root: { actions: [{ privilege: 'admin' }], roleType: 'internal' } }, + groupRoles: {}, + }, + }); + if (path.endsWith('/logstream')) + return route.fulfill({ json: [{ name: 'application_logs' }, { name: 'app-logs' }] }); + if (path.endsWith('/schema')) + return route.fulfill({ + json: { fields: ['p_timestamp', 'level', 'host'].map((name) => ({ name })) }, + }); + if (path.endsWith(`/dashboards/${dashboard.dashboardId}`)) + return route.fulfill({ json: dashboard }); + if (path.endsWith('/query')) + return route.fulfill({ + json: route.request().postDataJSON().query.startsWith('SELECT message') + ? [{ host: 'server-one', message: 'new schema' }] + : [{ p_timestamp: new Date().toISOString(), level: 'INFO', host: 'server-one' }], + }); + return route.fulfill({ json: {} }); + }); + return dashboard; +} + +async function demo(page: Page, path = '/logs') { + await page.addInitScript(() => sessionStorage.setItem('parseable-mode', 'demo')); + await page.goto(path); +} +const table = (page: Page) => page.getByRole('table', { name: 'Query results', exact: true }); +const headerOrder = (page: Page) => + table(page) + .getByRole('columnheader') + .evaluateAll((headers) => + headers.map((header) => header.getAttribute('aria-label') || header.textContent?.trim()), + ); +async function addFields(page: Page) { + await expect(table(page)).toBeVisible(); + await page.getByRole('button', { name: 'Add columns', exact: true }).click(); + const picker = page.getByRole('dialog', { name: 'Add columns' }); + await picker.getByRole('checkbox', { name: 'level', exact: true }).check(); + await picker.getByRole('checkbox', { name: 'host', exact: true }).check(); + await picker.getByRole('button', { name: 'Done', exact: true }).click(); +} + +for (const virtual of [false, true]) { + test(`${virtual ? 'virtual' : 'paginated'} log headers drag, keep cells aligned, and preserve sorting`, async ({ + page, + }) => { + await demo(page); + await addFields(page); + if (virtual) await page.getByLabel('Result limit', { exact: true }).selectOption('1000'); + await table(page).getByRole('button', { name: 'Sort host', exact: true }).click(); + const hostBefore = await table(page) + .getByRole('row') + .nth(1) + .getByRole('cell') + .nth(3) + .textContent(); + await table(page) + .getByRole('button', { name: 'Move host', exact: true }) + .dragTo(table(page).getByRole('columnheader').nth(1)); + await expect + .poll(() => headerOrder(page)) + .toEqual(['Details', 'host', 'Ingestion Time (UTC)', 'level', 'Data']); + await expect(table(page).getByRole('row').nth(1).getByRole('cell').nth(1)).toHaveText( + hostBefore!, + ); + await expect( + table(page).getByRole('columnheader', { name: 'host', exact: true }), + ).toHaveAttribute('aria-sort', 'ascending'); + const handle = table(page).getByRole('button', { name: 'Move host', exact: true }); + await handle.focus(); + await handle.press('ArrowRight'); + await expect(handle).toBeFocused(); + await expect + .poll(() => headerOrder(page)) + .toEqual(['Details', 'Ingestion Time (UTC)', 'host', 'level', 'Data']); + await page.reload(); + await expect + .poll(() => headerOrder(page)) + .toEqual(['Details', 'Ingestion Time (UTC)', 'host', 'level', 'Data']); + }); +} + +test('keyboard reordering follows a column across virtual windows in both directions', async ({ + page, +}) => { + await demo(page); + await page.getByRole('button', { name: 'Add columns', exact: true }).click(); + const picker = page.getByRole('dialog', { name: 'Add columns' }); + const checkboxes = picker.getByRole('checkbox'); + const count = await checkboxes.count(); + for (const checkbox of await checkboxes.all()) await checkbox.check(); + await picker.getByRole('button', { name: 'Done', exact: true }).click(); + await page.getByLabel('Result limit', { exact: true }).selectOption('1000'); + const handle = table(page).getByRole('button', { name: 'Move p_timestamp', exact: true }); + const viewport = page.locator('.virtual-log-viewport'); + await handle.focus(); + for (const key of ['ArrowRight', 'ArrowLeft']) { + for (let index = 1; index < count; index++) { + await page.keyboard.press(key); + await expect(handle).toBeFocused(); + await expect + .poll(async () => { + const grip = await handle.boundingBox(); + const view = await viewport.boundingBox(); + return !!grip && !!view && grip.x >= view.x && grip.x + grip.width <= view.x + view.width; + }) + .toBe(true); + } + } + await expect.poll(() => viewport.evaluate((node) => node.scrollLeft)).toBe(0); +}); + +test('column picker drag and buttons preserve order across datasets and fresh visits', async ({ + page, +}) => { + await demo(page); + await addFields(page); + await page.getByRole('button', { name: 'Add columns', exact: true }).click(); + const picker = page.getByRole('dialog', { name: 'Add columns' }); + await picker + .getByRole('button', { name: 'Move host', exact: true }) + .dragTo(picker.locator('.column-option-row').first()); + await picker.getByRole('button', { name: 'Move level earlier', exact: true }).click(); + await expect(picker.getByRole('checkbox', { checked: true })).toHaveCount(3); + await expect( + picker.getByRole('button', { name: 'Move host earlier', exact: true }), + ).toBeDisabled(); + await picker.getByRole('textbox', { name: 'Search columns' }).fill('level'); + await picker.getByRole('button', { name: 'Move level earlier', exact: true }).click(); + await picker.getByRole('textbox', { name: 'Search columns' }).fill(''); + await expect( + picker.getByRole('button', { name: 'Move level earlier', exact: true }), + ).toBeDisabled(); + expect((await new AxeBuilder({ page }).include('[role="dialog"]').analyze()).violations).toEqual( + [], + ); + await picker.getByRole('button', { name: 'Done' }).click(); + await expect + .poll(() => headerOrder(page)) + .toEqual(['Details', 'level', 'host', 'Ingestion Time (UTC)', 'Data']); + await page.getByLabel('Dataset', { exact: true }).selectOption('api_logs'); + await expect(table(page).getByRole('columnheader', { name: 'host', exact: true })).toHaveCount(0); + await page.getByLabel('Dataset', { exact: true }).selectOption('application_logs'); + await expect + .poll(() => headerOrder(page)) + .toEqual(['Details', 'level', 'host', 'Ingestion Time (UTC)', 'Data']); + await page.goto('/logs'); // No URL snapshot: restores browser preferences. + await expect + .poll(() => headerOrder(page)) + .toEqual(['Details', 'level', 'host', 'Ingestion Time (UTC)', 'Data']); +}); + +test('explicit shared column order takes precedence without replacing dataset preferences', async ({ + page, +}) => { + await demo(page); + await addFields(page); + const state = { + version: 1, + dataset: 'application_logs', + range: '1h', + search: '', + expression: { combinator: 'and', groups: [] }, + columns: ['host', 'level'], + timezone: 'UTC', + limit: 100, + wrap: false, + showHistogram: true, + }; + await page.goto( + `/logs/explore/application_logs?${new URLSearchParams({ logView: JSON.stringify(state) })}`, + ); + await expect.poll(() => headerOrder(page)).toEqual(['Details', 'host', 'level', 'Data']); + await page.goto('/logs'); + await expect + .poll(() => headerOrder(page)) + .toEqual(['Details', 'Ingestion Time (UTC)', 'level', 'host', 'Data']); +}); + +test('SQL order survives reruns, reloads and reopening a saved query', async ({ page }) => { + await mockServer(page); + await page.goto('/sql-editor'); + await page + .getByRole('textbox', { name: 'SQL query' }) + .fill('SELECT p_timestamp, level, host FROM "application_logs" LIMIT 3'); + const run = page.getByRole('button', { name: 'Run query', exact: true }); + await run.click(); + await table(page) + .getByRole('button', { name: 'Move host', exact: true }) + .dragTo(table(page).getByRole('columnheader').nth(1)); + const expected = ['Details', 'host', 'Timestamp (UTC)', 'level']; + await expect.poll(() => headerOrder(page)).toEqual(expected); + await run.click(); + await expect.poll(() => headerOrder(page)).toEqual(expected); + await page.reload(); + await run.click(); + await expect.poll(() => headerOrder(page)).toEqual(expected); + await page.getByRole('textbox', { name: 'Saved query name' }).fill('Ordered results'); + await page.getByRole('button', { name: 'Save as new' }).click(); + await page.getByRole('button', { name: 'Close Ordered results', exact: true }).click(); + await page.getByRole('button', { name: 'Ordered results', exact: true }).click(); + await run.click(); + await expect.poll(() => headerOrder(page)).toEqual(expected); + await page.getByRole('button', { name: 'Add columns', exact: true }).click(); + await page.getByRole('dialog').getByRole('checkbox', { name: 'level', exact: true }).uncheck(); + await page.getByRole('dialog').getByRole('button', { name: 'Done' }).click(); + await page.reload(); + await run.click(); + await expect.poll(() => headerOrder(page)).toEqual(['Details', 'host', 'Timestamp (UTC)']); + await page + .getByRole('textbox', { name: 'SQL query' }) + .fill('SELECT message FROM "application_logs" LIMIT 3'); + await run.click(); + await expect.poll(() => headerOrder(page)).toEqual(['Details', 'host', 'message']); +}); + +test('metrics result column order persists', async ({ page }) => { + await demo(page, '/metrics'); + await page + .getByRole('textbox', { name: 'PromQL query A', exact: true }) + .fill('{"system.cpu.load_average.1m"}'); + await page.getByRole('button', { name: 'Run', exact: true }).click(); + await page.getByRole('tab', { name: 'Table', exact: true }).click(); + const result = page.getByRole('table', { name: 'Range summary', exact: true }); + await result + .getByRole('button', { name: 'Move Max', exact: true }) + .dragTo(result.getByRole('columnheader', { name: 'Series', exact: true })); + await expect(result.getByRole('columnheader').first()).toHaveAttribute('aria-label', 'Max'); + await page.reload(); + await page.getByRole('button', { name: 'Run', exact: true }).click(); + await page.getByRole('tab', { name: 'Table', exact: true }).click(); + await expect(result.getByRole('columnheader').first()).toHaveAttribute('aria-label', 'Max'); +}); + +test('dashboard tables remember order independently for each tile', async ({ page }) => { + const dashboard = await mockServer(page); + await page.goto(`/dashboards/${dashboard.dashboardId}`); + const first = page.getByRole('table', { name: 'Recent events results', exact: true }); + const second = page.getByRole('table', { name: 'Other events results', exact: true }); + await first + .getByRole('button', { name: 'Move host', exact: true }) + .dragTo(first.getByRole('columnheader').first()); + await expect(first.getByRole('columnheader').first()).toHaveAttribute('aria-label', 'host'); + await expect(second.getByRole('columnheader').first()).toHaveAttribute( + 'aria-label', + 'Timestamp (UTC)', + ); + await page.reload(); + await expect(first.getByRole('columnheader').first()).toHaveAttribute('aria-label', 'host'); + await expect(second.getByRole('columnheader').first()).toHaveAttribute( + 'aria-label', + 'Timestamp (UTC)', + ); +}); + +test('ordering remains usable with unavailable browser storage', async ({ page }) => { + await page.addInitScript(() => { + const original = Storage.prototype.setItem; + Storage.prototype.setItem = function (key, value) { + if (key.startsWith('parseable-columns:')) + throw new DOMException('Full', 'QuotaExceededError'); + return original.call(this, key, value); + }; + }); + await demo(page); + await addFields(page); + await table(page).getByRole('button', { name: 'Move host', exact: true }).press('ArrowLeft'); + await expect + .poll(() => headerOrder(page)) + .toEqual(['Details', 'Ingestion Time (UTC)', 'host', 'level', 'Data']); +}); diff --git a/frontend/e2e/column-picker-stability.spec.ts b/frontend/e2e/column-picker-stability.spec.ts new file mode 100644 index 000000000..f2f57ef98 --- /dev/null +++ b/frontend/e2e/column-picker-stability.spec.ts @@ -0,0 +1,32 @@ +import { test, expect } from '@playwright/test'; + +test('column picker keeps checkbox rows in place until an explicit reorder', async ({ page }) => { + await page.addInitScript(() => sessionStorage.setItem('parseable-mode', 'demo')); + await page.goto('/logs'); + await page.getByRole('button', { name: 'Add columns', exact: true }).click(); + const picker = page.getByRole('dialog', { name: 'Add columns' }); + const rows = picker.locator('.column-option-row'); + const labels = () => rows.locator('label').allTextContents(); + const initial = await labels(); + const checkboxPositions = () => + picker.getByRole('checkbox').evaluateAll((inputs) => + inputs.map((input) => { + const { x, y } = input.getBoundingClientRect(); + return { x, y }; + }), + ); + const initialPositions = await checkboxPositions(); + const host = picker.getByRole('checkbox', { name: 'host', exact: true }); + await host.check(); + await expect.poll(labels).toEqual(initial); + await expect.poll(checkboxPositions).toEqual(initialPositions); + await host.uncheck(); + await expect.poll(labels).toEqual(initial); + await expect.poll(checkboxPositions).toEqual(initialPositions); + await host.check(); + await picker.getByRole('button', { name: 'Move host earlier', exact: true }).click(); + await expect.poll(async () => (await labels()).slice(0, 2)).toEqual(['host', 'p_timestamp']); + await picker.getByRole('button', { name: 'Done', exact: true }).click(); + await page.getByRole('button', { name: 'Add columns', exact: true }).click(); + await expect.poll(async () => (await labels()).slice(0, 2)).toEqual(['host', 'p_timestamp']); +}); diff --git a/frontend/e2e/metrics.spec.ts b/frontend/e2e/metrics.spec.ts index 8f4a801bf..1be9fa7c6 100644 --- a/frontend/e2e/metrics.spec.ts +++ b/frontend/e2e/metrics.spec.ts @@ -791,7 +791,9 @@ test('a failing dataset info warns without blocking metrics and is reused until let broken = true; let infoRequests = 0; await page.route('**/api/v1/logstream/*/info', (route) => { - infoRequests++; + // Logs independently requests web_logs metadata when visiting it below. + // Count the other datasets so that request cannot look like a discovery rescan. + if (!route.request().url().includes('/web_logs/')) infoRequests++; return broken && route.request().url().includes('/metrics_b/') ? route.fulfill({ status: 500, body: 'Dataset info unavailable' }) : route.fallback(); @@ -810,7 +812,7 @@ test('a failing dataset info warns without blocking metrics and is reused until await page.getByRole('button', { name: 'Check again', exact: true }).click(); await expect(page.getByLabel('Metrics dataset').locator('option')).toHaveCount(2); await expect(warning).toHaveCount(0); - expect(infoRequests).toBe(scanned + 4); + expect(infoRequests).toBe(scanned + 3); }); test('a malformed PromQL success is a readable query error', async ({ page }) => { diff --git a/frontend/src/components/explorer/ColumnPicker.tsx b/frontend/src/components/explorer/ColumnPicker.tsx index 4acacb908..0f9b489b1 100644 --- a/frontend/src/components/explorer/ColumnPicker.tsx +++ b/frontend/src/components/explorer/ColumnPicker.tsx @@ -1,6 +1,8 @@ import { useState } from 'react'; -import { Columns3 } from 'lucide-react'; +import { ArrowDown, ArrowUp, Columns3 } from 'lucide-react'; import { Button, Dialog, Input } from '../ui'; +import { ColumnReorderHandle, useColumnReorder } from './ColumnReorder'; +import { orderedColumns } from './columnOrder'; export function ColumnPicker({ fields, selected, @@ -12,9 +14,26 @@ export function ColumnPicker({ }) { const [open, setOpen] = useState(false); const [search, setSearch] = useState(''); + const [rowOrder, setRowOrder] = useState([]); + const groupedOrder = (selection: string[]) => [ + ...selection, + ...fields.filter((field) => !selection.includes(field)), + ]; + const reorder = useColumnReorder(selected, (next) => { + setRowOrder(groupedOrder(next)); + onChange(next); + }); + const ordered = orderedColumns([...new Set([...selected, ...fields])], rowOrder); return ( <> - @@ -22,7 +41,7 @@ export function ColumnPicker({ open={open} onOpenChange={setOpen} title="Add columns" - description="Choose the fields to show in the results table." + description="Choose fields, then drag selected fields or use the arrows to change their order." > setSearch(e.target.value)} /> +

+ Fields keep their place while you choose. Reopen to group selected fields in table order. +

+
+ {reorder.announcement} +
- {fields + {ordered .filter((f) => f.toLowerCase().includes(search.toLowerCase())) .map((field) => ( - +
+ {selected.includes(field) ? ( + + ) : ( +
))}
+ ); +} diff --git a/frontend/src/components/explorer/DataTable.tsx b/frontend/src/components/explorer/DataTable.tsx index abebf6c26..9b4799779 100644 --- a/frontend/src/components/explorer/DataTable.tsx +++ b/frontend/src/components/explorer/DataTable.tsx @@ -1,3 +1,6 @@ +import { useColumnPreferences } from './useColumnPreferences'; +import { orderedColumns } from './columnOrder'; +import { ColumnReorderHandle, useColumnReorder } from './ColumnReorder'; import { useState } from 'react'; import { ArrowDown, ArrowUp, ArrowUpDown, ChevronLeft, ChevronRight } from 'lucide-react'; import { Button, EmptyState } from '../ui'; @@ -41,6 +44,8 @@ export function exportRecords(rows: LogRecord[], format: 'json' | 'csv') { export function DataTable({ rows, columns, + onColumnsChange, + columnPreferenceKey, onSelect, caption = 'Query results', wrap = false, @@ -51,6 +56,8 @@ export function DataTable({ }: { rows: LogRecord[]; columns?: string[]; + onColumnsChange?: (columns: string[]) => void; + columnPreferenceKey?: string; onSelect?: (row: LogRecord) => void; caption?: string; wrap?: boolean; @@ -61,11 +68,17 @@ export function DataTable({ }) { const [page, setPage] = useState(0); const [sort, setSort] = useState(); + const [preferred, setPreferred] = useColumnPreferences(columnPreferenceKey); + const available = columns || [...new Set(rows.flatMap(Object.keys))]; + const fields = onColumnsChange ? available : orderedColumns(available, preferred); + const changeColumns = onColumnsChange ?? setPreferred; + const reorder = useColumnReorder(fields, changeColumns); if (virtualized) return ( +
+ {reorder.announcement} +
@@ -101,33 +116,37 @@ export function DataTable({ ))} {summary && } diff --git a/frontend/src/components/explorer/VirtualLogTable.tsx b/frontend/src/components/explorer/VirtualLogTable.tsx index b7a5234aa..d1a8c6d6d 100644 --- a/frontend/src/components/explorer/VirtualLogTable.tsx +++ b/frontend/src/components/explorer/VirtualLogTable.tsx @@ -1,3 +1,4 @@ +import { ColumnReorderHandle, useColumnReorder } from './ColumnReorder'; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { ChevronRight } from 'lucide-react'; import { Button, EmptyState } from '../ui'; @@ -19,6 +20,7 @@ const HEADER_HEIGHT = 36; export function VirtualLogTable({ rows, columns, + onColumnsChange, onSelect, caption = 'Query results', wrap = false, @@ -28,6 +30,7 @@ export function VirtualLogTable({ }: { rows: LogRecord[]; columns?: string[]; + onColumnsChange?: (columns: string[]) => void; onSelect?: (row: LogRecord) => void; caption?: string; wrap?: boolean; @@ -39,8 +42,10 @@ export function VirtualLogTable({ const [position, setPosition] = useState({ top: 0, left: 0, width: 960, height: 480 }); const [sort, setSort] = useState(); const [focusedRow, setFocusedRow] = useState(); + const [focusedField, setFocusedField] = useState(); const pendingFocus = useRef(undefined); const fields = useMemo(() => columns ?? [...new Set(rows.flatMap(Object.keys))], [columns, rows]); + const reorder = useColumnReorder(fields, onColumnsChange ?? (() => {})); const displayFields = useMemo(() => (summary ? [...fields, null] : fields), [fields, summary]); const ordered = useMemo(() => orderLogRows(rows, sort), [rows, sort]); useEffect(() => { @@ -67,6 +72,18 @@ export function VirtualLogTable({ pendingFocus.current = undefined; }, [focusedRow, position.top]); const detailWidth = onSelect ? DETAILS_WIDTH : 0; + const focusedColumn = focusedField === undefined ? -1 : displayFields.indexOf(focusedField); + useLayoutEffect(() => { + const node = viewport.current; + if (!node || focusedColumn < 0) return; + const left = detailWidth + focusedColumn * COLUMN_WIDTH; + if (left < node.scrollLeft + detailWidth) node.scrollLeft = left - detailWidth; + else if (left + COLUMN_WIDTH > node.scrollLeft + node.clientWidth) + node.scrollLeft = left + COLUMN_WIDTH - node.clientWidth; + setPosition((current) => + current.left === node.scrollLeft ? current : { ...current, left: node.scrollLeft }, + ); + }, [focusedColumn, detailWidth]); const rowWindow = virtualWindow( rows.length, Math.max(0, position.top - HEADER_HEIGHT), @@ -82,6 +99,15 @@ export function VirtualLogTable({ 2, ); const visibleFields = displayFields.slice(columnWindow.start, columnWindow.end); + const headerIndices = Array.from( + { length: columnWindow.end - columnWindow.start }, + (_, index) => columnWindow.start + index, + ); + // Keep the focused header mounted while its new position scrolls into view. + if (focusedColumn >= 0 && !headerIndices.includes(focusedColumn)) { + headerIndices.push(focusedColumn); + headerIndices.sort((a, b) => a - b); + } const visibleRows = virtualWindow( rows.length, Math.max(0, position.top), @@ -107,6 +133,9 @@ export function VirtualLogTable({ ); return (
+
+ {reorder.announcement} +
Details
)} - {visibleFields.map((field, index) => ( -
- {field === null ? ( - label(field) - ) : ( - - )} -
- ))} + {headerIndices.map((index) => { + const field = displayFields[index]; + return ( +
setFocusedField(field)} + onBlurCapture={(event) => { + if (!event.currentTarget.contains(event.relatedTarget)) + setFocusedField(undefined); + }} + aria-sort={ + sort?.field === field ? (sort.descending ? 'descending' : 'ascending') : 'none' + } + key={field === null ? 'summary' : `field:${field}`} + {...(field !== null ? reorder.targetProps(field) : {})} + className="virtual-log-cell" + style={{ + left: detailWidth + index * COLUMN_WIDTH, + width: COLUMN_WIDTH, + }} + > + {field === null ? ( + label(field) + ) : ( +
+ {onColumnsChange && } + +
+ )} +
+ ); + })}
{rowIndices.map((rowIndex) => { const row = ordered[rowIndex]; diff --git a/frontend/src/components/explorer/column-reorder.css b/frontend/src/components/explorer/column-reorder.css new file mode 100644 index 000000000..37b3ccdba --- /dev/null +++ b/frontend/src/components/explorer/column-reorder.css @@ -0,0 +1,60 @@ +.reorderable-column-heading { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} +.reorderable-column-heading .column-sort { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} +.column-reorder-handle { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 20px; + width: 20px; + height: 22px; + border: 0; + padding: 0; + background: transparent; + color: var(--color-muted); + cursor: grab; +} +.column-reorder-handle:active { + cursor: grabbing; +} +.column-reorder-handle:disabled { + cursor: default; + opacity: 0.35; +} +.column-reorder-handle:hover { + color: var(--color-text); + background: var(--color-surface-hover); +} +.column-reorder-handle:focus-visible { + outline: 2px solid var(--color-focus); + outline-offset: -2px; +} +[data-column-drop-target] { + box-shadow: inset 3px 0 var(--color-primary); + background: var(--color-surface-hover); +} +.column-option-row { + display: flex; + align-items: center; + gap: 6px; + padding: 2px; +} +.column-option-row label { + flex: 1; + min-width: 0; +} +.column-option-row .ui-button { + flex-shrink: 0; +} +.column-order-help { + color: var(--color-muted); + font-size: 12px; +} diff --git a/frontend/src/components/explorer/columnOrder.test.ts b/frontend/src/components/explorer/columnOrder.test.ts new file mode 100644 index 000000000..a26e259f3 --- /dev/null +++ b/frontend/src/components/explorer/columnOrder.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { + columnStorageKey, + moveColumn, + orderedColumns, + readColumnOrder, + reconcileSelectedColumns, +} from './columnOrder'; + +describe('column preferences', () => { + it('reconciles SQL selections against changed schemas without revealing explicitly hidden fields', () => { + expect(reconcileSelectedColumns(['a', 'b', 'c', 'new'], ['c', 'a'], ['a', 'b', 'c'])).toEqual([ + 'c', + 'a', + 'new', + ]); + expect(reconcileSelectedColumns(['new'], ['a'], ['a'])).toEqual(['new']); + expect(reconcileSelectedColumns(['a', 'b'], ['b'], ['a'])).toEqual(['b']); + expect(reconcileSelectedColumns([], ['a'], ['a', 'b'])).toEqual(['a']); + expect(reconcileSelectedColumns(['b'], ['a'], ['a', 'b'])).toBeUndefined(); + }); + it('moves in both directions without changing the input or accepting unknown fields', () => { + const fields = ['timestamp', 'flags', 'temporality']; + expect(moveColumn(fields, 'temporality', 'timestamp')).toEqual([ + 'temporality', + 'timestamp', + 'flags', + ]); + expect(moveColumn(fields, 'timestamp', 'temporality')).toEqual([ + 'flags', + 'temporality', + 'timestamp', + ]); + expect(moveColumn(fields, 'missing', 'flags')).toBe(fields); + expect(moveColumn(fields, 'flags', 'flags')).toBe(fields); + expect(fields).toEqual(['timestamp', 'flags', 'temporality']); + }); + it('ignores removed fields and appends new fields without losing the preferred order', () => { + expect(orderedColumns(['a', 'b', 'c'], ['b', 'gone', 'a'])).toEqual(['b', 'a', 'c']); + expect(orderedColumns([], ['b'])).toEqual([]); + }); + it('rejects malformed preferences', () => { + for (const value of [null, {}, [], ['a', 2], ['a', 'a'], 'a']) + expect(readColumnOrder(value)).toBeUndefined(); + expect(readColumnOrder(['__proto__', 'a'])).toEqual(['__proto__', 'a']); + }); + it('isolates server, user, mode and table, and waits for live identity', () => { + const user = { id: 'alice', username: 'alice', source: 'server' as const }; + const key = columnStorageKey('live', user, 'logs:a', 'https://one'); + expect(key).not.toBe(columnStorageKey('live', { ...user, id: 'bob' }, 'logs:a', 'https://one')); + expect(key).not.toBe(columnStorageKey('live', user, 'logs:a', 'https://two')); + expect(key).not.toBe(columnStorageKey('live', user, 'logs:b', 'https://one')); + expect(key).not.toBe(columnStorageKey('demo', user, 'logs:a', 'https://one')); + expect(columnStorageKey('live', undefined, 'logs:a')).toBeNull(); + }); +}); diff --git a/frontend/src/components/explorer/columnOrder.ts b/frontend/src/components/explorer/columnOrder.ts new file mode 100644 index 000000000..e98d73d54 --- /dev/null +++ b/frontend/src/components/explorer/columnOrder.ts @@ -0,0 +1,53 @@ +import type { SessionIdentity } from '../../lib/types'; + +export function moveColumn(fields: string[], source: string, target: string): string[] { + const from = fields.indexOf(source), + to = fields.indexOf(target); + if (from < 0 || to < 0 || from === to) return fields; + const next = [...fields]; + next.splice(to, 0, ...next.splice(from, 1)); + return next; +} + +export function readColumnOrder(value: unknown): string[] | undefined { + return Array.isArray(value) && + value.length > 0 && + value.every((field) => typeof field === 'string') && + new Set(value).size === value.length + ? value + : undefined; +} + +/** Retain known preferences, and append newly returned fields in their original order. */ +export function orderedColumns(fields: string[], order?: string[]): string[] { + return order + ? [ + ...order.filter((field) => fields.includes(field)), + ...fields.filter((field) => !order.includes(field)), + ] + : fields; +} + +export function columnStorageKey( + mode: 'live' | 'demo', + identity: SessionIdentity | undefined, + table: string, + server = typeof location === 'undefined' ? '' : location.origin, +): string | null { + const owner = mode === 'demo' ? 'demo' : identity?.id; + return owner ? `parseable-columns:v1:${JSON.stringify([server, mode, owner, table])}` : null; +} + +/** Keep explicit hidden fields hidden, while exposing fields introduced by a changed query. */ +export function reconcileSelectedColumns( + fields: string[], + selected?: string[], + known = selected, +): string[] | undefined { + if (!selected || !fields.length) return selected; + const next = [ + ...selected.filter((field) => fields.includes(field)), + ...fields.filter((field) => !selected.includes(field) && !known?.includes(field)), + ]; + return next.length ? next : undefined; +} diff --git a/frontend/src/components/explorer/useColumnPreferences.ts b/frontend/src/components/explorer/useColumnPreferences.ts new file mode 100644 index 000000000..20f2fe7cf --- /dev/null +++ b/frontend/src/components/explorer/useColumnPreferences.ts @@ -0,0 +1,29 @@ +import { useContext, useState } from 'react'; +import { AppContext } from '../../app/AppProvider'; +import { columnStorageKey, readColumnOrder } from './columnOrder'; + +function read(key: string | null) { + try { + return key ? readColumnOrder(JSON.parse(localStorage.getItem(key) || 'null')) : undefined; + } catch { + return undefined; + } +} + +/** Writes only on explicit interaction, so opening a shared view never overwrites defaults. */ +export function useColumnPreferences(table?: string) { + const app = useContext(AppContext); + const key = app && table ? columnStorageKey(app.mode, app.identity, table) : null; + const [stored, setStored] = useState(() => ({ key, columns: read(key) })); + if (stored.key !== key) setStored({ key, columns: read(key) }); + const columns = stored.key === key ? stored.columns : read(key); + const setColumns = (columns: string[]) => { + setStored({ key, columns }); + try { + if (key) localStorage.setItem(key, JSON.stringify(columns)); + } catch { + /* Ordering still works when browser storage is unavailable. */ + } + }; + return [columns, setColumns] as const; +} diff --git a/frontend/src/features/dashboards/DashboardTile.tsx b/frontend/src/features/dashboards/DashboardTile.tsx index 12987ead6..a77fc4fa2 100644 --- a/frontend/src/features/dashboards/DashboardTile.tsx +++ b/frontend/src/features/dashboards/DashboardTile.tsx @@ -330,6 +330,7 @@ export function DashboardTile({ {(result || (results.loading && lastResult?.client === client)) && ( )} - + ); if (tile.chartType === 'query-value') { diff --git a/frontend/src/features/logs/LogsPage.tsx b/frontend/src/features/logs/LogsPage.tsx index 12b03d104..e7ea57991 100644 --- a/frontend/src/features/logs/LogsPage.tsx +++ b/frontend/src/features/logs/LogsPage.tsx @@ -1,3 +1,4 @@ +import { useColumnPreferences } from '../../components/explorer/useColumnPreferences'; import { createPortal } from 'react-dom'; import { createId } from '../../lib/ids'; import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; @@ -71,7 +72,12 @@ export function LogsPage() { ); const [traceFocus, setTraceFocus] = useState<{ field: string; traceId: string }>(); const [dropped, setDropped] = useState([]); - const [view, dispatchView] = useReducer(logViewReducer, dataset, initialLogView); + const [preferredColumns, saveColumns] = useColumnPreferences(`logs:${dataset}`); + const [draftView, dispatchView] = useReducer(logViewReducer, dataset, initialLogView); + const view = useMemo( + () => ({ ...draftView, columns: draftView.columns ?? preferredColumns }), + [draftView, preferredColumns], + ); const { range, search, expression, timezone, limit, columns, wrap, showHistogram } = view; const editView = (patch: Partial>) => dispatchView({ type: 'edit', patch }); @@ -86,7 +92,10 @@ export function LogsPage() { }; const setTimezone = (timezone: string) => editView({ timezone }); const setLimit = (limit: number) => editView({ limit }); - const setColumns = (columns: string[]) => editView({ columns }); + const setColumns = (columns: string[]) => { + editView({ columns }); + saveColumns(columns); + }; const setWrap = (wrap: boolean) => editView({ wrap }); const setShowHistogram = (showHistogram: boolean) => editView({ showHistogram }); // Tagged with its dataset so a timer left over from the previous dataset is ignored. @@ -690,6 +699,7 @@ export function LogsPage() { (); const ordered = sort ? [...rows].sort((left, right) => { @@ -38,6 +44,9 @@ export function ResultTable({ const currentPage = Math.min(page, pages - 1); return (
+
+ {reorder.announcement} +

{title}

@@ -81,10 +90,12 @@ export function ResultTable({
- {columns.map((field) => ( + {fields.map((field) => ( ))} @@ -124,7 +138,7 @@ export function ResultTable({ .slice(currentPage * pageSize, (currentPage + 1) * pageSize) .map((row, index) => ( - {columns.map((field) => ( + {fields.map((field) => (
{caption}
- + + )} + + Data
{title}
- +
{ const restored = createQueryTab(item.name, item.sql, item.range); restored.savedId = item.savedId; + restored.columns = item.columns; + restored.columnFields = item.columnFields; restored.datasets = item.datasets; restored.chart = readSqlChartConfig(item.chart) || restored.chart; return restored; @@ -54,6 +57,8 @@ function writeDrafts(scope: string, tabs: QueryTab[]) { range: item.range, datasets: item.datasets, chart: item.chart, + columns: item.columns, + columnFields: item.columnFields, updatedAt: new Date().toISOString(), })), ), @@ -124,7 +129,7 @@ function SqlWorkspace() { const existing = tabs.find((item) => item.id === id); if ( existing && - ['name', 'query', 'range', 'datasets', 'chart', 'savedId'].some( + ['name', 'query', 'range', 'datasets', 'chart', 'savedId', 'columns'].some( (key) => key in value && JSON.stringify(existing[key as keyof QueryTab]) !== @@ -251,7 +256,6 @@ function SqlWorkspace() { request, loading: true, error: undefined, - columns: undefined, rows: [], elapsed: undefined, canceled: false, @@ -259,7 +263,20 @@ function SqlWorkspace() { try { const rows = await client.query(request, controller.signal); if (!controller.signal.aborted && requests.current.get(id) === controller) - patch(id, { rows, loading: false, elapsed: performance.now() - start }); + setTabs((items) => + items.map((item) => { + if (item.id !== id) return item; + const available = [...new Set(rows.flatMap(Object.keys))]; + return { + ...item, + rows, + loading: false, + elapsed: performance.now() - start, + columns: reconcileSelectedColumns(available, item.columns, item.columnFields), + columnFields: available.length ? available : item.columnFields, + }; + }), + ); } catch (error) { if (!controller.signal.aborted && requests.current.get(id) === controller) patch(id, { @@ -341,6 +358,11 @@ function SqlWorkspace() { target.addEventListener('pointercancel', stop); }; const fields = [...new Set(tab.rows.flatMap(Object.keys))]; + const setColumns = (columns: string[]) => + patch(tab.id, { + columns, + columnFields: fields.length ? fields : tab.columnFields, + }); return (
@@ -464,6 +486,8 @@ function SqlWorkspace() { currentRange={tab.range} currentName={tab.name} currentChart={tab.chart} + currentColumns={tab.columns} + currentColumnFields={tab.columnFields} currentSavedId={tab.savedId} onSaved={(item) => patch(tab.id, { savedId: item.id, name: item.name })} onGenerateQuery={() => newQuery(datasetQuery(tab.datasets), tab.datasets)} @@ -479,6 +503,8 @@ function SqlWorkspace() { const next = createQueryTab(item.name, item.sql, item.range); next.datasets = item.datasets; next.savedId = item.id; + next.columns = item.columns; + next.columnFields = item.columnFields; next.chart = readSqlChartConfig(item.chart) || next.chart; setTabs((items) => [...items, next]); setActiveId(next.id); @@ -628,7 +654,7 @@ function SqlWorkspace() { patch(tab.id, { columns })} + onChange={setColumns} />