diff --git a/frontend/README.md b/frontend/README.md index e150ddedc..df555fd72 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -66,13 +66,18 @@ This creates `build.zip` with a top-level `dist/` directory, including `dist/ind - Datasets: search, create logs/metrics/traces with an optional custom partition, manage information and ingestion/storage statistics, inspect schema, explore, set/clear retention, configure/disable hot tier when the server enables it, and delete with typed name confirmation. Time partitions cannot be set at creation. Static schema upload, tags/labels, existing partition editing and index management are not supported. - Dashboards: server-backed lists and detail routes, search/tags/favourites/sort/pagination, ownership, full-document saves with conflict resolution, SQL and PromQL tiles, six variable types, URL time ranges and selections, classic import/export, and explicit migration of old browser-local dashboards. Demo dashboards use independent in-memory state per client. - Team: native users and one-time passwords, role and privilege management, default OIDC role configuration, provider group mappings and role provenance, and API key creation/copy/deletion. Search and 25-row pagination apply to every tab; authorization stays with the backend. +- Agents: schema-discovered GenAI telemetry datasets, bounded recent records, time ranges, local search, record inspection and trace handoff. +- Cluster: registered node reachability and resource metrics with independent request states; read-only. Errors: paginated OTLP error spans and trace handoff; classic grouped issue analytics remain unavailable. +- Workspace setup: `/onboarding` discovers accessible datasets and opens the matching explorer; `/new` explains server scope and provides connection/setup actions. Hosted workspace creation is not exposed by this server. +- Network access: edit the server’s outbound alert-target policy, including domain/CIDR rules, private IP access and TLS verification. +- 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. 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). No unusable paid-feature navigation or upgrade controls are exposed. +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. ## Components and selectors diff --git a/frontend/docs/api-contracts.md b/frontend/docs/api-contracts.md index 71c665e66..17a92c17f 100644 --- a/frontend/docs/api-contracts.md +++ b/frontend/docs/api-contracts.md @@ -206,3 +206,15 @@ APM uses typed SQL builders through the existing `/api/v1/query` client, with sc Add data creates datasets with `X-P-Log-Source` set to `json`, `otel-logs`, `otel-metrics` or `otel-traces`. Generated JSON commands use `/api/v1/ingest`; OTLP commands use `/v1/{signal}` with JSON encoding. Configuration contains environment-variable credential placeholders. Destinations with custom partitions are explicitly unsupported by guided setup: the required fields are explained, configuration/copying/verification remain unavailable, and selecting or creating an unpartitioned dataset restores setup. Verification reads dataset statistics, schema and info: the first check records a baseline, later lifetime-count increases report new stored events without claiming sender identity. Endpoint edits retain the baseline; destination changes reset it. See [observability validation](observability-validation.md) for bounds, caps, error behavior, live evidence and reference screenshots. + +## Native operations and setup screens + +Agents uses existing dataset list, schema, metadata and SQL requests. Discovery recognizes flattened `gen_ai.agent.name`, `gen_ai.agent.id`, `gen_ai.operation.name`, `gen_ai.request.model` and `gen_ai.tool.name`; direct dataset links defer the full schema scan until the selector is focused or rescanned. Discovery is limited to four concurrent schema requests and skips forbidden/missing datasets. Queries quote dataset/field identifiers, use the dataset time partition, and return at most 100 recent non-null GenAI records in captured UTC bounds. Search applies only to those loaded records. Run aggregation, evaluations and pricing are not inferred. Unknown demo dataset names cannot return synthetic records. + +Cluster reads `GET /api/v1/cluster/info` and `/api/v1/cluster/metrics` through the shared authenticated client. The first returns node identity, storage/staging paths, reachability and nullable status/error text. Metrics are independent: a denied metrics request does not hide node health. Nonfinite Rust metric values serialized as null display as unavailable per field. Missing cluster routes (404/405) show a deployment-mode explanation; 403 and 401 retain shared permission/session handling. No node deletion is exposed. + +Errors uses the existing trace-list contract with `options: "errorSpans"`, selectable UTC ranges, server sorting and pagination. The selected dataset must advertise `otel-traces`. Failed metadata inspection is distinguished from confirmed incompatibility and can be retried. Dataset/range changes reset pagination and cancel obsolete requests. Trace handoff preserves the captured query bounds. Classic grouped-issue and frequency endpoints are absent and are not called. + +Network access reads and replaces `GET/PUT /api/v1/alert-target-policy`. The complete object contains `allowPrivate`, `allowInvalidTls`, `allowedDomains`, `allowedCidrs`, `deniedDomains`, and `deniedCidrs`. All fields are required; the server is authoritative for authorization, CIDR validation and allow/deny overlaps. This policy governs outbound alert delivery, not ingress access to Parseable. The editor retains failed-save drafts and protects unsaved navigation. Demo writes are isolated within each client. The backend caches policy per node and loads it on startup; cross-node propagation is not guaranteed by a successful save. + +Onboarding uses the existing telemetry-aware dataset inventory and Add data routes. It does not call the side-effecting demo-data endpoint or provision a hosted workspace. Keystone and LLM management remain unsupported by this checkout: native deep links explain the limitation and offer deployment-dependent classic handoff only when classic is available. No provider keys are read or stored. diff --git a/frontend/docs/prism-parity.md b/frontend/docs/prism-parity.md index bd3f69e9e..bbdaa2b6a 100644 --- a/frontend/docs/prism-parity.md +++ b/frontend/docs/prism-parity.md @@ -121,3 +121,21 @@ This scope records implementation behavior, not a claim of full runtime or pixel Implemented on `feat/missing-observability-screens`: native trace list/detail and keyboard waterfall; SQL-derived span inventory, service insights and observed dependency map; JSON/OTLP/Collector ingestion configuration and measured arrival checks. Dataset navigation uses confirmed source metadata, including a Logs fallback for older JSON-backed datasets marked as traces. No paid APM endpoints or upgrade prompts are introduced. Original OSS APM supplied an enterprise call to action, so this is functional scope rather than pixel parity with a working original APM screen. The [observability validation record](observability-validation.md) documents original/new screenshots, contracts, capped results, fixture behavior and final test evidence. The native trace list also corrects identical retries with distinct ingestion timestamps so returned records agree with aggregate counts. + +## Native missing-screen scope (2026-10-12) + +The previously classic-only routes below now resolve to native pages. `classicUi.ts` marks them `limited`, distinguishing a usable native subset or capability explanation from full classic parity. The existing Traces, APM, Add data and telemetry-aware dataset routes retain their behavior. + +| Routes | Native behavior | Deliberate limits | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `/agents`, `/agents/:dataset` | Discover flattened GenAI fields through dataset schemas; query the latest 100 telemetry records in a selected time range; search loaded records; inspect full records; open compatible trace IDs | Individual events/spans, not classic run aggregation, evaluations, pricing or token-cost analytics. Demo uses explicit synthetic fixtures. | +| `/cluster` | Show registered distributed nodes, reachability, status, storage/staging locations and resource metrics from the existing cluster endpoints | Read-only. Standalone servers can report cluster administration unavailable. Node and metrics failures remain separate. | +| `/errors`, `/errors/:dataset` | Discover accessible OTLP trace datasets; list, sort and paginate error spans; open their trace waterfall | Classic grouped error issues and frequency endpoints are absent from this backend; arbitrary log issue aggregation is not fabricated. | +| `/onboarding` | Discover accessible datasets, link each to its confirmed explorer, and guide ingestion and team setup | Dataset presence is not presented as proof of event arrival. Existing Add data owns ingestion configuration and verification. | +| `/new` | Explain server workspace scope; open connection settings or native setup | No API in this checkout provisions a separate hosted workspace. | +| `/keystone`, `/keystone/:id` | Native availability page and classic investigation handoff where available | No supported investigation/conversation API in this checkout. No claim that an ID exists or is accessible. | +| `/settings/llm-key` | Native availability pages and deployment-dependent classic handoff | No provider-key management API. No credentials are read, stored or submitted. | + +Network access (`/settings/network-access`) edits the real outbound alert-target policy via `GET` and `PUT /api/v1/alert-target-policy`. It is distinct from ingress access control. The full policy includes private-IP and invalid-TLS switches plus allowed/denied domains and CIDRs. Backend authorization and validation remain authoritative; failed saves retain the draft. The policy cache is per server node and loaded at startup; cross-node propagation is a backend limitation, not asserted by the UI. + +Contracts were checked against `src/handlers/http/modal/query_server.rs`, `src/handlers/http/modal/server.rs`, `src/handlers/http/cluster/mod.rs`, `src/handlers/http/traces.rs`, and the classic route/asset evidence. Keystone metastore methods and LLM tool-execution endpoints do not constitute management HTTP contracts. Shared client handling preserves cancellation, server errors and session recovery for the added cluster reads. diff --git a/frontend/docs/validation-review.md b/frontend/docs/validation-review.md index af5afdd31..35176c918 100644 --- a/frontend/docs/validation-review.md +++ b/frontend/docs/validation-review.md @@ -639,3 +639,19 @@ The browser tests use deterministic transports. They check generated SQL and limited-result tooltip values, chart rendering, save/reload, explicit legacy bindings, and opaque metadata preservation. They do not establish live backend ingestion or cross-browser coverage. + +## Native missing screens (2026-10-12) + +[Shared Mayfly completion record](https://mayfly-celld.tail835e0d.ts.net/w/46ncdGypknzAj7dnRkJ8bQ?page=d665474a-2bc8-445f-a8e7-4949ea6e32e3#J63UryiLZKvb1G5vnY7RVQhXPDhXy-GpIhjXiCB_TzM) documents implementation commit `83503697`, scope limits, review findings and validation caveats, and is linked from the earlier Traces/APM/ingestion wiki page. At the initial wiki publication, this implementation was committed locally and had not been pushed or deployed. + +Implemented on `feat/native-missing-screens` from `origin/main` (`81d1e0c9`), with five Sol 6.1 agents covering implementation, independent regressions and Storybook QA, plus four Opus 5.5 review rounds. See [scope and deliberate limits](prism-parity.md#native-missing-screen-scope-2026-10-12). Network access is the real outbound alert-target policy editor; Keystone/LLM management and hosted workspace provisioning remain explicitly unavailable. + +Validation of the final implementation: + +- Production build and TypeScript pass. All **2,166 unit tests pass**, with one pre-existing skipped test. +- All **30 focused browser tests pass** in one serial run against a fresh production bundle: Agents, operations, onboarding, settings, outbound policy, stale dataset/session behavior and `/next` navigation. Server calls are mocked; these results do not claim a live distributed-cluster or policy-propagation check. +- **22 production mount/stale-asset tests pass**, including hard loads of every added screen at both `/` and `/next`. The **26 classic-link/onboarding tests** also passed during integration. +- All **49 native Storybook checks pass** across a serial run and one immediate isolated retry of a transient empty iframe. Coverage includes loading/empty/permission/error states, keyboard inspection and focus restoration, time-range request bodies, policy save/discard/validation, and narrow dark-theme accessibility. +- Opus findings addressed include network API scope, full-policy payloads, domain rules that could never match the backend, authorization-header allowlist help, permission-safe discard, unique field descriptions, missing metric values, failed dataset verification, unnecessary discovery requests, duplicate refresh requests and native URL preservation. An independent regression caught and fixed synthetic demo data appearing under unknown dataset names. + +Broader suites are **not clean** on this host. The 493-test development-server run was stopped after repeated `net::ERR_INSUFFICIENT_RESOURCES`, blank lazy pages and Chromium failures: 238 passed, 45 failed, 3 interrupted, 1 skipped and 206 not run. The full 221-test Storybook run had 162 passes and 59 failures (38 existing screenshot assertions and 21 runtime/resource failures). A representative grouped-bars screenshot was rebuilt and tested from an isolated `origin/main` archive: it fails by the same 1,093 pixels, and its actual PNG is byte-identical to this branch (`c21e1b584291ac5ee37e020c75832fb14dbec1566610613dc06c792f9249b06d`). No baselines were updated. The obsolete sidebar expectation was updated for Agents/Errors and passed on retry; the reported APM 24-hour test also passed independently. The final focused run used a production bundle to avoid the development server's large module graph. diff --git a/frontend/e2e-prod/mounts.spec.ts b/frontend/e2e-prod/mounts.spec.ts index f8de637bd..b8057312c 100644 --- a/frontend/e2e-prod/mounts.spec.ts +++ b/frontend/e2e-prod/mounts.spec.ts @@ -43,6 +43,21 @@ for (const mount of ['', '/next']) { await expect(page).toHaveURL(`${mount}/add-data`); await expect(page.getByRole('heading', { name: 'Add data', exact: true })).toBeVisible(); + for (const [path, title] of [ + ['/agents/agent_telemetry', 'Agents'], + ['/cluster', 'Cluster'], + ['/errors/demo_traces', 'Errors'], + ['/onboarding', 'Get started'], + ['/new', 'Set up a workspace'], + ['/keystone/example', 'Keystone'], + ['/settings/llm-key', 'LLM settings'], + ['/settings/network-access', 'Network access'], + ]) { + await page.goto(`${mount}${path}`); + await expect(page.getByRole('heading', { name: title, exact: true })).toBeVisible(); + } + await expect(page.getByLabel('Allowed domains', { exact: true })).toBeVisible(); + expect(failed).toEqual([]); expect(paths.filter((path) => !path.startsWith(`${mount}/`))).toEqual([]); // Lazy chunks import the entry module that is already running. diff --git a/frontend/e2e/agents.spec.ts b/frontend/e2e/agents.spec.ts new file mode 100644 index 000000000..fa4ce4b6e --- /dev/null +++ b/frontend/e2e/agents.spec.ts @@ -0,0 +1,106 @@ +import { expect, test, type Page } from '@playwright/test'; +import { agentDemoFields, agentDemoRows } from '../src/lib/agents'; +async function mockAgents(page: Page, status = 200, empty = false) { + const captured: Array<{ query: string; startTime: string; endTime: string }> = []; + await page.route('**/api/**', async (route) => { + const path = new URL(route.request().url()).pathname; + if (path === '/api/v1/logstream') return route.fulfill({ json: [{ name: 'agent_telemetry' }] }); + if (path.endsWith('/schema')) + return route.fulfill({ json: { fields: agentDemoFields.map((name) => ({ name })) } }); + if (path.endsWith('/info')) + return route.fulfill({ + json: { telemetryType: 'traces', logSource: [{ log_source_format: 'otel-traces' }] }, + }); + if (path === '/api/v1/about') return route.fulfill({ json: { capabilities: {} } }); + if (path === '/api/v1/query') { + captured.push(route.request().postDataJSON()); + return status === 200 + ? route.fulfill({ json: empty ? [] : agentDemoRows() }) + : route.fulfill({ status, body: 'Access denied' }); + } + return route.fulfill({ status: 404, body: 'No fixture' }); + }); + return captured; +} +test('discovers agent datasets, filters loaded records and exposes raw fields', async ({ + page, +}) => { + const captured = await mockAgents(page); + await page.goto('/agents'); + await expect(page.getByRole('heading', { name: 'Choose an agent dataset' })).toBeVisible(); + await page.getByLabel('Agents dataset').selectOption('agent_telemetry'); + await expect(page.getByRole('table')).toBeVisible(); + expect(captured[0].query).toContain('LIMIT 100'); + expect(Date.parse(captured[0].endTime) - Date.parse(captured[0].startTime)).toBe(3600000); + await page.getByLabel('Search agent telemetry').fill('Research'); + await expect(page.getByRole('row')).toHaveCount(2); + await page.getByRole('button', { name: 'Inspect telemetry record 1' }).click(); + await expect(page.getByRole('dialog')).toContainText('gen_ai.usage.input_tokens'); + await expect(page.getByRole('link', { name: 'Open trace' })).toHaveAttribute( + 'href', + /traceId=00000000000000000000000000000002/, + ); +}); +test('permission failure renders recovery without stale telemetry', async ({ page }) => { + await mockAgents(page, 403); + await page.goto('/agents/agent_telemetry'); + await expect(page.getByRole('heading', { name: 'Permission denied' })).toBeVisible(); + await expect(page.getByRole('table')).toHaveCount(0); +}); +test('empty telemetry on mobile stays within viewport', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await mockAgents(page, 200, true); + await page.goto('/agents/agent_telemetry'); + await expect( + page.getByRole('heading', { name: 'No agent telemetry in this range' }), + ).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe( + true, + ); +}); + +test('direct agent links defer schema discovery until the dataset selector is focused', async ({ + page, +}) => { + const schemaRequests: string[] = []; + page.on('request', (request) => { + if (new URL(request.url()).pathname.endsWith('/schema')) schemaRequests.push(request.url()); + }); + await mockAgents(page); + await page.route('**/api/v1/logstream', (route) => + route.fulfill({ json: [{ name: 'agent_telemetry' }, { name: 'another_dataset' }] }), + ); + await page.goto('/agents/agent_telemetry'); + await expect(page.getByRole('table')).toBeVisible(); + expect(schemaRequests.filter((url) => url.includes('another_dataset'))).toHaveLength(0); + await page.getByLabel('Agents dataset').focus(); + await expect + .poll(() => schemaRequests.filter((url) => url.includes('another_dataset')).length) + .toBe(1); +}); + +test('direct nonagent dataset link rejects its schema before submitting a query', async ({ + page, +}) => { + const captured = await mockAgents(page); + await page.route('**/api/v1/logstream/*/schema', (route) => + route.fulfill({ json: { fields: [{ name: 'p_timestamp' }, { name: 'message' }] } }), + ); + await page.goto('/agents/application_logs'); + await expect(page.getByRole('alert')).toContainText('no recognized GenAI agent'); + expect(captured).toHaveLength(0); + await expect(page.getByRole('table')).toHaveCount(0); +}); + +test('refreshing a preset submits one query with newly captured bounds', async ({ page }) => { + const captured = await mockAgents(page); + await page.goto('/agents/agent_telemetry?range=24h'); + await expect(page.getByRole('table')).toBeVisible(); + expect(captured).toHaveLength(1); + const firstEnd = captured[0].endTime; + await page.getByRole('button', { name: 'Refresh', exact: true }).click(); + await expect(page.getByRole('table')).toBeVisible(); + await expect.poll(() => captured.length).toBe(2); + expect(captured[1].endTime).not.toBe(firstEnd); + expect(Date.parse(captured[1].endTime) - Date.parse(captured[1].startTime)).toBe(86400000); +}); diff --git a/frontend/e2e/alerts.spec.ts b/frontend/e2e/alerts.spec.ts index c7de01253..9fbed8c34 100644 --- a/frontend/e2e/alerts.spec.ts +++ b/frontend/e2e/alerts.spec.ts @@ -185,6 +185,8 @@ test('Alerts is reachable with telemetry explorers in Observe, with no Classic U 'Metrics', 'Traces', 'APM', + 'Agents', + 'Errors', 'Alerts', ]); await expect(page.locator('.sidebar-group-label').filter({ hasText: 'Classic UI' })).toHaveCount( diff --git a/frontend/e2e/classic-links.spec.ts b/frontend/e2e/classic-links.spec.ts index 42ed56a36..6c0562781 100644 --- a/frontend/e2e/classic-links.spec.ts +++ b/frontend/e2e/classic-links.spec.ts @@ -163,7 +163,9 @@ test('classic paths that moved open their page here', async ({ page }) => { await expect(page.getByRole('button', { name: 'Sign out' })).toBeVisible(); } await page.goto('/keystone/example'); - await expect(page.getByText('This page is only available in the classic UI.')).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'Keystone analysis is unavailable in this native UI' }), + ).toBeVisible(); await expect(page.getByRole('button', { name: 'Sign out' })).toBeVisible(); }); diff --git a/frontend/e2e/native-regressions.spec.ts b/frontend/e2e/native-regressions.spec.ts new file mode 100644 index 000000000..40e99ec09 --- /dev/null +++ b/frontend/e2e/native-regressions.spec.ts @@ -0,0 +1,127 @@ +import { expect, test } from '@playwright/test'; +import { agentDemoFields } from '../src/lib/agents'; +import { demoClusterNodes } from '../src/lib/clusterDemo'; + +test('agent dataset changes clear inspected records while the new query is pending', async ({ + page, +}) => { + let release!: () => void; + const pending = new Promise((resolve) => { + release = resolve; + }); + await page.route('**/api/**', async (route) => { + const path = new URL(route.request().url()).pathname; + if (path === '/api/v1/logstream') + return route.fulfill({ json: [{ name: 'first' }, { name: 'second' }] }); + if (path.endsWith('/schema')) + return route.fulfill({ json: { fields: agentDemoFields.map((name) => ({ name })) } }); + if (path.endsWith('/info')) + return route.fulfill({ json: { logSource: [{ log_source_format: 'otel-traces' }] } }); + if (path === '/api/v1/query') { + const body = route.request().postDataJSON(); + if (body.query.includes('"second"')) await pending; + return route.fulfill({ + json: [ + { + p_timestamp: new Date().toISOString(), + 'gen_ai.agent.name': body.query.includes('"second"') ? 'Second agent' : 'First agent', + }, + ], + }); + } + return route.fulfill({ json: {} }); + }); + await page.goto('/agents/first'); + await expect(page.getByRole('cell', { name: 'First agent' })).toBeVisible(); + await page.getByRole('button', { name: 'Inspect telemetry record 1' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await page.keyboard.press('Escape'); + await page.getByLabel('Agents dataset').focus(); + await page.getByLabel('Agents dataset').selectOption('second'); + await expect(page.getByRole('cell', { name: 'First agent' })).toHaveCount(0); + await expect(page.getByRole('table')).toHaveCount(0); + await expect(page.getByRole('dialog')).toHaveCount(0); + release(); + await expect(page.getByRole('cell', { name: 'Second agent' })).toBeVisible(); +}); + +test('cluster refresh removes obsolete nodes on permission failure', async ({ page }) => { + let revoked = false; + await page.route('**/api/**', async (route) => { + const path = new URL(route.request().url()).pathname; + if (path === '/api/v1/cluster/info') + return revoked + ? route.fulfill({ status: 403, body: 'Forbidden' }) + : route.fulfill({ json: demoClusterNodes }); + if (path === '/api/v1/cluster/metrics' || path === '/api/v1/logstream') + return route.fulfill({ json: [] }); + return route.fulfill({ json: {} }); + }); + await page.goto('/cluster'); + await expect(page.getByText('https://ingestor-1.demo', { exact: true })).toBeVisible(); + revoked = true; + await page.getByRole('button', { name: 'Refresh', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Permission denied' })).toBeVisible(); + await expect(page.getByText('https://ingestor-1.demo', { exact: true })).toHaveCount(0); +}); + +test('expired agent session preserves the dataset and time range in the sign-in return path', async ({ + page, +}) => { + await page.route('**/api/**', async (route) => { + const path = new URL(route.request().url()).pathname; + if (path.endsWith('/schema')) return route.fulfill({ status: 401, body: 'Unauthorized' }); + if (path === '/api/v1/logstream') return route.fulfill({ json: [{ name: 'private_agents' }] }); + return route.fulfill({ json: {} }); + }); + const original = '/agents/private_agents?range=24h'; + await page.goto(original); + await expect(page).toHaveURL(/\/login\?/); + expect(new URL(page.url()).searchParams.get('next')).toBe(original); + await expect(page.getByRole('table')).toHaveCount(0); +}); + +test('demo agents do not attribute synthetic telemetry to an unknown dataset', async ({ page }) => { + await page.addInitScript(() => sessionStorage.setItem('parseable-mode', 'demo')); + await page.goto('/agents/does_not_exist'); + await expect(page.getByRole('heading', { name: 'Agents', exact: true })).toBeVisible(); + await expect(page.getByRole('cell', { name: 'Support assistant', exact: true })).toHaveCount(0); + await expect(page.getByRole('table')).toHaveCount(0); +}); + +test('native setup and settings links remain inside a /next mount', async ({ page }) => { + await page.route('**/next/**', async (route) => { + if (route.request().resourceType() !== 'document') return route.continue(); + const response = await route.fetch(); + await route.fulfill({ + response, + body: (await response.text()).replace('', ''), + }); + }); + await page.route('**/api/**', (route) => route.fulfill({ json: [] })); + await page.goto('/next/new'); + await expect( + page.getByRole('heading', { name: 'Set up a workspace', exact: true }), + ).toBeVisible(); + await expect(page.getByRole('link', { name: 'Set up this server', exact: true })).toHaveAttribute( + 'href', + '/next/onboarding', + ); + await expect( + page.getByRole('link', { name: 'open workspace creation in classic UI', exact: true }), + ).toHaveCount(0); + await page.getByRole('link', { name: 'Set up this server', exact: true }).click(); + await expect(page).toHaveURL(/\/next\/onboarding$/); + await expect(page.getByRole('link', { name: 'Set up ingestion', exact: true })).toHaveAttribute( + 'href', + '/next/add-data', + ); + await page.goto('/next/keystone/investigation'); + await expect(page.getByRole('link', { name: 'LLM settings', exact: true })).toHaveAttribute( + 'href', + '/next/settings/llm-key', + ); + await expect( + page.getByRole('link', { name: 'Open investigation in classic UI', exact: true }), + ).toHaveAttribute('href', '/keystone/investigation'); +}); diff --git a/frontend/e2e/network-access.spec.ts b/frontend/e2e/network-access.spec.ts new file mode 100644 index 000000000..b4ff312ac --- /dev/null +++ b/frontend/e2e/network-access.spec.ts @@ -0,0 +1,164 @@ +import { test, expect } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; +const policy = { + allowPrivate: false, + allowedDomains: ['alerts.example'], + allowedCidrs: [], + deniedDomains: [], + deniedCidrs: [], + allowInvalidTls: false, +}; +async function live(page: import('@playwright/test').Page) { + await page.route('**/api/**', (route) => route.fulfill({ json: [] })); +} +test('loads and saves the full outbound policy, retains server validation drafts, discards edits', async ({ + page, +}) => { + await live(page); + let puts = 0; + let body: unknown; + await page.route('**/api/v1/alert-target-policy', async (route) => { + if (route.request().method() === 'GET') return route.fulfill({ json: policy }); + puts++; + body = route.request().postDataJSON(); + if (puts === 1) + return route.fulfill({ + status: 400, + json: { + error: 'invalid_policy', + message: 'allow and deny domain lists conflict', + hint: 'Remove overlapping entries', + }, + }); + await new Promise((resolve) => setTimeout(resolve, 150)); + return route.fulfill({ json: body }); + }); + await page.goto('/settings/network-access'); + await expect(page.getByLabel('Allowed domains')).toHaveValue('alerts.example'); + await page.getByLabel('Denied domains').fill('alerts.example'); + await page.getByRole('button', { name: 'Save policy', exact: true }).click(); + await expect(page.getByRole('alert')).toContainText('allow and deny domain lists conflict'); + await expect(page.getByLabel('Denied domains')).toHaveValue('alerts.example'); + await page.getByLabel('Denied domains').fill('blocked.example\n\n other.example '); + await page.getByLabel('Allow private destinations', { exact: true }).check(); + await page.getByLabel('Allowed CIDRs').fill('10.0.0.0/8'); + await page.getByRole('button', { name: 'Save policy', exact: true }).dblclick(); + await expect(page.getByRole('status')).toContainText('Network policy saved.'); + expect(puts).toBe(2); + expect(body).toEqual({ + ...policy, + allowPrivate: true, + allowedCidrs: ['10.0.0.0/8'], + deniedDomains: ['blocked.example', 'other.example'], + }); + await page.getByLabel('Allowed domains').fill('changed.example'); + await page.getByRole('button', { name: 'Discard changes' }).click(); + await expect(page.getByLabel('Allowed domains')).toHaveValue('alerts.example'); + expect( + (await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa']).analyze()) + .violations, + ).toEqual([]); +}); +test('load retry and authoritative permission denial', async ({ page }) => { + await live(page); + let denied = false; + await page.route('**/api/v1/alert-target-policy', (route) => + route.fulfill( + denied ? { status: 403, body: 'Forbidden' } : { status: 503, body: 'Unavailable' }, + ), + ); + await page.goto('/settings/network-access'); + await expect(page.getByRole('alert')).toContainText('Could not load'); + denied = true; + await page.getByRole('button', { name: 'Retry' }).click(); + await expect(page.getByRole('alert')).toContainText('Administrator permission required'); + await expect(page.getByRole('button', { name: 'Save policy' })).toHaveCount(0); +}); +test('demo changes stay local and unsaved navigation can be cancelled', async ({ page }) => { + const writes: string[] = []; + page.on('request', (request) => { + if (request.url().includes('alert-target-policy')) writes.push(request.url()); + }); + await page.addInitScript(() => sessionStorage.setItem('parseable-mode', 'demo')); + await page.goto('/settings/network-access'); + await expect(page.getByText(/changes are stored locally/)).toBeVisible(); + await page.getByLabel('Allowed domains').fill('local.example'); + page.once('dialog', (dialog) => dialog.dismiss()); + await page + .getByRole('navigation', { name: 'Related pages' }) + .getByRole('link', { name: 'Keystone' }) + .click(); + await expect(page).toHaveURL(/network-access/); + await page.getByRole('button', { name: 'Save policy', exact: true }).click(); + await expect( + page.getByRole('status').filter({ hasText: 'Local sample policy saved.' }), + ).toBeVisible(); + await page + .getByRole('navigation', { name: 'Related pages' }) + .getByRole('link', { name: 'Keystone' }) + .click(); + await page + .getByRole('navigation', { name: 'Related pages' }) + .getByRole('link', { name: 'Network access' }) + .click(); + await expect(page.getByLabel('Allowed domains')).toHaveValue('local.example'); + expect(writes).toEqual([]); +}); + +test('invalid literal domain syntax prevents PUT and focuses accessible field errors', async ({ + page, +}) => { + await live(page); + let puts = 0; + await page.route('**/api/v1/alert-target-policy', (route) => { + if (route.request().method() === 'PUT') puts++; + return route.fulfill({ json: policy }); + }); + await page.goto('/settings/network-access'); + const domains = page.getByLabel('Allowed domains', { exact: true }); + for (const invalid of [ + '*.evil.example', + 'https://host.example', + 'host.example:443/path', + 'host example', + 'host\\example', + ]) { + await domains.fill(invalid); + await page.getByRole('button', { name: 'Save policy', exact: true }).click(); + await expect(domains).toBeFocused(); + await expect(domains).toHaveAttribute('aria-invalid', 'true'); + await expect(page.getByRole('alert')).toContainText('Enter a plain hostname'); + } + expect(puts).toBe(0); + await domains.fill('valid.example'); + await expect(domains).toHaveAttribute('aria-invalid', 'false'); +}); +test('revoked save permission retains draft and allows discard without re-enabling writes', async ({ + page, +}) => { + await live(page); + await page.route('**/api/v1/alert-target-policy', (route) => + route.fulfill( + route.request().method() === 'GET' ? { json: policy } : { status: 403, body: 'Forbidden' }, + ), + ); + await page.goto('/settings/network-access'); + const domains = page.getByLabel('Allowed domains', { exact: true }); + await domains.fill('changed.example'); + await page.getByRole('button', { name: 'Save policy', exact: true }).click(); + await expect(page.getByRole('alert')).toContainText('Administrator permission required'); + await expect(domains).toHaveValue('changed.example'); + await expect(domains).toBeDisabled(); + await page.getByRole('button', { name: 'Discard changes' }).click(); + await expect(domains).toHaveValue('alerts.example'); + await expect(page.getByRole('button', { name: 'Save policy', exact: true })).toBeDisabled(); + await expect(page.getByRole('alert')).toContainText('Administrator permission required'); + page.once('dialog', () => { + throw new Error('Clean discarded draft must not block navigation'); + }); + await page + .getByRole('navigation', { name: 'Related pages' }) + .getByRole('link', { name: 'Keystone' }) + .click(); + await expect(page).toHaveURL(/keystone/); +}); diff --git a/frontend/e2e/onboarding.spec.ts b/frontend/e2e/onboarding.spec.ts new file mode 100644 index 000000000..e02a9dd84 --- /dev/null +++ b/frontend/e2e/onboarding.spec.ts @@ -0,0 +1,85 @@ +import { test, expect, type Page } from '@playwright/test'; + +async function mockServer(page: Page, options: { empty?: boolean; failure?: number } = {}) { + const mutations: string[] = []; + await page.route('**/api/**', async (route) => { + const request = route.request(); + const path = new URL(request.url()).pathname; + if (request.method() !== 'GET') mutations.push(path); + if (path === '/api/v1/logstream') + return route.fulfill( + options.failure + ? { status: options.failure, body: 'Dataset inventory unavailable' } + : { + json: options.empty + ? [] + : [{ name: 'logs' }, { name: 'metrics' }, { name: 'traces' }], + }, + ); + const info = path.match(/\/logstream\/(logs|metrics|traces)\/info$/); + if (info) + return route.fulfill({ + json: { + telemetryType: info[1], + logSource: [{ log_source_format: info[1] === 'logs' ? 'json' : `otel-${info[1]}` }], + }, + }); + return route.fulfill({ status: 404, body: 'Not found' }); + }); + return mutations; +} + +test('setup discovers telemetry and links to the matching explorers without mutations', async ({ + page, +}) => { + const mutations = await mockServer(page); + await page.goto('/onboarding'); + await expect(page.getByRole('heading', { name: 'Get started', exact: true })).toBeVisible(); + await expect(page.getByText('3 accessible datasets.')).toBeVisible(); + for (const kind of ['logs', 'metrics', 'traces']) { + await expect( + page.locator('.setup-datasets').getByRole('link', { name: `${kind} ${kind}` }), + ).toHaveAttribute('href', `/${kind}/explore/${kind}`); + } + await page.getByRole('link', { name: 'Set up ingestion' }).click(); + await expect(page).toHaveURL('/add-data'); + expect(mutations).toEqual([]); +}); + +test('empty setup provides ingestion guidance, and failed discovery retries', async ({ page }) => { + await mockServer(page, { empty: true }); + await page.goto('/onboarding'); + await expect(page.getByText('No accessible datasets yet.')).toBeVisible(); + await page.unrouteAll(); + await mockServer(page, { failure: 503 }); + await page.getByRole('button', { name: 'Refresh setup' }).click(); + await expect(page.getByRole('alert')).toContainText('Dataset inventory unavailable'); + await expect(page.getByRole('heading', { name: '1. Connect your data' })).toHaveCount(0); + await page.unrouteAll(); + await mockServer(page); + await page.getByRole('button', { name: 'Try again' }).click(); + await expect(page.getByText('3 accessible datasets.')).toBeVisible(); +}); + +test('new workspace explains server scope and opens connection settings', async ({ page }) => { + const mutations = await mockServer(page); + await page.goto('/new'); + await expect(page.getByRole('heading', { name: 'Set up a workspace' })).toBeVisible(); + await expect( + page.getByText('Creating a separate hosted workspace', { exact: false }), + ).toBeVisible(); + await expect(page.getByRole('link', { name: /workspace creation in classic/ })).toHaveCount(0); + await page.getByRole('main').getByRole('button', { name: 'Connection settings' }).click(); + await expect(page.getByRole('dialog', { name: 'Connect to Parseable' })).toBeVisible(); + expect(mutations).toEqual([]); +}); + +test('onboarding permission errors preserve the route without presenting demo setup', async ({ + page, +}) => { + await mockServer(page, { failure: 403 }); + await page.goto('/onboarding'); + await expect(page.getByRole('alert')).toContainText('Permission denied'); + await expect(page.getByRole('button', { name: 'Explore demo data' })).toHaveCount(0); + await expect(page).toHaveURL('/onboarding'); +}); diff --git a/frontend/e2e/operations.spec.ts b/frontend/e2e/operations.spec.ts new file mode 100644 index 000000000..7a7ab4f64 --- /dev/null +++ b/frontend/e2e/operations.spec.ts @@ -0,0 +1,158 @@ +import { expect, test } from '@playwright/test'; +import { demoClusterNodes } from '../src/lib/clusterDemo'; +import { storyTraceList } from '../src/features/traces/fixtures'; +test('cluster shows verified node data and preserves metrics permission failures', async ({ + page, +}) => { + await page.route('**/api/**', async (route) => { + const path = new URL(route.request().url()).pathname; + if (path === '/api/v1/cluster/info') return route.fulfill({ json: demoClusterNodes }); + if (path === '/api/v1/cluster/metrics') + return route.fulfill({ status: 403, body: 'Cluster metrics access denied' }); + if (path === '/api/v1/logstream') return route.fulfill({ json: [] }); + return route.fulfill({ json: {} }); + }); + await page.goto('/cluster'); + await expect(page.getByRole('heading', { name: 'Cluster', exact: true })).toBeVisible(); + await expect(page.getByText('https://ingestor-1.demo', { exact: true })).toBeVisible(); + await expect(page.getByText('Permission denied', { exact: true })).toBeVisible(); + await expect(page.getByText('Reachable', { exact: true })).toHaveCount(2); +}); +test('errors uses supported OTLP error span endpoint and opens trace detail', async ({ page }) => { + const requests: unknown[] = []; + await page.route('**/api/**', async (route) => { + const path = new URL(route.request().url()).pathname; + if (path === '/api/v1/logstream') return route.fulfill({ json: [{ name: 'orders_traces' }] }); + if (path.endsWith('/info')) + return route.fulfill({ + json: { telemetryType: 'traces', logSource: [{ log_source_format: 'otel-traces' }] }, + }); + if (path.endsWith('/services/traces/list')) { + requests.push(route.request().postDataJSON()); + return route.fulfill({ + json: { + count: storyTraceList.length, + offset: 0, + limit: 25, + records: storyTraceList.slice(0, 25), + }, + }); + } + return route.fulfill({ json: {} }); + }); + await page.goto('/errors/orders_traces'); + await expect(page.getByRole('heading', { name: 'Errors', exact: true })).toBeVisible(); + await expect(page.getByTestId('trace-table')).toBeVisible(); + expect(requests[0]).toMatchObject({ + dataset: 'orders_traces', + options: 'errorSpans', + limit: 25, + offset: 0, + }); + const body = requests[0] as { startTime: string; endTime: string }; + expect(Date.parse(body.endTime) - Date.parse(body.startTime)).toBe(3600000); + await page.getByLabel('Time range', { exact: true }).selectOption('6h'); + await expect + .poll(() => + requests.some((value) => { + const request = value as { startTime: string; endTime: string }; + return Date.parse(request.endTime) - Date.parse(request.startTime) === 6 * 3600000; + }), + ) + .toBe(true); + const sixHours = requests.find((value) => { + const request = value as { startTime: string; endTime: string }; + return Date.parse(request.endTime) - Date.parse(request.startTime) === 6 * 3600000; + }) as { + startTime: string; + endTime: string; + offset: number; + sortBy: string; + }; + expect(Date.parse(sixHours.endTime) - Date.parse(sixHours.startTime)).toBe(6 * 3600000); + expect(sixHours).toMatchObject({ offset: 0, sortBy: 'mostRecent' }); + await expect(page).toHaveURL(/range=6h/); + await page + .getByTestId('trace-table') + .getByRole('button') + .filter({ hasText: storyTraceList[0].span_name }) + .first() + .click(); + await expect(page).toHaveURL(/\/traces\/explore\/orders_traces\?.*traceId=/); +}); +test('errors explains ineligible datasets without querying fabricated error APIs', async ({ + page, +}) => { + let unsupportedRequests = 0; + await page.route('**/api/**', async (route) => { + const path = new URL(route.request().url()).pathname; + if (path.includes('/errors/')) unsupportedRequests++; + if (path === '/api/v1/logstream') return route.fulfill({ json: [{ name: 'logs' }] }); + if (path.endsWith('/info')) + return route.fulfill({ + json: { telemetryType: 'logs', logSource: [{ log_source_format: 'json' }] }, + }); + return route.fulfill({ json: {} }); + }); + await page.goto('/errors/logs'); + await expect(page.getByText('No eligible error dataset')).toBeVisible(); + await expect(page.getByRole('link', { name: 'Explore logs' })).toHaveAttribute( + 'href', + '/logs/explore/logs', + ); + expect(unsupportedRequests).toBe(0); +}); +test('errors retries unverified dataset metadata instead of declaring it ineligible', async ({ + page, +}) => { + let metadataUnavailable = true; + await page.route('**/api/**', async (route) => { + const path = new URL(route.request().url()).pathname; + if (path === '/api/v1/logstream') return route.fulfill({ json: [{ name: 'orders_traces' }] }); + if (path.endsWith('/info')) + return metadataUnavailable + ? route.fulfill({ status: 503, body: 'Metadata temporarily unavailable' }) + : route.fulfill({ + json: { telemetryType: 'traces', logSource: [{ log_source_format: 'otel-traces' }] }, + }); + if (path.endsWith('/services/traces/list')) + return route.fulfill({ json: { count: 0, offset: 0, limit: 25, records: [] } }); + return route.fulfill({ json: {} }); + }); + await page.goto('/errors/orders_traces'); + await expect(page.getByText('Could not verify dataset', { exact: true })).toBeVisible(); + await expect(page.getByText('No eligible error dataset')).toHaveCount(0); + metadataUnavailable = false; + await page.getByRole('button', { name: 'Retry dataset verification' }).click(); + await expect(page.getByText('No error spans', { exact: true })).toBeVisible(); +}); +test('cluster renders null CPU as unavailable without discarding valid metrics', async ({ + page, +}) => { + await page.route('**/api/**', async (route) => { + const path = new URL(route.request().url()).pathname; + if (path === '/api/v1/cluster/info') return route.fulfill({ json: [demoClusterNodes[0]] }); + if (path === '/api/v1/cluster/metrics') + return route.fulfill({ + json: [ + { + address: demoClusterNodes[0].domain_name, + node_type: 'ingestor', + parseable_events_ingested: 125000, + process_resident_memory_bytes: 268435456, + process_cpu_usage_percent_avg: null, + parseable_staging_files: 4, + }, + ], + }); + if (path === '/api/v1/logstream') return route.fulfill({ json: [] }); + return route.fulfill({ json: {} }); + }); + await page.goto('/cluster'); + await expect(page.getByText('125,000', { exact: true })).toBeVisible(); + await expect( + page.locator('dt').filter({ hasText: 'Average CPU usage' }).locator('+ dd'), + ).toHaveText('Unavailable'); + await expect(page.getByText('256.0 MiB', { exact: true })).toBeVisible(); + await expect(page.getByText('Could not load data', { exact: true })).toHaveCount(0); +}); diff --git a/frontend/e2e/settings.spec.ts b/frontend/e2e/settings.spec.ts new file mode 100644 index 000000000..8a1a9bb69 --- /dev/null +++ b/frontend/e2e/settings.spec.ts @@ -0,0 +1,87 @@ +import { test, expect } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; + +const screens = [ + { + path: '/keystone', + title: 'Keystone', + message: 'Keystone analysis is unavailable in this native UI', + }, + { + path: '/keystone/investigation-123', + title: 'Keystone', + message: 'Keystone analysis is unavailable in this native UI', + }, + { + path: '/settings/llm-key', + title: 'LLM settings', + message: 'Provider management is unavailable in this native UI', + }, +]; + +for (const screen of screens) { + test(`${screen.path} explains native capability scope without inventing API requests`, async ({ + page, + }) => { + const managementRequests: string[] = []; + page.on('request', (request) => { + const path = new URL(request.url()).pathname; + if (/\/api\/.*\/(keystone|llm|network)/.test(path)) managementRequests.push(path); + }); + await page.addInitScript(() => sessionStorage.setItem('parseable-mode', 'demo')); + await page.goto(screen.path); + await expect(page.getByRole('heading', { name: screen.title, exact: true })).toBeVisible(); + await expect(page.getByRole('heading', { name: screen.message })).toBeVisible(); + await expect( + page.getByText( + 'Sample workspace: this capability is not simulated. No settings are changed.', + ), + ).toBeVisible(); + await expect(page.locator('main input[type="password"]')).toHaveCount(0); + await expect(page.getByRole('link', { name: /in classic UI/ })).toHaveCount(0); + if (screen.path.includes('investigation-123')) { + await expect( + page.getByText( + 'This investigation has not been loaded. Its existence and your access have not been verified.', + ), + ).toBeVisible(); + } + expect(managementRequests).toEqual([]); + expect( + (await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa']).analyze()) + .violations, + ).toEqual([]); + }); +} + +test('management pages expose useful native navigation', async ({ page }) => { + await page.addInitScript(() => sessionStorage.setItem('parseable-mode', 'demo')); + await page.goto('/settings/llm-key'); + await page + .getByRole('navigation', { name: 'Related pages' }) + .getByRole('link', { name: 'Network access' }) + .click(); + await expect(page.getByRole('heading', { name: 'Network access', exact: true })).toBeVisible(); + await page + .getByRole('navigation', { name: 'Related pages' }) + .getByRole('link', { name: 'Keystone' }) + .click(); + await expect(page.getByRole('heading', { name: 'Keystone', exact: true })).toBeVisible(); +}); + +test('live provider settings do not collect credentials or claim provider state', async ({ + page, +}) => { + const requests: string[] = []; + await page.route('**/api/**', (route) => { + requests.push(new URL(route.request().url()).pathname); + if (route.request().url().endsWith('/logstream')) return route.fulfill({ json: [] }); + return route.fulfill({ status: 403, body: 'Permission denied' }); + }); + await page.goto('/settings/llm-key?add_llm=true'); + await expect(page.getByRole('heading', { name: 'LLM settings', exact: true })).toBeVisible(); + await expect(page.getByText(/No provider credentials are read or stored here/)).toBeVisible(); + await expect(page.getByText(/Sample workspace:/)).toHaveCount(0); + await expect(page.locator('main input')).toHaveCount(0); + expect(requests.filter((path) => /\/(llm|keystone|network)/.test(path))).toEqual([]); +}); diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index eb47facc7..d56e8a2d1 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -42,6 +42,30 @@ const ApmPage = lazy(() => import('../features/apm/ApmPage').then((m) => ({ defa const IngestionPage = lazy(() => import('../features/ingestion/IngestionPage').then((m) => ({ default: m.IngestionPage })), ); +const OnboardingPage = lazy(() => + import('../features/onboarding/OnboardingPage').then((m) => ({ default: m.OnboardingPage })), +); +const NewWorkspacePage = lazy(() => + import('../features/onboarding/OnboardingPage').then((m) => ({ default: m.NewWorkspacePage })), +); +const AgentsPage = lazy(() => + import('../features/agents/AgentsPage').then((m) => ({ default: m.AgentsPage })), +); +const KeystonePage = lazy(() => + import('../features/keystone/KeystonePage').then((m) => ({ default: m.KeystonePage })), +); +const LlmSettingsPage = lazy(() => + import('../features/settings/SettingsPages').then((m) => ({ default: m.LlmSettingsPage })), +); +const NetworkAccessPage = lazy(() => + import('../features/settings/SettingsPages').then((m) => ({ default: m.NetworkAccessPage })), +); +const ClusterPage = lazy(() => + import('../features/cluster/ClusterPage').then((m) => ({ default: m.ClusterPage })), +); +const ErrorsPage = lazy(() => + import('../features/errors/ErrorsPage').then((m) => ({ default: m.ErrorsPage })), +); import { OverviewPage } from './OverviewPage'; export function App() { const { mode, session, identity, needsLogin, client } = useApp(); @@ -67,6 +91,13 @@ export function App() { dashboards: 'Dashboards', team: 'Team', components: 'Component library', + onboarding: 'Get started', + new: 'Workspace setup', + agents: 'Agents', + cluster: 'Cluster', + errors: 'Errors', + keystone: 'Keystone', + settings: 'Settings', } as Record )[location.pathname.split('/')[1]] || 'Overview'; const path = `${location.pathname}${location.search}${location.hash}`; @@ -190,6 +221,20 @@ export function App() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + setConnect(true)} />} + /> } /> } /> } /> diff --git a/frontend/src/app/Sidebar.tsx b/frontend/src/app/Sidebar.tsx index 6ea2dfee6..980c6a33d 100644 --- a/frontend/src/app/Sidebar.tsx +++ b/frontend/src/app/Sidebar.tsx @@ -17,6 +17,11 @@ import { Workflow, Network, Import, + Bot, + CircleAlert, + Server, + Shield, + ListChecks, } from 'lucide-react'; import { Button, Badge } from '../components/ui'; import { useApp } from './AppProvider'; @@ -36,6 +41,8 @@ const groups = [ { to: '/metrics', label: 'Metrics', id: 'metrics', icon: ChartLine }, { to: '/traces', label: 'Traces', id: 'traces', icon: Workflow }, { to: '/apm', label: 'APM', id: 'apm', icon: Network }, + { to: '/agents', label: 'Agents', id: 'agents', icon: Bot }, + { to: '/errors', label: 'Errors', id: 'errors', icon: CircleAlert }, { to: '/alerts', label: 'Alerts', id: 'alerts', icon: Bell }, ], }, @@ -43,12 +50,22 @@ const groups = [ label: 'Data', links: [ { to: '/datasets', label: 'Datasets', id: 'datasets', icon: Database }, + { to: '/onboarding', label: 'Get started', id: 'onboarding', icon: ListChecks }, { to: '/add-data', label: 'Add data', id: 'add-data', icon: Import }, ], }, { label: 'Admin', - links: [{ to: '/team', label: 'Team', id: 'team', icon: UsersRound }], + links: [ + { to: '/team', label: 'Team', id: 'team', icon: UsersRound }, + { to: '/cluster', label: 'Cluster', id: 'cluster', icon: Server }, + { + to: '/settings/network-access', + label: 'Network access', + id: 'network-access', + icon: Shield, + }, + ], }, { label: 'Develop', diff --git a/frontend/src/features/agents/AgentsPage.stories.tsx b/frontend/src/features/agents/AgentsPage.stories.tsx new file mode 100644 index 000000000..8a485c8e8 --- /dev/null +++ b/frontend/src/features/agents/AgentsPage.stories.tsx @@ -0,0 +1,96 @@ +import { useMemo } from 'react'; +import { createMemoryRouter, RouterProvider } from 'react-router-dom'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { AppContext } from '../../app/AppProvider'; +import { createDemoClient } from '../../lib/demo'; +import { ApiError } from '../../lib/apiError'; +import { agentDemoFields, agentDemoRows } from '../../lib/agents'; +import { AgentsPage } from './AgentsPage'; + +type State = + | 'default' + | 'empty' + | 'permission' + | 'error' + | 'loading' + | 'no-datasets' + | 'missing-fields' + | 'demo'; +function Example({ state }: { state: State }) { + const client = useMemo( + () => ({ + ...createDemoClient(), + async listDatasets() { + return state === 'no-datasets' + ? [] + : [{ name: 'agent_telemetry', type: 'traces' as const }]; + }, + async schema() { + return state === 'missing-fields' ? ['p_timestamp'] : agentDemoFields; + }, + async datasetInfo(name: string) { + return { name, logSourceFormats: ['otel-traces'], timePartition: 'p_timestamp' }; + }, + async query(_request: unknown, signal?: AbortSignal) { + if (state === 'loading') + await new Promise((resolve, reject) => { + const timer = setTimeout(resolve, 3600000); + signal?.addEventListener( + 'abort', + () => { + clearTimeout(timer); + reject(new DOMException('Aborted', 'AbortError')); + }, + { once: true }, + ); + }); + if (state === 'permission') throw new ApiError('Access to agent_telemetry is denied', 403); + if (state === 'error') throw new ApiError('Query node unavailable', 503); + return state === 'empty' ? [] : agentDemoRows(); + }, + }), + [state], + ); + const router = useMemo( + () => + createMemoryRouter( + [ + { path: '/agents', element: }, + { path: '/agents/:dataset', element: }, + ], + { initialEntries: [state === 'no-datasets' ? '/agents' : '/agents/agent_telemetry'] }, + ), + [state], + ); + return ( + {}, + }} + > + + + ); +} +const meta = { + title: 'Agents/Telemetry', + component: Example, + args: { state: 'default' }, + parameters: { layout: 'fullscreen' }, +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const Default: Story = {}; +export const Empty: Story = { args: { state: 'empty' } }; +export const PermissionDenied: Story = { args: { state: 'permission' } }; +export const Error: Story = { args: { state: 'error' } }; +export const Loading: Story = { args: { state: 'loading' } }; +export const NoDatasets: Story = { args: { state: 'no-datasets' } }; +export const MissingFields: Story = { args: { state: 'missing-fields' } }; +export const Demo: Story = { args: { state: 'demo' } }; diff --git a/frontend/src/features/agents/AgentsPage.tsx b/frontend/src/features/agents/AgentsPage.tsx new file mode 100644 index 000000000..34b6edb91 --- /dev/null +++ b/frontend/src/features/agents/AgentsPage.tsx @@ -0,0 +1,300 @@ +import { useCallback, useMemo, useState } from 'react'; +import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'; +import { Bot, RefreshCw } from 'lucide-react'; +import { Badge, Button, EmptyState, Input, Select, Sheet } from '../../components/ui'; +import { PageHeader } from '../../components/explorer/PageHeader'; +import { QueryState } from '../../components/explorer/QueryState'; +import { TimeRangePicker } from '../../components/explorer/TimeRangePicker'; +import { useApp } from '../../app/AppProvider'; +import { useAsync } from '../../hooks/useAsync'; +import { + AGENT_RECORD_LIMIT, + agentDemoRows, + agentRangeFromParams, + agentBoundsFromParams, + agentsPath, + agentText, + buildAgentQuery, + discoverAgentDatasets, +} from '../../lib/agents'; +import { timeBounds } from '../../lib/query'; +import type { LogRecord, TimeRange } from '../../lib/types'; +import './agents.css'; + +export function AgentsPage() { + const { client, mode } = useApp(); + const { dataset } = useParams<{ dataset: string }>(); + const navigate = useNavigate(); + const [params, setParams] = useSearchParams(); + const searchString = params.toString(); + const range = useMemo( + () => agentRangeFromParams(new URLSearchParams(searchString)), + [searchString], + ); + const bounds = useMemo( + () => agentBoundsFromParams(new URLSearchParams(searchString)), + [searchString], + ); + const suffix = `?${new URLSearchParams({ start: bounds.startTime, end: bounds.endTime, ...(typeof range === 'string' ? { range } : {}) })}`; + const [search, setSearch] = useState(''); + const [selected, setSelected] = useState(); + const [discoveryRequested, setDiscoveryRequested] = useState(false); + const discoveryEnabled = !dataset || discoveryRequested || mode === 'demo'; + const discovery = useAsync( + useCallback( + async (signal) => + !discoveryEnabled + ? undefined + : mode === 'demo' + ? { datasets: [{ name: 'agent_telemetry', compatible: true }], unchecked: [] } + : discoverAgentDatasets(client, signal), + [client, mode, discoveryEnabled], + ), + ); + const result = useAsync( + useCallback( + async (signal) => { + if (!dataset) return undefined; + if (mode === 'demo') { + if (dataset !== 'agent_telemetry') + throw new Error( + 'This dataset is not available in the demo workspace. Select agent_telemetry to explore sample records.', + ); + return { + rows: agentDemoRows(Date.parse(bounds.endTime)).filter( + (row) => Date.parse(String(row.p_timestamp)) >= Date.parse(bounds.startTime), + ), + timeColumn: 'p_timestamp', + traces: false, + }; + } + const [fields, info] = await Promise.all([ + client.schema(dataset, signal), + client.datasetInfo(dataset, signal), + ]); + const timeColumn = info.timePartition || 'p_timestamp'; + const rows = await client.query( + buildAgentQuery(dataset, fields, bounds, timeColumn), + signal, + ); + return { rows, timeColumn, traces: info.logSourceFormats.includes('otel-traces') }; + }, + [client, mode, dataset, bounds], + ), + ); + const changeRange = (value: TimeRange) => { + const next = new URLSearchParams(params), + captured = timeBounds(value); + next.set('start', captured.startTime); + next.set('end', captured.endTime); + if (typeof value === 'string') next.set('range', value); + else next.delete('range'); + setParams(next); + setSelected(undefined); + }; + const rows = + result.data?.rows.filter((row) => + Object.values(row).some( + (value) => typeof value === 'string' && value.toLowerCase().includes(search.toLowerCase()), + ), + ) ?? []; + return ( +
+ {mode === 'demo' ? 'Demo telemetry' : 'Live data'}} + /> +

+ Recent telemetry records, including individual spans and events. Run-level analytics and + model pricing are unavailable in this edition. +

+ {[...params.keys()].some((key) => !['start', 'end', 'range'].includes(key)) && ( +

+ This link includes options that are not applied here:{' '} + {[...new Set(params.keys())] + .filter((key) => !['start', 'end', 'range'].includes(key)) + .join(', ')} + . Use the native time range and search controls to refine these records. +

+ )} +
+ + + +
+ {dataset && ( + + )} + {dataset && discoveryEnabled && discovery.loading && ( +

+ Finding compatible datasets… +

+ )} + + {!!discovery.data?.unchecked.length && ( +

+ Could not inspect {discovery.data.unchecked.map((entry) => entry.name).join(', ')}.{' '} + +

+ )} + {!dataset && discovery.data && ( + } + title={ + discovery.data.datasets.length + ? 'Choose an agent dataset' + : 'No agent telemetry datasets found' + } + description="Datasets with GenAI agent, operation, model, or tool fields appear here. Ingest your instrumentation to get started." + action={ + + Add data + + } + /> + )} + {dataset && ( + <> + + {result.data && ( +
+
+ setSearch(event.target.value)} + /> + {result.data.rows.length} records loaded +
+

+ Showing up to {AGENT_RECORD_LIMIT} recent records in the selected time range. Search + filters loaded records. +

+ {!rows.length ? ( + + ) : ( +
+ + + + {['Time', 'Agent', 'Operation', 'Model / tool', 'Status', 'Details'].map( + (label) => ( + + ), + )} + + + + {rows.map((row, index) => ( + + + + + + + + + ))} + +
+ {label} +
{agentText(row, result.data!.timeColumn)}{agentText(row, 'gen_ai.agent.name', 'gen_ai.agent.id')}{agentText(row, 'gen_ai.operation.name', 'span_name')}{agentText(row, 'gen_ai.tool.name', 'gen_ai.request.model')} + {row.span_status_code === 2 + ? 'Error' + : row.span_status_code === 1 + ? 'OK' + : '—'} + + +
+
+ )} +
+ )} + + )} + { + if (!open) setSelected(undefined); + }} + title="Agent telemetry record" + > + {selected && ( + <> + {result.data?.traces && + typeof selected.span_trace_id === 'string' && + /^[a-fA-F0-9]{32}$/.test(selected.span_trace_id) && ( + + Open trace + + )} +
{JSON.stringify(selected, null, 2)}
+ + )} +
+
+ ); +} diff --git a/frontend/src/features/agents/agents.css b/frontend/src/features/agents/agents.css new file mode 100644 index 000000000..b043295c8 --- /dev/null +++ b/frontend/src/features/agents/agents.css @@ -0,0 +1,64 @@ +.agents-page { + min-width: 0; +} +.agents-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + margin: 16px 0; +} +.agents-controls .input { + max-width: 320px; +} +.agents-note { + color: var(--muted); + font-size: 13px; + line-height: 1.6; +} +.agents-panel { + border: 1px solid var(--border); + background: var(--surface); + border-radius: 12px; + padding: 16px; + min-width: 0; +} +.agents-table-scroll { + overflow-x: auto; +} +.agents-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} +.agents-table th, +.agents-table td { + padding: 12px; + text-align: left; + border-bottom: 1px solid var(--border); + max-width: 240px; + overflow-wrap: anywhere; +} +.agents-table th { + color: var(--muted); + font-weight: 500; +} +.agents-record { + white-space: pre-wrap; + overflow-wrap: anywhere; + font-size: 12px; + line-height: 1.6; +} +@media (max-width: 600px) { + .agents-controls > .select, + .agents-controls > .input { + width: 100%; + max-width: none; + } + .agents-table { + min-width: 720px; + } + .agents-panel { + padding: 12px; + } +} diff --git a/frontend/src/features/cluster/ClusterPage.stories.tsx b/frontend/src/features/cluster/ClusterPage.stories.tsx new file mode 100644 index 000000000..a81104523 --- /dev/null +++ b/frontend/src/features/cluster/ClusterPage.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { ClusterOverview } from './ClusterPage'; +import { demoClusterNodes, demoClusterMetrics } from '../../lib/clusterDemo'; +const meta = { title: 'Operations/Cluster', component: ClusterOverview } satisfies Meta< + typeof ClusterOverview +>; +export default meta; +type Story = StoryObj; +export const Healthy: Story = { args: { nodes: demoClusterNodes } }; +export const Empty: Story = { args: { nodes: [] } }; +export const Unreachable: Story = { + args: { + nodes: [ + { + ...demoClusterNodes[0], + reachable: false, + status: null, + error: 'Connection refused', + staging_path: '', + }, + ], + }, +}; + +export const MissingMetric: Story = { + args: { + nodes: [demoClusterNodes[0]], + metrics: [{ ...demoClusterMetrics[0], process_cpu_usage_percent_avg: null }], + }, +}; diff --git a/frontend/src/features/cluster/ClusterPage.tsx b/frontend/src/features/cluster/ClusterPage.tsx new file mode 100644 index 000000000..c3eb855e9 --- /dev/null +++ b/frontend/src/features/cluster/ClusterPage.tsx @@ -0,0 +1,111 @@ +import { useCallback } from 'react'; +import { useApp } from '../../app/AppProvider'; +import { useAsync } from '../../hooks/useAsync'; +import { PageHeader } from '../../components/explorer/PageHeader'; +import { QueryState } from '../../components/explorer/QueryState'; +import { Badge, Button, Card, EmptyState } from '../../components/ui'; +import { ApiError } from '../../lib/apiError'; +import type { ClusterNode, ClusterMetrics } from '../../lib/operationsContract'; +import './cluster.css'; +export function ClusterOverview({ + nodes, + metrics = [], +}: { + nodes: ClusterNode[]; + metrics?: ClusterMetrics[]; +}) { + return nodes.length === 0 ? ( + + ) : ( +
+ {nodes.map((node) => { + const metric = metrics.find( + (m) => m.address.replace(/\/$/, '') === node.domain_name.replace(/\/$/, ''), + ); + return ( + +
+

{node.node_type}

+ + {node.reachable ? 'Reachable' : 'Unreachable'} + +
+

{node.domain_name}

+
+
Staging
+
{node.staging_path || 'Unavailable'}
+
Storage
+
{node.storage_path || 'Unavailable'}
+
Status
+
{node.status || node.error || 'Unavailable'}
+ {metric && ( + <> +
Events ingested
+
{metric.parseable_events_ingested?.toLocaleString() ?? 'Unavailable'}
+
Resident memory
+
+ {metric.process_resident_memory_bytes === null + ? 'Unavailable' + : `${(metric.process_resident_memory_bytes / 1024 / 1024).toFixed(1)} MiB`} +
+
Average CPU usage
+
+ {metric.process_cpu_usage_percent_avg === null + ? 'Unavailable' + : `${metric.process_cpu_usage_percent_avg.toFixed(1)}%`} +
+
Staging files
+
{metric.parseable_staging_files?.toLocaleString() ?? 'Unavailable'}
+ + )} +
+
+ ); + })} +
+ ); +} +export function ClusterPage() { + const { mode, client } = useApp(); + const nodes = useAsync(useCallback((signal) => client.clusterNodes(signal), [mode, client])); + const metrics = useAsync(useCallback((signal) => client.clusterMetrics(signal), [mode, client])); + const unsupported = nodes.error instanceof ApiError && [404, 405].includes(nodes.error.status); + return ( +
+ { + nodes.reload(); + metrics.reload(); + }} + > + Refresh + + } + /> + {mode === 'demo' && Sample cluster} + {unsupported ? ( + + ) : ( + <> + + {nodes.data && ( + <> + + + + )} + + )} +
+ ); +} diff --git a/frontend/src/features/cluster/cluster.css b/frontend/src/features/cluster/cluster.css new file mode 100644 index 000000000..3c17640b8 --- /dev/null +++ b/frontend/src/features/cluster/cluster.css @@ -0,0 +1,30 @@ +.cluster-nodes { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr)); + gap: 16px; +} +.cluster-node { + padding: 20px; + min-width: 0; +} +.cluster-node h2 { + margin: 0; + text-transform: capitalize; + font-size: 16px; +} +.cluster-address, +.cluster-node dd { + overflow-wrap: anywhere; +} +.cluster-node dl { + display: grid; + grid-template-columns: minmax(100px, 1fr) minmax(0, 2fr); + gap: 12px; + font-size: 13px; +} +.cluster-node dt { + color: var(--color-muted); +} +.cluster-node dd { + margin: 0; +} diff --git a/frontend/src/features/errors/ErrorsPage.stories.tsx b/frontend/src/features/errors/ErrorsPage.stories.tsx new file mode 100644 index 000000000..92c94916a --- /dev/null +++ b/frontend/src/features/errors/ErrorsPage.stories.tsx @@ -0,0 +1,109 @@ +import { useMemo, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { createMemoryRouter, RouterProvider } from 'react-router-dom'; +import { AppContext } from '../../app/AppProvider'; +import { ApiError } from '../../lib/apiError'; +import { createDemoClient } from '../../lib/demo'; +import type { ParseableClient, TraceListRequest } from '../../lib/types'; +import { storyTraceList, STORY_BOUNDS } from '../traces/fixtures'; +import { ErrorsPage } from './ErrorsPage'; +type State = 'success' | 'loading' | 'error' | 'permission' | 'empty' | 'unavailable'; +function Example({ state }: { state: State }) { + const [requests, setRequests] = useState([]); + const client = useMemo( + () => + ({ + ...createDemoClient(), + async listDatasets() { + return state === 'unavailable' + ? [] + : [{ name: 'orders_traces', type: 'traces' as const }]; + }, + async datasetInfo(name: string) { + return { name, telemetryType: 'traces', logSourceFormats: ['otel-traces'] }; + }, + async listTraces(request: TraceListRequest, signal?: AbortSignal) { + setRequests((previous) => [...previous, request]); + signal?.throwIfAborted(); + if (state === 'loading') + await new Promise((_, reject) => + signal?.addEventListener( + 'abort', + () => reject(new DOMException('Aborted', 'AbortError')), + { once: true }, + ), + ); + if (state === 'error') throw new ApiError('Trace query node unavailable', 503); + if (state === 'permission') throw new ApiError('Error span access denied', 403); + const rows = state === 'empty' ? [] : storyTraceList.filter((row) => row.error_count > 0); + const sorts = { + mostRecent: ['span_start_time_unix_nano', -1], + leastRecent: ['span_start_time_unix_nano', 1], + longestFirst: ['span_duration_ns', -1], + shortestFirst: ['span_duration_ns', 1], + mostSpans: ['total_span_count', -1], + leastSpans: ['total_span_count', 1], + } as const; + const [field, direction] = sorts[request.sortBy ?? 'mostRecent']; + rows.sort( + (a, b) => + (field === 'span_start_time_unix_nano' + ? Date.parse(a[field]) - Date.parse(b[field]) + : Number(a[field]) - Number(b[field])) * direction, + ); + return { + count: rows.length, + offset: request.offset ?? 0, + limit: request.limit ?? 25, + records: rows.slice(request.offset ?? 0, (request.offset ?? 0) + (request.limit ?? 25)), + }; + }, + }) satisfies ParseableClient, + [state], + ); + const router = useMemo( + () => + createMemoryRouter( + [ + { + path: '/errors/:dataset?', + element: , + }, + ], + { + initialEntries: [ + `/errors/orders_traces?${new URLSearchParams({ start: STORY_BOUNDS.startTime, end: STORY_BOUNDS.endTime })}`, + ], + }, + ), + [], + ); + return ( + {}, + }} + > + +
+ Story transport inspection +
{JSON.stringify(requests, null, 2)}
+
+
+ ); +} +const meta = { title: 'Operations/Errors', component: Example } satisfies Meta; +export default meta; +type Story = StoryObj; +export const Success: Story = { args: { state: 'success' } }; +export const Loading: Story = { args: { state: 'loading' } }; +export const Error: Story = { args: { state: 'error' } }; +export const Permission: Story = { args: { state: 'permission' } }; +export const Empty: Story = { args: { state: 'empty' } }; +export const Unavailable: Story = { args: { state: 'unavailable' } }; diff --git a/frontend/src/features/errors/ErrorsPage.tsx b/frontend/src/features/errors/ErrorsPage.tsx new file mode 100644 index 000000000..7a9129ab2 --- /dev/null +++ b/frontend/src/features/errors/ErrorsPage.tsx @@ -0,0 +1,201 @@ +import { useCallback, useMemo, useState } from 'react'; +import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'; +import { useApp } from '../../app/AppProvider'; +import { useAsync } from '../../hooks/useAsync'; +import { PageHeader } from '../../components/explorer/PageHeader'; +import { QueryState } from '../../components/explorer/QueryState'; +import { Badge, Button, EmptyState, Select } from '../../components/ui'; +import { discoverTraceDatasets, TRACE_PAGE_SIZE } from '../traces/helpers'; +import { TraceTable } from '../traces/TraceTable'; +import { TimeRangePicker } from '../../components/explorer/TimeRangePicker'; +import { paramsRange, timeBounds } from '../../lib/query'; +import type { TimeRange, TraceSortBy } from '../../lib/types'; +import '../traces/traces.css'; +export function ErrorsUnavailable({ dataset }: { dataset?: string }) { + return ( + + Explore logs + + } + /> + ); +} +function errorRange(params: URLSearchParams): TimeRange { + if (params.has('start') || params.has('end')) return paramsRange(params); + const preset = params.get('range'); + if (preset) { + try { + timeBounds(preset as TimeRange); + return preset as TimeRange; + } catch { + /* Fall back to the default range. */ + } + } + return '1h'; +} +function ErrorSpans({ dataset }: { dataset: string }) { + const { client } = useApp(); + const navigate = useNavigate(); + const [params, setParams] = useSearchParams(); + const rangeKey = JSON.stringify(errorRange(params)); + const range = useMemo(() => JSON.parse(rangeKey) as TimeRange, [rangeKey]); + const [page, setPage] = useState(0); + const [sort, setSort] = useState('mostRecent'); + const [revision, setRevision] = useState(0); + const bounds = useMemo(() => timeBounds(range), [range, revision]); + function changeRange(value: TimeRange) { + const next = new URLSearchParams(params); + next.delete('start'); + next.delete('end'); + next.delete('range'); + if (typeof value === 'string') next.set('range', value); + else { + next.set('start', value.startTime); + next.set('end', value.endTime); + } + setPage(0); + setSort('mostRecent'); + setParams(next); + } + const result = useAsync( + useCallback( + (signal) => + client.listTraces( + { + dataset, + ...bounds, + options: 'errorSpans', + sortBy: sort, + offset: page * TRACE_PAGE_SIZE, + limit: TRACE_PAGE_SIZE, + }, + signal, + ), + [client, dataset, bounds, page, sort], + ), + ); + return ( + <> +
+

OTLP error spans · {TRACE_PAGE_SIZE} results per page

+ + +
+ + {result.data && + (result.data.records.length ? ( + { + setPage(0); + setSort(value); + }} + onPage={setPage} + noun="error spans" + onSelect={(row) => + navigate( + `/traces/explore/${encodeURIComponent(dataset)}?${new URLSearchParams({ traceId: row.span_trace_id, start: bounds.startTime, end: bounds.endTime })}`, + ) + } + /> + ) : ( + + ))} + + ); +} +export function ErrorsPage() { + const { dataset } = useParams(); + const [params] = useSearchParams(); + const { client, mode } = useApp(); + const navigate = useNavigate(); + const discovery = useAsync( + useCallback((signal) => discoverTraceDatasets(client, signal), [client]), + ); + const selected = dataset ?? discovery.data?.datasets[0]?.name; + const eligible = discovery.data?.datasets.some((entry) => entry.name === selected); + return ( +
+ + {mode === 'demo' && Sample data} +

+ This view lists individual error spans. Grouped error issues and frequency analysis are + unavailable on this server. +

+ {[...params.keys()].some((key) => !['start', 'end', 'range'].includes(key)) && ( +

+ This link includes options that are not applied here:{' '} + {[...new Set(params.keys())] + .filter((key) => !['start', 'end', 'range'].includes(key)) + .join(', ')} + . Use the native time range and sort controls to refine these error spans. +

+ )} + + {discovery.data?.unchecked.length ? ( +

+ Could not check datasets: {discovery.data.unchecked.join(', ')}.{' '} + +

+ ) : null} + {discovery.data && ( + <> + + {eligible && selected ? ( + + ) : selected && discovery.data.unchecked.includes(selected) ? ( + Retry dataset verification} + /> + ) : ( + + )} + + )} +
+ ); +} diff --git a/frontend/src/features/keystone/KeystonePage.tsx b/frontend/src/features/keystone/KeystonePage.tsx new file mode 100644 index 000000000..e6f34ae30 --- /dev/null +++ b/frontend/src/features/keystone/KeystonePage.tsx @@ -0,0 +1,17 @@ +import { useParams } from 'react-router-dom'; +import { useApp } from '../../app/AppProvider'; +import { classicUiAvailable } from '../../lib/classicUi'; +import { CapabilityPage } from '../settings/CapabilityPage'; + +export function KeystonePage() { + const { id } = useParams<{ id: string }>(); + const { mode } = useApp(); + return ( + + ); +} diff --git a/frontend/src/features/onboarding/Onboarding.stories.tsx b/frontend/src/features/onboarding/Onboarding.stories.tsx new file mode 100644 index 000000000..20635317e --- /dev/null +++ b/frontend/src/features/onboarding/Onboarding.stories.tsx @@ -0,0 +1,39 @@ +import { MemoryRouter } from 'react-router-dom'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { SetupGuide, WorkspaceIntro } from './OnboardingPage'; + +const meta = { + title: 'Onboarding/Setup guide', + component: SetupGuide, + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], + args: { datasets: [] }, +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const Empty: Story = {}; +export const AvailableDatasets: Story = { + args: { + datasets: [ + { name: 'app_logs', type: 'logs' }, + { name: 'otel_metrics', type: 'metrics' }, + { name: 'otel_traces', type: 'traces' }, + ], + }, +}; +export const MetadataUnavailable: Story = { + args: { datasets: [{ name: 'restricted_dataset', typeError: 'Permission denied' }] }, +}; +export const Demo: Story = { + args: { demo: true, datasets: [{ name: 'sample_logs', type: 'logs' }] }, +}; +export const NewWorkspace: Story = { + render: () => {}} />, +}; diff --git a/frontend/src/features/onboarding/OnboardingPage.tsx b/frontend/src/features/onboarding/OnboardingPage.tsx new file mode 100644 index 000000000..a1b2102fe --- /dev/null +++ b/frontend/src/features/onboarding/OnboardingPage.tsx @@ -0,0 +1,147 @@ +import { useCallback } from 'react'; +import { Link } from 'react-router-dom'; +import { Database, Import, Search, UsersRound } from 'lucide-react'; +import { useApp } from '../../app/AppProvider'; +import { PageHeader } from '../../components/explorer/PageHeader'; +import { QueryState } from '../../components/explorer/QueryState'; +import { Badge, Button, Card } from '../../components/ui'; +import { useAsync } from '../../hooks/useAsync'; +import { + datasetPath, + listDatasetInventory, + type DatasetInventoryEntry, +} from '../../lib/datasetPath'; +import './onboarding.css'; + +export function SetupGuide({ + datasets, + demo = false, +}: { + datasets: DatasetInventoryEntry[]; + demo?: boolean; +}) { + return ( +
+ + + + + + +
+ ); +} + +export function OnboardingPage() { + const { client, mode } = useApp(); + const inventory = useAsync( + useCallback((signal) => listDatasetInventory(client, signal), [client]), + ); + return ( +
+ + Refresh setup + + } + /> + + {inventory.data && } +
+ ); +} + +export function WorkspaceIntro({ + onConnect, + demo = false, +}: { + onConnect: () => void; + demo?: boolean; +}) { + return ( +
+ + + +
+ ); +} + +export function NewWorkspacePage({ onConnect }: { onConnect: () => void }) { + const { mode } = useApp(); + return ; +} diff --git a/frontend/src/features/onboarding/onboarding.css b/frontend/src/features/onboarding/onboarding.css new file mode 100644 index 000000000..62ff0a844 --- /dev/null +++ b/frontend/src/features/onboarding/onboarding.css @@ -0,0 +1,59 @@ +.onboarding-page { + max-width: 1080px; +} +.setup-guide { + display: grid; + gap: 18px; +} +.setup-step { + display: flex; + align-items: flex-start; + gap: 20px; + padding: 24px; +} +.setup-step > svg { + flex-shrink: 0; + color: var(--color-primary); + margin-top: 4px; +} +.setup-step > div { + min-width: 0; + flex: 1; +} +.setup-step h2 { + font-size: 18px; + margin: 0 0 10px; +} +.setup-step p { + line-height: 1.6; + margin: 0 0 16px; +} +.setup-step .inline { + flex-wrap: wrap; + gap: 16px; +} +.setup-step .inline + p { + margin-top: 18px; +} +.setup-datasets { + list-style: none; + margin: 0 0 20px; + padding: 0; + display: grid; + gap: 10px; +} +.setup-datasets li { + overflow-wrap: anywhere; +} +.setup-datasets a { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; +} +@media (max-width: 600px) { + .setup-step { + padding: 18px; + gap: 12px; + } +} diff --git a/frontend/src/features/settings/CapabilityPage.stories.tsx b/frontend/src/features/settings/CapabilityPage.stories.tsx new file mode 100644 index 000000000..4fd1b51ff --- /dev/null +++ b/frontend/src/features/settings/CapabilityPage.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { MemoryRouter } from 'react-router-dom'; +import { CapabilityPage } from './CapabilityPage'; + +const meta = { + title: 'Settings/Capabilities', + component: CapabilityPage, + args: { feature: 'llm', demo: false, classicAvailable: false }, + decorators: [ + (Story) => ( + + + + ), + ], + parameters: { layout: 'fullscreen' }, +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const LlmProviders: Story = {}; +export const Keystone: Story = { args: { feature: 'keystone' } }; +export const Investigation: Story = { + args: { feature: 'keystone', id: 'investigation-123', classicAvailable: true }, +}; +export const ClassicAvailable: Story = { args: { classicAvailable: true } }; +export const SampleWorkspace: Story = { args: { demo: true, classicAvailable: true } }; diff --git a/frontend/src/features/settings/CapabilityPage.tsx b/frontend/src/features/settings/CapabilityPage.tsx new file mode 100644 index 000000000..61c3cb63b --- /dev/null +++ b/frontend/src/features/settings/CapabilityPage.tsx @@ -0,0 +1,82 @@ +import { Link } from 'react-router-dom'; +import { ExternalLink, LockKeyhole, Sparkles, Shield } from 'lucide-react'; +import { Badge, Card, CardBody, EmptyState } from '../../components/ui'; +import { PageHeader } from '../../components/explorer/PageHeader'; +import { + settingsCapabilities, + settingsClassicPath, + type SettingsCapability, +} from '../../lib/settingsCapabilities'; +import './settings.css'; + +/** Local informational content: it deliberately makes no API or secret requests. */ +export function CapabilityPage({ + feature, + id, + demo = false, + classicAvailable = false, +}: { + feature: SettingsCapability; + id?: string; + demo?: boolean; + classicAvailable?: boolean; +}) { + const scope = settingsCapabilities[feature]; + const Icon = feature === 'keystone' ? Sparkles : feature === 'llm' ? LockKeyhole : Shield; + return ( +
+ + {demo && ( +

+ Sample workspace: this capability is not simulated. No settings are changed. +

+ )} + + +
+ Unavailable in native UI +
+ } + action={ + classicAvailable && !demo ? ( + + Open {feature === 'keystone' && id ? 'investigation' : scope.title} in classic UI + + ) : undefined + } + /> + {feature === 'keystone' && id && ( +

+ This investigation has not been loaded. Its existence and your access have not been + verified. +

+ )} +

+ {classicAvailable && !demo + ? 'The classic UI may support this feature for your deployment. Your existing permissions still apply.' + : 'Ask your deployment administrator which management interface supports this feature.'} +

+
+
+ +
+ ); +} diff --git a/frontend/src/features/settings/NetworkAccessPage.stories.tsx b/frontend/src/features/settings/NetworkAccessPage.stories.tsx new file mode 100644 index 000000000..b5137e482 --- /dev/null +++ b/frontend/src/features/settings/NetworkAccessPage.stories.tsx @@ -0,0 +1,56 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { createMemoryRouter, RouterProvider } from 'react-router-dom'; +import { NetworkAccessEditor } from './NetworkAccessPage'; +import { createDemoClient } from '../../lib/demo'; +import { ApiError } from '../../lib/apiError'; +const meta = { + title: 'Settings/Network access', + component: NetworkAccessEditor, + args: { client: createDemoClient(), demo: true }, + decorators: [ + (Story) => }])} />, + ], + parameters: { layout: 'fullscreen' }, +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const Default: Story = {}; +export const PermissionDenied: Story = { + args: { + demo: false, + client: { + ...createDemoClient(), + getAlertTargetPolicy: async () => { + throw new ApiError('Forbidden', 403); + }, + }, + }, +}; +export const Error: Story = { + args: { + demo: false, + client: { + ...createDemoClient(), + getAlertTargetPolicy: async () => { + throw new ApiError('Service unavailable', 503); + }, + }, + }, +}; +export const Loading: Story = { + args: { + demo: false, + client: { ...createDemoClient(), getAlertTargetPolicy: () => new Promise(() => {}) }, + }, +}; +export const ValidationError: Story = { + args: { + demo: false, + client: { + ...createDemoClient(), + putAlertTargetPolicy: async () => { + throw new ApiError('allow and deny CIDR lists conflict', 400); + }, + }, + }, +}; diff --git a/frontend/src/features/settings/NetworkAccessPage.tsx b/frontend/src/features/settings/NetworkAccessPage.tsx new file mode 100644 index 000000000..3e856ee3e --- /dev/null +++ b/frontend/src/features/settings/NetworkAccessPage.tsx @@ -0,0 +1,275 @@ +import { useEffect, useId, useRef, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useApp } from '../../app/AppProvider'; +import { PageHeader } from '../../components/explorer/PageHeader'; +import { useLeaveGuard } from '../../hooks/useLeaveGuard'; +import { ApiError } from '../../lib/apiError'; +import { invalidPolicyDomain, policyLines, policyListFields } from '../../lib/networkPolicy'; +import type { AlertTargetPolicy, ParseableClient } from '../../lib/types'; +import './settings.css'; + +const labels = { + allowedDomains: 'Allowed domains', + allowedCidrs: 'Allowed CIDRs', + deniedDomains: 'Denied domains', + deniedCidrs: 'Denied CIDRs', +}; +type Lists = Record<(typeof policyListFields)[number], string>; +const toLists = (policy: AlertTargetPolicy): Lists => + Object.fromEntries(policyListFields.map((key) => [key, policy[key].join('\n')])) as Lists; +export function NetworkAccessPage() { + const { client, mode, session } = useApp(); + return ; +} +export function NetworkAccessEditor({ + client, + demo = false, +}: { + client: ParseableClient; + demo?: boolean; +}) { + const formId = useId(); + const fields = useRef>>({}); + const [fieldErrors, setFieldErrors] = useState>>({}); + const [saved, setSaved] = useState(); + const [draft, setDraft] = useState(); + const [lists, setLists] = useState(); + const [error, setError] = useState(); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [success, setSuccess] = useState(false); + const [attempt, setAttempt] = useState(0); + const flight = useRef(false); + const mounted = useRef(true); + const dirty = + !!saved && + !!draft && + !!lists && + (JSON.stringify(draft) !== JSON.stringify(saved) || + policyListFields.some((key) => lists[key] !== saved[key].join('\n'))); + const markSaved = useLeaveGuard(dirty, 'network policy'); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setError(undefined); + setSaved(undefined); + setDraft(undefined); + setLists(undefined); + client + .getAlertTargetPolicy(controller.signal) + .then((policy) => { + if (!controller.signal.aborted) { + setSaved(policy); + setDraft(policy); + setLists(toLists(policy)); + } + }) + .catch((reason: unknown) => { + if (!controller.signal.aborted) setError(reason); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, [client, attempt]); + const forbidden = error instanceof ApiError && error.status === 403; + function edited() { + setSuccess(false); + if (!forbidden) setError(undefined); + } + async function save() { + if (flight.current || forbidden || !draft || !lists || !dirty) return; + const errors: Partial> = {}; + for (const key of ['allowedDomains', 'deniedDomains'] as const) { + const invalid = invalidPolicyDomain(lists[key]); + if (invalid) + errors[key] = + `Invalid domain: ${invalid}. Enter a plain hostname without wildcards, URLs, ports or paths.`; + } + setFieldErrors(errors); + const first = policyListFields.find((key) => errors[key]); + if (first) { + fields.current[first]?.focus(); + return; + } + flight.current = true; + setSaving(true); + setError(undefined); + setSuccess(false); + const policy = { ...draft }; + for (const key of policyListFields) policy[key] = policyLines(lists[key]); + try { + const result = await client.putAlertTargetPolicy(policy); + if (mounted.current) { + markSaved(); + setSaved(result); + setDraft(result); + setLists(toLists(result)); + setSuccess(true); + } + } catch (reason) { + if (mounted.current) setError(reason); + } finally { + flight.current = false; + if (mounted.current) setSaving(false); + } + } + return ( +
+ + {demo && ( +

+ Sample workspace: changes are stored locally for this session. No server policy is + changed. Server CIDR and overlap validation is not simulated. +

+ )} +

+ This policy controls outbound Slack, webhook and Alertmanager delivery. Public destinations + are allowed by default. Private destinations require both permission and an allowed domain + or CIDR match. An allowed domain or CIDR match is also required to send Authorization + headers, even to public targets. +

+

+ Domains include their subdomains. Allow and deny lists must not overlap. Policy updates are + cached per server node; other nodes load policy on restart. +

+ {loading &&

Loading network policy…

} + {error !== undefined && ( +
+ + {forbidden + ? 'Administrator permission required' + : draft + ? 'Could not save network policy' + : 'Could not load network policy'} + +

+ {forbidden + ? 'Only administrators with full server access can read or update this policy.' + : error instanceof Error + ? error.message + : 'The request failed.'} +

+ {!draft && !forbidden && ( + + )} +
+ )} + {success && ( +

{demo ? 'Local sample policy saved.' : 'Network policy saved.'}

+ )} + {draft && lists && ( +
{ + event.preventDefault(); + void save(); + }} + > +
+ Alert target policy + +

+ An allowed domain or CIDR match is also required for private destinations. +

+ {policyListFields.map((key) => ( +
+ +