Explore: Content Outline (#74536)

* Add images

* Basic button functionality; TODO placeholders for dispatching contentOutlineToggle and rendering content outline component

* Basic content outline container

* Content outline toggles

* Remove icon files from explore

* Scroll into view v1

* outline that reflect's explore's order of vizs

* Update icon name

* Add scrollId to PanelChrome; scrolling enabled for Table

* Add queries icon

* Improve scroll behavior in split view

* Add wrapper so the sticky navigation doesn't scroll when on the bottom of the window

* Fix the issue with logs gap; center icons

* Memoize register and unregister functions; adjust content height

* Make displayOrderId optional

* Use Node API for finding position of panels in content outline;  add tooltip

* Dock content outline in expanded mode; at tooltip to toggle button

* Handle content outline visibility from Explore and not redux; pass outlineItems as a prop

* Fix ContentOutline test

* Add interaction tracking

* Add padding to fix test

* Replace string literals with objects for styles

* Update event reporting payloads

* Custom content outline button; content outline container improvements

* Add aria-expanded to content outline button in ExploreToolbar

* Fix vertical and horizontal scrolling

* Add aria-controls

* Remove unneccessary css since ExploreToolbar is sticky

* Update feature toggles; Fix typos

* Make content outline button more prominent in split mode; add padding to content outline items;

* Diego's UX updates

* WIP: some scroll fixes

* Fix test and type error

* Add id to ContentOutline to differentiate in split mode

* No default exports

---------

Co-authored-by: Giordano Ricci <me@giordanoricci.com>
This commit is contained in:
Haris Rozajac 2023-10-13 10:57:13 -06:00 committed by GitHub
parent 91cf4f0c1c
commit 4ec54bc2c3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 694 additions and 233 deletions

View File

@ -3770,10 +3770,8 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
],
"public/app/features/explore/Explore.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"]
"public/app/features/explore/ContentOutline/ContentOutline.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/explore/ExploreDrawer.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],

View File

@ -24,6 +24,7 @@ Some features are enabled by default. You can disable these feature by setting t
| `disableEnvelopeEncryption` | Disable envelope encryption (emergency only) | |
| `publicDashboards` | Enables public access to dashboards | Yes |
| `featureHighlights` | Highlight Grafana Enterprise features | |
| `exploreContentOutline` | Content outline sidebar | Yes |
| `dataConnectionsConsole` | Enables a new top-level page called Connections. This page is an experiment that provides a better experience when you install and configure data sources and other plugins. | Yes |
| `cloudWatchCrossAccountQuerying` | Enables cross-account querying in CloudWatch datasources | Yes |
| `redshiftAsyncQueryDataSupport` | Enable async query data support for Redshift | Yes |

View File

@ -30,6 +30,7 @@ export interface FeatureToggles {
migrationLocking?: boolean;
storage?: boolean;
correlations?: boolean;
exploreContentOutline?: boolean;
datasourceQueryMultiStatus?: boolean;
traceToMetrics?: boolean;
newDBLibrary?: boolean;

View File

@ -86,6 +86,14 @@ var (
Stage: FeatureStagePublicPreview,
Owner: grafanaExploreSquad,
},
{
Name: "exploreContentOutline",
Description: "Content outline sidebar",
Stage: FeatureStageGeneralAvailability,
Owner: grafanaExploreSquad,
Expression: "true", // enabled by default
FrontendOnly: true,
},
{
Name: "datasourceQueryMultiStatus",
Description: "Introduce HTTP 207 Multi Status for api/ds/query",

View File

@ -11,6 +11,7 @@ featureHighlights,GA,@grafana/grafana-as-code,false,false,false,false
migrationLocking,preview,@grafana/backend-platform,false,false,false,false
storage,experimental,@grafana/grafana-app-platform-squad,false,false,false,false
correlations,preview,@grafana/explore-squad,false,false,false,false
exploreContentOutline,GA,@grafana/explore-squad,false,false,false,true
datasourceQueryMultiStatus,experimental,@grafana/plugins-platform-backend,false,false,false,false
traceToMetrics,experimental,@grafana/observability-traces-and-profiling,false,false,false,true
newDBLibrary,preview,@grafana/backend-platform,false,false,false,false

1 Name Stage Owner requiresDevMode RequiresLicense RequiresRestart FrontendOnly
11 migrationLocking preview @grafana/backend-platform false false false false
12 storage experimental @grafana/grafana-app-platform-squad false false false false
13 correlations preview @grafana/explore-squad false false false false
14 exploreContentOutline GA @grafana/explore-squad false false false true
15 datasourceQueryMultiStatus experimental @grafana/plugins-platform-backend false false false false
16 traceToMetrics experimental @grafana/observability-traces-and-profiling false false false true
17 newDBLibrary preview @grafana/backend-platform false false false false

View File

@ -55,6 +55,10 @@ const (
// Correlations page
FlagCorrelations = "correlations"
// FlagExploreContentOutline
// Content outline sidebar
FlagExploreContentOutline = "exploreContentOutline"
// FlagDatasourceQueryMultiStatus
// Introduce HTTP 207 Multi Status for api/ds/query
FlagDatasourceQueryMultiStatus = "datasourceQueryMultiStatus"

View File

@ -0,0 +1,70 @@
import { render, screen, fireEvent } from '@testing-library/react';
import React from 'react';
import { ContentOutline } from './ContentOutline';
jest.mock('./ContentOutlineContext', () => ({
useContentOutlineContext: jest.fn(),
}));
const scrollIntoViewMock = jest.fn();
const scrollerMock = document.createElement('div');
const setup = () => {
HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;
scrollerMock.scroll = jest.fn();
// Mock useContentOutlineContext with custom outlineItems
const mockUseContentOutlineContext = require('./ContentOutlineContext').useContentOutlineContext;
mockUseContentOutlineContext.mockReturnValue({
outlineItems: [
{
id: 'item-1',
icon: 'test-icon',
title: 'Item 1',
ref: document.createElement('div'),
},
{
id: 'item-2',
icon: 'test-icon',
title: 'Item 2',
ref: document.createElement('div'),
},
],
register: jest.fn(),
unregister: jest.fn(),
});
return render(<ContentOutline scroller={scrollerMock} panelId="content-outline-container-1" />);
};
describe('<ContentOutline />', () => {
beforeEach(() => {
setup();
});
it('toggles content on button click', () => {
let showContentOutlineButton = screen.getByLabelText('Expand content outline');
expect(showContentOutlineButton).toBeInTheDocument();
fireEvent.click(showContentOutlineButton);
const hideContentOutlineButton = screen.getByText('Collapse outline');
expect(hideContentOutlineButton).toBeInTheDocument();
fireEvent.click(hideContentOutlineButton);
showContentOutlineButton = screen.getByLabelText('Expand content outline');
expect(showContentOutlineButton).toBeInTheDocument();
});
it('scrolls into view on content button click', () => {
const itemButtons = screen.getAllByLabelText(/Item/i);
itemButtons.forEach((button) => {
fireEvent.click(button);
//assert scrollIntoView is called
expect(scrollerMock.scroll).toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,94 @@
import { css } from '@emotion/css';
import React from 'react';
import { useToggle } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { useStyles2, PanelContainer, CustomScrollbar } from '@grafana/ui';
import { useContentOutlineContext } from './ContentOutlineContext';
import { ContentOutlineItemButton } from './ContentOutlineItemButton';
const getStyles = (theme: GrafanaTheme2) => {
return {
wrapper: css({
label: 'wrapper',
position: 'relative',
display: 'flex',
justifyContent: 'center',
marginRight: theme.spacing(1),
height: '100%',
backgroundColor: theme.colors.background.primary,
}),
content: css({
label: 'content',
top: 0,
}),
buttonStyles: css({
textAlign: 'left',
width: '100%',
padding: theme.spacing(0, 1.5),
}),
};
};
export function ContentOutline({ scroller, panelId }: { scroller: HTMLElement | undefined; panelId: string }) {
const [expanded, toggleExpanded] = useToggle(false);
const styles = useStyles2((theme) => getStyles(theme));
const { outlineItems } = useContentOutlineContext();
const scrollIntoView = (ref: HTMLElement | null, buttonTitle: string) => {
let scrollValue = 0;
let el: HTMLElement | null | undefined = ref;
do {
scrollValue += el?.offsetTop || 0;
el = el?.offsetParent as HTMLElement;
} while (el && el !== scroller);
scroller?.scroll({
top: scrollValue,
behavior: 'smooth',
});
reportInteraction('explore_toolbar_contentoutline_clicked', {
item: 'select_section',
type: buttonTitle,
});
};
const toggle = () => {
toggleExpanded();
reportInteraction('explore_toolbar_contentoutline_clicked', {
item: 'outline',
type: expanded ? 'minimize' : 'expand',
});
};
return (
<PanelContainer className={styles.wrapper} id={panelId}>
<CustomScrollbar>
<div className={styles.content}>
<ContentOutlineItemButton
title={expanded ? 'Collapse outline' : undefined}
icon={expanded ? 'angle-left' : 'angle-right'}
onClick={toggle}
tooltip={!expanded ? 'Expand content outline' : undefined}
className={styles.buttonStyles}
aria-expanded={expanded}
/>
{outlineItems.map((item) => (
<ContentOutlineItemButton
key={item.id}
title={expanded ? item.title : undefined}
className={styles.buttonStyles}
icon={item.icon}
onClick={() => scrollIntoView(item.ref, item.title)}
tooltip={!expanded ? item.title : undefined}
/>
))}
</div>
</CustomScrollbar>
</PanelContainer>
);
}

View File

@ -0,0 +1,64 @@
import { uniqueId } from 'lodash';
import React, { useState, useContext, createContext, ReactNode, useCallback } from 'react';
import { ContentOutlineItemBaseProps } from './ContentOutlineItem';
export interface ContentOutlineItemContextProps extends ContentOutlineItemBaseProps {
id: string;
ref: HTMLElement | null;
}
type RegisterFunction = ({ title, icon, ref }: Omit<ContentOutlineItemContextProps, 'id'>) => string;
interface ContentOutlineContextProps {
outlineItems: ContentOutlineItemContextProps[];
register: RegisterFunction;
unregister: (id: string) => void;
}
const ContentOutlineContext = createContext<ContentOutlineContextProps | undefined>(undefined);
export const ContentOutlineContextProvider = ({ children }: { children: ReactNode }) => {
const [outlineItems, setOutlineItems] = useState<ContentOutlineItemContextProps[]>([]);
const register: RegisterFunction = useCallback(({ title, icon, ref }) => {
const id = uniqueId(`${title}-${icon}_`);
setOutlineItems((prevItems) => {
const updatedItems = [...prevItems, { id, title, icon, ref }];
return updatedItems.sort((a, b) => {
if (a.ref && b.ref) {
const diff = a.ref.compareDocumentPosition(b.ref);
if (diff === Node.DOCUMENT_POSITION_PRECEDING) {
return 1;
} else if (diff === Node.DOCUMENT_POSITION_FOLLOWING) {
return -1;
}
}
return 0;
});
});
return id;
}, []);
const unregister = useCallback((id: string) => {
setOutlineItems((prevItems) => prevItems.filter((item) => item.id !== id));
}, []);
return (
<ContentOutlineContext.Provider value={{ outlineItems, register, unregister }}>
{children}
</ContentOutlineContext.Provider>
);
};
export function useContentOutlineContext() {
const ctx = useContext(ContentOutlineContext);
if (!ctx) {
throw new Error('useContentOutlineContext must be used within a ContentOutlineContextProvider');
}
return ctx;
}

View File

@ -0,0 +1,32 @@
import React, { useEffect, useRef, ReactNode } from 'react';
import { useContentOutlineContext } from './ContentOutlineContext';
export interface ContentOutlineItemBaseProps {
title: string;
icon: string;
}
interface ContentOutlineItemProps extends ContentOutlineItemBaseProps {
children: ReactNode;
className?: string;
}
export function ContentOutlineItem({ title, icon, children, className }: ContentOutlineItemProps) {
const { register, unregister } = useContentOutlineContext();
const ref = useRef(null);
useEffect(() => {
// When the component mounts, register it and get its unique ID.
const id = register({ title: title, icon: icon, ref: ref.current });
// When the component unmounts, unregister it using its unique ID.
return () => unregister(id);
}, [title, icon, register, unregister]);
return (
<div className={className} ref={ref}>
{children}
</div>
);
}

View File

@ -0,0 +1,72 @@
import { cx, css } from '@emotion/css';
import React, { ButtonHTMLAttributes } from 'react';
import { IconName, isIconName, GrafanaTheme2 } from '@grafana/data';
import { Icon, useStyles2, Tooltip } from '@grafana/ui';
type CommonProps = {
title?: string;
icon: string;
tooltip?: string;
className?: string;
};
export type ContentOutlineItemButtonProps = CommonProps & ButtonHTMLAttributes<HTMLButtonElement>;
export function ContentOutlineItemButton({ title, icon, tooltip, className, ...rest }: ContentOutlineItemButtonProps) {
const styles = useStyles2(getStyles);
const buttonStyles = cx(styles.button, className);
const body = (
<button className={buttonStyles} aria-label={tooltip} {...rest}>
{renderIcon(icon)}
{title}
</button>
);
return tooltip ? (
<Tooltip content={tooltip} placement="bottom">
{body}
</Tooltip>
) : (
body
);
}
function renderIcon(icon: IconName | React.ReactNode) {
if (!icon) {
return null;
}
if (isIconName(icon)) {
return <Icon name={icon} size={'lg'} />;
}
return icon;
}
const getStyles = (theme: GrafanaTheme2) => {
return {
button: css({
label: 'content-outline-item-button',
display: 'flex',
flexGrow: 1,
alignItems: 'center',
height: theme.spacing(theme.components.height.md),
padding: theme.spacing(0, 1),
gap: theme.spacing(1),
color: theme.colors.text.secondary,
background: 'transparent',
border: 'none',
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
'&:hover': {
color: theme.colors.text.primary,
background: theme.colors.background.secondary,
textDecoration: 'underline',
},
}),
};
};

View File

@ -8,6 +8,7 @@ import { selectors } from '@grafana/e2e-selectors';
import { getPluginLinkExtensions } from '@grafana/runtime';
import { configureStore } from 'app/store/configureStore';
import { ContentOutlineContextProvider } from './ContentOutline/ContentOutlineContext';
import { Explore, Props } from './Explore';
import { initialExploreState } from './state/main';
import { scanStopAction } from './state/query';
@ -141,7 +142,9 @@ const setup = (overrideProps?: Partial<Props>) => {
return render(
<TestProvider store={store}>
<Explore {...exploreProps} />
<ContentOutlineContextProvider>
<Explore {...exploreProps} />
</ContentOutlineContextProvider>
</TestProvider>
);
};

View File

@ -35,6 +35,9 @@ import { StoreState } from 'app/types';
import { getTimeZone } from '../profile/state/selectors';
import { ContentOutline } from './ContentOutline/ContentOutline';
import { ContentOutlineContextProvider } from './ContentOutline/ContentOutlineContext';
import { ContentOutlineItem } from './ContentOutline/ContentOutlineItem';
import { CorrelationHelper } from './CorrelationHelper';
import { CustomContainer } from './CustomContainer';
import ExploreQueryInspector from './ExploreQueryInspector';
@ -69,30 +72,37 @@ import { updateTimeRange } from './state/time';
const getStyles = (theme: GrafanaTheme2) => {
return {
exploreMain: css`
label: exploreMain;
exploreMain: css({
label: 'exploreMain',
// Is needed for some transition animations to work.
position: relative;
margin-top: 21px;
display: flex;
flex-direction: column;
gap: ${theme.spacing(1)};
`,
queryContainer: css`
label: queryContainer;
// Need to override normal css class and don't want to count on ordering of the classes in html.
height: auto !important;
flex: unset !important;
display: unset !important;
padding: ${theme.spacing(1)};
`,
exploreContainer: css`
display: flex;
flex: 1 1 auto;
flex-direction: column;
padding: ${theme.spacing(2)};
padding-top: 0;
`,
position: 'relative',
marginTop: '21px',
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
}),
queryContainer: css({
label: 'queryContainer',
padding: theme.spacing(1),
}),
exploreContainer: css({
label: 'exploreContainer',
display: 'flex',
flexDirection: 'column',
paddingRight: theme.spacing(2),
marginBottom: theme.spacing(2),
}),
left: css({
marginBottom: theme.spacing(2),
}),
wrapper: css({
position: 'absolute',
top: 0,
left: theme.spacing(2),
right: 0,
bottom: 0,
display: 'flex',
}),
};
};
@ -109,6 +119,7 @@ enum ExploreDrawer {
interface ExploreState {
openDrawer?: ExploreDrawer;
contentOutlineVisible: boolean;
}
export type Props = ExploreProps & ConnectedProps<typeof connector>;
@ -137,6 +148,7 @@ export type Props = ExploreProps & ConnectedProps<typeof connector>;
* The result viewers determine some of the query options sent to the datasource, e.g.,
* `format`, to indicate eventual transformations by the datasources' result transformers.
*/
export class Explore extends React.PureComponent<Props, ExploreState> {
scrollElement: HTMLDivElement | undefined;
topOfViewRef = createRef<HTMLDivElement>();
@ -148,6 +160,7 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
super(props);
this.state = {
openDrawer: undefined,
contentOutlineVisible: false,
};
this.graphEventBus = props.eventBus.newScopedBus('graph', { onlyLocal: false });
this.logsEventBus = props.eventBus.newScopedBus('logs', { onlyLocal: false });
@ -174,6 +187,18 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
}
};
onContentOutlineToogle = () => {
this.setState((state) => {
reportInteraction('explore_toolbar_contentoutline_clicked', {
item: 'outline',
type: state.contentOutlineVisible ? 'close' : 'open',
});
return {
contentOutlineVisible: !state.contentOutlineVisible,
};
});
};
/**
* Used by Logs details.
* Returns true if all queries have the filter, otherwise false.
@ -318,18 +343,20 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
return Object.entries(groupedByPlugin).map(([pluginId, frames], index) => {
return (
<CustomContainer
key={index}
timeZone={timeZone}
pluginId={pluginId}
frames={frames}
state={queryResponse.state}
absoluteRange={absoluteRange}
height={400}
width={width}
splitOpenFn={this.onSplitOpen(pluginId)}
eventBus={eventBus}
/>
<ContentOutlineItem title={pluginId} icon="plug" key={index}>
<CustomContainer
key={index}
timeZone={timeZone}
pluginId={pluginId}
frames={frames}
state={queryResponse.state}
absoluteRange={absoluteRange}
height={400}
width={width}
splitOpenFn={this.onSplitOpen(pluginId)}
eventBus={eventBus}
/>
</ContentOutlineItem>
);
});
}
@ -338,68 +365,82 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
const { graphResult, absoluteRange, timeZone, queryResponse, showFlameGraph } = this.props;
return (
<GraphContainer
data={graphResult!}
height={showFlameGraph ? 180 : 400}
width={width}
absoluteRange={absoluteRange}
timeZone={timeZone}
onChangeTime={this.onUpdateTimeRange}
annotations={queryResponse.annotations}
splitOpenFn={this.onSplitOpen('graph')}
loadingState={queryResponse.state}
eventBus={this.graphEventBus}
/>
<ContentOutlineItem title="Graph" icon="graph-bar">
<GraphContainer
data={graphResult!}
height={showFlameGraph ? 180 : 400}
width={width}
absoluteRange={absoluteRange}
timeZone={timeZone}
onChangeTime={this.onUpdateTimeRange}
annotations={queryResponse.annotations}
splitOpenFn={this.onSplitOpen('graph')}
loadingState={queryResponse.state}
eventBus={this.graphEventBus}
/>
</ContentOutlineItem>
);
}
renderTablePanel(width: number) {
const { exploreId, timeZone } = this.props;
return (
<TableContainer
ariaLabel={selectors.pages.Explore.General.table}
width={width}
exploreId={exploreId}
onCellFilterAdded={this.onCellFilterAdded}
timeZone={timeZone}
splitOpenFn={this.onSplitOpen('table')}
/>
<ContentOutlineItem title="Table" icon="table">
<TableContainer
ariaLabel={selectors.pages.Explore.General.table}
width={width}
exploreId={exploreId}
onCellFilterAdded={this.onCellFilterAdded}
timeZone={timeZone}
splitOpenFn={this.onSplitOpen('table')}
/>
</ContentOutlineItem>
);
}
renderRawPrometheus(width: number) {
const { exploreId, datasourceInstance, timeZone } = this.props;
return (
<RawPrometheusContainer
showRawPrometheus={true}
ariaLabel={selectors.pages.Explore.General.table}
width={width}
exploreId={exploreId}
onCellFilterAdded={datasourceInstance?.modifyQuery ? this.onCellFilterAdded : undefined}
timeZone={timeZone}
splitOpenFn={this.onSplitOpen('table')}
/>
<ContentOutlineItem title="Raw Prometheus" icon="gf-prometheus">
<RawPrometheusContainer
showRawPrometheus={true}
ariaLabel={selectors.pages.Explore.General.table}
width={width}
exploreId={exploreId}
onCellFilterAdded={datasourceInstance?.modifyQuery ? this.onCellFilterAdded : undefined}
timeZone={timeZone}
splitOpenFn={this.onSplitOpen('table')}
/>
</ContentOutlineItem>
);
}
renderLogsPanel(width: number) {
const { exploreId, syncedTimes, theme, queryResponse } = this.props;
const spacing = parseInt(theme.spacing(2).slice(0, -2), 10);
// Need to make ContentOutlineItem a flex container so the gap works
const logsContentOutlineWrapper = css({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
});
return (
<LogsContainer
exploreId={exploreId}
loadingState={queryResponse.state}
syncedTimes={syncedTimes}
width={width - spacing}
onClickFilterLabel={this.onClickFilterLabel}
onClickFilterOutLabel={this.onClickFilterOutLabel}
onStartScanning={this.onStartScanning}
onStopScanning={this.onStopScanning}
eventBus={this.logsEventBus}
splitOpenFn={this.onSplitOpen('logs')}
scrollElement={this.scrollElement}
isFilterLabelActive={this.isFilterLabelActive}
/>
<ContentOutlineItem title="Logs" icon="gf-logs" className={logsContentOutlineWrapper}>
<LogsContainer
exploreId={exploreId}
loadingState={queryResponse.state}
syncedTimes={syncedTimes}
width={width - spacing}
onClickFilterLabel={this.onClickFilterLabel}
onClickFilterOutLabel={this.onClickFilterOutLabel}
onStartScanning={this.onStartScanning}
onStopScanning={this.onStopScanning}
eventBus={this.logsEventBus}
splitOpenFn={this.onSplitOpen('logs')}
scrollElement={this.scrollElement}
isFilterLabelActive={this.isFilterLabelActive}
/>
</ContentOutlineItem>
);
}
@ -407,17 +448,19 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
const { logsSample, timeZone, setSupplementaryQueryEnabled, exploreId, datasourceInstance, queries } = this.props;
return (
<LogsSamplePanel
queryResponse={logsSample.data}
timeZone={timeZone}
enabled={logsSample.enabled}
queries={queries}
datasourceInstance={datasourceInstance}
splitOpen={this.onSplitOpen('logsSample')}
setLogsSampleEnabled={(enabled: boolean) =>
setSupplementaryQueryEnabled(exploreId, enabled, SupplementaryQueryType.LogsSample)
}
/>
<ContentOutlineItem title="Logs Sample" icon="gf-logs">
<LogsSamplePanel
queryResponse={logsSample.data}
timeZone={timeZone}
enabled={logsSample.enabled}
queries={queries}
datasourceInstance={datasourceInstance}
splitOpen={this.onSplitOpen('logsSample')}
setLogsSampleEnabled={(enabled: boolean) =>
setSupplementaryQueryEnabled(exploreId, enabled, SupplementaryQueryType.LogsSample)
}
/>
</ContentOutlineItem>
);
}
@ -426,19 +469,25 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
const datasourceType = datasourceInstance ? datasourceInstance?.type : 'unknown';
return (
<NodeGraphContainer
dataFrames={this.memoizedGetNodeGraphDataFrames(queryResponse.series)}
exploreId={exploreId}
withTraceView={showTrace}
datasourceType={datasourceType}
splitOpenFn={this.onSplitOpen('nodeGraph')}
/>
<ContentOutlineItem title="Node Graph" icon="code-branch">
<NodeGraphContainer
dataFrames={this.memoizedGetNodeGraphDataFrames(queryResponse.series)}
exploreId={exploreId}
withTraceView={showTrace}
datasourceType={datasourceType}
splitOpenFn={this.onSplitOpen('nodeGraph')}
/>
</ContentOutlineItem>
);
}
renderFlameGraphPanel() {
const { queryResponse } = this.props;
return <FlameGraphExploreContainer dataFrames={queryResponse.flameGraphFrames} />;
return (
<ContentOutlineItem title="Flame Graph" icon="fire">
<FlameGraphExploreContainer dataFrames={queryResponse.flameGraphFrames} />
</ContentOutlineItem>
);
}
renderTraceViewPanel() {
@ -448,14 +497,16 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
return (
// If there is no data (like 404) we show a separate error so no need to show anything here
dataFrames.length && (
<TraceViewContainer
exploreId={exploreId}
dataFrames={dataFrames}
splitOpenFn={this.onSplitOpen('traceView')}
scrollElement={this.scrollElement}
queryResponse={queryResponse}
topOfViewRef={this.topOfViewRef}
/>
<ContentOutlineItem title="Traces" icon="file-alt">
<TraceViewContainer
exploreId={exploreId}
dataFrames={dataFrames}
splitOpenFn={this.onSplitOpen('traceView')}
scrollElement={this.scrollElement}
queryResponse={queryResponse}
topOfViewRef={this.topOfViewRef}
/>
</ContentOutlineItem>
)
);
}
@ -481,7 +532,7 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
correlationEditorDetails,
correlationEditorHelperData,
} = this.props;
const { openDrawer } = this.state;
const { openDrawer, contentOutlineVisible } = this.state;
const styles = getStyles(theme);
const showPanels = queryResponse && queryResponse.state !== LoadingState.NotStarted;
const showRichHistory = openDrawer === ExploreDrawer.RichHistory;
@ -508,85 +559,118 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
}
return (
<>
<ExploreToolbar exploreId={exploreId} onChangeTime={this.onChangeTime} />
<CustomScrollbar
testId={selectors.pages.Explore.General.scrollView}
scrollRefCallback={(scrollElement) => (this.scrollElement = scrollElement || undefined)}
<ContentOutlineContextProvider>
<ExploreToolbar
exploreId={exploreId}
onChangeTime={this.onChangeTime}
onContentOutlineToogle={this.onContentOutlineToogle}
isContentOutlineOpen={contentOutlineVisible}
/>
<div
style={{
position: 'relative',
height: '100%',
paddingLeft: theme.spacing(2),
}}
>
{datasourceInstance ? (
<div className={styles.exploreContainer} ref={this.topOfViewRef}>
<PanelContainer className={styles.queryContainer}>
{correlationsBox}
<QueryRows exploreId={exploreId} />
<SecondaryActions
// do not allow people to add queries with potentially different datasources in correlations editor mode
addQueryRowButtonDisabled={isLive || (isCorrelationsEditorMode && datasourceInstance.meta.mixed)}
// We cannot show multiple traces at the same time right now so we do not show add query button.
//TODO:unification
addQueryRowButtonHidden={false}
richHistoryRowButtonHidden={richHistoryRowButtonHidden}
richHistoryButtonActive={showRichHistory}
queryInspectorButtonActive={showQueryInspector}
onClickAddQueryRowButton={this.onClickAddQueryRowButton}
onClickRichHistoryButton={this.toggleShowRichHistory}
onClickQueryInspectorButton={this.toggleShowQueryInspector}
/>
<ResponseErrorContainer exploreId={exploreId} />
</PanelContainer>
<AutoSizer onResize={this.onResize} disableHeight>
{({ width }) => {
if (width === 0) {
return null;
}
<div className={styles.wrapper}>
{contentOutlineVisible && (
<div className={styles.left}>
<ContentOutline scroller={this.scrollElement} panelId={`content-outline-container-${exploreId}`} />
</div>
)}
<CustomScrollbar
testId={selectors.pages.Explore.General.scrollView}
scrollRefCallback={(scrollElement) => (this.scrollElement = scrollElement || undefined)}
hideHorizontalTrack
>
<div className={styles.exploreContainer} ref={this.topOfViewRef}>
{datasourceInstance ? (
<>
<ContentOutlineItem title="Queries" icon="arrow">
<PanelContainer className={styles.queryContainer}>
{correlationsBox}
<QueryRows exploreId={exploreId} />
<SecondaryActions
// do not allow people to add queries with potentially different datasources in correlations editor mode
addQueryRowButtonDisabled={
isLive || (isCorrelationsEditorMode && datasourceInstance.meta.mixed)
}
// We cannot show multiple traces at the same time right now so we do not show add query button.
//TODO:unification
addQueryRowButtonHidden={false}
richHistoryRowButtonHidden={richHistoryRowButtonHidden}
richHistoryButtonActive={showRichHistory}
queryInspectorButtonActive={showQueryInspector}
onClickAddQueryRowButton={this.onClickAddQueryRowButton}
onClickRichHistoryButton={this.toggleShowRichHistory}
onClickQueryInspectorButton={this.toggleShowQueryInspector}
/>
<ResponseErrorContainer exploreId={exploreId} />
</PanelContainer>
</ContentOutlineItem>
<AutoSizer onResize={this.onResize} disableHeight>
{({ width }) => {
if (width === 0) {
return null;
}
return (
<main className={cx(styles.exploreMain)} style={{ width }}>
<ErrorBoundaryAlert>
{showPanels && (
<>
{showMetrics && graphResult && (
<ErrorBoundaryAlert>{this.renderGraphPanel(width)}</ErrorBoundaryAlert>
)}
{showRawPrometheus && (
<ErrorBoundaryAlert>{this.renderRawPrometheus(width)}</ErrorBoundaryAlert>
)}
{showTable && <ErrorBoundaryAlert>{this.renderTablePanel(width)}</ErrorBoundaryAlert>}
{showLogs && <ErrorBoundaryAlert>{this.renderLogsPanel(width)}</ErrorBoundaryAlert>}
{showNodeGraph && <ErrorBoundaryAlert>{this.renderNodeGraphPanel()}</ErrorBoundaryAlert>}
{showFlameGraph && <ErrorBoundaryAlert>{this.renderFlameGraphPanel()}</ErrorBoundaryAlert>}
{showTrace && <ErrorBoundaryAlert>{this.renderTraceViewPanel()}</ErrorBoundaryAlert>}
{showLogsSample && <ErrorBoundaryAlert>{this.renderLogsSamplePanel()}</ErrorBoundaryAlert>}
{showCustom && <ErrorBoundaryAlert>{this.renderCustom(width)}</ErrorBoundaryAlert>}
{showNoData && <ErrorBoundaryAlert>{this.renderNoData()}</ErrorBoundaryAlert>}
</>
)}
{showRichHistory && (
<RichHistoryContainer
width={width}
exploreId={exploreId}
onClose={this.toggleShowRichHistory}
/>
)}
{showQueryInspector && (
<ExploreQueryInspector
exploreId={exploreId}
width={width}
onClose={this.toggleShowQueryInspector}
timeZone={timeZone}
/>
)}
</ErrorBoundaryAlert>
</main>
);
}}
</AutoSizer>
</div>
) : (
this.renderEmptyState(styles.exploreContainer)
)}
</CustomScrollbar>
</>
return (
<main className={cx(styles.exploreMain)} style={{ width }}>
<ErrorBoundaryAlert>
{showPanels && (
<>
{showMetrics && graphResult && (
<ErrorBoundaryAlert>{this.renderGraphPanel(width)}</ErrorBoundaryAlert>
)}
{showRawPrometheus && (
<ErrorBoundaryAlert>{this.renderRawPrometheus(width)}</ErrorBoundaryAlert>
)}
{showTable && <ErrorBoundaryAlert>{this.renderTablePanel(width)}</ErrorBoundaryAlert>}
{showLogs && <ErrorBoundaryAlert>{this.renderLogsPanel(width)}</ErrorBoundaryAlert>}
{showNodeGraph && (
<ErrorBoundaryAlert>{this.renderNodeGraphPanel()}</ErrorBoundaryAlert>
)}
{showFlameGraph && (
<ErrorBoundaryAlert>{this.renderFlameGraphPanel()}</ErrorBoundaryAlert>
)}
{showTrace && <ErrorBoundaryAlert>{this.renderTraceViewPanel()}</ErrorBoundaryAlert>}
{showLogsSample && (
<ErrorBoundaryAlert>{this.renderLogsSamplePanel()}</ErrorBoundaryAlert>
)}
{showCustom && <ErrorBoundaryAlert>{this.renderCustom(width)}</ErrorBoundaryAlert>}
{showNoData && <ErrorBoundaryAlert>{this.renderNoData()}</ErrorBoundaryAlert>}
</>
)}
{showRichHistory && (
<RichHistoryContainer
width={width}
exploreId={exploreId}
onClose={this.toggleShowRichHistory}
/>
)}
{showQueryInspector && (
<ExploreQueryInspector
exploreId={exploreId}
width={width}
onClose={this.toggleShowQueryInspector}
timeZone={timeZone}
/>
)}
</ErrorBoundaryAlert>
</main>
);
}}
</AutoSizer>
</>
) : (
this.renderEmptyState(styles.exploreContainer)
)}
</div>
</CustomScrollbar>
</div>
</div>
</ContentOutlineContextProvider>
);
}
}

View File

@ -4,7 +4,7 @@ import { connect } from 'react-redux';
import { EventBusSrv, GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { useStyles2 } from '@grafana/ui';
import { useStyles2, CustomScrollbar } from '@grafana/ui';
import { stopQueryState } from 'app/core/utils/explore';
import { StoreState, useSelector } from 'app/types';
@ -14,14 +14,11 @@ import { getExploreItemSelector } from './state/selectors';
const getStyles = (theme: GrafanaTheme2) => {
return {
explore: css`
label: explorePaneContainer;
display: flex;
flex: 1 1 auto;
flex-direction: column;
overflow: hidden;
min-width: 600px;
& + & {
border-left: 1px dotted ${theme.colors.border.medium};
}
height: 100%;
`,
};
};
@ -51,9 +48,11 @@ function ExplorePaneContainerUnconnected({ exploreId }: Props) {
}, []);
return (
<div className={styles.explore} ref={ref} data-testid={selectors.pages.Explore.General.container}>
<Explore exploreId={exploreId} eventBus={eventBus.current} />
</div>
<CustomScrollbar>
<div className={styles.explore} ref={ref} data-testid={selectors.pages.Explore.General.container}>
<Explore exploreId={exploreId} eventBus={eventBus.current} />
</div>
</CustomScrollbar>
);
}

View File

@ -4,7 +4,7 @@ import React, { RefObject, useMemo } from 'react';
import { shallowEqual } from 'react-redux';
import { DataSourceInstanceSettings, RawTimeRange, GrafanaTheme2 } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { reportInteraction, config } from '@grafana/runtime';
import {
defaultIntervals,
PageToolbar,
@ -43,25 +43,39 @@ import { isLeftPaneSelector, isSplit, selectCorrelationDetails, selectPanesEntri
import { syncTimes, changeRefreshInterval } from './state/time';
import { LiveTailControls } from './useLiveTailControls';
const getStyles = (theme: GrafanaTheme2) => ({
const getStyles = (theme: GrafanaTheme2, splitted: Boolean) => ({
rotateIcon: css({
'> div > svg': {
transform: 'rotate(180deg)',
},
}),
toolbarButton: css({
display: 'flex',
justifyContent: 'center',
marginRight: theme.spacing(0.5),
width: splitted && theme.spacing(6),
}),
});
interface Props {
exploreId: string;
onChangeTime: (range: RawTimeRange, changedByScanner?: boolean) => void;
onContentOutlineToogle: () => void;
isContentOutlineOpen: boolean;
topOfViewRef?: RefObject<HTMLDivElement>;
}
export function ExploreToolbar({ exploreId, topOfViewRef, onChangeTime }: Props) {
export function ExploreToolbar({
exploreId,
topOfViewRef,
onChangeTime,
onContentOutlineToogle,
isContentOutlineOpen,
}: Props) {
const dispatch = useDispatch();
const styles = useStyles2(getStyles);
const splitted = useSelector(isSplit);
const styles = useStyles2(getStyles, splitted);
const timeZone = useSelector((state: StoreState) => getTimeZone(state.user));
const fiscalYearStartMonth = useSelector((state: StoreState) => getFiscalYearStartMonth(state.user));
const { refreshInterval, datasourceInstance, range, isLive, isPaused, syncedTimes } = useSelector(
@ -217,6 +231,21 @@ export function ExploreToolbar({ exploreId, topOfViewRef, onChangeTime }: Props)
<PageToolbar
aria-label={t('explore.toolbar.aria-label', 'Explore toolbar')}
leftItems={[
config.featureToggles.exploreContentOutline && (
<ToolbarButton
key="content-outline"
variant="canvas"
tooltip="Content outline"
icon="list-ui-alt"
iconOnly={splitted}
onClick={onContentOutlineToogle}
aria-expanded={isContentOutlineOpen}
aria-controls={isContentOutlineOpen ? 'content-outline-container' : undefined}
className={styles.toolbarButton}
>
Outline
</ToolbarButton>
),
<DataSourcePicker
key={`${exploreId}-ds-picker`}
mixed={!isCorrelationsEditorMode}
@ -225,7 +254,7 @@ export function ExploreToolbar({ exploreId, topOfViewRef, onChangeTime }: Props)
hideTextValue={showSmallDataSourcePicker}
width={showSmallDataSourcePicker ? 8 : undefined}
/>,
]}
].filter(Boolean)}
forceShowLeftItems
>
{[

View File

@ -3,7 +3,7 @@ import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Components } from '@grafana/e2e-selectors';
import { HorizontalGroup, ToolbarButton, useTheme2 } from '@grafana/ui';
import { ToolbarButton, useTheme2 } from '@grafana/ui';
type Props = {
addQueryRowButtonDisabled?: boolean;
@ -20,6 +20,9 @@ type Props = {
const getStyles = (theme: GrafanaTheme2) => {
return {
containerMargin: css`
display: flex;
flex-wrap: wrap;
gap: ${theme.spacing(1)};
margin-top: ${theme.spacing(2)};
`,
};
@ -30,38 +33,36 @@ export function SecondaryActions(props: Props) {
const styles = getStyles(theme);
return (
<div className={styles.containerMargin}>
<HorizontalGroup>
{!props.addQueryRowButtonHidden && (
<ToolbarButton
variant="canvas"
aria-label="Add query"
onClick={props.onClickAddQueryRowButton}
disabled={props.addQueryRowButtonDisabled}
icon="plus"
>
Add query
</ToolbarButton>
)}
{!props.richHistoryRowButtonHidden && (
<ToolbarButton
variant={props.richHistoryButtonActive ? 'active' : 'canvas'}
aria-label="Query history"
onClick={props.onClickRichHistoryButton}
data-testid={Components.QueryTab.queryHistoryButton}
icon="history"
>
Query history
</ToolbarButton>
)}
{!props.addQueryRowButtonHidden && (
<ToolbarButton
variant={props.queryInspectorButtonActive ? 'active' : 'canvas'}
aria-label="Query inspector"
onClick={props.onClickQueryInspectorButton}
icon="info-circle"
variant="canvas"
aria-label="Add query"
onClick={props.onClickAddQueryRowButton}
disabled={props.addQueryRowButtonDisabled}
icon="plus"
>
Query inspector
Add query
</ToolbarButton>
</HorizontalGroup>
)}
{!props.richHistoryRowButtonHidden && (
<ToolbarButton
variant={props.richHistoryButtonActive ? 'active' : 'canvas'}
aria-label="Query history"
onClick={props.onClickRichHistoryButton}
data-testid={Components.QueryTab.queryHistoryButton}
icon="history"
>
Query history
</ToolbarButton>
)}
<ToolbarButton
variant={props.queryInspectorButtonActive ? 'active' : 'canvas'}
aria-label="Query inspector"
onClick={props.onClickQueryInspectorButton}
icon="info-circle"
>
Query inspector
</ToolbarButton>
</div>
);
}