mirror of
https://github.com/prometheus/prometheus.git
synced 2024-12-25 05:34:05 -08:00
migrate query history (#6193)
* migrate query history Signed-off-by: blalov <boyko.lalov@tick42.com> * update lock file Signed-off-by: blalov <boyko.lalov@tick42.com> * set expression input height when item is selected Signed-off-by: blalov <boyko.lalov@tick42.com> * pr review changes Signed-off-by: blalov <boyko.lalov@tick42.com>
This commit is contained in:
parent
89e610ef4c
commit
e235af9c47
|
@ -11,8 +11,8 @@
|
||||||
"@types/node": "^12.11.1",
|
"@types/node": "^12.11.1",
|
||||||
"@types/react": "^16.8.2",
|
"@types/react": "^16.8.2",
|
||||||
"@types/react-dom": "^16.8.0",
|
"@types/react-dom": "^16.8.0",
|
||||||
"@types/sanitize-html": "^1.20.2",
|
|
||||||
"@types/react-resize-detector": "^4.0.2",
|
"@types/react-resize-detector": "^4.0.2",
|
||||||
|
"@types/sanitize-html": "^1.20.2",
|
||||||
"bootstrap": "^4.2.1",
|
"bootstrap": "^4.2.1",
|
||||||
"downshift": "^3.2.2",
|
"downshift": "^3.2.2",
|
||||||
"flot": "^3.2.13",
|
"flot": "^3.2.13",
|
||||||
|
@ -26,10 +26,10 @@
|
||||||
"popper.js": "^1.14.3",
|
"popper.js": "^1.14.3",
|
||||||
"react": "^16.7.0",
|
"react": "^16.7.0",
|
||||||
"react-dom": "^16.7.0",
|
"react-dom": "^16.7.0",
|
||||||
"sanitize-html": "^1.20.1",
|
|
||||||
"react-resize-detector": "^4.2.1",
|
"react-resize-detector": "^4.2.1",
|
||||||
"react-scripts": "^3.2.0",
|
"react-scripts": "^3.2.0",
|
||||||
"reactstrap": "^8.0.1",
|
"reactstrap": "^8.0.1",
|
||||||
|
"sanitize-html": "^1.20.1",
|
||||||
"tempusdominus-bootstrap-4": "^5.1.2",
|
"tempusdominus-bootstrap-4": "^5.1.2",
|
||||||
"tempusdominus-core": "^5.0.3",
|
"tempusdominus-core": "^5.0.3",
|
||||||
"typescript": "^3.3.3"
|
"typescript": "^3.3.3"
|
||||||
|
|
|
@ -10,6 +10,16 @@ button.classic-ui-btn {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input[type='checkbox']:checked + label {
|
||||||
|
color: #286090;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-control-label {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: .875rem;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
.expression-input {
|
.expression-input {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,31 +1,16 @@
|
||||||
import React, { Component } from 'react';
|
import React, { Component } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Button,
|
|
||||||
Container,
|
Container,
|
||||||
Col,
|
|
||||||
Row,
|
|
||||||
} from 'reactstrap';
|
} from 'reactstrap';
|
||||||
|
|
||||||
import PanelList from './PanelList';
|
|
||||||
|
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
import PanelList from './PanelList';
|
||||||
|
|
||||||
class App extends Component {
|
class App extends Component {
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<Container fluid={true}>
|
<Container fluid={true}>
|
||||||
<Row>
|
|
||||||
<Col>
|
|
||||||
<Button
|
|
||||||
className="float-right classic-ui-btn"
|
|
||||||
color="link"
|
|
||||||
onClick={() => {window.location.pathname = "../../graph"}}
|
|
||||||
size="sm">
|
|
||||||
Return to classic UI
|
|
||||||
</Button>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
<PanelList />
|
<PanelList />
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|
22
web/ui/react-app/src/Checkbox.tsx
Normal file
22
web/ui/react-app/src/Checkbox.tsx
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import React, { FC, HTMLProps, memo } from 'react';
|
||||||
|
import { FormGroup, Label, Input } from 'reactstrap';
|
||||||
|
import { uuidGen } from './utils/func';
|
||||||
|
|
||||||
|
const Checkbox: FC<HTMLProps<HTMLSpanElement>> = ({ children, onChange, style }) => {
|
||||||
|
const id = uuidGen();
|
||||||
|
return (
|
||||||
|
<FormGroup className="custom-control custom-checkbox" style={style}>
|
||||||
|
<Input
|
||||||
|
onChange={onChange}
|
||||||
|
type="checkbox"
|
||||||
|
className="custom-control-input"
|
||||||
|
id={`checkbox_${id}`}
|
||||||
|
placeholder="password placeholder" />
|
||||||
|
<Label style={{ userSelect: 'none' }} className="custom-control-label" for={`checkbox_${id}`}>
|
||||||
|
{children}
|
||||||
|
</Label>
|
||||||
|
</FormGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(Checkbox);
|
|
@ -11,12 +11,9 @@ import Downshift, { ControllerStateAndHelpers } from 'downshift';
|
||||||
import fuzzy from 'fuzzy';
|
import fuzzy from 'fuzzy';
|
||||||
import SanitizeHTML from './components/SanitizeHTML';
|
import SanitizeHTML from './components/SanitizeHTML';
|
||||||
|
|
||||||
import { library } from '@fortawesome/fontawesome-svg-core';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faSearch, faSpinner } from '@fortawesome/free-solid-svg-icons';
|
import { faSearch, faSpinner } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
|
||||||
library.add(faSearch, faSpinner);
|
|
||||||
|
|
||||||
interface ExpressionInputProps {
|
interface ExpressionInputProps {
|
||||||
value: string;
|
value: string;
|
||||||
metricNames: string[];
|
metricNames: string[];
|
||||||
|
@ -59,8 +56,10 @@ class ExpressionInput extends Component<ExpressionInputProps, ExpressionInputSta
|
||||||
}, this.setHeight);
|
}, this.setHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDropdownSelection = (value: string) => this.setState({ value });
|
handleDropdownSelection = (value: string) => {
|
||||||
|
this.setState({ value, height: 'auto' }, this.setHeight)
|
||||||
|
};
|
||||||
|
|
||||||
handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (event.key === 'Enter' && !event.shiftKey) {
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
this.props.executeQuery(this.exprInputRef.current!.value);
|
this.props.executeQuery(this.exprInputRef.current!.value);
|
||||||
|
@ -73,7 +72,9 @@ class ExpressionInput extends Component<ExpressionInputProps, ExpressionInputSta
|
||||||
renderAutosuggest = (downshift: ControllerStateAndHelpers<any>) => {
|
renderAutosuggest = (downshift: ControllerStateAndHelpers<any>) => {
|
||||||
const { inputValue } = downshift
|
const { inputValue } = downshift
|
||||||
if (!inputValue || (this.prevNoMatchValue && inputValue.includes(this.prevNoMatchValue))) {
|
if (!inputValue || (this.prevNoMatchValue && inputValue.includes(this.prevNoMatchValue))) {
|
||||||
downshift.closeMenu();
|
// This is ugly but is needed in order to sync state updates.
|
||||||
|
// This way we force downshift to wait React render call to complete before closeMenu to be triggered.
|
||||||
|
setTimeout(downshift.closeMenu);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -84,7 +85,7 @@ class ExpressionInput extends Component<ExpressionInputProps, ExpressionInputSta
|
||||||
|
|
||||||
if (matches.length === 0) {
|
if (matches.length === 0) {
|
||||||
this.prevNoMatchValue = inputValue;
|
this.prevNoMatchValue = inputValue;
|
||||||
downshift.closeMenu();
|
setTimeout(downshift.closeMenu);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -128,7 +129,7 @@ class ExpressionInput extends Component<ExpressionInputProps, ExpressionInputSta
|
||||||
<InputGroup className="expression-input">
|
<InputGroup className="expression-input">
|
||||||
<InputGroupAddon addonType="prepend">
|
<InputGroupAddon addonType="prepend">
|
||||||
<InputGroupText>
|
<InputGroupText>
|
||||||
{this.props.loading ? <FontAwesomeIcon icon="spinner" spin/> : <FontAwesomeIcon icon="search"/>}
|
{this.props.loading ? <FontAwesomeIcon icon={faSpinner} spin /> : <FontAwesomeIcon icon={faSearch} />}
|
||||||
</InputGroupText>
|
</InputGroupText>
|
||||||
</InputGroupAddon>
|
</InputGroupAddon>
|
||||||
<Input
|
<Input
|
||||||
|
|
|
@ -8,7 +8,6 @@ import {
|
||||||
Input,
|
Input,
|
||||||
} from 'reactstrap';
|
} from 'reactstrap';
|
||||||
|
|
||||||
import { library } from '@fortawesome/fontawesome-svg-core';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import {
|
import {
|
||||||
faPlus,
|
faPlus,
|
||||||
|
@ -20,13 +19,6 @@ import {
|
||||||
import TimeInput from './TimeInput';
|
import TimeInput from './TimeInput';
|
||||||
import { parseRange, formatRange } from './utils/timeFormat';
|
import { parseRange, formatRange } from './utils/timeFormat';
|
||||||
|
|
||||||
library.add(
|
|
||||||
faPlus,
|
|
||||||
faMinus,
|
|
||||||
faChartArea,
|
|
||||||
faChartLine,
|
|
||||||
);
|
|
||||||
|
|
||||||
interface GraphControlsProps {
|
interface GraphControlsProps {
|
||||||
range: number;
|
range: number;
|
||||||
endTime: number | null;
|
endTime: number | null;
|
||||||
|
@ -111,7 +103,7 @@ class GraphControls extends Component<GraphControlsProps> {
|
||||||
<Form inline className="graph-controls" onSubmit={e => e.preventDefault()}>
|
<Form inline className="graph-controls" onSubmit={e => e.preventDefault()}>
|
||||||
<InputGroup className="range-input" size="sm">
|
<InputGroup className="range-input" size="sm">
|
||||||
<InputGroupAddon addonType="prepend">
|
<InputGroupAddon addonType="prepend">
|
||||||
<Button title="Decrease range" onClick={this.decreaseRange}><FontAwesomeIcon icon="minus" fixedWidth/></Button>
|
<Button title="Decrease range" onClick={this.decreaseRange}><FontAwesomeIcon icon={faMinus} fixedWidth/></Button>
|
||||||
</InputGroupAddon>
|
</InputGroupAddon>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
|
@ -121,7 +113,7 @@ class GraphControls extends Component<GraphControlsProps> {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InputGroupAddon addonType="append">
|
<InputGroupAddon addonType="append">
|
||||||
<Button title="Increase range" onClick={this.increaseRange}><FontAwesomeIcon icon="plus" fixedWidth/></Button>
|
<Button title="Increase range" onClick={this.increaseRange}><FontAwesomeIcon icon={faPlus} fixedWidth/></Button>
|
||||||
</InputGroupAddon>
|
</InputGroupAddon>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
|
|
||||||
|
@ -145,8 +137,8 @@ class GraphControls extends Component<GraphControlsProps> {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ButtonGroup className="stacked-input" size="sm">
|
<ButtonGroup className="stacked-input" size="sm">
|
||||||
<Button title="Show unstacked line graph" onClick={() => this.props.onChangeStacking(false)} active={!this.props.stacked}><FontAwesomeIcon icon="chart-line" fixedWidth/></Button>
|
<Button title="Show unstacked line graph" onClick={() => this.props.onChangeStacking(false)} active={!this.props.stacked}><FontAwesomeIcon icon={faChartLine} fixedWidth/></Button>
|
||||||
<Button title="Show stacked graph" onClick={() => this.props.onChangeStacking(true)} active={this.props.stacked}><FontAwesomeIcon icon="chart-area" fixedWidth/></Button>
|
<Button title="Show stacked graph" onClick={() => this.props.onChangeStacking(true)} active={this.props.stacked}><FontAwesomeIcon icon={faChartArea} fixedWidth/></Button>
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
|
|
|
@ -26,6 +26,7 @@ interface PanelProps {
|
||||||
onOptionsChanged: (opts: PanelOptions) => void;
|
onOptionsChanged: (opts: PanelOptions) => void;
|
||||||
metricNames: string[];
|
metricNames: string[];
|
||||||
removePanel: () => void;
|
removePanel: () => void;
|
||||||
|
onExecuteQuery: (query: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PanelState {
|
interface PanelState {
|
||||||
|
@ -102,6 +103,7 @@ class Panel extends Component<PanelProps, PanelState> {
|
||||||
|
|
||||||
executeQuery = (expr: string): void => {
|
executeQuery = (expr: string): void => {
|
||||||
const queryStart = Date.now();
|
const queryStart = Date.now();
|
||||||
|
this.props.onExecuteQuery(expr)
|
||||||
if (this.props.options.expr !== expr) {
|
if (this.props.options.expr !== expr) {
|
||||||
this.setOptions({expr: expr});
|
this.setOptions({expr: expr});
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import React, { Component } from 'react';
|
import React, { Component, ChangeEvent } from 'react';
|
||||||
|
|
||||||
import { Alert, Button, Col, Row } from 'reactstrap';
|
import { Alert, Button, Col, Row } from 'reactstrap';
|
||||||
|
|
||||||
import Panel, { PanelOptions, PanelDefaultOptions } from './Panel';
|
import Panel, { PanelOptions, PanelDefaultOptions } from './Panel';
|
||||||
import { decodePanelOptionsFromQueryString, encodePanelOptionsToQueryString } from './utils/urlParams';
|
import { decodePanelOptionsFromQueryString, encodePanelOptionsToQueryString } from './utils/urlParams';
|
||||||
|
import Checkbox from './Checkbox';
|
||||||
|
|
||||||
interface PanelListState {
|
interface PanelListState {
|
||||||
panels: {
|
panels: {
|
||||||
|
@ -17,7 +18,7 @@ interface PanelListState {
|
||||||
|
|
||||||
class PanelList extends Component<any, PanelListState> {
|
class PanelList extends Component<any, PanelListState> {
|
||||||
private key: number = 0;
|
private key: number = 0;
|
||||||
|
private initialMetricNames: string[] = [];
|
||||||
constructor(props: any) {
|
constructor(props: any) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
|
@ -45,7 +46,10 @@ class PanelList extends Component<any, PanelListState> {
|
||||||
throw new Error('Unexpected response status when fetching metric names: ' + resp.statusText); // TODO extract error
|
throw new Error('Unexpected response status when fetching metric names: ' + resp.statusText); // TODO extract error
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.then(json => this.setState({ metricNames: json.data }))
|
.then(json => {
|
||||||
|
this.initialMetricNames = json.data;
|
||||||
|
this.setMetrics();
|
||||||
|
})
|
||||||
.catch(error => this.setState({ fetchMetricsError: error.message }));
|
.catch(error => this.setState({ fetchMetricsError: error.message }));
|
||||||
|
|
||||||
const browserTime = new Date().getTime() / 1000;
|
const browserTime = new Date().getTime() / 1000;
|
||||||
|
@ -75,6 +79,40 @@ class PanelList extends Component<any, PanelListState> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isHistoryEnabled = () => JSON.parse(localStorage.getItem('enable-query-history') || 'false') as boolean;
|
||||||
|
|
||||||
|
getHistoryItems = () => JSON.parse(localStorage.getItem('history') || '[]') as string[];
|
||||||
|
|
||||||
|
toggleQueryHistory = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
localStorage.setItem('enable-query-history', `${e.target.checked}`);
|
||||||
|
this.setMetrics();
|
||||||
|
}
|
||||||
|
|
||||||
|
setMetrics = () => {
|
||||||
|
if (this.isHistoryEnabled()) {
|
||||||
|
const historyItems = this.getHistoryItems();
|
||||||
|
const { length } = historyItems;
|
||||||
|
this.setState({
|
||||||
|
metricNames: [...historyItems.slice(length - 50, length), ...this.initialMetricNames],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.setState({ metricNames: this.initialMetricNames });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleQueryHistory = (query: string) => {
|
||||||
|
const isSimpleMetric = this.initialMetricNames.indexOf(query) !== -1;
|
||||||
|
if (isSimpleMetric || !query.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const historyItems = this.getHistoryItems();
|
||||||
|
const extendedItems = historyItems.reduce((acc, metric) => {
|
||||||
|
return metric === query ? acc : [...acc, metric]; // Prevent adding query twice.
|
||||||
|
}, [query]);
|
||||||
|
localStorage.setItem('history', JSON.stringify(extendedItems));
|
||||||
|
this.setMetrics();
|
||||||
|
}
|
||||||
|
|
||||||
getKey(): string {
|
getKey(): string {
|
||||||
return (this.key++).toString();
|
return (this.key++).toString();
|
||||||
}
|
}
|
||||||
|
@ -116,6 +154,23 @@ class PanelList extends Component<any, PanelListState> {
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Row className="mb-2">
|
||||||
|
<Checkbox
|
||||||
|
style={{ margin: '0 0 0 15px', alignSelf: 'center' }}
|
||||||
|
onChange={this.toggleQueryHistory}
|
||||||
|
checked={this.isHistoryEnabled()}>
|
||||||
|
Enable query history
|
||||||
|
</Checkbox>
|
||||||
|
<Col>
|
||||||
|
<Button
|
||||||
|
className="float-right classic-ui-btn"
|
||||||
|
color="link"
|
||||||
|
onClick={() => { window.location.pathname = "../../graph" }}
|
||||||
|
size="sm">
|
||||||
|
Return to classic UI
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
<Row>
|
<Row>
|
||||||
<Col>
|
<Col>
|
||||||
{this.state.timeDriftError && <Alert color="danger"><strong>Warning:</strong> Error fetching server time: {this.state.timeDriftError}</Alert>}
|
{this.state.timeDriftError && <Alert color="danger"><strong>Warning:</strong> Error fetching server time: {this.state.timeDriftError}</Alert>}
|
||||||
|
@ -128,6 +183,7 @@ class PanelList extends Component<any, PanelListState> {
|
||||||
</Row>
|
</Row>
|
||||||
{this.state.panels.map(p =>
|
{this.state.panels.map(p =>
|
||||||
<Panel
|
<Panel
|
||||||
|
onExecuteQuery={this.handleQueryHistory}
|
||||||
key={p.key}
|
key={p.key}
|
||||||
options={p.options}
|
options={p.options}
|
||||||
onOptionsChanged={(opts: PanelOptions) => this.handleOptionsChanged(p.key, opts)}
|
onOptionsChanged={(opts: PanelOptions) => this.handleOptionsChanged(p.key, opts)}
|
||||||
|
|
|
@ -20,14 +20,11 @@ import {
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
|
|
||||||
library.add(
|
library.add(
|
||||||
faChevronLeft,
|
|
||||||
faChevronRight,
|
|
||||||
faCalendarCheck,
|
faCalendarCheck,
|
||||||
faArrowUp,
|
faArrowUp,
|
||||||
faArrowDown,
|
faArrowDown,
|
||||||
faTimes,
|
faTimes,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Sadly needed to also replace <i> within the date picker, since it's not a React component.
|
// Sadly needed to also replace <i> within the date picker, since it's not a React component.
|
||||||
dom.watch();
|
dom.watch();
|
||||||
|
|
||||||
|
@ -99,7 +96,7 @@ class TimeInput extends Component<TimeInputProps> {
|
||||||
return (
|
return (
|
||||||
<InputGroup className="time-input" size="sm">
|
<InputGroup className="time-input" size="sm">
|
||||||
<InputGroupAddon addonType="prepend">
|
<InputGroupAddon addonType="prepend">
|
||||||
<Button title="Decrease time" onClick={this.decreaseTime}><FontAwesomeIcon icon="chevron-left" fixedWidth/></Button>
|
<Button title="Decrease time" onClick={this.decreaseTime}><FontAwesomeIcon icon={faChevronLeft} fixedWidth /></Button>
|
||||||
</InputGroupAddon>
|
</InputGroupAddon>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
|
@ -114,12 +111,12 @@ class TimeInput extends Component<TimeInputProps> {
|
||||||
that functionality is broken, so we create an external solution instead. */}
|
that functionality is broken, so we create an external solution instead. */}
|
||||||
{this.props.time &&
|
{this.props.time &&
|
||||||
<InputGroupAddon addonType="append">
|
<InputGroupAddon addonType="append">
|
||||||
<Button className="clear-time-btn" title="Clear time" onClick={this.clearTime}><FontAwesomeIcon icon="times" fixedWidth/></Button>
|
<Button className="clear-time-btn" title="Clear time" onClick={this.clearTime}><FontAwesomeIcon icon={faTimes} fixedWidth /></Button>
|
||||||
</InputGroupAddon>
|
</InputGroupAddon>
|
||||||
}
|
}
|
||||||
|
|
||||||
<InputGroupAddon addonType="append">
|
<InputGroupAddon addonType="append">
|
||||||
<Button title="Increase time" onClick={this.increaseTime}><FontAwesomeIcon icon="chevron-right" fixedWidth/></Button>
|
<Button title="Increase time" onClick={this.increaseTime}><FontAwesomeIcon icon={faChevronRight} fixedWidth /></Button>
|
||||||
</InputGroupAddon>
|
</InputGroupAddon>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
);
|
);
|
||||||
|
|
1
web/ui/react-app/src/utils/func.ts
Normal file
1
web/ui/react-app/src/utils/func.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export const uuidGen = () => '_' + Math.random().toString(36).substr(2, 9);
|
Loading…
Reference in a new issue