mirror of https://github.com/grafana/grafana
Explore: Adds query inspector drawer to explore (#26698)
* Explore: Adds query inspector drawer to explorepull/26839/head
parent
145d221983
commit
02b12d3a7b
@ -0,0 +1,84 @@ |
||||
import React, { useState } from 'react'; |
||||
import { css } from 'emotion'; |
||||
|
||||
import { SelectableValue, GrafanaTheme } from '@grafana/data'; |
||||
import { stylesFactory, useTheme } from '../../themes'; |
||||
import { IconName, TabsBar, Tab, IconButton, CustomScrollbar, TabContent } from '../..'; |
||||
|
||||
export interface TabConfig { |
||||
label: string; |
||||
value: string; |
||||
content: React.ReactNode; |
||||
icon: IconName; |
||||
} |
||||
|
||||
export interface TabbedContainerProps { |
||||
tabs: TabConfig[]; |
||||
defaultTab?: string; |
||||
closeIconTooltip?: string; |
||||
onClose: () => void; |
||||
} |
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme) => { |
||||
return { |
||||
container: css` |
||||
height: 100%; |
||||
`,
|
||||
tabContent: css` |
||||
padding: ${theme.spacing.md}; |
||||
background-color: ${theme.colors.bodyBg}; |
||||
`,
|
||||
close: css` |
||||
position: absolute; |
||||
right: 16px; |
||||
top: 5px; |
||||
cursor: pointer; |
||||
font-size: ${theme.typography.size.lg}; |
||||
`,
|
||||
tabs: css` |
||||
padding-top: ${theme.spacing.sm}; |
||||
border-color: ${theme.colors.formInputBorder}; |
||||
ul { |
||||
margin-left: ${theme.spacing.md}; |
||||
} |
||||
`,
|
||||
scrollbar: css` |
||||
min-height: 100% !important; |
||||
background-color: ${theme.colors.panelBg}; |
||||
`,
|
||||
}; |
||||
}); |
||||
|
||||
export function TabbedContainer(props: TabbedContainerProps) { |
||||
const [activeTab, setActiveTab] = useState( |
||||
props.tabs.some(tab => tab.value === props.defaultTab) ? props.defaultTab : props.tabs?.[0].value |
||||
); |
||||
|
||||
const onSelectTab = (item: SelectableValue<string>) => { |
||||
setActiveTab(item.value!); |
||||
}; |
||||
|
||||
const { tabs, onClose, closeIconTooltip } = props; |
||||
const theme = useTheme(); |
||||
const styles = getStyles(theme); |
||||
|
||||
return ( |
||||
<div className={styles.container}> |
||||
<TabsBar className={styles.tabs}> |
||||
{tabs.map(t => ( |
||||
<Tab |
||||
key={t.value} |
||||
label={t.label} |
||||
active={t.value === activeTab} |
||||
onChangeTab={() => onSelectTab(t)} |
||||
icon={t.icon} |
||||
/> |
||||
))} |
||||
<IconButton className={styles.close} onClick={onClose} name="times" title={closeIconTooltip ?? 'Close'} /> |
||||
</TabsBar> |
||||
<CustomScrollbar className={styles.scrollbar}> |
||||
<TabContent className={styles.tabContent}>{tabs.find(t => t.value === activeTab)?.content}</TabContent> |
||||
</CustomScrollbar> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,11 @@ |
||||
import React from 'react'; |
||||
import { mount } from 'enzyme'; |
||||
import { ExploreDrawer } from './ExploreDrawer'; |
||||
|
||||
describe('<ExploreDrawer />', () => { |
||||
it('renders child element', () => { |
||||
const childElement = <div>Child element</div>; |
||||
const wrapper = mount(<ExploreDrawer width={400}>{childElement}</ExploreDrawer>); |
||||
expect(wrapper.text()).toBe('Child element'); |
||||
}); |
||||
}); |
@ -0,0 +1,93 @@ |
||||
// Libraries
|
||||
import React from 'react'; |
||||
import { Resizable, ResizeCallback } from 're-resizable'; |
||||
import { css, cx, keyframes } from 'emotion'; |
||||
|
||||
// Services & Utils
|
||||
import { stylesFactory, useTheme } from '@grafana/ui'; |
||||
|
||||
// Types
|
||||
import { GrafanaTheme } from '@grafana/data'; |
||||
|
||||
const drawerSlide = keyframes` |
||||
0% { |
||||
transform: translateY(400px); |
||||
} |
||||
|
||||
100% { |
||||
transform: translateY(0px); |
||||
} |
||||
`;
|
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme) => { |
||||
const shadowColor = theme.isLight ? theme.palette.gray4 : theme.palette.black; |
||||
|
||||
return { |
||||
container: css` |
||||
position: fixed !important; |
||||
bottom: 0; |
||||
background: ${theme.colors.pageHeaderBg}; |
||||
border-top: 1px solid ${theme.colors.formInputBorder}; |
||||
margin: 0px; |
||||
margin-right: -${theme.spacing.md}; |
||||
margin-left: -${theme.spacing.md}; |
||||
box-shadow: 0 0 4px ${shadowColor}; |
||||
z-index: ${theme.zIndex.sidemenu}; |
||||
`,
|
||||
drawerActive: css` |
||||
opacity: 1; |
||||
animation: 0.5s ease-out ${drawerSlide}; |
||||
`,
|
||||
rzHandle: css` |
||||
background: ${theme.colors.formInputBorder}; |
||||
transition: 0.3s background ease-in-out; |
||||
position: relative; |
||||
width: 200px !important; |
||||
height: 7px !important; |
||||
left: calc(50% - 100px) !important; |
||||
top: -4px !important; |
||||
cursor: grab; |
||||
border-radius: 4px; |
||||
&:hover { |
||||
background: ${theme.colors.formInputBorderHover}; |
||||
} |
||||
`,
|
||||
}; |
||||
}); |
||||
|
||||
export interface Props { |
||||
width: number; |
||||
children: React.ReactNode; |
||||
onResize?: ResizeCallback; |
||||
} |
||||
|
||||
export function ExploreDrawer(props: Props) { |
||||
const { width, children, onResize } = props; |
||||
const theme = useTheme(); |
||||
const styles = getStyles(theme); |
||||
const drawerWidth = `${width + 31.5}px`; |
||||
|
||||
return ( |
||||
<Resizable |
||||
className={cx(styles.container, styles.drawerActive)} |
||||
defaultSize={{ width: drawerWidth, height: '400px' }} |
||||
handleClasses={{ top: styles.rzHandle }} |
||||
enable={{ |
||||
top: true, |
||||
right: false, |
||||
bottom: false, |
||||
left: false, |
||||
topRight: false, |
||||
bottomRight: false, |
||||
bottomLeft: false, |
||||
topLeft: false, |
||||
}} |
||||
maxHeight="100vh" |
||||
maxWidth={drawerWidth} |
||||
minWidth={drawerWidth} |
||||
onResize={onResize} |
||||
> |
||||
{children} |
||||
</Resizable> |
||||
); |
||||
} |
@ -0,0 +1,183 @@ |
||||
import React, { useState } from 'react'; |
||||
import { Button, JSONFormatter, LoadingPlaceholder, TabbedContainer, TabConfig } from '@grafana/ui'; |
||||
import { AppEvents, PanelData, TimeZone } from '@grafana/data'; |
||||
|
||||
import appEvents from 'app/core/app_events'; |
||||
import { CopyToClipboard } from 'app/core/components/CopyToClipboard/CopyToClipboard'; |
||||
import { StoreState, ExploreItemState, ExploreId } from 'app/types'; |
||||
import { hot } from 'react-hot-loader'; |
||||
import { connect } from 'react-redux'; |
||||
import { ExploreDrawer } from 'app/features/explore/ExploreDrawer'; |
||||
import { useEffectOnce } from 'react-use'; |
||||
import { getBackendSrv } from 'app/core/services/backend_srv'; |
||||
import { InspectStatsTab } from '../dashboard/components/Inspector/InspectStatsTab'; |
||||
import { getPanelInspectorStyles } from '../dashboard/components/Inspector/styles'; |
||||
|
||||
function stripPropsFromResponse(response: any) { |
||||
// ignore silent requests
|
||||
if (response.config?.silent) { |
||||
return {}; |
||||
} |
||||
|
||||
const clonedResponse = { ...response }; // clone - dont modify the response
|
||||
|
||||
if (clonedResponse.headers) { |
||||
delete clonedResponse.headers; |
||||
} |
||||
|
||||
if (clonedResponse.config) { |
||||
clonedResponse.request = clonedResponse.config; |
||||
|
||||
delete clonedResponse.config; |
||||
delete clonedResponse.request.transformRequest; |
||||
delete clonedResponse.request.transformResponse; |
||||
delete clonedResponse.request.paramSerializer; |
||||
delete clonedResponse.request.jsonpCallbackParam; |
||||
delete clonedResponse.request.headers; |
||||
delete clonedResponse.request.requestId; |
||||
delete clonedResponse.request.inspect; |
||||
delete clonedResponse.request.retry; |
||||
delete clonedResponse.request.timeout; |
||||
} |
||||
|
||||
if (clonedResponse.data) { |
||||
clonedResponse.response = clonedResponse.data; |
||||
|
||||
delete clonedResponse.config; |
||||
delete clonedResponse.data; |
||||
delete clonedResponse.status; |
||||
delete clonedResponse.statusText; |
||||
delete clonedResponse.ok; |
||||
delete clonedResponse.url; |
||||
delete clonedResponse.redirected; |
||||
delete clonedResponse.type; |
||||
delete clonedResponse.$$config; |
||||
} |
||||
|
||||
return clonedResponse; |
||||
} |
||||
|
||||
interface Props { |
||||
loading: boolean; |
||||
width: number; |
||||
exploreId: ExploreId; |
||||
queryResponse?: PanelData; |
||||
onClose: () => void; |
||||
} |
||||
|
||||
function ExploreQueryInspector(props: Props) { |
||||
const [formattedJSON, setFormattedJSON] = useState({}); |
||||
|
||||
const getTextForClipboard = () => { |
||||
return JSON.stringify(formattedJSON, null, 2); |
||||
}; |
||||
|
||||
const onClipboardSuccess = () => { |
||||
appEvents.emit(AppEvents.alertSuccess, ['Content copied to clipboard']); |
||||
}; |
||||
|
||||
const [allNodesExpanded, setAllNodesExpanded] = useState(false); |
||||
const getOpenNodeCount = () => { |
||||
if (allNodesExpanded === null) { |
||||
return 3; // 3 is default, ie when state is null
|
||||
} else if (allNodesExpanded) { |
||||
return 20; |
||||
} |
||||
return 1; |
||||
}; |
||||
|
||||
const onToggleExpand = () => { |
||||
setAllNodesExpanded(!allNodesExpanded); |
||||
}; |
||||
|
||||
const { loading, width, onClose, queryResponse } = props; |
||||
|
||||
const [response, setResponse] = useState<PanelData>({} as PanelData); |
||||
useEffectOnce(() => { |
||||
const inspectorStreamSub = getBackendSrv() |
||||
.getInspectorStream() |
||||
.subscribe(resp => { |
||||
const strippedResponse = stripPropsFromResponse(resp); |
||||
setResponse(strippedResponse); |
||||
}); |
||||
|
||||
return () => { |
||||
inspectorStreamSub?.unsubscribe(); |
||||
}; |
||||
}); |
||||
|
||||
const haveData = response && Object.keys(response).length > 0; |
||||
const styles = getPanelInspectorStyles(); |
||||
|
||||
const statsTab: TabConfig = { |
||||
label: 'Stats', |
||||
value: 'stats', |
||||
icon: 'chart-line', |
||||
content: <InspectStatsTab data={queryResponse!} timeZone={queryResponse?.request?.timezone as TimeZone} />, |
||||
}; |
||||
|
||||
const inspectorTab: TabConfig = { |
||||
label: 'Query Inspector', |
||||
value: 'query_inspector', |
||||
icon: 'info-circle', |
||||
content: ( |
||||
<> |
||||
<div className={styles.toolbar}> |
||||
{haveData && ( |
||||
<> |
||||
<Button |
||||
icon={allNodesExpanded ? 'minus' : 'plus'} |
||||
variant="secondary" |
||||
className={styles.toolbarItem} |
||||
onClick={onToggleExpand} |
||||
> |
||||
{allNodesExpanded ? 'Collapse' : 'Expand'} all |
||||
</Button> |
||||
|
||||
<CopyToClipboard |
||||
text={getTextForClipboard} |
||||
onSuccess={onClipboardSuccess} |
||||
elType="div" |
||||
className={styles.toolbarItem} |
||||
> |
||||
<Button icon="copy" variant="secondary"> |
||||
Copy to clipboard |
||||
</Button> |
||||
</CopyToClipboard> |
||||
</> |
||||
)} |
||||
<div className="flex-grow-1" /> |
||||
</div> |
||||
<div className={styles.contentQueryInspector}> |
||||
{loading && <LoadingPlaceholder text="Loading query inspector..." />} |
||||
{!loading && haveData && ( |
||||
<JSONFormatter json={response!} open={getOpenNodeCount()} onDidRender={setFormattedJSON} /> |
||||
)} |
||||
{!loading && !haveData && ( |
||||
<p className="muted">No request & response collected yet. Run query to collect request & response.</p> |
||||
)} |
||||
</div> |
||||
</> |
||||
), |
||||
}; |
||||
|
||||
const tabs = [statsTab, inspectorTab]; |
||||
return ( |
||||
<ExploreDrawer width={width} onResize={() => {}}> |
||||
<TabbedContainer tabs={tabs} onClose={onClose} closeIconTooltip="Close query inspector" /> |
||||
</ExploreDrawer> |
||||
); |
||||
} |
||||
|
||||
function mapStateToProps(state: StoreState, { exploreId }: { exploreId: ExploreId }) { |
||||
const explore = state.explore; |
||||
const item: ExploreItemState = explore[exploreId]; |
||||
const { loading, queryResponse } = item; |
||||
|
||||
return { |
||||
loading, |
||||
queryResponse, |
||||
}; |
||||
} |
||||
|
||||
export default hot(module)(connect(mapStateToProps)(ExploreQueryInspector)); |
Loading…
Reference in new issue