diff --git a/NOTICE b/NOTICE index 7c0e4c1020..33f226d9f8 100644 --- a/NOTICE +++ b/NOTICE @@ -91,6 +91,11 @@ https://github.com/dgryski/go-tsz Copyright (c) 2015,2016 Damian Gryski See https://github.com/dgryski/go-tsz/blob/master/LICENSE for license details. +The Codicon icon font from Microsoft +https://github.com/microsoft/vscode-codicons +Copyright (c) Microsoft Corporation and other contributors +See https://github.com/microsoft/vscode-codicons/blob/main/LICENSE for license details. + We also use code from a large number of npm packages. For details, see: - https://github.com/prometheus/prometheus/blob/main/web/ui/react-app/package.json - https://github.com/prometheus/prometheus/blob/main/web/ui/react-app/package-lock.json diff --git a/web/ui/react-app/package.json b/web/ui/react-app/package.json index 62a188d13c..713851ba83 100644 --- a/web/ui/react-app/package.json +++ b/web/ui/react-app/package.json @@ -3,11 +3,22 @@ "version": "0.1.0", "private": true, "dependencies": { + "@codemirror/autocomplete": "^0.18.3", + "@codemirror/closebrackets": "^0.18.0", + "@codemirror/commands": "^0.18.0", + "@codemirror/comment": "^0.18.0", + "@codemirror/highlight": "^0.18.3", + "@codemirror/history": "^0.18.0", + "@codemirror/language": "^0.18.0", + "@codemirror/lint": "^0.18.1", + "@codemirror/matchbrackets": "^0.18.0", + "@codemirror/search": "^0.18.2", + "@codemirror/state": "^0.18.2", + "@codemirror/view": "^0.18.3", "@fortawesome/fontawesome-svg-core": "^1.2.14", "@fortawesome/free-solid-svg-icons": "^5.7.1", "@fortawesome/react-fontawesome": "^0.1.4", "@reach/router": "^1.2.1", - "@testing-library/react-hooks": "^3.1.1", "@types/jest": "^26.0.10", "@types/jquery": "^3.5.1", "@types/node": "^12.11.1", @@ -18,6 +29,7 @@ "@types/react-resize-detector": "^5.0.0", "@types/sanitize-html": "^1.20.2", "bootstrap": "^4.2.1", + "codemirror-promql": "^0.13.0", "css.escape": "^1.5.1", "downshift": "^3.4.8", "enzyme-to-json": "^3.4.3", @@ -63,6 +75,7 @@ "not op_mini all" ], "devDependencies": { + "@testing-library/react-hooks": "^3.1.1", "@types/enzyme": "^3.10.3", "@types/enzyme-adapter-react-16": "^1.0.5", "@types/flot": "0.0.31", @@ -83,6 +96,7 @@ "eslint-plugin-react": "7.x", "eslint-plugin-react-hooks": "2.x", "jest-fetch-mock": "^3.0.3", + "mutationobserver-shim": "^0.3.7", "prettier": "^1.18.2", "sinon": "^9.0.3" }, @@ -90,6 +104,9 @@ "jest": { "snapshotSerializers": [ "enzyme-to-json/serializer" + ], + "transformIgnorePatterns": [ + "/node_modules/(?!codemirror-promql).+(js|jsx)$" ] } } diff --git a/web/ui/react-app/src/App.css b/web/ui/react-app/src/App.css index d34f68a254..b1f54660a5 100644 --- a/web/ui/react-app/src/App.css +++ b/web/ui/react-app/src/App.css @@ -36,10 +36,17 @@ input[type='checkbox']:checked + label { margin-bottom: 10px; } -.expression-input textarea { - /* font-family: Menlo,Monaco,Consolas,'Courier New',monospace; */ - resize: none; - overflow: hidden; +.expression-input .cm-expression-input { + border: 1px solid #ced4da; + flex: 1 1 auto; + padding: 4px 0 0 8px; + font-size: 15px; +} + +/* Font used for autocompletion item icons. */ +@font-face { + font-family: 'codicon'; + src: local('codicon'), url(./fonts/codicon.ttf) format('truetype'); } button.execute-btn { diff --git a/web/ui/react-app/src/fonts/codicon.ttf b/web/ui/react-app/src/fonts/codicon.ttf new file mode 100644 index 0000000000..82acc8995b Binary files /dev/null and b/web/ui/react-app/src/fonts/codicon.ttf differ diff --git a/web/ui/react-app/src/index.tsx b/web/ui/react-app/src/index.tsx index 9398bc8983..ba8aececcc 100755 --- a/web/ui/react-app/src/index.tsx +++ b/web/ui/react-app/src/index.tsx @@ -3,6 +3,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import App from './App'; import 'bootstrap/dist/css/bootstrap.min.css'; +import './fonts/codicon.ttf'; import { isPresent } from './utils'; // Declared/defined in public/index.html, value replaced by Prometheus when serving bundle. diff --git a/web/ui/react-app/src/pages/graph/CMExpressionInput.test.tsx b/web/ui/react-app/src/pages/graph/CMExpressionInput.test.tsx new file mode 100644 index 0000000000..d748056bb9 --- /dev/null +++ b/web/ui/react-app/src/pages/graph/CMExpressionInput.test.tsx @@ -0,0 +1,72 @@ +import * as React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import CMExpressionInput from './CMExpressionInput'; +import { Button, InputGroup, InputGroupAddon, Input } from 'reactstrap'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faSearch, faSpinner } from '@fortawesome/free-solid-svg-icons'; + +describe('CMExpressionInput', () => { + const expressionInputProps = { + value: 'node_cpu', + queryHistory: [], + metricNames: [], + executeQuery: (): void => { + // Do nothing. + }, + onExpressionChange: (): void => { + // Do nothing. + }, + loading: false, + enableAutocomplete: true, + enableHighlighting: true, + enableLinter: true, + }; + + let expressionInput: ReactWrapper; + beforeEach(() => { + expressionInput = mount(); + }); + + it('renders an InputGroup', () => { + const inputGroup = expressionInput.find(InputGroup); + expect(inputGroup.prop('className')).toEqual('expression-input'); + }); + + it('renders a search icon when it is not loading', () => { + const addon = expressionInput.find(InputGroupAddon).filterWhere(addon => addon.prop('addonType') === 'prepend'); + const icon = addon.find(FontAwesomeIcon); + expect(icon.prop('icon')).toEqual(faSearch); + }); + + it('renders a loading icon when it is loading', () => { + const expressionInput = mount(); + const addon = expressionInput.find(InputGroupAddon).filterWhere(addon => addon.prop('addonType') === 'prepend'); + const icon = addon.find(FontAwesomeIcon); + expect(icon.prop('icon')).toEqual(faSpinner); + expect(icon.prop('spin')).toBe(true); + }); + + it('renders a CodeMirror expression input', () => { + const input = expressionInput.find('div.cm-expression-input'); + expect(input.text()).toContain('node_cpu'); + }); + + it('renders an execute button', () => { + const addon = expressionInput.find(InputGroupAddon).filterWhere(addon => addon.prop('addonType') === 'append'); + const button = addon + .find(Button) + .find('.execute-btn') + .first(); + expect(button.prop('color')).toEqual('primary'); + expect(button.text()).toEqual('Execute'); + }); + + it('executes the query when clicking the execute button', () => { + const spyExecuteQuery = jest.fn(); + const props = { ...expressionInputProps, executeQuery: spyExecuteQuery }; + const wrapper = mount(); + const btn = wrapper.find(Button).filterWhere(btn => btn.hasClass('execute-btn')); + btn.simulate('click'); + expect(spyExecuteQuery).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web/ui/react-app/src/pages/graph/CMExpressionInput.tsx b/web/ui/react-app/src/pages/graph/CMExpressionInput.tsx new file mode 100644 index 0000000000..ab9de6d36e --- /dev/null +++ b/web/ui/react-app/src/pages/graph/CMExpressionInput.tsx @@ -0,0 +1,240 @@ +import React, { FC, useState, useEffect, useRef } from 'react'; +import { Button, InputGroup, InputGroupAddon, InputGroupText } from 'reactstrap'; + +import { EditorView, highlightSpecialChars, keymap, ViewUpdate, placeholder } from '@codemirror/view'; +import { EditorState, Prec, Compartment } from '@codemirror/state'; +import { indentOnInput, syntaxTree } from '@codemirror/language'; +import { history, historyKeymap } from '@codemirror/history'; +import { defaultKeymap, insertNewlineAndIndent } from '@codemirror/commands'; +import { bracketMatching } from '@codemirror/matchbrackets'; +import { closeBrackets, closeBracketsKeymap } from '@codemirror/closebrackets'; +import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'; +import { commentKeymap } from '@codemirror/comment'; +import { lintKeymap } from '@codemirror/lint'; +import { PromQLExtension } from 'codemirror-promql'; +import { autocompletion, completionKeymap, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; +import { theme, promqlHighlighter } from './CMTheme'; + +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faSearch, faSpinner, faGlobeEurope } from '@fortawesome/free-solid-svg-icons'; +import MetricsExplorer from './MetricsExplorer'; +import { CompleteStrategy, newCompleteStrategy } from 'codemirror-promql/complete'; +import { usePathPrefix } from '../../contexts/PathPrefixContext'; + +const promqlExtension = new PromQLExtension(); + +interface CMExpressionInputProps { + value: string; + onExpressionChange: (expr: string) => void; + queryHistory: string[]; + metricNames: string[]; + executeQuery: () => void; + loading: boolean; + enableAutocomplete: boolean; + enableHighlighting: boolean; + enableLinter: boolean; +} + +const dynamicConfigCompartment = new Compartment(); + +// Autocompletion strategy that wraps the main one and enriches +// it with past query items. +export class HistoryCompleteStrategy implements CompleteStrategy { + private complete: CompleteStrategy; + private queryHistory: string[]; + constructor(complete: CompleteStrategy, queryHistory: string[]) { + this.complete = complete; + this.queryHistory = queryHistory; + } + + promQL(context: CompletionContext): Promise | CompletionResult | null { + return Promise.resolve(this.complete.promQL(context)).then(res => { + const { state, pos } = context; + const tree = syntaxTree(state).resolve(pos, -1); + const start = res != null ? res.from : tree.from; + + if (start !== 0) { + return res; + } + + const historyItems: CompletionResult = { + from: start, + to: pos, + options: this.queryHistory.map(q => ({ + label: q.length < 80 ? q : q.slice(0, 76).concat('...'), + detail: 'past query', + apply: q, + info: q.length < 80 ? undefined : q, + })), + span: /^[a-zA-Z0-9_:]+$/, + }; + + if (res !== null) { + historyItems.options = historyItems.options.concat(res.options); + } + return historyItems; + }); + } +} + +const CMExpressionInput: FC = ({ + value, + onExpressionChange, + queryHistory, + metricNames, + executeQuery, + loading, + enableAutocomplete, + enableHighlighting, + enableLinter, +}) => { + const containerRef = useRef(null); + const viewRef = useRef(null); + const [showMetricsExplorer, setShowMetricsExplorer] = useState(false); + const pathPrefix = usePathPrefix(); + + // (Re)initialize editor based on settings / setting changes. + useEffect(() => { + // Build the dynamic part of the config. + promqlExtension.activateCompletion(enableAutocomplete); + promqlExtension.activateLinter(enableLinter); + promqlExtension.setComplete({ + completeStrategy: new HistoryCompleteStrategy( + newCompleteStrategy({ + remote: { url: pathPrefix }, + }), + queryHistory + ), + }); + const dynamicConfig = [enableHighlighting ? promqlHighlighter : [], promqlExtension.asExtension()]; + + // Create or reconfigure the editor. + const 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: value, + extensions: [ + theme, + highlightSpecialChars(), + history(), + EditorState.allowMultipleSelections.of(true), + indentOnInput(), + bracketMatching(), + closeBrackets(), + autocompletion(), + highlightSelectionMatches(), + keymap.of([ + ...closeBracketsKeymap, + ...defaultKeymap, + ...searchKeymap, + ...historyKeymap, + ...commentKeymap, + ...completionKeymap, + ...lintKeymap, + ]), + placeholder('Expression (press Shift+Enter for newlines)'), + dynamicConfigCompartment.of(dynamicConfig), + // This keymap is added without precedence so that closing the autocomplete dropdown + // via Escape works without blurring the editor. + keymap.of([ + { + key: 'Escape', + run: (v: EditorView): boolean => { + v.contentDOM.blur(); + return false; + }, + }, + ]), + Prec.override( + keymap.of([ + { + key: 'Enter', + run: (v: EditorView): boolean => { + executeQuery(); + return true; + }, + }, + { + key: 'Shift-Enter', + run: insertNewlineAndIndent, + }, + ]) + ), + EditorView.updateListener.of((update: ViewUpdate): void => { + onExpressionChange(update.state.doc.toString()); + }), + ], + }); + + const view = new EditorView({ + state: startState, + parent: containerRef.current, + }); + + viewRef.current = view; + + view.focus(); + } else { + // The editor already exists, just reconfigure the dynamically configured parts. + view.dispatch( + view.state.update({ + effects: dynamicConfigCompartment.reconfigure(dynamicConfig), + }) + ); + } + // "value" is only used in the initial render, so we don't want to + // re-run this effect every time that "value" changes. + // + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enableAutocomplete, enableHighlighting, enableLinter, executeQuery, onExpressionChange, queryHistory]); + + const insertAtCursor = (value: string) => { + const view = viewRef.current; + if (view === null) { + return; + } + const { from, to } = view.state.selection.ranges[0]; + view.dispatch( + view.state.update({ + changes: { from, to, insert: value }, + }) + ); + }; + + return ( + <> + + + + {loading ? : } + + +
+ + + + + + + + + + + ); +}; + +export default CMExpressionInput; diff --git a/web/ui/react-app/src/pages/graph/CMTheme.tsx b/web/ui/react-app/src/pages/graph/CMTheme.tsx new file mode 100644 index 0000000000..80cd394215 --- /dev/null +++ b/web/ui/react-app/src/pages/graph/CMTheme.tsx @@ -0,0 +1,183 @@ +import { HighlightStyle, tags } from '@codemirror/highlight'; +import { EditorView } from '@codemirror/view'; + +export const theme = EditorView.theme({ + '&': { + '&.cm-focused': { + outline: 'none', + outline_fallback: 'none', + }, + }, + '.cm-scroller': { + overflow: 'hidden', + fontFamily: '"DejaVu Sans Mono", monospace', + }, + '.cm-placeholder': { + fontFamily: + '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"', + }, + + '.cm-matchingBracket': { + color: '#000', + backgroundColor: '#dedede', + fontWeight: 'bold', + outline: '1px dashed transparent', + }, + '.cm-nonmatchingBracket': { borderColor: 'red' }, + + '.cm-tooltip': { + backgroundColor: '#f8f8f8', + borderColor: 'rgba(52, 79, 113, 0.2)', + }, + + '.cm-tooltip.cm-tooltip-autocomplete': { + '& > ul': { + maxHeight: '350px', + fontFamily: '"DejaVu Sans Mono", monospace', + maxWidth: 'unset', + }, + '& > ul > li': { + padding: '2px 1em 2px 3px', + }, + '& li:hover': { + backgroundColor: '#ddd', + }, + '& > ul > li[aria-selected]': { + backgroundColor: '#d6ebff', + color: 'unset', + }, + minWidth: '30%', + }, + + '.cm-completionDetail': { + float: 'right', + color: '#999', + }, + + '.cm-tooltip.cm-completionInfo': { + marginTop: '-11px', + padding: '10px', + fontFamily: "'Open Sans', 'Lucida Sans Unicode', 'Lucida Grande', sans-serif;", + border: 'none', + backgroundColor: '#d6ebff', + minWidth: '250px', + maxWidth: 'min-content', + }, + + '.cm-completionInfo.cm-completionInfo-right': { + '&:before': { + content: "' '", + height: '0', + position: 'absolute', + width: '0', + left: '-20px', + border: '10px solid transparent', + borderRightColor: '#d6ebff', + }, + marginLeft: '12px', + }, + '.cm-completionInfo.cm-completionInfo-left': { + '&:before': { + content: "' '", + height: '0', + position: 'absolute', + width: '0', + right: '-20px', + border: '10px solid transparent', + borderLeftColor: '#d6ebff', + }, + marginRight: '12px', + }, + + '.cm-completionMatchedText': { + textDecoration: 'none', + fontWeight: 'bold', + color: '#0066bf', + }, + + '.cm-line': { + '&::selection': { + backgroundColor: '#add6ff', + }, + '& > span::selection': { + backgroundColor: '#add6ff', + }, + }, + + '.cm-selectionMatch': { + backgroundColor: '#e6f3ff', + }, + + '.cm-diagnostic': { + '&.cm-diagnostic-error': { + borderLeft: '3px solid #e65013', + }, + }, + + '.cm-completionIcon': { + boxSizing: 'content-box', + fontSize: '16px', + lineHeight: '1', + marginRight: '10px', + verticalAlign: 'top', + '&:after': { content: "'\\ea88'" }, + fontFamily: 'codicon', + paddingRight: '0', + opacity: '1', + color: '#007acc', + }, + + '.cm-completionIcon-function, .cm-completionIcon-method': { + '&:after': { content: "'\\ea8c'" }, + color: '#652d90', + }, + '.cm-completionIcon-class': { + '&:after': { content: "'○'" }, + }, + '.cm-completionIcon-interface': { + '&:after': { content: "'◌'" }, + }, + '.cm-completionIcon-variable': { + '&:after': { content: "'𝑥'" }, + }, + '.cm-completionIcon-constant': { + '&:after': { content: "'\\eb5f'" }, + color: '#007acc', + }, + '.cm-completionIcon-type': { + '&:after': { content: "'𝑡'" }, + }, + '.cm-completionIcon-enum': { + '&:after': { content: "'∪'" }, + }, + '.cm-completionIcon-property': { + '&:after': { content: "'□'" }, + }, + '.cm-completionIcon-keyword': { + '&:after': { content: "'\\eb62'" }, + color: '#616161', + }, + '.cm-completionIcon-namespace': { + '&:after': { content: "'▢'" }, + }, + '.cm-completionIcon-text': { + '&:after': { content: "'\\ea95'" }, + color: '#ee9d28', + }, +}); + +export const promqlHighlighter = HighlightStyle.define([ + { tag: tags.name, color: '#000' }, + { tag: tags.number, color: '#09885a' }, + { tag: tags.string, color: '#a31515' }, + { tag: tags.keyword, color: '#008080' }, + { tag: tags.function(tags.variableName), color: '#008080' }, + { tag: tags.labelName, color: '#800000' }, + { tag: tags.operator }, + { tag: tags.modifier, color: '#008080' }, + { tag: tags.paren }, + { tag: tags.squareBracket }, + { tag: tags.brace }, + { tag: tags.invalid, color: 'red' }, + { tag: tags.comment, color: '#888', fontStyle: 'italic' }, +]); diff --git a/web/ui/react-app/src/pages/graph/Panel.tsx b/web/ui/react-app/src/pages/graph/Panel.tsx index 7920072631..d498d2109c 100644 --- a/web/ui/react-app/src/pages/graph/Panel.tsx +++ b/web/ui/react-app/src/pages/graph/Panel.tsx @@ -5,6 +5,7 @@ import { Alert, Button, Col, Nav, NavItem, NavLink, Row, TabContent, TabPane } f import moment from 'moment-timezone'; import ExpressionInput from './ExpressionInput'; +import CMExpressionInput from './CMExpressionInput'; import GraphControls from './GraphControls'; import { GraphTabContent } from './GraphTabContent'; import DataTable from './DataTable'; @@ -22,7 +23,10 @@ interface PanelProps { removePanel: () => void; onExecuteQuery: (query: string) => void; pathPrefix: string; + useExperimentalEditor: boolean; enableAutocomplete: boolean; + enableHighlighting: boolean; + enableLinter: boolean; } interface PanelState { @@ -232,15 +236,29 @@ class Panel extends Component {
- + {this.props.useExperimentalEditor ? ( + + ) : ( + + )} diff --git a/web/ui/react-app/src/pages/graph/PanelList.test.tsx b/web/ui/react-app/src/pages/graph/PanelList.test.tsx index 833921ef02..023288c802 100755 --- a/web/ui/react-app/src/pages/graph/PanelList.test.tsx +++ b/web/ui/react-app/src/pages/graph/PanelList.test.tsx @@ -6,15 +6,19 @@ import { Button } from 'reactstrap'; import Panel from './Panel'; describe('PanelList', () => { - it('renders query history and local time checkboxes', () => { + it('renders configuration checkboxes', () => { [ - { id: 'query-history-checkbox', label: 'Enable query history' }, - { id: 'use-local-time-checkbox', label: 'Use local time' }, + { id: 'use-local-time-checkbox', label: 'Use local time', default: false }, + { id: 'query-history-checkbox', label: 'Enable query history', default: false }, + { id: 'autocomplete-checkbox', label: 'Enable autocomplete', default: true }, + { id: 'use-experimental-editor-checkbox', label: 'Use experimental editor', default: false }, + { id: 'highlighting-checkbox', label: 'Enable highlighting', default: true }, + { id: 'linter-checkbox', label: 'Enable linter', default: true }, ].forEach((cb, idx) => { const panelList = shallow(); const checkbox = panelList.find(Checkbox).at(idx); expect(checkbox.prop('id')).toEqual(cb.id); - expect(checkbox.prop('defaultChecked')).toBe(false); + expect(checkbox.prop('defaultChecked')).toBe(cb.default); expect(checkbox.children().text()).toBe(cb.label); }); }); diff --git a/web/ui/react-app/src/pages/graph/PanelList.tsx b/web/ui/react-app/src/pages/graph/PanelList.tsx index 4e07587908..0181b2c625 100644 --- a/web/ui/react-app/src/pages/graph/PanelList.tsx +++ b/web/ui/react-app/src/pages/graph/PanelList.tsx @@ -17,19 +17,25 @@ export const updateURL = (nextPanels: PanelMeta[]) => { window.history.pushState({}, '', query); }; -interface PanelListProps extends RouteComponentProps { +interface PanelListContentProps extends RouteComponentProps { panels: PanelMeta[]; metrics: string[]; useLocalTime: boolean; + useExperimentalEditor: boolean; queryHistoryEnabled: boolean; enableAutocomplete: boolean; + enableHighlighting: boolean; + enableLinter: boolean; } -export const PanelListContent: FC = ({ +export const PanelListContent: FC = ({ metrics = [], useLocalTime, + useExperimentalEditor, queryHistoryEnabled, enableAutocomplete, + enableHighlighting, + enableLinter, ...rest }) => { const [panels, setPanels] = useState(rest.panels); @@ -99,10 +105,13 @@ export const PanelListContent: FC = ({ ) ) } + useExperimentalEditor={useExperimentalEditor} useLocalTime={useLocalTime} metricNames={metrics} pastQueries={queryHistoryEnabled ? historyItems : []} enableAutocomplete={enableAutocomplete} + enableHighlighting={enableHighlighting} + enableLinter={enableLinter} /> ))}