diff --git a/README.md b/README.md index 8906ca895..a8cb676fb 100644 --- a/README.md +++ b/README.md @@ -37,17 +37,19 @@ Some of the key highlights include: ## Quickstart -Download and run the Parseable binary on your laptop: +Download the latest published binary from this fork (`VibeEngineeringAI/parseable`). +The installer requires a published release containing the asset for your platform; +it will fail if one is unavailable. ```bash -curl -fsSL https://logg.ing/install | bash +curl -fsSL https://raw.githubusercontent.com/VibeEngineeringAI/parseable/main/scripts/download.sh | sh ```
For Windows ```pwsh -powershell -c "irm https://logg.ing/install-windows | iex" +powershell -c "irm https://raw.githubusercontent.com/VibeEngineeringAI/parseable/main/scripts/download.ps1 | iex" ```
diff --git a/docker-compose-distributed-test-with-kafka.yaml b/docker-compose-distributed-test-with-kafka.yaml index 7ad424d9d..f173e22b0 100644 --- a/docker-compose-distributed-test-with-kafka.yaml +++ b/docker-compose-distributed-test-with-kafka.yaml @@ -72,6 +72,7 @@ services: - P_USERNAME=parseableadmin - P_PASSWORD=parseableadmin - P_CHECK_UPDATE=false + - P_SEND_ANONYMOUS_USAGE_DATA=false - P_PARQUET_COMPRESSION_ALGO=snappy - P_MODE=query - RUST_LOG=warn @@ -113,6 +114,7 @@ services: - P_USERNAME=parseableadmin - P_PASSWORD=parseableadmin - P_CHECK_UPDATE=false + - P_SEND_ANONYMOUS_USAGE_DATA=false - P_PARQUET_COMPRESSION_ALGO=snappy - P_MODE=ingest - P_INGESTOR_ENDPOINT=parseable-ingest-one:8000 @@ -161,6 +163,7 @@ services: - P_USERNAME=parseableadmin - P_PASSWORD=parseableadmin - P_CHECK_UPDATE=false + - P_SEND_ANONYMOUS_USAGE_DATA=false - P_PARQUET_COMPRESSION_ALGO=snappy - P_MODE=ingest - P_INGESTOR_ENDPOINT=parseable-ingest-two:8000 diff --git a/docker-compose-distributed-test.yaml b/docker-compose-distributed-test.yaml index 25b246638..52a5352f3 100644 --- a/docker-compose-distributed-test.yaml +++ b/docker-compose-distributed-test.yaml @@ -43,6 +43,7 @@ services: - P_USERNAME=parseableadmin - P_PASSWORD=parseableadmin - P_CHECK_UPDATE=false + - P_SEND_ANONYMOUS_USAGE_DATA=false - P_PARQUET_COMPRESSION_ALGO=snappy - P_MODE=query - RUST_LOG=warn @@ -80,6 +81,7 @@ services: - P_USERNAME=parseableadmin - P_PASSWORD=parseableadmin - P_CHECK_UPDATE=false + - P_SEND_ANONYMOUS_USAGE_DATA=false - P_PARQUET_COMPRESSION_ALGO=snappy - P_MODE=ingest - P_INGESTOR_ENDPOINT=parseable-ingest-one:8000 diff --git a/docker-compose-gcs-distributed-test.yaml b/docker-compose-gcs-distributed-test.yaml index 16c4c79bd..a90e53e2f 100644 --- a/docker-compose-gcs-distributed-test.yaml +++ b/docker-compose-gcs-distributed-test.yaml @@ -18,6 +18,7 @@ services: - P_USERNAME=parseableadmin - P_PASSWORD=parseableadmin - P_CHECK_UPDATE=false + - P_SEND_ANONYMOUS_USAGE_DATA=false - P_PARQUET_COMPRESSION_ALGO=snappy - P_MODE=query - RUST_LOG=warn @@ -33,7 +34,7 @@ services: restart_policy: condition: on-failure delay: 20s - max_attempts: + max_attempts: 3 volumes: - "${GCS_CREDENTIALS_PATH:-./credentials}:/parseable/svc/:ro,z" # ingest server one @@ -52,6 +53,7 @@ services: - P_USERNAME=parseableadmin - P_PASSWORD=parseableadmin - P_CHECK_UPDATE=false + - P_SEND_ANONYMOUS_USAGE_DATA=false - P_PARQUET_COMPRESSION_ALGO=snappy - P_MODE=ingest - P_INGESTOR_ENDPOINT=parseable-ingest-one:8000 diff --git a/docker-compose-test-with-kafka.yaml b/docker-compose-test-with-kafka.yaml index 6e630e202..ff8f09f2e 100644 --- a/docker-compose-test-with-kafka.yaml +++ b/docker-compose-test-with-kafka.yaml @@ -44,6 +44,7 @@ services: - P_USERNAME=parseableadmin - P_PASSWORD=parseableadmin - P_CHECK_UPDATE=false + - P_SEND_ANONYMOUS_USAGE_DATA=false - P_PARQUET_COMPRESSION_ALGO=snappy - P_KAFKA_CONSUMER_TOPICS=test-logs-stream - P_KAFKA_BOOTSTRAP_SERVERS=kafka-0:9092 diff --git a/docker-compose-test.yaml b/docker-compose-test.yaml index 635e31ceb..062cc04dc 100644 --- a/docker-compose-test.yaml +++ b/docker-compose-test.yaml @@ -42,6 +42,7 @@ services: - P_USERNAME=parseableadmin - P_PASSWORD=parseableadmin - P_CHECK_UPDATE=false + - P_SEND_ANONYMOUS_USAGE_DATA=false - P_PARQUET_COMPRESSION_ALGO=snappy - MAX_PERIODIC_LOCAL_SYNC_CYCLES=1 - RUST_LOG=warn diff --git a/docs/sql-filter-conversion.md b/docs/sql-filter-conversion.md new file mode 100644 index 000000000..bd89b5f5a --- /dev/null +++ b/docs/sql-filter-conversion.md @@ -0,0 +1,79 @@ +# Saved SQL filter compatibility and conversion + +Dataset-scoped SQL filters and upstream SQL filters using `stream_name: "sql"` +are both supported. Existing views are not automatically converted. New native +Logs views keep their dataset-scoped format until the owner chooses **Convert to +SQL view** in Saved views. Converted views still open in the original dataset; +`explorer_state` retains their dataset, query controls, time range and display +settings. Editing a converted view keeps it converted. + +The native explorer reads both formats. Classic filters without native explorer +state remain visible under their original dataset, but cannot be opened or +converted by the native explorer. The literal `sql` is a reserved dataset name, +so it cannot collide with a user-created dataset. + +## Backend operation + +``` +POST /api/v1/filters/{filter_id}/convert-to-sql +``` + +The endpoint takes no JSON payload and returns the complete saved filter. It +requires filter-update permission and an owner-or-admin check. Only SQL filters +can be converted. The server copies the authorized stored record, preserving +its ID, owner, query, time filter and extra metadata. Repeating the request is +safe, including after an interrupted cleanup. Admin updates also preserve the +original owner. + +Ordinary create and update requests accept a valid dataset name or, for SQL +filters only, the literal `sql`. Path-like names remain invalid. A stale client +cannot turn a converted SQL filter back into a dataset-scoped one by sending its +old stream name. + +## Reconciliation and recovery + +Persistence uses `users//filters//.json`. Conversion writes +the `sql` copy first, updates the server cache, then removes obsolete copies of +the same owner's filter ID. Cleanup failure returns an error, while the durable +new copy remains available for retry. Deletion removes aliases before the +current copy, so a successful deletion cannot leave an older copy to return on +restart. + +Startup reconciliation groups records by owner and ID within each tenant. When +both canonical SQL and legacy SQL copies exist, it deterministically keeps the +canonical copy and removes obsolete SQL copies. A legacy record with no +canonical copy is left in its existing format. Ambiguous legacy-only duplicates +are selected deterministically without deleting the alternatives. Cleanup +failures are logged and can be retried on the next startup or conversion; there +is no one-time completion flag that could hide unfinished work. + +Older administrator edits could also leave the same filter ID under different +owners. These records are retained independently. Each owner, including an +administrator, can manage their own copy; an administrator without a copy can +manage another owner's record only when the ID is unambiguous. The native +Saved views selector distinguishes the owners' copies. + +A process-wide async lock serializes filter persistence and cache changes. +This follows the existing single-query-server ownership model: it is not a +distributed lock, and concurrent writes through multiple independent query +servers sharing the same metastore are not coordinated by this change. + +Before a rollout, back up stored filter metadata and verify a legacy view can +be opened, converted, edited, restarted and deleted without reappearing. A +rollback to an older native UI will hide converted views because that UI only +understands dataset-scoped records; keep this compatible UI when rolling back +other changes. Conversion does not change log data or delete datasets. + +## Regression checks + +Run the isolated HTTP/restart smoke test against a newly built server: + +```sh +PARSEABLE_BIN=/absolute/path/to/parseable python3 scripts/test-sql-filter-conversion.py +``` + +It starts its own local-store process and temporary data directories; it does not +contact an existing deployment. The fixture checks owner authorization, admin +updates, conversion retries, stale-client edits, startup cleanup of an injected +legacy duplicate, and delete followed by restart. Rust metastore tests also +exercise cleanup failures, tenant isolation, and ambiguous legacy records. diff --git a/docs/tool-api.md b/docs/tool-api.md index 720aaa30b..d14c8f491 100644 --- a/docs/tool-api.md +++ b/docs/tool-api.md @@ -21,10 +21,13 @@ Response: { "name": "get_dataset_schema", "title": "Get dataset schema", - "description": "Get columns and data types for a dataset.", + "description": "Get the full Arrow JSON schema by default, or request format=compact for tab-separated column names and types.", "inputSchema": { "type": "object", - "properties": { "dataset": { "type": "string", "minLength": 1 } }, + "properties": { + "dataset": { "type": "string", "minLength": 1 }, + "format": { "type": "string", "enum": ["json", "compact"], "default": "json" } + }, "required": ["dataset"], "additionalProperties": false } @@ -89,3 +92,38 @@ tool. The REST API is not itself an MCP transport. An MCP adapter maps the registry response to `tools/list`, forwards calls to this endpoint, and returns the `CallToolResult` from Parseable through MCP `tools/call`. + +## Schema output compatibility + +`get_dataset_schema` preserves the full Arrow schema object in +`structuredContent.result` when `format` is omitted or `"json"`. Existing JSON +consumers do not need to change. Set `"format": "compact"` to receive a string +with a `name\ttype` header and one tab-separated row per field. Compact output +escapes tabs, newlines, carriage returns and backslashes and omits metadata and +top-level nullability; it is for inspection, not Arrow schema interchange. +The HTTP schema endpoint likewise defaults to JSON and accepts `?format=compact`. + +## Error tools + +`list_log_errors` and `list_trace_errors` each require `dataset`, `startTime`, +and `endTime`; `limit` defaults to 50 and is bounded to 1–500. They enforce query +permissions and require logs and traces telemetry, respectively. +The log tool applies exact, half-open time bounds (start inclusive, end exclusive) +to the dataset's configured time partition, or `p_timestamp` by default; `lastSeen` +uses the same timestamp column. Subminute ranges retain their full precision. + +`list_log_errors` classifies records by explicit severity, following the native +Errors page's policy. It checks every available `level`, `log_level`, `severity`, +`severity_text` and `severity_description` column using case-insensitive, trimmed +severity names, plus OTel `severity_number` codes 17–24. Pino/Bunyan codes 50 and +60 are accepted only in `level`. Invalid numeric text and fractional codes do +not fail the query or count as valid severities. A dataset without a supported +severity column produces an actionable tool error; message substrings, +`p_log_category` and the presence of exception metadata do not independently +classify records as errors. + +The log tool groups by service and error type, returns a sample message, count +and last-seen value, and coalesces supported aliases per row. Its `total` is the +number of returned groups, not an unbounded count of all matching groups. The +native Errors page additionally separates issues by message, so its grouping +and pagination are not identical to this summary tool. diff --git a/frontend/README.md b/frontend/README.md index 33fce92ff..677c5b101 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -74,7 +74,7 @@ This creates `build.zip` with a top-level `dist/` directory, including `dist/ind - Keystone and LLM settings: native capability pages with explicit limitations and deployment-dependent classic links. See [native screen scope](docs/prism-parity.md#native-missing-screen-scope-2026-10-12). - Component gallery at `/components`, plus isolated Storybook stories for UI, PromQL, charts, Traces, APM and ingestion setup. -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, while 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. +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. 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. diff --git a/frontend/docs/api-contracts.md b/frontend/docs/api-contracts.md index 0ae0cf18d..4de086ade 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`. Updates preserve unrelated server metadata. Only verified owners can update/delete a view; authorized views owned by others are labeled read only and can still be applied or copied with Save new view. Administrator access does not bypass this UI ownership constraint because the backend update path reassigns ownership. 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. 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. diff --git a/frontend/e2e/logs-depth-ownership.spec.ts b/frontend/e2e/logs-depth-ownership.spec.ts index 8660dfbf0..9537edbfb 100644 --- a/frontend/e2e/logs-depth-ownership.spec.ts +++ b/frontend/e2e/logs-depth-ownership.spec.ts @@ -71,7 +71,7 @@ test('saved views allow owner-only mutations, shared apply/copy, and refresh aft ); 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('shared'); + await dialog.getByLabel('Saved log view').selectOption({ label: 'Shared incidents (read only)' }); await expect(dialog.getByText(/This view is read only/)).toBeVisible(); await expect(dialog.getByRole('button', { name: 'Update selected' })).toBeDisabled(); await expect(dialog.getByRole('button', { name: 'Delete selected' })).toBeDisabled(); @@ -84,7 +84,7 @@ test('saved views allow owner-only mutations, shared apply/copy, and refresh aft await page.getByRole('button', { name: 'Saved views', exact: true }).click(); await expect(dialog.getByRole('option', { name: /Created in another tab/ })).toHaveCount(1); expect(reads).toBeGreaterThan(beforeReads); - await dialog.getByLabel('Saved log view').selectOption('owned'); + await dialog.getByLabel('Saved log view').selectOption({ label: 'My incidents' }); await expect(dialog.getByRole('button', { name: 'Update selected' })).toBeEnabled(); await expect(dialog.getByRole('button', { name: 'Delete selected' })).toBeEnabled(); await dialog.getByRole('button', { name: 'Update selected' }).click(); diff --git a/frontend/e2e/logs-depth-quality.spec.ts b/frontend/e2e/logs-depth-quality.spec.ts index db7bf17da..d18daf00d 100644 --- a/frontend/e2e/logs-depth-quality.spec.ts +++ b/frontend/e2e/logs-depth-quality.spec.ts @@ -126,7 +126,7 @@ for (const transition of ['AND', 'clear', 'apply', 'dataset'] as const) { if (transition === 'apply') { 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('clean'); + await dialog.getByLabel('Saved log view').selectOption({ label: 'Clean view (read only)' }); await dialog.getByRole('button', { name: 'Apply view', exact: true }).click(); } await expect(page.getByText(notice, { exact: true })).toHaveCount(0); diff --git a/frontend/e2e/logs-depth.spec.ts b/frontend/e2e/logs-depth.spec.ts index 68c6c6a3b..5723f2403 100644 --- a/frontend/e2e/logs-depth.spec.ts +++ b/frontend/e2e/logs-depth.spec.ts @@ -149,7 +149,9 @@ test('timezone share links and server saved views restore after reload', async ( expect(saved).toHaveLength(1); await page.goto(url(view())); await page.getByRole('button', { name: 'Saved views', exact: true }).click(); - await dialog.getByRole('combobox', { name: 'Saved log view' }).selectOption('depth-view'); + await dialog + .getByRole('combobox', { name: 'Saved log view' }) + .selectOption({ label: 'Depth view (read only)' }); await dialog.getByRole('button', { name: 'Apply view', exact: true }).click(); await expect(page.getByRole('combobox', { name: 'Timezone', exact: true })).toHaveValue( 'America/Chicago', diff --git a/frontend/e2e/logs-sql-view-conversion.spec.ts b/frontend/e2e/logs-sql-view-conversion.spec.ts new file mode 100644 index 000000000..f68982458 --- /dev/null +++ b/frontend/e2e/logs-sql-view-conversion.spec.ts @@ -0,0 +1,379 @@ +import { createHash } from 'node:crypto'; +import { expect, test, type Page } from '@playwright/test'; +import type { SavedLogView } from '../src/lib/types'; + +const owner = createHash('sha256').update('reader').digest('hex'); +const state = { + version: 1, + dataset: 'events', + range: { startTime: '2026-10-12T12:00:00Z', endTime: '2026-10-12T13:00:00Z' }, + search: 'saved search', + expression: { combinator: 'and', groups: [] }, + timezone: 'UTC', + limit: 100, + wrap: false, + showHistogram: true, +}; +const makeView = (filter_id: string, extra: Partial = {}): SavedLogView => ({ + filter_id, + filter_name: filter_id, + stream_name: 'events', + user_id: owner, + query: { filter_type: 'sql', filter_query: 'SELECT * FROM events', filter_builder: null }, + explorer_state: JSON.stringify(state), + custom: { pinned: true }, + ...extra, +}); +async function fixture( + page: Page, + views: SavedLogView[], + options: { + conversionError?: boolean; + conversionCleanupError?: boolean; + waitForConversion?: Promise; + waitForCreate?: Promise | (() => Promise); + } = {}, +) { + const mutations: { method: string; path: string; body: unknown }[] = []; + await page + .context() + .addCookies([{ name: 'user_id', value: 'reader', url: 'http://127.0.0.1:5173' }]); + await page.route('**/api/v1/**', async (route) => { + const request = route.request(), + path = new URL(request.url()).pathname; + if (path.endsWith('/users/reader')) + return route.fulfill({ json: { id: 'reader', username: 'Reader' } }); + if (path.endsWith('/logstream')) + return route.fulfill({ json: [{ name: 'events' }, { name: 'other' }] }); + if (path.endsWith('/schema')) + return route.fulfill({ json: { fields: [{ name: 'p_timestamp' }, { name: 'message' }] } }); + if (path.endsWith('/info')) return route.fulfill({ json: { timePartition: 'p_timestamp' } }); + if (path.endsWith('/filters') && request.method() === 'GET') + return route.fulfill({ json: views }); + if (path.includes('/filters')) { + mutations.push({ + method: request.method(), + path, + body: request.postData() ? request.postDataJSON() : null, + }); + if (path.endsWith('/convert-to-sql')) { + await options.waitForConversion; + if (options.conversionError) + return route.fulfill({ status: 503, body: 'Conversion unavailable. Try again.' }); + const view = views.find( + (view) => view.filter_id === path.split('/').at(-2) && view.user_id === owner, + )!; + view.stream_name = 'sql'; + if (options.conversionCleanupError) + return route.fulfill({ status: 503, body: 'Cleanup unavailable. Retry conversion.' }); + return route.fulfill({ json: view }); + } + if (request.method() === 'POST') { + await (typeof options.waitForCreate === 'function' + ? options.waitForCreate() + : options.waitForCreate); + const view = { + ...request.postDataJSON(), + filter_id: views.some((view) => view.filter_id === 'created') ? 'created-2' : 'created', + user_id: owner, + }; + views.push(view); + return route.fulfill({ json: view }); + } + if (request.method() === 'PUT') { + const id = path.split('/').at(-1), + index = views.findIndex((view) => view.filter_id === id && view.user_id === owner); + views[index] = { ...request.postDataJSON(), filter_id: id, user_id: owner }; + return route.fulfill({ json: views[index] }); + } + if (request.method() === 'DELETE') { + const id = path.split('/').at(-1); + views.splice( + views.findIndex((view) => view.filter_id === id && view.user_id === owner), + 1, + ); + return route.fulfill({ status: 200 }); + } + } + if (path.endsWith('/query')) return route.fulfill({ json: [] }); + return route.fulfill({ json: {} }); + }); + await page.goto( + `/logs/explore/events?${new URLSearchParams({ logView: JSON.stringify(state) })}`, + ); + await page.getByRole('button', { name: 'Saved views', exact: true }).click(); + return { dialog: page.getByRole('dialog', { name: 'Saved log views' }), mutations }; +} + +test('new views remain dataset scoped until converted, reopen from SQL storage, and retain SQL on update', async ({ + page, +}) => { + const views: SavedLogView[] = []; + const { dialog, mutations } = await fixture(page, views); + await dialog.getByLabel('Log view name').fill('My saved view'); + await dialog.getByRole('button', { name: 'Save new view', exact: true }).click(); + await expect(dialog.getByText('View saved on the server.', { exact: true })).toBeVisible(); + expect(views[0].stream_name).toBe('events'); + const legacy = structuredClone(views[0]); + await dialog.getByLabel('Log view name').fill('Unsaved rename'); + await expect( + dialog.getByText( + 'Convert this saved view to SQL while keeping its existing query and dataset.', + ), + ).toBeVisible(); + await dialog.getByRole('button', { name: 'Convert to SQL view' }).click(); + await expect( + dialog.getByText('View converted to SQL. Its query and dataset were retained.'), + ).toBeVisible(); + expect(views[0]).toEqual({ ...legacy, stream_name: 'sql' }); + expect(mutations.at(-1)).toEqual({ + method: 'POST', + path: '/api/v1/filters/created/convert-to-sql', + body: null, + }); + await expect(dialog.getByLabel('Log view name')).toHaveValue('My saved view'); + await expect(dialog.getByRole('button', { name: 'Convert to SQL view' })).toBeDisabled(); + await page.reload(); + await page.getByRole('button', { name: 'Saved views', exact: true }).click(); + await dialog.getByLabel('Saved log view').selectOption({ label: 'My saved view' }); + await dialog.getByRole('button', { name: 'Apply view', exact: true }).click(); + await expect(page.getByLabel('Search messages', { exact: true })).toHaveValue('saved search'); + await page.getByLabel('Search messages', { exact: true }).fill('updated search'); + await page.getByRole('button', { name: 'Saved views', exact: true }).click(); + await dialog.getByLabel('Saved log view').selectOption({ label: 'My saved view' }); + await dialog.getByLabel('Log view name').fill('Updated SQL view'); + await dialog.getByRole('button', { name: 'Update selected' }).click(); + await expect(dialog.getByText('View saved on the server.', { exact: true })).toBeVisible(); + expect(views[0]).toMatchObject({ + stream_name: 'sql', + filter_id: legacy.filter_id, + user_id: owner, + filter_name: 'Updated SQL view', + }); + expect(JSON.parse(views[0].explorer_state!)).toMatchObject({ + dataset: 'events', + search: 'updated search', + }); +}); + +test('SQL views stay within their explorer dataset and read-only or incompatible views cannot convert', async ({ + page, +}) => { + const { dialog, mutations } = await fixture(page, [ + makeView('owned'), + makeView('shared', { user_id: 'someone-else' }), + makeView('canonical', { stream_name: 'sql' }), + makeView('other-canonical', { + stream_name: 'sql', + explorer_state: JSON.stringify({ ...state, dataset: 'other' }), + }), + makeView('malformed', { stream_name: 'sql', explorer_state: '{' }), + makeView('invalid', { stream_name: 'sql', explorer_state: '{}' }), + makeView('classic', { explorer_state: null }), + ]); + const select = dialog.getByLabel('Saved log view'); + await expect(select.locator('option')).toHaveCount(5); + await expect(select.getByRole('option', { name: 'other-canonical', exact: true })).toHaveCount(0); + await expect(select.getByRole('option', { name: 'malformed', exact: true })).toHaveCount(0); + await expect(select.getByRole('option', { name: 'invalid', exact: true })).toHaveCount(0); + await select.selectOption({ label: 'shared (read only)' }); + await expect(dialog.getByRole('button', { name: 'Convert to SQL view' })).toBeDisabled(); + await expect(dialog.getByRole('button', { name: 'Apply view' })).toBeEnabled(); + await select.selectOption({ label: 'classic (classic or incompatible)' }); + await expect(dialog.getByRole('button', { name: 'Convert to SQL view' })).toBeDisabled(); + await expect(dialog.getByRole('button', { name: 'Apply view' })).toBeDisabled(); + await select.selectOption({ label: 'canonical' }); + await expect(dialog.getByRole('button', { name: 'Convert to SQL view' })).toBeDisabled(); + await expect(dialog.getByRole('button', { name: 'Apply view' })).toBeEnabled(); + await dialog.getByRole('button', { name: 'Close dialog' }).click(); + await page.getByLabel('Dataset', { exact: true }).selectOption('other'); + await page.getByRole('button', { name: 'Saved views', exact: true }).click(); + await expect(select.locator('option')).toHaveCount(2); + await expect(select.getByRole('option', { name: 'other-canonical', exact: true })).toHaveCount(1); + expect(mutations).toEqual([]); +}); + +test('conversion failure retains the legacy view and explains the server error', async ({ + page, +}) => { + const views = [makeView('owned')], + options = { conversionError: true }; + const { dialog } = await fixture(page, views, options); + await dialog.getByLabel('Saved log view').selectOption({ label: 'owned' }); + await dialog.getByRole('button', { name: 'Convert to SQL view' }).click(); + await expect(dialog.getByRole('alert')).toContainText('Conversion unavailable. Try again.'); + expect(views[0].stream_name).toBe('events'); + await expect(dialog.getByRole('button', { name: 'Convert to SQL view' })).toBeEnabled(); + options.conversionError = false; + await dialog.getByRole('button', { name: 'Convert to SQL view' }).click(); + await expect( + dialog.getByText('View converted to SQL. Its query and dataset were retained.'), + ).toBeVisible(); +}); + +test('conversion cleanup failure retains an enabled retry after the server writes SQL metadata', async ({ + page, +}) => { + const views = [makeView('owned')]; + const options = { conversionCleanupError: true }; + const { dialog, mutations } = await fixture(page, views, options); + await dialog.getByLabel('Saved log view').selectOption({ label: 'owned' }); + await dialog.getByRole('button', { name: 'Convert to SQL view' }).click(); + await expect(dialog.getByRole('alert')).toContainText('Cleanup unavailable. Retry conversion.'); + expect(views[0].stream_name).toBe('sql'); + await expect(dialog.getByRole('button', { name: 'Convert to SQL view' })).toBeEnabled(); + options.conversionCleanupError = false; + await dialog.getByRole('button', { name: 'Convert to SQL view' }).click(); + await expect( + dialog.getByText('View converted to SQL. Its query and dataset were retained.'), + ).toBeVisible(); + expect(mutations).toHaveLength(2); +}); + +test('late conversion responses do not change a reopened dialog selection or name', async ({ + page, +}) => { + let finish!: () => void; + const waitForConversion = new Promise((resolve) => { + finish = resolve; + }); + const { dialog, mutations } = await fixture( + page, + [makeView('converting'), makeView('other-selection')], + { waitForConversion }, + ); + await dialog.getByLabel('Saved log view').selectOption({ label: 'converting' }); + await dialog.getByRole('button', { name: 'Convert to SQL view' }).click(); + await expect.poll(() => mutations.length).toBe(1); + await dialog.getByRole('button', { name: 'Close dialog' }).click(); + await page.getByRole('button', { name: 'Saved views', exact: true }).click(); + await dialog.getByLabel('Saved log view').selectOption({ label: 'other-selection' }); + await dialog.getByLabel('Log view name').fill('Keep my draft'); + const response = page.waitForResponse('**/filters/converting/convert-to-sql'); + finish(); + await response; + await expect(dialog.getByLabel('Saved log view').locator('option:checked')).toHaveText( + 'other-selection', + ); + await expect(dialog.getByLabel('Log view name')).toHaveValue('Keep my draft'); + await expect( + dialog.getByText('View converted to SQL. Its query and dataset were retained.'), + ).toHaveCount(0); + await expect(dialog.getByRole('button', { name: 'Update selected' })).toBeEnabled(); +}); + +test('saved views with the same ID across owners remain independently selectable', async ({ + page, +}) => { + const views = [ + makeView('collision', { + filter_name: 'Shared view', + user_id: 'someone-else', + explorer_state: JSON.stringify({ ...state, search: 'shared search' }), + }), + makeView('collision', { filter_name: 'My owned view' }), + ]; + const { dialog, mutations } = await fixture(page, views); + const select = dialog.getByLabel('Saved log view'); + await select.selectOption({ label: 'Shared view (read only)' }); + await expect(dialog.getByRole('button', { name: 'Update selected' })).toBeDisabled(); + await dialog.getByRole('button', { name: 'Apply view', exact: true }).click(); + await expect(page.getByLabel('Search messages', { exact: true })).toHaveValue('shared search'); + await page.getByRole('button', { name: 'Saved views', exact: true }).click(); + await select.selectOption({ label: 'My owned view' }); + await expect(dialog.getByLabel('Log view name')).toHaveValue('My owned view'); + await expect(dialog.getByRole('button', { name: 'Update selected' })).toBeEnabled(); + await dialog.getByLabel('Log view name').fill('Updated owned view'); + await dialog.getByRole('button', { name: 'Update selected' }).click(); + await expect(dialog.getByText('View saved on the server.', { exact: true })).toBeVisible(); + expect(mutations.at(-1)).toMatchObject({ method: 'PUT', path: '/api/v1/filters/collision' }); + expect(views[0].filter_name).toBe('Shared view'); + expect(views[1].filter_name).toBe('Updated owned view'); + await dialog.getByRole('button', { name: 'Convert to SQL view' }).click(); + await expect( + dialog.getByText('View converted to SQL. Its query and dataset were retained.'), + ).toBeVisible(); + expect(views.map((view) => view.stream_name)).toEqual(['events', 'sql']); + await dialog.getByRole('button', { name: 'Delete selected' }).click(); + await expect(dialog.getByText('View deleted.', { exact: true })).toBeVisible(); + expect(views.map((view) => view.filter_name)).toEqual(['Shared view']); +}); + +test('a pending save stays locked across dialog close and refreshes the reopened list when complete', async ({ + page, +}) => { + let finish!: () => void; + const waitForCreate = new Promise((resolve) => { + finish = resolve; + }); + const views: SavedLogView[] = []; + const { dialog, mutations } = await fixture(page, views, { waitForCreate }); + await dialog.getByLabel('Log view name').fill('First saved view'); + await dialog.getByRole('button', { name: 'Save new view', exact: true }).click(); + await expect.poll(() => mutations.length).toBe(1); + await dialog.getByRole('button', { name: 'Close dialog' }).click(); + await page.getByRole('button', { name: 'Saved views', exact: true }).click(); + try { + await expect(dialog.getByRole('button', { name: 'Save new view', exact: true })).toBeDisabled(); + await dialog.getByLabel('Log view name').fill('Keep this draft'); + await expect(dialog.getByLabel('Saved log view').locator('option')).toHaveCount(1); + } finally { + finish(); + } + await expect(dialog.getByLabel('Saved log view').locator('option')).toHaveCount(2); + await expect(dialog.getByLabel('Log view name')).toHaveValue('Keep this draft'); + await expect(dialog.getByLabel('Saved log view')).toHaveValue(''); + await expect(dialog.getByText('View saved on the server.', { exact: true })).toHaveCount(0); + await expect(dialog.getByRole('button', { name: 'Save new view', exact: true })).toBeEnabled(); + expect(mutations).toHaveLength(1); + expect(views).toHaveLength(1); +}); + +test('an old dataset save cannot unlock or overwrite a pending save in the new dataset', async ({ + page, +}) => { + let finishOld!: () => void; + let finishNew!: () => void; + const oldSave = new Promise((resolve) => { + finishOld = resolve; + }); + const newSave = new Promise((resolve) => { + finishNew = resolve; + }); + let calls = 0; + const views: SavedLogView[] = []; + const { dialog, mutations } = await fixture(page, views, { + waitForCreate: () => (++calls === 1 ? oldSave : newSave), + }); + await dialog.getByLabel('Log view name').fill('Old dataset view'); + await dialog.getByRole('button', { name: 'Save new view', exact: true }).click(); + await expect.poll(() => mutations.length).toBe(1); + await dialog.getByRole('button', { name: 'Close dialog' }).click(); + await page.getByLabel('Dataset', { exact: true }).selectOption('other'); + await page.getByRole('button', { name: 'Saved views', exact: true }).click(); + await expect(dialog.getByLabel('Log view name')).toHaveValue(''); + await dialog.getByLabel('Log view name').fill('New dataset view'); + await dialog.getByRole('button', { name: 'Save new view', exact: true }).click(); + await expect.poll(() => mutations.length).toBe(2); + const oldResponse = page.waitForResponse( + (response) => + response.url().endsWith('/filters') && + response.request().method() === 'POST' && + response.request().postDataJSON().stream_name === 'events', + ); + finishOld(); + await oldResponse; + try { + await expect(dialog.getByRole('button', { name: 'Save new view', exact: true })).toBeDisabled(); + await expect(dialog.getByLabel('Log view name')).toHaveValue('New dataset view'); + await expect(dialog.getByText('View saved on the server.', { exact: true })).toHaveCount(0); + } finally { + finishNew(); + } + await expect(dialog.getByText('View saved on the server.', { exact: true })).toBeVisible(); + await expect(dialog.getByLabel('Saved log view').locator('option:checked')).toHaveText( + 'New dataset view', + ); + await expect(dialog.getByLabel('Saved log view').locator('option')).toHaveCount(2); + expect(views.map((view) => view.stream_name)).toEqual(['events', 'other']); +}); diff --git a/frontend/src/features/logs/SavedViews.test.ts b/frontend/src/features/logs/SavedViews.test.ts index 39863ee01..eeec3a63c 100644 --- a/frontend/src/features/logs/SavedViews.test.ts +++ b/frontend/src/features/logs/SavedViews.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { ownsSavedLogView, savedLogViewBody, savedLogViewState } from './SavedViews'; +import { + ownsSavedLogView, + savedLogViewBody, + savedLogViewState, + savedLogViewsForDataset, +} from './SavedViews'; import { canShowContext, contextField } from './LogContext'; import type { LogViewState } from './logViewState'; @@ -44,6 +49,45 @@ describe('server saved views', () => { expect(next.time_filter).toBeNull(); expect(savedLogViewState(next).wrap).toBe(true); }); + it('opens canonical SQL views using their explorer dataset and preserves conversion on update', () => { + const legacy = savedLogViewBody(state, 'Legacy'); + expect(legacy.stream_name).toBe('app'); + const canonical = { ...legacy, stream_name: 'sql', filter_id: 'same-id', user_id: 'owner' }; + expect(savedLogViewState(canonical)).toEqual(state); + const updated = savedLogViewBody({ ...state, search: 'new search' }, 'New', canonical); + expect(updated).toMatchObject({ stream_name: 'sql', filter_id: 'same-id', user_id: 'owner' }); + expect(savedLogViewState(updated).search).toBe('new search'); + expect(savedLogViewBody(state, 'Copy').stream_name).toBe('app'); + }); + it('lists native views by their decoded dataset while retaining dataset-scoped classic entries', () => { + const legacy = savedLogViewBody(state, 'Legacy'); + const canonical = { ...legacy, stream_name: 'sql', filter_name: 'Canonical' }; + const { explorer_state: _state, ...classic } = { ...legacy, filter_name: 'Classic' }; + const available = savedLogViewsForDataset( + [ + legacy, + canonical, + classic, + { + ...canonical, + filter_name: 'Other dataset', + explorer_state: JSON.stringify({ ...state, dataset: 'other' }), + }, + { ...canonical, filter_name: 'Broken JSON', explorer_state: '{' }, + { ...canonical, filter_name: 'Invalid state', explorer_state: '{}' }, + { ...classic, stream_name: 'sql' }, + { ...classic, stream_name: 'other' }, + ], + 'app', + ); + expect(available.map(({ view }) => view.filter_name)).toEqual([ + 'Legacy', + 'Canonical', + 'Classic', + ]); + expect(available.map(({ decoded }) => decoded?.dataset)).toEqual(['app', 'app', undefined]); + expect(savedLogViewsForDataset([canonical], 'sql')).toEqual([]); + }); it('rejects classic formats and mismatched dataset metadata', () => { const { explorer_state: _state, ...classic } = savedLogViewBody(state, 'Classic'); expect(() => savedLogViewState(classic)).toThrow('classic'); diff --git a/frontend/src/features/logs/SavedViews.tsx b/frontend/src/features/logs/SavedViews.tsx index 88500a7a0..e333f7c07 100644 --- a/frontend/src/features/logs/SavedViews.tsx +++ b/frontend/src/features/logs/SavedViews.tsx @@ -9,13 +9,30 @@ import type { SavedLogView } from '../../lib/types'; import { decodeLogView, encodeLogView, type LogViewState } from './logViewState'; export function savedLogViewState(view: SavedLogView): LogViewState { - if (typeof view.explorer_state !== 'string') + if (typeof view.explorer_state !== 'string' || view.query.filter_type !== 'sql') throw new Error('This saved view uses a classic format and cannot be opened in this explorer.'); const state = decodeLogView(view.explorer_state); - if (state.dataset !== view.stream_name) + if (view.stream_name !== 'sql' && state.dataset !== view.stream_name) throw new Error('This saved view has inconsistent dataset metadata.'); return state; } +export function savedLogViewsForDataset(views: SavedLogView[], dataset: string) { + return views.flatMap<{ view: SavedLogView; decoded: LogViewState | undefined }>((view) => { + try { + const decoded = savedLogViewState(view); + return decoded.dataset === dataset ? [{ view, decoded }] : []; + } catch { + // Classic dataset-scoped views remain visible, but canonical views need a + // valid explorer state to establish which dataset they belong to. + return view.stream_name !== 'sql' && view.stream_name === dataset + ? [{ view, decoded: undefined }] + : []; + } + }); +} +function savedLogViewKey(view: SavedLogView): string { + return view.filter_id ? JSON.stringify([view.user_id ?? null, view.filter_id]) : ''; +} /** Ownership is a display constraint; backend authorization remains authoritative. */ export function ownsSavedLogView( view: SavedLogView | undefined, @@ -36,7 +53,7 @@ export function savedLogViewBody( return { ...previous, filter_name, - stream_name: state.dataset, + stream_name: previous?.stream_name === 'sql' ? 'sql' : state.dataset, query: { ...(previous?.query ?? {}), filter_type: 'sql', @@ -80,8 +97,10 @@ export function SavedViews({ const [error, setError] = useState(''); const [notice, setNotice] = useState(''); const generation = useRef(0); + const pendingMutation = useRef<{ generation: number } | null>(null); useEffect(() => { generation.current += 1; + pendingMutation.current = null; setSelected(''); setName(''); setError(''); @@ -89,6 +108,7 @@ export function SavedViews({ setBusy(false); return () => { generation.current += 1; + pendingMutation.current = null; }; }, [client, state.dataset]); const views = useAsync( @@ -98,23 +118,20 @@ export function SavedViews({ ), ); const available = useMemo( - () => - (views.data ?? []) - .filter((view) => view.stream_name === state.dataset) - .map((view) => { - try { - return { view, decoded: savedLogViewState(view) }; - } catch { - return { view, decoded: undefined }; - } - }), + () => savedLogViewsForDataset(views.data ?? [], state.dataset), [views.data, state.dataset], ); - const selectedView = available.find((item) => item.view.filter_id === selected); + const selectedView = selected + ? available.find((item) => savedLogViewKey(item.view) === selected) + : undefined; const view = selectedView?.view; const owned = ownsSavedLogView(view, ownerHash.data, mode === 'demo'); - async function mutate(action: 'create' | 'update' | 'delete') { + async function mutate(action: 'create' | 'update' | 'delete' | 'convert') { + if (pendingMutation.current) return; const requestGeneration = generation.current; + const mutation = { generation: requestGeneration }; + let completed = false; + pendingMutation.current = mutation; setError(''); setNotice(''); setBusy(true); @@ -123,16 +140,23 @@ export function SavedViews({ const created = await client.createLogView( savedLogViewBody(state, name, undefined, timestampField), ); + completed = true; if (requestGeneration !== generation.current) return; - setSelected(created.filter_id ?? ''); + setSelected(savedLogViewKey(created)); setName(created.filter_name); } else { if (!view?.filter_id) throw new Error('Select a saved view first.'); if (!owned) throw new Error( - 'Only the owner can update or delete this saved view. Apply it or save a new copy instead.', + 'Only the owner can change this saved view. Apply it or save a new copy instead.', ); - if (action === 'update') { + if (action === 'convert') { + if (!selectedView?.decoded) throw new Error('This saved view cannot be converted here.'); + const converted = await client.convertLogViewToSql(view.filter_id); + completed = true; + if (requestGeneration !== generation.current) return; + setName(converted.filter_name); + } else if (action === 'update') { // Classic views remain readable in the list but are never overwritten. if (!selectedView?.decoded) throw new Error('This saved view cannot be overwritten here.'); @@ -140,22 +164,38 @@ export function SavedViews({ view.filter_id, savedLogViewBody(state, name, view, timestampField), ); - } else await client.deleteLogView(view.filter_id); + completed = true; + } else { + await client.deleteLogView(view.filter_id); + completed = true; + } if (requestGeneration !== generation.current) return; if (action === 'delete') { setSelected(''); setName(''); } } - views.reload(); - setNotice(action === 'delete' ? 'View deleted.' : 'View saved on the server.'); + setNotice( + action === 'delete' + ? 'View deleted.' + : action === 'convert' + ? 'View converted to SQL. Its query and dataset were retained.' + : 'View saved on the server.', + ); } catch (cause) { if (requestGeneration === generation.current) setError(cause instanceof Error ? cause.message : String(cause)); } finally { - if (requestGeneration === generation.current) setBusy(false); + if (pendingMutation.current === mutation) { + pendingMutation.current = null; + // A server write owns the lock across dialog generations. Confirmed + // writes refresh the list even when their draft and notice are stale. + if (completed) views.reload(); + setBusy(false); + } } } + const editingDisabled = busy && pendingMutation.current?.generation === generation.current; const compatible = !!selectedView?.decoded; return ( <> @@ -165,7 +205,12 @@ export function SavedViews({ { + if (!nextOpen) { + generation.current += 1; + } + setOpen(nextOpen); + }} title="Saved log views" description="Views are saved on your Parseable server for this dataset." > @@ -182,12 +227,13 @@ export function SavedViews({ setName(event.target.value)} /> {error &&

{error}

} {notice &&

{notice}

}
+