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 { ApplyFieldOverrideOptions } from './fieldOverrides';
......@@ -15,19 +16,40 @@ export enum LoadingState {
}
export interface QueryResultMeta {
[key: string]: any;
/** DatasSource Specific Values */
custom?: Record<string, any>;
// Match the result to the query
requestId?: string;
/** Stats */
stats?: QueryResultMetaStat[];
// Used in Explore for highlighting
searchWords?: string[];
/** Meta Notices */
notices?: QueryResultMetaNotice[];
// Used in Explore to show limit applied to search result
limit?: number;
/** Used to track transformation ids that where part of the processing */
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 {
......
......@@ -16,14 +16,31 @@ export interface PanelPluginMeta extends PluginMeta {
}
export interface PanelData {
/**
* State of the data (loading, done, error, streaming)
*/
state: LoadingState;
/**
* Contains data frames with field overrides applied
*/
series: DataFrame[];
/**
* Request contains the queries and properties sent to the datasource
*/
request?: DataQueryRequest;
/**
* Timing measurements
*/
timings?: DataQueryTimings;
/**
* Any query errors
*/
error?: DataQueryError;
/**
* 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>(
export const onUpdateDatasourceJsonDataOptionChecked = <J, S, K extends keyof J>(
props: DataSourcePluginOptionsEditorProps<J, S>,
key: K,
val: boolean
) => (event?: React.SyntheticEvent<HTMLInputElement>) => {
updateDatasourcePluginJsonDataOption(props, key, val);
key: K
) => (event: React.SyntheticEvent<HTMLInputElement>) => {
updateDatasourcePluginJsonDataOption(props, key, event.currentTarget.checked);
};
export const onUpdateDatasourceSecureJsonDataOptionSelect = <J, S extends {} = KeyValue>(
......
......@@ -37,7 +37,7 @@ export interface Props extends Themeable {
height: number;
width: number;
field: FieldConfig;
display: DisplayProcessor;
display?: DisplayProcessor;
value: DisplayValue;
orientation: VizOrientation;
itemSpacing?: number;
......
......@@ -18,10 +18,11 @@ export interface Props {
height: number;
/** Minimal column width specified in pixels */
columnMinWidth?: number;
noHeader?: boolean;
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 [ref, headerRowMeasurements] = useMeasure();
const tableStyles = getTableStyles(theme);
......@@ -67,15 +68,17 @@ export const Table: FC<Props> = memo(({ data, height, onCellClick, width, column
return (
<div {...getTableProps()} className={tableStyles.table}>
<CustomScrollbar>
<div>
{headerGroups.map((headerGroup: any) => (
<div className={tableStyles.thead} {...headerGroup.getHeaderGroupProps()} ref={ref}>
{headerGroup.headers.map((column: any) =>
renderHeaderCell(column, tableStyles.headerCell, data.fields[column.index])
)}
</div>
))}
</div>
{!noHeader && (
<div>
{headerGroups.map((headerGroup: any) => (
<div className={tableStyles.thead} {...headerGroup.getHeaderGroupProps()} ref={ref}>
{headerGroup.headers.map((column: any) =>
renderHeaderCell(column, tableStyles.headerCell, data.fields[column.index])
)}
</div>
))}
</div>
)}
<FixedSizeList
height={height - headerRowMeasurements.height}
itemCount={rows.length}
......
import React, { FC } from 'react';
import { css } from 'emotion';
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 { PanelModel } from '../../state';
interface Props {
tab: InspectTab;
tabs: Array<{ label: string; value: InspectTab }>;
stats: { requestTime: number; queries: number; dataSources: number };
panelData: PanelData;
panel: PanelModel;
isExpanded: boolean;
onSelectTab: (tab: SelectableValue<InspectTab>) => void;
onClose: () => void;
onToggleExpand: () => void;
......@@ -24,7 +23,7 @@ export const InspectHeader: FC<Props> = ({
onClose,
onToggleExpand,
panel,
stats,
panelData,
isExpanded,
}) => {
const theme = useTheme();
......@@ -42,7 +41,7 @@ export const InspectHeader: FC<Props> = ({
</div>
<div className={styles.titleWrapper}>
<h3>{panel.title}</h3>
<div>{formatStats(stats)}</div>
<div className="muted">{formatStats(panelData)}</div>
</div>
<TabsBar className={styles.tabsBar}>
{tabs.map((t, index) => {
......@@ -95,10 +94,15 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
};
});
function formatStats(stats: { requestTime: number; queries: number; dataSources: number }) {
const queries = `${stats.queries} ${stats.queries === 1 ? 'query' : 'queries'}`;
const dataSources = `${stats.dataSources} ${stats.dataSources === 1 ? 'data source' : 'data sources'}`;
const requestTime = `${stats.requestTime === -1 ? 'N/A' : stats.requestTime}ms`;
function formatStats(panelData: PanelData) {
const { request } = panelData;
if (!request) {
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 {
toCSV,
DataQueryError,
PanelData,
DataQuery,
getValueFormat,
formattedValueToString,
QueryResultMetaStat,
} from '@grafana/data';
import { config } from 'app/core/config';
......@@ -51,8 +53,6 @@ interface State {
// If the datasource supports custom metadata
metaDS?: DataSourceApi;
stats: { requestTime: number; queries: number; dataSources: number; processingTime: number };
drawerWidth: string;
}
......@@ -65,7 +65,6 @@ export class PanelInspector extends PureComponent<Props, State> {
selected: 0,
tab: props.selectedTab || InspectTab.Data,
drawerWidth: '50%',
stats: { requestTime: 0, queries: 0, dataSources: 0, processingTime: 0 },
};
}
......@@ -89,23 +88,13 @@ export class PanelInspector extends PureComponent<Props, State> {
const error = lastResult.error;
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
if (data && targets.length) {
const queries: Record<string, DataQuery> = {};
for (const target of targets) {
queries[target.refId] = target;
}
for (const frame of data) {
const q = queries[frame.refId];
if (q && frame.meta && frame.meta.custom) {
const dataSource = await getDataSourceSrv().get(q.datasource);
if (frame.meta && frame.meta.custom) {
// get data source from first query
const dataSource = await getDataSourceSrv().get(targets[0].datasource);
if (dataSource && dataSource.components?.MetadataInspector) {
metaDS = dataSource;
......@@ -121,12 +110,6 @@ export class PanelInspector extends PureComponent<Props, State> {
data,
metaDS,
tab: error ? InspectTab.Error : prevState.tab,
stats: {
requestTime,
queries: targets.length,
dataSources,
processingTime,
},
}));
}
......@@ -184,7 +167,6 @@ export class PanelInspector extends PureComponent<Props, State> {
};
});
// Apply dummy styles
const processed = applyFieldOverrides({
data,
theme: config.theme,
......@@ -251,29 +233,66 @@ export class PanelInspector extends PureComponent<Props, State> {
}
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 (
<table className="filter-table width-30">
<tbody>
<tr>
<td>Query time</td>
<td>{`${stats.requestTime === -1 ? 'N/A' : stats.requestTime + 'ms'}`}</td>
</tr>
<tr>
<td>Data processing time</td>
<td>{`${
stats.processingTime === -1
? 'N/A'
: Math.round((stats.processingTime + Number.EPSILON) * 100) / 100 + 'ms'
}`}</td>
</tr>
</tbody>
</table>
<div style={{ paddingBottom: '16px' }}>
<div className="section-heading">{name}</div>
<table className="filter-table width-30">
<tbody>
{stats.map(stat => {
return (
<tr>
<td>{stat.title}</td>
<td style={{ textAlign: 'right' }}>{formatStat(stat.value, stat.unit)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
drawerHeader = () => {
const { tab, last, stats } = this.state;
const { tab, last } = this.state;
const error = last?.error;
const tabs = [];
......@@ -296,7 +315,7 @@ export class PanelInspector extends PureComponent<Props, State> {
<InspectHeader
tabs={tabs}
tab={tab}
stats={stats}
panelData={last}
onSelectTab={this.onSelectTab}
onClose={this.onDismiss}
panel={this.props.panel}
......@@ -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(() => {
return {
toolbar: css`
......
......@@ -12,6 +12,7 @@ import { PanelChromeAngular } from './PanelChromeAngular';
// Actions
import { initDashboardPanel } from '../state/actions';
import { updateLocation } from 'app/core/reducers/location';
// Types
import { PanelModel, DashboardModel } from '../state';
......@@ -33,6 +34,7 @@ export interface ConnectedProps {
export interface DispatchProps {
initDashboardPanel: typeof initDashboardPanel;
updateLocation: typeof updateLocation;
}
export type Props = OwnProps & ConnectedProps & DispatchProps;
......@@ -72,7 +74,7 @@ export class DashboardPanelUnconnected extends PureComponent<Props, State> {
};
renderPanel(plugin: PanelPlugin) {
const { dashboard, panel, isFullscreen, isInView, isInEditMode } = this.props;
const { dashboard, panel, isFullscreen, isInView, isInEditMode, updateLocation } = this.props;
return (
<AutoSizer>
......@@ -105,6 +107,7 @@ export class DashboardPanelUnconnected extends PureComponent<Props, State> {
isInEditMode={isInEditMode}
width={width}
height={height}
updateLocation={updateLocation}
/>
);
}}
......@@ -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);
......@@ -11,6 +11,7 @@ import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel';
import { profiler } from 'app/core/profiler';
import { getProcessedDataFrames } from '../state/runRequest';
import config from 'app/core/config';
import { updateLocation } from 'app/core/actions';
// Types
import { DashboardModel, PanelModel } from '../state';
import { PANEL_BORDER } from 'app/core/constants';
......@@ -36,6 +37,7 @@ export interface Props {
isInEditMode?: boolean;
width: number;
height: number;
updateLocation: typeof updateLocation;
}
export interface State {
......@@ -43,8 +45,6 @@ export interface State {
renderCounter: number;
errorMessage?: string;
refreshWhenInView: boolean;
// Current state of all events
data: PanelData;
}
......@@ -312,7 +312,7 @@ export class PanelChrome extends PureComponent<Props, State> {
}
render() {
const { dashboard, panel, isFullscreen, width, height } = this.props;
const { dashboard, panel, isFullscreen, width, height, updateLocation } = this.props;
const { errorMessage, data } = this.state;
const { transparent } = panel;
......@@ -328,14 +328,14 @@ export class PanelChrome extends PureComponent<Props, State> {
<PanelHeader
panel={panel}
dashboard={dashboard}
timeInfo={data.request ? data.request.timeInfo : undefined}
title={panel.title}
description={panel.description}
scopedVars={panel.scopedVars}
links={panel.links}
error={errorMessage}
isFullscreen={isFullscreen}
isLoading={data.state === LoadingState.Loading}
data={data}
updateLocation={updateLocation}
/>
<ErrorBoundary>
{({ error }) => {
......
......@@ -17,6 +17,7 @@ import config from 'app/core/config';
import { DashboardModel, PanelModel } from '../state';
import { StoreState } from 'app/types';
import { LoadingState, DefaultTimeRange, PanelData, PanelPlugin, PanelEvents } from '@grafana/data';
import { updateLocation } from 'app/core/actions';
import { PANEL_BORDER } from 'app/core/constants';
interface OwnProps {
......@@ -35,6 +36,7 @@ interface ConnectedProps {
interface DispatchProps {
setPanelAngularComponent: typeof setPanelAngularComponent;
updateLocation: typeof updateLocation;
}
export type Props = OwnProps & ConnectedProps & DispatchProps;
......@@ -215,7 +217,7 @@ export class PanelChromeAngularUnconnected extends PureComponent<Props, State> {
}
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 { transparent } = panel;
......@@ -238,7 +240,6 @@ export class PanelChromeAngularUnconnected extends PureComponent<Props, State> {
<PanelHeader
panel={panel}
dashboard={dashboard}
timeInfo={data.request ? data.request.timeInfo : undefined}
title={panel.title}
description={panel.description}
scopedVars={panel.scopedVars}
......@@ -246,7 +247,8 @@ export class PanelChromeAngularUnconnected extends PureComponent<Props, State> {
links={panel.links}
error={errorMessage}
isFullscreen={isFullscreen}
isLoading={data.state === LoadingState.Loading}
data={data}
updateLocation={updateLocation}
/>
<div className={panelContentClassNames}>
<div ref={element => (this.element = element)} className="panel-height-helper" />
......@@ -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);
import React, { Component } from 'react';
import classNames from 'classnames';
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 { ClickOutsideWrapper } from '@grafana/ui';
import { ClickOutsideWrapper, Tooltip } from '@grafana/ui';
import { e2e } from '@grafana/e2e';
import PanelHeaderCorner from './PanelHeaderCorner';
......@@ -14,11 +14,11 @@ import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import { getPanelLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers';
import { getPanelMenu } from 'app/features/dashboard/utils/getPanelMenu';
import { updateLocation } from 'app/core/actions';
export interface Props {
panel: PanelModel;
dashboard: DashboardModel;
timeInfo?: string;
title?: string;
description?: string;
scopedVars?: ScopedVars;
......@@ -26,7 +26,8 @@ export interface Props {
links?: DataLink[];
error?: string;
isFullscreen: boolean;
isLoading: boolean;
data: PanelData;
updateLocation: typeof updateLocation;
}
interface ClickCoordinates {
......@@ -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() {
const { panel, timeInfo, scopedVars, error, isFullscreen, isLoading } = this.props;
const { panel, scopedVars, error, isFullscreen, data } = this.props;
const { menuItems } = this.state;
const title = templateSrv.replaceWithText(panel.title, scopedVars);
......@@ -102,9 +130,20 @@ export class PanelHeader extends Component<Props, State> {
'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 (
<>
{isLoading && this.renderLoadingState()}
{data.state === LoadingState.Loading && this.renderLoadingState()}
<div className={panelHeaderClass}>
<PanelHeaderCorner
panel={panel}
......@@ -121,6 +160,7 @@ export class PanelHeader extends Component<Props, State> {
aria-label={e2e.pages.Dashboard.Panels.Panel.selectors.title(title)}
>
<div className="panel-title">
{Object.values(notices).map(this.renderNotice)}
<span className="icon-gf panel-alert-icon" />
<span className="panel-title-text">
{title} <span className="fa fa-caret-down panel-menu-toggle" />
......@@ -130,9 +170,9 @@ export class PanelHeader extends Component<Props, State> {
<PanelHeaderMenu items={menuItems} />
</ClickOutsideWrapper>
)}
{timeInfo && (
{data.request && data.request.timeInfo && (
<span className="panel-time-info">
<i className="fa fa-clock-o" /> {timeInfo}
<i className="fa fa-clock-o" /> {data.request.timeInfo}
</span>
)}
</div>
......
import { css, cx } from 'emotion';
import React, { PureComponent } from 'react';
import { MetadataInspectorProps, DataFrame } from '@grafana/data';
import { MetadataInspectorProps } from '@grafana/data';
import { GraphiteDatasource } from './datasource';
import { GraphiteQuery, GraphiteOptions, MetricTankMeta, MetricTankResultMeta } from './types';
import { parseSchemaRetentions } from './meta';
import { GraphiteQuery, GraphiteOptions, MetricTankSeriesMeta } from './types';
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>;
......@@ -11,48 +15,159 @@ export interface 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 (
<div>
<h3>Info</h3>
<table>
<tbody>
{buckets.map(row => (
<tr key={row.interval}>
<td>{row.interval} &nbsp;</td>
<td>{row.retention} &nbsp;</td>
<td>{row.chunkspan} &nbsp;</td>
<td>{row.numchunks} &nbsp;</td>
<td>{row.ready} &nbsp;</td>
</tr>
))}
</tbody>
</table>
<pre>{JSON.stringify(info, null, 2)}</pre>
<div className={styles.metaItem} key={key}>
<div className={styles.metaItemHeader}>Schema: {meta['schema-name']}</div>
<div className={styles.metaItemBody}>
<div className={styles.step}>
<div className={styles.stepHeading}>Step 1: Fetch</div>
<div className={styles.stepDescription}>
First data is fetched, either from raw data archive or a rollup archive
</div>
{rollupNotice && <p>{rollupNotice.text}</p>}
{!rollupNotice && <p>No rollup archive was used</p>}
<div>
{buckets.map((bucket, index) => {
const bucketLength = kbn.interval_to_seconds(bucket.retention);
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>
);
};
}
render() {
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];
const meta = frame.meta?.custom as MetricTankMeta;
if (!meta || !meta.info) {
return <>No Metadatata on DataFrame</>;
if (Object.keys(seriesMetas).length === 0) {
return <div>No response meta data</div>;
}
return (
<div>
<h3>MetricTank Request</h3>
<pre>{JSON.stringify(meta.request, null, 2)}</pre>
{meta.info.map(info => this.renderInfo(info, frame))}
<h2 className="page-heading">Aggregation & rollup</h2>
{Object.keys(seriesMetas).map(key => this.renderMeta(seriesMetas[key], key))}
</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 { DataSourceHttpSettings, FormLabel, Button, Select } from '@grafana/ui';
import { DataSourcePluginOptionsEditorProps, onUpdateDatasourceJsonDataOptionSelect } from '@grafana/data';
import { DataSourceHttpSettings, FormLabel, Select, Switch } from '@grafana/ui';
import {
DataSourcePluginOptionsEditorProps,
onUpdateDatasourceJsonDataOptionSelect,
onUpdateDatasourceJsonDataOptionChecked,
} from '@grafana/data';
import { GraphiteOptions, GraphiteType } from '../types';
import styles from './ConfigEditor.styles';
const graphiteVersions = [
{ label: '0.9.x', value: '0.9' },
......@@ -17,22 +20,27 @@ const graphiteTypes = Object.entries(GraphiteType).map(([label, value]) => ({
export type Props = DataSourcePluginOptionsEditorProps<GraphiteOptions>;
interface State {
showMetricTankHelp: boolean;
}
export class ConfigEditor extends PureComponent<Props, State> {
export class ConfigEditor extends PureComponent<Props> {
constructor(props: 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() {
const { options, onOptionsChange } = this.props;
const { showMetricTankHelp } = this.state;
const currentVersion =
graphiteVersions.find(item => item.value === options.jsonData.graphiteVersion) ?? graphiteVersions[2];
......@@ -61,39 +69,25 @@ export class ConfigEditor extends PureComponent<Props, State> {
</div>
<div className="gf-form-inline">
<div className="gf-form">
<FormLabel>Type</FormLabel>
<FormLabel tooltip={this.renderTypeHelp}>Type</FormLabel>
<Select
options={graphiteTypes}
value={graphiteTypes.find(type => type.value === options.jsonData.graphiteType)}
width={8}
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>
{showMetricTankHelp && (
<div className="grafana-info-box m-t-2">
<div className="alert-body">
<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>
{options.jsonData.graphiteType === GraphiteType.Metrictank && (
<div className="gf-form-inline">
<div className="gf-form">
<Switch
label="Rollup indicator"
labelClass={'width-10'}
tooltip="Shows up as an info icon in panel headers when data is aggregated"
checked={options.jsonData.rollupIndicatorEnabled}
onChange={onUpdateDatasourceJsonDataOptionChecked(this.props, 'rollupIndicatorEnabled')}
/>
</div>
</div>
)}
......
......@@ -7,14 +7,16 @@ import {
DataQueryRequest,
toDataFrame,
DataSourceApi,
QueryResultMetaStat,
} from '@grafana/data';
import { isVersionGtOrEq, SemVersion } from 'app/core/utils/version';
import gfunc from './gfunc';
import { getBackendSrv } from '@grafana/runtime';
import { TemplateSrv } from 'app/features/templating/template_srv';
//Types
import { GraphiteOptions, GraphiteQuery, GraphiteType } from './types';
// Types
import { GraphiteOptions, GraphiteQuery, GraphiteType, MetricTankRequestMeta } from './types';
import { getSearchFilterScopedVar } from '../../../features/templating/variable';
import { getRollupNotice, getRuntimeConsolidationNotice } from 'app/plugins/datasource/graphite/meta';
export class GraphiteDatasource extends DataSourceApi<GraphiteQuery, GraphiteOptions> {
basicAuth: string;
......@@ -23,6 +25,7 @@ export class GraphiteDatasource extends DataSourceApi<GraphiteQuery, GraphiteOpt
graphiteVersion: any;
supportsTags: boolean;
isMetricTank: boolean;
rollupIndicatorEnabled: boolean;
cacheTimeout: any;
withCredentials: boolean;
funcDefs: any = null;
......@@ -39,6 +42,7 @@ export class GraphiteDatasource extends DataSourceApi<GraphiteQuery, GraphiteOpt
this.isMetricTank = instanceSettings.jsonData.graphiteType === GraphiteType.Metrictank;
this.supportsTags = supportsTags(this.graphiteVersion);
this.cacheTimeout = instanceSettings.cacheTimeout;
this.rollupIndicatorEnabled = instanceSettings.jsonData.rollupIndicatorEnabled;
this.withCredentials = instanceSettings.withCredentials;
this.funcDefs = null;
this.funcDefsPromise = null;
......@@ -108,33 +112,71 @@ export class GraphiteDatasource extends DataSourceApi<GraphiteQuery, GraphiteOpt
if (!result || !result.data) {
return { data };
}
// Series are either at the root or under a node called 'series'
const series = result.data.series || result.data;
if (!_.isArray(series)) {
throw { message: 'Missing series in result', data: result };
}
for (let i = 0; i < series.length; i++) {
const s = series[i];
for (let y = 0; y < s.datapoints.length; y++) {
s.datapoints[y][1] *= 1000;
}
const frame = toDataFrame(s);
// Metrictank metadata
if (s.meta) {
frame.meta = {
custom: {
request: result.data.meta, // info for the whole request
info: s.meta, // Array of metadata
requestMetaList: result.data.meta, // info for the whole request
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);
}
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) {
let tags: string[] = [];
tags = tagString.split(',');
......@@ -278,7 +320,7 @@ export class GraphiteDatasource extends DataSourceApi<GraphiteQuery, GraphiteOpt
return date.unix();
}
metricFindQuery(query: string, optionalOptions: any) {
metricFindQuery(query: string, optionalOptions?: any) {
const options: any = optionalOptions || {};
let interpolatedQuery = this.templateSrv.replace(
query,
......@@ -573,7 +615,7 @@ export class GraphiteDatasource extends DataSourceApi<GraphiteQuery, GraphiteOpt
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 cleanOptions = [],
targets: any = {};
......
export interface MetricTankResultMeta {
'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",
}
import { MetricTankSeriesMeta } from './types';
import { QueryResultMetaNotice } from '@grafana/data';
// https://github.com/grafana/metrictank/blob/master/scripts/config/storage-schemas.conf#L15-L46
......@@ -19,6 +17,7 @@ function toInteger(val?: string): number | undefined {
}
return undefined;
}
function toBooleanOrTimestamp(val?: string): number | boolean | undefined {
if (val) {
if (val === 'true') {
......@@ -32,6 +31,44 @@ function toBooleanOrTimestamp(val?: string): number | boolean | 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[] {
if (!spec) {
return [];
......
......@@ -10,19 +10,94 @@ jest.mock('@grafana/runtime', () => ({
getBackendSrv: () => backendSrv,
}));
interface Context {
templateSrv: TemplateSrv;
ds: GraphiteDatasource;
}
describe('graphiteDatasource', () => {
const datasourceRequestMock = jest.spyOn(backendSrv, 'datasourceRequest');
const ctx: any = {
// @ts-ignore
templateSrv: new TemplateSrv(),
instanceSettings: { url: 'url', name: 'graphiteProd', jsonData: {} },
};
let ctx = {} as Context;
beforeEach(() => {
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', () => {
......@@ -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;
});
});
......@@ -233,7 +308,7 @@ describe('graphiteDatasource', () => {
describe('when formatting targets', () => {
it('does not attempt to glob for one variable', () => {
ctx.ds.templateSrv.init([
ctx.templateSrv.init([
{
type: 'query',
name: 'metric',
......@@ -248,7 +323,7 @@ describe('graphiteDatasource', () => {
});
it('globs for more than one variable', () => {
ctx.ds.templateSrv.init([
ctx.templateSrv.init([
{
type: 'query',
name: 'metric',
......@@ -259,6 +334,7 @@ describe('graphiteDatasource', () => {
const results = ctx.ds.buildGraphiteParams({
targets: [{ target: 'my.[[metric]].*' }],
});
expect(results).toStrictEqual(['target=my.%7Ba%2Cb%7D.*', 'format=json']);
});
});
......@@ -352,7 +428,7 @@ describe('graphiteDatasource', () => {
});
it('/metrics/find should be POST', () => {
ctx.ds.templateSrv.init([
ctx.templateSrv.init([
{
type: 'query',
name: 'foo',
......
......@@ -7,6 +7,7 @@ export interface GraphiteQuery extends DataQuery {
export interface GraphiteOptions extends DataSourceJsonData {
graphiteVersion: string;
graphiteType: GraphiteType;
rollupIndicatorEnabled?: boolean;
}
export enum GraphiteType {
......@@ -15,10 +16,10 @@ export enum GraphiteType {
}
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-retentions': string; //"1s:35d:20min:5:1542274085,1min:38d:2h:1:true,10min:120d:6h:1:true,2h:2y:6h:2",
'archive-read': number;
......@@ -32,5 +33,5 @@ export interface MetricTankResultMeta {
export interface MetricTankMeta {
request: MetricTankRequestMeta;
info: MetricTankResultMeta[];
info: MetricTankSeriesMeta[];
}
import React from 'react';
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 { BarGaugePanel } from './BarGaugePanel';
......@@ -19,6 +28,27 @@ describe('BarGaugePanel', () => {
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 {
......
......@@ -6,7 +6,7 @@ import { NewsOptions, DEFAULT_FEED_URL } from './types';
const PROXY_PREFIX = 'https://cors-anywhere.herokuapp.com/';
interface State {
feedUrl: string;
feedUrl?: string;
}
export class NewsPanelEditor extends PureComponent<PanelEditorProps<NewsOptions>, State> {
......@@ -41,27 +41,29 @@ export class NewsPanelEditor extends PureComponent<PanelEditorProps<NewsOptions>
return (
<>
<PanelOptionsGroup title="Feed">
<div className="gf-form">
<FormField
label="URL"
labelWidth={7}
inputWidth={30}
value={feedUrl || ''}
placeholder={DEFAULT_FEED_URL}
onChange={this.onFeedUrlChange}
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 className="gf-form">
<FormField
label="URL"
labelWidth={7}
inputWidth={30}
value={feedUrl || ''}
placeholder={DEFAULT_FEED_URL}
onChange={this.onFeedUrlChange}
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>
)}
</>
</PanelOptionsGroup>
</>
);
......
......@@ -175,7 +175,8 @@ class SingleStatCtrl extends MetricsPanelCtrl {
}
const distinct = getDistinctNames(frames);
let fieldInfo = distinct.byName[panel.tableColumn]; //
let fieldInfo: FieldInfo | undefined = distinct.byName[panel.tableColumn];
this.fieldNames = distinct.names;
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