Commit 044ec401 by Torkel Ödegaard Committed by GitHub

Graphite: Rollup Indicator (#22738)

* WIP: Rollup indiator progress

* Progress

* Progress, can now open inspector with right tab

* changed type and made inspect

* Showing stats

* Progress

* Progress

* Getting ready for v1

* Added option and fixed some strict nulls

* Updated

* Fixed test
parent 579abad9
import { FieldConfig } from './dataFrame';
import { DataTransformerConfig } from './transformations'; import { DataTransformerConfig } from './transformations';
import { ApplyFieldOverrideOptions } from './fieldOverrides'; import { ApplyFieldOverrideOptions } from './fieldOverrides';
...@@ -15,19 +16,40 @@ export enum LoadingState { ...@@ -15,19 +16,40 @@ export enum LoadingState {
} }
export interface QueryResultMeta { export interface QueryResultMeta {
[key: string]: any; /** DatasSource Specific Values */
custom?: Record<string, any>;
// Match the result to the query /** Stats */
requestId?: string; stats?: QueryResultMetaStat[];
// Used in Explore for highlighting /** Meta Notices */
searchWords?: string[]; notices?: QueryResultMetaNotice[];
// Used in Explore to show limit applied to search result /** Used to track transformation ids that where part of the processing */
limit?: number; transformations?: string[];
// DatasSource Specific Values /**
custom?: Record<string, any>; * Legacy data source specific, should be moved to custom
* */
gmdMeta?: any[]; // used by cloudwatch
rawQuery?: string; // used by stackdriver
alignmentPeriod?: string; // used by stackdriver
query?: string; // used by azure log
searchWords?: string[]; // used by log models and loki
limit?: number; // used by log models and loki
json?: boolean; // used to keep track of old json doc values
}
export interface QueryResultMetaStat extends FieldConfig {
title: string;
value: number;
}
export interface QueryResultMetaNotice {
severity: 'info' | 'warning' | 'error';
text: string;
url?: string;
inspect?: 'meta' | 'error' | 'data' | 'stats';
} }
export interface QueryResultBase { export interface QueryResultBase {
......
...@@ -16,14 +16,31 @@ export interface PanelPluginMeta extends PluginMeta { ...@@ -16,14 +16,31 @@ export interface PanelPluginMeta extends PluginMeta {
} }
export interface PanelData { export interface PanelData {
/**
* State of the data (loading, done, error, streaming)
*/
state: LoadingState; state: LoadingState;
/** /**
* Contains data frames with field overrides applied * Contains data frames with field overrides applied
*/ */
series: DataFrame[]; series: DataFrame[];
/**
* Request contains the queries and properties sent to the datasource
*/
request?: DataQueryRequest; request?: DataQueryRequest;
/**
* Timing measurements
*/
timings?: DataQueryTimings; timings?: DataQueryTimings;
/**
* Any query errors
*/
error?: DataQueryError; error?: DataQueryError;
/** /**
* Contains the range from the request or a shifted time range if a request uses relative time * Contains the range from the request or a shifted time range if a request uses relative time
*/ */
......
...@@ -29,10 +29,9 @@ export const onUpdateDatasourceJsonDataOptionSelect = <J, S, K extends keyof J>( ...@@ -29,10 +29,9 @@ export const onUpdateDatasourceJsonDataOptionSelect = <J, S, K extends keyof J>(
export const onUpdateDatasourceJsonDataOptionChecked = <J, S, K extends keyof J>( export const onUpdateDatasourceJsonDataOptionChecked = <J, S, K extends keyof J>(
props: DataSourcePluginOptionsEditorProps<J, S>, props: DataSourcePluginOptionsEditorProps<J, S>,
key: K, key: K
val: boolean ) => (event: React.SyntheticEvent<HTMLInputElement>) => {
) => (event?: React.SyntheticEvent<HTMLInputElement>) => { updateDatasourcePluginJsonDataOption(props, key, event.currentTarget.checked);
updateDatasourcePluginJsonDataOption(props, key, val);
}; };
export const onUpdateDatasourceSecureJsonDataOptionSelect = <J, S extends {} = KeyValue>( export const onUpdateDatasourceSecureJsonDataOptionSelect = <J, S extends {} = KeyValue>(
......
...@@ -37,7 +37,7 @@ export interface Props extends Themeable { ...@@ -37,7 +37,7 @@ export interface Props extends Themeable {
height: number; height: number;
width: number; width: number;
field: FieldConfig; field: FieldConfig;
display: DisplayProcessor; display?: DisplayProcessor;
value: DisplayValue; value: DisplayValue;
orientation: VizOrientation; orientation: VizOrientation;
itemSpacing?: number; itemSpacing?: number;
......
...@@ -18,10 +18,11 @@ export interface Props { ...@@ -18,10 +18,11 @@ export interface Props {
height: number; height: number;
/** Minimal column width specified in pixels */ /** Minimal column width specified in pixels */
columnMinWidth?: number; columnMinWidth?: number;
noHeader?: boolean;
onCellClick?: TableFilterActionCallback; onCellClick?: TableFilterActionCallback;
} }
export const Table: FC<Props> = memo(({ data, height, onCellClick, width, columnMinWidth }) => { export const Table: FC<Props> = memo(({ data, height, onCellClick, width, columnMinWidth, noHeader }) => {
const theme = useTheme(); const theme = useTheme();
const [ref, headerRowMeasurements] = useMeasure(); const [ref, headerRowMeasurements] = useMeasure();
const tableStyles = getTableStyles(theme); const tableStyles = getTableStyles(theme);
...@@ -67,15 +68,17 @@ export const Table: FC<Props> = memo(({ data, height, onCellClick, width, column ...@@ -67,15 +68,17 @@ export const Table: FC<Props> = memo(({ data, height, onCellClick, width, column
return ( return (
<div {...getTableProps()} className={tableStyles.table}> <div {...getTableProps()} className={tableStyles.table}>
<CustomScrollbar> <CustomScrollbar>
<div> {!noHeader && (
{headerGroups.map((headerGroup: any) => ( <div>
<div className={tableStyles.thead} {...headerGroup.getHeaderGroupProps()} ref={ref}> {headerGroups.map((headerGroup: any) => (
{headerGroup.headers.map((column: any) => <div className={tableStyles.thead} {...headerGroup.getHeaderGroupProps()} ref={ref}>
renderHeaderCell(column, tableStyles.headerCell, data.fields[column.index]) {headerGroup.headers.map((column: any) =>
)} renderHeaderCell(column, tableStyles.headerCell, data.fields[column.index])
</div> )}
))} </div>
</div> ))}
</div>
)}
<FixedSizeList <FixedSizeList
height={height - headerRowMeasurements.height} height={height - headerRowMeasurements.height}
itemCount={rows.length} itemCount={rows.length}
......
import React, { FC } from 'react'; import React, { FC } from 'react';
import { css } from 'emotion'; import { css } from 'emotion';
import { Icon, selectThemeVariant, stylesFactory, Tab, TabsBar, useTheme } from '@grafana/ui'; import { Icon, selectThemeVariant, stylesFactory, Tab, TabsBar, useTheme } from '@grafana/ui';
import { GrafanaTheme, SelectableValue } from '@grafana/data'; import { GrafanaTheme, SelectableValue, PanelData, getValueFormat, formattedValueToString } from '@grafana/data';
import { InspectTab } from './PanelInspector'; import { InspectTab } from './PanelInspector';
import { PanelModel } from '../../state'; import { PanelModel } from '../../state';
interface Props { interface Props {
tab: InspectTab; tab: InspectTab;
tabs: Array<{ label: string; value: InspectTab }>; tabs: Array<{ label: string; value: InspectTab }>;
stats: { requestTime: number; queries: number; dataSources: number }; panelData: PanelData;
panel: PanelModel; panel: PanelModel;
isExpanded: boolean; isExpanded: boolean;
onSelectTab: (tab: SelectableValue<InspectTab>) => void; onSelectTab: (tab: SelectableValue<InspectTab>) => void;
onClose: () => void; onClose: () => void;
onToggleExpand: () => void; onToggleExpand: () => void;
...@@ -24,7 +23,7 @@ export const InspectHeader: FC<Props> = ({ ...@@ -24,7 +23,7 @@ export const InspectHeader: FC<Props> = ({
onClose, onClose,
onToggleExpand, onToggleExpand,
panel, panel,
stats, panelData,
isExpanded, isExpanded,
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
...@@ -42,7 +41,7 @@ export const InspectHeader: FC<Props> = ({ ...@@ -42,7 +41,7 @@ export const InspectHeader: FC<Props> = ({
</div> </div>
<div className={styles.titleWrapper}> <div className={styles.titleWrapper}>
<h3>{panel.title}</h3> <h3>{panel.title}</h3>
<div>{formatStats(stats)}</div> <div className="muted">{formatStats(panelData)}</div>
</div> </div>
<TabsBar className={styles.tabsBar}> <TabsBar className={styles.tabsBar}>
{tabs.map((t, index) => { {tabs.map((t, index) => {
...@@ -95,10 +94,15 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => { ...@@ -95,10 +94,15 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
}; };
}); });
function formatStats(stats: { requestTime: number; queries: number; dataSources: number }) { function formatStats(panelData: PanelData) {
const queries = `${stats.queries} ${stats.queries === 1 ? 'query' : 'queries'}`; const { request } = panelData;
const dataSources = `${stats.dataSources} ${stats.dataSources === 1 ? 'data source' : 'data sources'}`; if (!request) {
const requestTime = `${stats.requestTime === -1 ? 'N/A' : stats.requestTime}ms`; return '';
}
const queryCount = request.targets.length;
const requestTime = request.endTime ? request.endTime - request.startTime : 0;
const formatted = formattedValueToString(getValueFormat('ms')(requestTime));
return `${queries} - ${dataSources} - ${requestTime}`; return `${queryCount} queries with total query time of ${formatted}`;
} }
...@@ -16,7 +16,9 @@ import { ...@@ -16,7 +16,9 @@ import {
toCSV, toCSV,
DataQueryError, DataQueryError,
PanelData, PanelData,
DataQuery, getValueFormat,
formattedValueToString,
QueryResultMetaStat,
} from '@grafana/data'; } from '@grafana/data';
import { config } from 'app/core/config'; import { config } from 'app/core/config';
...@@ -51,8 +53,6 @@ interface State { ...@@ -51,8 +53,6 @@ interface State {
// If the datasource supports custom metadata // If the datasource supports custom metadata
metaDS?: DataSourceApi; metaDS?: DataSourceApi;
stats: { requestTime: number; queries: number; dataSources: number; processingTime: number };
drawerWidth: string; drawerWidth: string;
} }
...@@ -65,7 +65,6 @@ export class PanelInspector extends PureComponent<Props, State> { ...@@ -65,7 +65,6 @@ export class PanelInspector extends PureComponent<Props, State> {
selected: 0, selected: 0,
tab: props.selectedTab || InspectTab.Data, tab: props.selectedTab || InspectTab.Data,
drawerWidth: '50%', drawerWidth: '50%',
stats: { requestTime: 0, queries: 0, dataSources: 0, processingTime: 0 },
}; };
} }
...@@ -89,23 +88,13 @@ export class PanelInspector extends PureComponent<Props, State> { ...@@ -89,23 +88,13 @@ export class PanelInspector extends PureComponent<Props, State> {
const error = lastResult.error; const error = lastResult.error;
const targets = lastResult.request?.targets || []; const targets = lastResult.request?.targets || [];
const requestTime = lastResult.request?.endTime ? lastResult.request?.endTime - lastResult.request.startTime : -1;
const dataSources = new Set(targets.map(t => t.datasource)).size;
const processingTime = lastResult.timings?.dataProcessingTime || -1;
// Find the first DataSource wanting to show custom metadata // Find the first DataSource wanting to show custom metadata
if (data && targets.length) { if (data && targets.length) {
const queries: Record<string, DataQuery> = {};
for (const target of targets) {
queries[target.refId] = target;
}
for (const frame of data) { for (const frame of data) {
const q = queries[frame.refId]; if (frame.meta && frame.meta.custom) {
// get data source from first query
if (q && frame.meta && frame.meta.custom) { const dataSource = await getDataSourceSrv().get(targets[0].datasource);
const dataSource = await getDataSourceSrv().get(q.datasource);
if (dataSource && dataSource.components?.MetadataInspector) { if (dataSource && dataSource.components?.MetadataInspector) {
metaDS = dataSource; metaDS = dataSource;
...@@ -121,12 +110,6 @@ export class PanelInspector extends PureComponent<Props, State> { ...@@ -121,12 +110,6 @@ export class PanelInspector extends PureComponent<Props, State> {
data, data,
metaDS, metaDS,
tab: error ? InspectTab.Error : prevState.tab, tab: error ? InspectTab.Error : prevState.tab,
stats: {
requestTime,
queries: targets.length,
dataSources,
processingTime,
},
})); }));
} }
...@@ -184,7 +167,6 @@ export class PanelInspector extends PureComponent<Props, State> { ...@@ -184,7 +167,6 @@ export class PanelInspector extends PureComponent<Props, State> {
}; };
}); });
// Apply dummy styles
const processed = applyFieldOverrides({ const processed = applyFieldOverrides({
data, data,
theme: config.theme, theme: config.theme,
...@@ -251,29 +233,66 @@ export class PanelInspector extends PureComponent<Props, State> { ...@@ -251,29 +233,66 @@ export class PanelInspector extends PureComponent<Props, State> {
} }
renderStatsTab() { renderStatsTab() {
const { stats } = this.state; const { last } = this.state;
const { request } = last;
if (!request) {
return null;
}
let stats: QueryResultMetaStat[] = [];
const requestTime = request.endTime ? request.endTime - request.startTime : -1;
const processingTime = last.timings?.dataProcessingTime || -1;
let dataRows = 0;
for (const frame of last.series) {
dataRows += frame.length;
}
stats.push({ title: 'Total request time', value: requestTime, unit: 'ms' });
stats.push({ title: 'Data processing time', value: processingTime, unit: 'ms' });
stats.push({ title: 'Number of queries', value: request.targets.length });
stats.push({ title: 'Total number rows', value: dataRows });
let dataStats: QueryResultMetaStat[] = [];
for (const series of last.series) {
if (series.meta && series.meta.stats) {
dataStats = dataStats.concat(series.meta.stats);
}
}
return (
<>
{this.renderStatsTable('Stats', stats)}
{dataStats.length && this.renderStatsTable('Data source stats', dataStats)}
</>
);
}
renderStatsTable(name: string, stats: QueryResultMetaStat[]) {
return ( return (
<table className="filter-table width-30"> <div style={{ paddingBottom: '16px' }}>
<tbody> <div className="section-heading">{name}</div>
<tr> <table className="filter-table width-30">
<td>Query time</td> <tbody>
<td>{`${stats.requestTime === -1 ? 'N/A' : stats.requestTime + 'ms'}`}</td> {stats.map(stat => {
</tr> return (
<tr> <tr>
<td>Data processing time</td> <td>{stat.title}</td>
<td>{`${ <td style={{ textAlign: 'right' }}>{formatStat(stat.value, stat.unit)}</td>
stats.processingTime === -1 </tr>
? 'N/A' );
: Math.round((stats.processingTime + Number.EPSILON) * 100) / 100 + 'ms' })}
}`}</td> </tbody>
</tr> </table>
</tbody> </div>
</table>
); );
} }
drawerHeader = () => { drawerHeader = () => {
const { tab, last, stats } = this.state; const { tab, last } = this.state;
const error = last?.error; const error = last?.error;
const tabs = []; const tabs = [];
...@@ -296,7 +315,7 @@ export class PanelInspector extends PureComponent<Props, State> { ...@@ -296,7 +315,7 @@ export class PanelInspector extends PureComponent<Props, State> {
<InspectHeader <InspectHeader
tabs={tabs} tabs={tabs}
tab={tab} tab={tab}
stats={stats} panelData={last}
onSelectTab={this.onSelectTab} onSelectTab={this.onSelectTab}
onClose={this.onDismiss} onClose={this.onDismiss}
panel={this.props.panel} panel={this.props.panel}
...@@ -327,6 +346,14 @@ export class PanelInspector extends PureComponent<Props, State> { ...@@ -327,6 +346,14 @@ export class PanelInspector extends PureComponent<Props, State> {
} }
} }
function formatStat(value: any, unit?: string): string {
if (unit) {
return formattedValueToString(getValueFormat(unit)(value));
} else {
return value;
}
}
const getStyles = stylesFactory(() => { const getStyles = stylesFactory(() => {
return { return {
toolbar: css` toolbar: css`
......
...@@ -12,6 +12,7 @@ import { PanelChromeAngular } from './PanelChromeAngular'; ...@@ -12,6 +12,7 @@ import { PanelChromeAngular } from './PanelChromeAngular';
// Actions // Actions
import { initDashboardPanel } from '../state/actions'; import { initDashboardPanel } from '../state/actions';
import { updateLocation } from 'app/core/reducers/location';
// Types // Types
import { PanelModel, DashboardModel } from '../state'; import { PanelModel, DashboardModel } from '../state';
...@@ -33,6 +34,7 @@ export interface ConnectedProps { ...@@ -33,6 +34,7 @@ export interface ConnectedProps {
export interface DispatchProps { export interface DispatchProps {
initDashboardPanel: typeof initDashboardPanel; initDashboardPanel: typeof initDashboardPanel;
updateLocation: typeof updateLocation;
} }
export type Props = OwnProps & ConnectedProps & DispatchProps; export type Props = OwnProps & ConnectedProps & DispatchProps;
...@@ -72,7 +74,7 @@ export class DashboardPanelUnconnected extends PureComponent<Props, State> { ...@@ -72,7 +74,7 @@ export class DashboardPanelUnconnected extends PureComponent<Props, State> {
}; };
renderPanel(plugin: PanelPlugin) { renderPanel(plugin: PanelPlugin) {
const { dashboard, panel, isFullscreen, isInView, isInEditMode } = this.props; const { dashboard, panel, isFullscreen, isInView, isInEditMode, updateLocation } = this.props;
return ( return (
<AutoSizer> <AutoSizer>
...@@ -105,6 +107,7 @@ export class DashboardPanelUnconnected extends PureComponent<Props, State> { ...@@ -105,6 +107,7 @@ export class DashboardPanelUnconnected extends PureComponent<Props, State> {
isInEditMode={isInEditMode} isInEditMode={isInEditMode}
width={width} width={width}
height={height} height={height}
updateLocation={updateLocation}
/> />
); );
}} }}
...@@ -170,6 +173,6 @@ const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = ( ...@@ -170,6 +173,6 @@ const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = (
}; };
}; };
const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = { initDashboardPanel }; const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = { initDashboardPanel, updateLocation };
export const DashboardPanel = connect(mapStateToProps, mapDispatchToProps)(DashboardPanelUnconnected); export const DashboardPanel = connect(mapStateToProps, mapDispatchToProps)(DashboardPanelUnconnected);
...@@ -11,6 +11,7 @@ import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel'; ...@@ -11,6 +11,7 @@ import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel';
import { profiler } from 'app/core/profiler'; import { profiler } from 'app/core/profiler';
import { getProcessedDataFrames } from '../state/runRequest'; import { getProcessedDataFrames } from '../state/runRequest';
import config from 'app/core/config'; import config from 'app/core/config';
import { updateLocation } from 'app/core/actions';
// Types // Types
import { DashboardModel, PanelModel } from '../state'; import { DashboardModel, PanelModel } from '../state';
import { PANEL_BORDER } from 'app/core/constants'; import { PANEL_BORDER } from 'app/core/constants';
...@@ -36,6 +37,7 @@ export interface Props { ...@@ -36,6 +37,7 @@ export interface Props {
isInEditMode?: boolean; isInEditMode?: boolean;
width: number; width: number;
height: number; height: number;
updateLocation: typeof updateLocation;
} }
export interface State { export interface State {
...@@ -43,8 +45,6 @@ export interface State { ...@@ -43,8 +45,6 @@ export interface State {
renderCounter: number; renderCounter: number;
errorMessage?: string; errorMessage?: string;
refreshWhenInView: boolean; refreshWhenInView: boolean;
// Current state of all events
data: PanelData; data: PanelData;
} }
...@@ -312,7 +312,7 @@ export class PanelChrome extends PureComponent<Props, State> { ...@@ -312,7 +312,7 @@ export class PanelChrome extends PureComponent<Props, State> {
} }
render() { render() {
const { dashboard, panel, isFullscreen, width, height } = this.props; const { dashboard, panel, isFullscreen, width, height, updateLocation } = this.props;
const { errorMessage, data } = this.state; const { errorMessage, data } = this.state;
const { transparent } = panel; const { transparent } = panel;
...@@ -328,14 +328,14 @@ export class PanelChrome extends PureComponent<Props, State> { ...@@ -328,14 +328,14 @@ export class PanelChrome extends PureComponent<Props, State> {
<PanelHeader <PanelHeader
panel={panel} panel={panel}
dashboard={dashboard} dashboard={dashboard}
timeInfo={data.request ? data.request.timeInfo : undefined}
title={panel.title} title={panel.title}
description={panel.description} description={panel.description}
scopedVars={panel.scopedVars} scopedVars={panel.scopedVars}
links={panel.links} links={panel.links}
error={errorMessage} error={errorMessage}
isFullscreen={isFullscreen} isFullscreen={isFullscreen}
isLoading={data.state === LoadingState.Loading} data={data}
updateLocation={updateLocation}
/> />
<ErrorBoundary> <ErrorBoundary>
{({ error }) => { {({ error }) => {
......
...@@ -17,6 +17,7 @@ import config from 'app/core/config'; ...@@ -17,6 +17,7 @@ import config from 'app/core/config';
import { DashboardModel, PanelModel } from '../state'; import { DashboardModel, PanelModel } from '../state';
import { StoreState } from 'app/types'; import { StoreState } from 'app/types';
import { LoadingState, DefaultTimeRange, PanelData, PanelPlugin, PanelEvents } from '@grafana/data'; import { LoadingState, DefaultTimeRange, PanelData, PanelPlugin, PanelEvents } from '@grafana/data';
import { updateLocation } from 'app/core/actions';
import { PANEL_BORDER } from 'app/core/constants'; import { PANEL_BORDER } from 'app/core/constants';
interface OwnProps { interface OwnProps {
...@@ -35,6 +36,7 @@ interface ConnectedProps { ...@@ -35,6 +36,7 @@ interface ConnectedProps {
interface DispatchProps { interface DispatchProps {
setPanelAngularComponent: typeof setPanelAngularComponent; setPanelAngularComponent: typeof setPanelAngularComponent;
updateLocation: typeof updateLocation;
} }
export type Props = OwnProps & ConnectedProps & DispatchProps; export type Props = OwnProps & ConnectedProps & DispatchProps;
...@@ -215,7 +217,7 @@ export class PanelChromeAngularUnconnected extends PureComponent<Props, State> { ...@@ -215,7 +217,7 @@ export class PanelChromeAngularUnconnected extends PureComponent<Props, State> {
} }
render() { render() {
const { dashboard, panel, isFullscreen, plugin, angularComponent } = this.props; const { dashboard, panel, isFullscreen, plugin, angularComponent, updateLocation } = this.props;
const { errorMessage, data, alertState } = this.state; const { errorMessage, data, alertState } = this.state;
const { transparent } = panel; const { transparent } = panel;
...@@ -238,7 +240,6 @@ export class PanelChromeAngularUnconnected extends PureComponent<Props, State> { ...@@ -238,7 +240,6 @@ export class PanelChromeAngularUnconnected extends PureComponent<Props, State> {
<PanelHeader <PanelHeader
panel={panel} panel={panel}
dashboard={dashboard} dashboard={dashboard}
timeInfo={data.request ? data.request.timeInfo : undefined}
title={panel.title} title={panel.title}
description={panel.description} description={panel.description}
scopedVars={panel.scopedVars} scopedVars={panel.scopedVars}
...@@ -246,7 +247,8 @@ export class PanelChromeAngularUnconnected extends PureComponent<Props, State> { ...@@ -246,7 +247,8 @@ export class PanelChromeAngularUnconnected extends PureComponent<Props, State> {
links={panel.links} links={panel.links}
error={errorMessage} error={errorMessage}
isFullscreen={isFullscreen} isFullscreen={isFullscreen}
isLoading={data.state === LoadingState.Loading} data={data}
updateLocation={updateLocation}
/> />
<div className={panelContentClassNames}> <div className={panelContentClassNames}>
<div ref={element => (this.element = element)} className="panel-height-helper" /> <div ref={element => (this.element = element)} className="panel-height-helper" />
...@@ -262,6 +264,6 @@ const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = ( ...@@ -262,6 +264,6 @@ const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = (
}; };
}; };
const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = { setPanelAngularComponent }; const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = { setPanelAngularComponent, updateLocation };
export const PanelChromeAngular = connect(mapStateToProps, mapDispatchToProps)(PanelChromeAngularUnconnected); export const PanelChromeAngular = connect(mapStateToProps, mapDispatchToProps)(PanelChromeAngularUnconnected);
import React, { Component } from 'react'; import React, { Component } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { isEqual } from 'lodash'; import { isEqual } from 'lodash';
import { DataLink, ScopedVars, PanelMenuItem } from '@grafana/data'; import { DataLink, ScopedVars, PanelMenuItem, PanelData, LoadingState, QueryResultMetaNotice } from '@grafana/data';
import { AngularComponent } from '@grafana/runtime'; import { AngularComponent } from '@grafana/runtime';
import { ClickOutsideWrapper } from '@grafana/ui'; import { ClickOutsideWrapper, Tooltip } from '@grafana/ui';
import { e2e } from '@grafana/e2e'; import { e2e } from '@grafana/e2e';
import PanelHeaderCorner from './PanelHeaderCorner'; import PanelHeaderCorner from './PanelHeaderCorner';
...@@ -14,11 +14,11 @@ import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; ...@@ -14,11 +14,11 @@ import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
import { PanelModel } from 'app/features/dashboard/state/PanelModel'; import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import { getPanelLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers'; import { getPanelLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers';
import { getPanelMenu } from 'app/features/dashboard/utils/getPanelMenu'; import { getPanelMenu } from 'app/features/dashboard/utils/getPanelMenu';
import { updateLocation } from 'app/core/actions';
export interface Props { export interface Props {
panel: PanelModel; panel: PanelModel;
dashboard: DashboardModel; dashboard: DashboardModel;
timeInfo?: string;
title?: string; title?: string;
description?: string; description?: string;
scopedVars?: ScopedVars; scopedVars?: ScopedVars;
...@@ -26,7 +26,8 @@ export interface Props { ...@@ -26,7 +26,8 @@ export interface Props {
links?: DataLink[]; links?: DataLink[];
error?: string; error?: string;
isFullscreen: boolean; isFullscreen: boolean;
isLoading: boolean; data: PanelData;
updateLocation: typeof updateLocation;
} }
interface ClickCoordinates { interface ClickCoordinates {
...@@ -92,8 +93,35 @@ export class PanelHeader extends Component<Props, State> { ...@@ -92,8 +93,35 @@ export class PanelHeader extends Component<Props, State> {
); );
} }
openInspect = (e: React.SyntheticEvent, tab: string) => {
const { updateLocation, panel } = this.props;
e.stopPropagation();
updateLocation({
query: { inspect: panel.id, tab },
partial: true,
});
};
renderNotice = (notice: QueryResultMetaNotice) => {
return (
<Tooltip content={notice.text} key={notice.severity}>
{notice.inspect ? (
<div className="panel-info-notice" onClick={e => this.openInspect(e, notice.inspect)}>
<span className="fa fa-info-circle" style={{ marginRight: '8px', cursor: 'pointer' }} />
</div>
) : (
<a className="panel-info-notice" href={notice.url} target="_blank">
<span className="fa fa-info-circle" style={{ marginRight: '8px', cursor: 'pointer' }} />
</a>
)}
</Tooltip>
);
};
render() { render() {
const { panel, timeInfo, scopedVars, error, isFullscreen, isLoading } = this.props; const { panel, scopedVars, error, isFullscreen, data } = this.props;
const { menuItems } = this.state; const { menuItems } = this.state;
const title = templateSrv.replaceWithText(panel.title, scopedVars); const title = templateSrv.replaceWithText(panel.title, scopedVars);
...@@ -102,9 +130,20 @@ export class PanelHeader extends Component<Props, State> { ...@@ -102,9 +130,20 @@ export class PanelHeader extends Component<Props, State> {
'grid-drag-handle': !isFullscreen, 'grid-drag-handle': !isFullscreen,
}); });
// dedupe on severity
const notices: Record<string, QueryResultMetaNotice> = {};
for (const series of data.series) {
if (series.meta && series.meta.notices) {
for (const notice of series.meta.notices) {
notices[notice.severity] = notice;
}
}
}
return ( return (
<> <>
{isLoading && this.renderLoadingState()} {data.state === LoadingState.Loading && this.renderLoadingState()}
<div className={panelHeaderClass}> <div className={panelHeaderClass}>
<PanelHeaderCorner <PanelHeaderCorner
panel={panel} panel={panel}
...@@ -121,6 +160,7 @@ export class PanelHeader extends Component<Props, State> { ...@@ -121,6 +160,7 @@ export class PanelHeader extends Component<Props, State> {
aria-label={e2e.pages.Dashboard.Panels.Panel.selectors.title(title)} aria-label={e2e.pages.Dashboard.Panels.Panel.selectors.title(title)}
> >
<div className="panel-title"> <div className="panel-title">
{Object.values(notices).map(this.renderNotice)}
<span className="icon-gf panel-alert-icon" /> <span className="icon-gf panel-alert-icon" />
<span className="panel-title-text"> <span className="panel-title-text">
{title} <span className="fa fa-caret-down panel-menu-toggle" /> {title} <span className="fa fa-caret-down panel-menu-toggle" />
...@@ -130,9 +170,9 @@ export class PanelHeader extends Component<Props, State> { ...@@ -130,9 +170,9 @@ export class PanelHeader extends Component<Props, State> {
<PanelHeaderMenu items={menuItems} /> <PanelHeaderMenu items={menuItems} />
</ClickOutsideWrapper> </ClickOutsideWrapper>
)} )}
{timeInfo && ( {data.request && data.request.timeInfo && (
<span className="panel-time-info"> <span className="panel-time-info">
<i className="fa fa-clock-o" /> {timeInfo} <i className="fa fa-clock-o" /> {data.request.timeInfo}
</span> </span>
)} )}
</div> </div>
......
import { css, cx } from 'emotion';
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import { MetadataInspectorProps, DataFrame } from '@grafana/data'; import { MetadataInspectorProps } from '@grafana/data';
import { GraphiteDatasource } from './datasource'; import { GraphiteDatasource } from './datasource';
import { GraphiteQuery, GraphiteOptions, MetricTankMeta, MetricTankResultMeta } from './types'; import { GraphiteQuery, GraphiteOptions, MetricTankSeriesMeta } from './types';
import { parseSchemaRetentions } from './meta'; import { parseSchemaRetentions, getRollupNotice, getRuntimeConsolidationNotice } from './meta';
import { stylesFactory } from '@grafana/ui';
import { config } from 'app/core/config';
import kbn from 'app/core/utils/kbn';
export type Props = MetadataInspectorProps<GraphiteDatasource, GraphiteQuery, GraphiteOptions>; export type Props = MetadataInspectorProps<GraphiteDatasource, GraphiteQuery, GraphiteOptions>;
...@@ -11,48 +15,159 @@ export interface State { ...@@ -11,48 +15,159 @@ export interface State {
} }
export class MetricTankMetaInspector extends PureComponent<Props, State> { export class MetricTankMetaInspector extends PureComponent<Props, State> {
state = { index: 0 }; renderMeta(meta: MetricTankSeriesMeta, key: string) {
const styles = getStyles();
const buckets = parseSchemaRetentions(meta['schema-retentions']);
const rollupNotice = getRollupNotice([meta]);
const runtimeNotice = getRuntimeConsolidationNotice([meta]);
const normFunc = (meta['consolidate-normfetch'] || '').replace('Consolidator', '');
let totalSeconds = 0;
for (const bucket of buckets) {
totalSeconds += kbn.interval_to_seconds(bucket.retention);
}
renderInfo = (info: MetricTankResultMeta, frame: DataFrame) => {
const buckets = parseSchemaRetentions(info['schema-retentions']);
return ( return (
<div> <div className={styles.metaItem} key={key}>
<h3>Info</h3> <div className={styles.metaItemHeader}>Schema: {meta['schema-name']}</div>
<table> <div className={styles.metaItemBody}>
<tbody> <div className={styles.step}>
{buckets.map(row => ( <div className={styles.stepHeading}>Step 1: Fetch</div>
<tr key={row.interval}> <div className={styles.stepDescription}>
<td>{row.interval} &nbsp;</td> First data is fetched, either from raw data archive or a rollup archive
<td>{row.retention} &nbsp;</td> </div>
<td>{row.chunkspan} &nbsp;</td>
<td>{row.numchunks} &nbsp;</td> {rollupNotice && <p>{rollupNotice.text}</p>}
<td>{row.ready} &nbsp;</td> {!rollupNotice && <p>No rollup archive was used</p>}
</tr>
))} <div>
</tbody> {buckets.map((bucket, index) => {
</table> const bucketLength = kbn.interval_to_seconds(bucket.retention);
<pre>{JSON.stringify(info, null, 2)}</pre> const lengthPercent = (bucketLength / totalSeconds) * 100;
const isActive = index === meta['archive-read'];
return (
<div key={bucket.retention} className={styles.bucket}>
<div className={styles.bucketInterval}>{bucket.interval}</div>
<div
className={cx(styles.bucketRetention, { [styles.bucketRetentionActive]: isActive })}
style={{ flexGrow: lengthPercent }}
/>
<div style={{ flexGrow: 100 - lengthPercent }}>{bucket.retention}</div>
</div>
);
})}
</div>
</div>
<div className={styles.step}>
<div className={styles.stepHeading}>Step 2: Normalization</div>
<div className={styles.stepDescription}>
Normalization happens when series with different intervals between points are combined.
</div>
{meta['aggnum-norm'] > 1 && <p>Normalization did occur using {normFunc}</p>}
{meta['aggnum-norm'] === 1 && <p>No normalization was needed</p>}
</div>
<div className={styles.step}>
<div className={styles.stepHeading}>Step 3: Runtime consolidation</div>
<div className={styles.stepDescription}>
If there are too many data points at this point Metrictank will consolidate them down to below max data
points (set in queries tab).
</div>
{runtimeNotice && <p>{runtimeNotice.text}</p>}
{!runtimeNotice && <p>No runtime consolidation</p>}
</div>
</div>
</div> </div>
); );
}; }
render() { render() {
const { data } = this.props; const { data } = this.props;
if (!data || !data.length) {
return <div>No Metadata</div>; // away to dedupe them
const seriesMetas: Record<string, MetricTankSeriesMeta> = {};
for (const series of data) {
if (series.meta && series.meta.custom) {
for (const metaItem of series.meta.custom.seriesMetaList as MetricTankSeriesMeta[]) {
// key is to dedupe as many series will have identitical meta
const key = `${metaItem['schema-name']}-${metaItem['archive-read']}`;
seriesMetas[key] = metaItem;
}
}
} }
const frame = data[this.state.index]; if (Object.keys(seriesMetas).length === 0) {
const meta = frame.meta?.custom as MetricTankMeta; return <div>No response meta data</div>;
if (!meta || !meta.info) {
return <>No Metadatata on DataFrame</>;
} }
return ( return (
<div> <div>
<h3>MetricTank Request</h3> <h2 className="page-heading">Aggregation & rollup</h2>
<pre>{JSON.stringify(meta.request, null, 2)}</pre> {Object.keys(seriesMetas).map(key => this.renderMeta(seriesMetas[key], key))}
{meta.info.map(info => this.renderInfo(info, frame))}
</div> </div>
); );
} }
} }
const getStyles = stylesFactory(() => {
const { theme } = config;
const borderColor = theme.isDark ? theme.colors.gray25 : theme.colors.gray85;
const background = theme.isDark ? theme.colors.dark1 : theme.colors.white;
const headerBg = theme.isDark ? theme.colors.gray15 : theme.colors.gray85;
return {
metaItem: css`
background: ${background};
border: 1px solid ${borderColor};
margin-bottom: ${theme.spacing.md};
`,
metaItemHeader: css`
background: ${headerBg};
padding: ${theme.spacing.xs} ${theme.spacing.md};
font-size: ${theme.typography.size.md};
`,
metaItemBody: css`
padding: ${theme.spacing.md};
`,
stepHeading: css`
font-size: ${theme.typography.size.md};
`,
stepDescription: css`
font-size: ${theme.typography.size.sm};
color: ${theme.colors.textWeak};
margin-bottom: ${theme.spacing.sm};
`,
step: css`
margin-bottom: ${theme.spacing.lg};
&:last-child {
margin-bottom: 0;
}
`,
bucket: css`
display: flex;
margin-bottom: ${theme.spacing.sm};
border-radius: ${theme.border.radius.md};
`,
bucketInterval: css`
flex-grow: 0;
width: 60px;
`,
bucketRetention: css`
background: linear-gradient(0deg, ${theme.colors.blue85}, ${theme.colors.blue95});
text-align: center;
color: ${theme.colors.white};
margin-right: ${theme.spacing.md};
border-radius: ${theme.border.radius.md};
`,
bucketRetentionActive: css`
background: linear-gradient(0deg, ${theme.colors.greenBase}, ${theme.colors.greenShade});
`,
};
});
import { css } from 'emotion';
const styles = {
helpbtn: css`
margin-left: 8px;
margin-top: 5px;
`,
};
export default styles;
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import { DataSourceHttpSettings, FormLabel, Button, Select } from '@grafana/ui'; import { DataSourceHttpSettings, FormLabel, Select, Switch } from '@grafana/ui';
import { DataSourcePluginOptionsEditorProps, onUpdateDatasourceJsonDataOptionSelect } from '@grafana/data'; import {
DataSourcePluginOptionsEditorProps,
onUpdateDatasourceJsonDataOptionSelect,
onUpdateDatasourceJsonDataOptionChecked,
} from '@grafana/data';
import { GraphiteOptions, GraphiteType } from '../types'; import { GraphiteOptions, GraphiteType } from '../types';
import styles from './ConfigEditor.styles';
const graphiteVersions = [ const graphiteVersions = [
{ label: '0.9.x', value: '0.9' }, { label: '0.9.x', value: '0.9' },
...@@ -17,22 +20,27 @@ const graphiteTypes = Object.entries(GraphiteType).map(([label, value]) => ({ ...@@ -17,22 +20,27 @@ const graphiteTypes = Object.entries(GraphiteType).map(([label, value]) => ({
export type Props = DataSourcePluginOptionsEditorProps<GraphiteOptions>; export type Props = DataSourcePluginOptionsEditorProps<GraphiteOptions>;
interface State { export class ConfigEditor extends PureComponent<Props> {
showMetricTankHelp: boolean;
}
export class ConfigEditor extends PureComponent<Props, State> {
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
this.state = {
showMetricTankHelp: false,
};
} }
renderTypeHelp = () => {
return (
<p>
There are different types of Graphite compatible backends. Here you can specify the type you are using. If you
are using{' '}
<a href="https://github.com/grafana/metrictank" className="pointer" target="_blank">
Metrictank
</a>{' '}
then select that here. This will enable Metrictank specific features like query processing meta data. Metrictank
is a multi-tenant timeseries engine for Graphite and friends.
</p>
);
};
render() { render() {
const { options, onOptionsChange } = this.props; const { options, onOptionsChange } = this.props;
const { showMetricTankHelp } = this.state;
const currentVersion = const currentVersion =
graphiteVersions.find(item => item.value === options.jsonData.graphiteVersion) ?? graphiteVersions[2]; graphiteVersions.find(item => item.value === options.jsonData.graphiteVersion) ?? graphiteVersions[2];
...@@ -61,39 +69,25 @@ export class ConfigEditor extends PureComponent<Props, State> { ...@@ -61,39 +69,25 @@ export class ConfigEditor extends PureComponent<Props, State> {
</div> </div>
<div className="gf-form-inline"> <div className="gf-form-inline">
<div className="gf-form"> <div className="gf-form">
<FormLabel>Type</FormLabel> <FormLabel tooltip={this.renderTypeHelp}>Type</FormLabel>
<Select <Select
options={graphiteTypes} options={graphiteTypes}
value={graphiteTypes.find(type => type.value === options.jsonData.graphiteType)} value={graphiteTypes.find(type => type.value === options.jsonData.graphiteType)}
width={8} width={8}
onChange={onUpdateDatasourceJsonDataOptionSelect(this.props, 'graphiteType')} onChange={onUpdateDatasourceJsonDataOptionSelect(this.props, 'graphiteType')}
/> />
<div className={styles.helpbtn}>
<Button
variant="secondary"
size="sm"
onClick={() =>
this.setState((prevState: State) => ({ showMetricTankHelp: !prevState.showMetricTankHelp }))
}
>
Help <i className={showMetricTankHelp ? 'fa fa-caret-down' : 'fa fa-caret-right'} />
</Button>
</div>
</div> </div>
</div> </div>
{showMetricTankHelp && ( {options.jsonData.graphiteType === GraphiteType.Metrictank && (
<div className="grafana-info-box m-t-2"> <div className="gf-form-inline">
<div className="alert-body"> <div className="gf-form">
<p> <Switch
There are different types of Graphite compatible backends. Here you can specify the type you are label="Rollup indicator"
using. If you are using{' '} labelClass={'width-10'}
<a href="https://github.com/grafana/metrictank" className="pointer" target="_blank"> tooltip="Shows up as an info icon in panel headers when data is aggregated"
Metrictank checked={options.jsonData.rollupIndicatorEnabled}
</a>{' '} onChange={onUpdateDatasourceJsonDataOptionChecked(this.props, 'rollupIndicatorEnabled')}
then select that here. This will enable Metrictank specific features like query processing meta data. />
Metrictank is a multi-tenant timeseries engine for Graphite and friends.
</p>
</div> </div>
</div> </div>
)} )}
......
...@@ -7,14 +7,16 @@ import { ...@@ -7,14 +7,16 @@ import {
DataQueryRequest, DataQueryRequest,
toDataFrame, toDataFrame,
DataSourceApi, DataSourceApi,
QueryResultMetaStat,
} from '@grafana/data'; } from '@grafana/data';
import { isVersionGtOrEq, SemVersion } from 'app/core/utils/version'; import { isVersionGtOrEq, SemVersion } from 'app/core/utils/version';
import gfunc from './gfunc'; import gfunc from './gfunc';
import { getBackendSrv } from '@grafana/runtime'; import { getBackendSrv } from '@grafana/runtime';
import { TemplateSrv } from 'app/features/templating/template_srv'; import { TemplateSrv } from 'app/features/templating/template_srv';
//Types // Types
import { GraphiteOptions, GraphiteQuery, GraphiteType } from './types'; import { GraphiteOptions, GraphiteQuery, GraphiteType, MetricTankRequestMeta } from './types';
import { getSearchFilterScopedVar } from '../../../features/templating/variable'; import { getSearchFilterScopedVar } from '../../../features/templating/variable';
import { getRollupNotice, getRuntimeConsolidationNotice } from 'app/plugins/datasource/graphite/meta';
export class GraphiteDatasource extends DataSourceApi<GraphiteQuery, GraphiteOptions> { export class GraphiteDatasource extends DataSourceApi<GraphiteQuery, GraphiteOptions> {
basicAuth: string; basicAuth: string;
...@@ -23,6 +25,7 @@ export class GraphiteDatasource extends DataSourceApi<GraphiteQuery, GraphiteOpt ...@@ -23,6 +25,7 @@ export class GraphiteDatasource extends DataSourceApi<GraphiteQuery, GraphiteOpt
graphiteVersion: any; graphiteVersion: any;
supportsTags: boolean; supportsTags: boolean;
isMetricTank: boolean; isMetricTank: boolean;
rollupIndicatorEnabled: boolean;
cacheTimeout: any; cacheTimeout: any;
withCredentials: boolean; withCredentials: boolean;
funcDefs: any = null; funcDefs: any = null;
...@@ -39,6 +42,7 @@ export class GraphiteDatasource extends DataSourceApi<GraphiteQuery, GraphiteOpt ...@@ -39,6 +42,7 @@ export class GraphiteDatasource extends DataSourceApi<GraphiteQuery, GraphiteOpt
this.isMetricTank = instanceSettings.jsonData.graphiteType === GraphiteType.Metrictank; this.isMetricTank = instanceSettings.jsonData.graphiteType === GraphiteType.Metrictank;
this.supportsTags = supportsTags(this.graphiteVersion); this.supportsTags = supportsTags(this.graphiteVersion);
this.cacheTimeout = instanceSettings.cacheTimeout; this.cacheTimeout = instanceSettings.cacheTimeout;
this.rollupIndicatorEnabled = instanceSettings.jsonData.rollupIndicatorEnabled;
this.withCredentials = instanceSettings.withCredentials; this.withCredentials = instanceSettings.withCredentials;
this.funcDefs = null; this.funcDefs = null;
this.funcDefsPromise = null; this.funcDefsPromise = null;
...@@ -108,33 +112,71 @@ export class GraphiteDatasource extends DataSourceApi<GraphiteQuery, GraphiteOpt ...@@ -108,33 +112,71 @@ export class GraphiteDatasource extends DataSourceApi<GraphiteQuery, GraphiteOpt
if (!result || !result.data) { if (!result || !result.data) {
return { data }; return { data };
} }
// Series are either at the root or under a node called 'series' // Series are either at the root or under a node called 'series'
const series = result.data.series || result.data; const series = result.data.series || result.data;
if (!_.isArray(series)) { if (!_.isArray(series)) {
throw { message: 'Missing series in result', data: result }; throw { message: 'Missing series in result', data: result };
} }
for (let i = 0; i < series.length; i++) { for (let i = 0; i < series.length; i++) {
const s = series[i]; const s = series[i];
for (let y = 0; y < s.datapoints.length; y++) { for (let y = 0; y < s.datapoints.length; y++) {
s.datapoints[y][1] *= 1000; s.datapoints[y][1] *= 1000;
} }
const frame = toDataFrame(s); const frame = toDataFrame(s);
// Metrictank metadata // Metrictank metadata
if (s.meta) { if (s.meta) {
frame.meta = { frame.meta = {
custom: { custom: {
request: result.data.meta, // info for the whole request requestMetaList: result.data.meta, // info for the whole request
info: s.meta, // Array of metadata seriesMetaList: s.meta, // Array of metadata
}, },
}; };
if (this.rollupIndicatorEnabled) {
const rollupNotice = getRollupNotice(s.meta);
const runtimeNotice = getRuntimeConsolidationNotice(s.meta);
if (rollupNotice) {
frame.meta.notices = [rollupNotice];
} else if (runtimeNotice) {
frame.meta.notices = [runtimeNotice];
}
}
// only add the request stats to the first frame
if (i === 0 && result.data.meta.stats) {
frame.meta.stats = this.getRequestStats(result.data.meta);
}
} }
data.push(frame); data.push(frame);
} }
return { data }; return { data };
}; };
getRequestStats(meta: MetricTankRequestMeta): QueryResultMetaStat[] {
const stats: QueryResultMetaStat[] = [];
for (const key in meta.stats) {
let unit: string | undefined = undefined;
if (key.endsWith('.ms')) {
unit = 'ms';
}
stats.push({ title: key, value: meta.stats[key], unit });
}
return stats;
}
parseTags(tagString: string) { parseTags(tagString: string) {
let tags: string[] = []; let tags: string[] = [];
tags = tagString.split(','); tags = tagString.split(',');
...@@ -278,7 +320,7 @@ export class GraphiteDatasource extends DataSourceApi<GraphiteQuery, GraphiteOpt ...@@ -278,7 +320,7 @@ export class GraphiteDatasource extends DataSourceApi<GraphiteQuery, GraphiteOpt
return date.unix(); return date.unix();
} }
metricFindQuery(query: string, optionalOptions: any) { metricFindQuery(query: string, optionalOptions?: any) {
const options: any = optionalOptions || {}; const options: any = optionalOptions || {};
let interpolatedQuery = this.templateSrv.replace( let interpolatedQuery = this.templateSrv.replace(
query, query,
...@@ -573,7 +615,7 @@ export class GraphiteDatasource extends DataSourceApi<GraphiteQuery, GraphiteOpt ...@@ -573,7 +615,7 @@ export class GraphiteDatasource extends DataSourceApi<GraphiteQuery, GraphiteOpt
return getBackendSrv().datasourceRequest(options); return getBackendSrv().datasourceRequest(options);
} }
buildGraphiteParams(options: any, scopedVars: ScopedVars): string[] { buildGraphiteParams(options: any, scopedVars?: ScopedVars): string[] {
const graphiteOptions = ['from', 'until', 'rawData', 'format', 'maxDataPoints', 'cacheTimeout']; const graphiteOptions = ['from', 'until', 'rawData', 'format', 'maxDataPoints', 'cacheTimeout'];
const cleanOptions = [], const cleanOptions = [],
targets: any = {}; targets: any = {};
......
export interface MetricTankResultMeta { import { MetricTankSeriesMeta } from './types';
'schema-name': string; import { QueryResultMetaNotice } from '@grafana/data';
'schema-retentions': string; //"1s:35d:20min:5:1542274085,1min:38d:2h:1:true,10min:120d:6h:1:true,2h:2y:6h:2",
}
// https://github.com/grafana/metrictank/blob/master/scripts/config/storage-schemas.conf#L15-L46 // https://github.com/grafana/metrictank/blob/master/scripts/config/storage-schemas.conf#L15-L46
...@@ -19,6 +17,7 @@ function toInteger(val?: string): number | undefined { ...@@ -19,6 +17,7 @@ function toInteger(val?: string): number | undefined {
} }
return undefined; return undefined;
} }
function toBooleanOrTimestamp(val?: string): number | boolean | undefined { function toBooleanOrTimestamp(val?: string): number | boolean | undefined {
if (val) { if (val) {
if (val === 'true') { if (val === 'true') {
...@@ -32,6 +31,44 @@ function toBooleanOrTimestamp(val?: string): number | boolean | undefined { ...@@ -32,6 +31,44 @@ function toBooleanOrTimestamp(val?: string): number | boolean | undefined {
return undefined; return undefined;
} }
export function getRollupNotice(metaList: MetricTankSeriesMeta[]): QueryResultMetaNotice | null {
for (const meta of metaList) {
const archiveIndex = meta['archive-read'];
if (archiveIndex > 0) {
const schema = parseSchemaRetentions(meta['schema-retentions']);
const intervalString = schema[archiveIndex].interval;
const func = meta['consolidate-normfetch'].replace('Consolidator', '');
return {
text: `Data is rolled up, aggregated over ${intervalString} using ${func} function`,
severity: 'info',
inspect: 'meta',
};
}
}
return null;
}
export function getRuntimeConsolidationNotice(metaList: MetricTankSeriesMeta[]): QueryResultMetaNotice | null {
for (const meta of metaList) {
const runtimeNr = meta['aggnum-rc'];
if (runtimeNr > 0) {
const func = meta['consolidate-rc'].replace('Consolidator', '');
return {
text: `Data is runtime consolidated, ${runtimeNr} datapoints combined using ${func} function`,
severity: 'info',
inspect: 'meta',
};
}
}
return null;
}
export function parseSchemaRetentions(spec: string): RetentionInfo[] { export function parseSchemaRetentions(spec: string): RetentionInfo[] {
if (!spec) { if (!spec) {
return []; return [];
......
...@@ -10,19 +10,94 @@ jest.mock('@grafana/runtime', () => ({ ...@@ -10,19 +10,94 @@ jest.mock('@grafana/runtime', () => ({
getBackendSrv: () => backendSrv, getBackendSrv: () => backendSrv,
})); }));
interface Context {
templateSrv: TemplateSrv;
ds: GraphiteDatasource;
}
describe('graphiteDatasource', () => { describe('graphiteDatasource', () => {
const datasourceRequestMock = jest.spyOn(backendSrv, 'datasourceRequest'); const datasourceRequestMock = jest.spyOn(backendSrv, 'datasourceRequest');
const ctx: any = { let ctx = {} as Context;
// @ts-ignore
templateSrv: new TemplateSrv(),
instanceSettings: { url: 'url', name: 'graphiteProd', jsonData: {} },
};
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
ctx.instanceSettings.url = '/api/datasources/proxy/1';
ctx.ds = new GraphiteDatasource(ctx.instanceSettings, ctx.templateSrv); const instanceSettings = {
url: '/api/datasources/proxy/1',
name: 'graphiteProd',
jsonData: {
rollupIndicatorEnabled: true,
},
};
const templateSrv = new TemplateSrv();
const ds = new GraphiteDatasource(instanceSettings, templateSrv);
ctx = { templateSrv, ds };
});
describe('convertResponseToDataFrames', () => {
it('should transform regular result', () => {
const result = ctx.ds.convertResponseToDataFrames({
data: {
meta: {
stats: {
'executeplan.cache-hit-partial.count': 5,
'executeplan.cache-hit.count': 10,
},
},
series: [
{
target: 'seriesA',
datapoints: [
[100, 200],
[101, 201],
],
meta: [
{
'aggnum-norm': 1,
'aggnum-rc': 7,
'archive-interval': 3600,
'archive-read': 1,
'consolidate-normfetch': 'AverageConsolidator',
'consolidate-rc': 'AverageConsolidator',
count: 1,
'schema-name': 'wpUsageMetrics',
'schema-retentions': '1h:35d:6h:2,2h:2y:6h:2',
},
],
},
{
target: 'seriesB',
meta: [
{
'aggnum-norm': 1,
'aggnum-rc': 0,
'archive-interval': 3600,
'archive-read': 0,
'consolidate-normfetch': 'AverageConsolidator',
'consolidate-rc': 'NoneConsolidator',
count: 1,
'schema-name': 'wpUsageMetrics',
'schema-retentions': '1h:35d:6h:2,2h:2y:6h:2',
},
],
datapoints: [
[200, 300],
[201, 301],
],
},
],
},
});
expect(result.data.length).toBe(2);
expect(result.data[0].name).toBe('seriesA');
expect(result.data[1].name).toBe('seriesB');
expect(result.data[0].length).toBe(2);
expect(result.data[0].meta.notices.length).toBe(1);
expect(result.data[0].meta.notices[0].text).toBe('Data is rolled up, aggregated over 2h using Average function');
expect(result.data[1].meta.notices).toBeUndefined();
});
}); });
describe('When querying graphite with one target using query editor target spec', () => { describe('When querying graphite with one target using query editor target spec', () => {
...@@ -53,7 +128,7 @@ describe('graphiteDatasource', () => { ...@@ -53,7 +128,7 @@ describe('graphiteDatasource', () => {
}); });
}); });
await ctx.ds.query(query).then((data: any) => { await ctx.ds.query(query as any).then((data: any) => {
results = data; results = data;
}); });
}); });
...@@ -233,7 +308,7 @@ describe('graphiteDatasource', () => { ...@@ -233,7 +308,7 @@ describe('graphiteDatasource', () => {
describe('when formatting targets', () => { describe('when formatting targets', () => {
it('does not attempt to glob for one variable', () => { it('does not attempt to glob for one variable', () => {
ctx.ds.templateSrv.init([ ctx.templateSrv.init([
{ {
type: 'query', type: 'query',
name: 'metric', name: 'metric',
...@@ -248,7 +323,7 @@ describe('graphiteDatasource', () => { ...@@ -248,7 +323,7 @@ describe('graphiteDatasource', () => {
}); });
it('globs for more than one variable', () => { it('globs for more than one variable', () => {
ctx.ds.templateSrv.init([ ctx.templateSrv.init([
{ {
type: 'query', type: 'query',
name: 'metric', name: 'metric',
...@@ -259,6 +334,7 @@ describe('graphiteDatasource', () => { ...@@ -259,6 +334,7 @@ describe('graphiteDatasource', () => {
const results = ctx.ds.buildGraphiteParams({ const results = ctx.ds.buildGraphiteParams({
targets: [{ target: 'my.[[metric]].*' }], targets: [{ target: 'my.[[metric]].*' }],
}); });
expect(results).toStrictEqual(['target=my.%7Ba%2Cb%7D.*', 'format=json']); expect(results).toStrictEqual(['target=my.%7Ba%2Cb%7D.*', 'format=json']);
}); });
}); });
...@@ -352,7 +428,7 @@ describe('graphiteDatasource', () => { ...@@ -352,7 +428,7 @@ describe('graphiteDatasource', () => {
}); });
it('/metrics/find should be POST', () => { it('/metrics/find should be POST', () => {
ctx.ds.templateSrv.init([ ctx.templateSrv.init([
{ {
type: 'query', type: 'query',
name: 'foo', name: 'foo',
......
...@@ -7,6 +7,7 @@ export interface GraphiteQuery extends DataQuery { ...@@ -7,6 +7,7 @@ export interface GraphiteQuery extends DataQuery {
export interface GraphiteOptions extends DataSourceJsonData { export interface GraphiteOptions extends DataSourceJsonData {
graphiteVersion: string; graphiteVersion: string;
graphiteType: GraphiteType; graphiteType: GraphiteType;
rollupIndicatorEnabled?: boolean;
} }
export enum GraphiteType { export enum GraphiteType {
...@@ -15,10 +16,10 @@ export enum GraphiteType { ...@@ -15,10 +16,10 @@ export enum GraphiteType {
} }
export interface MetricTankRequestMeta { export interface MetricTankRequestMeta {
[key: string]: any; // TODO -- fill this with real values from metrictank [key: string]: any;
} }
export interface MetricTankResultMeta { export interface MetricTankSeriesMeta {
'schema-name': string; 'schema-name': string;
'schema-retentions': string; //"1s:35d:20min:5:1542274085,1min:38d:2h:1:true,10min:120d:6h:1:true,2h:2y:6h:2", 'schema-retentions': string; //"1s:35d:20min:5:1542274085,1min:38d:2h:1:true,10min:120d:6h:1:true,2h:2y:6h:2",
'archive-read': number; 'archive-read': number;
...@@ -32,5 +33,5 @@ export interface MetricTankResultMeta { ...@@ -32,5 +33,5 @@ export interface MetricTankResultMeta {
export interface MetricTankMeta { export interface MetricTankMeta {
request: MetricTankRequestMeta; request: MetricTankRequestMeta;
info: MetricTankResultMeta[]; info: MetricTankSeriesMeta[];
} }
import React from 'react'; import React from 'react';
import { mount, ReactWrapper } from 'enzyme'; import { mount, ReactWrapper } from 'enzyme';
import { PanelData, dateMath, TimeRange, VizOrientation, PanelProps, LoadingState, dateTime } from '@grafana/data'; import {
PanelData,
dateMath,
TimeRange,
VizOrientation,
PanelProps,
LoadingState,
dateTime,
toDataFrame,
} from '@grafana/data';
import { BarGaugeDisplayMode } from '@grafana/ui'; import { BarGaugeDisplayMode } from '@grafana/ui';
import { BarGaugePanel } from './BarGaugePanel'; import { BarGaugePanel } from './BarGaugePanel';
...@@ -19,6 +28,27 @@ describe('BarGaugePanel', () => { ...@@ -19,6 +28,27 @@ describe('BarGaugePanel', () => {
expect(displayValue).toBe('No data'); expect(displayValue).toBe('No data');
}); });
}); });
describe('when there is data', () => {
const wrapper = createBarGaugePanelWithData({
series: [
toDataFrame({
target: 'test',
datapoints: [
[100, 1000],
[100, 200],
],
}),
],
timeRange: createTimeRange(),
state: LoadingState.Done,
});
it('should render with title "No data"', () => {
const displayValue = wrapper.find('div.bar-gauge__value').text();
expect(displayValue).toBe('100');
});
});
}); });
function createTimeRange(): TimeRange { function createTimeRange(): TimeRange {
......
...@@ -6,7 +6,7 @@ import { NewsOptions, DEFAULT_FEED_URL } from './types'; ...@@ -6,7 +6,7 @@ import { NewsOptions, DEFAULT_FEED_URL } from './types';
const PROXY_PREFIX = 'https://cors-anywhere.herokuapp.com/'; const PROXY_PREFIX = 'https://cors-anywhere.herokuapp.com/';
interface State { interface State {
feedUrl: string; feedUrl?: string;
} }
export class NewsPanelEditor extends PureComponent<PanelEditorProps<NewsOptions>, State> { export class NewsPanelEditor extends PureComponent<PanelEditorProps<NewsOptions>, State> {
...@@ -41,27 +41,29 @@ export class NewsPanelEditor extends PureComponent<PanelEditorProps<NewsOptions> ...@@ -41,27 +41,29 @@ export class NewsPanelEditor extends PureComponent<PanelEditorProps<NewsOptions>
return ( return (
<> <>
<PanelOptionsGroup title="Feed"> <PanelOptionsGroup title="Feed">
<div className="gf-form"> <>
<FormField <div className="gf-form">
label="URL" <FormField
labelWidth={7} label="URL"
inputWidth={30} labelWidth={7}
value={feedUrl || ''} inputWidth={30}
placeholder={DEFAULT_FEED_URL} value={feedUrl || ''}
onChange={this.onFeedUrlChange} placeholder={DEFAULT_FEED_URL}
tooltip="Only RSS feed formats are supported (not Atom)." onChange={this.onFeedUrlChange}
onBlur={this.onUpdatePanel} tooltip="Only RSS feed formats are supported (not Atom)."
/> onBlur={this.onUpdatePanel}
</div> />
{suggestProxy && (
<div>
<br />
<div>If the feed is unable to connect, consider a CORS proxy</div>
<Button variant="inverse" onClick={this.onSetProxyPrefix}>
Use Proxy
</Button>
</div> </div>
)} {suggestProxy && (
<div>
<br />
<div>If the feed is unable to connect, consider a CORS proxy</div>
<Button variant="inverse" onClick={this.onSetProxyPrefix}>
Use Proxy
</Button>
</div>
)}
</>
</PanelOptionsGroup> </PanelOptionsGroup>
</> </>
); );
......
...@@ -175,7 +175,8 @@ class SingleStatCtrl extends MetricsPanelCtrl { ...@@ -175,7 +175,8 @@ class SingleStatCtrl extends MetricsPanelCtrl {
} }
const distinct = getDistinctNames(frames); const distinct = getDistinctNames(frames);
let fieldInfo = distinct.byName[panel.tableColumn]; // let fieldInfo: FieldInfo | undefined = distinct.byName[panel.tableColumn];
this.fieldNames = distinct.names; this.fieldNames = distinct.names;
if (!fieldInfo) { if (!fieldInfo) {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment