diff --git a/packages/browser-tests/.gitignore b/packages/browser-tests/.gitignore
index cff0eba1e..ef2d09575 100644
--- a/packages/browser-tests/.gitignore
+++ b/packages/browser-tests/.gitignore
@@ -1,3 +1,4 @@
node_modules
cypress/videos
cypress/screenshots
+cypress/downloads
diff --git a/packages/browser-tests/cypress/integration/console/download.spec.js b/packages/browser-tests/cypress/integration/console/download.spec.js
new file mode 100644
index 000000000..a4f54a732
--- /dev/null
+++ b/packages/browser-tests/cypress/integration/console/download.spec.js
@@ -0,0 +1,97 @@
+describe("download functionality", () => {
+ beforeEach(() => {
+ cy.loadConsoleWithAuth();
+ });
+
+ it("should show download button with results", () => {
+ // When
+ cy.typeQuery("select x from long_sequence(10)");
+ cy.runLine();
+
+ // Then
+ cy.getByDataHook("download-parquet-button").should("be.visible");
+ cy.getByDataHook("download-dropdown-button").should("be.visible");
+ cy.getByDataHook("download-csv-button").should("not.exist");
+
+ // When
+ cy.getByDataHook("download-dropdown-button").click();
+
+ // Then
+ cy.getByDataHook("download-csv-button").should("be.visible");
+ });
+
+ it("should trigger CSV download", () => {
+ const query = "select x from long_sequence(10)";
+
+ // Given
+ cy.intercept("GET", "**/exp?*", (req) => {
+ req.reply({
+ statusCode: 200,
+ body: null,
+ });
+ }).as("exportRequest");
+
+ // When
+ cy.typeQuery(query);
+ cy.runLine();
+ cy.getByDataHook("download-dropdown-button").click();
+ cy.getByDataHook("download-csv-button").click();
+
+ // Then
+ cy.wait("@exportRequest").then((interception) => {
+ expect(interception.request.url).to.include("fmt=csv");
+ expect(interception.request.url).to.include(
+ encodeURIComponent(query.replace(/\s+/g, " "))
+ );
+ });
+ });
+
+ it("should trigger Parquet download", () => {
+ const query = "select x from long_sequence(10)";
+
+ // Given
+ cy.intercept("GET", "**/exp?*", (req) => {
+ req.reply({
+ statusCode: 200,
+ body: null,
+ });
+ }).as("exportRequest");
+
+ // When
+ cy.typeQuery(query);
+ cy.runLine();
+ cy.getByDataHook("download-parquet-button").click();
+
+ // Then
+ cy.wait("@exportRequest").then((interception) => {
+ expect(interception.request.url).to.include("fmt=parquet");
+ expect(interception.request.url).to.include("rmode=nodelay");
+ expect(interception.request.url).to.include(
+ encodeURIComponent(query.replace(/\s+/g, " "))
+ );
+ });
+ });
+
+ it("should show error toast on bad request", () => {
+ // Given
+ cy.intercept("GET", "**/exp?*", (req) => {
+ const url = new URL(req.url);
+ url.searchParams.set("fmt", "badformat");
+ req.url = url.toString();
+ }).as("badExportRequest");
+
+ // When
+ cy.typeQuery("select x from long_sequence(5)");
+ cy.runLine();
+ cy.getByDataHook("download-dropdown-button").click();
+ cy.getByDataHook("download-csv-button").click();
+
+ // Then
+ cy.wait("@badExportRequest").then(() => {
+ cy.getByRole("alert").should(
+ "contain",
+ "An error occurred while downloading the file: unrecognised format [format=badformat]"
+ );
+ });
+ });
+});
diff --git a/packages/browser-tests/questdb b/packages/browser-tests/questdb
index bdd0fbf67..93f248438 160000
--- a/packages/browser-tests/questdb
+++ b/packages/browser-tests/questdb
@@ -1 +1 @@
-Subproject commit bdd0fbf6784312962761566bba26665e5fb8ab6e
+Subproject commit 93f248438f1854eacdfe592aafb4a8a9e0fc63d4
diff --git a/packages/web-console/serve-dist.js b/packages/web-console/serve-dist.js
index a1a5fe2c9..dc8cb9b37 100644
--- a/packages/web-console/serve-dist.js
+++ b/packages/web-console/serve-dist.js
@@ -22,7 +22,8 @@ const server = http.createServer((req, res) => {
reqPathName.startsWith("/settings") ||
reqPathName.startsWith("/warnings") ||
reqPathName.startsWith("/chk") ||
- reqPathName.startsWith("/imp")
+ reqPathName.startsWith("/imp") ||
+ reqPathName.startsWith("/exp")
) {
// proxy /exec requests to localhost:9000
const options = {
diff --git a/packages/web-console/src/components/LoadingSpinner/index.tsx b/packages/web-console/src/components/LoadingSpinner/index.tsx
new file mode 100644
index 000000000..161f142b4
--- /dev/null
+++ b/packages/web-console/src/components/LoadingSpinner/index.tsx
@@ -0,0 +1,24 @@
+import React from "react"
+import styled, { useTheme } from "styled-components"
+import { Loader3 } from "@styled-icons/remix-line"
+import { Color } from "../../types"
+import { spinAnimation } from "../../components/Animation"
+
+const StyledLoader = styled(Loader3)<{ $size: string, $color: Color }>`
+ width: ${({ $size }) => $size};
+ height: ${({ $size }) => $size};
+ color: ${({ $color, theme }) => $color ? theme.color[$color] : theme.color.pink};
+ ${spinAnimation};
+`
+
+type Props = {
+ size?: string
+ color?: Color
+}
+
+export const LoadingSpinner = ({ size = "18px", color = "pink" }: Props) => {
+ const theme = useTheme()
+ return (
+
+ )
+}
diff --git a/packages/web-console/src/components/index.ts b/packages/web-console/src/components/index.ts
index e8c085f35..dc2b67eb4 100644
--- a/packages/web-console/src/components/index.ts
+++ b/packages/web-console/src/components/index.ts
@@ -31,6 +31,7 @@ export * from "./Hooks"
export * from "./IconWithTooltip"
export * from "./Input"
export * from "./Link"
+export * from "./LoadingSpinner"
export * from "./PaneContent"
export * from "./PaneMenu"
export * from "./PaneWrapper"
diff --git a/packages/web-console/src/scenes/Result/index.tsx b/packages/web-console/src/scenes/Result/index.tsx
index 09dc656fa..b46ffc730 100644
--- a/packages/web-console/src/scenes/Result/index.tsx
+++ b/packages/web-console/src/scenes/Result/index.tsx
@@ -32,12 +32,14 @@ import { HandPointLeft } from "@styled-icons/fa-regular"
import { TableFreezeColumn } from "@styled-icons/fluentui-system-filled"
import { Markdown } from "@styled-icons/bootstrap/Markdown"
import { Check } from "@styled-icons/bootstrap/Check"
+import { ArrowDownS } from "@styled-icons/remix-line"
import { grid } from "../../js/console/grid"
import { quickVis } from "../../js/console/quick-vis"
import {
PaneContent,
PaneWrapper,
PopperHover,
+ PopperToggle,
PrimaryToggleButton,
Text,
Tooltip,
@@ -46,7 +48,7 @@ import { actions, selectors } from "../../store"
import { color, ErrorResult, QueryRawResult } from "../../utils"
import * as QuestDB from "../../utils/questdb"
import { ResultViewMode } from "scenes/Console/types"
-import { Button } from "@questdb/react-components"
+import { Button, Box } from "@questdb/react-components"
import type { IQuestDBGrid } from "../../js/console/grid.js"
import { eventBus } from "../../modules/EventBus"
import { EventType } from "../../modules/EventBus/types"
@@ -55,6 +57,8 @@ import { LINE_NUMBER_HARD_LIMIT } from "../Editor/Monaco"
import { QueryInNotification } from "../Editor/Monaco/query-in-notification"
import { NotificationType } from "../../store/Query/types"
import { copyToClipboard } from "../../utils/copyToClipboard"
+import { toast } from "../../components"
+import { API_VERSION } from "../../consts"
const Root = styled.div`
display: flex;
@@ -96,6 +100,39 @@ const TableFreezeColumnIcon = styled(TableFreezeColumn)`
const RowCount = styled(Text)`
margin-right: 1rem;
+ line-height: 1.285;
+`
+
+const DownloadButton = styled(Button)`
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0 1rem;
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+`
+
+const ArrowIcon = styled(ArrowDownS)<{ $open: boolean }>`
+ transform: ${({ $open }) => $open ? "rotate(180deg)" : "rotate(0deg)"};
+`
+
+const DownloadDropdownButton = styled(Button)`
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0 0.5rem;
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+`
+
+const DownloadMenuItem = styled(Button)`
+ display: flex;
+ align-items: center;
+ gap: 1.2rem;
+ width: 100%;
+ height: 3rem;
+ padding: 0 1rem;
+ font-size: 1.4rem;
`
const Result = ({ viewMode }: { viewMode: ResultViewMode }) => {
@@ -105,6 +142,7 @@ const Result = ({ viewMode }: { viewMode: ResultViewMode }) => {
const activeSidebar = useSelector(selectors.console.getActiveSidebar)
const gridRef = useRef()
const [gridFreezeLeftState, setGridFreezeLeftState] = useState(0)
+ const [downloadMenuActive, setDownloadMenuActive] = useState(false)
const dispatch = useDispatch()
useEffect(() => {
@@ -264,6 +302,48 @@ const Result = ({ viewMode }: { viewMode: ResultViewMode }) => {
}
}, [result])
+ const handleDownload = (format: "csv" | "parquet") => {
+ setDownloadMenuActive(false)
+ const sql = gridRef?.current?.getSQL()
+ if (!sql) {
+ toast.error("No SQL query found to download")
+ return
+ }
+
+ const url = `exp?${QuestDB.Client.encodeParams({
+ query: sql,
+ version: API_VERSION,
+ fmt: `${format}`,
+ filename: `questdb-query-${Date.now().toString()}`,
+ ...(format === "parquet" ? { rmode: "nodelay" } : {}),
+ })}`
+
+ const iframe = document.createElement("iframe")
+ iframe.style.display = "none"
+ document.body.appendChild(iframe)
+
+ iframe.onerror = (e) => {
+ toast.error(`An error occurred while downloading the file: ${e}`)
+ }
+
+ iframe.onload = () => {
+ const content = iframe.contentDocument?.body?.textContent
+ if (content) {
+ let error = 'An error occurred while downloading the file'
+ try {
+ const contentJson = JSON.parse(content)
+ error += `: ${contentJson.error ?? content}`
+ } catch (_) {
+ error += `: ${content}`
+ }
+ toast.error(error)
+ }
+ document.body.removeChild(iframe)
+ }
+
+ iframe.src = url
+ }
+
return (
@@ -285,25 +365,45 @@ const Result = ({ viewMode }: { viewMode: ResultViewMode }) => {
))}
- handleDownload("parquet")}
+ >
+
+
+ Download as Parquet
+
+
+ {
- const sql = gridRef?.current?.getSQL()
- if (sql) {
- quest.exportQueryToCsv(sql)
- }
- }}
+
-
-
+
+
}
>
- Download result as a CSV file
-
+ handleDownload("csv")}
+ >
+ Download as CSV
+
+
diff --git a/packages/web-console/src/scenes/Search/SearchPanel.tsx b/packages/web-console/src/scenes/Search/SearchPanel.tsx
index 7cb5c38c4..3f3391076 100644
--- a/packages/web-console/src/scenes/Search/SearchPanel.tsx
+++ b/packages/web-console/src/scenes/Search/SearchPanel.tsx
@@ -1,5 +1,4 @@
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react'
-import { Loader3 } from '@styled-icons/remix-line'
import { Error as ErrorIcon } from '@styled-icons/boxicons-regular'
import styled, { css } from 'styled-components'
import { Input, Checkbox } from '@questdb/react-components'
@@ -11,7 +10,7 @@ import { eventBus } from '../../modules/EventBus'
import { EventType } from '../../modules/EventBus/types'
import { useSearch } from '../../providers'
import { db } from '../../store/db'
-import { spinAnimation } from '../../components'
+import { LoadingSpinner } from '../../components'
import { color } from '../../utils'
import { useEffectIgnoreFirst } from '../../components/Hooks/useEffectIgnoreFirst'
import { SearchTimeoutError, SearchCancelledError, terminateSearchWorker } from '../../utils/textSearch'
@@ -113,12 +112,6 @@ const SearchSummary = styled.div`
font-size: 1.1rem;
`
-const Loader = styled(Loader3)`
- width: 2rem;
- color: ${color("pink")};
- ${spinAnimation};
-`
-
const CheckboxWrapper = styled.div`
display: flex;
align-items: center;
@@ -189,7 +182,7 @@ const DelayedLoader = () => {
return (
-
+
Searching...
)
diff --git a/packages/web-console/src/utils/questdb/client.ts b/packages/web-console/src/utils/questdb/client.ts
index 40526ee87..37cde899c 100644
--- a/packages/web-console/src/utils/questdb/client.ts
+++ b/packages/web-console/src/utils/questdb/client.ts
@@ -25,7 +25,7 @@ import {
Permission,
SymbolColumnDetails,
} from "./types"
-import { ssoAuthState } from "../../modules/OAuth2/ssoAuthState";
+import { ssoAuthState } from "../../modules/OAuth2/ssoAuthState"
export class Client {
private _controllers: AbortController[] = []
@@ -453,29 +453,6 @@ export class Client {
return { status: response.status, success: true }
}
- async exportQueryToCsv(query: string) {
- try {
- const response: Response = await fetch(
- `exp?${Client.encodeParams({ query, version: API_VERSION })}`,
- { headers: this.commonHeaders },
- )
- const blob = await response.blob()
- const filename = response.headers
- .get("Content-Disposition")
- ?.split("=")[1]
- const url = window.URL.createObjectURL(blob)
- const a = document.createElement("a")
- a.href = url
- a.download = filename
- ? filename.replaceAll(`"`, "")
- : `questdb-query-${new Date().getTime()}.csv`
- a.click()
- window.URL.revokeObjectURL(url)
- } catch (error) {
- throw error
- }
- }
-
async getLatestRelease() {
try {
const response: Response = await fetch(