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(