Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/good-turtles-lie.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@prisma/studio-core": minor
---

Add Query Details copy actions
2 changes: 1 addition & 1 deletion FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ Users can pan/zoom, inspect key and nullable markers, and jump from a node direc

Embedders can optionally provide live query snapshots through Studio's BFF bridge, and Studio shows them in a dedicated `Queries` view directly under the schema visualizer.
The view plots live query throughput and average latency from recent snapshot rows and successive snapshot updates, defaulting to the most recent 5 minutes with quick switches for 1 minute, 15 minutes, and 1 hour. The chart summary and query list follow the selected time window, including row execution and rows-returned counters, while historical first-snapshot rows render as latency context points instead of fake throughput spikes or cumulative totals. Live throughput points use one-second buckets at the query's observed time, live lines stay connected across short bursts, long unmeasured gaps break into separate segments, isolated samples render as points, and hovering the plot shows exact readings.
Users can filter by touched table, sort by operational signals, and open a detail sheet for SQL, metrics, query metadata, and optional recommendations.
Users can filter by touched table, sort by operational signals, and open a detail sheet for SQL, metrics, query metadata, and optional recommendations. The detail sheet includes compact copy actions for the original SQL and the full recommendation payload, so users can move either into an editor, issue, or follow-up prompt without manually selecting long text.
The table and recommendation text label returned-row volume as `Rows Returned`; optional provider read-work estimates stay separate so rows returned are not described as reads.
When Studio's shared `llm` hook is available, the query table adds an Analysis column that analyzes newly observed query groups in the background, one at a time, and stops automatic work after the first five groups. Rows show a running indicator, a manual Analyze action, and a completed all-good, info, or warning icon; the detail sheet uses the same analysis queue for manual recommendations. Without that hook, the AI analysis UI is hidden.
If an embedder does not provide query insights, Studio hides the `Queries` menu item and stale `view=queries` URLs fall back to the normal default view.
Expand Down
110 changes: 110 additions & 0 deletions ui/studio/views/queries/QueriesView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1346,6 +1346,116 @@ describe("QueriesView", () => {
container.remove();
});

it("copies the SQL text and recommendation from the query details sheet", async () => {
const originalClipboard = Object.getOwnPropertyDescriptor(
navigator,
"clipboard",
);
const writeText = vi.fn().mockResolvedValue(undefined);

Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: {
writeText,
},
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

useStudioMock.mockReturnValue({
hasAiQueryRecommendations: true,
queryInsights: {
getSnapshot,
},
requestLlm,
});
requestLlm.mockResolvedValueOnce(
JSON.stringify({
improvedPrisma: "await prisma.user.findMany({ select: { id: true } })",
improvedSql: "select id from users",
level: "info",
recommendations: [
"Project only the columns the UI needs.",
"Keep pagination count queries separate from row fetches.",
],
summary: "The query over-fetches columns.",
}),
);

const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);

try {
act(() => {
root.render(<QueriesView />);
});
await flushMicrotasks();

const rowButton = [...container.querySelectorAll("button")].find(
(button) => button.textContent?.includes("select * from users"),
);

expect(rowButton).not.toBeUndefined();

act(() => {
click(rowButton!);
});

await vi.waitFor(() => {
expect(document.body.textContent).toContain(
"Project only the columns the UI needs.",
);
});

const copySqlButton = document.body.querySelector(
'[aria-label="Copy SQL text"]',
);
const copyRecommendationButton = document.body.querySelector(
'[aria-label="Copy recommendation"]',
);

expect(copySqlButton).not.toBeNull();
expect(copyRecommendationButton).not.toBeNull();

await act(async () => {
click(copySqlButton!);
await Promise.resolve();
});

expect(writeText).toHaveBeenLastCalledWith("select * from users");

await act(async () => {
click(copyRecommendationButton!);
await Promise.resolve();
});

expect(writeText).toHaveBeenLastCalledWith(
[
"Recommendation",
"The query over-fetches columns.",
"",
"- Project only the columns the UI needs.",
"- Keep pagination count queries separate from row fetches.",
"",
"SQL",
"select id from users",
"",
"Prisma",
"await prisma.user.findMany({ select: { id: true } })",
].join("\n"),
);
} finally {
act(() => {
root.unmount();
});
container.remove();
if (originalClipboard) {
Object.defineProperty(navigator, "clipboard", originalClipboard);
} else {
Reflect.deleteProperty(navigator, "clipboard");
}
}
});

it("runs automatic query analysis serially, stops after five groups, and allows manual analysis", async () => {
getSnapshot.mockResolvedValueOnce([
null,
Expand Down
144 changes: 127 additions & 17 deletions ui/studio/views/queries/QueriesView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
ChevronLeft,
ChevronRight,
CircleCheck,
Copy,
Info,
Loader2,
Pause,
Expand Down Expand Up @@ -1582,9 +1583,17 @@ function QueryDetailsSheet(props: {
))}
</div>

<pre className="max-h-72 overflow-auto rounded-md border border-border/70 bg-muted/40 p-3 text-xs text-foreground">
<code>{query.query}</code>
</pre>
<div className="flex items-start gap-2">
<pre className="max-h-72 min-w-0 flex-1 overflow-auto rounded-md border border-border/70 bg-muted/40 p-3 text-xs text-foreground">
<code>{query.query}</code>
</pre>
<CopyToClipboardButton
ariaLabel="Copy SQL text"
className="mt-1 shrink-0"
label="Copy SQL"
text={query.query}
/>
</div>

<div className="grid grid-cols-2 gap-3 text-sm md:grid-cols-4">
<MetricInline label="Executions" value={query.count} />
Expand All @@ -1605,20 +1614,31 @@ function QueryDetailsSheet(props: {
<Sparkles data-icon="inline-start" />
Recommendations
</div>
{analysis && (
<QueryAnalysisStatusBadge level={analysis.level} />
)}
{!analysis && !isAnalysisLoading && !isAnalysisQueued && (
<Button
className="h-7 shadow-none"
onClick={onAnalyze}
size="xs"
type="button"
variant="outline"
>
{analysisError ? "Retry" : "Analyze"}
</Button>
)}
<div className="flex items-center gap-2">
{analysis && (
<>
<CopyToClipboardButton
ariaLabel="Copy recommendation"
label="Copy recommendation"
text={formatQueryAnalysisCopyText(analysis)}
/>
<QueryAnalysisStatusBadge level={analysis.level} />
</>
)}
{!analysis &&
!isAnalysisLoading &&
!isAnalysisQueued && (
<Button
className="h-7 shadow-none"
onClick={onAnalyze}
size="xs"
type="button"
variant="outline"
>
{analysisError ? "Retry" : "Analyze"}
</Button>
)}
</div>
</div>

{isAnalysisLoading ? (
Expand Down Expand Up @@ -1649,6 +1669,74 @@ function QueryDetailsSheet(props: {
);
}

function CopyToClipboardButton(props: {
ariaLabel: string;
className?: string;
label: string;
text: string;
}) {
const { ariaLabel, className, label, text } = props;
const [hasCopied, setHasCopied] = useState(false);
const resetCopiedTimeoutRef = useRef<number | null>(null);

useEffect(() => {
return () => {
if (resetCopiedTimeoutRef.current !== null) {
window.clearTimeout(resetCopiedTimeoutRef.current);
}
};
}, []);

const handleCopy = useCallback(() => {
if (typeof navigator.clipboard?.writeText !== "function") {
return;
}

void navigator.clipboard
.writeText(text)
.then(() => {
setHasCopied(true);

if (resetCopiedTimeoutRef.current !== null) {
window.clearTimeout(resetCopiedTimeoutRef.current);
}

resetCopiedTimeoutRef.current = window.setTimeout(() => {
setHasCopied(false);
resetCopiedTimeoutRef.current = null;
}, 1200);
})
.catch((error) => {
console.error("Failed to copy to clipboard:", error);
});
}, [text]);

return (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={ariaLabel}
className={cn(
"size-7 border border-border/70 bg-background/85 text-muted-foreground shadow-none hover:text-foreground",
className,
)}
onClick={handleCopy}
size="icon"
type="button"
variant="ghost"
>
{hasCopied ? <CircleCheck /> : <Copy />}
</Button>
</TooltipTrigger>
<TooltipContent side="left">
{hasCopied ? "Copied" : label}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

function MetricInline(props: { label: string; value: number | string }) {
return (
<div className="rounded-md border border-border/70 bg-background/60 p-2">
Expand Down Expand Up @@ -1676,6 +1764,28 @@ function QueryAnalysisStatusBadge(props: { level: QueryInsightAnalysisLevel }) {
);
}

function formatQueryAnalysisCopyText(analysis: QueryInsightAnalysis): string {
const sections = [`Recommendation\n${analysis.summary}`];

if (analysis.recommendations.length > 0) {
sections.push(
analysis.recommendations
.map((recommendation) => `- ${recommendation}`)
.join("\n"),
);
}

if (analysis.improvedSql) {
sections.push(`SQL\n${analysis.improvedSql}`);
}

if (analysis.improvedPrisma) {
sections.push(`Prisma\n${analysis.improvedPrisma}`);
}

return sections.join("\n\n");
}

function QueryAnalysis(props: { analysis: QueryInsightAnalysis }) {
const { analysis } = props;

Expand Down