diff --git a/components/webui/client/package-lock.json b/components/webui/client/package-lock.json index 98a9e5a3a6..7db9f5c893 100644 --- a/components/webui/client/package-lock.json +++ b/components/webui/client/package-lock.json @@ -12,6 +12,7 @@ "@ant-design/v5-patch-for-react-19": "^1.0.3", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", + "@monaco-editor/react": "^4.7.0", "@mui/joy": "^5.0.0-beta.51", "@sinclair/typebox": "^0.34.25", "@tanstack/react-query": "^5.81.5", @@ -22,6 +23,7 @@ "chartjs-adapter-dayjs-4": "^1.0.4", "chartjs-plugin-zoom": "^2.2.0", "dayjs": "^1.11.13", + "monaco-editor": "^0.52.2", "react": "^19.0.0", "react-chartjs-2": "^5.3.0", "react-dom": "^19.0.0", @@ -1462,6 +1464,29 @@ "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", "license": "MIT" }, + "node_modules/@monaco-editor/loader": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.5.0.tgz", + "integrity": "sha512-hKoGSM+7aAc7eRTRjpqAZucPmoNOC4UUbknb/VNoTkEIkCPhqV8LfbsgM1webRM7S/z21eHEx9Fkwx8Z/C/+Xw==", + "license": "MIT", + "dependencies": { + "state-local": "^1.0.6" + } + }, + "node_modules/@monaco-editor/react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", + "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", + "license": "MIT", + "dependencies": { + "@monaco-editor/loader": "^1.5.0" + }, + "peerDependencies": { + "monaco-editor": ">= 0.25.0 < 1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@mui/base": { "version": "5.0.0-beta.40-0", "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.40-0.tgz", @@ -6225,6 +6250,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/monaco-editor": { + "version": "0.52.2", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", + "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -8006,6 +8037,12 @@ "license": "MIT", "peer": true }, + "node_modules/state-local": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", + "license": "MIT" + }, "node_modules/string-convert": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", diff --git a/components/webui/client/package.json b/components/webui/client/package.json index 4fdef4c3f2..2d25ed742e 100644 --- a/components/webui/client/package.json +++ b/components/webui/client/package.json @@ -16,6 +16,7 @@ "@ant-design/v5-patch-for-react-19": "^1.0.3", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", + "@monaco-editor/react": "^4.7.0", "@mui/joy": "^5.0.0-beta.51", "@sinclair/typebox": "^0.34.25", "@tanstack/react-query": "^5.81.5", @@ -26,6 +27,7 @@ "chartjs-adapter-dayjs-4": "^1.0.4", "chartjs-plugin-zoom": "^2.2.0", "dayjs": "^1.11.13", + "monaco-editor": "^0.52.2", "react": "^19.0.0", "react-chartjs-2": "^5.3.0", "react-dom": "^19.0.0", diff --git a/components/webui/client/public/settings.json b/components/webui/client/public/settings.json index d2f7580d39..67f60e795b 100644 --- a/components/webui/client/public/settings.json +++ b/components/webui/client/public/settings.json @@ -1,5 +1,6 @@ { "ClpStorageEngine": "clp", + "ClpQueryEngine": "native", "MongoDbSearchResultsMetadataCollectionName": "results-metadata", "SqlDbClpArchivesTableName": "clp_archives", "SqlDbClpDatasetsTableName": "clp_datasets", diff --git a/components/webui/client/src/components/SqlEditor/index.tsx b/components/webui/client/src/components/SqlEditor/index.tsx new file mode 100644 index 0000000000..90eaf72794 --- /dev/null +++ b/components/webui/client/src/components/SqlEditor/index.tsx @@ -0,0 +1,145 @@ +import { + useCallback, + useEffect, + useState, +} from "react"; + +import { + Editor, + EditorProps, + useMonaco, +} from "@monaco-editor/react"; +import {language as sqlLanguage} from "monaco-editor/esm/vs/basic-languages/sql/sql.js"; +import * as monaco from "monaco-editor/esm/vs/editor/editor.api.js"; + +import "./monaco-loader"; + + +const MAX_VISIBLE_LINES: number = 5; + +type SqlEditorProps = Omit; + +/** + * Monaco editor with highlighting and autocomplete for SQL syntax. + * + * @param props + * @return + */ +const SqlEditor = (props: SqlEditorProps) => { + const monacoEditor = useMonaco(); + + useEffect(() => { + if (null === monacoEditor) { + return () => { + }; + } + + // Adds autocomplete suggestions for SQL keywords on editor load + const provider = monacoEditor.languages.registerCompletionItemProvider("sql", { + provideCompletionItems: (model, position) => { + const word = model.getWordUntilPosition(position); + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }; + const suggestions = sqlLanguage.keywords.map((keyword: string) => ({ + detail: "Presto SQL (CLP)", + insertText: `${keyword} `, + kind: monacoEditor.languages.CompletionItemKind.Keyword, + label: keyword, + range: range, + })); + + // When SQL keyword suggestions appear (e.g., after "SELECT a"), hitting Enter + // accepts the first suggestion. To prevent accidental auto-completion + // in multi-line queries and to allow users to dismiss suggestions more easily, + // we make the current input the first suggestion. + // Users can then use arrow keys to select a keyword if needed. + const typedWord = model.getValueInRange(range); + if (0 < typedWord.length) { + suggestions.push({ + detail: "Current", + insertText: `${typedWord}\n`, + kind: monaco.languages.CompletionItemKind.Text, + label: typedWord, + range: range, + }); + } + + return { + suggestions: suggestions, + incomplete: true, + }; + }, + triggerCharacters: [ + " ", + "\n", + ], + }); + + return () => { + provider.dispose(); + }; + }, [monacoEditor]); + + const [isContentMultiline, setIsContentMultiline] = useState(false); + + const handleMonacoMount = useCallback((editor: monaco.editor.IStandaloneCodeEditor) => { + editor.onDidContentSizeChange((ev) => { + if (false === ev.contentHeightChanged) { + return; + } + if (null === monacoEditor) { + throw new Error("Unexpected null Monaco instance"); + } + const domNode = editor.getDomNode(); + if (null === domNode) { + throw new Error("Unexpected null editor DOM node"); + } + const model = editor.getModel(); + if (null === model) { + throw new Error("Unexpected null editor model"); + } + const lineHeight = editor.getOption(monacoEditor.editor.EditorOption.lineHeight); + const contentHeight = editor.getContentHeight(); + const approxWrappedLines = Math.round(contentHeight / lineHeight); + setIsContentMultiline(1 < approxWrappedLines); + if (MAX_VISIBLE_LINES >= approxWrappedLines) { + domNode.style.height = `${contentHeight}px`; + } else { + domNode.style.height = `${lineHeight * MAX_VISIBLE_LINES}px`; + } + }); + }, [monacoEditor]); + + return ( + } + options={{ + automaticLayout: true, + folding: isContentMultiline, + fontSize: 20, + lineHeight: 30, + lineNumbers: isContentMultiline ? + "on" : + "off", + lineNumbersMinChars: 2, + minimap: {enabled: false}, + overviewRulerBorder: false, + placeholder: "Enter your SQL query", + renderLineHighlightOnlyWhenFocus: true, + scrollBeyondLastLine: false, + wordWrap: "on", + }} + onMount={handleMonacoMount} + {...props}/> + ); +}; + +export default SqlEditor; diff --git a/components/webui/client/src/components/SqlEditor/monaco-loader.ts b/components/webui/client/src/components/SqlEditor/monaco-loader.ts new file mode 100644 index 0000000000..8a7c7af83f --- /dev/null +++ b/components/webui/client/src/components/SqlEditor/monaco-loader.ts @@ -0,0 +1,29 @@ +/* eslint-disable import/default, @stylistic/max-len */ +import {loader} from "@monaco-editor/react"; +import * as monaco from "monaco-editor/esm/vs/editor/editor.api"; +import EditorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker"; + +import "monaco-editor/esm/vs/basic-languages/sql/sql.contribution.js"; +import "monaco-editor/esm/vs/editor/contrib/clipboard/browser/clipboard.js"; +import "monaco-editor/esm/vs/editor/contrib/contextmenu/browser/contextmenu.js"; +import "monaco-editor/esm/vs/editor/contrib/find/browser/findController.js"; +import "monaco-editor/esm/vs/editor/contrib/wordHighlighter/browser/wordHighlighter.js"; +import "monaco-editor/esm/vs/editor/contrib/suggest/browser/suggestController.js"; +import "monaco-editor/esm/vs/editor/contrib/placeholderText/browser/placeholderText.contribution.js"; + + +/* eslint-enable import/default, @stylistic/max-len */ + + +self.MonacoEnvironment = { + /** + * Creates a web worker for Monaco Editor. + * + * @return + */ + getWorker () { + return new EditorWorker(); + }, +}; + +loader.config({monaco}); diff --git a/components/webui/client/src/components/SqlEditor/monaco-sql.d.ts b/components/webui/client/src/components/SqlEditor/monaco-sql.d.ts new file mode 100644 index 0000000000..a1a88ea6f2 --- /dev/null +++ b/components/webui/client/src/components/SqlEditor/monaco-sql.d.ts @@ -0,0 +1,10 @@ +declare module "monaco-editor/esm/vs/basic-languages/sql/sql.js" { + import {languages} from "monaco-editor/esm/vs/editor/editor.api"; + + + interface SqlLanguageDefinition extends languages.IMonarchLanguage { + keywords: string[]; + } + + export const language: SqlLanguageDefinition; +} diff --git a/components/webui/client/src/config/index.ts b/components/webui/client/src/config/index.ts index 64beca676f..0fed22bb9e 100644 --- a/components/webui/client/src/config/index.ts +++ b/components/webui/client/src/config/index.ts @@ -9,7 +9,16 @@ enum CLP_STORAGE_ENGINES { CLP_S = "clp-s", } +/** + * Query engine options. + */ +enum CLP_QUERY_ENGINES { + NATIVE = "native", + PRESTO = "presto", +} + const SETTINGS_STORAGE_ENGINE = settings.ClpStorageEngine as CLP_STORAGE_ENGINES; +const SETTINGS_QUERY_ENGINE = settings.ClpQueryEngine as CLP_QUERY_ENGINES; /** * Stream type based on the storage engine. @@ -19,7 +28,9 @@ const STREAM_TYPE = CLP_STORAGE_ENGINES.CLP === SETTINGS_STORAGE_ENGINE ? "json"; export { + CLP_QUERY_ENGINES, CLP_STORAGE_ENGINES, + SETTINGS_QUERY_ENGINE, SETTINGS_STORAGE_ENGINE, STREAM_TYPE, }; diff --git a/components/webui/client/src/pages/SearchPage/SearchControls/Presto/RunButton/index.tsx b/components/webui/client/src/pages/SearchPage/SearchControls/Presto/RunButton/index.tsx new file mode 100644 index 0000000000..839d96209f --- /dev/null +++ b/components/webui/client/src/pages/SearchPage/SearchControls/Presto/RunButton/index.tsx @@ -0,0 +1,38 @@ +import {CaretRightOutlined} from "@ant-design/icons"; +import { + Button, + Tooltip, +} from "antd"; + +import useSearchStore from "../../../SearchState/index"; + + +/** + * Renders a button to run the SQL query. + * + * @return + */ +const RunButton = () => { + const queryString = useSearchStore((state) => state.queryString); + + const isQueryStringEmpty = "" === queryString; + const tooltipTitle = isQueryStringEmpty ? + "Enter SQL query to run" : + ""; + + return ( + + + + ); +}; + +export default RunButton; diff --git a/components/webui/client/src/pages/SearchPage/SearchControls/Presto/SqlQueryInput/index.module.css b/components/webui/client/src/pages/SearchPage/SearchControls/Presto/SqlQueryInput/index.module.css new file mode 100644 index 0000000000..e27abcd719 --- /dev/null +++ b/components/webui/client/src/pages/SearchPage/SearchControls/Presto/SqlQueryInput/index.module.css @@ -0,0 +1,5 @@ +/* Allows the editor to shrink when page width decreases */ +.input { + width: 100%; + min-width: 0; +} diff --git a/components/webui/client/src/pages/SearchPage/SearchControls/Presto/SqlQueryInput/index.tsx b/components/webui/client/src/pages/SearchPage/SearchControls/Presto/SqlQueryInput/index.tsx new file mode 100644 index 0000000000..c831a6c53c --- /dev/null +++ b/components/webui/client/src/pages/SearchPage/SearchControls/Presto/SqlQueryInput/index.tsx @@ -0,0 +1,26 @@ +import {useCallback} from "react"; + +import SqlEditor from "../../../../../components/SqlEditor"; +import useSearchStore from "../../../SearchState/index"; +import styles from "./index.module.css"; + + +/** + * Renders SQL query input. + * + * @return + */ +const SqlQueryInput = () => { + const handleChange = useCallback((value: string | undefined) => { + const {updateQueryString} = useSearchStore.getState(); + updateQueryString(value || ""); + }, []); + + return ( +
+ +
+ ); +}; + +export default SqlQueryInput; diff --git a/components/webui/client/src/pages/SearchPage/SearchControls/SearchButton/SubmitButton/index.tsx b/components/webui/client/src/pages/SearchPage/SearchControls/SearchButton/SubmitButton/index.tsx index c416428c62..b325c28301 100644 --- a/components/webui/client/src/pages/SearchPage/SearchControls/SearchButton/SubmitButton/index.tsx +++ b/components/webui/client/src/pages/SearchPage/SearchControls/SearchButton/SubmitButton/index.tsx @@ -11,7 +11,7 @@ import { SETTINGS_STORAGE_ENGINE, } from "../../../../../config"; import {computeTimelineConfig} from "../../../SearchResults/SearchResultsTimeline/utils"; -import useSearchStore, {SEARCH_STATE_DEFAULT} from "../../../SearchState/index"; +import useSearchStore from "../../../SearchState/index"; import {SEARCH_UI_STATE} from "../../../SearchState/typings"; import {handleQuerySubmit} from "../../search-requests"; import styles from "./index.module.css"; @@ -63,7 +63,7 @@ const SubmitButton = () => { selectDataset, updateCachedDataset]); - const isQueryStringEmpty = queryString === SEARCH_STATE_DEFAULT.queryString; + const isQueryStringEmpty = "" === queryString; // Submit button must be disabled if there are no datasets since clp-s requires dataset option // for queries. diff --git a/components/webui/client/src/pages/SearchPage/SearchControls/index.tsx b/components/webui/client/src/pages/SearchPage/SearchControls/index.tsx index 0b8e767558..b6e4c49f1c 100644 --- a/components/webui/client/src/pages/SearchPage/SearchControls/index.tsx +++ b/components/webui/client/src/pages/SearchPage/SearchControls/index.tsx @@ -1,9 +1,13 @@ import { + CLP_QUERY_ENGINES, CLP_STORAGE_ENGINES, + SETTINGS_QUERY_ENGINE, SETTINGS_STORAGE_ENGINE, } from "../../../config"; import Dataset from "./Dataset"; import styles from "./index.module.css"; +import RunButton from "./Presto/RunButton"; +import SqlQueryInput from "./Presto/SqlQueryInput"; import QueryInput from "./QueryInput"; import SearchButton from "./SearchButton"; import TimeRangeInput from "./TimeRangeInput"; @@ -27,10 +31,21 @@ const SearchControls = () => { return (
- {CLP_STORAGE_ENGINES.CLP_S === SETTINGS_STORAGE_ENGINE && } - - - + {SETTINGS_QUERY_ENGINE === CLP_QUERY_ENGINES.NATIVE ? + ( + <> + {CLP_STORAGE_ENGINES.CLP_S === SETTINGS_STORAGE_ENGINE && } + + + + + ) : + ( + <> + + + + )}
); diff --git a/components/webui/client/src/settings.ts b/components/webui/client/src/settings.ts index 53ac0949e8..b20711e1d8 100644 --- a/components/webui/client/src/settings.ts +++ b/components/webui/client/src/settings.ts @@ -3,6 +3,7 @@ import axios from "axios"; type Settings = { ClpStorageEngine: string; + ClpQueryEngine: string; MongoDbSearchResultsMetadataCollectionName: string; SqlDbClpArchivesTableName: string; SqlDbClpDatasetsTableName: string; diff --git a/components/webui/client/vite.config.ts b/components/webui/client/vite.config.ts index 76e70f3563..ec97467ce1 100644 --- a/components/webui/client/vite.config.ts +++ b/components/webui/client/vite.config.ts @@ -9,6 +9,13 @@ export default defineConfig({ base: "./", build: { target: "esnext", + rollupOptions: { + output: { + manualChunks: { + "monaco-editor": ["monaco-editor"], + }, + }, + }, }, plugins: [ react(),