Vmui/query editor (#1472)

* fix: move request button to server input

* feat: add switch for query autocomplete

* refactor: rename state for popover open

* feat: add detect os by userAgent

* fix: change hotkey to run query for mac

* fix: change detect mac os

* fix: change div to span inside Typography

Co-authored-by: yury <yurymolodov@victoriametrics.com>
This commit is contained in:
Yury Molodov 2021-07-23 12:00:44 +03:00 committed by GitHub
parent 05672ffc32
commit a91d41f12a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 149 additions and 43 deletions

View File

@ -1,7 +1,5 @@
import React, {FC, useEffect, useState} from "react"; import React, {FC, useEffect, useState} from "react";
import {Box, FormControlLabel, IconButton, Switch, Tooltip} from "@material-ui/core"; import {Box, FormControlLabel, IconButton, Switch, Tooltip} from "@material-ui/core";
import PlayCircleOutlineIcon from "@material-ui/icons/PlayCircleOutline";
import EqualizerIcon from "@material-ui/icons/Equalizer"; import EqualizerIcon from "@material-ui/icons/Equalizer";
import {useAppDispatch, useAppState} from "../../../state/common/StateContext"; import {useAppDispatch, useAppState} from "../../../state/common/StateContext";
import CircularProgressWithLabel from "../../common/CircularProgressWithLabel"; import CircularProgressWithLabel from "../../common/CircularProgressWithLabel";
@ -70,23 +68,19 @@ export const ExecutionControls: FC = () => {
}; };
return <Box display="flex" alignItems="center"> return <Box display="flex" alignItems="center">
<Box mr={2}>
<Tooltip title="Execute Query">
<IconButton onClick={()=>dispatch({type: "RUN_QUERY"})}>
<PlayCircleOutlineIcon className={classes.colorizing} fontSize="large"/>
</IconButton>
</Tooltip>
</Box>
{<FormControlLabel {<FormControlLabel
control={<Switch size="small" className={classes.colorizing} checked={autoRefresh} onChange={handleChange} />} control={<Switch size="small" className={classes.colorizing} checked={autoRefresh} onChange={handleChange} />}
label="Auto-refresh" label="Auto-refresh"
/>} />}
{autoRefresh && <> {autoRefresh && <>
<CircularProgressWithLabel className={classes.colorizing} label={delay} value={progress} onClick={() => {iterateDelays();}} /> <CircularProgressWithLabel className={classes.colorizing} label={delay} value={progress}
<Box ml={1}> onClick={() => {iterateDelays();}} />
<IconButton onClick={() => {iterateDelays();}}><EqualizerIcon style={{color: "white"}} /></IconButton> <Tooltip title="Change delay refresh">
</Box> <Box ml={1}>
<IconButton onClick={() => {iterateDelays();}}><EqualizerIcon style={{color: "white"}} /></IconButton>
</Box>
</Tooltip>
</>} </>}
</Box>; </Box>;
}; };

View File

@ -1,4 +1,4 @@
import React, {FC, useState} from "react"; import React, {FC, useRef, useState} from "react";
import { import {
Accordion, Accordion,
AccordionDetails, AccordionDetails,
@ -7,7 +7,10 @@ import {
Grid, Grid,
IconButton, IconButton,
TextField, TextField,
Typography Typography,
FormControlLabel,
Tooltip,
Switch,
} from "@material-ui/core"; } from "@material-ui/core";
import QueryEditor from "./QueryEditor"; import QueryEditor from "./QueryEditor";
import {TimeSelector} from "./TimeSelector"; import {TimeSelector} from "./TimeSelector";
@ -15,14 +18,36 @@ import {useAppDispatch, useAppState} from "../../../state/common/StateContext";
import ExpandMoreIcon from "@material-ui/icons/ExpandMore"; import ExpandMoreIcon from "@material-ui/icons/ExpandMore";
import SecurityIcon from "@material-ui/icons/Security"; import SecurityIcon from "@material-ui/icons/Security";
import {AuthDialog} from "./AuthDialog"; import {AuthDialog} from "./AuthDialog";
import PlayCircleOutlineIcon from "@material-ui/icons/PlayCircleOutline";
import Portal from "@material-ui/core/Portal";
import Popover from "@material-ui/core/Popover";
import SettingsIcon from "@material-ui/icons/Settings";
import {saveToStorage} from "../../../utils/storage";
const QueryConfigurator: FC = () => { const QueryConfigurator: FC = () => {
const {serverUrl, query, time: {duration}} = useAppState(); const {serverUrl, query, time: {duration}} = useAppState();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const {queryControls: {autocomplete}} = useAppState();
const onChangeAutocomplete = () => {
dispatch({type: "TOGGLE_AUTOCOMPLETE"});
saveToStorage("AUTOCOMPLETE", !autocomplete);
};
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [expanded, setExpanded] = useState(true); const [expanded, setExpanded] = useState(true);
const [popoverOpen, setPopoverOpen] = useState(false);
const refSettings = useRef<SVGGElement | any>(null);
const queryContainer = useRef<HTMLDivElement>(null);
const onSetDuration = (dur: string) => dispatch({type: "SET_DURATION", payload: dur});
const onRunQuery = () => dispatch({type: "RUN_QUERY"});
const onSetQuery = (query: string) => dispatch({type: "SET_QUERY", payload: query});
const onSetServer = ({target: {value}}: {target: {value: string}}) => {
dispatch({type: "SET_SERVER", payload: value});
};
return ( return (
<> <>
@ -35,31 +60,65 @@ const QueryConfigurator: FC = () => {
<Box mr={2}> <Box mr={2}>
<Typography variant="h6" component="h2">Query Configuration</Typography> <Typography variant="h6" component="h2">Query Configuration</Typography>
</Box> </Box>
{!expanded && <Box flexGrow={1} onClick={e => e.stopPropagation()} onFocusCapture={e => e.stopPropagation()}> <Box flexGrow={1} onClick={e => e.stopPropagation()} onFocusCapture={e => e.stopPropagation()}>
<QueryEditor server={serverUrl} query={query} oneLiner setQuery={(query) => dispatch({type: "SET_QUERY", payload: query})}/> <Portal disablePortal={!expanded} container={queryContainer.current}>
</Box>} <QueryEditor server={serverUrl} query={query} oneLiner={!expanded} autocomplete={autocomplete}
runQuery={onRunQuery}
setQuery={onSetQuery}/>
</Portal>
</Box>
</AccordionSummary> </AccordionSummary>
<AccordionDetails> <AccordionDetails>
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid item xs={12} md={6}> <Grid item xs={12} md={6}>
<Box> <Box>
<Box py={2} display="flex"> <Box py={2} display="flex" alignItems="center">
<TextField variant="outlined" fullWidth label="Server URL" value={serverUrl} <TextField variant="outlined" fullWidth label="Server URL" value={serverUrl}
inputProps={{ inputProps={{
style: {fontFamily: "Monospace"} style: {fontFamily: "Monospace"}
}} }}
onChange={(e) => dispatch({type: "SET_SERVER", payload: e.target.value})}/> onChange={onSetServer}/>
<Box pl={.5} flexGrow={0}> <Box ml={1}>
<IconButton onClick={() => setDialogOpen(true)}> <Tooltip title="Execute Query">
<SecurityIcon/> <IconButton onClick={onRunQuery}>
</IconButton> <PlayCircleOutlineIcon />
</IconButton>
</Tooltip>
</Box>
<Box>
<Tooltip title="Request Auth Settings">
<IconButton onClick={() => setDialogOpen(true)}>
<SecurityIcon/>
</IconButton>
</Tooltip>
</Box> </Box>
</Box> </Box>
<QueryEditor server={serverUrl} query={query} setQuery={(query) => dispatch({type: "SET_QUERY", payload: query})}/> <Box py={2} display="flex">
<Box flexGrow={1} mr={2}>
{/* for portal QueryEditor */}
<div ref={queryContainer} />
</Box>
<div>
<Tooltip title="Query Editor Settings">
<IconButton onClick={() => setPopoverOpen(!popoverOpen)}>
<SettingsIcon ref={refSettings}/>
</IconButton>
</Tooltip>
<Popover open={popoverOpen} transformOrigin={{vertical: -20, horizontal: "left"}}
onClose={() => setPopoverOpen(false)}
anchorEl={refSettings.current}>
<Box p={2}>
{<FormControlLabel
control={<Switch size="small" checked={autocomplete} onChange={onChangeAutocomplete}/>}
label="Autocomplete"
/>}
</Box>
</Popover>
</div>
</Box>
</Box> </Box>
</Grid> </Grid>
<Grid item xs={12} md={6}> <Grid item xs={8} md={6} >
<Box style={{ <Box style={{
borderRadius: "4px", borderRadius: "4px",
borderColor: "#b9b9b9", borderColor: "#b9b9b9",
@ -68,7 +127,7 @@ const QueryConfigurator: FC = () => {
height: "calc(100% - 18px)", height: "calc(100% - 18px)",
marginTop: "16px" marginTop: "16px"
}}> }}>
<TimeSelector setDuration={(dur) => dispatch({type: "SET_DURATION", payload: dur})} duration={duration}/> <TimeSelector setDuration={onSetDuration} duration={duration}/>
</Box> </Box>
</Grid> </Grid>
</Grid> </Grid>

View File

@ -4,15 +4,20 @@ import {defaultKeymap} from "@codemirror/next/commands";
import React, {FC, useEffect, useRef, useState} from "react"; import React, {FC, useEffect, useRef, useState} from "react";
import { PromQLExtension } from "codemirror-promql"; import { PromQLExtension } from "codemirror-promql";
import { basicSetup } from "@codemirror/next/basic-setup"; import { basicSetup } from "@codemirror/next/basic-setup";
import {isMacOs} from "../../../utils/detect-os";
export interface QueryEditorProps { export interface QueryEditorProps {
setQuery: (query: string) => void; setQuery: (query: string) => void;
runQuery: () => void;
query: string; query: string;
server: string; server: string;
oneLiner?: boolean; oneLiner?: boolean;
autocomplete: boolean
} }
const QueryEditor: FC<QueryEditorProps> = ({query, setQuery, server, oneLiner = false}) => { const QueryEditor: FC<QueryEditorProps> = ({
query, setQuery, runQuery, server, oneLiner = false, autocomplete
}) => {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@ -33,28 +38,41 @@ const QueryEditor: FC<QueryEditorProps> = ({query, setQuery, server, oneLiner =
// update state on change of autocomplete server // update state on change of autocomplete server
useEffect(() => { useEffect(() => {
const promQL = new PromQLExtension().setComplete({url: server}); const promQL = new PromQLExtension();
promQL.activateCompletion(autocomplete);
promQL.setComplete({url: server});
const listenerExtension = EditorView.updateListener.of(editorUpdate => { const listenerExtension = EditorView.updateListener.of(editorUpdate => {
if (editorUpdate.docChanged) { if (editorUpdate.docChanged) {
setQuery( setQuery(editorUpdate.state.doc.toJSON().map(el => el.trim()).join(""));
editorUpdate.state.doc.toJSON().map(el => el.trim()).join("")
);
} }
}); });
editorView?.setState(EditorState.create({ editorView?.setState(EditorState.create({
doc: query, doc: query,
extensions: [basicSetup, keymap(defaultKeymap), listenerExtension, promQL.asExtension()] extensions: [
basicSetup,
keymap(defaultKeymap),
listenerExtension,
promQL.asExtension(),
keymap([
{
key: isMacOs() ? "Cmd-Enter" : "Ctrl-Enter",
run: (): boolean => {
runQuery();
return true;
},
},
]),
]
})); }));
}, [server, editorView]); }, [server, editorView, autocomplete]);
return ( return (
<> <>
{/*Class one-line-scroll and other codemirror stylings are declared in index.css*/} {/*Class one-line-scroll and other codemirror styles are declared in index.css*/}
<div ref={ref} className={oneLiner ? "one-line-scroll" : undefined}></div> <div ref={ref} className={oneLiner ? "one-line-scroll" : undefined}/>
</> </>
); );
}; };

View File

@ -21,7 +21,7 @@ const HomeLayout: FC = () => {
<> <>
<AppBar position="static"> <AppBar position="static">
<Toolbar> <Toolbar>
<Box mr={2} display="flex"> <Box display="flex">
<Typography variant="h5"> <Typography variant="h5">
<span style={{fontWeight: "bolder"}}>VM</span> <span style={{fontWeight: "bolder"}}>VM</span>
<span style={{fontWeight: "lighter"}}>UI</span> <span style={{fontWeight: "lighter"}}>UI</span>
@ -43,7 +43,7 @@ const HomeLayout: FC = () => {
Create an issue Create an issue
</Link> </Link>
</div> </div>
<Box flexGrow={1}> <Box ml={4} flexGrow={1}>
<ExecutionControls/> <ExecutionControls/>
</Box> </Box>
<DisplayTypeSwitch/> <DisplayTypeSwitch/>

View File

@ -37,7 +37,7 @@ export const ChartTooltip: React.FC<ChartTooltipProps> = ({data, time}) => {
<Box> <Box>
<Typography variant="body2"> <Typography variant="body2">
{data.metrics.map(({key, value}) => {data.metrics.map(({key, value}) =>
<Box mb={.25} key={key} display="flex" flexDirection="row" alignItems="center"> <Box component="span" mb={.25} key={key} display="flex" flexDirection="row" alignItems="center">
<span>{key}:&nbsp;</span> <span>{key}:&nbsp;</span>
<span style={{fontWeight: "bold"}}>{value}</span> <span style={{fontWeight: "bold"}}>{value}</span>
</Box>)} </Box>)}

View File

@ -31,3 +31,9 @@ code {
.one-line-scroll .cm-wrap { .one-line-scroll .cm-wrap {
height: 24px; height: 24px;
} }
.cm-content, .cm-gutter { min-height: 51px; }
.one-line-scroll .cm-content,
.one-line-scroll .cm-gutter {
min-height: auto;
}

View File

@ -15,6 +15,7 @@ export interface AppState {
time: TimeState; time: TimeState;
queryControls: { queryControls: {
autoRefresh: boolean; autoRefresh: boolean;
autocomplete: boolean
} }
} }
@ -28,6 +29,7 @@ export type Action =
| { type: "RUN_QUERY"} | { type: "RUN_QUERY"}
| { type: "RUN_QUERY_TO_NOW"} | { type: "RUN_QUERY_TO_NOW"}
| { type: "TOGGLE_AUTOREFRESH"} | { type: "TOGGLE_AUTOREFRESH"}
| { type: "TOGGLE_AUTOCOMPLETE"}
export const initialState: AppState = { export const initialState: AppState = {
serverUrl: getFromStorage("PREFERRED_URL") as string || "https://", // https://demo.promlabs.com or https://play.victoriametrics.com/select/accounting/1/6a716b0f-38bc-4856-90ce-448fd713e3fe/prometheus", serverUrl: getFromStorage("PREFERRED_URL") as string || "https://", // https://demo.promlabs.com or https://play.victoriametrics.com/select/accounting/1/6a716b0f-38bc-4856-90ce-448fd713e3fe/prometheus",
@ -38,7 +40,8 @@ export const initialState: AppState = {
period: getTimeperiodForDuration("1h") period: getTimeperiodForDuration("1h")
}, },
queryControls: { queryControls: {
autoRefresh: false autoRefresh: false,
autocomplete: getFromStorage("AUTOCOMPLETE") as boolean || false
} }
}; };
@ -99,6 +102,14 @@ export function reducer(state: AppState, action: Action): AppState {
autoRefresh: !state.queryControls.autoRefresh autoRefresh: !state.queryControls.autoRefresh
} }
}; };
case "TOGGLE_AUTOCOMPLETE":
return {
...state,
queryControls: {
...state.queryControls,
autocomplete: !state.queryControls.autocomplete
}
};
case "RUN_QUERY": case "RUN_QUERY":
return { return {
...state, ...state,

View File

@ -0,0 +1,13 @@
const desktopOs = {
windows: "Windows",
mac: "Mac OS",
linux: "Linux"
};
export const getOs = () : string => {
return Object.values(desktopOs).find(os => navigator.userAgent.indexOf(os) >= 0) || "unknown";
};
export const isMacOs = (): boolean => {
return getOs() === desktopOs.mac;
};

View File

@ -1,4 +1,9 @@
export type StorageKeys = "PREFERRED_URL" | "LAST_QUERY" | "BASIC_AUTH_DATA" | "BEARER_AUTH_DATA" | "AUTH_TYPE"; export type StorageKeys = "PREFERRED_URL"
| "LAST_QUERY"
| "BASIC_AUTH_DATA"
| "BEARER_AUTH_DATA"
| "AUTH_TYPE"
| "AUTOCOMPLETE";
export const saveToStorage = (key: StorageKeys, value: string | boolean | Record<string, unknown>): void => { export const saveToStorage = (key: StorageKeys, value: string | boolean | Record<string, unknown>): void => {
if (value) { if (value) {