mirror of https://github.com/prometheus/prometheus
Allow copying label-value pair to buffer on click (#11229)
* Allow copying label-value pair to buffer on click Kept similar DOM structure to keep test compatibility. Using `navigator.clipboard` API since it is used by the current standard browsers. React hot toast is used to notify that the text was successfully copied into clipboard. Signed-off-by: lpessoa <luisalmeida@yape.com.pe> * Using reactstrap for toast notification Using the bootstrap toast notification provided by reactstrap. Clipboard handling is managed using React.Context via a shared callback. Updated css according to CR suggestions. Signed-off-by: lpessoa <luisalmeida@yape.com.pe> * Changes from CR comments Cleaning up renderFormatted method. Renamed Clipboard to ToastContext. Updated tests. Signed-off-by: Luis Pessoa <luisalmeida@yape.com.pe> Signed-off-by: lpessoa <luisalmeida@yape.com.pe> Signed-off-by: Luis Pessoa <luisalmeida@yape.com.pe>pre-ooo
parent
4a493db432
commit
9591103bb9
|
@ -1,25 +1,25 @@
|
|||
import React, { FC } from 'react';
|
||||
import Navigation from './Navbar';
|
||||
import { FC } from 'react';
|
||||
import { Container } from 'reactstrap';
|
||||
import Navigation from './Navbar';
|
||||
|
||||
import { BrowserRouter as Router, Redirect, Switch, Route } from 'react-router-dom';
|
||||
import { BrowserRouter as Router, Redirect, Route, Switch } from 'react-router-dom';
|
||||
import { PathPrefixContext } from './contexts/PathPrefixContext';
|
||||
import { ThemeContext, themeName, themeSetting } from './contexts/ThemeContext';
|
||||
import { useLocalStorage } from './hooks/useLocalStorage';
|
||||
import useMedia from './hooks/useMedia';
|
||||
import {
|
||||
AgentPage,
|
||||
AlertsPage,
|
||||
ConfigPage,
|
||||
FlagsPage,
|
||||
PanelListPage,
|
||||
RulesPage,
|
||||
ServiceDiscoveryPage,
|
||||
StatusPage,
|
||||
TargetsPage,
|
||||
TSDBStatusPage,
|
||||
PanelListPage,
|
||||
} from './pages';
|
||||
import { PathPrefixContext } from './contexts/PathPrefixContext';
|
||||
import { ThemeContext, themeName, themeSetting } from './contexts/ThemeContext';
|
||||
import { Theme, themeLocalStorageKey } from './Theme';
|
||||
import { useLocalStorage } from './hooks/useLocalStorage';
|
||||
import useMedia from './hooks/useMedia';
|
||||
|
||||
interface AppProps {
|
||||
consolesLink: string | null;
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
import React from 'react';
|
||||
|
||||
const ToastContext = React.createContext((msg: string) => {
|
||||
return;
|
||||
});
|
||||
|
||||
function useToastContext() {
|
||||
return React.useContext(ToastContext);
|
||||
}
|
||||
|
||||
export { useToastContext, ToastContext };
|
|
@ -1,13 +1,14 @@
|
|||
import React, { FC, useState, useEffect } from 'react';
|
||||
import { Alert, Button } from 'reactstrap';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { Alert, Button, Toast, ToastBody } from 'reactstrap';
|
||||
|
||||
import Panel, { PanelOptions, PanelDefaultOptions } from './Panel';
|
||||
import Checkbox from '../../components/Checkbox';
|
||||
import { generateID, decodePanelOptionsFromQueryString, encodePanelOptionsToQueryString, callAll } from '../../utils';
|
||||
import { API_PATH } from '../../constants/constants';
|
||||
import { ToastContext } from '../../contexts/ToastContext';
|
||||
import { usePathPrefix } from '../../contexts/PathPrefixContext';
|
||||
import { useFetch } from '../../hooks/useFetch';
|
||||
import { useLocalStorage } from '../../hooks/useLocalStorage';
|
||||
import { usePathPrefix } from '../../contexts/PathPrefixContext';
|
||||
import { API_PATH } from '../../constants/constants';
|
||||
import { callAll, decodePanelOptionsFromQueryString, encodePanelOptionsToQueryString, generateID } from '../../utils';
|
||||
import Panel, { PanelDefaultOptions, PanelOptions } from './Panel';
|
||||
|
||||
export type PanelMeta = { key: string; options: PanelOptions; id: string };
|
||||
|
||||
|
@ -125,6 +126,7 @@ const PanelList: FC = () => {
|
|||
const [enableAutocomplete, setEnableAutocomplete] = useLocalStorage('enable-metric-autocomplete', true);
|
||||
const [enableHighlighting, setEnableHighlighting] = useLocalStorage('enable-syntax-highlighting', true);
|
||||
const [enableLinter, setEnableLinter] = useLocalStorage('enable-linter', true);
|
||||
const [clipboardMsg, setClipboardMsg] = useState<string | null>(null);
|
||||
|
||||
const pathPrefix = usePathPrefix();
|
||||
const { response: metricsRes, error: metricsErr } = useFetch<string[]>(`${pathPrefix}/${API_PATH}/label/__name__/values`);
|
||||
|
@ -134,6 +136,13 @@ const PanelList: FC = () => {
|
|||
`${pathPrefix}/${API_PATH}/query?query=time()`
|
||||
);
|
||||
|
||||
const onClipboardMsg = (msg: string) => {
|
||||
setClipboardMsg(msg);
|
||||
setTimeout(() => {
|
||||
setClipboardMsg(null);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (timeRes.data) {
|
||||
const serverTime = timeRes.data.result[0];
|
||||
|
@ -149,73 +158,81 @@ const PanelList: FC = () => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="clearfix">
|
||||
<div className="float-left">
|
||||
<Checkbox
|
||||
wrapperStyles={{ display: 'inline-block' }}
|
||||
id="use-local-time-checkbox"
|
||||
onChange={({ target }) => setUseLocalTime(target.checked)}
|
||||
defaultChecked={useLocalTime}
|
||||
<ToastContext.Provider value={onClipboardMsg}>
|
||||
<div className="clearfix">
|
||||
<Toast
|
||||
isOpen={clipboardMsg != null}
|
||||
style={{ position: 'fixed', zIndex: 1000, left: '50%', transform: 'translateX(-50%)' }}
|
||||
>
|
||||
Use local time
|
||||
<ToastBody>Label matcher copied to clipboard</ToastBody>
|
||||
</Toast>
|
||||
<div className="float-left">
|
||||
<Checkbox
|
||||
wrapperStyles={{ display: 'inline-block' }}
|
||||
id="use-local-time-checkbox"
|
||||
onChange={({ target }) => setUseLocalTime(target.checked)}
|
||||
defaultChecked={useLocalTime}
|
||||
>
|
||||
Use local time
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
wrapperStyles={{ marginLeft: 20, display: 'inline-block' }}
|
||||
id="query-history-checkbox"
|
||||
onChange={({ target }) => setEnableQueryHistory(target.checked)}
|
||||
defaultChecked={enableQueryHistory}
|
||||
>
|
||||
Enable query history
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
wrapperStyles={{ marginLeft: 20, display: 'inline-block' }}
|
||||
id="autocomplete-checkbox"
|
||||
onChange={({ target }) => setEnableAutocomplete(target.checked)}
|
||||
defaultChecked={enableAutocomplete}
|
||||
>
|
||||
Enable autocomplete
|
||||
</Checkbox>
|
||||
</div>
|
||||
<Checkbox
|
||||
wrapperStyles={{ marginLeft: 20, display: 'inline-block' }}
|
||||
id="highlighting-checkbox"
|
||||
onChange={({ target }) => setEnableHighlighting(target.checked)}
|
||||
defaultChecked={enableHighlighting}
|
||||
>
|
||||
Enable highlighting
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
wrapperStyles={{ marginLeft: 20, display: 'inline-block' }}
|
||||
id="query-history-checkbox"
|
||||
onChange={({ target }) => setEnableQueryHistory(target.checked)}
|
||||
defaultChecked={enableQueryHistory}
|
||||
id="linter-checkbox"
|
||||
onChange={({ target }) => setEnableLinter(target.checked)}
|
||||
defaultChecked={enableLinter}
|
||||
>
|
||||
Enable query history
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
wrapperStyles={{ marginLeft: 20, display: 'inline-block' }}
|
||||
id="autocomplete-checkbox"
|
||||
onChange={({ target }) => setEnableAutocomplete(target.checked)}
|
||||
defaultChecked={enableAutocomplete}
|
||||
>
|
||||
Enable autocomplete
|
||||
Enable linter
|
||||
</Checkbox>
|
||||
</div>
|
||||
<Checkbox
|
||||
wrapperStyles={{ marginLeft: 20, display: 'inline-block' }}
|
||||
id="highlighting-checkbox"
|
||||
onChange={({ target }) => setEnableHighlighting(target.checked)}
|
||||
defaultChecked={enableHighlighting}
|
||||
>
|
||||
Enable highlighting
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
wrapperStyles={{ marginLeft: 20, display: 'inline-block' }}
|
||||
id="linter-checkbox"
|
||||
onChange={({ target }) => setEnableLinter(target.checked)}
|
||||
defaultChecked={enableLinter}
|
||||
>
|
||||
Enable linter
|
||||
</Checkbox>
|
||||
</div>
|
||||
{(delta > 30 || timeErr) && (
|
||||
<Alert color="danger">
|
||||
<strong>Warning: </strong>
|
||||
{timeErr && `Unexpected response status when fetching server time: ${timeErr.message}`}
|
||||
{delta >= 30 &&
|
||||
`Error fetching server time: Detected ${delta} seconds time difference between your browser and the server. Prometheus relies on accurate time and time drift might cause unexpected query results.`}
|
||||
</Alert>
|
||||
)}
|
||||
{metricsErr && (
|
||||
<Alert color="danger">
|
||||
<strong>Warning: </strong>
|
||||
Error fetching metrics list: Unexpected response status when fetching metric names: {metricsErr.message}
|
||||
</Alert>
|
||||
)}
|
||||
<PanelListContent
|
||||
panels={decodePanelOptionsFromQueryString(window.location.search)}
|
||||
useLocalTime={useLocalTime}
|
||||
metrics={metricsRes.data}
|
||||
queryHistoryEnabled={enableQueryHistory}
|
||||
enableAutocomplete={enableAutocomplete}
|
||||
enableHighlighting={enableHighlighting}
|
||||
enableLinter={enableLinter}
|
||||
/>
|
||||
{(delta > 30 || timeErr) && (
|
||||
<Alert color="danger">
|
||||
<strong>Warning: </strong>
|
||||
{timeErr && `Unexpected response status when fetching server time: ${timeErr.message}`}
|
||||
{delta >= 30 &&
|
||||
`Error fetching server time: Detected ${delta} seconds time difference between your browser and the server. Prometheus relies on accurate time and time drift might cause unexpected query results.`}
|
||||
</Alert>
|
||||
)}
|
||||
{metricsErr && (
|
||||
<Alert color="danger">
|
||||
<strong>Warning: </strong>
|
||||
Error fetching metrics list: Unexpected response status when fetching metric names: {metricsErr.message}
|
||||
</Alert>
|
||||
)}
|
||||
<PanelListContent
|
||||
panels={decodePanelOptionsFromQueryString(window.location.search)}
|
||||
useLocalTime={useLocalTime}
|
||||
metrics={metricsRes.data}
|
||||
queryHistoryEnabled={enableQueryHistory}
|
||||
enableAutocomplete={enableAutocomplete}
|
||||
enableHighlighting={enableHighlighting}
|
||||
enableLinter={enableLinter}
|
||||
/>
|
||||
</ToastContext.Provider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import * as React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { shallow, ShallowWrapper } from 'enzyme';
|
||||
import SeriesName from './SeriesName';
|
||||
|
||||
describe('SeriesName', () => {
|
||||
|
@ -51,26 +51,36 @@ describe('SeriesName', () => {
|
|||
{ name: 'label3', value: 'value_3', className: 'legend-label-name' },
|
||||
{ name: '}', className: 'legend-label-brace' },
|
||||
];
|
||||
|
||||
const testLabelContainerElement = (text:string, expectedText:string, container: ShallowWrapper) => {
|
||||
expect(text).toEqual(expectedText);
|
||||
expect(container.prop('className')).toEqual('legend-label-container');
|
||||
expect(container.childAt(0).prop('className')).toEqual('legend-label-name');
|
||||
expect(container.childAt(2).prop('className')).toEqual('legend-label-value');
|
||||
}
|
||||
|
||||
testCases.forEach((tc, i) => {
|
||||
const child = seriesName.childAt(i);
|
||||
const firstChildElement = child.childAt(0);
|
||||
const firstChildElementClass = firstChildElement.prop('className')
|
||||
const text = child
|
||||
.children()
|
||||
.map((ch) => ch.text())
|
||||
.join('');
|
||||
switch (child.children().length) {
|
||||
case 1:
|
||||
expect(text).toEqual(tc.name);
|
||||
expect(child.prop('className')).toEqual(tc.className);
|
||||
switch(firstChildElementClass) {
|
||||
case 'legend-label-container':
|
||||
testLabelContainerElement(text, `${tc.name}="${tc.value}"`, firstChildElement)
|
||||
break
|
||||
default:
|
||||
expect(text).toEqual(tc.name);
|
||||
expect(child.prop('className')).toEqual(tc.className);
|
||||
}
|
||||
break;
|
||||
case 3:
|
||||
expect(text).toEqual(`${tc.name}="${tc.value}"`);
|
||||
expect(child.childAt(0).prop('className')).toEqual('legend-label-name');
|
||||
expect(child.childAt(2).prop('className')).toEqual('legend-label-value');
|
||||
break;
|
||||
case 4:
|
||||
expect(text).toEqual(`, ${tc.name}="${tc.value}"`);
|
||||
expect(child.childAt(1).prop('className')).toEqual('legend-label-name');
|
||||
expect(child.childAt(3).prop('className')).toEqual('legend-label-value');
|
||||
case 2:
|
||||
const container = child.childAt(1);
|
||||
testLabelContainerElement(text, `, ${tc.name}="${tc.value}"`, container)
|
||||
break;
|
||||
default:
|
||||
fail('incorrect number of children: ' + child.children().length);
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import React, { FC } from 'react';
|
||||
import React, { FC, useContext } from 'react';
|
||||
import { useToastContext } from '../../contexts/ToastContext';
|
||||
import { metricToSeriesName } from '../../utils';
|
||||
|
||||
interface SeriesNameProps {
|
||||
|
@ -7,6 +8,20 @@ interface SeriesNameProps {
|
|||
}
|
||||
|
||||
const SeriesName: FC<SeriesNameProps> = ({ labels, format }) => {
|
||||
const setClipboardMsg = useToastContext();
|
||||
|
||||
const toClipboard = (e: React.MouseEvent<HTMLSpanElement>) => {
|
||||
let copyText = e.currentTarget.innerText || '';
|
||||
navigator.clipboard
|
||||
.writeText(copyText.trim())
|
||||
.then(() => {
|
||||
setClipboardMsg(copyText);
|
||||
})
|
||||
.catch((reason) => {
|
||||
console.error(`unable to copy text: ${reason}`);
|
||||
});
|
||||
};
|
||||
|
||||
const renderFormatted = (): React.ReactElement => {
|
||||
const labelNodes: React.ReactElement[] = [];
|
||||
let first = true;
|
||||
|
@ -18,7 +33,9 @@ const SeriesName: FC<SeriesNameProps> = ({ labels, format }) => {
|
|||
labelNodes.push(
|
||||
<span key={label}>
|
||||
{!first && ', '}
|
||||
<span className="legend-label-name">{label}</span>=<span className="legend-label-value">"{labels[label]}"</span>
|
||||
<span className="legend-label-container" onClick={toClipboard} title="Click to copy label matcher">
|
||||
<span className="legend-label-name">{label}</span>=<span className="legend-label-value">"{labels[label]}"</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
|
|
|
@ -275,6 +275,14 @@ input[type='checkbox']:checked + label {
|
|||
margin-right: 1px;
|
||||
}
|
||||
|
||||
.legend-label-container:hover {
|
||||
background-color: #add6ff;
|
||||
border-radius: 3px;
|
||||
padding-bottom: 1px;
|
||||
color: #495057;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.legend-label-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue