From 58402a741e49f900f39b917206cd400896e30749 Mon Sep 17 00:00:00 2001 From: Augustin Husson Date: Tue, 17 May 2022 16:12:20 +0200 Subject: [PATCH] introduce codemirror editor for local query to filter targets, service discovery Signed-off-by: Augustin Husson --- web/ui/package-lock.json | 61 +++++++--- web/ui/react-app/package.json | 5 +- web/ui/react-app/src/components/SearchBar.tsx | 106 +++++++++++++++--- .../src/pages/alerts/AlertContents.tsx | 14 ++- .../src/pages/serviceDiscovery/Services.tsx | 27 ++++- .../src/pages/targets/ScrapePoolList.tsx | 14 ++- 6 files changed, 177 insertions(+), 50 deletions(-) diff --git a/web/ui/package-lock.json b/web/ui/package-lock.json index a7d6d74e39..a5adef5ef7 100644 --- a/web/ui/package-lock.json +++ b/web/ui/package-lock.json @@ -2963,17 +2963,32 @@ "@lezer/common": "^0.15.0" } }, + "node_modules/@nexucis/codemirror-kvsearch": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@nexucis/codemirror-kvsearch/-/codemirror-kvsearch-0.2.0.tgz", + "integrity": "sha512-6amnDba+DhQnYAjz87yj18c2co5rmEDd2XbSsIsMxuV+QRfcVOPeKtsr/pWFrVH5/7lp6cGSaWqPgqYNmjAP9g==", + "dependencies": { + "@nexucis/kvsearch": "^0.8.0" + }, + "peerDependencies": { + "@codemirror/autocomplete": "^0.19.8", + "@codemirror/language": "^0.19.5", + "@codemirror/lint": "^0.19.3", + "@codemirror/state": "^0.19.6", + "@lezer/common": "^0.15.10" + } + }, "node_modules/@nexucis/fuzzy": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@nexucis/fuzzy/-/fuzzy-0.4.0.tgz", - "integrity": "sha512-VZVzETBXJjFzTd5RmKR+X8V5uM9PKM2yyb7v/aygMF+1iAnZwxzQtxbFx358SNP23eid4s85TIREHw50p8Zugg==" + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@nexucis/fuzzy/-/fuzzy-0.4.1.tgz", + "integrity": "sha512-oe+IW6ELwVGYL3340M+nKIT1exZizOjxdUFlTs36BqzxTENBbynG+cCWr4RNaUQF3bV78NspKwTBpTlnYADrTA==" }, "node_modules/@nexucis/kvsearch": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@nexucis/kvsearch/-/kvsearch-0.7.0.tgz", - "integrity": "sha512-Zl1u0wUpgpfY1JmHIKyLuqDdN5iTm/wuLXbBbm//Qck/un9ivGYtePpT1/BjG/2XisBsvHb7EldMAuRQaUahtg==", + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@nexucis/kvsearch/-/kvsearch-0.8.0.tgz", + "integrity": "sha512-83m6AgFNva4+5Zoc3Z5vnk+KZBMqA6cotNIIm9Zejwp5VlhO/Pfy6RTZCB8KKKA3MRSP7EDYBMBwnOfMQKpHyg==", "dependencies": { - "@nexucis/fuzzy": "^0.4.0" + "@nexucis/fuzzy": "^0.4.1" } }, "node_modules/@nodelib/fs.scandir": { @@ -17331,8 +17346,9 @@ "@fortawesome/fontawesome-svg-core": "6.1.1", "@fortawesome/free-solid-svg-icons": "6.1.1", "@fortawesome/react-fontawesome": "0.1.17", - "@nexucis/fuzzy": "^0.4.0", - "@nexucis/kvsearch": "^0.7.0", + "@nexucis/codemirror-kvsearch": "^0.2.0", + "@nexucis/fuzzy": "^0.4.1", + "@nexucis/kvsearch": "^0.8.0", "bootstrap": "^4.6.1", "codemirror-promql": "0.19.0", "css.escape": "^1.5.1", @@ -19495,17 +19511,25 @@ "@lezer/common": "^0.15.0" } }, + "@nexucis/codemirror-kvsearch": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@nexucis/codemirror-kvsearch/-/codemirror-kvsearch-0.2.0.tgz", + "integrity": "sha512-6amnDba+DhQnYAjz87yj18c2co5rmEDd2XbSsIsMxuV+QRfcVOPeKtsr/pWFrVH5/7lp6cGSaWqPgqYNmjAP9g==", + "requires": { + "@nexucis/kvsearch": "^0.8.0" + } + }, "@nexucis/fuzzy": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@nexucis/fuzzy/-/fuzzy-0.4.0.tgz", - "integrity": "sha512-VZVzETBXJjFzTd5RmKR+X8V5uM9PKM2yyb7v/aygMF+1iAnZwxzQtxbFx358SNP23eid4s85TIREHw50p8Zugg==" + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@nexucis/fuzzy/-/fuzzy-0.4.1.tgz", + "integrity": "sha512-oe+IW6ELwVGYL3340M+nKIT1exZizOjxdUFlTs36BqzxTENBbynG+cCWr4RNaUQF3bV78NspKwTBpTlnYADrTA==" }, "@nexucis/kvsearch": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@nexucis/kvsearch/-/kvsearch-0.7.0.tgz", - "integrity": "sha512-Zl1u0wUpgpfY1JmHIKyLuqDdN5iTm/wuLXbBbm//Qck/un9ivGYtePpT1/BjG/2XisBsvHb7EldMAuRQaUahtg==", + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@nexucis/kvsearch/-/kvsearch-0.8.0.tgz", + "integrity": "sha512-83m6AgFNva4+5Zoc3Z5vnk+KZBMqA6cotNIIm9Zejwp5VlhO/Pfy6RTZCB8KKKA3MRSP7EDYBMBwnOfMQKpHyg==", "requires": { - "@nexucis/fuzzy": "^0.4.0" + "@nexucis/fuzzy": "^0.4.1" } }, "@nodelib/fs.scandir": { @@ -23951,8 +23975,9 @@ "@fortawesome/fontawesome-svg-core": "6.1.1", "@fortawesome/free-solid-svg-icons": "6.1.1", "@fortawesome/react-fontawesome": "0.1.17", - "@nexucis/fuzzy": "^0.4.0", - "@nexucis/kvsearch": "^0.7.0", + "@nexucis/codemirror-kvsearch": "^0.2.0", + "@nexucis/fuzzy": "^0.4.1", + "@nexucis/kvsearch": "^0.8.0", "@testing-library/react-hooks": "^7.0.1", "@types/enzyme": "^3.10.10", "@types/flot": "0.0.32", diff --git a/web/ui/react-app/package.json b/web/ui/react-app/package.json index ee2dc8ed77..9c74ee2c40 100644 --- a/web/ui/react-app/package.json +++ b/web/ui/react-app/package.json @@ -19,8 +19,9 @@ "@fortawesome/fontawesome-svg-core": "6.1.1", "@fortawesome/free-solid-svg-icons": "6.1.1", "@fortawesome/react-fontawesome": "0.1.17", - "@nexucis/fuzzy": "^0.4.0", - "@nexucis/kvsearch": "^0.7.0", + "@nexucis/fuzzy": "^0.4.1", + "@nexucis/kvsearch": "^0.8.0", + "@nexucis/codemirror-kvsearch": "^0.2.0", "bootstrap": "^4.6.1", "codemirror-promql": "0.19.0", "css.escape": "^1.5.1", diff --git a/web/ui/react-app/src/components/SearchBar.tsx b/web/ui/react-app/src/components/SearchBar.tsx index 23aa515ad7..1688c88b49 100644 --- a/web/ui/react-app/src/components/SearchBar.tsx +++ b/web/ui/react-app/src/components/SearchBar.tsx @@ -1,34 +1,110 @@ -import React, { ChangeEvent, FC, useEffect } from 'react'; -import { Input, InputGroup, InputGroupAddon, InputGroupText } from 'reactstrap'; +import React, { FC, useEffect, useRef } from 'react'; +import { InputGroup, InputGroupAddon, InputGroupText } from 'reactstrap'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faSearch } from '@fortawesome/free-solid-svg-icons'; +import { EditorState } from '@codemirror/state'; +import { baseTheme, lightTheme, promqlHighlighter } from '../pages/graph/CMTheme'; +import { EditorView, highlightSpecialChars, keymap, placeholder as placeholderFunc, ViewUpdate } from '@codemirror/view'; +import { history, historyKeymap } from '@codemirror/history'; +import { indentOnInput } from '@codemirror/language'; +import { bracketMatching } from '@codemirror/matchbrackets'; +import { closeBrackets, closeBracketsKeymap } from '@codemirror/closebrackets'; +import { autocompletion, completionKeymap } from '@codemirror/autocomplete'; +import { highlightSelectionMatches } from '@codemirror/search'; +import { defaultKeymap } from '@codemirror/commands'; +import { commentKeymap } from '@codemirror/comment'; +import { lintKeymap } from '@codemirror/lint'; +import { KVSearchExtension } from '@nexucis/codemirror-kvsearch'; export interface SearchBarProps { - handleChange: (e: string) => void; + handleChange: (state: EditorState) => void; placeholder: string; defaultValue: string; + objects?: Record[]; } -const SearchBar: FC = ({ handleChange, placeholder, defaultValue }) => { - let filterTimeout: NodeJS.Timeout; - - const handleSearchChange = (e: ChangeEvent) => { - clearTimeout(filterTimeout); - filterTimeout = setTimeout(() => { - handleChange(e.target.value); - }, 300); - }; +const SearchBar: FC = ({ handleChange, placeholder, defaultValue, objects }) => { + const containerRef = useRef(null); + const viewRef = useRef(null); + const filterTimeoutRef = useRef(null); useEffect(() => { - handleChange(defaultValue); - }, [defaultValue, handleChange]); + const kvsearchExtension = new KVSearchExtension(objects); + const handleSearchChange = (state: EditorState) => { + let filterTimeout = filterTimeoutRef.current; + if (filterTimeout !== null) { + clearTimeout(filterTimeout); + } + + filterTimeout = setTimeout(() => { + handleChange(state); + }, 300); + filterTimeoutRef.current = filterTimeout; + }; + // Create or reconfigure the editor. + let view = viewRef.current; + if (view === null) { + // If the editor does not exist yet, create it. + if (!containerRef.current) { + throw new Error('expected CodeMirror container element to exist'); + } + const startState = EditorState.create({ + doc: defaultValue, + extensions: [ + baseTheme, + promqlHighlighter, + lightTheme, + highlightSpecialChars(), + history(), + EditorState.allowMultipleSelections.of(true), + indentOnInput(), + bracketMatching(), + closeBrackets(), + autocompletion(), + highlightSelectionMatches(), + EditorView.lineWrapping, + keymap.of([ + ...closeBracketsKeymap, + ...defaultKeymap, + ...historyKeymap, + ...commentKeymap, + ...completionKeymap, + ...lintKeymap, + ]), + placeholderFunc(placeholder), + keymap.of([ + { + key: 'Escape', + run: (v: EditorView): boolean => { + v.contentDOM.blur(); + return false; + }, + }, + ]), + EditorView.updateListener.of((update: ViewUpdate): void => { + handleSearchChange(update.state); + }), + kvsearchExtension.asExtension(), + ], + }); + + view = new EditorView({ + state: startState, + parent: containerRef.current, + }); + viewRef.current = view; + + view.focus(); + } + handleChange(view.state); + }, [defaultValue, handleChange, objects, placeholder]); return ( {} - +
); }; diff --git a/web/ui/react-app/src/pages/alerts/AlertContents.tsx b/web/ui/react-app/src/pages/alerts/AlertContents.tsx index 2837399f7c..7d8ffdc8aa 100644 --- a/web/ui/react-app/src/pages/alerts/AlertContents.tsx +++ b/web/ui/react-app/src/pages/alerts/AlertContents.tsx @@ -8,6 +8,8 @@ import { useLocalStorage } from '../../hooks/useLocalStorage'; import CustomInfiniteScroll, { InfiniteScrollItemsProps } from '../../components/CustomInfiniteScroll'; import { KVSearch } from '@nexucis/kvsearch'; import SearchBar from '../../components/SearchBar'; +import { EditorState } from '@codemirror/state'; +import { translate } from '@nexucis/codemirror-kvsearch'; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type RuleState = keyof RuleStatus; @@ -79,13 +81,15 @@ const AlertsContent: FC = ({ groups = [], statsCount }) => { }; const handleSearchChange = useCallback( - (value: string) => { - setQuerySearchFilter(value); - if (value !== '') { - const pattern = value.trim(); + (state: EditorState) => { + const pattern = state.doc.toString().trim(); + setQuerySearchFilter(pattern); + if (pattern !== '') { + const query = translate(state); const result: RuleGroup[] = []; for (const group of groups) { - const ruleFilterList = kvSearchRule.filter(pattern, group.rules); + const ruleFilterList = + query !== null ? kvSearchRule.filterWithQuery(query, group.rules) : kvSearchRule.filter(pattern, group.rules); if (ruleFilterList.length > 0) { result.push({ file: group.file, diff --git a/web/ui/react-app/src/pages/serviceDiscovery/Services.tsx b/web/ui/react-app/src/pages/serviceDiscovery/Services.tsx index 21bf2259b9..ba232e6b88 100644 --- a/web/ui/react-app/src/pages/serviceDiscovery/Services.tsx +++ b/web/ui/react-app/src/pages/serviceDiscovery/Services.tsx @@ -10,6 +10,8 @@ import { API_PATH } from '../../constants/constants'; import { KVSearch } from '@nexucis/kvsearch'; import { Container } from 'reactstrap'; import SearchBar from '../../components/SearchBar'; +import { EditorState } from '@codemirror/state'; +import { translate } from '@nexucis/codemirror-kvsearch'; interface ServiceMap { activeTargets: Target[]; @@ -101,11 +103,19 @@ export const ServiceDiscoveryContent: FC = ({ activeTargets, dropped const [labelList, setLabelList] = useState(processTargets(activeTargets, droppedTargets)); const handleSearchChange = useCallback( - (value: string) => { - setQuerySearchFilter(value); - if (value !== '') { - const activeTargetResult = activeTargetKVSearch.filter(value.trim(), activeTargets); - const droppedTargetResult = droppedTargetKVSearch.filter(value.trim(), droppedTargets); + (state: EditorState) => { + const pattern = state.doc.toString().trim(); + setQuerySearchFilter(pattern); + if (pattern !== '') { + const query = translate(state); + const activeTargetResult = + query !== null + ? activeTargetKVSearch.filterWithQuery(query, activeTargets) + : activeTargetKVSearch.filter(pattern, activeTargets); + const droppedTargetResult = + query !== null + ? droppedTargetKVSearch.filterWithQuery(query, droppedTargets) + : droppedTargetKVSearch.filter(pattern, droppedTargets); setActiveTargetList(activeTargetResult.map((value) => value.original)); setDroppedTargetList(droppedTargetResult.map((value) => value.original)); } else { @@ -126,7 +136,12 @@ export const ServiceDiscoveryContent: FC = ({ activeTargets, dropped <>

Service Discovery

- +
    {mapObjEntries(targetList, ([k, v]) => ( diff --git a/web/ui/react-app/src/pages/targets/ScrapePoolList.tsx b/web/ui/react-app/src/pages/targets/ScrapePoolList.tsx index d7078128e8..84c4f361df 100644 --- a/web/ui/react-app/src/pages/targets/ScrapePoolList.tsx +++ b/web/ui/react-app/src/pages/targets/ScrapePoolList.tsx @@ -13,6 +13,8 @@ import styles from './ScrapePoolPanel.module.css'; import { ToggleMoreLess } from '../../components/ToggleMoreLess'; import SearchBar from '../../components/SearchBar'; import { setQuerySearchFilter, getQuerySearchFilter } from '../../utils/index'; +import { EditorState } from '@codemirror/state'; +import { translate } from '@nexucis/codemirror-kvsearch'; interface ScrapePoolListProps { activeTargets: Target[]; @@ -74,10 +76,13 @@ const ScrapePoolListContent: FC = ({ activeTargets }) => { const { showHealthy, showUnhealthy } = filter; const handleSearchChange = useCallback( - (value: string) => { - setQuerySearchFilter(value); - if (value !== '') { - const result = kvSearch.filter(value.trim(), activeTargets); + (state: EditorState) => { + const pattern = state.doc.toString().trim(); + setQuerySearchFilter(pattern); + if (pattern !== '') { + const query = translate(state); + const result = + query !== null ? kvSearch.filterWithQuery(query, activeTargets) : kvSearch.filter(pattern, activeTargets); setTargetList(result.map((value) => value.original)); } else { setTargetList(activeTargets); @@ -104,6 +109,7 @@ const ScrapePoolListContent: FC = ({ activeTargets }) => { defaultValue={defaultValue} handleChange={handleSearchChange} placeholder="Filter by endpoint or labels" + objects={activeTargets} />