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/auth/auth.spec.js b/packages/browser-tests/cypress/integration/auth/auth.spec.js
index 4707e0257..72540928f 100644
--- a/packages/browser-tests/cypress/integration/auth/auth.spec.js
+++ b/packages/browser-tests/cypress/integration/auth/auth.spec.js
@@ -1,6 +1,6 @@
///
-const contextPath = process.env.QDB_HTTP_CONTEXT_WEB_CONSOLE || ""
+const contextPath = process.env.QDB_HTTP_CONTEXT_WEB_CONSOLE || "";
const baseUrl = `http://localhost:9999${contextPath}`;
const interceptSettings = (payload) => {
@@ -12,10 +12,10 @@ const interceptSettings = (payload) => {
describe("OSS", () => {
before(() => {
interceptSettings({
- "config": {
+ config: {
"release.type": "OSS",
"release.version": "1.2.3",
- }
+ },
});
cy.visit(baseUrl);
});
@@ -29,7 +29,7 @@ describe("OSS", () => {
describe("Auth - UI", () => {
before(() => {
interceptSettings({
- "config": {
+ config: {
"release.type": "EE",
"release.version": "1.2.3",
"acl.enabled": true,
@@ -40,7 +40,7 @@ describe("Auth - UI", () => {
"acl.oidc.token.endpoint": null,
"acl.oidc.pkce.required": null,
"acl.oidc.groups.encoded.in.token": false,
- }
+ },
});
cy.visit(baseUrl);
});
@@ -52,11 +52,10 @@ describe("Auth - UI", () => {
});
});
-
describe("Auth - OIDC", () => {
before(() => {
interceptSettings({
- "config": {
+ config: {
"release.type": "EE",
"release.version": "1.2.3",
"acl.enabled": true,
@@ -67,7 +66,7 @@ describe("Auth - OIDC", () => {
"acl.oidc.token.endpoint": "https://host:9999/token",
"acl.oidc.pkce.required": true,
"acl.oidc.groups.encoded.in.token": false,
- }
+ },
});
cy.visit(baseUrl);
});
@@ -83,7 +82,7 @@ describe("Auth - OIDC", () => {
describe("Auth - Basic", () => {
before(() => {
interceptSettings({
- "config": {
+ config: {
"release.type": "EE",
"release.version": "1.2.3",
"acl.enabled": true,
@@ -94,7 +93,7 @@ describe("Auth - Basic", () => {
"acl.oidc.token.endpoint": null,
"acl.oidc.pkce.required": null,
"acl.oidc.groups.encoded.in.token": false,
- }
+ },
});
cy.visit(baseUrl);
});
@@ -108,7 +107,7 @@ describe("Auth - Basic", () => {
describe("Auth - Disabled", () => {
before(() => {
interceptSettings({
- "config": {
+ config: {
"release.type": "EE",
"release.version": "1.2.3",
"acl.enabled": false,
@@ -119,7 +118,7 @@ describe("Auth - Disabled", () => {
"acl.oidc.token.endpoint": null,
"acl.oidc.pkce.required": null,
"acl.oidc.groups.encoded.in.token": false,
- }
+ },
});
cy.visit(baseUrl);
});
@@ -129,3 +128,75 @@ describe("Auth - Disabled", () => {
cy.getEditor().should("be.visible");
});
});
+
+describe("Auth - Session Parameter (OAuth)", () => {
+ describe("OAuth Login with session=true", () => {
+ beforeEach(() => {
+ interceptSettings({
+ config: {
+ "release.type": "EE",
+ "release.version": "1.2.3",
+ "acl.enabled": true,
+ "acl.basic.auth.realm.enabled": false,
+ "acl.oidc.enabled": true,
+ "acl.oidc.client.id": "test-client",
+ "acl.oidc.authorization.endpoint": "https://oauth.example.com/auth",
+ "acl.oidc.token.endpoint": "https://oauth.example.com/token",
+ "acl.oidc.pkce.required": true,
+ "acl.oidc.groups.encoded.in.token": false,
+ },
+ });
+ });
+
+ it("should call exec with session=true after OAuth token exchange", () => {
+ cy.intercept(
+ {
+ method: "GET",
+ url: `${baseUrl}/exec?query=select%202&session=true`,
+ },
+ (req) => {
+ expect(req.headers).to.have.property("authorization");
+ expect(req.headers.authorization).to.match(/^Bearer /);
+
+ req.reply({
+ statusCode: 200,
+ headers: {
+ "set-cookie": "qdb-session=oauth-session-id; Path=/; HttpOnly",
+ },
+ body: {
+ query: "select 2",
+ columns: [{ name: "column", type: "INT" }],
+ dataset: [[2]],
+ count: 1,
+ },
+ });
+ }
+ ).as("oauthSessionStart");
+
+ cy.intercept(
+ {
+ method: "POST",
+ url: "https://oauth.example.com/token",
+ },
+ {
+ statusCode: 200,
+ body: {
+ access_token: "mock-access-token",
+ token_type: "Bearer",
+ expires_in: 3600,
+ },
+ }
+ ).as("tokenExchange");
+
+ cy.visit(`${baseUrl}?code=test-auth-code&state=test-state`);
+ cy.wait("@settings");
+
+ cy.wait("@tokenExchange");
+ cy.wait("@oauthSessionStart").then((interception) => {
+ expect(interception.request.url).to.include("session=true");
+ expect(interception.request.url).to.include("select%202");
+ expect(interception.response.headers).to.have.property("set-cookie");
+ });
+ });
+ });
+});
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..b2ae9c205
--- /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("query", "badquery");
+ 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: table does not exist [table=badquery]"
+ );
+ });
+ });
+});
diff --git a/packages/browser-tests/cypress/integration/console/session.spec.js b/packages/browser-tests/cypress/integration/console/session.spec.js
new file mode 100644
index 000000000..52f1631a6
--- /dev/null
+++ b/packages/browser-tests/cypress/integration/console/session.spec.js
@@ -0,0 +1,87 @@
+///
+
+const contextPath = process.env.QDB_HTTP_CONTEXT_WEB_CONSOLE || "";
+const baseUrl = `http://localhost:9999${contextPath}`;
+
+describe("HTTP Session Management", () => {
+ it("should create session on login, maintain it across requests, and persist after page refresh", () => {
+ // Given
+ cy.intercept("GET", `${baseUrl}/exec?query=*&session=true`).as(
+ "sessionStart"
+ );
+
+ // When
+ cy.handleStorageAndVisit(baseUrl);
+ cy.loginWithUserAndPassword();
+
+ // Then
+ cy.wait("@sessionStart").then((interception) => {
+ expect(interception.request.url).to.include("session=true");
+ expect(interception.request.headers).to.have.property("authorization");
+ expect(interception.response.headers["set-cookie"]).to.exist;
+ });
+ cy.getEditor().should("be.visible");
+
+ // Given
+ cy.intercept("GET", /\/exec\?.*query=SELECT%201/).as("queryExec");
+
+ // When
+ cy.clearEditor();
+ cy.typeQuery("SELECT 1");
+ cy.clickRunIconInLine(1);
+
+ // Then
+ cy.wait("@queryExec").then((interception) => {
+ expect(interception.request.url).to.not.include("session=true");
+ expect(interception.request.url).to.not.include("session=false");
+ expect(interception.response.statusCode).to.equal(200);
+ });
+ cy.getGrid().should("be.visible");
+
+ // When
+ cy.handleStorageAndVisit(baseUrl, false);
+
+ // Then
+ cy.getEditor().should("be.visible");
+ cy.clearEditor();
+ cy.typeQuery("SELECT 2");
+ cy.clickRunIconInLine(1);
+ cy.getGrid().should("be.visible");
+ cy.getGridRow(0).should("contain", "2");
+ });
+
+ it("should destroy session on logout, clear local storage, and show login screen after refresh", () => {
+ // Given
+ cy.loadConsoleWithAuth();
+ cy.window().then((win) => {
+ const basicAuthHeader = win.localStorage.getItem("basic.auth.header");
+ expect(basicAuthHeader).to.not.be.null;
+ });
+ cy.intercept("GET", `${baseUrl}/exec?query=*&session=false`).as(
+ "sessionDestroy"
+ );
+
+ // When
+ cy.getByDataHook("button-logout").click();
+
+ // Then
+ cy.wait("@sessionDestroy").then((interception) => {
+ expect(interception.request.url).to.include("session=false");
+ expect(interception.request.url).to.include("select%202");
+ });
+ cy.getByDataHook("auth-login").should("be.visible");
+ cy.window().then((win) => {
+ const basicAuthHeader = win.localStorage.getItem("basic.auth.header");
+ const restToken = win.localStorage.getItem("rest.token");
+ expect(basicAuthHeader).to.be.null;
+ expect(restToken).to.be.null;
+ });
+
+ // When
+ cy.reload();
+
+ // Then
+ cy.getByDataHook("auth-login").should("be.visible");
+ cy.getEditor().should("not.exist");
+ });
+});
diff --git a/packages/browser-tests/questdb b/packages/browser-tests/questdb
index bdd0fbf67..00b767b41 160000
--- a/packages/browser-tests/questdb
+++ b/packages/browser-tests/questdb
@@ -1 +1 @@
-Subproject commit bdd0fbf6784312962761566bba26665e5fb8ab6e
+Subproject commit 00b767b41346c24c8d586cec71e700a9557c37ae
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/modules/OAuth2/views/login.tsx b/packages/web-console/src/modules/OAuth2/views/login.tsx
index d0b432ecc..8183f6a63 100644
--- a/packages/web-console/src/modules/OAuth2/views/login.tsx
+++ b/packages/web-console/src/modules/OAuth2/views/login.tsx
@@ -240,7 +240,7 @@ export const Login = ({
const { username, password } = values
try {
const response = await fetch(
- `exec?query=${httpBasicAuthStrategy.query(username)}`,
+ `exec?query=${httpBasicAuthStrategy.query(username)}&session=true`,
{
headers: {
Authorization: `Basic ${btoa(`${username}:${password}`)}`,
diff --git a/packages/web-console/src/providers/AuthProvider.tsx b/packages/web-console/src/providers/AuthProvider.tsx
index 5f0b62cd7..d0879b44a 100644
--- a/packages/web-console/src/providers/AuthProvider.tsx
+++ b/packages/web-console/src/providers/AuthProvider.tsx
@@ -196,6 +196,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
})
const tokenResponse = await response.json()
setAuthToken(tokenResponse, settings)
+ await startServerSession(tokenResponse)
} catch (e) {
throw e
}
@@ -243,6 +244,25 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
}
}
+ const startServerSession = async (tokenResponse: AuthPayload) => {
+ // execute a simple query with session=true
+ await fetch(
+ `exec?query=select 2&session=true`,
+ {
+ headers: {
+ Authorization: `Bearer ${tokenResponse.groups_encoded_in_token ? tokenResponse.id_token : tokenResponse.access_token}`,
+ },
+ },
+ )
+ }
+
+ const destroyServerSession = () => {
+ // execute a simple query with session=false
+ fetch(`exec?query=select 2&session=false`).catch(
+ // ignore result
+ )
+ }
+
const uiAuthLogin = () => {
// Check if user is authenticated already with basic auth
const token = getValue(StoreKey.REST_TOKEN)
@@ -277,6 +297,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
if (promptForLogin && settings["acl.oidc.client.id"]) {
removeSSOUserNameWithClientID(settings["acl.oidc.client.id"])
}
+ destroyServerSession()
dispatch({ view: View.login })
}
diff --git a/packages/web-console/src/scenes/Result/index.tsx b/packages/web-console/src/scenes/Result/index.tsx
index 09dc656fa..dea4169bc 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(