From 22781d4a7dcaf80a0ca90626c60e5a08012cd094 Mon Sep 17 00:00:00 2001 From: emrberk Date: Fri, 10 Oct 2025 14:18:56 +0300 Subject: [PATCH 01/15] export parquet initial --- packages/web-console/assets/csv-file.svg | 5 + packages/web-console/assets/parquet-file.svg | 5 + packages/web-console/serve-dist.js | 3 +- .../web-console/src/scenes/Result/index.tsx | 94 ++++++++++++++++--- .../web-console/src/utils/questdb/client.ts | 11 ++- 5 files changed, 99 insertions(+), 19 deletions(-) create mode 100644 packages/web-console/assets/csv-file.svg create mode 100644 packages/web-console/assets/parquet-file.svg diff --git a/packages/web-console/assets/csv-file.svg b/packages/web-console/assets/csv-file.svg new file mode 100644 index 000000000..7713717a7 --- /dev/null +++ b/packages/web-console/assets/csv-file.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/packages/web-console/assets/parquet-file.svg b/packages/web-console/assets/parquet-file.svg new file mode 100644 index 000000000..3c62a6676 --- /dev/null +++ b/packages/web-console/assets/parquet-file.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file 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/scenes/Result/index.tsx b/packages/web-console/src/scenes/Result/index.tsx index 09dc656fa..7cc1cd83b 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, @@ -98,6 +100,39 @@ const RowCount = styled(Text)` margin-right: 1rem; ` +const DownloadButton = styled(Button)` + display: flex; + align-items: center; + gap: 0.5rem; + padding-right: 0.7rem; +` + +const DownloadMenu = styled.div` + min-width: 200px; + background: ${({ theme }) => theme.color.backgroundDarker}; + border: 1px solid ${({ theme }) => theme.color.gray1}; + border-radius: 0.4rem; + overflow: hidden; +` + +const DownloadMenuItem = styled.button` + display: flex; + align-items: center; + gap: 1.2rem; + width: 100%; + padding: 0.75rem 1rem; + text-align: left; + background: transparent; + border: none; + color: ${({ theme }) => theme.color.foreground}; + cursor: pointer; + font-size: 13px; + + &:hover { + background: ${({ theme }) => theme.color.selection}; + } +` + const Result = ({ viewMode }: { viewMode: ResultViewMode }) => { const { quest } = useContext(QuestContext) const [count, setCount] = useState() @@ -105,6 +140,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 +300,14 @@ const Result = ({ viewMode }: { viewMode: ResultViewMode }) => { } }, [result]) + const handleDownload = (format: "csv" | "parquet") => { + const sql = gridRef?.current?.getSQL() + if (sql) { + quest.exportQuery(sql, format) + } + setDownloadMenuActive(false) + } + return ( @@ -285,25 +329,45 @@ const Result = ({ viewMode }: { viewMode: ResultViewMode }) => { ))} - { - const sql = gridRef?.current?.getSQL() - if (sql) { - quest.exportQueryToCsv(sql) - } - }} + + + + + + } > - - + Download result + + } > - Download result as a CSV file - + + handleDownload("csv")}> + CSV + Download as CSV + + handleDownload("parquet")}> + Parquet + Download as Parquet + + + diff --git a/packages/web-console/src/utils/questdb/client.ts b/packages/web-console/src/utils/questdb/client.ts index 40526ee87..9794e04a3 100644 --- a/packages/web-console/src/utils/questdb/client.ts +++ b/packages/web-console/src/utils/questdb/client.ts @@ -453,10 +453,15 @@ export class Client { return { status: response.status, success: true } } - async exportQueryToCsv(query: string) { + async exportQuery(query: string, format: "csv" | "parquet") { try { const response: Response = await fetch( - `exp?${Client.encodeParams({ query, version: API_VERSION })}`, + `exp?${Client.encodeParams({ + query, + version: API_VERSION, + fmt: format, + ...(format === "parquet" ? { parquetVersion: "1" } : {}), + })}`, { headers: this.commonHeaders }, ) const blob = await response.blob() @@ -468,7 +473,7 @@ export class Client { a.href = url a.download = filename ? filename.replaceAll(`"`, "") - : `questdb-query-${new Date().getTime()}.csv` + : `questdb-query-${new Date().getTime()}.${format}` a.click() window.URL.revokeObjectURL(url) } catch (error) { From adf472fb1f22922fd7313d15c34145c541018a80 Mon Sep 17 00:00:00 2001 From: emrberk Date: Sat, 11 Oct 2025 04:36:02 +0300 Subject: [PATCH 02/15] feat(web-console): export parquet files, fix unresponsive CSV imports --- packages/browser-tests/.gitignore | 1 + .../integration/console/download.spec.js | 172 ++++++++++++++++++ packages/web-console/public/download-sw.js | 81 +++++++++ .../src/components/LoadingSpinner/index.tsx | 24 +++ packages/web-console/src/components/index.ts | 1 + packages/web-console/src/index.tsx | 3 + .../web-console/src/scenes/Result/index.tsx | 62 ++++--- .../src/scenes/Search/SearchPanel.tsx | 11 +- .../web-console/src/utils/questdb/client.ts | 78 ++++++-- .../web-console/src/utils/serviceWorker.ts | 50 +++++ packages/web-console/webpack.config.js | 2 + 11 files changed, 440 insertions(+), 45 deletions(-) create mode 100644 packages/browser-tests/cypress/integration/console/download.spec.js create mode 100755 packages/web-console/public/download-sw.js create mode 100644 packages/web-console/src/components/LoadingSpinner/index.tsx create mode 100644 packages/web-console/src/utils/serviceWorker.ts 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..c80ecef29 --- /dev/null +++ b/packages/browser-tests/cypress/integration/console/download.spec.js @@ -0,0 +1,172 @@ +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("result-download-button").should("be.visible"); + + // When + cy.getByDataHook("result-download-button").click(); + + // Then + cy.getByDataHook("download-csv-button").should("be.visible"); + cy.getByDataHook("download-parquet-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("result-download-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("result-download-button").click(); + 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("parquetVersion=1"); + 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("result-download-button").click(); + cy.getByDataHook("download-csv-button").click(); + + // Then + cy.wait("@badExportRequest").then(() => { + cy.getByRole("alert").should( + "contain", + "Download failed with status code 400: unrecognised format [format=badformat]" + ); + }); + }); + + it("should show error toast on server error", () => { + // Given + cy.intercept("GET", "**/exp?*", (req) => { + req.reply({ + statusCode: 500, + }); + }).as("serverErrorRequest"); + + // When + cy.typeQuery("select x from long_sequence(5)"); + cy.runLine(); + cy.getByDataHook("result-download-button").click(); + cy.getByDataHook("download-csv-button").click(); + + // Then + cy.wait("@serverErrorRequest").then(() => { + cy.getByRole("alert").should( + "contain", + "Download failed with status code 500: Internal Server Error" + ); + }); + }); + + it("should show loading spinner when downloading", () => { + // Given + cy.intercept("GET", "**/exp?*", (req) => { + req.reply({ + statusCode: 200, + body: null, + delay: 1000, + }); + }).as("exportRequest"); + + // When + cy.typeQuery("select * from long_sequence(10)"); + cy.runLine(); + cy.getByDataHook("result-download-button").click(); + cy.getByDataHook("download-parquet-button").click(); + + // Then + cy.getByDataHook("download-loading-indicator").should("be.visible"); + + // Then + cy.wait("@exportRequest").then(() => { + cy.getByDataHook("download-loading-indicator").should("not.exist"); + }); + }); + + it("should download the file", () => { + const query = "select x from long_sequence(10)"; + // Given + cy.intercept("GET", "**/exp?*").as("exportRequest"); + + // When + cy.typeQuery(query); + cy.runLine(); + cy.getByDataHook("result-download-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, " ")) + ); + const filename = new URL(interception.request.url).searchParams.get( + "filename" + ); + cy.readFile(`cypress/downloads/${filename}.csv`).should( + "eq", + '"x"\r\n1\r\n2\r\n3\r\n4\r\n5\r\n6\r\n7\r\n8\r\n9\r\n10\r\n' + ); + }); + }); +}); diff --git a/packages/web-console/public/download-sw.js b/packages/web-console/public/download-sw.js new file mode 100755 index 000000000..4a9ff7c6b --- /dev/null +++ b/packages/web-console/public/download-sw.js @@ -0,0 +1,81 @@ +let authToken = null + +self.addEventListener('message', (event) => { + if (event.data && event.data.type === 'SET_AUTH_TOKEN') { + authToken = event.data.token + } +}) + +self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url) + + if (url.pathname === '/exp' || url.pathname.endsWith('/exp')) { + const requestKey = new URL(event.request.url).searchParams.get('filename') + + event.respondWith( + (async () => { + try { + const headers = new Headers(event.request.headers) + if (authToken) { + headers.set('Authorization', authToken) + } + + const modifiedRequest = new Request(event.request, { + headers: headers, + }) + + const response = await fetch(modifiedRequest) + + if (!response.ok) { + let message = response.statusText + try { + const json = await response.json() + const error = json.error + if (error) { + message = error + } + } catch (_) {} + + self.clients.matchAll().then((clients) => { + clients.forEach((client) => { + client.postMessage({ + type: `DOWNLOAD_ERROR_${requestKey}`, + status: response.status, + message, + }) + }) + }) + } else { + self.clients.matchAll().then((clients) => { + clients.forEach((client) => { + client.postMessage({ + type: `DOWNLOAD_START_${requestKey}`, + }) + }) + }) + } + return response + } catch (error) { + self.clients.matchAll().then((clients) => { + clients.forEach((client) => { + client.postMessage({ + type: `DOWNLOAD_ERROR_${requestKey}`, + status: 500, + message: error.message ?? 'Internal server error', + }) + }) + }) + console.error('[SW] Download service worker error:', error) + } + })() + ); + } +}); + +self.addEventListener('install', (_) => { + self.skipWaiting() +}); + +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()) +}); 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/index.tsx b/packages/web-console/src/index.tsx index 066fd3cfd..da71d679e 100644 --- a/packages/web-console/src/index.tsx +++ b/packages/web-console/src/index.tsx @@ -45,6 +45,7 @@ import Layout from "./scenes/Layout" import { theme } from "./theme" import { LocalStorageProvider } from "./providers/LocalStorageProvider" import { AuthProvider, QuestProvider, SettingsProvider, PosthogProviderWrapper } from "./providers" +import { registerDownloadServiceWorker } from "./utils/serviceWorker" const epicMiddleware = createEpicMiddleware< StoreAction, @@ -63,6 +64,8 @@ const FadeSlow = createGlobalFadeTransition( TransitionDuration.SLOW, ) +registerDownloadServiceWorker() + ReactDOM.render( diff --git a/packages/web-console/src/scenes/Result/index.tsx b/packages/web-console/src/scenes/Result/index.tsx index 7cc1cd83b..f0feafea4 100644 --- a/packages/web-console/src/scenes/Result/index.tsx +++ b/packages/web-console/src/scenes/Result/index.tsx @@ -48,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" @@ -57,6 +57,7 @@ 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, LoadingSpinner } from "../../components" const Root = styled.div` display: flex; @@ -136,6 +137,8 @@ const DownloadMenuItem = styled.button` const Result = ({ viewMode }: { viewMode: ResultViewMode }) => { const { quest } = useContext(QuestContext) const [count, setCount] = useState() + const [downloadingQueries, setDownloadingQueries] = useState>(new Set()) + const [currentQuery, setCurrentQuery] = useState() const result = useSelector(selectors.query.getResult) const activeSidebar = useSelector(selectors.console.getActiveSidebar) const gridRef = useRef() @@ -297,15 +300,31 @@ const Result = ({ viewMode }: { viewMode: ResultViewMode }) => { useEffect(() => { if (result?.type === QuestDB.Type.DQL) { setCount(result.count) + setCurrentQuery(result.query) } }, [result]) - const handleDownload = (format: "csv" | "parquet") => { + const handleDownload = async (format: "csv" | "parquet") => { + setDownloadMenuActive(false) const sql = gridRef?.current?.getSQL() if (sql) { - quest.exportQuery(sql, format) + try { + setDownloadingQueries((prev) => { + prev.add(sql) + return new Set(prev) + }) + await quest.exportQuery(sql, format) + } catch (error) { + toast.error((error as Error).message) + } finally { + setDownloadingQueries((prev) => { + prev.delete(sql) + return new Set(prev) + }) + } + } else { + toast.error("No SQL query found") } - setDownloadMenuActive(false) } return ( @@ -337,35 +356,36 @@ const Result = ({ viewMode }: { viewMode: ResultViewMode }) => { { name: "offset", options: { - offset: [0, 8], + offset: [0, 4], }, }, ]} trigger={ - - + + {currentQuery && downloadingQueries.has(currentQuery) ? ( + + + Preparing download + + ) : ( + <> + Download - - } - > - Download result - - + + )} + } > - handleDownload("csv")}> - CSV - Download as CSV - - handleDownload("parquet")}> + handleDownload("parquet")} data-hook="download-parquet-button"> Parquet Download as Parquet + handleDownload("csv")} data-hook="download-csv-button"> + 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 9794e04a3..e592dfc19 100644 --- a/packages/web-console/src/utils/questdb/client.ts +++ b/packages/web-console/src/utils/questdb/client.ts @@ -25,7 +25,8 @@ import { Permission, SymbolColumnDetails, } from "./types" -import { ssoAuthState } from "../../modules/OAuth2/ssoAuthState"; +import { ssoAuthState } from "../../modules/OAuth2/ssoAuthState" +import { updateServiceWorkerAuthToken, getIsServiceWorkerReady } from "../serviceWorker" export class Client { private _controllers: AbortController[] = [] @@ -48,6 +49,7 @@ export class Client { setCommonHeaders(headers: Record) { this.commonHeaders = headers + updateServiceWorkerAuthToken(headers.Authorization) } private refreshAuthToken = async () => { @@ -453,31 +455,77 @@ export class Client { return { status: response.status, success: true } } - async exportQuery(query: string, format: "csv" | "parquet") { + async exportQuery(query: string, format: "csv" | "parquet"): Promise { + const requestKey = `questdb-query-${Date.now().toString()}` + const url = `exp?${Client.encodeParams({ + query, + version: API_VERSION, + fmt: format, + filename: requestKey, + ...(format === "parquet" ? { parquetVersion: "1" } : {}), + })}` + + if (getIsServiceWorkerReady()) { + return new Promise((resolve, reject) => { + const iframe = document.createElement('iframe') + iframe.style.display = 'none' + + const cleanup = () => { + if (iframe.parentNode) { + document.body.removeChild(iframe) + } + navigator.serviceWorker.removeEventListener('message', eventListener) + } + + const eventListener = (event: MessageEvent) => { + if (event.data.type === `DOWNLOAD_ERROR_${requestKey}`) { + cleanup() + reject(new Error(`Download failed with status code ${event.data.status}: ${event.data.message}`)) + } + if (event.data.type === `DOWNLOAD_START_${requestKey}`) { + cleanup() + resolve() + } + } + navigator.serviceWorker.addEventListener('message', eventListener) + + iframe.onerror = () => { + cleanup() + reject(new Error('Download failed')) + } + + document.body.appendChild(iframe) + iframe.src = url + }) + } + + // Fallback to blob approach if service worker is not registered + const response: Response = await fetch(url, { headers: this.commonHeaders }) + if (!response.ok) { + let message = response.statusText + try { + const json = await response.json() + message = json.error ?? message + } catch (_) {} + throw new Error(`Download failed with status code ${response.status}: ${message}`) + } + try { - const response: Response = await fetch( - `exp?${Client.encodeParams({ - query, - version: API_VERSION, - fmt: format, - ...(format === "parquet" ? { parquetVersion: "1" } : {}), - })}`, - { headers: this.commonHeaders }, - ) const blob = await response.blob() const filename = response.headers .get("Content-Disposition") ?.split("=")[1] - const url = window.URL.createObjectURL(blob) + const objUrl = window.URL.createObjectURL(blob) const a = document.createElement("a") - a.href = url + a.href = objUrl a.download = filename ? filename.replaceAll(`"`, "") : `questdb-query-${new Date().getTime()}.${format}` a.click() - window.URL.revokeObjectURL(url) + window.URL.revokeObjectURL(objUrl) } catch (error) { - throw error + const message = error instanceof Error ? error.message : error as string + throw new Error(`Download failed while creating the file: ${message}`) } } diff --git a/packages/web-console/src/utils/serviceWorker.ts b/packages/web-console/src/utils/serviceWorker.ts new file mode 100644 index 000000000..5bdc85239 --- /dev/null +++ b/packages/web-console/src/utils/serviceWorker.ts @@ -0,0 +1,50 @@ +let serviceWorkerRegistration: ServiceWorkerRegistration | null = null; +let pendingAuthToken: string | null = null; + +export const registerDownloadServiceWorker = async (): Promise => { + if (!('serviceWorker' in navigator)) { + console.warn('Service Workers are not supported in this browser') + return + } + + try { + const registration = await navigator.serviceWorker.register('/download-sw.js', { + scope: '/', + }) + serviceWorkerRegistration = registration + await navigator.serviceWorker.ready + await processPendingAuthToken() + } catch (error) { + console.error('Service Worker registration failed:', error) + } +} + +export const getIsServiceWorkerReady = () => { + return serviceWorkerRegistration !== null && serviceWorkerRegistration.active !== null +} + +const processPendingAuthToken = async (): Promise => { + if (!serviceWorkerRegistration?.active) { + return + } + + if (pendingAuthToken) { + serviceWorkerRegistration.active?.postMessage({ + type: 'SET_AUTH_TOKEN', + token: pendingAuthToken, + }) + pendingAuthToken = null + } +} + +export const updateServiceWorkerAuthToken = (token: string) => { + pendingAuthToken = token + + if (getIsServiceWorkerReady()) { + serviceWorkerRegistration?.active?.postMessage({ + type: 'SET_AUTH_TOKEN', + token: token, + }) + pendingAuthToken = null + } +} \ No newline at end of file diff --git a/packages/web-console/webpack.config.js b/packages/web-console/webpack.config.js index 1923b8e9d..ac247b095 100644 --- a/packages/web-console/webpack.config.js +++ b/packages/web-console/webpack.config.js @@ -197,6 +197,7 @@ module.exports = { new CopyWebpackPlugin({ patterns: [ { from: "./assets/", to: "assets/" }, + { from: path.resolve(__dirname, "public"), to: "." }, ...monacoConfig.assetCopyPatterns, ], }), @@ -214,6 +215,7 @@ module.exports = { new CopyWebpackPlugin({ patterns: [ { from: "./assets/", to: "assets/" }, + { from: path.resolve(__dirname, "public"), to: "." }, ...monacoConfig.assetCopyPatterns, ...monacoConfig.sourceMapCopyPatterns, ], From a04e543364ea6d70ed34e956f72cc2262ff7bf30 Mon Sep 17 00:00:00 2001 From: emrberk Date: Mon, 13 Oct 2025 12:16:19 +0300 Subject: [PATCH 03/15] update submodule and loader text --- packages/browser-tests/questdb | 2 +- packages/web-console/src/scenes/Result/index.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/browser-tests/questdb b/packages/browser-tests/questdb index bdd0fbf67..ec6bd7e3c 160000 --- a/packages/browser-tests/questdb +++ b/packages/browser-tests/questdb @@ -1 +1 @@ -Subproject commit bdd0fbf6784312962761566bba26665e5fb8ab6e +Subproject commit ec6bd7e3cca081992e356a977a98834532258012 diff --git a/packages/web-console/src/scenes/Result/index.tsx b/packages/web-console/src/scenes/Result/index.tsx index f0feafea4..7a36af433 100644 --- a/packages/web-console/src/scenes/Result/index.tsx +++ b/packages/web-console/src/scenes/Result/index.tsx @@ -365,7 +365,7 @@ const Result = ({ viewMode }: { viewMode: ResultViewMode }) => { {currentQuery && downloadingQueries.has(currentQuery) ? ( - Preparing download + Preparing the file ) : ( <> From 1cc589ab7bdcc4de7c7f3f5816e791945724243f Mon Sep 17 00:00:00 2001 From: emrberk Date: Mon, 13 Oct 2025 15:41:48 +0300 Subject: [PATCH 04/15] handle safari downloads with new tab, fix token handling in service worker controller change --- .../web-console/src/utils/questdb/client.ts | 9 +++++ .../web-console/src/utils/serviceWorker.ts | 35 +++++++++---------- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/packages/web-console/src/utils/questdb/client.ts b/packages/web-console/src/utils/questdb/client.ts index e592dfc19..f6b7412ac 100644 --- a/packages/web-console/src/utils/questdb/client.ts +++ b/packages/web-console/src/utils/questdb/client.ts @@ -466,6 +466,15 @@ export class Client { })}` if (getIsServiceWorkerReady()) { + const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent) + if (isSafari) { + const a = document.createElement("a") + a.target = '_blank' + a.href = url + a.click() + return + } + return new Promise((resolve, reject) => { const iframe = document.createElement('iframe') iframe.style.display = 'none' diff --git a/packages/web-console/src/utils/serviceWorker.ts b/packages/web-console/src/utils/serviceWorker.ts index 5bdc85239..96bae1d79 100644 --- a/packages/web-console/src/utils/serviceWorker.ts +++ b/packages/web-console/src/utils/serviceWorker.ts @@ -1,5 +1,5 @@ -let serviceWorkerRegistration: ServiceWorkerRegistration | null = null; -let pendingAuthToken: string | null = null; +let serviceWorkerRegistration: ServiceWorkerRegistration | null = null +let currentAuthToken: string | null = null export const registerDownloadServiceWorker = async (): Promise => { if (!('serviceWorker' in navigator)) { @@ -12,8 +12,14 @@ export const registerDownloadServiceWorker = async (): Promise => { scope: '/', }) serviceWorkerRegistration = registration + + navigator.serviceWorker.addEventListener('controllerchange', () => { + console.log('[SW] Controller changed') + sendAuthTokenToServiceWorker() + }) + await navigator.serviceWorker.ready - await processPendingAuthToken() + sendAuthTokenToServiceWorker() } catch (error) { console.error('Service Worker registration failed:', error) } @@ -23,28 +29,21 @@ export const getIsServiceWorkerReady = () => { return serviceWorkerRegistration !== null && serviceWorkerRegistration.active !== null } -const processPendingAuthToken = async (): Promise => { +const sendAuthTokenToServiceWorker = (): void => { if (!serviceWorkerRegistration?.active) { return } - if (pendingAuthToken) { - serviceWorkerRegistration.active?.postMessage({ - type: 'SET_AUTH_TOKEN', - token: pendingAuthToken, - }) - pendingAuthToken = null - } + serviceWorkerRegistration.active.postMessage({ + type: 'SET_AUTH_TOKEN', + token: currentAuthToken, + }) } export const updateServiceWorkerAuthToken = (token: string) => { - pendingAuthToken = token + currentAuthToken = token if (getIsServiceWorkerReady()) { - serviceWorkerRegistration?.active?.postMessage({ - type: 'SET_AUTH_TOKEN', - token: token, - }) - pendingAuthToken = null + sendAuthTokenToServiceWorker() } -} \ No newline at end of file +} From c5279d55a89b33a266a08d39db60a1afeb7949e6 Mon Sep 17 00:00:00 2001 From: emrberk Date: Mon, 13 Oct 2025 15:54:28 +0300 Subject: [PATCH 05/15] fix type and quotes --- packages/web-console/src/scenes/Result/index.tsx | 2 +- packages/web-console/src/utils/questdb/client.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/web-console/src/scenes/Result/index.tsx b/packages/web-console/src/scenes/Result/index.tsx index 7a36af433..0a20e51e5 100644 --- a/packages/web-console/src/scenes/Result/index.tsx +++ b/packages/web-console/src/scenes/Result/index.tsx @@ -383,7 +383,7 @@ const Result = ({ viewMode }: { viewMode: ResultViewMode }) => { Download as Parquet handleDownload("csv")} data-hook="download-csv-button"> - CSV + CSV Download as CSV diff --git a/packages/web-console/src/utils/questdb/client.ts b/packages/web-console/src/utils/questdb/client.ts index f6b7412ac..9a202b317 100644 --- a/packages/web-console/src/utils/questdb/client.ts +++ b/packages/web-console/src/utils/questdb/client.ts @@ -469,7 +469,7 @@ export class Client { const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent) if (isSafari) { const a = document.createElement("a") - a.target = '_blank' + a.target = "_blank" a.href = url a.click() return @@ -533,7 +533,7 @@ export class Client { a.click() window.URL.revokeObjectURL(objUrl) } catch (error) { - const message = error instanceof Error ? error.message : error as string + const message = error instanceof Error ? error.message : String(error) throw new Error(`Download failed while creating the file: ${message}`) } } From e873229aa0a91a9dfcf3301ae523b5262e092883 Mon Sep 17 00:00:00 2001 From: emrberk Date: Mon, 13 Oct 2025 16:01:18 +0300 Subject: [PATCH 06/15] more on reviews --- packages/web-console/public/download-sw.js | 6 +++--- packages/web-console/src/scenes/Result/index.tsx | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/web-console/public/download-sw.js b/packages/web-console/public/download-sw.js index 4a9ff7c6b..015c5fa09 100755 --- a/packages/web-console/public/download-sw.js +++ b/packages/web-console/public/download-sw.js @@ -30,9 +30,9 @@ self.addEventListener('fetch', (event) => { let message = response.statusText try { const json = await response.json() - const error = json.error - if (error) { - message = error + const errorMessage = json.error + if (errorMessage) { + message = errorMessage } } catch (_) {} diff --git a/packages/web-console/src/scenes/Result/index.tsx b/packages/web-console/src/scenes/Result/index.tsx index 0a20e51e5..a89ab6886 100644 --- a/packages/web-console/src/scenes/Result/index.tsx +++ b/packages/web-console/src/scenes/Result/index.tsx @@ -310,16 +310,16 @@ const Result = ({ viewMode }: { viewMode: ResultViewMode }) => { if (sql) { try { setDownloadingQueries((prev) => { - prev.add(sql) - return new Set(prev) + return new Set(prev).add(sql) }) await quest.exportQuery(sql, format) } catch (error) { toast.error((error as Error).message) } finally { setDownloadingQueries((prev) => { - prev.delete(sql) - return new Set(prev) + const newSet = new Set(prev) + newSet.delete(sql) + return newSet }) } } else { From dfcd28a1077786ecf6434967036a98358781a159 Mon Sep 17 00:00:00 2001 From: emrberk Date: Mon, 13 Oct 2025 20:16:52 +0300 Subject: [PATCH 07/15] show parquet download as the default action --- .../integration/console/download.spec.js | 17 ++-- .../web-console/src/scenes/Result/index.tsx | 91 ++++++++++--------- 2 files changed, 56 insertions(+), 52 deletions(-) diff --git a/packages/browser-tests/cypress/integration/console/download.spec.js b/packages/browser-tests/cypress/integration/console/download.spec.js index c80ecef29..45ccbd62c 100644 --- a/packages/browser-tests/cypress/integration/console/download.spec.js +++ b/packages/browser-tests/cypress/integration/console/download.spec.js @@ -9,14 +9,15 @@ describe("download functionality", () => { cy.runLine(); // Then - cy.getByDataHook("result-download-button").should("be.visible"); + 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("result-download-button").click(); + cy.getByDataHook("download-dropdown-button").click(); // Then cy.getByDataHook("download-csv-button").should("be.visible"); - cy.getByDataHook("download-parquet-button").should("be.visible"); }); it("should trigger CSV download", () => { @@ -33,7 +34,7 @@ describe("download functionality", () => { // When cy.typeQuery(query); cy.runLine(); - cy.getByDataHook("result-download-button").click(); + cy.getByDataHook("download-dropdown-button").click(); cy.getByDataHook("download-csv-button").click(); // Then @@ -59,7 +60,6 @@ describe("download functionality", () => { // When cy.typeQuery(query); cy.runLine(); - cy.getByDataHook("result-download-button").click(); cy.getByDataHook("download-parquet-button").click(); // Then @@ -83,7 +83,7 @@ describe("download functionality", () => { // When cy.typeQuery("select x from long_sequence(5)"); cy.runLine(); - cy.getByDataHook("result-download-button").click(); + cy.getByDataHook("download-dropdown-button").click(); cy.getByDataHook("download-csv-button").click(); // Then @@ -106,7 +106,7 @@ describe("download functionality", () => { // When cy.typeQuery("select x from long_sequence(5)"); cy.runLine(); - cy.getByDataHook("result-download-button").click(); + cy.getByDataHook("download-dropdown-button").click(); cy.getByDataHook("download-csv-button").click(); // Then @@ -131,7 +131,6 @@ describe("download functionality", () => { // When cy.typeQuery("select * from long_sequence(10)"); cy.runLine(); - cy.getByDataHook("result-download-button").click(); cy.getByDataHook("download-parquet-button").click(); // Then @@ -151,7 +150,7 @@ describe("download functionality", () => { // When cy.typeQuery(query); cy.runLine(); - cy.getByDataHook("result-download-button").click(); + cy.getByDataHook("download-dropdown-button").click(); cy.getByDataHook("download-csv-button").click(); // Then diff --git a/packages/web-console/src/scenes/Result/index.tsx b/packages/web-console/src/scenes/Result/index.tsx index a89ab6886..34974690a 100644 --- a/packages/web-console/src/scenes/Result/index.tsx +++ b/packages/web-console/src/scenes/Result/index.tsx @@ -105,33 +105,28 @@ const DownloadButton = styled(Button)` display: flex; align-items: center; gap: 0.5rem; - padding-right: 0.7rem; + padding: 0 1rem; + border-top-right-radius: 0; + border-bottom-right-radius: 0; ` -const DownloadMenu = styled.div` - min-width: 200px; - background: ${({ theme }) => theme.color.backgroundDarker}; - border: 1px solid ${({ theme }) => theme.color.gray1}; - border-radius: 0.4rem; - overflow: hidden; +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` +const DownloadMenuItem = styled(Button)` display: flex; align-items: center; gap: 1.2rem; width: 100%; - padding: 0.75rem 1rem; - text-align: left; - background: transparent; - border: none; - color: ${({ theme }) => theme.color.foreground}; - cursor: pointer; - font-size: 13px; - - &:hover { - background: ${({ theme }) => theme.color.selection}; - } + height: 3rem; + padding: 0 1rem; + font-size: 1.4rem; ` const Result = ({ viewMode }: { viewMode: ResultViewMode }) => { @@ -145,6 +140,7 @@ const Result = ({ viewMode }: { viewMode: ResultViewMode }) => { const [gridFreezeLeftState, setGridFreezeLeftState] = useState(0) const [downloadMenuActive, setDownloadMenuActive] = useState(false) const dispatch = useDispatch() + const isDownloading = !!currentQuery && downloadingQueries.has(currentQuery) useEffect(() => { const _grid = grid( @@ -348,6 +344,24 @@ const Result = ({ viewMode }: { viewMode: ResultViewMode }) => { ))} + handleDownload("parquet")} + > + {isDownloading ? ( + + + Preparing the file + + ) : ( + <> + + Download as Parquet + + )} + { }, ]} trigger={ - - {currentQuery && downloadingQueries.has(currentQuery) ? ( - - - Preparing the file - - ) : ( - <> - - Download - - - )} - + + + } > - - handleDownload("parquet")} data-hook="download-parquet-button"> - Parquet - Download as Parquet - - handleDownload("csv")} data-hook="download-csv-button"> - CSV - Download as CSV - - + handleDownload("csv")} + data-hook="download-csv-button" + skin="secondary" + > + + Download as CSV + From a5d7030352e05f05efa3cfb15c3501aa74e1c87c Mon Sep 17 00:00:00 2001 From: emrberk Date: Mon, 13 Oct 2025 20:50:32 +0300 Subject: [PATCH 08/15] remove icons --- packages/web-console/assets/csv-file.svg | 5 ----- packages/web-console/assets/parquet-file.svg | 5 ----- packages/web-console/src/scenes/Result/index.tsx | 7 +++---- 3 files changed, 3 insertions(+), 14 deletions(-) delete mode 100644 packages/web-console/assets/csv-file.svg delete mode 100644 packages/web-console/assets/parquet-file.svg diff --git a/packages/web-console/assets/csv-file.svg b/packages/web-console/assets/csv-file.svg deleted file mode 100644 index 7713717a7..000000000 --- a/packages/web-console/assets/csv-file.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/packages/web-console/assets/parquet-file.svg b/packages/web-console/assets/parquet-file.svg deleted file mode 100644 index 3c62a6676..000000000 --- a/packages/web-console/assets/parquet-file.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/packages/web-console/src/scenes/Result/index.tsx b/packages/web-console/src/scenes/Result/index.tsx index 34974690a..fbf85bf57 100644 --- a/packages/web-console/src/scenes/Result/index.tsx +++ b/packages/web-console/src/scenes/Result/index.tsx @@ -356,10 +356,10 @@ const Result = ({ viewMode }: { viewMode: ResultViewMode }) => { Preparing the file ) : ( - <> - + + Download as Parquet - + )} { data-hook="download-csv-button" skin="secondary" > - Download as CSV From 62869971747b0dee61587682b83098a414d44603 Mon Sep 17 00:00:00 2001 From: emrberk Date: Mon, 13 Oct 2025 22:45:20 +0300 Subject: [PATCH 09/15] adjust line height of the text --- packages/web-console/src/scenes/Result/index.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/web-console/src/scenes/Result/index.tsx b/packages/web-console/src/scenes/Result/index.tsx index fbf85bf57..701b3b2a0 100644 --- a/packages/web-console/src/scenes/Result/index.tsx +++ b/packages/web-console/src/scenes/Result/index.tsx @@ -99,6 +99,7 @@ const TableFreezeColumnIcon = styled(TableFreezeColumn)` const RowCount = styled(Text)` margin-right: 1rem; + line-height: 1.285; ` const DownloadButton = styled(Button)` @@ -356,8 +357,8 @@ const Result = ({ viewMode }: { viewMode: ResultViewMode }) => { Preparing the file ) : ( - - + + Download as Parquet )} From 12addd24a60a83bb66ca610265904f56caf2f9be Mon Sep 17 00:00:00 2001 From: emrberk Date: Mon, 13 Oct 2025 23:10:38 +0300 Subject: [PATCH 10/15] animation --- .../web-console/src/scenes/Result/index.tsx | 36 ++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/web-console/src/scenes/Result/index.tsx b/packages/web-console/src/scenes/Result/index.tsx index 701b3b2a0..85aa7b318 100644 --- a/packages/web-console/src/scenes/Result/index.tsx +++ b/packages/web-console/src/scenes/Result/index.tsx @@ -25,7 +25,7 @@ import $ from "jquery" import React, { useContext, useEffect, useRef, useState } from "react" import { useDispatch, useSelector } from "react-redux" -import styled from "styled-components" +import styled, { keyframes } from "styled-components" import { Download2, Refresh } from "@styled-icons/remix-line" import { Reset } from "@styled-icons/boxicons-regular" import { HandPointLeft } from "@styled-icons/fa-regular" @@ -111,6 +111,34 @@ const DownloadButton = styled(Button)` border-bottom-right-radius: 0; ` +const SlideKeyframes = keyframes` + from { + background-position: 200% center; + } + to { + background-position: -200% center; + } +` + +const AnimatedText = styled(Text)` + background: linear-gradient( + 90deg, + ${color("gray2")} 0%, + ${color("gray2")} 40%, + ${color("white")} 50%, + ${color("gray2")} 60%, + ${color("gray2")} 100% + ); + background-size: 200% 100%; + background-clip: text; + /* stylelint-disable-next-line property-no-vendor-prefix */ + -webkit-background-clip: text; + /* stylelint-disable-next-line property-no-vendor-prefix */ + -webkit-text-fill-color: transparent; + color: transparent !important; + animation: ${SlideKeyframes} 3s linear infinite; +` + const DownloadDropdownButton = styled(Button)` display: flex; align-items: center; @@ -352,9 +380,9 @@ const Result = ({ viewMode }: { viewMode: ResultViewMode }) => { onClick={() => handleDownload("parquet")} > {isDownloading ? ( - + - Preparing the file + Preparing the file ) : ( @@ -379,7 +407,7 @@ const Result = ({ viewMode }: { viewMode: ResultViewMode }) => { From 967bc2f67b16a0abb424be2bae6e9e29265adcaf Mon Sep 17 00:00:00 2001 From: emrberk Date: Tue, 14 Oct 2025 15:58:31 +0300 Subject: [PATCH 11/15] simplify the auth flow, add fallbacks --- packages/browser-tests/questdb | 2 +- packages/web-console/public/download-sw.js | 10 ++ .../web-console/src/scenes/Result/index.tsx | 4 +- packages/web-console/src/utils/platform.ts | 3 + .../web-console/src/utils/questdb/client.ts | 142 +++++++++++------- .../web-console/src/utils/serviceWorker.ts | 52 ++++--- 6 files changed, 134 insertions(+), 79 deletions(-) diff --git a/packages/browser-tests/questdb b/packages/browser-tests/questdb index ec6bd7e3c..fc057a560 160000 --- a/packages/browser-tests/questdb +++ b/packages/browser-tests/questdb @@ -1 +1 @@ -Subproject commit ec6bd7e3cca081992e356a977a98834532258012 +Subproject commit fc057a56061a77376024771969bf67684f619e22 diff --git a/packages/web-console/public/download-sw.js b/packages/web-console/public/download-sw.js index 015c5fa09..d9e96f9fb 100755 --- a/packages/web-console/public/download-sw.js +++ b/packages/web-console/public/download-sw.js @@ -3,11 +3,21 @@ let authToken = null self.addEventListener('message', (event) => { if (event.data && event.data.type === 'SET_AUTH_TOKEN') { authToken = event.data.token + self.clients.matchAll().then((clients) => { + clients.forEach((client) => { + client.postMessage({ + type: 'AUTH_TOKEN_ACK', + }) + }) + }) } }) self.addEventListener('fetch', (event) => { const url = new URL(event.request.url) + if (url.searchParams.get('noAuth')) { + return + } if (url.pathname === '/exp' || url.pathname.endsWith('/exp')) { const requestKey = new URL(event.request.url).searchParams.get('filename') diff --git a/packages/web-console/src/scenes/Result/index.tsx b/packages/web-console/src/scenes/Result/index.tsx index 85aa7b318..701bba4ae 100644 --- a/packages/web-console/src/scenes/Result/index.tsx +++ b/packages/web-console/src/scenes/Result/index.tsx @@ -53,6 +53,7 @@ import type { IQuestDBGrid } from "../../js/console/grid.js" import { eventBus } from "../../modules/EventBus" import { EventType } from "../../modules/EventBus/types" import { QuestContext } from "../../providers" +import { useSettings } from "../../providers/SettingsProvider" import { LINE_NUMBER_HARD_LIMIT } from "../Editor/Monaco" import { QueryInNotification } from "../Editor/Monaco/query-in-notification" import { NotificationType } from "../../store/Query/types" @@ -160,6 +161,7 @@ const DownloadMenuItem = styled(Button)` const Result = ({ viewMode }: { viewMode: ResultViewMode }) => { const { quest } = useContext(QuestContext) + const { settings } = useSettings() const [count, setCount] = useState() const [downloadingQueries, setDownloadingQueries] = useState>(new Set()) const [currentQuery, setCurrentQuery] = useState() @@ -337,7 +339,7 @@ const Result = ({ viewMode }: { viewMode: ResultViewMode }) => { setDownloadingQueries((prev) => { return new Set(prev).add(sql) }) - await quest.exportQuery(sql, format) + await quest.exportQuery(sql, format, settings["acl.enabled"]) } catch (error) { toast.error((error as Error).message) } finally { diff --git a/packages/web-console/src/utils/platform.ts b/packages/web-console/src/utils/platform.ts index d2860a0e5..f0a7f145a 100644 --- a/packages/web-console/src/utils/platform.ts +++ b/packages/web-console/src/utils/platform.ts @@ -3,6 +3,7 @@ type Platform = { isWindows: boolean isIOS: boolean isLinux: boolean + isSafari: boolean } export const platform: Platform = { @@ -10,6 +11,7 @@ export const platform: Platform = { isWindows: false, isIOS: false, isLinux: false, + isSafari: false, } if (typeof navigator === "object") { @@ -21,4 +23,5 @@ if (typeof navigator === "object") { navigator.userAgent.indexOf("iPhone") >= 0) && !!navigator.maxTouchPoints && navigator.maxTouchPoints > 0 + platform.isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent) } diff --git a/packages/web-console/src/utils/questdb/client.ts b/packages/web-console/src/utils/questdb/client.ts index 9a202b317..29a5f646e 100644 --- a/packages/web-console/src/utils/questdb/client.ts +++ b/packages/web-console/src/utils/questdb/client.ts @@ -1,4 +1,4 @@ -import { isServerError } from "../../utils" +import { isServerError, platform } from "../../utils" import { TelemetryConfigShape } from "../../store/Telemetry/types" import { eventBus } from "../../modules/EventBus" import { EventType } from "../../modules/EventBus/types" @@ -26,7 +26,7 @@ import { SymbolColumnDetails, } from "./types" import { ssoAuthState } from "../../modules/OAuth2/ssoAuthState" -import { updateServiceWorkerAuthToken, getIsServiceWorkerReady } from "../serviceWorker" +import { getIsServiceWorkerReady, setServiceWorkerAuth } from "../serviceWorker" export class Client { private _controllers: AbortController[] = [] @@ -49,7 +49,6 @@ export class Client { setCommonHeaders(headers: Record) { this.commonHeaders = headers - updateServiceWorkerAuthToken(headers.Authorization) } private refreshAuthToken = async () => { @@ -455,60 +454,21 @@ export class Client { return { status: response.status, success: true } } - async exportQuery(query: string, format: "csv" | "parquet"): Promise { - const requestKey = `questdb-query-${Date.now().toString()}` - const url = `exp?${Client.encodeParams({ - query, - version: API_VERSION, - fmt: format, - filename: requestKey, - ...(format === "parquet" ? { parquetVersion: "1" } : {}), - })}` - - if (getIsServiceWorkerReady()) { - const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent) - if (isSafari) { - const a = document.createElement("a") - a.target = "_blank" - a.href = url - a.click() - return - } - - return new Promise((resolve, reject) => { - const iframe = document.createElement('iframe') - iframe.style.display = 'none' - - const cleanup = () => { - if (iframe.parentNode) { - document.body.removeChild(iframe) - } - navigator.serviceWorker.removeEventListener('message', eventListener) - } - - const eventListener = (event: MessageEvent) => { - if (event.data.type === `DOWNLOAD_ERROR_${requestKey}`) { - cleanup() - reject(new Error(`Download failed with status code ${event.data.status}: ${event.data.message}`)) - } - if (event.data.type === `DOWNLOAD_START_${requestKey}`) { - cleanup() - resolve() - } - } - navigator.serviceWorker.addEventListener('message', eventListener) - - iframe.onerror = () => { - cleanup() - reject(new Error('Download failed')) - } + openDownloadTab(url: string) { + const a = document.createElement("a") + a.target = "_blank" + a.href = url + a.click() + } - document.body.appendChild(iframe) - iframe.src = url - }) - } + createHiddenIframe() { + const iframe = document.createElement("iframe") + iframe.style.display = "none" + document.body.appendChild(iframe) + return iframe + } - // Fallback to blob approach if service worker is not registered + async downloadWithBlob(url: string, format: "csv" | "parquet") { const response: Response = await fetch(url, { headers: this.commonHeaders }) if (!response.ok) { let message = response.statusText @@ -538,6 +498,78 @@ export class Client { } } + async downloadWithServiceWorker(url: string, requestKey: string): Promise { + return new Promise((resolve, reject) => { + if (platform.isSafari) { + this.openDownloadTab(url) + resolve() + } + const iframe = this.createHiddenIframe() + const cleanup = () => { + if (iframe.parentNode) { + document.body.removeChild(iframe) + } + navigator.serviceWorker.removeEventListener('message', eventListener) + } + + const eventListener = (event: MessageEvent) => { + if (event.data.type === `DOWNLOAD_ERROR_${requestKey}`) { + cleanup() + reject(new Error(`Download failed with status code ${event.data.status}: ${event.data.message}`)) + } + if (event.data.type === `DOWNLOAD_START_${requestKey}`) { + cleanup() + resolve() + } + } + navigator.serviceWorker.addEventListener('message', eventListener) + + iframe.onerror = () => { + cleanup() + reject(new Error('Download failed')) + } + + iframe.src = url + }) + } + + async exportQuery(query: string, format: "csv" | "parquet", isAclEnabled: boolean | undefined): Promise { + const authToken = this.commonHeaders.Authorization + const requestKey = `questdb-query-${Date.now().toString()}` + const url = `exp?${Client.encodeParams({ + query, + version: API_VERSION, + fmt: format, + filename: requestKey, + ...(!isAclEnabled ? { noAuth: true } : {}), + ...(format === "parquet" ? { parquetVersion: "1" } : {}), + })}` + + // No auth token, service worker will not intercept + if (!isAclEnabled) { + if (platform.isSafari) { + this.openDownloadTab(url) + return + } + const iframe = this.createHiddenIframe() + iframe.src = url + return + } + + // Authentication to be handled by service worker + if (getIsServiceWorkerReady()) { + try { + await setServiceWorkerAuth(authToken, 2000) + } catch (_) { + return this.downloadWithBlob(url, format) + } + return this.downloadWithServiceWorker(url, requestKey) + } + + // Fallback to blob approach if service worker is not registered + return this.downloadWithBlob(url, format) + } + async getLatestRelease() { try { const response: Response = await fetch( diff --git a/packages/web-console/src/utils/serviceWorker.ts b/packages/web-console/src/utils/serviceWorker.ts index 96bae1d79..2e9c378d4 100644 --- a/packages/web-console/src/utils/serviceWorker.ts +++ b/packages/web-console/src/utils/serviceWorker.ts @@ -1,5 +1,4 @@ let serviceWorkerRegistration: ServiceWorkerRegistration | null = null -let currentAuthToken: string | null = null export const registerDownloadServiceWorker = async (): Promise => { if (!('serviceWorker' in navigator)) { @@ -13,13 +12,7 @@ export const registerDownloadServiceWorker = async (): Promise => { }) serviceWorkerRegistration = registration - navigator.serviceWorker.addEventListener('controllerchange', () => { - console.log('[SW] Controller changed') - sendAuthTokenToServiceWorker() - }) - await navigator.serviceWorker.ready - sendAuthTokenToServiceWorker() } catch (error) { console.error('Service Worker registration failed:', error) } @@ -29,21 +22,36 @@ export const getIsServiceWorkerReady = () => { return serviceWorkerRegistration !== null && serviceWorkerRegistration.active !== null } -const sendAuthTokenToServiceWorker = (): void => { - if (!serviceWorkerRegistration?.active) { - return - } +export const setServiceWorkerAuth = (token: string, timeoutMs: number = 2000): Promise => { + return new Promise((resolve, reject) => { + if (!getIsServiceWorkerReady()) { + return reject() + } + let resolved = false + + const onMessage = (event: MessageEvent) => { + if (event.data && event.data.type === 'AUTH_TOKEN_ACK') { + resolved = true + cleanup() + resolve() + } + } + const cleanup = () => { + navigator.serviceWorker.removeEventListener('message', onMessage) + } + navigator.serviceWorker.addEventListener('message', onMessage) + + serviceWorkerRegistration?.active?.postMessage({ + type: 'SET_AUTH_TOKEN', + token, + }) - serviceWorkerRegistration.active.postMessage({ - type: 'SET_AUTH_TOKEN', - token: currentAuthToken, + setTimeout(() => { + if (!resolved) { + console.warn('[SW] Auth token ack timed out') + cleanup() + reject() + } + }, timeoutMs) }) } - -export const updateServiceWorkerAuthToken = (token: string) => { - currentAuthToken = token - - if (getIsServiceWorkerReady()) { - sendAuthTokenToServiceWorker() - } -} From b0fcf06c5a4081b55b67e10fe23d149b661e2e01 Mon Sep 17 00:00:00 2001 From: ideoma <2159629+ideoma@users.noreply.github.com> Date: Tue, 14 Oct 2025 17:27:24 +0100 Subject: [PATCH 12/15] add nodelay request param --- packages/web-console/src/utils/questdb/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web-console/src/utils/questdb/client.ts b/packages/web-console/src/utils/questdb/client.ts index 29a5f646e..b66eea1a8 100644 --- a/packages/web-console/src/utils/questdb/client.ts +++ b/packages/web-console/src/utils/questdb/client.ts @@ -542,7 +542,7 @@ export class Client { fmt: format, filename: requestKey, ...(!isAclEnabled ? { noAuth: true } : {}), - ...(format === "parquet" ? { parquetVersion: "1" } : {}), + ...(format === "parquet" ? { rmode: "nodelay" } : {}), })}` // No auth token, service worker will not intercept From 665a48b3b2047fc17f67eb876ece44518a1a6a25 Mon Sep 17 00:00:00 2001 From: emrberk Date: Wed, 15 Oct 2025 15:09:45 +0300 Subject: [PATCH 13/15] convert download flow to use link directly --- .../integration/console/download.spec.js | 99 +-------------- packages/web-console/public/download-sw.js | 91 -------------- packages/web-console/src/index.tsx | 3 - .../web-console/src/scenes/Result/index.tsx | 93 ++++---------- packages/web-console/src/utils/platform.ts | 3 - .../web-console/src/utils/questdb/client.ts | 119 +----------------- .../web-console/src/utils/serviceWorker.ts | 57 --------- packages/web-console/webpack.config.js | 2 - 8 files changed, 27 insertions(+), 440 deletions(-) delete mode 100755 packages/web-console/public/download-sw.js delete mode 100644 packages/web-console/src/utils/serviceWorker.ts diff --git a/packages/browser-tests/cypress/integration/console/download.spec.js b/packages/browser-tests/cypress/integration/console/download.spec.js index 45ccbd62c..c72bb934b 100644 --- a/packages/browser-tests/cypress/integration/console/download.spec.js +++ b/packages/browser-tests/cypress/integration/console/download.spec.js @@ -65,107 +65,10 @@ describe("download functionality", () => { // Then cy.wait("@exportRequest").then((interception) => { expect(interception.request.url).to.include("fmt=parquet"); - expect(interception.request.url).to.include("parquetVersion=1"); + 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", - "Download failed with status code 400: unrecognised format [format=badformat]" - ); - }); - }); - - it("should show error toast on server error", () => { - // Given - cy.intercept("GET", "**/exp?*", (req) => { - req.reply({ - statusCode: 500, - }); - }).as("serverErrorRequest"); - - // 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("@serverErrorRequest").then(() => { - cy.getByRole("alert").should( - "contain", - "Download failed with status code 500: Internal Server Error" - ); - }); - }); - - it("should show loading spinner when downloading", () => { - // Given - cy.intercept("GET", "**/exp?*", (req) => { - req.reply({ - statusCode: 200, - body: null, - delay: 1000, - }); - }).as("exportRequest"); - - // When - cy.typeQuery("select * from long_sequence(10)"); - cy.runLine(); - cy.getByDataHook("download-parquet-button").click(); - - // Then - cy.getByDataHook("download-loading-indicator").should("be.visible"); - - // Then - cy.wait("@exportRequest").then(() => { - cy.getByDataHook("download-loading-indicator").should("not.exist"); - }); - }); - - it("should download the file", () => { - const query = "select x from long_sequence(10)"; - // Given - cy.intercept("GET", "**/exp?*").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, " ")) - ); - const filename = new URL(interception.request.url).searchParams.get( - "filename" - ); - cy.readFile(`cypress/downloads/${filename}.csv`).should( - "eq", - '"x"\r\n1\r\n2\r\n3\r\n4\r\n5\r\n6\r\n7\r\n8\r\n9\r\n10\r\n' - ); - }); - }); }); diff --git a/packages/web-console/public/download-sw.js b/packages/web-console/public/download-sw.js deleted file mode 100755 index d9e96f9fb..000000000 --- a/packages/web-console/public/download-sw.js +++ /dev/null @@ -1,91 +0,0 @@ -let authToken = null - -self.addEventListener('message', (event) => { - if (event.data && event.data.type === 'SET_AUTH_TOKEN') { - authToken = event.data.token - self.clients.matchAll().then((clients) => { - clients.forEach((client) => { - client.postMessage({ - type: 'AUTH_TOKEN_ACK', - }) - }) - }) - } -}) - -self.addEventListener('fetch', (event) => { - const url = new URL(event.request.url) - if (url.searchParams.get('noAuth')) { - return - } - - if (url.pathname === '/exp' || url.pathname.endsWith('/exp')) { - const requestKey = new URL(event.request.url).searchParams.get('filename') - - event.respondWith( - (async () => { - try { - const headers = new Headers(event.request.headers) - if (authToken) { - headers.set('Authorization', authToken) - } - - const modifiedRequest = new Request(event.request, { - headers: headers, - }) - - const response = await fetch(modifiedRequest) - - if (!response.ok) { - let message = response.statusText - try { - const json = await response.json() - const errorMessage = json.error - if (errorMessage) { - message = errorMessage - } - } catch (_) {} - - self.clients.matchAll().then((clients) => { - clients.forEach((client) => { - client.postMessage({ - type: `DOWNLOAD_ERROR_${requestKey}`, - status: response.status, - message, - }) - }) - }) - } else { - self.clients.matchAll().then((clients) => { - clients.forEach((client) => { - client.postMessage({ - type: `DOWNLOAD_START_${requestKey}`, - }) - }) - }) - } - return response - } catch (error) { - self.clients.matchAll().then((clients) => { - clients.forEach((client) => { - client.postMessage({ - type: `DOWNLOAD_ERROR_${requestKey}`, - status: 500, - message: error.message ?? 'Internal server error', - }) - }) - }) - console.error('[SW] Download service worker error:', error) - } - })() - ); - } -}); - -self.addEventListener('install', (_) => { - self.skipWaiting() -}); - -self.addEventListener('activate', (event) => { - event.waitUntil(self.clients.claim()) -}); diff --git a/packages/web-console/src/index.tsx b/packages/web-console/src/index.tsx index da71d679e..066fd3cfd 100644 --- a/packages/web-console/src/index.tsx +++ b/packages/web-console/src/index.tsx @@ -45,7 +45,6 @@ import Layout from "./scenes/Layout" import { theme } from "./theme" import { LocalStorageProvider } from "./providers/LocalStorageProvider" import { AuthProvider, QuestProvider, SettingsProvider, PosthogProviderWrapper } from "./providers" -import { registerDownloadServiceWorker } from "./utils/serviceWorker" const epicMiddleware = createEpicMiddleware< StoreAction, @@ -64,8 +63,6 @@ const FadeSlow = createGlobalFadeTransition( TransitionDuration.SLOW, ) -registerDownloadServiceWorker() - ReactDOM.render( diff --git a/packages/web-console/src/scenes/Result/index.tsx b/packages/web-console/src/scenes/Result/index.tsx index 701bba4ae..12ec28a95 100644 --- a/packages/web-console/src/scenes/Result/index.tsx +++ b/packages/web-console/src/scenes/Result/index.tsx @@ -25,7 +25,7 @@ import $ from "jquery" import React, { useContext, useEffect, useRef, useState } from "react" import { useDispatch, useSelector } from "react-redux" -import styled, { keyframes } from "styled-components" +import styled from "styled-components" import { Download2, Refresh } from "@styled-icons/remix-line" import { Reset } from "@styled-icons/boxicons-regular" import { HandPointLeft } from "@styled-icons/fa-regular" @@ -53,12 +53,12 @@ import type { IQuestDBGrid } from "../../js/console/grid.js" import { eventBus } from "../../modules/EventBus" import { EventType } from "../../modules/EventBus/types" import { QuestContext } from "../../providers" -import { useSettings } from "../../providers/SettingsProvider" 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, LoadingSpinner } from "../../components" +import { toast } from "../../components" +import { API_VERSION } from "../../consts" const Root = styled.div` display: flex; @@ -112,34 +112,6 @@ const DownloadButton = styled(Button)` border-bottom-right-radius: 0; ` -const SlideKeyframes = keyframes` - from { - background-position: 200% center; - } - to { - background-position: -200% center; - } -` - -const AnimatedText = styled(Text)` - background: linear-gradient( - 90deg, - ${color("gray2")} 0%, - ${color("gray2")} 40%, - ${color("white")} 50%, - ${color("gray2")} 60%, - ${color("gray2")} 100% - ); - background-size: 200% 100%; - background-clip: text; - /* stylelint-disable-next-line property-no-vendor-prefix */ - -webkit-background-clip: text; - /* stylelint-disable-next-line property-no-vendor-prefix */ - -webkit-text-fill-color: transparent; - color: transparent !important; - animation: ${SlideKeyframes} 3s linear infinite; -` - const DownloadDropdownButton = styled(Button)` display: flex; align-items: center; @@ -161,17 +133,13 @@ const DownloadMenuItem = styled(Button)` const Result = ({ viewMode }: { viewMode: ResultViewMode }) => { const { quest } = useContext(QuestContext) - const { settings } = useSettings() const [count, setCount] = useState() - const [downloadingQueries, setDownloadingQueries] = useState>(new Set()) - const [currentQuery, setCurrentQuery] = useState() const result = useSelector(selectors.query.getResult) const activeSidebar = useSelector(selectors.console.getActiveSidebar) const gridRef = useRef() const [gridFreezeLeftState, setGridFreezeLeftState] = useState(0) const [downloadMenuActive, setDownloadMenuActive] = useState(false) const dispatch = useDispatch() - const isDownloading = !!currentQuery && downloadingQueries.has(currentQuery) useEffect(() => { const _grid = grid( @@ -327,31 +295,29 @@ const Result = ({ viewMode }: { viewMode: ResultViewMode }) => { useEffect(() => { if (result?.type === QuestDB.Type.DQL) { setCount(result.count) - setCurrentQuery(result.query) } }, [result]) - const handleDownload = async (format: "csv" | "parquet") => { + const handleDownload = (format: "csv" | "parquet") => { setDownloadMenuActive(false) const sql = gridRef?.current?.getSQL() - if (sql) { - try { - setDownloadingQueries((prev) => { - return new Set(prev).add(sql) - }) - await quest.exportQuery(sql, format, settings["acl.enabled"]) - } catch (error) { - toast.error((error as Error).message) - } finally { - setDownloadingQueries((prev) => { - const newSet = new Set(prev) - newSet.delete(sql) - return newSet - }) - } - } else { - toast.error("No SQL query found") + 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 link = document.createElement("a") + link.href = url + link.target = "_blank" + link.click() } return ( @@ -378,20 +344,12 @@ const Result = ({ viewMode }: { viewMode: ResultViewMode }) => { handleDownload("parquet")} > - {isDownloading ? ( - - - Preparing the file - - ) : ( - - - Download as Parquet - - )} + + + Download as Parquet + { } > handleDownload("csv")} data-hook="download-csv-button" skin="secondary" + onClick={() => handleDownload("csv")} > Download as CSV diff --git a/packages/web-console/src/utils/platform.ts b/packages/web-console/src/utils/platform.ts index f0a7f145a..d2860a0e5 100644 --- a/packages/web-console/src/utils/platform.ts +++ b/packages/web-console/src/utils/platform.ts @@ -3,7 +3,6 @@ type Platform = { isWindows: boolean isIOS: boolean isLinux: boolean - isSafari: boolean } export const platform: Platform = { @@ -11,7 +10,6 @@ export const platform: Platform = { isWindows: false, isIOS: false, isLinux: false, - isSafari: false, } if (typeof navigator === "object") { @@ -23,5 +21,4 @@ if (typeof navigator === "object") { navigator.userAgent.indexOf("iPhone") >= 0) && !!navigator.maxTouchPoints && navigator.maxTouchPoints > 0 - platform.isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent) } diff --git a/packages/web-console/src/utils/questdb/client.ts b/packages/web-console/src/utils/questdb/client.ts index b66eea1a8..37cde899c 100644 --- a/packages/web-console/src/utils/questdb/client.ts +++ b/packages/web-console/src/utils/questdb/client.ts @@ -1,4 +1,4 @@ -import { isServerError, platform } from "../../utils" +import { isServerError } from "../../utils" import { TelemetryConfigShape } from "../../store/Telemetry/types" import { eventBus } from "../../modules/EventBus" import { EventType } from "../../modules/EventBus/types" @@ -26,7 +26,6 @@ import { SymbolColumnDetails, } from "./types" import { ssoAuthState } from "../../modules/OAuth2/ssoAuthState" -import { getIsServiceWorkerReady, setServiceWorkerAuth } from "../serviceWorker" export class Client { private _controllers: AbortController[] = [] @@ -454,122 +453,6 @@ export class Client { return { status: response.status, success: true } } - openDownloadTab(url: string) { - const a = document.createElement("a") - a.target = "_blank" - a.href = url - a.click() - } - - createHiddenIframe() { - const iframe = document.createElement("iframe") - iframe.style.display = "none" - document.body.appendChild(iframe) - return iframe - } - - async downloadWithBlob(url: string, format: "csv" | "parquet") { - const response: Response = await fetch(url, { headers: this.commonHeaders }) - if (!response.ok) { - let message = response.statusText - try { - const json = await response.json() - message = json.error ?? message - } catch (_) {} - throw new Error(`Download failed with status code ${response.status}: ${message}`) - } - - try { - const blob = await response.blob() - const filename = response.headers - .get("Content-Disposition") - ?.split("=")[1] - const objUrl = window.URL.createObjectURL(blob) - const a = document.createElement("a") - a.href = objUrl - a.download = filename - ? filename.replaceAll(`"`, "") - : `questdb-query-${new Date().getTime()}.${format}` - a.click() - window.URL.revokeObjectURL(objUrl) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - throw new Error(`Download failed while creating the file: ${message}`) - } - } - - async downloadWithServiceWorker(url: string, requestKey: string): Promise { - return new Promise((resolve, reject) => { - if (platform.isSafari) { - this.openDownloadTab(url) - resolve() - } - const iframe = this.createHiddenIframe() - const cleanup = () => { - if (iframe.parentNode) { - document.body.removeChild(iframe) - } - navigator.serviceWorker.removeEventListener('message', eventListener) - } - - const eventListener = (event: MessageEvent) => { - if (event.data.type === `DOWNLOAD_ERROR_${requestKey}`) { - cleanup() - reject(new Error(`Download failed with status code ${event.data.status}: ${event.data.message}`)) - } - if (event.data.type === `DOWNLOAD_START_${requestKey}`) { - cleanup() - resolve() - } - } - navigator.serviceWorker.addEventListener('message', eventListener) - - iframe.onerror = () => { - cleanup() - reject(new Error('Download failed')) - } - - iframe.src = url - }) - } - - async exportQuery(query: string, format: "csv" | "parquet", isAclEnabled: boolean | undefined): Promise { - const authToken = this.commonHeaders.Authorization - const requestKey = `questdb-query-${Date.now().toString()}` - const url = `exp?${Client.encodeParams({ - query, - version: API_VERSION, - fmt: format, - filename: requestKey, - ...(!isAclEnabled ? { noAuth: true } : {}), - ...(format === "parquet" ? { rmode: "nodelay" } : {}), - })}` - - // No auth token, service worker will not intercept - if (!isAclEnabled) { - if (platform.isSafari) { - this.openDownloadTab(url) - return - } - const iframe = this.createHiddenIframe() - iframe.src = url - return - } - - // Authentication to be handled by service worker - if (getIsServiceWorkerReady()) { - try { - await setServiceWorkerAuth(authToken, 2000) - } catch (_) { - return this.downloadWithBlob(url, format) - } - return this.downloadWithServiceWorker(url, requestKey) - } - - // Fallback to blob approach if service worker is not registered - return this.downloadWithBlob(url, format) - } - async getLatestRelease() { try { const response: Response = await fetch( diff --git a/packages/web-console/src/utils/serviceWorker.ts b/packages/web-console/src/utils/serviceWorker.ts deleted file mode 100644 index 2e9c378d4..000000000 --- a/packages/web-console/src/utils/serviceWorker.ts +++ /dev/null @@ -1,57 +0,0 @@ -let serviceWorkerRegistration: ServiceWorkerRegistration | null = null - -export const registerDownloadServiceWorker = async (): Promise => { - if (!('serviceWorker' in navigator)) { - console.warn('Service Workers are not supported in this browser') - return - } - - try { - const registration = await navigator.serviceWorker.register('/download-sw.js', { - scope: '/', - }) - serviceWorkerRegistration = registration - - await navigator.serviceWorker.ready - } catch (error) { - console.error('Service Worker registration failed:', error) - } -} - -export const getIsServiceWorkerReady = () => { - return serviceWorkerRegistration !== null && serviceWorkerRegistration.active !== null -} - -export const setServiceWorkerAuth = (token: string, timeoutMs: number = 2000): Promise => { - return new Promise((resolve, reject) => { - if (!getIsServiceWorkerReady()) { - return reject() - } - let resolved = false - - const onMessage = (event: MessageEvent) => { - if (event.data && event.data.type === 'AUTH_TOKEN_ACK') { - resolved = true - cleanup() - resolve() - } - } - const cleanup = () => { - navigator.serviceWorker.removeEventListener('message', onMessage) - } - navigator.serviceWorker.addEventListener('message', onMessage) - - serviceWorkerRegistration?.active?.postMessage({ - type: 'SET_AUTH_TOKEN', - token, - }) - - setTimeout(() => { - if (!resolved) { - console.warn('[SW] Auth token ack timed out') - cleanup() - reject() - } - }, timeoutMs) - }) -} diff --git a/packages/web-console/webpack.config.js b/packages/web-console/webpack.config.js index ac247b095..1923b8e9d 100644 --- a/packages/web-console/webpack.config.js +++ b/packages/web-console/webpack.config.js @@ -197,7 +197,6 @@ module.exports = { new CopyWebpackPlugin({ patterns: [ { from: "./assets/", to: "assets/" }, - { from: path.resolve(__dirname, "public"), to: "." }, ...monacoConfig.assetCopyPatterns, ], }), @@ -215,7 +214,6 @@ module.exports = { new CopyWebpackPlugin({ patterns: [ { from: "./assets/", to: "assets/" }, - { from: path.resolve(__dirname, "public"), to: "." }, ...monacoConfig.assetCopyPatterns, ...monacoConfig.sourceMapCopyPatterns, ], From 78e28fa8790390cd1c0c8d3dce5561c8ffd944e2 Mon Sep 17 00:00:00 2001 From: emrberk Date: Wed, 15 Oct 2025 15:11:45 +0300 Subject: [PATCH 14/15] fix arrow direction --- packages/web-console/src/scenes/Result/index.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/web-console/src/scenes/Result/index.tsx b/packages/web-console/src/scenes/Result/index.tsx index 12ec28a95..e5a8d88ac 100644 --- a/packages/web-console/src/scenes/Result/index.tsx +++ b/packages/web-console/src/scenes/Result/index.tsx @@ -112,6 +112,10 @@ const DownloadButton = styled(Button)` 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; @@ -368,7 +372,7 @@ const Result = ({ viewMode }: { viewMode: ResultViewMode }) => { skin="secondary" data-hook="download-dropdown-button" > - + } > From 5282ea9cde08aa621533dcc3278dd87b21cee1f7 Mon Sep 17 00:00:00 2001 From: emrberk Date: Wed, 15 Oct 2025 18:54:01 +0300 Subject: [PATCH 15/15] handle error body in iframe --- .../integration/console/download.spec.js | 23 ++++++++++++++ packages/browser-tests/questdb | 2 +- .../web-console/src/scenes/Result/index.tsx | 30 +++++++++++++++---- 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/packages/browser-tests/cypress/integration/console/download.spec.js b/packages/browser-tests/cypress/integration/console/download.spec.js index c72bb934b..a4f54a732 100644 --- a/packages/browser-tests/cypress/integration/console/download.spec.js +++ b/packages/browser-tests/cypress/integration/console/download.spec.js @@ -71,4 +71,27 @@ describe("download functionality", () => { ); }); }); + + 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 fc057a560..93f248438 160000 --- a/packages/browser-tests/questdb +++ b/packages/browser-tests/questdb @@ -1 +1 @@ -Subproject commit fc057a56061a77376024771969bf67684f619e22 +Subproject commit 93f248438f1854eacdfe592aafb4a8a9e0fc63d4 diff --git a/packages/web-console/src/scenes/Result/index.tsx b/packages/web-console/src/scenes/Result/index.tsx index e5a8d88ac..b46ffc730 100644 --- a/packages/web-console/src/scenes/Result/index.tsx +++ b/packages/web-console/src/scenes/Result/index.tsx @@ -313,15 +313,35 @@ const Result = ({ viewMode }: { viewMode: ResultViewMode }) => { const url = `exp?${QuestDB.Client.encodeParams({ query: sql, version: API_VERSION, - fmt: format, + fmt: `${format}`, filename: `questdb-query-${Date.now().toString()}`, ...(format === "parquet" ? { rmode: "nodelay" } : {}), })}` - const link = document.createElement("a") - link.href = url - link.target = "_blank" - link.click() + 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 (