From 841f3da41dbe6cd5a86fdf582ed85b7c8d21000f Mon Sep 17 00:00:00 2001 From: emrberk Date: Tue, 22 Jul 2025 13:14:31 +0300 Subject: [PATCH 1/3] feat(web-console): add loading indicator and notification for a single query when running multiple queries --- .../src/scenes/Editor/Monaco/index.tsx | 77 +++++++++++-------- 1 file changed, 47 insertions(+), 30 deletions(-) diff --git a/packages/web-console/src/scenes/Editor/Monaco/index.tsx b/packages/web-console/src/scenes/Editor/Monaco/index.tsx index bb1676f2d..1f5993dc8 100644 --- a/packages/web-console/src/scenes/Editor/Monaco/index.tsx +++ b/packages/web-console/src/scenes/Editor/Monaco/index.tsx @@ -130,6 +130,18 @@ const Content = styled(PaneContent)` background-image: url("data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIGhlaWdodD0iMjJweCIgd2lkdGg9IjIycHgiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgICA8ZGVmcz4KICAgICAgICA8Y2xpcFBhdGggaWQ9ImNsaXAwIj48cmVjdCB3aWR0aD0iMjQiIGhlaWdodD0iMjQiLz48L2NsaXBQYXRoPgogICAgPC9kZWZzPgogICAgPGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwKSI+CiAgICAgICAgPHBhdGggZD0iTTggNC45MzR2MTQuMTMyYzAgLjQzMy40NjYuNzAyLjgxMi40ODRsMTAuNTYzLTcuMDY2YS41LjUgMCAwIDAgMC0uODMyTDguODEyIDQuNjE2QS41LjUgMCAwIDAgOCA0LjkzNFoiIGZpbGw9IiM1MGZhN2IiLz4KICAgICAgICA8Y2lyY2xlIGN4PSIxOCIgY3k9IjgiIHI9IjYiIGZpbGw9IiNmZjU1NTUiLz4KICAgICAgICA8cmVjdCB4PSIxNyIgeT0iNCIgd2lkdGg9IjIiIGhlaWdodD0iNSIgZmlsbD0id2hpdGUiIHJ4PSIwLjUiLz4KICAgICAgICA8Y2lyY2xlIGN4PSIxOCIgY3k9IjExIiByPSIxIiBmaWxsPSJ3aGl0ZSIvPgogICAgPC9nPgo8L3N2Zz4="); } + .cursorQueryGlyph.loading-glyph:after { + height: 22px; + width: 22px; + background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0id2hpdGUiPgogIDxwYXRoIGZpbGw9Im5vbmUiIGQ9Ik0wIDBoMjR2MjRIMHoiIC8+CiAgPHBhdGggZD0iTTEyIDJhMSAxIDAgMCAxIDEgMXYzYTEgMSAwIDAgMS0yIDBWM2ExIDEgMCAwIDEgMS0xem0wIDE1YTEgMSAwIDAgMSAxIDF2M2ExIDEgMCAwIDEtMiAwdi0zYTEgMSAwIDAgMSAxLTF6bTguNjYtMTBhMSAxIDAgMCAxLS4zNjYgMS4zNjZsLTIuNTk4IDEuNWExIDEgMCAxIDEtMS0xLjczMmwyLjU5OC0xLjVBMSAxIDAgMCAxIDIwLjY2IDd6TTcuNjcgMTQuNWExIDEgMCAwIDEtLjM2NiAxLjM2NmwtMi41OTggMS41YTEgMSAwIDEgMS0xLTEuNzMybDIuNTk4LTEuNWExIDEgMCAwIDEgMS4zNjYuMzY2ek0yMC42NiAxN2ExIDEgMCAwIDEtMS4zNjYuMzY2bC0yLjU5OC0xLjVhMSAxIDAgMCAxIDEtMS43MzJsMi41OTggMS41QTEgMSAwIDAgMSAyMC42NiAxN3pNNy42NyA5LjVhMSAxIDAgMCAxLTEuMzY2LjM2NmwtMi41OTgtMS41YTEgMSAwIDEgMSAxLTEuNzMybDIuNTk4IDEuNUExIDEgMCAwIDEgNy42NyA5LjV6IiAvPgo8L3N2Zz4="); + animation: loading-glyph-spin 3s linear infinite; + } + + @keyframes loading-glyph-spin { + from { transform: rotate(0); } + to { transform: rotate(360deg); } + } + .cancelQueryGlyph { &:after { background-image: url("data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIGhlaWdodD0iMjJweCIgd2lkdGg9IjIycHgiIGFyaWEtaGlkZGVuPSJ0cnVlIiBmb2N1c2FibGU9ImZhbHNlIiBmaWxsPSIjZmY1NTU1IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGNsYXNzPSJTdHlsZWRJY29uQmFzZS1zYy1lYTl1bGotMCBqQ2hkR0siPjxwYXRoIGZpbGw9Im5vbmUiIGQ9Ik0wIDBoMjR2MjRIMHoiPjwvcGF0aD48cGF0aCBkPSJNNyA3djEwaDEwVjdIN3pNNiA1aDEyYTEgMSAwIDAgMSAxIDF2MTJhMSAxIDAgMCAxLTEgMUg2YTEgMSAwIDAgMS0xLTFWNmExIDEgMCAwIDEgMS0xeiI+PC9wYXRoPjwvc3ZnPgo="); @@ -801,6 +813,17 @@ const MonacoEditor = ({ executionRefs }: { executionRefs: React.MutableRefObject let notification: Partial & { content: ReactNode, query: QueryKey } | null = null dispatch(actions.query.setResult(undefined)) + dispatch(actions.query.setActiveNotification({ + type: NotificationType.LOADING, + query: `${activeBufferRef.current.label}@${0}-${0}`, + content: ( + + Running query "{effectiveQueryText.length > 30 ? `${effectiveQueryText.slice(0, 30)}...` : effectiveQueryText}" + + ), + createdAt: new Date(), + })) + try { const result = await quest.queryRaw(normalizeQueryText(effectiveQueryText), { limit: "0,1000", explain: true }) @@ -930,7 +953,8 @@ const MonacoEditor = ({ executionRefs }: { executionRefs: React.MutableRefObject const handleRunScript = async () => { let successfulQueries = 0 let failedQueries = 0 - if (!editorRef.current) return + const editor = editorRef.current + if (!editor) return const queriesToRun = queriesToRunRef.current && queriesToRunRef.current.length > 1 ? queriesToRunRef.current : undefined const runningAllQueries = !queriesToRun // Clear all notifications & execution refs for the buffer @@ -943,31 +967,27 @@ const MonacoEditor = ({ executionRefs }: { executionRefs: React.MutableRefObject } isRunningScriptRef.current = true - - notificationTimeoutRef.current = window.setTimeout(() => { - dispatch( - actions.query.setActiveNotification({ - type: NotificationType.LOADING, - query: `${activeBufferRef.current.label}@${0}-${0}`, - content: ( - - Running queries... - - ), - createdAt: new Date(), - }), - ) - notificationTimeoutRef.current = null - }, 1000) const startTime = Date.now() - const queries = queriesToRun ?? getAllQueries(editorRef.current) + const queries = queriesToRun ?? getAllQueries(editor) let lastQuery: Request | undefined let individualQueryResults: Array = [] + editor.updateOptions({ readOnly: true }) + for (let i = 0; i < queries.length; i++) { const query = queries[i] + editor.revealPositionInCenterIfOutsideViewport({ lineNumber: query.row + 1, column: query.column }) + const queryGlyph = editor.getLineDecorations(query.row + 1)?.find(d => d.options.glyphMarginClassName?.includes("cursorQueryGlyph")) + if (queryGlyph) { + queryGlyph.options.glyphMarginClassName += " loading-glyph" + editor.deltaDecorations([], [queryGlyph]) + } const result = await runIndividualQuery(query, i === queries.length - 1) + if (queryGlyph) { + queryGlyph.options.glyphMarginClassName = queryGlyph.options.glyphMarginClassName?.replace(" loading-glyph", "") + editor.deltaDecorations([], [queryGlyph]) + } individualQueryResults.push(result) if (result.success) { successfulQueries++ @@ -982,11 +1002,6 @@ const MonacoEditor = ({ executionRefs }: { executionRefs: React.MutableRefObject const duration = (Date.now() - startTime) * 1e6 - if (notificationTimeoutRef.current) { - window.clearTimeout(notificationTimeoutRef.current) - notificationTimeoutRef.current = null - } - const lastResult = individualQueryResults[individualQueryResults.length - 1] if (lastResult && lastResult.result?.type === QuestDB.Type.DQL) { dispatch(actions.query.setResult(lastResult.result)) @@ -1010,15 +1025,14 @@ const MonacoEditor = ({ executionRefs }: { executionRefs: React.MutableRefObject }) if (editorRef.current && lastQuery) { - const position = { - lineNumber: lastQuery.row + 1, - column: lastQuery.column + if (lastResult && !lastResult.success) { + editor.setPosition({ + lineNumber: lastQuery.row + 1, + column: lastQuery.column, + }, "script") } editorRef.current.focus() - editorRef.current.revealPosition(position) - if (runningAllQueries) { - editorRef.current.setPosition(position, "script") - } + editorRef.current.revealPosition(editor.getPosition()!) } const completedGracefully = queries.length === individualQueryResults.length @@ -1046,6 +1060,7 @@ const MonacoEditor = ({ executionRefs }: { executionRefs: React.MutableRefObject isRunningScriptRef.current = false scriptStopRef.current = false stopAfterFailureRef.current = true + editor.updateOptions({ readOnly: false }) } useEffect(() => { @@ -1099,6 +1114,7 @@ const MonacoEditor = ({ executionRefs }: { executionRefs: React.MutableRefObject const isRunningExplain = running === RunningType.EXPLAIN if (request?.query) { + editorRef.current?.updateOptions({ readOnly: true }) const parentQuery = request.query const parentQueryKey = createQueryKeyFromRequest(editorRef.current, request) const originalQueryText = request.selection ? request.selection.queryText : request.query @@ -1310,6 +1326,7 @@ const MonacoEditor = ({ executionRefs }: { executionRefs: React.MutableRefObject if (monacoRef?.current && editorRef?.current) { applyGlyphsAndLineMarkings(monacoRef.current, editorRef.current) } + editorRef.current?.updateOptions({ readOnly: !!request }) }, [request]) const setCompletionProvider = async () => { From 5fa08a70244f551fdd7c2fc6e8f38a837192b001 Mon Sep 17 00:00:00 2001 From: emrberk Date: Tue, 22 Jul 2025 14:51:30 +0300 Subject: [PATCH 2/3] add test cases, fix decorations diffing bug --- packages/browser-tests/cypress/commands.js | 27 ++--- .../integration/console/editor.spec.js | 99 +++++++++++++++---- .../integration/console/schema.spec.js | 4 +- .../src/scenes/Editor/Monaco/index.tsx | 42 +++++--- 4 files changed, 120 insertions(+), 52 deletions(-) diff --git a/packages/browser-tests/cypress/commands.js b/packages/browser-tests/cypress/commands.js index bab84e83d..4570cc1a5 100644 --- a/packages/browser-tests/cypress/commands.js +++ b/packages/browser-tests/cypress/commands.js @@ -24,7 +24,7 @@ const tableSchemas = { const materializedViewSchemas = { btc_trades_mv: "CREATE MATERIALIZED VIEW IF NOT EXISTS btc_trades_mv WITH BASE btc_trades as (" + - "SELECT timestamp, avg(amount) FROM btc_trades SAMPLE BY 1m) PARTITION BY week;", + "SELECT timestamp, avg(amount) avg FROM btc_trades SAMPLE BY 1m) PARTITION BY week;", }; before(() => { @@ -209,18 +209,7 @@ Cypress.Commands.add("getCursorQueryGlyph", () => cy.get(".cursorQueryGlyph")); Cypress.Commands.add("getRunIconInLine", (lineNumber) => { cy.getCursorQueryGlyph().should("be.visible"); const selector = `.cursorQueryGlyph-line-${lineNumber}`; - cy.get("body").then(() => { - let element = null; - - if (Cypress.$(selector).length > 0) { - element = cy.get(selector).first(); - } - - if (!element) { - throw new Error(`No run icon found for line ${lineNumber}.`); - } - return element; - }); + return cy.get(selector).first(); }); Cypress.Commands.add("openRunDropdownInLine", (lineNumber) => { @@ -249,6 +238,18 @@ Cypress.Commands.add("clickRunQuery", () => { .wait("@exec"); }); +Cypress.Commands.add("clickRunScript", (continueOnFailure = false) => { + cy.getEditor().should("be.visible"); + cy.getByDataHook("button-run-query-dropdown").click(); + cy.getByDataHook("button-run-script").click(); + + cy.getByRole("dialog").should("be.visible"); + if (continueOnFailure) { + cy.getByDataHook("stop-after-failure-checkbox").uncheck(); + } + cy.getByDataHook("run-all-queries-confirm").click(); +}); + const numberRangeRegexp = (n, width = 3) => { const [min, max] = [n - width, n + width]; const numbers = Array.from( diff --git a/packages/browser-tests/cypress/integration/console/editor.spec.js b/packages/browser-tests/cypress/integration/console/editor.spec.js index c72243125..9b386bcea 100644 --- a/packages/browser-tests/cypress/integration/console/editor.spec.js +++ b/packages/browser-tests/cypress/integration/console/editor.spec.js @@ -296,14 +296,8 @@ describe("run all queries in tab", () => { cy.typeQuery("select 1;\nselect a;\nselect 3;"); // When - cy.getByDataHook("button-run-query-dropdown").click(); - cy.getByDataHook("button-run-script").click(); - // Then - cy.getByRole("dialog").should("be.visible"); - cy.getByDataHook("stop-after-failure-checkbox").should("be.checked"); + cy.clickRunScript(); - // When - cy.getByDataHook("run-all-queries-confirm").click(); // Then cy.getByDataHook("error-notification") .invoke("text") @@ -321,14 +315,8 @@ describe("run all queries in tab", () => { cy.typeQuery("select 1;\nselect a;\nselect 3;"); // When - cy.getByDataHook("button-run-query-dropdown").click(); - cy.getByDataHook("button-run-script").click(); - // Then - cy.getByRole("dialog").should("be.visible"); - cy.getByDataHook("stop-after-failure-checkbox").uncheck(); + cy.clickRunScript(true); - // When - cy.getByDataHook("run-all-queries-confirm").click(); // Then cy.getByDataHook("success-notification") .invoke("text") @@ -340,6 +328,81 @@ describe("run all queries in tab", () => { cy.get(".error-glyph").should("have.length", 1); cy.get(".cursorQueryGlyph").should("have.length", 3); }); + + it("should scroll to the running query and show the loading notification", () => { + // Given + cy.intercept("/exec*", (req) => { + req.on("response", (res) => { + res.setDelay(1000); + }); + }); + cy.typeQuery( + "select 1;\n\n\n\n\n\n\n\n\nselect 2;\n\n\n\n\n\n\n\n\n\nselect 3;" + ); + + // When + cy.clickRunScript(); + + // Then + cy.getByDataHook("loading-notification").should( + "contain", + `Running query "select 1"` + ); + cy.getRunIconInLine(1).should("be.visible"); + // Then + cy.getByDataHook("loading-notification").should( + "contain", + `Running query "select 2"` + ); + cy.getRunIconInLine(10).should("be.visible"); + // Then + cy.getByDataHook("loading-notification").should( + "contain", + `Running query "select 3"` + ); + cy.getRunIconInLine(20).should("be.visible"); + }); + + it("should disable editing when running script", () => { + // Given + cy.intercept("/exec*", (req) => { + req.on("response", (res) => { + res.setDelay(1000); + }); + }); + cy.typeQuery("select 1;\nselect 2;\nselect 3;"); + + // When + cy.clickRunScript(); + + // Then + cy.typeQuery("should not be visible"); + cy.getEditorContent().should("not.contain", "should not be visible"); + }); + + it("should move cursor to the failed query after running script", () => { + // Given + cy.typeQuery("select 1;\nselect a;\nselect 2;"); + cy.clickLine(1); + + // When + cy.clickRunScript(); + + // Then + cy.get(".active-line-number").should("contain", "2"); + }); + + it("should keep the cursor position if all queries are successful", () => { + // Given + cy.typeQuery("select 1;\nselect 2;\nselect 3;"); + cy.clickLine(1); + + // When + cy.clickRunScript(); + + // Then + cy.get(".active-line-number").should("contain", "1"); + }); }); describe("appendQuery", () => { @@ -1076,14 +1139,8 @@ describe("multiple run buttons with dynamic query log", () => { it("should keep execution info per tab", () => { // When cy.typeQuery("select 1;\nselect a;\nselect 3;"); - cy.getByDataHook("button-run-query-dropdown").click(); - cy.getByDataHook("button-run-script").click(); - // Then - cy.getByRole("dialog").should("be.visible"); - cy.getByDataHook("stop-after-failure-checkbox").uncheck(); + cy.clickRunScript(true); - // When - cy.getByDataHook("run-all-queries-confirm").click(); // Then cy.getByDataHook("success-notification") .invoke("text") diff --git a/packages/browser-tests/cypress/integration/console/schema.spec.js b/packages/browser-tests/cypress/integration/console/schema.spec.js index a59edb469..61c1dcb98 100644 --- a/packages/browser-tests/cypress/integration/console/schema.spec.js +++ b/packages/browser-tests/cypress/integration/console/schema.spec.js @@ -273,13 +273,13 @@ describe("questdb schema with suspended tables with Linux OS error codes", () => cy.typeQuery( "ALTER TABLE btc_trades SUSPEND WAL WITH 24, 'Too many open files';" ) - .clickRunIconInLine(1) + .clickRunQuery() .clearEditor(); cy.typeQuery( "ALTER TABLE ecommerce_stats SUSPEND WAL WITH 12, 'Out of memory';" ) - .clickRunIconInLine(1) + .clickRunQuery() .clearEditor(); }); beforeEach(() => { diff --git a/packages/web-console/src/scenes/Editor/Monaco/index.tsx b/packages/web-console/src/scenes/Editor/Monaco/index.tsx index 1f5993dc8..0c10d0645 100644 --- a/packages/web-console/src/scenes/Editor/Monaco/index.tsx +++ b/packages/web-console/src/scenes/Editor/Monaco/index.tsx @@ -954,7 +954,8 @@ const MonacoEditor = ({ executionRefs }: { executionRefs: React.MutableRefObject let successfulQueries = 0 let failedQueries = 0 const editor = editorRef.current - if (!editor) return + const monaco = monacoRef.current + if (!editor || !monaco) return const queriesToRun = queriesToRunRef.current && queriesToRunRef.current.length > 1 ? queriesToRunRef.current : undefined const runningAllQueries = !queriesToRun // Clear all notifications & execution refs for the buffer @@ -967,7 +968,6 @@ const MonacoEditor = ({ executionRefs }: { executionRefs: React.MutableRefObject } isRunningScriptRef.current = true - const startTime = Date.now() const queries = queriesToRun ?? getAllQueries(editor) let lastQuery: Request | undefined @@ -975,18 +975,29 @@ const MonacoEditor = ({ executionRefs }: { executionRefs: React.MutableRefObject editor.updateOptions({ readOnly: true }) + const startTime = Date.now() for (let i = 0; i < queries.length; i++) { const query = queries[i] editor.revealPositionInCenterIfOutsideViewport({ lineNumber: query.row + 1, column: query.column }) const queryGlyph = editor.getLineDecorations(query.row + 1)?.find(d => d.options.glyphMarginClassName?.includes("cursorQueryGlyph")) + let delta = null if (queryGlyph) { - queryGlyph.options.glyphMarginClassName += " loading-glyph" - editor.deltaDecorations([], [queryGlyph]) + delta = editor.createDecorationsCollection([{ + range: new monaco.Range( + query.row + 1, + 1, + query.row + 1, + 1 + ), + options: { + isWholeLine: false, + glyphMarginClassName: 'cursorQueryGlyph loading-glyph', + }, + }]) } const result = await runIndividualQuery(query, i === queries.length - 1) - if (queryGlyph) { - queryGlyph.options.glyphMarginClassName = queryGlyph.options.glyphMarginClassName?.replace(" loading-glyph", "") - editor.deltaDecorations([], [queryGlyph]) + if (delta) { + delta.clear() } individualQueryResults.push(result) if (result.success) { @@ -1024,16 +1035,15 @@ const MonacoEditor = ({ executionRefs }: { executionRefs: React.MutableRefObject dispatch(actions.query.addNotification({ ...notification!, updateActiveNotification: false }, activeBuffer.id as number)) }) - if (editorRef.current && lastQuery) { - if (lastResult && !lastResult.success) { - editor.setPosition({ - lineNumber: lastQuery.row + 1, - column: lastQuery.column, - }, "script") - } - editorRef.current.focus() - editorRef.current.revealPosition(editor.getPosition()!) + const lastFailureIndex = individualQueryResults.reduce((acc, result, index) => result.success ? acc : index, -1) + if (lastFailureIndex !== -1) { + editor.setPosition({ + lineNumber: queries[lastFailureIndex].row + 1, + column: queries[lastFailureIndex].column, + }, "script") } + editor.focus() + editor.revealPositionInCenterIfOutsideViewport(editor.getPosition()!) const completedGracefully = queries.length === individualQueryResults.length if (completedGracefully || (failedQueries > 0 && stopAfterFailureRef.current && runningAllQueries)) { From 0bd980e6b3ebf9b12bde095de36d5f5da2f783e1 Mon Sep 17 00:00:00 2001 From: emrberk Date: Tue, 22 Jul 2025 14:52:01 +0300 Subject: [PATCH 3/3] update submodule --- packages/browser-tests/questdb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/browser-tests/questdb b/packages/browser-tests/questdb index fe0ccbbc3..7db9598a7 160000 --- a/packages/browser-tests/questdb +++ b/packages/browser-tests/questdb @@ -1 +1 @@ -Subproject commit fe0ccbbc378e413dac9dd3be61f628b98c4264b3 +Subproject commit 7db9598a76df64c01a967495b04cc12c4d1e95d7