diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx
index 23074b605..fbfffca3b 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx
@@ -1,5 +1,6 @@
-import { LightBulbIcon } from "@heroicons/react/20/solid";
-import { ColumnSchema } from "@internal/tsql";
+import { ArrowDownTrayIcon, ClipboardIcon, LightBulbIcon } from "@heroicons/react/20/solid";
+import type { OutputColumnMetadata } from "@internal/clickhouse";
+import type { ColumnSchema } from "@internal/tsql";
import { Form, useNavigation } from "@remix-run/react";
import {
type ActionFunctionArgs,
@@ -21,6 +22,12 @@ import { CopyableText } from "~/components/primitives/CopyableText";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
+import {
+ Popover,
+ PopoverArrowTrigger,
+ PopoverContent,
+ PopoverMenuItem,
+} from "~/components/primitives/Popover";
import {
ResizableHandle,
ResizablePanel,
@@ -36,6 +43,7 @@ import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { executeQuery } from "~/services/queryService.server";
import { requireUser } from "~/services/session.server";
+import { downloadFile, rowsToCSV, rowsToJSON } from "~/utils/dataExport";
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
import { defaultQuery, querySchemas } from "~/v3/querySchemas";
@@ -278,12 +286,17 @@ export default function Page() {
)}
-
+
+ {results?.rows && results?.columns && results.rows.length > 0 && (
+
+ )}
+
+
{isLoading ? (
@@ -328,6 +341,60 @@ export default function Page() {
);
}
+function ExportResultsButton({
+ rows,
+ columns,
+}: {
+ rows: Record
[];
+ columns: OutputColumnMetadata[];
+}) {
+ const [isOpen, setIsOpen] = useState(false);
+
+ const handleCopyCSV = () => {
+ const csv = rowsToCSV(rows, columns);
+ navigator.clipboard.writeText(csv);
+ setIsOpen(false);
+ };
+
+ const handleExportCSV = () => {
+ const csv = rowsToCSV(rows, columns);
+ const timestamp = new Date().toISOString().slice(0, 19).replace(/[:-]/g, "");
+ downloadFile(csv, `query-results-${timestamp}.csv`, "text/csv");
+ setIsOpen(false);
+ };
+
+ const handleCopyJSON = () => {
+ const json = rowsToJSON(rows);
+ navigator.clipboard.writeText(json);
+ setIsOpen(false);
+ };
+
+ const handleExportJSON = () => {
+ const json = rowsToJSON(rows);
+ const timestamp = new Date().toISOString().slice(0, 19).replace(/[:-]/g, "");
+ downloadFile(json, `query-results-${timestamp}.json`, "application/json");
+ setIsOpen(false);
+ };
+
+ return (
+
+ Export
+
+
+
+
+ );
+}
+
function QueryHelpSidebar({ onClose }: { onClose: () => void }) {
return (
diff --git a/apps/webapp/app/utils/dataExport.ts b/apps/webapp/app/utils/dataExport.ts
new file mode 100644
index 000000000..f29e19817
--- /dev/null
+++ b/apps/webapp/app/utils/dataExport.ts
@@ -0,0 +1,78 @@
+import type { OutputColumnMetadata } from "@internal/clickhouse";
+
+/**
+ * Escape a value for CSV format.
+ * - Wraps in quotes if the value contains commas, quotes, or newlines
+ * - Escapes quotes by doubling them
+ */
+function escapeCSVValue(value: unknown): string {
+ if (value === null || value === undefined) {
+ return "";
+ }
+
+ const stringValue = typeof value === "object" ? JSON.stringify(value) : String(value);
+
+ // Check if we need to quote the value
+ if (
+ stringValue.includes(",") ||
+ stringValue.includes('"') ||
+ stringValue.includes("\n") ||
+ stringValue.includes("\r")
+ ) {
+ // Escape quotes by doubling them and wrap in quotes
+ return `"${stringValue.replace(/"/g, '""')}"`;
+ }
+
+ return stringValue;
+}
+
+/**
+ * Convert query result rows to CSV format.
+ *
+ * @param rows - Array of row objects from query results
+ * @param columns - Column metadata describing the result columns
+ * @returns CSV string with header row and data rows
+ */
+export function rowsToCSV(rows: Record[], columns: OutputColumnMetadata[]): string {
+ if (columns.length === 0) {
+ return "";
+ }
+
+ const columnNames = columns.map((col) => col.name);
+
+ // Header row
+ const headerRow = columnNames.map(escapeCSVValue).join(",");
+
+ // Data rows
+ const dataRows = rows.map((row) => columnNames.map((name) => escapeCSVValue(row[name])).join(","));
+
+ return [headerRow, ...dataRows].join("\n");
+}
+
+/**
+ * Convert query result rows to JSON format.
+ *
+ * @param rows - Array of row objects from query results
+ * @returns Formatted JSON string
+ */
+export function rowsToJSON(rows: Record[]): string {
+ return JSON.stringify(rows, null, 2);
+}
+
+/**
+ * Trigger a file download in the browser.
+ *
+ * @param content - The file content as a string
+ * @param filename - The name for the downloaded file
+ * @param mimeType - The MIME type of the file
+ */
+export function downloadFile(content: string, filename: string, mimeType: string): void {
+ const blob = new Blob([content], { type: mimeType });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = filename;
+ a.click();
+ URL.revokeObjectURL(url);
+}
+