From 90fdda86c0279b7b35d2f84a81cde314318567ec Mon Sep 17 00:00:00 2001 From: emrberk Date: Sun, 21 Dec 2025 17:08:07 +0300 Subject: [PATCH 1/7] fix: alhandle running new queries when there is an operaton in progress --- src/providers/EditorProvider/index.tsx | 8 +- src/scenes/Editor/Monaco/QueryDropdown.tsx | 4 +- src/scenes/Editor/Monaco/index.tsx | 123 +++++++++---- src/scenes/Editor/Monaco/tabs.tsx | 16 +- src/scenes/Editor/Monaco/utils.ts | 3 +- src/utils/questdb/client.ts | 204 ++++++++++++--------- 6 files changed, 228 insertions(+), 130 deletions(-) diff --git a/src/providers/EditorProvider/index.tsx b/src/providers/EditorProvider/index.tsx index ec041cf7f..82eda1ca2 100644 --- a/src/providers/EditorProvider/index.tsx +++ b/src/providers/EditorProvider/index.tsx @@ -38,6 +38,8 @@ export type EditorContext = { monacoRef: MutableRefObject insertTextAtCursor: (text: string) => void appendQuery: (query: string, options?: AppendQueryOptions) => void + tabsDisabled: boolean + setTabsDisabled: (disabled: boolean) => void buffers: Buffer[] activeBuffer: Buffer setActiveBuffer: ( @@ -72,6 +74,8 @@ const defaultValues = { monacoRef: { current: null }, insertTextAtCursor: () => undefined, appendQuery: () => undefined, + tabsDisabled: false, + setTabsDisabled: () => undefined, buffers: [], activeBuffer: fallbackBuffer, setActiveBuffer: () => Promise.resolve(), @@ -97,7 +101,7 @@ export const EditorProvider: React.FC = ({ children }) => { const [temporaryBufferId, setTemporaryBufferId] = useState( null, ) - + const [tabsDisabled, setTabsDisabled] = useState(false) const rawBuffers = useLiveQuery(bufferStore.getAll, []) const buffers = useMemo(() => { if (!rawBuffers) return undefined @@ -396,6 +400,8 @@ export const EditorProvider: React.FC = ({ children }) => { } }, inFocus, + tabsDisabled, + setTabsDisabled, buffers, activeBuffer, setActiveBuffer, diff --git a/src/scenes/Editor/Monaco/QueryDropdown.tsx b/src/scenes/Editor/Monaco/QueryDropdown.tsx index eb69d86db..aeaef527b 100644 --- a/src/scenes/Editor/Monaco/QueryDropdown.tsx +++ b/src/scenes/Editor/Monaco/QueryDropdown.tsx @@ -67,8 +67,8 @@ type QueryDropdownProps = { positionRef: React.MutableRefObject<{ x: number; y: number } | null> queriesRef: React.MutableRefObject isContextMenuRef: React.MutableRefObject - onRunQuery: (query?: Request) => void - onExplainQuery: (query?: Request) => void + onRunQuery: (query: Request) => void + onExplainQuery: (query: Request) => void } export const QueryDropdown: React.FC = ({ diff --git a/src/scenes/Editor/Monaco/index.tsx b/src/scenes/Editor/Monaco/index.tsx index 76ddf6110..cd45fe727 100644 --- a/src/scenes/Editor/Monaco/index.tsx +++ b/src/scenes/Editor/Monaco/index.tsx @@ -206,6 +206,7 @@ const MonacoEditor = ({ const editorContext = useEditor() const { buffers, + setTabsDisabled, editorRef, monacoRef, insertTextAtCursor, @@ -236,6 +237,15 @@ const MonacoEditor = ({ const queryOffsetsRef = useRef< { startOffset: number; endOffset: number }[] | null >([]) + const pendingActionRef = useRef< + | { type: RunningType.SCRIPT } + | { + type: RunningType.QUERY | RunningType.EXPLAIN + queryText: string + startOffset: number + } + | undefined + >(undefined) const queriesToRunRef = useRef([]) const scriptStopRef = useRef(false) const stopAfterFailureRef = useRef(true) @@ -386,13 +396,6 @@ const MonacoEditor = ({ } const handleEditorClick = (e: React.MouseEvent) => { - if ( - isRunningScriptRef.current && - e.target instanceof Element && - e.target.classList.contains("cursorQueryGlyph") - ) { - return - } const editor = editorRef.current const model = editor?.getModel() if (!editor || !model) return @@ -409,6 +412,10 @@ const MonacoEditor = ({ e.target instanceof Element && e.target.classList.contains("cursorQueryGlyph") ) { + if (e.target.classList.contains("loading-glyph")) { + return + } + editor.focus() const target = editor.getTargetAtClientPoint(e.clientX, e.clientY) @@ -424,34 +431,66 @@ const MonacoEditor = ({ return } if (dropdownQueries.length === 1) { - setCursorBeforeRunning(dropdownQueries[0]) - toggleRunning() + runQueryAction(dropdownQueries[0], RunningType.QUERY) } } } } - const handleRunQuery = (query?: Request) => { + const handleRunQuery = (query: Request) => { + setDropdownOpen(false) + runQueryAction(query, RunningType.QUERY) + } + + const handleExplainQuery = (query: Request) => { setDropdownOpen(false) + runQueryAction(query, RunningType.EXPLAIN) + } + + const runQueryAction = ( + query: Request, + type: RunningType.QUERY | RunningType.EXPLAIN, + ) => { + const editor = editorRef.current + const model = editor?.getModel() + if (!editor || !model) return + + const startOffset = getQueryStartOffset(editor, query) + const queryText = query.query - if (query) { + if (runningValueRef.current === RunningType.NONE) { setCursorBeforeRunning(query) - } else if (targetPositionRef.current) { - editorRef.current?.setPosition(targetPositionRef.current) + toggleRunning(type) + return } - toggleRunning() + pendingActionRef.current = { type, queryText, startOffset } + toggleRunning(RunningType.NONE) } - const handleExplainQuery = (query?: Request) => { - setDropdownOpen(false) - if (query) { - setCursorBeforeRunning(query) - } else if (targetPositionRef.current) { - editorRef.current?.setPosition(targetPositionRef.current) + const executePendingAction = () => { + const pending = pendingActionRef.current + const editor = editorRef.current + const model = editor?.getModel() + if (!pending || !editor || !model) return + + pendingActionRef.current = undefined + + if (pending.type === RunningType.SCRIPT) { + setScriptConfirmationOpen(true) + return + } + + if ( + !validateQueryAtOffset(editor, pending.queryText, pending.startOffset) + ) { + return } - toggleRunning(RunningType.EXPLAIN) + const position = model.getPositionAt(pending.startOffset) + editor.setPosition(position) + + toggleRunning(pending.type) } const applyLineMarkings = ( @@ -668,18 +707,14 @@ const MonacoEditor = ({ editor, monaco, runQuery: () => { - if (runningValueRef.current === RunningType.NONE) { - if (queriesToRunRef.current.length === 1) { - toggleRunning() - } else if (queriesToRunRef.current.length > 1) { - handleTriggerRunScript() - } + if (queriesToRunRef.current.length === 1) { + handleRunQuery(queriesToRunRef.current[0]) + } else if (queriesToRunRef.current.length > 1) { + handleTriggerRunScript() } }, runScript: () => { - if (runningValueRef.current === RunningType.NONE) { - handleTriggerRunScript(true) - } + handleTriggerRunScript(true) }, deleteBuffer: (id: number) => editorContext.deleteBuffer(id), addBuffer: () => editorContext.addBuffer(), @@ -1131,7 +1166,10 @@ const MonacoEditor = ({ const handleTriggerRunScript = (runAll?: boolean) => { if (running === RunningType.SCRIPT) { dispatch(actions.query.toggleRunning()) - } else if (running === RunningType.NONE) { + return + } + + const triggerScript = () => { if (runAll) { setScriptConfirmationOpen(true) return @@ -1147,6 +1185,15 @@ const MonacoEditor = ({ dispatch(actions.query.toggleRunning(RunningType.SCRIPT)) } } + + if (runningValueRef.current === RunningType.NONE) { + triggerScript() + return + } + + // Store script action for later execution + pendingActionRef.current = { type: RunningType.SCRIPT } + toggleRunning(RunningType.NONE) } const handleConfirmRunScript = () => { @@ -1183,7 +1230,7 @@ const MonacoEditor = ({ } isRunningScriptRef.current = true - + setTabsDisabled(true) const queries = queriesToRun ?? getAllQueries(editor) const individualQueryResults: Array = [] @@ -1317,10 +1364,14 @@ const MonacoEditor = ({ activeBufferRef.current.id as number, ), ) + setTabsDisabled(false) isRunningScriptRef.current = false - scriptStopRef.current = false stopAfterFailureRef.current = true editor.updateOptions({ readOnly: false }) + if (scriptStopRef.current) { + scriptStopRef.current = false + executePendingAction() + } } useEffect(() => { @@ -1352,10 +1403,9 @@ const MonacoEditor = ({ useEffect(() => { if (running === RunningType.NONE && request) { quest.abort() - dispatch(actions.query.stopRunning()) setRequest(undefined) } - }, [request, quest, dispatch, running]) + }, [request, quest, running]) useEffect(() => { runningValueRef.current = running @@ -1620,6 +1670,9 @@ const MonacoEditor = ({ ) } }) + .finally(() => { + executePendingAction() + }) setRequest(request) } else { dispatch(actions.query.stopRunning()) diff --git a/src/scenes/Editor/Monaco/tabs.tsx b/src/scenes/Editor/Monaco/tabs.tsx index 355b193e4..62594d70b 100644 --- a/src/scenes/Editor/Monaco/tabs.tsx +++ b/src/scenes/Editor/Monaco/tabs.tsx @@ -1,5 +1,5 @@ import React, { useLayoutEffect, useState, useMemo } from "react" -import styled from "styled-components" +import styled, { css } from "styled-components" import { Tabs as ReactChromeTabs } from "../../../components/ReactChromeTabs" import { useEditor } from "../../../providers" import { File, History, LineChart, Trash } from "@styled-icons/boxicons-regular" @@ -25,11 +25,17 @@ type Tab = { const Root = styled(Box).attrs({ align: "center", justifyContent: "space-between", -})` +})<{ $tabsDisabled: boolean }>` width: 100%; display: flex; background: ${({ theme }) => theme.color.backgroundLighter}; padding-right: 1rem; + ${({ $tabsDisabled }) => + $tabsDisabled && + css` + opacity: 0.5; + pointer-events: none; + `} ` const HistoryButton = styled(Button)` @@ -61,6 +67,7 @@ export const Tabs = () => { updateBuffersPositions, deleteBuffer, archiveBuffer, + tabsDisabled, } = useEditor() const [tabsVisible, setTabsVisible] = useState(false) const userLocale = useMemo(fetchUserLocale, []) @@ -188,7 +195,10 @@ export const Tabs = () => { } return ( - + (result) } + private removeController(controller: AbortController) { + const index = this._controllers.indexOf(controller) + if (index >= 0) { + this._controllers.splice(index, 1) + } + } + async queryRaw(query: string, options?: Options): Promise { const controller = new AbortController() const payload = { @@ -172,6 +179,9 @@ export class Client { headers: this.commonHeaders, }) } catch (error) { + this.removeController(controller) + Client.numOfPendingQueries-- + const err = { position: -1, query, @@ -196,115 +206,133 @@ export class Client { eventBus.publish(EventType.MSG_CONNECTION_ERROR, genericErrorPayload) return Promise.reject(genericErrorPayload) - } finally { - const index = this._controllers.indexOf(controller) - - if (index >= 0) { - this._controllers.splice(index, 1) - } - - Client.numOfPendingQueries-- } - if ( - response.ok || - response.status === 400 || - (response.ok && response.status === 403) - ) { - let responseText - try { - responseText = await response.text() - } catch (error) { - return Promise.reject({ - error: `Failed to read response: ${error}`, - type: Type.ERROR, - }) - } - const fetchTime = (new Date().getTime() - start.getTime()) * 1e6 - let data - try { - data = JSON.parse(responseText) as RawResult - } catch (error) { - return Promise.reject({ - error: `Invalid JSON response from the server: ${error}`, - type: Type.ERROR, - }) - } + try { + if ( + response.ok || + response.status === 400 || + (response.ok && response.status === 403) + ) { + let responseText + try { + responseText = await response.text() + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + return Promise.reject({ + position: -1, + query, + type: Type.ERROR, + error: "Cancelled by user", + }) + } + return Promise.reject({ + error: `Failed to read response: ${error}`, + type: Type.ERROR, + }) + } + const fetchTime = (new Date().getTime() - start.getTime()) * 1e6 + let data + try { + data = JSON.parse(responseText) as RawResult + } catch (error) { + return Promise.reject({ + error: `Invalid JSON response from the server: ${error}`, + type: Type.ERROR, + }) + } - eventBus.publish(EventType.MSG_CONNECTION_OK) + eventBus.publish(EventType.MSG_CONNECTION_OK) - if (response.status === 403) { - eventBus.publish(EventType.MSG_CONNECTION_FORBIDDEN, data) - } + if (response.status === 403) { + eventBus.publish(EventType.MSG_CONNECTION_FORBIDDEN, data) + } - if (data.ddl) { - return { - query, - type: Type.DDL, + if (data.ddl) { + return { + query, + type: Type.DDL, + } } - } - if (data.dml) { - return { - query, - type: Type.DML, + if (data.dml) { + return { + query, + type: Type.DML, + } } - } - if (data.error) { - return Promise.reject({ - ...data, - type: Type.ERROR, - }) - } + if (data.error) { + return Promise.reject({ + ...data, + type: Type.ERROR, + }) + } + + if (data.notice) { + return { + ...data, + type: Type.NOTICE, + } + } - if (data.notice) { return { ...data, - type: Type.NOTICE, + timings: { + ...data.timings, + fetch: fetchTime, + }, + type: Type.DQL, } } - return { - ...data, - timings: { - ...data.timings, - fetch: fetchTime, - }, - type: Type.DQL, + const errorPayload: Record = { + status: response.status, + error: response.statusText, } - } - const errorPayload: Record = { - status: response.status, - error: response.statusText, - } - - if (isServerError(response)) { - errorPayload.error = `QuestDB is not reachable [${response.status}]` - errorPayload.position = -1 - errorPayload.query = query - errorPayload.type = Type.ERROR - eventBus.publish(EventType.MSG_CONNECTION_ERROR, errorPayload) - } + if (isServerError(response)) { + errorPayload.error = `QuestDB is not reachable [${response.status}]` + errorPayload.position = -1 + errorPayload.query = query + errorPayload.type = Type.ERROR + eventBus.publish(EventType.MSG_CONNECTION_ERROR, errorPayload) + } - if (response.status === 401) { - errorPayload.error = `Unauthorized` - eventBus.publish(EventType.MSG_CONNECTION_UNAUTHORIZED, errorPayload) - } + if (response.status === 401) { + errorPayload.error = `Unauthorized` + eventBus.publish(EventType.MSG_CONNECTION_UNAUTHORIZED, errorPayload) + } - if (response.status === 403) { - const errorText = (await response.text()).trim() - if (errorText.startsWith("{")) { - const data = JSON.parse(errorText) as ErrorResult - errorPayload.error = data.error - } else { - errorPayload.error = errorText + if (response.status === 403) { + let errorText + try { + errorText = (await response.text()).trim() + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + return Promise.reject({ + position: -1, + query, + type: Type.ERROR, + error: "Cancelled by user", + }) + } + throw error + } + if (errorText.startsWith("{")) { + const data = JSON.parse(errorText) as ErrorResult + errorPayload.error = data.error + } else { + errorPayload.error = errorText + } + eventBus.publish(EventType.MSG_CONNECTION_FORBIDDEN, errorPayload) } - eventBus.publish(EventType.MSG_CONNECTION_FORBIDDEN, errorPayload) - } - return Promise.reject(errorPayload) + return Promise.reject(errorPayload) + } finally { + this.removeController(controller) + Client.numOfPendingQueries-- + } } async showTables(): Promise> { From 983c7ea09ca4abbef6220c4569826fa54b51ca74 Mon Sep 17 00:00:00 2001 From: emrberk Date: Mon, 22 Dec 2025 03:10:51 +0300 Subject: [PATCH 2/7] abort confirmation, add test cases --- e2e/questdb | 2 +- e2e/tests/console/editor.spec.js | 137 +++++++++++++++++++++++++++++ src/components/TopBar/toolbar.tsx | 2 +- src/index.tsx | 4 +- src/scenes/Editor/Monaco/index.tsx | 132 +++++++++++++++++++++++++-- src/store/Telemetry/epics.test.ts | 66 +++++++++++--- 6 files changed, 318 insertions(+), 25 deletions(-) diff --git a/e2e/questdb b/e2e/questdb index 944b63d6e..dc8e45daa 160000 --- a/e2e/questdb +++ b/e2e/questdb @@ -1 +1 @@ -Subproject commit 944b63d6e8c78acda2cfaeaee5bb23a1adcc2584 +Subproject commit dc8e45daa9ba651027b9a8acce40a432b5c614f1 diff --git a/e2e/tests/console/editor.spec.js b/e2e/tests/console/editor.spec.js index 1beb61f62..4723bbeba 100644 --- a/e2e/tests/console/editor.spec.js +++ b/e2e/tests/console/editor.spec.js @@ -1235,3 +1235,140 @@ describe("multiple run buttons with dynamic query log", () => { cy.get(".cursorQueryGlyph").should("have.length", 3) }) }) + +describe("abortion on new query execution", () => { + beforeEach(() => { + cy.loadConsoleWithAuth() + cy.getEditorContent().should("be.visible") + cy.clearEditor() + cy.intercept("/exec*", (req) => { + req.on("response", (res) => { + res.setDelay(1200) + }) + }) + }) + + it("should show abort confirmation dialog when triggering new query while another is running", () => { + // When + cy.typeQuery("select 1;") + cy.clickRunIconInLine(1) + + // Then + cy.getByDataHook("loading-notification").should("be.visible") + + // When + cy.typeQueryDirectly("select 1;\nselect 2;") + cy.clickRunIconInLine(2) + + // Then + cy.getByDataHook("abort-confirmation-dialog").should("be.visible") + + // When + cy.getByDataHook("abort-confirmation-dialog-confirm").click() + + // Then + cy.getByDataHook("success-notification").should("contain", "select 2") + + // When + cy.clickLine(1) + + // Then + cy.getByDataHook("error-notification").should( + "contain", + "Cancelled by user", + ) + + // When + cy.clickLine(2) + + // Then + cy.getByDataHook("success-notification").should("contain", "select 2") + }) + + it("should keep original query running when dismiss is clicked in abort dialog", () => { + // When + cy.typeQuery("select 1;") + cy.clickRunIconInLine(1) + + // Then + cy.getByDataHook("loading-notification").should("be.visible") + + // When + cy.typeQueryDirectly("select 1;\nselect 2;") + cy.clickRunIconInLine(2) + + // Then + cy.getByDataHook("abort-confirmation-dialog").should("be.visible") + + // When + cy.getByDataHook("abort-confirmation-dialog-dismiss").click() + + // Then + cy.getByDataHook("abort-confirmation-dialog").should("not.exist") + cy.getByDataHook("success-notification").should("contain", "select 1") + }) + + it("should run new query after original completes while abort dialog is open", () => { + // When + cy.typeQuery("select 1;") + cy.clickRunIconInLine(1) + + // Then + cy.getByDataHook("loading-notification").should("be.visible") + + // When + cy.typeQueryDirectly("select 1;\nselect 2;") + cy.clickRunIconInLine(2) + + // Then + cy.getByDataHook("abort-confirmation-dialog").should("be.visible") + + // When (wait for original to complete naturally) + cy.getByDataHook("success-notification").should("contain", "select 1") + cy.wait(100) + + // Then + cy.getByDataHook("success-notification").should("contain", "select 1") + + // When + cy.getByDataHook("abort-confirmation-dialog-confirm").click() + + // Then + cy.getByDataHook("success-notification").should("contain", "select 2") + cy.clickLine(1) + cy.getByDataHook("success-notification").should("contain", "select 1") + cy.clickLine(2) + cy.getByDataHook("success-notification").should("contain", "select 2") + }) + + it("should show abort warning in script confirmation dialog when query is running", () => { + // Given + cy.intercept("/exec*", (req) => { + req.on("response", (res) => { + res.setDelay(1200) + }) + }) + cy.typeQuery("select 1;\nselect 2;\nselect 3;") + cy.clickRunIconInLine(1) + cy.getByDataHook("loading-notification").should("be.visible") + + // When + cy.typeQuery(`${ctrlOrCmd}{shift}{enter}`) + + // Then + cy.getByRole("dialog").should("be.visible") + cy.getByDataHook("run-all-queries-warning").should("be.visible") + cy.getByDataHook("run-all-queries-warning").should( + "contain", + "Current query execution will be aborted", + ) + + // When + cy.getByDataHook("run-all-queries-confirm").click() + + // Then + cy.getByDataHook("success-notification") + .invoke("text") + .should("match", /3 successful/) + }) +}) diff --git a/src/components/TopBar/toolbar.tsx b/src/components/TopBar/toolbar.tsx index bde352848..dc2eba828 100644 --- a/src/components/TopBar/toolbar.tsx +++ b/src/components/TopBar/toolbar.tsx @@ -476,7 +476,7 @@ export const Toolbar = () => { ) if (response.type === QuestDB.Type.DQL && response.count === 1) { const serverInfo = response.data[0] - sendServerInfoTelemetry(serverInfo) + void sendServerInfoTelemetry(serverInfo) } return } diff --git a/src/index.tsx b/src/index.tsx index 98ffc06d7..c50b6d376 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -60,7 +60,9 @@ const epicMiddleware = createEpicMiddleware< const store = createStore(rootReducer, compose(applyMiddleware(epicMiddleware))) -epicMiddleware.run(rootEpic) +if (import.meta.env.MODE !== "development") { + epicMiddleware.run(rootEpic) +} const FadeReg = createGlobalFadeTransition("fade-reg", TransitionDuration.REG) diff --git a/src/scenes/Editor/Monaco/index.tsx b/src/scenes/Editor/Monaco/index.tsx index cd45fe727..ac2d188ad 100644 --- a/src/scenes/Editor/Monaco/index.tsx +++ b/src/scenes/Editor/Monaco/index.tsx @@ -2,6 +2,7 @@ import Editor from "@monaco-editor/react" import type { Monaco } from "@monaco-editor/react" import { loader } from "@monaco-editor/react" import { Stop } from "@styled-icons/remix-line" +import { Error as ErrorIcon } from "@styled-icons/boxicons-regular" import type { editor, IDisposable } from "monaco-editor" import React, { useCallback, @@ -223,6 +224,9 @@ const MonacoEditor = ({ const [refreshingTables, setRefreshingTables] = useState(false) const [dropdownOpen, setDropdownOpen] = useState(false) const [scriptConfirmationOpen, setScriptConfirmationOpen] = useState(false) + const [abortConfirmationOpen, setAbortConfirmationOpen] = useState(false) + const abortConfirmationOpenRef = useRef(false) + const scriptConfirmationOpenRef = useRef(false) const dispatch = useDispatch() const running = useSelector(selectors.query.getRunning) const tables = useSelector(selectors.query.getTables) @@ -465,7 +469,7 @@ const MonacoEditor = ({ } pendingActionRef.current = { type, queryText, startOffset } - toggleRunning(RunningType.NONE) + setAbortConfirmationOpen(true) } const executePendingAction = () => { @@ -477,7 +481,8 @@ const MonacoEditor = ({ pendingActionRef.current = undefined if (pending.type === RunningType.SCRIPT) { - setScriptConfirmationOpen(true) + queriesToRunRef.current = [] + dispatch(actions.query.toggleRunning(RunningType.SCRIPT)) return } @@ -1063,7 +1068,10 @@ const MonacoEditor = ({ try { const result = await quest.queryRaw( normalizeQueryText(effectiveQueryText), - { limit: "0,1000", explain: true }, + { + limit: "0,1000", + explain: true, + }, ) if (executionRefs.current[activeBufferId]) { @@ -1193,11 +1201,21 @@ const MonacoEditor = ({ // Store script action for later execution pendingActionRef.current = { type: RunningType.SCRIPT } - toggleRunning(RunningType.NONE) + setScriptConfirmationOpen(true) } const handleConfirmRunScript = () => { setScriptConfirmationOpen(false) + + if (pendingActionRef.current) { + if (runningValueRef.current === RunningType.NONE) { + executePendingAction() + } else { + toggleRunning(RunningType.NONE) + } + return + } + queriesToRunRef.current = [] dispatch(actions.query.toggleRunning(RunningType.SCRIPT)) } @@ -1209,6 +1227,17 @@ const MonacoEditor = ({ } } + const handleCloseDialog = () => { + if (!scriptConfirmationOpen) return + pendingActionRef.current = undefined + handleToggleDialog(false) + } + const handleCloseAbortDialog = () => { + if (!abortConfirmationOpen) return + pendingActionRef.current = undefined + setAbortConfirmationOpen(false) + } + const handleRunScript = async () => { let successfulQueries = 0 let failedQueries = 0 @@ -1370,7 +1399,12 @@ const MonacoEditor = ({ editor.updateOptions({ readOnly: false }) if (scriptStopRef.current) { scriptStopRef.current = false - executePendingAction() + if ( + !abortConfirmationOpenRef.current && + !scriptConfirmationOpenRef.current + ) { + executePendingAction() + } } } @@ -1387,6 +1421,14 @@ const MonacoEditor = ({ activeNotificationRef.current = activeNotification }, [activeNotification]) + useEffect(() => { + abortConfirmationOpenRef.current = abortConfirmationOpen + }, [abortConfirmationOpen]) + + useEffect(() => { + scriptConfirmationOpenRef.current = scriptConfirmationOpen + }, [scriptConfirmationOpen]) + useEffect(() => { const gridNotificationKeySuffix = `@${LINE_NUMBER_HARD_LIMIT + 1}-${LINE_NUMBER_HARD_LIMIT + 1}` queryNotificationsRef.current = queryNotifications @@ -1671,7 +1713,12 @@ const MonacoEditor = ({ } }) .finally(() => { - executePendingAction() + if ( + !abortConfirmationOpenRef.current && + !scriptConfirmationOpenRef.current + ) { + executePendingAction() + } }) setRequest(request) } else { @@ -1833,12 +1880,24 @@ const MonacoEditor = ({ handleToggleDialog(false)} - onInteractOutside={() => handleToggleDialog(false)} + onEscapeKeyDown={handleCloseDialog} + onInteractOutside={handleCloseDialog} > Run all queries + {pendingActionRef.current && ( + + + + Current query execution will be aborted. + + + )} You are about to run all queries in this tab. This action may modify or delete your data permanently. @@ -1872,7 +1931,7 @@ const MonacoEditor = ({ handleToggleDialog(false)} + onClick={handleCloseDialog} > Cancel @@ -1889,6 +1948,61 @@ const MonacoEditor = ({ + + { + setAbortConfirmationOpen(open) + }} + > + + + + + + + Cancel current query? + + + + A query is currently running. Starting a new query will cancel + the current execution. + + + + + + + Dismiss + + + + { + setAbortConfirmationOpen(false) + if (runningValueRef.current === RunningType.NONE) { + executePendingAction() + } else { + toggleRunning(RunningType.NONE) + } + }} + > + Cancel current query + + + + + ) } diff --git a/src/store/Telemetry/epics.test.ts b/src/store/Telemetry/epics.test.ts index ebee7b498..022de1b4b 100644 --- a/src/store/Telemetry/epics.test.ts +++ b/src/store/Telemetry/epics.test.ts @@ -1,15 +1,27 @@ import { describe, it, expect, vi, beforeEach } from "vitest" import { of, Subject } from "rxjs" -import { StateObservable } from "redux-observable" -import { TelemetryAT } from "../../types" +import { ActionsObservable, StateObservable } from "redux-observable" +import { + TelemetryAT, + TelemetryRemoteConfigShape, + TelemetryConfigShape, + StoreAction, + StoreShape, +} from "../../types" // Hoist mock functions so they're available when vi.mock runs -const { mockQueryRaw, mockFromFetch, mockGetRemoteConfig, mockGetConfig, mockSetRemoteConfig } = vi.hoisted(() => ({ +const { + mockQueryRaw, + mockFromFetch, + mockGetRemoteConfig, + mockGetConfig, + mockSetRemoteConfig, +} = vi.hoisted(() => ({ mockQueryRaw: vi.fn(), mockFromFetch: vi.fn(), mockGetRemoteConfig: vi.fn(), mockGetConfig: vi.fn(), - mockSetRemoteConfig: vi.fn((config: any) => ({ + mockSetRemoteConfig: vi.fn((config: TelemetryRemoteConfigShape) => ({ type: "telemetry/SET_REMOTE_CONFIG", payload: config, })), @@ -42,13 +54,16 @@ vi.mock("../../consts", () => ({ vi.mock("../../store", () => ({ actions: { telemetry: { - setRemoteConfig: (config: any) => mockSetRemoteConfig(config), + setRemoteConfig: (config: TelemetryRemoteConfigShape) => + mockSetRemoteConfig(config), }, }, selectors: { telemetry: { - getRemoteConfig: () => mockGetRemoteConfig(), - getConfig: () => mockGetConfig(), + getRemoteConfig: (): TelemetryRemoteConfigShape | undefined => + mockGetRemoteConfig() as TelemetryRemoteConfigShape | undefined, + getConfig: (): TelemetryConfigShape | undefined => + mockGetConfig() as TelemetryConfigShape | undefined, }, }, })) @@ -87,7 +102,11 @@ describe("startTelemetry epic", () => { const state$ = new StateObservable(new Subject(), {}) - const epic$ = startTelemetry(action$ as any, state$ as any, undefined as any) + const epic$ = startTelemetry( + action$ as ActionsObservable, + state$ as StateObservable, + undefined, + ) // The epic should not error when lastUpdated is missing let errored = false @@ -129,7 +148,11 @@ describe("startTelemetry epic", () => { const state$ = new StateObservable(new Subject(), {}) - const epic$ = startTelemetry(action$ as any, state$ as any, undefined as any) + const epic$ = startTelemetry( + action$ as ActionsObservable, + state$ as StateObservable, + undefined, + ) let errored = false const sub = epic$.subscribe({ @@ -181,7 +204,11 @@ describe("startTelemetry epic", () => { const state$ = new StateObservable(new Subject(), {}) - const epic$ = startTelemetry(action$ as any, state$ as any, undefined as any) + const epic$ = startTelemetry( + action$ as ActionsObservable, + state$ as StateObservable, + undefined, + ) let errored = false const sub = epic$.subscribe({ @@ -237,8 +264,13 @@ describe("startTelemetry epic", () => { const state$ = new StateObservable(new Subject(), {}) - const epic$ = startTelemetry(action$ as any, state$ as any, undefined as any) + const epic$ = startTelemetry( + action$ as ActionsObservable, + state$ as StateObservable, + undefined, + ) + // eslint-disable-next-line @typescript-eslint/no-unused-vars let errored = false const sub = epic$.subscribe({ error: () => { @@ -281,7 +313,11 @@ describe("startTelemetry epic", () => { const state$ = new StateObservable(new Subject(), {}) - const epic$ = startTelemetry(action$ as any, state$ as any, undefined as any) + const epic$ = startTelemetry( + action$ as ActionsObservable, + state$ as StateObservable, + undefined, + ) // The epic should catch errors and not propagate them let errorReceived = false @@ -327,7 +363,11 @@ describe("startTelemetry epic", () => { const state$ = new StateObservable(new Subject(), {}) - const epic$ = startTelemetry(action$ as any, state$ as any, undefined as any) + const epic$ = startTelemetry( + action$ as ActionsObservable, + state$ as StateObservable, + undefined, + ) const sub = epic$.subscribe() From e6f555ab815f8b9b189e7447c701616f9a41cc11 Mon Sep 17 00:00:00 2001 From: emrberk Date: Mon, 22 Dec 2025 03:36:44 +0300 Subject: [PATCH 3/7] check line icon instead of loading notification --- e2e/tests/console/editor.spec.js | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/e2e/tests/console/editor.spec.js b/e2e/tests/console/editor.spec.js index 4723bbeba..96ef20383 100644 --- a/e2e/tests/console/editor.spec.js +++ b/e2e/tests/console/editor.spec.js @@ -1250,14 +1250,13 @@ describe("abortion on new query execution", () => { it("should show abort confirmation dialog when triggering new query while another is running", () => { // When - cy.typeQuery("select 1;") + cy.typeQuery("select 1;\nselect 2;") cy.clickRunIconInLine(1) // Then - cy.getByDataHook("loading-notification").should("be.visible") + cy.getCancelIconInLine(1).should("be.visible") // When - cy.typeQueryDirectly("select 1;\nselect 2;") cy.clickRunIconInLine(2) // Then @@ -1287,14 +1286,13 @@ describe("abortion on new query execution", () => { it("should keep original query running when dismiss is clicked in abort dialog", () => { // When - cy.typeQuery("select 1;") + cy.typeQuery("select 1;\nselect 2;") cy.clickRunIconInLine(1) // Then - cy.getByDataHook("loading-notification").should("be.visible") + cy.getCancelIconInLine(1).should("be.visible") // When - cy.typeQueryDirectly("select 1;\nselect 2;") cy.clickRunIconInLine(2) // Then @@ -1310,14 +1308,13 @@ describe("abortion on new query execution", () => { it("should run new query after original completes while abort dialog is open", () => { // When - cy.typeQuery("select 1;") + cy.typeQuery("select 1;\nselect 2;") cy.clickRunIconInLine(1) // Then - cy.getByDataHook("loading-notification").should("be.visible") + cy.getCancelIconInLine(1).should("be.visible") // When - cy.typeQueryDirectly("select 1;\nselect 2;") cy.clickRunIconInLine(2) // Then @@ -1342,22 +1339,18 @@ describe("abortion on new query execution", () => { }) it("should show abort warning in script confirmation dialog when query is running", () => { - // Given - cy.intercept("/exec*", (req) => { - req.on("response", (res) => { - res.setDelay(1200) - }) - }) + // When cy.typeQuery("select 1;\nselect 2;\nselect 3;") cy.clickRunIconInLine(1) - cy.getByDataHook("loading-notification").should("be.visible") + + // Then + cy.getCancelIconInLine(1).should("be.visible") // When - cy.typeQuery(`${ctrlOrCmd}{shift}{enter}`) + cy.realPress(["Meta", "Shift", "Enter"]) // Then cy.getByRole("dialog").should("be.visible") - cy.getByDataHook("run-all-queries-warning").should("be.visible") cy.getByDataHook("run-all-queries-warning").should( "contain", "Current query execution will be aborted", From e3107284b086fcf3c61329f0beaca77643bb98d1 Mon Sep 17 00:00:00 2001 From: emrberk Date: Mon, 22 Dec 2025 03:45:18 +0300 Subject: [PATCH 4/7] add command, test with shorter CI --- .github/workflows/ci.yml | 28 +++++++--------------------- e2e/commands.js | 6 ++++++ package.json | 2 +- 3 files changed, 14 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a9df1912..a97a55314 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,20 +20,17 @@ jobs: cache: maven - name: Build QuestDB - run: mvn clean package -f e2e/questdb/pom.xml -DskipTests -P build-binaries + run: + mvn clean package -f e2e/questdb/pom.xml -DskipTests -P build-binaries - name: Extract QuestDB - run: tar -xzf e2e/questdb/core/target/questdb-*-rt-linux-x86-64.tar.gz -C tmp/ + run: + tar -xzf e2e/questdb/core/target/questdb-*-rt-linux-x86-64.tar.gz -C + tmp/ - name: Create DB Root run: mkdir tmp/dbroot - - name: Start QuestDB - run: ./tmp/questdb-*-rt-linux-x86-64/bin/questdb.sh start -d ./tmp/dbroot - env: - QDB_DEV_MODE_ENABLED: "true" - QDB_TELEMETRY_ENABLED: "false" - - uses: actions/setup-node@v4 with: node-version: "20" @@ -45,20 +42,9 @@ jobs: - name: Build run: yarn build - - name: Run bundle watcher - run: yarn bundlewatch - - - name: Run unit tests - run: yarn test:unit - - - name: Run e2e tests - auth - run: yarn preview & yarn test:e2e:auth - - - name: Stop QuestDB - run: ./tmp/questdb-*-rt-linux-x86-64/bin/questdb.sh stop - - name: Start QuestDB, set auth credentials - run: ./tmp/questdb-*-rt-linux-x86-64/bin/questdb.sh start -d ./tmp/dbroot + run: + ./tmp/questdb-*-rt-linux-x86-64/bin/questdb.sh start -d ./tmp/dbroot env: QDB_DEV_MODE_ENABLED: "true" QDB_HTTP_USER: "admin" diff --git a/e2e/commands.js b/e2e/commands.js index ff4f8de51..55f11869e 100644 --- a/e2e/commands.js +++ b/e2e/commands.js @@ -213,6 +213,12 @@ Cypress.Commands.add("getRunIconInLine", (lineNumber) => { return cy.get(selector).first(); }); +Cypress.Commands.add("getCancelIconInLine", (lineNumber) => { + cy.get(".cancelQueryGlyph").should("be.visible"); + const selector = `.cancelQueryGlyph-line-${lineNumber}`; + return cy.get(selector).first(); +}); + Cypress.Commands.add("openRunDropdownInLine", (lineNumber) => { cy.getRunIconInLine(lineNumber).rightclick(); }); diff --git a/package.json b/package.json index 8ad693087..474a953ab 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "start": "COMMIT_HASH=$(git rev-parse --short HEAD) vite", "preview": "vite preview", "test:unit": "TZ=UTC vitest run", - "test:e2e": "cypress run --spec 'e2e/tests/console/*.spec.js'", + "test:e2e": "cypress run --spec 'e2e/tests/console/editor.spec.js'", "test:e2e:auth": "cypress run --spec 'e2e/tests/auth/*.spec.js'", "test:e2e:enterprise": "cypress run --spec 'e2e/tests/enterprise/*.spec.js'", "typecheck": "tsc --noEmit", From b4efff6e89bc7535264b2fa131a45d70ce72aebe Mon Sep 17 00:00:00 2001 From: emrberk Date: Mon, 22 Dec 2025 03:50:26 +0300 Subject: [PATCH 5/7] ops --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a97a55314..52ba56608 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,7 @@ jobs: QDB_TELEMETRY_ENABLED: "false" - name: Run e2e tests - run: yarn test:e2e + run: yarn preview & yarn test:e2e - name: Print Log Files if: success() || failure() From 0531255582f4204c4fd68ee7122a043eba420669 Mon Sep 17 00:00:00 2001 From: emrberk Date: Mon, 22 Dec 2025 04:13:10 +0300 Subject: [PATCH 6/7] remove query->script test, no stable multiple key support --- .github/workflows/ci.yml | 21 ++++++++++++++++++++- e2e/tests/console/editor.spec.js | 27 --------------------------- 2 files changed, 20 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52ba56608..801a93af4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,13 @@ jobs: - name: Create DB Root run: mkdir tmp/dbroot + - name: Start QuestDB + run: + ./tmp/questdb-*-rt-linux-x86-64/bin/questdb.sh start -d ./tmp/dbroot + env: + QDB_DEV_MODE_ENABLED: "true" + QDB_TELEMETRY_ENABLED: "false" + - uses: actions/setup-node@v4 with: node-version: "20" @@ -42,6 +49,18 @@ jobs: - name: Build run: yarn build + - name: Run bundle watcher + run: yarn bundlewatch + + - name: Run unit tests + run: yarn test:unit + + - name: Run e2e tests - auth + run: yarn preview & yarn test:e2e:auth + + - name: Stop QuestDB + run: ./tmp/questdb-*-rt-linux-x86-64/bin/questdb.sh stop + - name: Start QuestDB, set auth credentials run: ./tmp/questdb-*-rt-linux-x86-64/bin/questdb.sh start -d ./tmp/dbroot @@ -52,7 +71,7 @@ jobs: QDB_TELEMETRY_ENABLED: "false" - name: Run e2e tests - run: yarn preview & yarn test:e2e + run: yarn test:e2e - name: Print Log Files if: success() || failure() diff --git a/e2e/tests/console/editor.spec.js b/e2e/tests/console/editor.spec.js index 96ef20383..fc95e35af 100644 --- a/e2e/tests/console/editor.spec.js +++ b/e2e/tests/console/editor.spec.js @@ -1337,31 +1337,4 @@ describe("abortion on new query execution", () => { cy.clickLine(2) cy.getByDataHook("success-notification").should("contain", "select 2") }) - - it("should show abort warning in script confirmation dialog when query is running", () => { - // When - cy.typeQuery("select 1;\nselect 2;\nselect 3;") - cy.clickRunIconInLine(1) - - // Then - cy.getCancelIconInLine(1).should("be.visible") - - // When - cy.realPress(["Meta", "Shift", "Enter"]) - - // Then - cy.getByRole("dialog").should("be.visible") - cy.getByDataHook("run-all-queries-warning").should( - "contain", - "Current query execution will be aborted", - ) - - // When - cy.getByDataHook("run-all-queries-confirm").click() - - // Then - cy.getByDataHook("success-notification") - .invoke("text") - .should("match", /3 successful/) - }) }) From 3b8e34005f3204182bf833c6bf3bf92b737ec3b5 Mon Sep 17 00:00:00 2001 From: emrberk Date: Mon, 22 Dec 2025 04:21:22 +0300 Subject: [PATCH 7/7] revert package.json changes --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 474a953ab..8ad693087 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "start": "COMMIT_HASH=$(git rev-parse --short HEAD) vite", "preview": "vite preview", "test:unit": "TZ=UTC vitest run", - "test:e2e": "cypress run --spec 'e2e/tests/console/editor.spec.js'", + "test:e2e": "cypress run --spec 'e2e/tests/console/*.spec.js'", "test:e2e:auth": "cypress run --spec 'e2e/tests/auth/*.spec.js'", "test:e2e:enterprise": "cypress run --spec 'e2e/tests/enterprise/*.spec.js'", "typecheck": "tsc --noEmit",