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
Luis Filipe Pessoa 2022-09-20 09:30:24 -03:00 committed by GitHub
parent 4a493db432
commit 9591103bb9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 151 additions and 88 deletions

View File

@ -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;

View File

@ -0,0 +1,11 @@
import React from 'react';
const ToastContext = React.createContext((msg: string) => {
return;
});
function useToastContext() {
return React.useContext(ToastContext);
}
export { useToastContext, ToastContext };

View File

@ -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>
</>
);
};

View File

@ -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);

View File

@ -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>
);

View File

@ -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;
}