diff --git a/.changeset/query-insights-studio-port.md b/.changeset/query-insights-studio-port.md new file mode 100644 index 00000000..075f116c --- /dev/null +++ b/.changeset/query-insights-studio-port.md @@ -0,0 +1,5 @@ +--- +"@prisma/studio-core": minor +--- + +Add the host-provided Query Insights Studio view with live `prisma-log` stream ingestion, ppg-dev demo support, charted query metrics, sortable query rows, and AI recommendation details. diff --git a/Architecture/demo-compute-bundling.md b/Architecture/demo-compute-bundling.md index 6f1843c5..1ac79d11 100644 --- a/Architecture/demo-compute-bundling.md +++ b/Architecture/demo-compute-bundling.md @@ -35,7 +35,7 @@ That means when `build-compute.ts` bundles `demo/ppg-dev/server.ts` with Bun: - Bun sees `@prisma/dev`'s literal Bun manifest import - Bun emits hashed PGlite `.wasm`, `.data`, and extension archives next to the bundled server entrypoint -- `build-compute.ts` then copies the same runtime assets into `deploy/bundle/` with their canonical names like `pglite.wasm` and `pglite-seed.tar.gz` +- `build-compute.ts` then copies the same runtime assets into `deploy/bundle/` with their canonical names like `pglite.wasm`, `pglite.data`, `initdb.wasm`, and extension archives That extra copy is a Studio-side workaround for the current Compute boot path: the deployed `@prisma/dev` runtime still resolves stable filenames relative to diff --git a/Architecture/navigation-url-state.md b/Architecture/navigation-url-state.md index 5f43a21f..6a86daa9 100644 --- a/Architecture/navigation-url-state.md +++ b/Architecture/navigation-url-state.md @@ -8,7 +8,7 @@ Navigation state MUST be URL-driven and managed through `useNavigation` + Nuqs. This architecture governs: -- active Studio view (`table`, `schema`, `console`, `sql`, `stream`) +- active Studio view (`table`, `schema`, `console`, `sql`, `stream`, `query-insights`) - active schema/table/stream - active stream follow mode - active stream aggregation-panel visibility @@ -47,6 +47,8 @@ Only keys declared in [`ui/hooks/nuqs.ts`](../ui/hooks/nuqs.ts) are allowed: - `filter` - `sort` - `pin` +- `queryInsightsSort` +- `queryInsightsTable` - `pageIndex` - `pageSize` - `search` @@ -58,6 +60,8 @@ Notes: - `searchScope` is legacy URL state and is not used for table-name navigation filtering. - `pin` stores left-pinned data columns for the grid as a comma-separated list (for example `pin=id,bigint_col`). - `pin` order is authoritative and MUST be updated when users drag-reorder pinned columns. +- `queryInsightsSort` stores Query Insights sort state as `:`. +- `queryInsightsTable` stores the active Query Insights table-name filter. - `pageIndex` remains URL-backed for table navigation. - `pageSize` remains a supported hash key for compatibility, but table rendering now takes its authoritative rows-per-page preference from `studioUiCollection.tablePageSize` in [`Architecture/ui-state.md`](ui-state.md). - `streamFollow` stores the active stream follow mode (`paused`, `live`, or `tail`). @@ -82,11 +86,14 @@ Adding a new URL key requires updating `StateKey` in `nuqs.ts` first. - `streamFollow`: no global default in `useNavigation`; the active stream view MUST resolve an absent value to `tail` and materialize that into the hash - `aggregations`: no global default in `useNavigation`; the active stream view MUST treat an absent flag as closed and MUST NOT materialize that closed state into the hash - `streamAggregationRange`: no standalone default; the active stream view MUST clear it whenever `aggregations` is absent, and MUST materialize its default range only after the aggregation panel is opened +- `queryInsightsSort`: no global default in `useNavigation`; the Query Insights view resolves an absent or invalid value to `reads:desc` +- `queryInsightsTable`: no global default in `useNavigation`; absent means all observed tables When Studio is running without a database connection but with Streams enabled: - the resolved default `view` MUST become `"stream"` instead of `"table"` - stale database-oriented views such as `table`, `schema`, `console`, and `sql` MUST resolve back to the stream view instead of trying to render database-only UI against a disabled database session +- stale `query-insights` URLs MUST resolve back to the default view when the host did not provide a Query Insights transport When URL params are stale from a previous DB, invalid `schema`/`table` values MUST be resolved to valid current defaults. Shared table page size and infinite-scroll mode are not derived from URL defaults; they are restored through Studio UI state and then mirrored into query behavior by `usePagination`. diff --git a/Architecture/non-standard-ui.md b/Architecture/non-standard-ui.md index 62bc7a88..1af3f939 100644 --- a/Architecture/non-standard-ui.md +++ b/Architecture/non-standard-ui.md @@ -145,6 +145,26 @@ It deliberately excludes: - The storage breakdowns also need collapsible ledger-style accounting boxes whose headers surface the section totals when folded shut, plus faint shared-cap annotations that sit beside right-aligned byte values and one shared cap marker spanning both Routing and Exact cache rows, which is not a stock ShadCN pattern. - No stock ShadCN pattern covers that descriptor-driven observability layout, especially when the UI must distinguish logical bytes from physical storage signals, separate search coverage from historical run indexes, hide unconfigured routing rows, and keep the remaining cost caveats explicit instead of inventing unavailable totals. +### Query Insights Live Observability View + +- Canonical components: + - [`ui/studio/views/query-insights/QueryInsightsView.tsx`](../ui/studio/views/query-insights/QueryInsightsView.tsx) + - [`ui/studio/views/query-insights/QueryInsightsChart.tsx`](../ui/studio/views/query-insights/QueryInsightsChart.tsx) +- Closest standard ShadCN alternatives: + - `Card` + - `Table` + - `Sheet` +- Why it stays non-standard: + - Query Insights needs a bounded live SSE session, chart ticks that update independently from table pause state, row grouping by Prisma operation, pause-buffer flushing highlights, and previous/next navigation inside a detail sheet. + - No stock ShadCN block covers that observability-specific control model, so Studio composes the surface from standard primitives while keeping the stream, chart, and row-state behavior local to this view. +- Required internals: + - `Badge` + - `Button` + - `Card` + - `Select` + - `Sheet` + - `Table` + ## Standardization Candidates These are the current high-signal places where Studio is bypassing a plausible standard ShadCN component or composition pattern. diff --git a/Architecture/query-insights.md b/Architecture/query-insights.md new file mode 100644 index 00000000..45d0449b --- /dev/null +++ b/Architecture/query-insights.md @@ -0,0 +1,93 @@ +# Query Insights Architecture + +This document is normative for Studio's Query Insights view (`view=query-insights`). + +Query Insights is an optional host-provided observability surface. Studio core owns the UI, URL state, stream decoding, bounded in-browser session state, charts, table controls, and detail sheet. The host owns the backend stream, authorization, tenant or demo database lookup, and structured analysis endpoint. + +## Studio Boundary + +Studio receives Query Insights through `StudioProps.queryInsights`. + +The transport provides: + +- initial AI recommendation consent state +- an SSE `streamUrl` that reads a JSON `prisma-log` Prisma Streams stream +- a structured `analyze(input)` function +- an `enableAiRecommendations()` function +- optional UI event forwarding + +When the transport is absent, Studio MUST hide the sidebar item and command-palette action. A stale `#view=query-insights` URL MUST resolve back to the normal default view. + +## URL State + +Query Insights uses Studio's normal hash navigation state: + +- `view=query-insights` +- `queryInsightsSort=:` +- `queryInsightsTable=` + +All reads and writes MUST go through `useNavigation`. + +## Runtime State + +Query Insights rows are intentionally local React state rather than TanStack DB collections. + +This is a documented exception to the database-state architecture because Query Insights data is not table data, has no persistence contract, resets per stream session, and is bounded to 500 unique query patterns plus a 500-row paused buffer. Chart points are also local and capped to 100 points. + +The stream MUST only be opened while the Query Insights view is mounted. Unmounting the view closes the EventSource. + +## Stream Contract + +The canonical Query Insights data source is a Prisma Streams JSON stream named +`prisma-log`. Query execution records MUST be appended to that stream +continuously by the host while queries execute, regardless of whether the Query +Insights view is currently open. + +The Studio UI reads that stream through the normal Streams HTTP surface. On +mount, it first performs a non-live snapshot read from the same stream URL so +already-recorded queries populate the view. It then opens the live SSE URL from +the returned `Stream-Next-Offset` cursor so new appends continue streaming +without replay gaps. The live connection receives standard Streams SSE events: + +- `data`: a JSON array of `prisma-log` events +- `control`: stream cursor and up-to-date metadata, currently ignored by Query + Insights + +Each query event in the `data` batch uses `type: "query"` plus SQL, latency, +count, reads, rows returned, tables, optional Prisma metadata, optional +`groupKey`, optional `queryId`, and optional min/max latency. Studio derives +one-second chart buckets from each query event timestamp and aggregates repeated +query patterns in browser state. + +The client MAY still decode legacy `queries` and `chartTick` SSE event names for +compatibility, but new demo and host integrations MUST feed Query Insights from +`prisma-log`. + +## UI Contract + +The view uses standard ShadCN primitives for cards, buttons, badges, selects, table, and sheet. + +The live observability composition is Studio-specific: two Chart.js-backed metric cards feed from chart ticks, a bounded ShadCN table renders sorted and filtered query rows, and a ShadCN sheet renders details and recommendations. + +Selecting a row auto-pauses table updates. Closing a sheet that caused auto-pause resumes and flushes the paused buffer. + +## Query Visibility + +Studio's `Query` contract includes `meta.visibility`. + +Postgres adapter-generated introspection, table reads, mutations, and fallback lint helper queries MUST be marked `studio-system`. Raw SQL editor executions remain user-visible. BFF hosts should append `-- prisma:studio` to system queries before forwarding them to the database so backend Query Insights implementations can classify them consistently with `-- prisma:console`. System classification MUST NOT prevent successful query executions from being appended to `prisma-log`. + +## ppg-dev Demo + +The local `ppg-dev` demo hosts the Query Insights backend itself: + +- `/api/config` advertises the Query Insights transport URLs +- ppg-dev ensures the `prisma-log` stream exists on the configured Prisma + Streams server at startup +- `/api/query` appends the Studio system suffix to system queries, executes the database request, and appends each successful SQL execution to `prisma-log` with query visibility metadata +- the Query Insights UI reads from `/api/streams/v1/stream/prisma-log`, so it + uses the same same-origin Streams proxy as the Stream view +- `/api/query-insights/analyze` returns deterministic demo analysis +- `/api/query-insights/enable-ai` stores consent in demo memory + +This keeps the demo same-origin and exercises the Studio transport without depending on production control-plane services. diff --git a/Architecture/tanstack-db-performance.md b/Architecture/tanstack-db-performance.md index e9f353c4..8fad21cf 100644 --- a/Architecture/tanstack-db-performance.md +++ b/Architecture/tanstack-db-performance.md @@ -129,6 +129,7 @@ Any change to guard semantics MUST update tests for: Architecture-compliance tests MUST also cover: - allowed `createCollection(...)` boundaries in production UI code +- stream-event row collections created through Studio context and reused by query scope - `cleanupOnUnmount` paths avoiding shared TanStack DB reads/writes - per-cell components avoiding direct global hook reads that fan out across wide grids - grid-wide shared context-menu usage instead of per-cell context wrappers diff --git a/FEATURES.md b/FEATURES.md index 9fe1a3cb..ae601560 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -21,6 +21,13 @@ The same demo entrypoint can also run against external development infrastructur Studio can run without a database connection when a Streams server is configured, which makes it usable as a focused event-log and stream-search tool. In that mode the shell hides schema selection, table navigation, and database-only views, defaults into the stream view, and keeps all Streams browsing, search, aggregation, and live/tail behavior working through the normal `/api/streams` proxy. +## Query Insights + +Embedders can provide a Query Insights transport that adds a native Studio view at `view=query-insights`. +The view reads query events from a `prisma-log` Prisma Streams stream while active, replays the latest stream snapshot on mount, buckets event timestamps into latency and queries-per-second charts, groups Prisma operations when metadata is available, and keeps the query table sortable, filterable by table, and bounded in memory. +Selecting a row opens a Studio sheet with SQL, runtime stats, Prisma operation context, and structured recommendations after workspace-scoped AI consent. +The `ppg-dev` demo appends every successful SQL execution from `/api/query` into `prisma-log` continuously through the same Streams server used by the demo, with Studio system queries annotated for downstream classification. + ## Local Streams Development Override Studio's local development workflow can temporarily replace the published npm `@prisma/dev` package with the sibling source package from `../team-expansion/dev/server`, while also swapping its `@prisma/streams-local` dependency over to a built local Streams checkout. diff --git a/data/postgres-core/adapter.test.ts b/data/postgres-core/adapter.test.ts index 1be4420b..cc588f98 100644 --- a/data/postgres-core/adapter.test.ts +++ b/data/postgres-core/adapter.test.ts @@ -66,6 +66,9 @@ describe("postgres-core/adapter", () => { "not ilike", ], "query": { + "meta": { + "visibility": "studio-system", + }, "parameters": [ "", "nextval(%", @@ -485,7 +488,9 @@ describe("postgres-core/adapter", () => { expect(error).toBeNull(); expect(result?.rowCount).toBe(2); expect(result?.rows).toEqual([{ one: 1 }, { one: 2 }]); - expect(result?.query.sql).toBe("select 1 as one union all select 2 as one"); + expect(result?.query.sql).toBe( + "select 1 as one union all select 2 as one", + ); }); it("returns adapter errors for invalid SQL", async () => { diff --git a/data/postgres-core/adapter.ts b/data/postgres-core/adapter.ts index a962baaf..2ede2845 100644 --- a/data/postgres-core/adapter.ts +++ b/data/postgres-core/adapter.ts @@ -1,6 +1,5 @@ import { type Adapter, - type AdapterUpdateDetails, type AdapterDeleteResult, type AdapterError, type AdapterInsertResult, @@ -12,6 +11,7 @@ import { type AdapterSqlLintDetails, type AdapterSqlLintResult, type AdapterSqlSchemaResult, + type AdapterUpdateDetails, type AdapterUpdateManyResult, type AdapterUpdateResult, type Column, @@ -23,7 +23,12 @@ import { createFullTableSearchExecutionState, executeQueryWithFullTableSearchGuardrails, } from "../full-table-search"; -import { asQuery, type Query, QueryResult } from "../query"; +import { + asQuery, + type Query, + QueryResult, + withQueryVisibility, +} from "../query"; import { createSqlEditorSchemaFromIntrospection } from "../sql-editor-schema"; import type { Either } from "../type-utils"; import { POSTGRESQL_DATA_TYPES_TO_METADATA } from "./datatype"; @@ -46,6 +51,10 @@ import { export type PostgresAdapterRequirements = AdapterRequirements; +function markStudioSystemQuery(query: Query): Query { + return withQueryVisibility(query, "studio-system"); +} + export function createPostgresAdapter( requirements: PostgresAdapterRequirements, ): Adapter { @@ -61,7 +70,7 @@ export function createPostgresAdapter( options: Parameters>[1], ): Promise> { const queries = updates.map((update) => - getUpdateQuery(update, otherRequirements), + markStudioSystemQuery(getUpdateQuery(update, otherRequirements)), ); try { @@ -126,17 +135,19 @@ export function createPostgresAdapter( try { const tablesQuery = getTablesQuery(otherRequirements); const timezoneQuery = getTimezoneQuery(); + const systemTablesQuery = markStudioSystemQuery(tablesQuery); + const systemTimezoneQuery = markStudioSystemQuery(timezoneQuery); const [[tablesError, tables], [timezoneError, timezones]] = await Promise.all([ - executor.execute(tablesQuery, options), - executor.execute(timezoneQuery, options), + executor.execute(systemTablesQuery, options), + executor.execute(systemTimezoneQuery, options), ]); if (tablesError) { return createPostgresAdapterError({ error: tablesError, - query: tablesQuery, + query: systemTablesQuery, }); } @@ -146,7 +157,7 @@ export function createPostgresAdapter( return [ null, - createIntrospection({ query: tablesQuery, tables, timezone }), + createIntrospection({ query: systemTablesQuery, tables, timezone }), ]; } catch (error: unknown) { return createPostgresAdapterError({ error: error as Error }); @@ -173,7 +184,9 @@ export function createPostgresAdapter( options, ): Promise> { try { - const query = getSelectQuery(details, otherRequirements); + const query = markStudioSystemQuery( + getSelectQuery(details, otherRequirements), + ); const [error, results] = await executeQueryWithFullTableSearchGuardrails({ executor, @@ -268,7 +281,9 @@ export function createPostgresAdapter( options, ): Promise> { try { - const query = getInsertQuery(details, otherRequirements); + const query = markStudioSystemQuery( + getInsertQuery(details, otherRequirements), + ); const [error, rows] = await executor.execute(query, options); @@ -287,7 +302,9 @@ export function createPostgresAdapter( options, ): Promise> { try { - const query = getUpdateQuery(details, otherRequirements); + const query = markStudioSystemQuery( + getUpdateQuery(details, otherRequirements), + ); const [error, results] = await executor.execute(query, options); @@ -319,7 +336,9 @@ export function createPostgresAdapter( options, ): Promise> { try { - const query = getDeleteQuery(details, otherRequirements); + const query = markStudioSystemQuery( + getDeleteQuery(details, otherRequirements), + ); const [error] = await executor.execute(query, options); @@ -393,7 +412,10 @@ async function lintWithExplainFallback( const explainQuery = asQuery>( `EXPLAIN ${statement.statement}`, ); - const [error] = await executor.execute(explainQuery, options); + const [error] = await executor.execute( + markStudioSystemQuery(explainQuery), + options, + ); if (!error) { continue; diff --git a/data/query.ts b/data/query.ts index 569a9aa2..90d50888 100644 --- a/data/query.ts +++ b/data/query.ts @@ -98,6 +98,9 @@ class ImmediateValueTransformer extends OperationNodeTransformer { declare const queryType: unique symbol; export interface Query> { [queryType]?: T; + meta?: { + visibility?: "studio-system" | "user"; + }; parameters: readonly unknown[]; sql: string; transformations?: Partial>; @@ -111,6 +114,19 @@ export function asQuery(query: string | Query): Query { return query as never; } +export function withQueryVisibility( + query: Query, + visibility: NonNullable["visibility"], +): Query { + return { + ...query, + meta: { + ...query.meta, + visibility, + }, + }; +} + export type QueryResult = T extends Query ? R[] diff --git a/demo/ppg-dev/DemoShell.tsx b/demo/ppg-dev/DemoShell.tsx index e95a21f2..168cd74b 100644 --- a/demo/ppg-dev/DemoShell.tsx +++ b/demo/ppg-dev/DemoShell.tsx @@ -4,7 +4,8 @@ import { useEffect, useState } from "react"; import type { Adapter } from "../../data/adapter"; import type { StudioLlm, StudioLlmResponse } from "../../data/llm"; import { isStudioLlmResponse } from "../../data/llm"; -import { Studio } from "../../ui"; +import { Studio, type StudioQueryInsights } from "../../ui"; +import type { DemoConfig } from "./config"; function canUseBrowserFullscreen(): boolean { return ( @@ -73,11 +74,19 @@ export function DemoApp(props: { aiEnabled: boolean; bootId: string; hasDatabase: boolean; + queryInsights?: DemoConfig["queryInsights"]; seededAt?: string; streamsUrl?: string; }) { - const { adapter, aiEnabled, bootId, hasDatabase, seededAt, streamsUrl } = - props; + const { + adapter, + aiEnabled, + bootId, + hasDatabase, + queryInsights: queryInsightsConfig, + seededAt, + streamsUrl, + } = props; const llm: StudioLlm | undefined = aiEnabled ? async (request) => { const response = await fetch("/api/ai", { @@ -113,6 +122,48 @@ export function DemoApp(props: { } satisfies StudioLlmResponse; } : undefined; + const queryInsights: StudioQueryInsights | undefined = queryInsightsConfig + ? { + aiRecommendationsEnabled: queryInsightsConfig.aiRecommendationsEnabled, + async analyze(input) { + const response = await fetch(queryInsightsConfig.analyzeUrl, { + body: JSON.stringify(input), + headers: { + "content-type": "application/json", + }, + method: "POST", + }); + + if (!response.ok) { + return { + error: `Query analysis failed (${response.status} ${response.statusText})`, + result: null, + }; + } + + return (await response.json()) as Awaited< + ReturnType + >; + }, + async enableAiRecommendations() { + const response = await fetch(queryInsightsConfig.enableAiUrl, { + method: "POST", + }); + + if (!response.ok) { + throw new Error( + `Failed enabling Query Insights AI (${response.status} ${response.statusText})`, + ); + } + + return (await response.json()) as { ok: true }; + }, + onEvent(event) { + console.debug("[demo] query insights event", event); + }, + streamUrl: queryInsightsConfig.streamUrl, + } + : undefined; return (
diff --git a/demo/ppg-dev/build-compute.test.ts b/demo/ppg-dev/build-compute.test.ts index 226cc512..5732d9dd 100644 --- a/demo/ppg-dev/build-compute.test.ts +++ b/demo/ppg-dev/build-compute.test.ts @@ -1,5 +1,5 @@ import { spawn } from "node:child_process"; -import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import { mkdtemp, readdir, readFile, rm } from "node:fs/promises"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -139,140 +139,133 @@ afterAll(async () => { }); describe("build-compute", () => { - it( - "copies stable Prisma dev runtime assets next to the bundled server entrypoint", - async () => { - const bunVersion = await getBunVersion(); + it("copies stable Prisma dev runtime assets next to the bundled server entrypoint", async () => { + const bunVersion = await getBunVersion(); - if (!bunVersion) { - return; - } - - const outputDir = await mkdtemp( - join(tmpdir(), "studio-build-compute-output-"), - ); - tempDirs.add(outputDir); - - const build = await runProcess( - "bun", - ["demo/ppg-dev/build-compute.ts", outputDir], - { - cwd: process.cwd(), - env: { - STUDIO_DEMO_AI_ENABLED: "false", - }, - }, - ); - - expect(build.code).toBe(0); - expect(build.stderr).toBe(""); - - const rootEntries = await readdir(outputDir); - const bundleEntries = await readdir(join(outputDir, "bundle")); - - expect(rootEntries).toContain("bundle"); - expect(rootEntries).toContain("touch"); - expect(rootEntries.some((entry) => entry.endsWith(".tar.gz"))).toBe(false); - expect(rootEntries.some((entry) => entry.endsWith(".wasm"))).toBe(false); - expect(rootEntries.some((entry) => entry.endsWith(".data"))).toBe(false); - - expect(bundleEntries).toContain("server.bundle.js"); - expect(bundleEntries).toContain("initdb.wasm"); - expect(bundleEntries).toContain("pglite.data"); - expect(bundleEntries).toContain("pglite.wasm"); - expect(bundleEntries).toContain("pglite-seed.tar.gz"); - expect( - bundleEntries.some( - (entry) => entry.includes(".tar-") && entry.endsWith(".gz"), - ), - ).toBe(true); - - const touchEntries = await readdir(join(outputDir, "touch")); - const hashVendorEntries = await readdir( - join(outputDir, "touch", "hash_vendor"), - ); - const workerBundle = await readFile( - join(outputDir, "touch", "processor_worker.js"), - "utf8", - ); - - expect(touchEntries).toContain("processor_worker.js"); - expect(touchEntries).toContain("hash_vendor"); - expect(hashVendorEntries).toContain("LICENSE.hash-wasm"); - expect(hashVendorEntries).toContain("NOTICE.md"); - expect(hashVendorEntries).toContain("xxhash3.umd.min.cjs"); - expect(hashVendorEntries).toContain("xxhash32.umd.min.cjs"); - expect(hashVendorEntries).toContain("xxhash64.umd.min.cjs"); - expect(workerBundle).not.toContain('from "better-result"'); - expect(workerBundle).not.toContain('from "ajv"'); - - const serverBundle = await readFile( - join(outputDir, "bundle", "server.bundle.js"), - "utf8", - ); - expect(serverBundle).not.toContain( - "sourceMappingURL=data:application/json;base64", - ); + if (!bunVersion) { + return; + } - if (!supportsBundledPrismaDevBoot(bunVersion)) { - return; - } + const outputDir = await mkdtemp( + join(tmpdir(), "studio-build-compute-output-"), + ); + tempDirs.add(outputDir); - const port = await getAvailablePort(); - const serverProcess = spawn("bun", ["./bundle/server.bundle.js"], { - cwd: outputDir, + const build = await runProcess( + "bun", + ["demo/ppg-dev/build-compute.ts", outputDir], + { + cwd: process.cwd(), env: { - ...process.env, STUDIO_DEMO_AI_ENABLED: "false", - STUDIO_DEMO_PORT: String(port), }, - stdio: ["ignore", "pipe", "pipe"], - }); + }, + ); + + expect(build.code).toBe(0); + expect(build.stderr).toBe(""); + + const rootEntries = await readdir(outputDir); + const bundleEntries = await readdir(join(outputDir, "bundle")); + + expect(rootEntries).toContain("bundle"); + expect(rootEntries).toContain("touch"); + expect(rootEntries.some((entry) => entry.endsWith(".tar.gz"))).toBe(false); + expect(rootEntries.some((entry) => entry.endsWith(".wasm"))).toBe(false); + expect(rootEntries.some((entry) => entry.endsWith(".data"))).toBe(false); + + expect(bundleEntries).toContain("server.bundle.js"); + expect(bundleEntries).toContain("initdb.wasm"); + expect(bundleEntries).toContain("pglite.data"); + expect(bundleEntries).toContain("pglite.wasm"); + expect( + bundleEntries.some( + (entry) => entry.includes(".tar-") && entry.endsWith(".gz"), + ), + ).toBe(true); + + const touchEntries = await readdir(join(outputDir, "touch")); + const hashVendorEntries = await readdir( + join(outputDir, "touch", "hash_vendor"), + ); + const workerBundle = await readFile( + join(outputDir, "touch", "processor_worker.js"), + "utf8", + ); + + expect(touchEntries).toContain("processor_worker.js"); + expect(touchEntries).toContain("hash_vendor"); + expect(hashVendorEntries).toContain("LICENSE.hash-wasm"); + expect(hashVendorEntries).toContain("NOTICE.md"); + expect(hashVendorEntries).toContain("xxhash3.umd.min.cjs"); + expect(hashVendorEntries).toContain("xxhash32.umd.min.cjs"); + expect(hashVendorEntries).toContain("xxhash64.umd.min.cjs"); + expect(workerBundle).not.toContain('from "better-result"'); + expect(workerBundle).not.toContain('from "ajv"'); + + const serverBundle = await readFile( + join(outputDir, "bundle", "server.bundle.js"), + "utf8", + ); + expect(serverBundle).not.toContain( + "sourceMappingURL=data:application/json;base64", + ); + + if (!supportsBundledPrismaDevBoot(bunVersion)) { + return; + } + + const port = await getAvailablePort(); + const serverProcess = spawn("bun", ["./bundle/server.bundle.js"], { + cwd: outputDir, + env: { + ...process.env, + STUDIO_DEMO_AI_ENABLED: "false", + STUDIO_DEMO_PORT: String(port), + }, + stdio: ["ignore", "pipe", "pipe"], + }); - let stdout = ""; - let stderr = ""; + let stdout = ""; + let stderr = ""; - serverProcess.stdout.on("data", (chunk) => { - stdout += String(chunk); - }); - serverProcess.stderr.on("data", (chunk) => { - stderr += String(chunk); - }); + serverProcess.stdout.on("data", (chunk) => { + stdout += String(chunk); + }); + serverProcess.stderr.on("data", (chunk) => { + stderr += String(chunk); + }); - try { - const response = await waitForHttp( - `http://127.0.0.1:${port}/api/config`, - ); - const payload = (await response.json()) as { - bootId?: unknown; - streams?: { - url?: unknown; - }; + try { + const response = await waitForHttp(`http://127.0.0.1:${port}/api/config`); + const payload = (await response.json()) as { + bootId?: unknown; + streams?: { + url?: unknown; }; + }; - expect(typeof payload.bootId).toBe("string"); - expect(typeof payload.streams?.url).toBe("string"); - expect(payload.streams?.url).toBe("/api/streams"); - - const faviconResponse = await fetch( - `http://127.0.0.1:${port}/favicon.ico`, - ); - expect(faviconResponse.status).toBe(204); - } finally { - serverProcess.kill("SIGTERM"); - if ( - serverProcess.exitCode === null && - serverProcess.signalCode === null - ) { - await new Promise((resolve) => { - serverProcess.once("close", () => resolve()); - }); - } + expect(typeof payload.bootId).toBe("string"); + expect(typeof payload.streams?.url).toBe("string"); + expect(payload.streams?.url).toBe("/api/streams"); + + const faviconResponse = await fetch( + `http://127.0.0.1:${port}/favicon.ico`, + ); + expect(faviconResponse.status).toBe(204); + } finally { + serverProcess.kill("SIGTERM"); + if ( + serverProcess.exitCode === null && + serverProcess.signalCode === null + ) { + await new Promise((resolve) => { + serverProcess.once("close", () => resolve()); + }); } + } - expect(normalizeBundledServerStderr(stderr)).toBe(""); - expect(stdout).toContain(`http://localhost:${port}`); - }, - 120_000, - ); + expect(normalizeBundledServerStderr(stderr)).toBe(""); + expect(stdout).toContain(`http://localhost:${port}`); + }, 120_000); }); diff --git a/demo/ppg-dev/client.tsx b/demo/ppg-dev/client.tsx index 0d3955d6..5180d4e5 100644 --- a/demo/ppg-dev/client.tsx +++ b/demo/ppg-dev/client.tsx @@ -51,6 +51,7 @@ async function bootstrap(): Promise { hasDatabase={config.database.enabled} seededAt={config.seededAt} aiEnabled={config.ai?.enabled === true} + queryInsights={config.queryInsights} streamsUrl={config.streams?.url} />, ); diff --git a/demo/ppg-dev/config.ts b/demo/ppg-dev/config.ts index c268972a..3f2a1267 100644 --- a/demo/ppg-dev/config.ts +++ b/demo/ppg-dev/config.ts @@ -6,6 +6,12 @@ export interface DemoConfig { database: { enabled: boolean; }; + queryInsights?: { + aiRecommendationsEnabled: boolean; + analyzeUrl: string; + enableAiUrl: string; + streamUrl: string; + }; seededAt?: string; streams?: { url: string; @@ -50,10 +56,18 @@ export function buildDemoConfig(args: { aiEnabled: boolean; bootId: string; databaseEnabled: boolean; + queryInsights?: DemoConfig["queryInsights"]; seededAt?: string | null; streamsUrl?: string; }): DemoConfig { - const { aiEnabled, bootId, databaseEnabled, seededAt, streamsUrl } = args; + const { + aiEnabled, + bootId, + databaseEnabled, + queryInsights, + seededAt, + streamsUrl, + } = args; return { ai: { @@ -63,6 +77,11 @@ export function buildDemoConfig(args: { database: { enabled: databaseEnabled, }, + ...(queryInsights + ? { + queryInsights, + } + : {}), ...(seededAt ? { seededAt, diff --git a/demo/ppg-dev/query-insights.test.ts b/demo/ppg-dev/query-insights.test.ts new file mode 100644 index 00000000..e533084b --- /dev/null +++ b/demo/ppg-dev/query-insights.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; + +import { + analyzeDemoQueryInsight, + appendStudioSystemQuerySuffix, + createQueryInsightsLogEvent, + getQueryInsightsQueryVisibility, + isStudioSystemQuery, + parseSqlTableNames, + QUERY_INSIGHTS_LOG_STREAM_NAME, + STUDIO_SYSTEM_QUERY_SUFFIX, +} from "./query-insights"; + +describe("ppg-dev Query Insights helpers", () => { + it("tags and detects Studio system queries", () => { + const tagged = appendStudioSystemQuerySuffix({ + meta: { visibility: "studio-system" }, + parameters: [], + sql: "select * from pg_catalog.pg_class", + }); + + expect(tagged.sql).toBe( + `select * from pg_catalog.pg_class ${STUDIO_SYSTEM_QUERY_SUFFIX}`, + ); + expect(isStudioSystemQuery(tagged)).toBe(true); + expect(getQueryInsightsQueryVisibility(tagged)).toBe("studio-system"); + expect( + isStudioSystemQuery({ + meta: { visibility: "user" }, + parameters: [], + sql: "select * from users", + }), + ).toBe(false); + }); + + it("extracts complete table names from common SQL statements", () => { + expect( + parseSqlTableNames( + 'select * from organizations join "team_members" on true', + ), + ).toEqual(["organizations", "team_members"]); + expect( + parseSqlTableNames("update public.incidents set severity = 2"), + ).toEqual(["public.incidents"]); + expect(parseSqlTableNames("select * from pg_catalog.pg_class")).toEqual([]); + }); + + it("builds prisma-log query events for user and Studio system queries", () => { + const event = createQueryInsightsLogEvent({ + durationMs: 12.5, + query: { + parameters: [], + sql: "select * from organizations limit 3", + }, + rows: [{ id: 1 }, { id: 2 }], + ts: 1_700_000_000_000, + }); + + expect(QUERY_INSIGHTS_LOG_STREAM_NAME).toBe("prisma-log"); + expect(event).toMatchObject({ + count: 1, + durationMs: 12.5, + rowsReturned: 2, + sql: "select * from organizations limit 3", + tables: ["organizations"], + ts: 1_700_000_000_000, + type: "query", + visibility: "user", + }); + const systemEvent = createQueryInsightsLogEvent({ + durationMs: 1, + query: appendStudioSystemQuerySuffix({ + meta: { visibility: "studio-system" }, + parameters: [], + sql: "select * from pg_catalog.pg_class", + }), + rows: [], + ts: 1_700_000_000_001, + }); + + expect(systemEvent).toMatchObject({ + sql: "select * from pg_catalog.pg_class", + type: "query", + visibility: "studio-system", + }); + expect( + createQueryInsightsLogEvent({ + durationMs: 1, + query: { + parameters: [], + sql: " ", + }, + rows: [], + }), + ).toBeNull(); + }); + + it("returns deterministic demo analysis for broad select queries", () => { + const result = analyzeDemoQueryInsight({ + queryStats: { + count: 1, + duration: 125, + reads: 0, + rowsReturned: 150, + }, + rawQuery: "select * from users", + }); + + expect(result.isOptimal).toBe(false); + expect(result.recommendations.join(" ")).toContain("LIMIT"); + expect(result.improvedSql).toContain("LIMIT 50"); + }); +}); diff --git a/demo/ppg-dev/query-insights.ts b/demo/ppg-dev/query-insights.ts new file mode 100644 index 00000000..24f8a5df --- /dev/null +++ b/demo/ppg-dev/query-insights.ts @@ -0,0 +1,242 @@ +import type { Query } from "../../data/query"; +import type { + QueryInsightsAnalysisResult, + QueryInsightsAnalyzeInput, + QueryInsightsQueryVisibility, + QueryInsightsStreamQuery, +} from "../../ui/studio/views/query-insights/types"; + +export const STUDIO_SYSTEM_QUERY_SUFFIX = "-- prisma:studio"; +export const QUERY_INSIGHTS_LOG_STREAM_NAME = "prisma-log"; +const CONSOLE_SYSTEM_QUERY_SUFFIX = "-- prisma:console"; + +export interface QueryInsightsLogEvent extends QueryInsightsStreamQuery { + type: "query"; + visibility: QueryInsightsQueryVisibility; +} + +function normalizeSql(sql: string): string { + return sql.replace(/\s+/g, " ").trim(); +} + +function stripKnownSystemSuffixes(sql: string): string { + return sql + .replaceAll(STUDIO_SYSTEM_QUERY_SUFFIX, "") + .replaceAll(CONSOLE_SYSTEM_QUERY_SUFFIX, "") + .trim(); +} + +export function isStudioSystemQuery(query: Query): boolean { + return ( + query.meta?.visibility === "studio-system" || + query.sql.includes(STUDIO_SYSTEM_QUERY_SUFFIX) || + query.sql.includes(CONSOLE_SYSTEM_QUERY_SUFFIX) + ); +} + +export function getQueryInsightsQueryVisibility( + query: Query, +): QueryInsightsQueryVisibility { + return isStudioSystemQuery(query) ? "studio-system" : "user"; +} + +export function appendStudioSystemQuerySuffix(query: Query): Query { + if (query.meta?.visibility !== "studio-system") { + return query; + } + + if (query.sql.includes(STUDIO_SYSTEM_QUERY_SUFFIX)) { + return query; + } + + return { + ...query, + sql: `${query.sql.trim()} ${STUDIO_SYSTEM_QUERY_SUFFIX}`, + }; +} + +export function parseSqlTableNames(sql: string): string[] { + const tables = new Set(); + const patterns = [ + /\bfrom\s+("?[\w.]+"?)/gi, + /\bjoin\s+("?[\w.]+"?)/gi, + /\bupdate\s+("?[\w.]+"?)/gi, + /\binsert\s+into\s+("?[\w.]+"?)/gi, + /\bdelete\s+from\s+("?[\w.]+"?)/gi, + ]; + + for (const pattern of patterns) { + for (const match of sql.matchAll(pattern)) { + const table = match[1]?.replaceAll('"', "").trim(); + + if (table && !table.startsWith("pg_catalog.")) { + tables.add(table); + } + } + } + + return Array.from(tables).sort(); +} + +export function createQueryInsightsLogEvent(args: { + durationMs: number; + query: Query; + rows: unknown; + ts?: number; +}): QueryInsightsLogEvent | null { + const cleanedSql = stripKnownSystemSuffixes(args.query.sql); + const normalizedSql = normalizeSql(cleanedSql); + + if (normalizedSql.length === 0) { + return null; + } + + const durationMs = Math.max(0, args.durationMs); + + return { + count: 1, + durationMs, + groupKey: null, + maxDurationMs: durationMs, + minDurationMs: durationMs, + prismaQueryInfo: null, + queryId: null, + reads: 0, + rowsReturned: Array.isArray(args.rows) ? args.rows.length : 0, + sql: cleanedSql, + tables: parseSqlTableNames(cleanedSql), + ts: args.ts ?? Date.now(), + type: "query", + visibility: getQueryInsightsQueryVisibility(args.query), + }; +} + +function createStreamsApiUrl( + streamsServerUrl: string, + streamName: string, +): string { + const url = new URL( + `v1/stream/${encodeURIComponent(streamName)}`, + `${streamsServerUrl.replace(/\/+$/, "")}/`, + ); + + return url.toString(); +} + +async function getResponseText(response: Response): Promise { + try { + return await response.text(); + } catch { + return response.statusText; + } +} + +export async function ensureQueryInsightsLogStream(args: { + fetchFn?: typeof fetch; + streamsServerUrl: string; +}): Promise { + const fetchFn = args.fetchFn ?? fetch; + const response = await fetchFn( + createStreamsApiUrl(args.streamsServerUrl, QUERY_INSIGHTS_LOG_STREAM_NAME), + { + body: "[]", + headers: { + "content-type": "application/json", + }, + method: "PUT", + }, + ); + + if (!response.ok) { + throw new Error( + `Failed ensuring ${QUERY_INSIGHTS_LOG_STREAM_NAME} stream (${response.status} ${await getResponseText(response)})`, + ); + } +} + +export async function appendQueryInsightsLogEvent(args: { + event: QueryInsightsLogEvent; + fetchFn?: typeof fetch; + streamsServerUrl: string; +}): Promise { + const fetchFn = args.fetchFn ?? fetch; + const url = createStreamsApiUrl( + args.streamsServerUrl, + QUERY_INSIGHTS_LOG_STREAM_NAME, + ); + const append = () => + fetchFn(url, { + body: JSON.stringify(args.event), + headers: { + "content-type": "application/json", + "stream-timestamp": new Date(args.event.ts).toISOString(), + }, + method: "POST", + }); + let response = await append(); + + if (response.status === 404) { + await ensureQueryInsightsLogStream({ + fetchFn, + streamsServerUrl: args.streamsServerUrl, + }); + response = await append(); + } + + if (!response.ok) { + throw new Error( + `Failed appending ${QUERY_INSIGHTS_LOG_STREAM_NAME} event (${response.status} ${await getResponseText(response)})`, + ); + } +} + +export function analyzeDemoQueryInsight( + input: QueryInsightsAnalyzeInput, +): QueryInsightsAnalysisResult { + const stats = input.queryStats; + const isSlow = (stats?.duration ?? 0) >= 100; + const returnsManyRows = (stats?.rowsReturned ?? 0) >= 100; + const selectWithoutLimit = + /^\s*select\b/i.test(input.rawQuery) && !/\blimit\b/i.test(input.rawQuery); + const recommendations: string[] = []; + const issuesFound: string[] = []; + + if (isSlow) { + issuesFound.push( + "Average latency is high for an interactive Studio query.", + ); + recommendations.push( + "Inspect predicates and add an index for the most selective filter columns.", + ); + } + + if (returnsManyRows || selectWithoutLimit) { + issuesFound.push("The query can return more rows than an operator needs."); + recommendations.push( + "Add a LIMIT clause or narrow the selected columns to reduce transferred rows.", + ); + } + + if (issuesFound.length === 0) { + return { + analysisMarkdown: + "# Query looks healthy\n\nNo obvious issue was detected from the available demo runtime statistics.", + confidenceScore: 0.72, + isOptimal: true, + issuesFound: [], + recommendations: [], + }; + } + + return { + analysisMarkdown: + "# Query can be tightened\n\n## What to do\nUse the recommendations below to reduce latency or returned rows.\n\n## Why this matters\nThe ppg-dev demo captures query timing from the Studio BFF path and flags high-latency or broad result-set patterns.", + confidenceScore: 0.68, + improvedSql: selectWithoutLimit + ? `${input.rawQuery.replace(/;+\s*$/, "")}\nLIMIT 50;` + : undefined, + isOptimal: false, + issuesFound, + recommendations, + }; +} diff --git a/demo/ppg-dev/server.ts b/demo/ppg-dev/server.ts index 251020f3..88a3d912 100644 --- a/demo/ppg-dev/server.ts +++ b/demo/ppg-dev/server.ts @@ -12,9 +12,18 @@ import { type StudioLlmRequest, type StudioLlmResponse, } from "../../data/llm"; +import type { Query } from "../../data/query"; import pkg from "../../package.json" with { type: "json" }; import { AnthropicOutputLimitError, runAnthropicLlmRequest } from "./anthropic"; import { buildDemoConfig, resolveDemoAiEnabled } from "./config"; +import { + analyzeDemoQueryInsight, + appendQueryInsightsLogEvent, + appendStudioSystemQuerySuffix, + createQueryInsightsLogEvent, + ensureQueryInsightsLogStream, + QUERY_INSIGHTS_LOG_STREAM_NAME, +} from "./query-insights"; import { type DemoRuntime, startDemoRuntime } from "./runtime"; import { formatDemoRuntimeUsage, @@ -63,7 +72,7 @@ type BuiltAsset = { contentType: string; }; -type PostgresExecutor = DemoRuntime["postgresExecutor"]; +type PostgresExecutor = NonNullable; // When the server is bundled by build-compute.ts, the virtual:prebuilt-assets // module is resolved at bundle time and provides the pre-built client JS, CSS, @@ -96,6 +105,8 @@ const AI_ENABLED = resolveDemoAiEnabled({ }); const BOOT_ID = crypto.randomUUID(); const STREAMS_PROXY_BASE_PATH = "/api/streams"; +const QUERY_INSIGHTS_ANALYZE_PATH = "/api/query-insights/analyze"; +const QUERY_INSIGHTS_ENABLE_AI_PATH = "/api/query-insights/enable-ai"; const CACHE_CONTROL_STATIC = isProduction ? "public, max-age=31536000, immutable" : "no-cache, no-store, must-revalidate"; @@ -147,6 +158,7 @@ let appStyles = ""; let builtAssets = new Map(); let assetVersion = 0; let assetError: string | null = null; +let queryInsightsAiRecommendationsEnabled = false; let isBuilding = false; let isBuildQueued = false; @@ -272,6 +284,10 @@ async function main(): Promise { seededAt = runtime.seededAt; streamsServerUrl = runtime.streamsServerUrl; + if (streamsServerUrl && postgresExecutor) { + await ensureQueryInsightsLogStream({ streamsServerUrl }); + } + if (prebuiltAssets) { appScript = prebuiltAssets.appScript; appStyles = prebuiltAssets.appStyles; @@ -344,6 +360,15 @@ async function handleRequest(request: Request): Promise { aiEnabled: AI_ENABLED, bootId: BOOT_ID, databaseEnabled: postgresExecutor != null, + queryInsights: + postgresExecutor != null && streamsServerUrl + ? { + aiRecommendationsEnabled: queryInsightsAiRecommendationsEnabled, + analyzeUrl: QUERY_INSIGHTS_ANALYZE_PATH, + enableAiUrl: QUERY_INSIGHTS_ENABLE_AI_PATH, + streamUrl: createQueryInsightsBrowserStreamUrl(), + } + : undefined, seededAt, streamsUrl: streamsServerUrl ? STREAMS_PROXY_BASE_PATH : undefined, }), @@ -358,6 +383,14 @@ async function handleRequest(request: Request): Promise { return await handleAiRequest(request); } + if (url.pathname === QUERY_INSIGHTS_ANALYZE_PATH) { + return await handleQueryInsightsAnalyzeRequest(request); + } + + if (url.pathname === QUERY_INSIGHTS_ENABLE_AI_PATH) { + return handleQueryInsightsEnableAiRequest(request); + } + if ( url.pathname === STREAMS_PROXY_BASE_PATH || url.pathname.startsWith(`${STREAMS_PROXY_BASE_PATH}/`) @@ -426,6 +459,19 @@ async function handleRequest(request: Request): Promise { return new Response("Not Found", { status: 404 }); } +function createQueryInsightsBrowserStreamUrl(): string { + const searchParams = new URLSearchParams({ + format: "json", + live: "sse", + offset: "-1", + timeout: "30s", + }); + + return `${STREAMS_PROXY_BASE_PATH}/v1/stream/${encodeURIComponent( + QUERY_INSIGHTS_LOG_STREAM_NAME, + )}?${searchParams.toString()}`; +} + async function handleBffQueryRequest(request: Request): Promise { if (request.method === "OPTIONS") { return new Response(null, { @@ -461,7 +507,7 @@ async function handleBffQueryRequest(request: Request): Promise { try { if (payload.procedure === "query") { const [error, result] = await runSerializedQuery(() => - executor.execute(payload.query), + executeBffQuery(executor, payload.query), ); return Response.json([error ? serializeError(error) : null, result]); @@ -475,7 +521,7 @@ async function handleBffQueryRequest(request: Request): Promise { } const [firstError, firstResult] = await runSerializedQuery(() => - executor.execute(firstQuery), + executeBffQuery(executor, firstQuery), ); if (firstError) { @@ -483,7 +529,7 @@ async function handleBffQueryRequest(request: Request): Promise { } const [secondError, secondResult] = await runSerializedQuery(() => - executor.execute(secondQuery), + executeBffQuery(executor, secondQuery), ); if (secondError) { @@ -514,7 +560,7 @@ async function handleBffQueryRequest(request: Request): Promise { > = (queries, options) => executor.executeTransaction!(queries, options); const [error, result] = await runSerializedQuery(() => - executeTransaction(payload.queries), + executeBffTransaction(executeTransaction, payload.queries), ); return Response.json([error ? serializeError(error) : null, result]); @@ -544,6 +590,162 @@ async function handleBffQueryRequest(request: Request): Promise { } } +async function executeBffQuery( + executor: PostgresExecutor, + query: Query, +): ReturnType { + const preparedQuery = appendStudioSystemQuerySuffix(query); + const startedAt = performance.now(); + const result = await executor.execute(preparedQuery); + const durationMs = Math.max(0, performance.now() - startedAt); + const [error, rows] = result; + + if (!error) { + await appendQueryInsightsLog({ + durationMs, + query: preparedQuery, + rows, + }); + } + + return result; +} + +async function executeBffTransaction( + executeTransaction: NonNullable, + queries: readonly Query[], +): ReturnType> { + const preparedQueries = queries.map(appendStudioSystemQuerySuffix); + const startedAt = performance.now(); + const result = await executeTransaction(preparedQueries); + const durationMs = Math.max(0, performance.now() - startedAt); + const [error, results] = result; + + if (!error) { + const perQueryDurationMs = + preparedQueries.length > 0 ? durationMs / preparedQueries.length : 0; + + for (const [index, query] of preparedQueries.entries()) { + await appendQueryInsightsLog({ + durationMs: perQueryDurationMs, + query, + rows: results[index], + }); + } + } + + return result; +} + +async function appendQueryInsightsLog(args: { + durationMs: number; + query: Query; + rows: unknown; +}): Promise { + const activeStreamsServerUrl = streamsServerUrl; + + if (!activeStreamsServerUrl) { + return; + } + + const event = createQueryInsightsLogEvent(args); + + if (!event) { + return; + } + + try { + await appendQueryInsightsLogEvent({ + event, + streamsServerUrl: activeStreamsServerUrl, + }); + } catch (error) { + console.warn( + `[demo] failed to append ${QUERY_INSIGHTS_LOG_STREAM_NAME} event: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +async function handleQueryInsightsAnalyzeRequest( + request: Request, +): Promise { + if (request.method === "OPTIONS") { + return new Response(null, { + headers: { + Allow: "POST,OPTIONS", + }, + status: 204, + }); + } + + if (request.method !== "POST") { + return new Response("Method Not Allowed", { + headers: { + Allow: "POST,OPTIONS", + }, + status: 405, + }); + } + + let payload: unknown; + + try { + payload = await request.json(); + } catch { + return Response.json( + { + error: "Invalid JSON payload", + result: null, + }, + { status: 400 }, + ); + } + + if ( + typeof payload !== "object" || + payload === null || + typeof (payload as { rawQuery?: unknown }).rawQuery !== "string" + ) { + return Response.json( + { + error: "Invalid request body", + result: null, + }, + { status: 400 }, + ); + } + + return Response.json({ + error: null, + result: analyzeDemoQueryInsight( + payload as Parameters[0], + ), + }); +} + +function handleQueryInsightsEnableAiRequest(request: Request): Response { + if (request.method === "OPTIONS") { + return new Response(null, { + headers: { + Allow: "POST,OPTIONS", + }, + status: 204, + }); + } + + if (request.method !== "POST") { + return new Response("Method Not Allowed", { + headers: { + Allow: "POST,OPTIONS", + }, + status: 405, + }); + } + + queryInsightsAiRecommendationsEnabled = true; + return Response.json({ ok: true }); +} + async function handleAiRequest(request: Request): Promise { if (request.method === "OPTIONS") { return new Response(null, { diff --git a/ui/components/ui/select.test.tsx b/ui/components/ui/select.test.tsx new file mode 100644 index 00000000..bf8e4d6d --- /dev/null +++ b/ui/components/ui/select.test.tsx @@ -0,0 +1,55 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "./select"; + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +describe("SelectContent", () => { + it("uses the Studio sans font inside the portal", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + , + ); + + await Promise.resolve(); + }); + + expect(document.body.innerHTML).toContain("All tables"); + expect( + document.body.querySelector('[role="listbox"]')?.className, + ).toContain("font-sans"); + + act(() => { + root.unmount(); + }); + container.remove(); + }); +}); diff --git a/ui/components/ui/select.tsx b/ui/components/ui/select.tsx index 2407da27..85bc9a32 100644 --- a/ui/components/ui/select.tsx +++ b/ui/components/ui/select.tsx @@ -80,7 +80,7 @@ const SelectContent = React.forwardRef< , - VariantProps {} + VariantProps { + container?: HTMLElement | null; + showCloseButton?: boolean; +} const SheetContent = React.forwardRef< React.ElementRef, SheetContentProps ->(({ side = "right", className, children, ...props }, ref) => ( - - - - - - Close - - {children} - - -)); +>( + ( + { + side = "right", + className, + children, + container, + showCloseButton = true, + ...props + }, + ref, + ) => ( + +
+ + + {showCloseButton ? ( + + + Close + + ) : null} + {children} + +
+
+ ), +); SheetContent.displayName = SheetPrimitive.Content.displayName; const SheetHeader = ({ diff --git a/ui/hooks/nuqs.ts b/ui/hooks/nuqs.ts index 663c3ebe..36e40399 100644 --- a/ui/hooks/nuqs.ts +++ b/ui/hooks/nuqs.ts @@ -21,6 +21,8 @@ export type StateKey = | "pageIndex" | "pageSize" | "pin" + | "queryInsightsSort" + | "queryInsightsTable" | "streamAggregationRange" | "streamFollow" | "streamRoutingKey" diff --git a/ui/hooks/use-introspection.test.tsx b/ui/hooks/use-introspection.test.tsx index 4f5832df..ee75286e 100644 --- a/ui/hooks/use-introspection.test.tsx +++ b/ui/hooks/use-introspection.test.tsx @@ -13,12 +13,15 @@ import type { import type { StudioEventBase } from "../studio/Studio"; import { useIntrospection } from "./use-introspection"; -const useStudioMock = vi.fn< - () => { - adapter: Adapter; - onEvent: (event: StudioEventBase) => void; - } ->(); +const { useStudioMock } = vi.hoisted(() => ({ + useStudioMock: vi.fn< + () => { + adapter: Adapter; + hasDatabase: boolean; + onEvent: (event: StudioEventBase) => void; + } + >(), +})); vi.mock("../studio/context", () => ({ useStudio: useStudioMock, @@ -116,6 +119,7 @@ function renderHarness(args: { useStudioMock.mockReturnValue({ adapter, + hasDatabase: true, onEvent, }); diff --git a/ui/hooks/use-navigation.tsx b/ui/hooks/use-navigation.tsx index 493a2af8..e7bbcbe8 100644 --- a/ui/hooks/use-navigation.tsx +++ b/ui/hooks/use-navigation.tsx @@ -110,8 +110,13 @@ function buildNavigationTableNames(introspection: AdapterIntrospectResult) { * implement a specialized hook instead. */ function useNavigationInternal() { - const { adapter, hasDatabase, navigationTableNamesCollection, streamsUrl } = - useStudio(); + const { + adapter, + hasDatabase, + navigationTableNamesCollection, + queryInsights, + streamsUrl, + } = useStudio(); const { data: introspection, isFetching } = useIntrospection(); const { schemas } = introspection; @@ -172,6 +177,10 @@ function useNavigationInternal() { defaultValue: defaults.pageSize, }); const [pinParam, setPinParam] = useQueryState("pin"); + const [queryInsightsSortParam, setQueryInsightsSortParam] = + useQueryState("queryInsightsSort"); + const [queryInsightsTableParam, setQueryInsightsTableParam] = + useQueryState("queryInsightsTable"); const [schemaParam, setSchemaParam] = useQueryState("schema", { defaultValue: defaults.schema, }); @@ -216,7 +225,11 @@ function useNavigationInternal() { ? activeTables[resolvedTableParam] : undefined; const resolvedViewParam = - !hasDatabase && typeof streamsUrl === "string" ? "stream" : viewParam; + !hasDatabase && typeof streamsUrl === "string" + ? "stream" + : viewParam === "query-insights" && (!hasDatabase || !queryInsights) + ? defaults.view + : viewParam; const metadata = useMemo( () => ({ @@ -235,6 +248,8 @@ function useNavigationInternal() { pageIndexParam, pageSizeParam, pinParam, + queryInsightsSortParam, + queryInsightsTableParam, schemaParam, searchParam, searchScopeParam, @@ -250,6 +265,10 @@ function useNavigationInternal() { setPageIndexParam: setPageIndexParam as NuqsSetNullableValue, setPageSizeParam: setPageSizeParam as NuqsSetNullableValue, setPinParam: setPinParam as NuqsSetNullableValue, + setQueryInsightsSortParam: + setQueryInsightsSortParam as NuqsSetNullableValue, + setQueryInsightsTableParam: + setQueryInsightsTableParam as NuqsSetNullableValue, setSchemaParam: setSchemaParam as NuqsSetNullableValue, setSearchParam: setSearchParam as NuqsSetNullableValue, setSearchScopeParam: setSearchScopeParam as NuqsSetNullableValue, diff --git a/ui/hooks/use-stream-events.test.tsx b/ui/hooks/use-stream-events.test.tsx index a87ecdcb..a21fe545 100644 --- a/ui/hooks/use-stream-events.test.tsx +++ b/ui/hooks/use-stream-events.test.tsx @@ -560,6 +560,50 @@ describe("useStreamEvents", () => { harness.cleanup(); }); + it("falls back to ts epoch fields for event timestamps", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify([ + { + ts: 1_700_000_000_000, + value: { + id: "query-1", + }, + }, + ]), + { + headers: { + "content-type": "application/json", + }, + }, + ), + ); + const harness = renderHarness({ + pageCount: 1, + pageSize: 1, + stream: { + createdAt: "2026-03-24T14:42:38.890Z", + epoch: 0, + expiresAt: null, + name: "prisma-log", + nextOffset: "1", + sealedThrough: "-1", + uploadedThrough: "-1", + }, + visibleEventCount: 1n, + }); + + await waitFor(() => harness.getLatestState()?.events.length === 1); + + expect(harness.getLatestState()?.events[0]).toEqual( + expect.objectContaining({ + exactTimestamp: "2023-11-14T22:13:20.000Z", + }), + ); + + harness.cleanup(); + }); + it("keeps the last resolved event window visible while a larger tail window is fetching", async () => { let resolveSecondFetch: ((response: Response) => void) | undefined; const fetchSpy = vi diff --git a/ui/hooks/use-stream-events.ts b/ui/hooks/use-stream-events.ts index dade8ada..3d091461 100644 --- a/ui/hooks/use-stream-events.ts +++ b/ui/hooks/use-stream-events.ts @@ -349,6 +349,7 @@ function extractExactTimestamp( headers?.timestamp, value.timestamp, value.time, + value.ts, value.createdAt, value.created_at, value.occurredAt, diff --git a/ui/index.tsx b/ui/index.tsx index 353a8ae6..26dcd908 100644 --- a/ui/index.tsx +++ b/ui/index.tsx @@ -1,4 +1,8 @@ -export { Studio, type StudioProps } from "./studio/Studio"; +export { + Studio, + type StudioProps, + type StudioQueryInsights, +} from "./studio/Studio"; export { type CustomTheme, type ThemeVariables, diff --git a/ui/studio/CommandPalette.test.tsx b/ui/studio/CommandPalette.test.tsx index 21cb7b6c..16adfe54 100644 --- a/ui/studio/CommandPalette.test.tsx +++ b/ui/studio/CommandPalette.test.tsx @@ -20,7 +20,7 @@ interface NavigationMockValue { schemaParam: string; setSchemaParam: () => Promise; setTableParam: () => Promise; - viewParam: "table" | "schema" | "console" | "sql"; + viewParam: "console" | "query-insights" | "schema" | "sql" | "table"; } interface IntrospectionMockValue { @@ -36,6 +36,8 @@ const toggleNavigationMock = vi.fn(); const setThemeModeMock = vi.fn(); let isNavigationOpen = true; let isDarkMode = false; +let hasDatabase = true; +let queryInsightsTransport: unknown; let themeMode: "dark" | "light" | "system" = "system"; const uiStateStore = new Map(); const uiStateListeners = new Map void>>(); @@ -150,8 +152,10 @@ vi.mock("../hooks/use-ui-state", async () => { vi.mock("./context", () => ({ useStudio: () => ({ + hasDatabase, isDarkMode, isNavigationOpen, + queryInsights: queryInsightsTransport, setThemeMode: setThemeModeMock, themeMode, toggleNavigation: toggleNavigationMock, @@ -290,6 +294,8 @@ describe("Studio command palette", () => { }); isNavigationOpen = true; isDarkMode = false; + hasDatabase = true; + queryInsightsTransport = undefined; themeMode = "system"; setThemeModeMock.mockReset(); toggleNavigationMock.mockReset(); @@ -303,6 +309,8 @@ describe("Studio command palette", () => { window.location.hash = ""; isNavigationOpen = true; isDarkMode = false; + hasDatabase = true; + queryInsightsTransport = undefined; themeMode = "system"; HTMLElement.prototype.scrollIntoView = originalScrollIntoView; uiStateStore.clear(); @@ -352,6 +360,7 @@ describe("Studio command palette", () => { expect(document.body.textContent).not.toContain("incidents"); expect(document.body.textContent).toContain("Visualizer"); expect(document.body.textContent).toContain("Console"); + expect(document.body.textContent).not.toContain("Query Insights"); expect(document.body.textContent).toContain("SQL"); expect(document.body.textContent).toContain("Studio theme"); expect(document.body.textContent).toContain("Light"); @@ -364,6 +373,41 @@ describe("Studio command palette", () => { container.remove(); }); + it("shows Query Insights in navigation commands only when configured", () => { + queryInsightsTransport = {}; + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + + act(() => { + keyDown("k", { metaKey: true }); + }); + + const item = getCommandItemByText("Query Insights"); + + expect(item).toBeDefined(); + + act(() => { + item?.dispatchEvent( + new MouseEvent("click", { + bubbles: true, + cancelable: true, + }), + ); + }); + + expect(window.location.hash).toBe("#viewParam=query-insights"); + + act(() => { + root.unmount(); + }); + container.remove(); + }); + it("renders the popup inside the Studio scope and keeps it centered", () => { const studioRoot = document.createElement("div"); studioRoot.className = "ps"; diff --git a/ui/studio/CommandPalette.tsx b/ui/studio/CommandPalette.tsx index 671fc58b..e9cf3098 100644 --- a/ui/studio/CommandPalette.tsx +++ b/ui/studio/CommandPalette.tsx @@ -1,6 +1,7 @@ import type { LucideIcon } from "lucide-react"; import { BetweenVerticalStart, + ChartNoAxesColumnIncreasing, Database, FileCode2, GalleryVerticalEnd, @@ -164,7 +165,9 @@ function AppearanceCommandItem(props: { value={value} className={cn( "justify-between gap-3", - disabled ? "text-muted-foreground/55" : "text-foreground hover:bg-secondary/85", + disabled + ? "text-muted-foreground/55" + : "text-foreground hover:bg-secondary/85", )} > @@ -334,7 +337,9 @@ export function StudioCommandPaletteProvider(props: PropsWithChildren) { function StudioCommandPalette() { const { isDarkMode, + hasDatabase, isNavigationOpen, + queryInsights, setThemeMode, themeMode, toggleNavigation, @@ -508,6 +513,23 @@ function StudioCommandPalette() { }, section: "views", }, + ...(queryInsights && hasDatabase !== false + ? [ + { + disabled: false, + icon: ChartNoAxesColumnIncreasing, + id: "view:query-insights", + keywords: ["query insights", "queries", "latency", "qps"], + label: "Query Insights", + onSelect: () => { + window.location.hash = createUrl({ + viewParam: "query-insights", + }); + }, + section: "views" as const, + }, + ] + : []), { disabled: false, icon: FileCode2, @@ -528,7 +550,7 @@ function StudioCommandPalette() { query, }), ); - }, [createUrl, query]); + }, [createUrl, hasDatabase, query, queryInsights]); useEffect(() => { if (!isOpen) { setQuery(""); diff --git a/ui/studio/Navigation.test.tsx b/ui/studio/Navigation.test.tsx index ef86d614..d0f215e3 100644 --- a/ui/studio/Navigation.test.tsx +++ b/ui/studio/Navigation.test.tsx @@ -17,7 +17,13 @@ interface NavigationMockValue { setSchemaParam: () => Promise; setTableParam: () => Promise; streamParam: string | null; - viewParam: "table" | "schema" | "console" | "sql" | "stream"; + viewParam: + | "console" + | "query-insights" + | "schema" + | "sql" + | "stream" + | "table"; } interface IntrospectionMockValue { @@ -57,6 +63,7 @@ interface StudioMockValue { hasDatabase: boolean; isDarkMode: boolean; navigationWidth: number; + queryInsights?: unknown; setNavigationWidth: (width: number) => void; } @@ -427,6 +434,56 @@ describe("Navigation", () => { container.remove(); }); + it("shows Query Insights navigation only when the transport is configured", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + + expect(container.textContent).not.toContain("Query Insights"); + + useStudioMock.mockImplementation(() => ({ + hasDatabase: true, + isDarkMode, + navigationWidth: 192, + queryInsights: {}, + setNavigationWidth: setNavigationWidthMock, + })); + + act(() => { + root.render(); + }); + + const link = Array.from(container.querySelectorAll("a")).find( + (item) => item.textContent?.trim() === "Query Insights", + ); + + expect(link).toBeDefined(); + expect(link?.getAttribute("href")).toBe("#viewParam=query-insights"); + + useStudioMock.mockImplementation(() => ({ + hasDatabase: false, + isDarkMode, + navigationWidth: 192, + queryInsights: {}, + setNavigationWidth: setNavigationWidthMock, + })); + + act(() => { + root.render(); + }); + + expect(container.textContent).not.toContain("Query Insights"); + + act(() => { + root.unmount(); + }); + container.remove(); + }); + it("hides schema and table navigation when the session has no database", () => { useStudioMock.mockImplementation(() => ({ hasDatabase: false, diff --git a/ui/studio/Navigation.tsx b/ui/studio/Navigation.tsx index 8c048906..6aa66f37 100644 --- a/ui/studio/Navigation.tsx +++ b/ui/studio/Navigation.tsx @@ -41,8 +41,13 @@ type NavigationProps = { export function Navigation({ className }: NavigationProps) { const { metadata, createUrl, streamParam, viewParam, schemaParam } = useNavigation(); - const { hasDatabase, isDarkMode, navigationWidth, setNavigationWidth } = - useStudio(); + const { + hasDatabase, + isDarkMode, + navigationWidth, + queryInsights, + setNavigationWidth, + } = useStudio(); const { isFetching, activeTable } = metadata; const { errorState, hasResolvedIntrospection, isRefetching, refetch } = useIntrospection(); @@ -526,6 +531,20 @@ export function Navigation({ className }: NavigationProps) { Console + {queryInsights && hasDatabase ? ( + + + Query Insights + + + ) : null} ({ ConsoleView: () =>
Console view
, })); +vi.mock("./views/query-insights/QueryInsightsView", () => ({ + QueryInsightsView: () =>
Query Insights view
, +})); + vi.mock("./views/schema/SchemaView", () => ({ SchemaView: () =>
Schema view
, })); @@ -252,6 +257,49 @@ describe("Studio", () => { container.remove(); }); + it("renders the query insights view when the transport-backed view is active", () => { + useStudioMock.mockReturnValue({ + hasDatabase: true, + isNavigationOpen: true, + queryInsights: {}, + streamsUrl: "/api/streams", + }); + useNavigationMock.mockReturnValue({ + metadata: { + activeTable: undefined, + }, + viewParam: "query-insights", + }); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + expect(container.textContent).toContain("Query Insights view"); + + act(() => { + root.unmount(); + }); + container.remove(); + }); + it("renders a database-unavailable placeholder for database views when the session has no database", () => { useStudioMock.mockReturnValue({ hasDatabase: false, diff --git a/ui/studio/Studio.tsx b/ui/studio/Studio.tsx index 6f7e2fe3..5a152a41 100644 --- a/ui/studio/Studio.tsx +++ b/ui/studio/Studio.tsx @@ -14,6 +14,12 @@ import { IntrospectionStatusNotice } from "./IntrospectionStatusNotice"; import { Navigation } from "./Navigation"; import { StudioHeader } from "./StudioHeader"; import { ConsoleView } from "./views/console/ConsoleView"; +import { QueryInsightsView } from "./views/query-insights/QueryInsightsView"; +import type { + QueryInsightsAnalyzeInput, + QueryInsightsAnalyzeResponse, + QueryInsightsUiEvent, +} from "./views/query-insights/types"; import { SchemaView } from "./views/schema/SchemaView"; import { SqlView } from "./views/sql/SqlView"; import { StreamView } from "./views/stream/StreamView"; @@ -68,6 +74,7 @@ export interface StudioProps { hasDatabase?: boolean; llm?: StudioLlm; onEvent?: (error: StudioEvent) => void; + queryInsights?: StudioQueryInsights; streamsUrl?: string; /** * Custom theme configuration or CSS string from shadcn @@ -76,6 +83,16 @@ export interface StudioProps { theme?: CustomTheme | string; } +export interface StudioQueryInsights { + aiRecommendationsEnabled: boolean; + analyze( + input: QueryInsightsAnalyzeInput, + ): Promise; + enableAiRecommendations(): Promise<{ ok: true }>; + onEvent?: (event: QueryInsightsUiEvent) => void; + streamUrl: string; +} + /** * Main Studio component that provides database visualization and management */ @@ -85,6 +102,7 @@ export function Studio(props: StudioProps) { hasDatabase = true, llm, onEvent, + queryInsights, streamsUrl, theme, } = props; @@ -100,6 +118,7 @@ export function Studio(props: StudioProps) { hasDatabase={hasDatabase} llm={llm} onEvent={onEvent} + queryInsights={queryInsights} streamsUrl={streamsUrl} theme={theme} > @@ -111,6 +130,7 @@ export function Studio(props: StudioProps) { const views: Record JSX.Element | null> = { schema: SchemaView, table: ActiveTableView, + "query-insights": QueryInsightsView, stream: StreamView, console: ConsoleView, sql: SqlView, diff --git a/ui/studio/context.tsx b/ui/studio/context.tsx index bc43bec9..720b70a9 100644 --- a/ui/studio/context.tsx +++ b/ui/studio/context.tsx @@ -35,7 +35,12 @@ import { createUrl, NavigationContextProvider } from "../hooks/use-navigation"; import { CustomTheme, useTheme } from "../hooks/use-theme"; import shortUUID from "../lib/short-uuid"; import { NuqsHashAdapter } from "./NuqsHashAdapter"; -import { StudioEvent, StudioEventBase, StudioOperationEvent } from "./Studio"; +import { + StudioEvent, + StudioEventBase, + StudioOperationEvent, + type StudioQueryInsights, +} from "./Studio"; import { instrumentTanStackCollectionMutations } from "./tanstack-db-mutation-guard"; declare const VERSION_INJECTED_AT_BUILD_TIME: string; @@ -255,6 +260,7 @@ interface StudioContextValue { adapter: Adapter; hasDatabase: boolean; llm?: StudioLlm; + queryInsights?: StudioQueryInsights; streamsUrl?: string; hasAiFilter: boolean; hasAiSql: boolean; @@ -298,6 +304,7 @@ export type StudioContextProviderProps = PropsWithChildren<{ hasDatabase?: boolean; llm?: StudioLlm; onEvent?: (event: StudioEvent) => void; + queryInsights?: StudioQueryInsights; streamsUrl?: string; theme?: CustomTheme | string; }>; @@ -309,6 +316,7 @@ export function StudioContextProvider(props: StudioContextProviderProps) { adapter, hasDatabase = true, llm, + queryInsights, streamsUrl, theme, } = props; @@ -823,6 +831,7 @@ export function StudioContextProvider(props: StudioContextProviderProps) { adapter, hasDatabase, llm, + queryInsights, streamsUrl, hasAiFilter, hasAiSql, diff --git a/ui/studio/tanstack-db-performance-architecture.test.ts b/ui/studio/tanstack-db-performance-architecture.test.ts index 9fa828df..5280a2f9 100644 --- a/ui/studio/tanstack-db-performance-architecture.test.ts +++ b/ui/studio/tanstack-db-performance-architecture.test.ts @@ -54,6 +54,7 @@ describe("TanStack DB architecture compliance", () => { expect(filesWithCreateCollection).toEqual( [ "ui/hooks/use-active-table-rows-collection.ts", + "ui/hooks/use-stream-events.ts", "ui/hooks/use-ui-state.ts", "ui/studio/context.tsx", ].sort(), diff --git a/ui/studio/views/query-insights/QueryInsightsChart.tsx b/ui/studio/views/query-insights/QueryInsightsChart.tsx new file mode 100644 index 00000000..9e3669ca --- /dev/null +++ b/ui/studio/views/query-insights/QueryInsightsChart.tsx @@ -0,0 +1,254 @@ +import { useEffect, useMemo, useRef } from "react"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/ui/components/ui/card"; + +import type { QueryInsightsChartPoint } from "./types"; + +type ChartKind = "latency" | "qps"; + +function formatMetric( + value: number, + kind: ChartKind, +): { + unit: string; + value: string; +} { + if (kind === "qps") { + return { + unit: "queries/sec", + value: value.toFixed(1), + }; + } + + if (value >= 1_000) { + return { + unit: "seconds", + value: (value / 1_000).toFixed(2), + }; + } + + if (value >= 1) { + return { + unit: "milliseconds", + value: value.toFixed(0), + }; + } + + return { + unit: "milliseconds", + value: value.toFixed(2), + }; +} + +function getCssVariable(name: string, fallback: string): string { + if (typeof window === "undefined") { + return fallback; + } + + const value = window + .getComputedStyle(document.documentElement) + .getPropertyValue(name) + .trim(); + + return value || fallback; +} + +function getElementFontFamily(element: HTMLElement): string | undefined { + if (typeof window === "undefined") { + return undefined; + } + + return window.getComputedStyle(element).fontFamily || undefined; +} + +export function QueryInsightsChart(props: { + data: QueryInsightsChartPoint[]; + kind: ChartKind; + loading?: boolean; + pollingIntervalMs?: number; + title: string; +}) { + const { + data, + kind, + loading = false, + pollingIntervalMs = 1_000, + title, + } = props; + const canvasRef = useRef(null); + const chartRef = useRef<{ destroy(): void } | null>(null); + const values = useMemo(() => { + if (kind === "qps") { + return data.map((point) => + Number((point.queryCount / (pollingIntervalMs / 1_000)).toFixed(2)), + ); + } + + return data.map((point) => point.avgDurationMs); + }, [data, kind, pollingIntervalMs]); + const headlineValue = useMemo(() => { + if (kind === "qps") { + return values.at(-1) ?? 0; + } + + const totalQueries = data.reduce( + (total, point) => total + point.queryCount, + 0, + ); + + if (totalQueries === 0) { + return 0; + } + + return ( + data.reduce( + (total, point) => total + point.avgDurationMs * point.queryCount, + 0, + ) / totalQueries + ); + }, [data, kind, values]); + const headline = formatMetric(headlineValue, kind); + + useEffect(() => { + const canvas = canvasRef.current; + + if (!canvas || loading) { + return; + } + + const context = canvas.getContext("2d"); + + if (!context) { + return; + } + + let isCanceled = false; + + void import("chart.js/auto") + .then(({ default: Chart }) => { + if (isCanceled) { + return; + } + + chartRef.current?.destroy(); + + const labels = data.map((point) => + new Date(point.ts).toISOString().slice(11, 19), + ); + const primary = getCssVariable( + "--primary", + "oklch(0.64 0.1423 268.56)", + ); + const muted = getCssVariable( + "--muted-foreground", + "oklch(0.552 0.016 285.938)", + ); + const border = getCssVariable("--border", "oklch(0.92 0.004 286.32)"); + const fontFamily = getElementFontFamily(canvas); + + chartRef.current = new Chart(context, { + data: { + datasets: [ + { + ...(kind === "latency" + ? { + barPercentage: 0.7, + categoryPercentage: 0.75, + maxBarThickness: 48, + } + : { + pointHitRadius: 8, + pointHoverRadius: 4, + }), + backgroundColor: kind === "latency" ? primary : "transparent", + borderColor: primary, + borderWidth: 2, + data: values, + fill: false, + pointRadius: kind === "qps" ? 2 : 0, + tension: 0.2, + }, + ], + labels, + }, + options: { + animation: false, + font: { + family: fontFamily, + size: 11, + }, + maintainAspectRatio: false, + plugins: { + legend: { display: false }, + tooltip: { + callbacks: { + label(context) { + const value = Number(context.parsed.y ?? 0); + return kind === "qps" + ? `${value.toFixed(1)} QPS` + : `${value.toFixed(2)}ms`; + }, + }, + }, + }, + scales: { + x: { + display: false, + grid: { display: false }, + }, + y: { + beginAtZero: true, + border: { display: false }, + grid: { color: border }, + ticks: { + color: muted, + maxTicksLimit: 3, + }, + }, + }, + }, + type: kind === "latency" ? "bar" : "line", + }); + }) + .catch(() => undefined); + + return () => { + isCanceled = true; + chartRef.current?.destroy(); + chartRef.current = null; + }; + }, [data, kind, loading, values]); + + return ( + + +
+ + {title} + + {headline.unit} +
+
+ {headline.value} +
+
+ + {loading ? ( +
+ ) : ( + + )} + + + ); +} diff --git a/ui/studio/views/query-insights/QueryInsightsView.test.tsx b/ui/studio/views/query-insights/QueryInsightsView.test.tsx new file mode 100644 index 00000000..c67e75b7 --- /dev/null +++ b/ui/studio/views/query-insights/QueryInsightsView.test.tsx @@ -0,0 +1,197 @@ +import { act, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { QueryInsightsView } from "./QueryInsightsView"; +import type { QueryInsightsQuery } from "./types"; + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const { + analyzeMock, + pauseMock, + queryInsightsMock, + queryRowsState, + resumeMock, + setQueryInsightsSortParamMock, + setQueryInsightsTableParamMock, +} = vi.hoisted(() => ({ + ...(() => { + const analyzeMock = vi.fn(); + const enableAiRecommendationsMock = vi.fn(); + const queryInsightsMock = { + aiRecommendationsEnabled: true, + analyze: analyzeMock, + enableAiRecommendations: enableAiRecommendationsMock, + onEvent: vi.fn(), + streamUrl: "/api/query-insights", + }; + + return { + analyzeMock, + enableAiRecommendationsMock, + pauseMock: vi.fn(), + queryInsightsMock, + queryRowsState: { + queries: [] as QueryInsightsQuery[], + }, + resumeMock: vi.fn(), + setQueryInsightsSortParamMock: vi.fn(), + setQueryInsightsTableParamMock: vi.fn(), + }; + })(), +})); + +vi.mock("../../context", () => ({ + useStudio: () => ({ + hasDatabase: true, + isNavigationOpen: true, + queryInsights: queryInsightsMock, + toggleNavigation: vi.fn(), + }), +})); + +vi.mock("../../StudioHeader", () => ({ + StudioHeader: ({ + children, + endContent, + }: { + children?: ReactNode; + endContent?: ReactNode; + }) => ( +
+ {children} + {endContent} +
+ ), +})); + +vi.mock("@/ui/hooks/use-navigation", () => ({ + useNavigation: () => ({ + queryInsightsSortParam: null, + queryInsightsTableParam: null, + setQueryInsightsSortParam: setQueryInsightsSortParamMock, + setQueryInsightsTableParam: setQueryInsightsTableParamMock, + }), +})); + +vi.mock("./use-query-insights-stream", () => ({ + useQueryInsightsStream: () => ({ status: "open" }), +})); + +vi.mock("./use-query-insights-rows", () => ({ + useQueryInsightsRows: () => ({ + flushedIds: new Set(), + ingestQueries: vi.fn(), + isAtLimit: false, + isPaused: false, + pause: pauseMock, + pauseBufferSize: 0, + queries: queryRowsState.queries, + recentlyAddedIds: new Set(), + resume: resumeMock, + }), +})); + +function createQuery(overrides: Partial = {}) { + return { + count: 281, + duration: 0, + groupKey: null, + id: "query-1", + lastSeen: 1_700_000_000_000, + maxDurationMs: 0, + minDurationMs: 0, + prismaQueryInfo: null, + query: + "WITH state_mapping AS ( SELECT CASE WHEN state = 'active' AND wait_event_type IS NULL THEN 'active' WHEN state = 'active' AND wait_event_type IS NOT NULL THEN 'waiting' END AS state FROM pg_stat_activity ) SELECT * FROM state_mapping", + queryId: null, + reads: 3, + rowsReturned: 42, + tables: ["state_mapping"], + ...overrides, + } satisfies QueryInsightsQuery; +} + +describe("QueryInsightsView", () => { + beforeEach(() => { + vi.useFakeTimers(); + queryRowsState.queries = [createQuery()]; + analyzeMock.mockResolvedValue({ + error: null, + result: { + analysisMarkdown: + "## Problem\nThe query scans more rows than needed.\n\n## Why it matters\nExtra reads can slow down interactive workflows.", + improvedSql: "CREATE INDEX state_mapping_idx ON state_mapping (state);", + isOptimal: false, + issuesFound: ["Sequential scan"], + recommendations: ["Adding an index could reduce reads by 80%."], + }, + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + document.body.innerHTML = ""; + }); + + it("opens the original right-side AI drawer for a selected query", async () => { + const container = document.createElement("div"); + container.className = "ps"; + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + + const tableFrame = container.querySelector( + '[data-testid="query-insights-table"]', + )?.parentElement; + + expect(tableFrame?.className).toContain("border-transparent"); + expect(tableFrame?.className).toContain("rounded-sm"); + expect(tableFrame?.className).toContain("after:border-border"); + + const queryRow = Array.from( + container.querySelectorAll('[role="button"]'), + ).find((element) => element.textContent?.includes("WITH state_mapping")); + + expect(queryRow).not.toBeUndefined(); + + act(() => { + queryRow?.click(); + }); + + const dialog = document.body.querySelector('[role="dialog"]'); + + expect(dialog?.className).toContain("right-2"); + expect(dialog?.className).toContain("rounded-xl"); + expect(container.contains(dialog)).toBe(true); + expect(document.body.textContent).toContain( + "This SQL query reads from state_mapping.", + ); + expect(document.body.textContent).toContain("Show full query"); + expect(document.body.textContent).toContain("Analyzing query..."); + expect(pauseMock).toHaveBeenCalled(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(350); + }); + + expect(document.body.textContent).toContain("Recommendations"); + expect(document.body.textContent).not.toContain("## Problem"); + expect(document.body.textContent).toContain( + "Extra reads can slow down interactive workflows.", + ); + expect(document.body.textContent).toContain( + "Adding an index could reduce reads by 80%.", + ); + expect( + document.body.querySelector(".text-primary")?.textContent, + ).toBe("80%"); + }); +}); diff --git a/ui/studio/views/query-insights/QueryInsightsView.tsx b/ui/studio/views/query-insights/QueryInsightsView.tsx new file mode 100644 index 00000000..df3ce04d --- /dev/null +++ b/ui/studio/views/query-insights/QueryInsightsView.tsx @@ -0,0 +1,1514 @@ +import { + ChevronLeft, + ChevronRight, + Copy, + DatabaseZap, + Loader2, + Pause, + Play, + SearchX, + X, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { Badge } from "@/ui/components/ui/badge"; +import { Button } from "@/ui/components/ui/button"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/ui/components/ui/select"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/ui/components/ui/sheet"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/ui/components/ui/table"; +import { useNavigation } from "@/ui/hooks/use-navigation"; +import { cn } from "@/ui/lib/utils"; + +import { useStudio } from "../../context"; +import { StudioHeader } from "../../StudioHeader"; +import { QueryInsightsChart } from "./QueryInsightsChart"; +import { + buildQueryInsightsDisplayRows, + filterQueryInsightsByTable, + getAvailableQueryInsightTables, +} from "./rows"; +import { + QUERY_INSIGHTS_CHART_BUFFER_LIMIT, + QUERY_INSIGHTS_DEFAULT_SORT, + QUERY_INSIGHTS_MAX_QUERIES, + type QueryInsightsAnalysisResult, + type QueryInsightsAnalyzeInput, + type QueryInsightsChartPoint, + type QueryInsightsDisplayRow, + type QueryInsightsGroup, + type QueryInsightsQuery, + type QueryInsightsSortDirection, + type QueryInsightsSortField, + type QueryInsightsSortState, +} from "./types"; +import { useQueryInsightsRows } from "./use-query-insights-rows"; +import { useQueryInsightsStream } from "./use-query-insights-stream"; + +const SORT_OPTIONS: Array<{ label: string; value: string }> = [ + { label: "Reads, high to low", value: "reads:desc" }, + { label: "Reads, low to high", value: "reads:asc" }, + { label: "Latency, high to low", value: "latency:desc" }, + { label: "Latency, low to high", value: "latency:asc" }, + { label: "Executions, high to low", value: "executions:desc" }, + { label: "Executions, low to high", value: "executions:asc" }, + { label: "Last seen, newest", value: "lastSeen:desc" }, + { label: "Last seen, oldest", value: "lastSeen:asc" }, +]; +const ALL_TABLES_VALUE = "__all__"; + +function parseSortParam( + value: string | null | undefined, +): QueryInsightsSortState { + if (!value) { + return QUERY_INSIGHTS_DEFAULT_SORT; + } + + const [field, direction] = value.split(":") as [ + QueryInsightsSortField | undefined, + QueryInsightsSortDirection | undefined, + ]; + + if ( + (field === "latency" || + field === "reads" || + field === "executions" || + field === "lastSeen") && + (direction === "asc" || direction === "desc") + ) { + return { direction, field }; + } + + return QUERY_INSIGHTS_DEFAULT_SORT; +} + +function serializeSort(sort: QueryInsightsSortState): string { + return `${sort.field}:${sort.direction}`; +} + +function formatNumber(value: number): string { + return new Intl.NumberFormat("en-US", { + maximumFractionDigits: 1, + notation: value >= 10_000 ? "compact" : "standard", + }).format(value); +} + +function formatLatency(ms: number): string { + if (ms < 1) { + return "< 1ms"; + } + + if (ms >= 1_000) { + return `${(ms / 1_000).toFixed(2)}s`; + } + + return `${ms.toFixed(0)}ms`; +} + +function formatTime(ts: number): string { + return new Date(ts).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + +function mergeChartPoints( + existing: QueryInsightsChartPoint, + incoming: QueryInsightsChartPoint, +): QueryInsightsChartPoint { + const queryCount = existing.queryCount + incoming.queryCount; + + return { + avgDurationMs: + queryCount > 0 + ? (existing.avgDurationMs * existing.queryCount + + incoming.avgDurationMs * incoming.queryCount) / + queryCount + : 0, + queryCount, + ts: existing.ts, + }; +} + +function formatTableList(tables: string[]): string { + if (tables.length === 0) { + return "unknown tables"; + } + + if (tables.length === 1) { + return tables[0] ?? "unknown table"; + } + + return `${tables.slice(0, -1).join(", ")} and ${tables.at(-1)}`; +} + +function getRowKey(row: QueryInsightsDisplayRow): string { + return row.type === "group" + ? `group:${row.group.groupKey}` + : `query:${row.query.id}`; +} + +function getRowLabel(row: QueryInsightsDisplayRow): string { + if (row.type === "group") { + const { prismaQueryInfo } = row.group; + return `${prismaQueryInfo.model ? `${prismaQueryInfo.model}.` : ""}${prismaQueryInfo.action}()`; + } + + const { prismaQueryInfo } = row.query; + + if (prismaQueryInfo && !prismaQueryInfo.isRaw) { + return `${prismaQueryInfo.model ? `${prismaQueryInfo.model}.` : ""}${prismaQueryInfo.action}()`; + } + + return row.query.query; +} + +function rowMatchesKey( + row: QueryInsightsDisplayRow, + key: string | null, +): boolean { + return key !== null && getRowKey(row) === key; +} + +function getRowMetrics(row: QueryInsightsDisplayRow) { + if (row.type === "group") { + return { + count: row.group.totalCount, + duration: row.group.avgDuration, + lastSeen: row.group.lastSeen, + reads: row.group.totalReads, + rowsReturned: row.group.totalRows, + tables: row.group.tables, + }; + } + + return { + count: row.query.count, + duration: row.query.duration, + lastSeen: row.query.lastSeen, + reads: row.query.reads, + rowsReturned: row.query.rowsReturned, + tables: row.query.tables, + }; +} + +function PrismaOperationCode(props: { + info: NonNullable; +}) { + const { info } = props; + + return ( + + {info.model ? `${info.model}.` : ""} + {info.action} + {info.payload ? `(${JSON.stringify(info.payload)})` : "()"} + + ); +} + +function QueryLabel(props: { row: QueryInsightsDisplayRow }) { + const { row } = props; + + if (row.type === "group") { + return ( +
+ + + {row.group.children.length} SQL statements + +
+ ); + } + + if (row.query.prismaQueryInfo && !row.query.prismaQueryInfo.isRaw) { + return ( +
+ + + {row.query.query} + +
+ ); + } + + return ( + {row.query.query} + ); +} + +function QueryInsightsTable(props: { + displayRows: QueryInsightsDisplayRow[]; + flushedIds: Set; + isAtLimit: boolean; + isPaused: boolean; + onSelectRow: (row: QueryInsightsDisplayRow) => void; + pauseBufferSize: number; + recentlyAddedIds: Set; + selectedRowKey: string | null; + sort: QueryInsightsSortState; +}) { + const { + displayRows, + flushedIds, + isAtLimit, + isPaused, + onSelectRow, + pauseBufferSize, + recentlyAddedIds, + selectedRowKey, + sort, + } = props; + + return ( +
+ {isAtLimit ? ( +
+ Showing the latest {formatNumber(QUERY_INSIGHTS_MAX_QUERIES)} unique + query patterns. +
+ ) : null} + {isPaused && pauseBufferSize > 0 ? ( +
+ Recorded {formatNumber(pauseBufferSize)} recent{" "} + {pauseBufferSize === 1 ? "query" : "queries"} while paused. +
+ ) : null} +
+ + + Latency + Query + + Executions + + + Reads + + + Last Seen + + + + + {displayRows.map((row) => { + const key = getRowKey(row); + const metrics = getRowMetrics(row); + const isSelected = key === selectedRowKey; + const queryIds = + row.type === "group" + ? row.group.children.map((query) => query.id) + : [row.query.id]; + const isFlushed = queryIds.some((id) => flushedIds.has(id)); + const isNew = queryIds.some((id) => recentlyAddedIds.has(id)); + + return ( + onSelectRow(row)} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") { + return; + } + + event.preventDefault(); + onSelectRow(row); + }} + role="button" + tabIndex={0} + > + + = 500 + ? "destructive" + : metrics.duration >= 100 + ? "secondary" + : "success" + } + > + {formatLatency(metrics.duration)} + + + + + + + {formatNumber(metrics.count)} + + + {metrics.reads > 0 ? formatNumber(metrics.reads) : "-"} + + + {formatTime(metrics.lastSeen)} + + + ); + })} + +
+ + ); +} + +function CopyButton(props: { + className?: string; + label: string; + onCopied?: () => void; + text: string; +}) { + const { className, label, onCopied, text } = props; + + return ( + + ); +} + +function EmbeddedCodeBlock(props: { + children: string; + language: "sql" | "text" | "ts"; + lineClamp?: number; + onCopied?: () => void; +}) { + const { children, language, lineClamp, onCopied } = props; + const clampStyle = + lineClamp != null + ? ({ + WebkitBoxOrient: "vertical", + WebkitLineClamp: lineClamp, + display: "-webkit-box", + } as const) + : undefined; + + return ( +
+ +
+        {children}
+      
+
+ ); +} + +function buildAnalysisInput( + selectedRow: QueryInsightsDisplayRow, +): QueryInsightsAnalyzeInput { + const target = + selectedRow.type === "group" + ? selectedRow.group.children[0] + : selectedRow.query; + + return { + explainPlan: null, + groupChildren: + selectedRow.type === "group" + ? selectedRow.group.children.map((child) => ({ + queryStats: { + count: child.count, + duration: child.duration, + reads: child.reads, + rowsReturned: child.rowsReturned, + }, + rawQuery: child.query, + })) + : null, + prismaQueryInfo: target?.prismaQueryInfo + ? JSON.stringify(target.prismaQueryInfo) + : null, + queryStats: target + ? { + count: target.count, + duration: target.duration, + reads: target.reads, + rowsReturned: target.rowsReturned, + } + : null, + rawQuery: target?.query ?? "", + }; +} + +function useQueryInsightsAnalysis(selectedRow: QueryInsightsDisplayRow | null) { + const { queryInsights } = useStudio(); + const [isAiEnabled, setIsAiEnabled] = useState( + () => queryInsights?.aiRecommendationsEnabled === true, + ); + const [isEnabling, setIsEnabling] = useState(false); + const [isAnalyzing, setIsAnalyzing] = useState(false); + const [analysis, setAnalysis] = useState( + null, + ); + const [error, setError] = useState(null); + const requestIdRef = useRef(0); + + useEffect(() => { + if (queryInsights?.aiRecommendationsEnabled) { + setIsAiEnabled(true); + } + }, [queryInsights?.aiRecommendationsEnabled]); + + useEffect(() => { + setAnalysis(null); + setError(null); + + if (!selectedRow || !queryInsights || !isAiEnabled) { + setIsAnalyzing(false); + return; + } + + const requestId = requestIdRef.current + 1; + requestIdRef.current = requestId; + setIsAnalyzing(true); + + const timer = setTimeout(() => { + void queryInsights + .analyze(buildAnalysisInput(selectedRow)) + .then((response) => { + if (requestIdRef.current !== requestId) { + return; + } + + setAnalysis(response.result); + setError(response.error ?? null); + }) + .catch((error: unknown) => { + if (requestIdRef.current !== requestId) { + return; + } + + setError(error instanceof Error ? error.message : String(error)); + }) + .finally(() => { + if (requestIdRef.current === requestId) { + setIsAnalyzing(false); + } + }); + }, 300); + + return () => { + clearTimeout(timer); + }; + }, [isAiEnabled, queryInsights, selectedRow]); + + const enable = useCallback(async () => { + if (!queryInsights) { + return; + } + + setIsEnabling(true); + setError(null); + + try { + await queryInsights.enableAiRecommendations(); + setIsAiEnabled(true); + queryInsights.onEvent?.({ + name: "studio:query_insights:ai_consent_accepted", + }); + } catch (error) { + setError(error instanceof Error ? error.message : String(error)); + setIsAiEnabled(false); + } finally { + setIsEnabling(false); + } + }, [queryInsights]); + + return { + analysis, + enable, + error, + isAiEnabled, + isAnalyzing, + isEnabling, + }; +} + +function ProseSummary(props: { row: QueryInsightsDisplayRow }) { + const { row } = props; + const metrics = getRowMetrics(row); + const tableText = + metrics.tables.length > 0 ? formatTableList(metrics.tables) : null; + + if (row.type === "group") { + return ( +
+

+ This Prisma ORM call had an average total latency of{" "} + {formatLatency(metrics.duration)} + , with a minimum of{" "} + + {formatLatency(row.group.minDuration)} + {" "} + and a maximum of{" "} + + {formatLatency(row.group.maxDuration)} + + . +

+

+ The call generated{" "} + + {row.group.children.length} SQL{" "} + {row.group.children.length === 1 ? "query" : "queries"} + + {tableText + ? ` that read from ${tableText} ${ + metrics.tables.length === 1 ? "table" : "tables" + }` + : ""}{" "} + with a total of{" "} + + {formatNumber(row.group.totalRows)} + {" "} + rows. It has been executed{" "} + {formatNumber(metrics.count)}{" "} + times in this session. +

+
+ ); + } + + const { prismaQueryInfo } = row.query; + const isPrisma = prismaQueryInfo && !prismaQueryInfo.isRaw; + + return ( +

+ {isPrisma ? ( + <> + This{" "} + + {prismaQueryInfo.model ? `${prismaQueryInfo.model}.` : ""} + {prismaQueryInfo.action}() + {" "} + Prisma operation + {tableText + ? ` reads from ${tableText} ${ + metrics.tables.length === 1 ? "table" : "tables" + }` + : ""} + . + + ) : ( + <>This SQL query{tableText ? ` reads from ${tableText}` : ""}. + )}{" "} + It has been executed{" "} + {formatNumber(metrics.count)} times, + averaging{" "} + {formatLatency(metrics.duration)}{" "} + latency and{" "} + {formatNumber(metrics.reads)} block + reads per call. +

+ ); +} + +const SQL_LINE_CLAMP = 4; +const SQL_LENGTH_THRESHOLD = 160; + +function CollapsibleSqlBlock(props: { + onCopied: () => void; + query: QueryInsightsQuery; +}) { + const { onCopied, query } = props; + const [isExpanded, setIsExpanded] = useState(false); + + useEffect(() => { + setIsExpanded(false); + }, [query.id]); + + const isLong = + query.query.split("\n").length > SQL_LINE_CLAMP || + query.query.length > SQL_LENGTH_THRESHOLD; + + return ( +
+ + {query.query} + + {isLong ? ( + + ) : null} +
+ ); +} + +function renderMarkdownText(text: string) { + const blocks: Array<{ + text: string; + type: "heading" | "paragraph"; + }> = []; + let paragraphLines: string[] = []; + + const flushParagraph = () => { + if (paragraphLines.length === 0) { + return; + } + + blocks.push({ + text: paragraphLines.join(" ").replace(/\*\*/g, ""), + type: "paragraph", + }); + paragraphLines = []; + }; + + for (const line of text.split("\n")) { + const trimmedLine = line.trim(); + + if (!trimmedLine) { + flushParagraph(); + continue; + } + + const heading = /^(#{1,3})\s+(.+)$/.exec(trimmedLine); + + if (heading) { + flushParagraph(); + blocks.push({ + text: heading[2] ?? trimmedLine, + type: "heading", + }); + continue; + } + + paragraphLines.push(trimmedLine); + } + + flushParagraph(); + + return blocks; +} + +function buildRecommendationPrompt( + recommendation: string, + query: QueryInsightsQuery, +): string { + const context = + query.prismaQueryInfo && !query.prismaQueryInfo.isRaw + ? `${query.prismaQueryInfo.model ? `${query.prismaQueryInfo.model}.` : ""}${query.prismaQueryInfo.action}()` + : null; + const parts = [ + context + ? `My Prisma query (${context}) has a performance issue:` + : "My query has a performance issue:", + "", + recommendation, + "", + `Query stats: avg latency ${formatLatency(query.duration)}, ${formatNumber( + query.reads, + )} block reads, ${formatNumber(query.rowsReturned)} rows returned.`, + "", + "SQL:", + query.query, + "", + "Please help me fix this issue. Show the solution in both SQL and Prisma schema.prisma syntax where applicable.", + ]; + + return parts.join("\n"); +} + +function HighlightedText(props: { text: string }) { + const parts = props.text.split(/(\d+(?:\.\d+)?%)/g); + + return ( +

+ {parts.map((part, index) => + /^\d+(?:\.\d+)?%$/.test(part) ? ( + + {part} + + ) : ( + part + ), + )} +

+ ); +} + +type RecommendationOutputTab = "prisma" | "prompt" | "sql"; + +function RecommendationBlock(props: { + improvedPrisma?: string; + improvedSql?: string; + onCopied: (tab: "ai-prompt" | "prisma" | "sql") => void; + query: QueryInsightsQuery; + recommendation: string; +}) { + const { improvedPrisma, improvedSql, onCopied, query, recommendation } = + props; + const [tab, setTab] = useState( + improvedPrisma ? "prisma" : "prompt", + ); + const content = + tab === "prisma" + ? (improvedPrisma ?? "") + : tab === "sql" + ? (improvedSql ?? "") + : buildRecommendationPrompt(recommendation, query); + + return ( +
+ +
+
+ +
+ + onCopied( + tab === "prisma" ? "prisma" : tab === "sql" ? "sql" : "ai-prompt", + ) + } + > + {content} + +
+
+ ); +} + +function AnalysisResult(props: { + onCopied: (tab: "ai-prompt" | "prisma" | "sql") => void; + query: QueryInsightsQuery; + result: QueryInsightsAnalysisResult; +}) { + const { onCopied, query, result } = props; + return ( +
+
+ {renderMarkdownText(result.analysisMarkdown).map((block, index) => + block.type === "heading" ? ( +

+ {block.text} +

+ ) : ( +

+ {block.text} +

+ ), + )} +
+ + {result.issuesFound.length > 0 ? ( +
+

Issues

+
    + {result.issuesFound.map((issue) => ( +
  • {issue}
  • + ))} +
+
+ ) : null} + + {result.recommendations.length > 0 ? ( +
+

Recommendations

+
+ {result.recommendations.map((recommendation) => ( + + ))} +
+
+ ) : null} +
+ ); +} + +function ConsentPanel(props: { + isEnabling: boolean; + onEnable: () => Promise; +}) { + const { isEnabling, onEnable } = props; + + return ( +
+
+
+

+ Enable AI-powered recommendations +

+

+ Enabling this feature will pass query structure to generative AI to + identify improvements in your queries. Query parameters are not used + for this feature and are not visible, stored, or used for AI in any + way. +

+
+ +
+
+ ); +} + +function QueryAnalysisState(props: { + analysis: QueryInsightsAnalysisResult | null; + error: string | null; + isAnalyzing: boolean; + onCopied: (tab: "ai-prompt" | "prisma" | "sql") => void; + query: QueryInsightsQuery | null | undefined; +}) { + const { analysis, error, isAnalyzing, onCopied, query } = props; + + if (isAnalyzing) { + return ( +
+ + Analyzing query... +
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (analysis && query) { + return ( + + ); + } + + return null; +} + +function ChildSqlCard(props: { + index: number; + onCopied: () => void; + query: QueryInsightsQuery; +}) { + const { index, onCopied, query } = props; + const tableText = + query.tables.length > 0 ? formatTableList(query.tables) : null; + const ordinal = + index === 0 + ? "first" + : index === 1 + ? "second" + : index === 2 + ? "third" + : `#${index + 1}`; + + return ( +
+

+ The {ordinal} SQL query had an average latency of{" "} + {formatLatency(query.duration)}. + {tableText + ? ` It accessed ${tableText} ${ + query.tables.length === 1 ? "table" : "tables" + }.` + : ""}{" "} + This query returned{" "} + {formatNumber(query.rowsReturned)}{" "} + rows. +

+ + {query.query} + +
+ ); +} + +function DetailSheet(props: { + hasNext: boolean; + hasPrevious: boolean; + onClose: () => void; + onCopied: (tab: "ai-prompt" | "prisma" | "sql") => void; + onNext: () => void; + onPrevious: () => void; + row: QueryInsightsDisplayRow | null; +}) { + const { hasNext, hasPrevious, onClose, onCopied, onNext, onPrevious, row } = + props; + const { analysis, enable, error, isAiEnabled, isAnalyzing, isEnabling } = + useQueryInsightsAnalysis(row); + const { queryInsights } = useStudio(); + const scopeRef = useRef(null); + const isOpen = row !== null; + const scopePortalContainer = scopeRef.current?.closest(".ps"); + const portalContainer = + scopePortalContainer instanceof HTMLElement + ? scopePortalContainer + : typeof document === "undefined" + ? null + : document.body; + + useEffect(() => { + if (isOpen && !isAiEnabled) { + queryInsights?.onEvent?.({ + name: "studio:query_insights:ai_consent_callout_viewed", + }); + } + }, [isAiEnabled, isOpen, queryInsights]); + + return ( + <> +