Datasource/Cloudwatch: Adds support for Cloudwatch Logs (#23566)

* Datasource/Cloudwatch: Adds support for Cloudwatch Logs

* Fix rebase leftover

* Use jsurl for AWS url serialization

* WIP: Temporary workaround for CLIQ metrics

* Only allow up to 20 log groups to be selected

* WIP additional changes

* More changes based on feedback

* More changes based on PR feedback

* Fix strict null errors
This commit is contained in:
kay delaney
2020-04-25 21:48:20 +01:00
committed by GitHub
parent af00fa7214
commit f48ba11d4c
78 changed files with 3914 additions and 399 deletions

View File

@@ -0,0 +1,18 @@
const JSURL = require('jsurl');
export interface AwsUrl {
end: string;
start: string;
timeType?: 'ABSOLUTE' | 'RELATIVE';
tz?: 'local' | 'UTC';
unit?: string;
editorString: string;
isLiveTail: boolean;
source: string[];
}
export function encodeUrl(obj: AwsUrl, region: string): string {
return `https://${region}.console.aws.amazon.com/cloudwatch/home?region=${region}#logsV2:logs-insights$3FqueryDetail$3D${JSURL.stringify(
obj
)}`;
}

View File

@@ -2,9 +2,9 @@ import React, { ChangeEvent } from 'react';
import { LegacyForms } from '@grafana/ui';
const { Switch } = LegacyForms;
import { PanelData } from '@grafana/data';
import { CloudWatchQuery, AnnotationQuery } from '../types';
import CloudWatchDatasource from '../datasource';
import { QueryField, QueryFieldsEditor } from './';
import { AnnotationQuery } from '../types';
import { CloudWatchDatasource } from '../datasource';
import { QueryField, PanelQueryEditor } from './';
export type Props = {
query: AnnotationQuery;
@@ -17,11 +17,12 @@ export function AnnotationQueryEditor(props: React.PropsWithChildren<Props>) {
const { query, onChange } = props;
return (
<>
<QueryFieldsEditor
<PanelQueryEditor
{...props}
onChange={(editorQuery: CloudWatchQuery) => onChange({ ...query, ...editorQuery })}
hideWilcard
></QueryFieldsEditor>
onChange={(editorQuery: AnnotationQuery) => onChange({ ...query, ...editorQuery })}
onRunQuery={() => {}}
history={[]}
></PanelQueryEditor>
<div className="gf-form-inline">
<Switch
label="Enable Prefix Matching"

View File

@@ -0,0 +1,61 @@
import _ from 'lodash';
import React, { Component } from 'react';
import { CloudWatchLogsQuery } from '../types';
import { PanelData } from '@grafana/data';
import { encodeUrl, AwsUrl } from '../aws_url';
import { CloudWatchDatasource } from '../datasource';
interface Props {
query: CloudWatchLogsQuery;
panelData: PanelData;
datasource: CloudWatchDatasource;
}
interface State {
href: string;
}
export default class CloudWatchLink extends Component<Props, State> {
state: State = { href: '' };
async componentDidUpdate(prevProps: Props) {
if (prevProps.panelData !== this.props.panelData && this.props.panelData.request) {
const href = this.getExternalLink();
this.setState({ href });
}
}
getExternalLink(): string {
const { query, panelData, datasource } = this.props;
const range = panelData?.request?.range;
if (!range) {
return '';
}
const start = range.from.toISOString();
const end = range.to.toISOString();
const urlProps: AwsUrl = {
end,
start,
timeType: 'ABSOLUTE',
tz: 'UTC',
editorString: query.expression,
isLiveTail: false,
source: query.logGroupNames,
};
return encodeUrl(urlProps, datasource.getActualRegion(query.region));
}
render() {
const { href } = this.state;
return (
<a href={href} target="_blank" rel="noopener">
<i className="fa fa-share-square-o" /> CloudWatch Logs Insights
</a>
);
}
}

View File

@@ -0,0 +1,48 @@
import React, { PureComponent } from 'react';
import { ExploreQueryFieldProps } from '@grafana/data';
import { RadioButtonGroup } from '@grafana/ui';
import { CloudWatchQuery } from '../types';
import { CloudWatchDatasource } from '../datasource';
import LogsQueryEditor from './LogsQueryEditor';
import { MetricsQueryEditor } from './MetricsQueryEditor';
import { cx, css } from 'emotion';
export type Props = ExploreQueryFieldProps<CloudWatchDatasource, CloudWatchQuery>;
export class CombinedMetricsEditor extends PureComponent<Props> {
renderMetricsEditor() {
return <MetricsQueryEditor {...this.props} />;
}
renderLogsEditor() {
return <LogsQueryEditor {...this.props} />;
}
render() {
const { query } = this.props;
const apiMode = query.apiMode ?? query.queryMode ?? 'Metrics';
return (
<>
<div
className={cx(
css`
margin-bottom: 4px;
`
)}
>
<RadioButtonGroup
options={[
{ label: 'Metrics API', value: 'Metrics' },
{ label: 'Logs API', value: 'Logs' },
]}
value={apiMode}
onChange={(v: 'Metrics' | 'Logs') => this.props.onChange({ ...query, apiMode: v })}
/>
</div>
{apiMode === 'Metrics' ? this.renderMetricsEditor() : this.renderLogsEditor()}
</>
);
}
}

View File

@@ -11,7 +11,7 @@ import {
} from '@grafana/data';
import { SelectableValue } from '@grafana/data';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import CloudWatchDatasource from '../datasource';
import { CloudWatchDatasource } from '../datasource';
import { CloudWatchJsonData, CloudWatchSecureJsonData } from '../types';
import { CancelablePromise, makePromiseCancelable } from 'app/core/utils/CancelablePromise';
@@ -36,7 +36,7 @@ export class ConfigEditor extends PureComponent<Props, State> {
};
}
loadRegionsPromise: CancelablePromise<any> = null;
loadRegionsPromise: CancelablePromise<any> | null = null;
componentDidMount() {
this.loadRegionsPromise = makePromiseCancelable(this.loadRegions());
@@ -56,9 +56,7 @@ export class ConfigEditor extends PureComponent<Props, State> {
async loadRegions() {
await getDatasourceSrv()
.loadDatasource(this.props.options.name)
.then((ds: CloudWatchDatasource) => {
return ds.getRegions();
})
.then((ds: CloudWatchDatasource) => ds.getRegions())
.then(
(regions: any) => {
this.setState({
@@ -100,12 +98,10 @@ export class ConfigEditor extends PureComponent<Props, State> {
];
this.setState({
regions: regions.map((region: string) => {
return {
value: region,
label: region,
};
}),
regions: regions.map((region: string) => ({
value: region,
label: region,
})),
});
// expected to fail when creating new datasource
@@ -162,7 +158,7 @@ export class ConfigEditor extends PureComponent<Props, State> {
)}
{options.jsonData.authType === 'keys' && (
<div>
{options.secureJsonFields.accessKey ? (
{options.secureJsonFields?.accessKey ? (
<div className="gf-form-inline">
<div className="gf-form">
<InlineFormLabel className="width-14">Access Key ID</InlineFormLabel>
@@ -194,7 +190,7 @@ export class ConfigEditor extends PureComponent<Props, State> {
</div>
</div>
)}
{options.secureJsonFields.secretKey ? (
{options.secureJsonFields?.secretKey ? (
<div className="gf-form-inline">
<div className="gf-form">
<InlineFormLabel className="width-14">Secret Access Key</InlineFormLabel>

View File

@@ -0,0 +1,138 @@
import React, { PureComponent } from 'react';
import { stripIndent, stripIndents } from 'common-tags';
import { ExploreStartPageProps, DataQuery, ExploreMode } from '@grafana/data';
import Prism from 'prismjs';
import tokenizer from '../syntax';
import { flattenTokens } from '@grafana/ui/src/slate-plugins/slate-prism';
const CLIQ_EXAMPLES = [
{
title: 'View latency statistics for 5-minute intervals',
expr: stripIndents`filter @type = "REPORT" |
stats avg(@duration), max(@duration), min(@duration) by bin(5m)`,
},
{
title: 'Determine the amount of overprovisioned memory',
expr: stripIndent`
filter @type = "REPORT" |
stats max(@memorySize / 1024 / 1024) as provisonedMemoryMB,
min(@maxMemoryUsed / 1024 / 1024) as smallestMemoryRequestMB,
avg(@maxMemoryUsed / 1024 / 1024) as avgMemoryUsedMB,
max(@maxMemoryUsed / 1024 / 1024) as maxMemoryUsedMB,
provisonedMemoryMB - maxMemoryUsedMB as overProvisionedMB`,
},
{
title: 'Find the most expensive requests',
expr: stripIndents`filter @type = "REPORT" |
fields @requestId, @billedDuration | sort by @billedDuration desc`,
},
{
title: 'Average, min, and max byte transfers by source and destination IP addresses',
expr: `stats avg(bytes), min(bytes), max(bytes) by srcAddr, dstAddr`,
},
{
title: 'IP addresses using UDP transfer protocol',
expr: 'filter protocol=17 | stats count(*) by srcAddr',
},
{
title: 'Top 10 byte transfers by source and destination IP addresses',
expr: stripIndents`stats sum(bytes) as bytesTransferred by srcAddr, dstAddr |
sort bytesTransferred desc |
limit 10`,
},
{
title: 'Top 20 source IP addresses with highest number of rejected requests',
expr: stripIndents`filter action="REJECT" |
stats count(*) as numRejections by srcAddr |
sort numRejections desc |
limit 20`,
},
{
title: 'Number of log entries by service, event type, and region',
expr: 'stats count(*) by eventSource, eventName, awsRegion',
},
{
title: 'Number of log entries by region and EC2 event type',
expr: stripIndents`filter eventSource="ec2.amazonaws.com" |
stats count(*) as eventCount by eventName, awsRegion |
sort eventCount desc`,
},
{
title: 'Regions, usernames, and ARNs of newly created IAM users',
expr: stripIndents`filter eventName="CreateUser" |
fields awsRegion, requestParameters.userName, responseElements.user.arn`,
},
{
title: '25 most recently added log events',
expr: stripIndents`fields @timestamp, @message |
sort @timestamp desc |
limit 25`,
},
{
title: 'Number of exceptions logged every 5 minutes',
expr: stripIndents`filter @message like /Exception/ |
stats count(*) as exceptionCount by bin(5m) |
sort exceptionCount desc`,
},
{
title: 'List of log events that are not exceptions',
expr: 'fields @message | filter @message not like /Exception/',
},
];
function renderHighlightedMarkup(code: string, keyPrefix: string) {
const grammar = Prism.languages['cloudwatch'] ?? tokenizer;
const tokens = flattenTokens(Prism.tokenize(code, grammar));
const spans = tokens
.filter(token => typeof token !== 'string')
.map((token, i) => {
return (
<span
className={`prism-token token ${token.types.join(' ')} ${token.aliases.join(' ')}`}
key={`${keyPrefix}-token-${i}`}
>
{token.content}
</span>
);
});
return <div className="slate-query-field">{spans}</div>;
}
export default class LogsCheatSheet extends PureComponent<ExploreStartPageProps, { userExamples: string[] }> {
renderExpression(expr: string, keyPrefix: string) {
const { onClickExample } = this.props;
return (
<div
className="cheat-sheet-item__example"
key={expr}
onClick={e => onClickExample({ refId: 'A', expression: expr } as DataQuery)}
>
<pre>{renderHighlightedMarkup(expr, keyPrefix)}</pre>
</div>
);
}
renderLogsCheatSheet() {
return (
<div>
<h2>CloudWatch Logs Cheat Sheet</h2>
{CLIQ_EXAMPLES.map((item, i) => (
<div className="cheat-sheet-item" key={`item-${i}`}>
<div className="cheat-sheet-item__title">{item.title}</div>
{this.renderExpression(item.expr, `item-${i}`)}
</div>
))}
</div>
);
}
render() {
const { exploreMode } = this.props;
return exploreMode === ExploreMode.Logs && this.renderLogsCheatSheet();
}
}

View File

@@ -0,0 +1,67 @@
// Libraries
import React, { memo } from 'react';
// Types
import { AbsoluteTimeRange, QueryEditorProps } from '@grafana/data';
import { FormLabel } from '@grafana/ui/src/components/FormLabel/FormLabel';
import { CloudWatchDatasource } from '../datasource';
import { CloudWatchLogsQuery, CloudWatchQuery } from '../types';
import { CloudWatchLogsQueryField } from './LogsQueryField';
import { useCloudWatchSyntax } from '../useCloudwatchSyntax';
import { CloudWatchLanguageProvider } from '../language_provider';
import CloudWatchLink from './CloudWatchLink';
import { css } from 'emotion';
type Props = QueryEditorProps<CloudWatchDatasource, CloudWatchQuery>;
const labelClass = css`
margin-left: 3px;
flex-grow: 0;
`;
export const CloudWatchLogsQueryEditor = memo(function CloudWatchLogsQueryEditor(props: Props) {
const { query, data, datasource, onRunQuery, onChange, exploreId, exploreMode } = props;
let absolute: AbsoluteTimeRange;
if (data?.request?.range?.from) {
const { range } = data.request;
absolute = {
from: range.from.valueOf(),
to: range.to.valueOf(),
};
} else {
absolute = {
from: Date.now() - 10000,
to: Date.now(),
};
}
const { isSyntaxReady, syntax } = useCloudWatchSyntax(
datasource.languageProvider as CloudWatchLanguageProvider,
absolute
);
return (
<CloudWatchLogsQueryField
exploreId={exploreId}
exploreMode={exploreMode}
datasource={datasource}
query={query}
onBlur={() => {}}
onChange={(val: CloudWatchLogsQuery) => onChange({ ...val, queryMode: 'Logs' })}
onRunQuery={onRunQuery}
history={[]}
data={data}
absoluteRange={absolute}
syntaxLoaded={isSyntaxReady}
syntax={syntax}
ExtraFieldElement={
<FormLabel className={`gf-form-label--btn ${labelClass}`} width="auto" tooltip="Link to Graph in AWS">
<CloudWatchLink query={query as CloudWatchLogsQuery} panelData={data} datasource={datasource} />
</FormLabel>
}
/>
);
});
export default CloudWatchLogsQueryEditor;

View File

@@ -0,0 +1,377 @@
// Libraries
import React, { ReactNode } from 'react';
import intersection from 'lodash/intersection';
import {
QueryField,
SlatePrism,
LegacyForms,
TypeaheadInput,
TypeaheadOutput,
BracesPlugin,
Select,
MultiSelect,
Token,
} from '@grafana/ui';
// Utils & Services
// dom also includes Element polyfills
import { Plugin, Node, Editor } from 'slate';
import syntax from '../syntax';
// Types
import { ExploreQueryFieldProps, AbsoluteTimeRange, SelectableValue, ExploreMode, AppEvents } from '@grafana/data';
import { CloudWatchQuery, CloudWatchLogsQuery } from '../types';
import { CloudWatchDatasource } from '../datasource';
import Prism, { Grammar } from 'prismjs';
import { CloudWatchLanguageProvider } from '../language_provider';
import { css } from 'emotion';
import { ExploreId } from 'app/types';
import { dispatch } from 'app/store/store';
import { changeModeAction } from 'app/features/explore/state/actionTypes';
import { appEvents } from 'app/core/core';
export interface CloudWatchLogsQueryFieldProps extends ExploreQueryFieldProps<CloudWatchDatasource, CloudWatchQuery> {
absoluteRange: AbsoluteTimeRange;
onLabelsRefresh?: () => void;
ExtraFieldElement?: ReactNode;
syntaxLoaded: boolean;
syntax: Grammar;
exploreId: ExploreId;
}
const containerClass = css`
flex-grow: 1;
min-height: 35px;
`;
const rowGap = css`
gap: 3px;
`;
interface State {
selectedLogGroups: Array<SelectableValue<string>>;
availableLogGroups: Array<SelectableValue<string>>;
loadingLogGroups: boolean;
regions: Array<SelectableValue<string>>;
selectedRegion: SelectableValue<string>;
invalidLogGroups: boolean;
hint:
| {
message: string;
fix: {
label: string;
action: () => void;
};
}
| undefined;
}
export class CloudWatchLogsQueryField extends React.PureComponent<CloudWatchLogsQueryFieldProps, State> {
state: State = {
selectedLogGroups:
(this.props.query as CloudWatchLogsQuery).logGroupNames?.map(logGroup => ({
value: logGroup,
label: logGroup,
})) ?? [],
availableLogGroups: [],
regions: [],
invalidLogGroups: false,
selectedRegion: (this.props.query as CloudWatchLogsQuery).region
? {
label: (this.props.query as CloudWatchLogsQuery).region,
value: (this.props.query as CloudWatchLogsQuery).region,
text: (this.props.query as CloudWatchLogsQuery).region,
}
: { label: 'default', value: 'default', text: 'default' },
loadingLogGroups: false,
hint: undefined,
};
plugins: Plugin[];
constructor(props: CloudWatchLogsQueryFieldProps, context: React.Context<any>) {
super(props, context);
Prism.languages['cloudwatch'] = syntax;
this.plugins = [
BracesPlugin(),
SlatePrism({
onlyIn: (node: Node) => node.object === 'block' && node.type === 'code_block',
getSyntax: (node: Node) => 'cloudwatch',
}),
];
}
fetchLogGroupOptions = async (region: string) => {
try {
const logGroups: string[] = await this.props.datasource.describeLogGroups({
refId: this.props.query.refId,
region,
});
return logGroups.map(logGroup => ({
value: logGroup,
label: logGroup,
}));
} catch (err) {
appEvents.emit(AppEvents.alertError, [err]);
return [];
}
};
componentWillMount = () => {
const { datasource, query } = this.props;
this.setState({
loadingLogGroups: true,
});
this.fetchLogGroupOptions(query.region).then(logGroups => {
this.setState({
loadingLogGroups: false,
availableLogGroups: logGroups,
});
});
datasource.getRegions().then(regions => {
this.setState({
regions,
});
});
};
onChangeQuery = (value: string, override?: boolean) => {
// Send text change to parent
const { query, onChange, onRunQuery } = this.props;
const { selectedLogGroups, selectedRegion } = this.state;
if (onChange) {
const nextQuery = {
...query,
expression: value,
logGroupNames: selectedLogGroups?.map(logGroupName => logGroupName.value) ?? [],
region: selectedRegion.value,
};
onChange(nextQuery);
if (override && onRunQuery) {
onRunQuery();
}
}
};
setSelectedLogGroups = (v: Array<SelectableValue<string>>) => {
this.setState({
selectedLogGroups: v,
});
const { onChange, query } = this.props;
if (onChange) {
const nextQuery = {
...query,
logGroupNames: v.map(logGroupName => logGroupName.value) ?? [],
};
onChange(nextQuery);
}
};
setSelectedRegion = async (v: SelectableValue<string>) => {
this.setState({
selectedRegion: v,
loadingLogGroups: true,
});
const logGroups = await this.fetchLogGroupOptions(v.value!);
this.setState(state => ({
availableLogGroups: logGroups,
selectedLogGroups: intersection(state.selectedLogGroups, logGroups),
loadingLogGroups: false,
}));
const { onChange, query } = this.props;
if (onChange) {
const nextQuery = {
...query,
region: v.value,
};
onChange(nextQuery);
}
};
onTypeahead = async (typeahead: TypeaheadInput): Promise<TypeaheadOutput> => {
const { datasource, exploreMode } = this.props;
const { selectedLogGroups } = this.state;
if (!datasource.languageProvider) {
return { suggestions: [] };
}
const cloudwatchLanguageProvider = datasource.languageProvider as CloudWatchLanguageProvider;
const { history, absoluteRange } = this.props;
const { prefix, text, value, wrapperClasses, labelKey, editor } = typeahead;
const result = await cloudwatchLanguageProvider.provideCompletionItems(
{ text, value, prefix, wrapperClasses, labelKey, editor },
{ history, absoluteRange, logGroupNames: selectedLogGroups.map(logGroup => logGroup.value!) }
);
const tokens = editor?.value.data.get('tokens');
const queryUsesStatsCommand = tokens.find(
(token: Token) => token.types.includes('query-command') && token.content.toLowerCase() === 'stats'
);
// TEMP: Remove when logs/metrics unification is complete
if (queryUsesStatsCommand && exploreMode === ExploreMode.Logs) {
this.setState({
hint: {
message: 'You are trying to run a stats query in Logs mode. ',
fix: {
label: 'Switch to Metrics mode.',
action: this.switchToMetrics,
},
},
});
}
return result;
};
switchToMetrics = () => {
const { query, onChange, exploreId } = this.props;
if (onChange) {
const nextQuery: CloudWatchLogsQuery = {
...(query as CloudWatchLogsQuery),
apiMode: 'Logs',
};
onChange(nextQuery);
}
dispatch(changeModeAction({ exploreId, mode: ExploreMode.Metrics }));
};
onQueryFieldClick = (_event: Event, _editor: Editor, next: () => any) => {
const { selectedLogGroups, loadingLogGroups } = this.state;
const queryFieldDisabled = loadingLogGroups || selectedLogGroups.length === 0;
if (queryFieldDisabled) {
this.setState({
invalidLogGroups: true,
});
}
next();
};
onOpenLogGroupMenu = () => {
this.setState({
invalidLogGroups: false,
});
};
render() {
const { ExtraFieldElement, data, query, syntaxLoaded, datasource } = this.props;
const {
selectedLogGroups,
availableLogGroups,
regions,
selectedRegion,
loadingLogGroups,
hint,
invalidLogGroups,
} = this.state;
const showError = data && data.error && data.error.refId === query.refId;
const cleanText = datasource.languageProvider ? datasource.languageProvider.cleanText : undefined;
const MAX_LOG_GROUPS = 20;
return (
<>
<div className={`gf-form gf-form--grow flex-grow-1 ${rowGap}`}>
<LegacyForms.FormField
label="Region"
labelWidth={4}
inputEl={
<Select
options={regions}
value={selectedRegion}
onChange={v => this.setSelectedRegion(v)}
width={18}
placeholder="Choose Region"
menuPlacement="bottom"
maxMenuHeight={500}
/>
}
/>
<LegacyForms.FormField
label="Log Groups"
labelWidth={6}
className="flex-grow-1"
inputEl={
<MultiSelect
options={availableLogGroups}
value={selectedLogGroups}
onChange={v => {
this.setSelectedLogGroups(v);
}}
className={containerClass}
closeMenuOnSelect={false}
isClearable={true}
invalid={invalidLogGroups}
isOptionDisabled={() => selectedLogGroups.length >= MAX_LOG_GROUPS}
placeholder="Choose Log Groups"
maxVisibleValues={4}
menuPlacement="bottom"
noOptionsMessage="No log groups available"
isLoading={loadingLogGroups}
onOpenMenu={this.onOpenLogGroupMenu}
/>
}
/>
</div>
<div className="gf-form-inline gf-form-inline--nowrap flex-grow-1">
<div className="gf-form gf-form--grow flex-shrink-1">
<QueryField
additionalPlugins={this.plugins}
query={query.expression}
onChange={this.onChangeQuery}
onBlur={this.props.onBlur}
onClick={this.onQueryFieldClick}
onRunQuery={this.props.onRunQuery}
onTypeahead={this.onTypeahead}
cleanText={cleanText}
placeholder="Enter a CloudWatch Logs Insights query"
portalOrigin="cloudwatch"
syntaxLoaded={syntaxLoaded}
disabled={loadingLogGroups || selectedLogGroups.length === 0}
/>
</div>
{ExtraFieldElement}
</div>
{hint && (
<div className="query-row-break">
<div className="text-warning">
{hint.message}
<a className="text-link muted" onClick={hint.fix.action}>
{hint.fix.label}
</a>
</div>
</div>
)}
{showError ? (
<div className="query-row-break">
<div className="prom-query-field-info text-error">{data?.error?.message}</div>
</div>
) : null}
</>
);
}
}

View File

@@ -5,8 +5,8 @@ import { act } from 'react-dom/test-utils';
import { DataSourceInstanceSettings } from '@grafana/data';
import { TemplateSrv } from 'app/features/templating/template_srv';
import { CustomVariable } from 'app/features/templating/all';
import { Props, QueryEditor, normalizeQuery } from './QueryEditor';
import CloudWatchDatasource from '../datasource';
import { MetricsQueryEditor, Props, normalizeQuery } from './MetricsQueryEditor';
import { CloudWatchDatasource } from '../datasource';
const setup = () => {
const instanceSettings = {
@@ -37,6 +37,8 @@ const setup = () => {
const props: Props = {
query: {
queryMode: 'Metrics',
apiMode: 'Metrics',
refId: '',
id: '',
region: 'us-east-1',
@@ -63,7 +65,7 @@ describe('QueryEditor', () => {
const { act } = renderer;
await act(async () => {
const props = setup();
const tree = renderer.create(<QueryEditor {...props} />).toJSON();
const tree = renderer.create(<MetricsQueryEditor {...props} />).toJSON();
expect(tree).toMatchSnapshot();
});
});
@@ -74,7 +76,7 @@ describe('QueryEditor', () => {
await act(async () => {
const props = setup();
props.query.region = (null as unknown) as string;
const wrapper = mount(<QueryEditor {...props} />);
const wrapper = mount(<MetricsQueryEditor {...props} />);
expect(
wrapper
.find('.gf-form-inline')

View File

@@ -1,11 +1,12 @@
import React, { PureComponent, ChangeEvent } from 'react';
import isEmpty from 'lodash/isEmpty';
import { ExploreQueryFieldProps } from '@grafana/data';
import { LegacyForms, ValidationEvents, EventsWithValidation, Icon } from '@grafana/ui';
const { Input, Switch } = LegacyForms;
import isEmpty from 'lodash/isEmpty';
import { CloudWatchQuery } from '../types';
import CloudWatchDatasource from '../datasource';
import { QueryField, Alias, QueryFieldsEditor } from './';
import { CloudWatchQuery, CloudWatchMetricsQuery } from '../types';
import { CloudWatchDatasource } from '../datasource';
import { QueryField, Alias, MetricsQueryFieldsEditor } from './';
export type Props = ExploreQueryFieldProps<CloudWatchDatasource, CloudWatchQuery>;
@@ -33,7 +34,7 @@ export const normalizeQuery = ({
statistics,
period,
...rest
}: CloudWatchQuery): CloudWatchQuery => {
}: CloudWatchMetricsQuery): CloudWatchMetricsQuery => {
const normalizedQuery = {
namespace: namespace || '',
metricName: metricName || '',
@@ -49,10 +50,10 @@ export const normalizeQuery = ({
return !rest.hasOwnProperty('matchExact') ? { ...normalizedQuery, matchExact: true } : normalizedQuery;
};
export class QueryEditor extends PureComponent<Props, State> {
export class MetricsQueryEditor extends PureComponent<Props, State> {
state: State = { showMeta: false };
onChange(query: CloudWatchQuery) {
onChange(query: CloudWatchMetricsQuery) {
const { onChange, onRunQuery } = this.props;
onChange(query);
onRunQuery();
@@ -60,12 +61,14 @@ export class QueryEditor extends PureComponent<Props, State> {
render() {
const { data, onRunQuery } = this.props;
const metricsQuery = this.props.query as CloudWatchMetricsQuery;
const { showMeta } = this.state;
const query = normalizeQuery(this.props.query);
const query = normalizeQuery(metricsQuery);
const metaDataExist = data && Object.values(data).length && data.state === 'Done';
return (
<>
<QueryFieldsEditor {...{ ...this.props, query }}></QueryFieldsEditor>
<MetricsQueryFieldsEditor {...{ ...this.props, query }}></MetricsQueryFieldsEditor>
{query.statistics.length <= 1 && (
<div className="gf-form-inline">
<div className="gf-form">
@@ -77,7 +80,7 @@ export class QueryEditor extends PureComponent<Props, State> {
className="gf-form-input width-8"
onBlur={onRunQuery}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
this.onChange({ ...query, id: event.target.value })
this.onChange({ ...metricsQuery, id: event.target.value })
}
validationEvents={idValidationEvents}
value={query.id}
@@ -93,9 +96,9 @@ export class QueryEditor extends PureComponent<Props, State> {
<Input
className="gf-form-input"
onBlur={onRunQuery}
value={query.expression}
value={query.expression || ''}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
this.onChange({ ...query, expression: event.target.value })
this.onChange({ ...metricsQuery, expression: event.target.value })
}
/>
</QueryField>
@@ -107,11 +110,11 @@ export class QueryEditor extends PureComponent<Props, State> {
<QueryField label="Period" tooltip="Minimum interval between points in seconds">
<Input
className="gf-form-input width-8"
value={query.period}
value={query.period || ''}
placeholder="auto"
onBlur={onRunQuery}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
this.onChange({ ...query, period: event.target.value })
this.onChange({ ...metricsQuery, period: event.target.value })
}
/>
</QueryField>
@@ -121,14 +124,22 @@ export class QueryEditor extends PureComponent<Props, State> {
label="Alias"
tooltip="Alias replacement variables: {{metric}}, {{stat}}, {{namespace}}, {{region}}, {{period}}, {{label}}, {{YOUR_DIMENSION_NAME}}"
>
<Alias value={query.alias} onChange={(value: string) => this.onChange({ ...query, alias: value })} />
<Alias
value={metricsQuery.alias}
onChange={(value: string) => this.onChange({ ...metricsQuery, alias: value })}
/>
</QueryField>
<Switch
label="Match Exact"
labelClass="query-keyword"
tooltip="Only show metrics that exactly match all defined dimension names."
checked={query.matchExact}
onChange={() => this.onChange({ ...query, matchExact: !query.matchExact })}
checked={metricsQuery.matchExact}
onChange={() =>
this.onChange({
...metricsQuery,
matchExact: !metricsQuery.matchExact,
})
}
/>
<label className="gf-form-label">
<a
@@ -157,7 +168,7 @@ export class QueryEditor extends PureComponent<Props, State> {
</tr>
</thead>
<tbody>
{data?.series[0]?.meta?.gmdMeta.map(({ ID, Expression, Period }: any) => (
{data?.series?.[0]?.meta?.gmdMeta?.map(({ ID, Expression, Period }: any) => (
<tr key={ID}>
<td>{ID}</td>
<td>{Expression}</td>

View File

@@ -1,16 +1,15 @@
import React, { useState, useEffect } from 'react';
import { SelectableValue } from '@grafana/data';
import { Segment, SegmentAsync } from '@grafana/ui';
import { CloudWatchQuery, SelectableStrings } from '../types';
import CloudWatchDatasource from '../datasource';
import { Stats, Dimensions, QueryInlineField } from './';
import { CloudWatchQuery, SelectableStrings, CloudWatchMetricsQuery } from '../types';
import { CloudWatchDatasource } from '../datasource';
import { Stats, Dimensions, QueryInlineField } from '.';
export type Props = {
query: CloudWatchQuery;
datasource: CloudWatchDatasource;
onRunQuery?: () => void;
onChange: (value: CloudWatchQuery) => void;
hideWilcard?: boolean;
};
interface State {
@@ -21,13 +20,14 @@ interface State {
showMeta: boolean;
}
export function QueryFieldsEditor({
export function MetricsQueryFieldsEditor({
query,
datasource,
onChange,
onRunQuery = () => {},
hideWilcard = false,
}: React.PropsWithChildren<Props>) {
const metricsQuery = query as CloudWatchMetricsQuery;
const [state, setState] = useState<State>({
regions: [],
namespaces: [],
@@ -74,13 +74,13 @@ export function QueryFieldsEditor({
// Load dimension values based on current selected dimensions.
// Remove the new dimension key and all dimensions that has a wildcard as selected value
const loadDimensionValues = (newKey: string) => {
const { [newKey]: value, ...dim } = query.dimensions;
const { [newKey]: value, ...dim } = metricsQuery.dimensions;
const newDimensions = Object.entries(dim).reduce(
(result, [key, value]) => (value === '*' ? result : { ...result, [key]: value }),
{}
);
return datasource
.getDimensionValues(query.region, query.namespace, query.metricName, newKey, newDimensions)
.getDimensionValues(query.region, query.namespace, metricsQuery.metricName, newKey, newDimensions)
.then(values => (values.length ? [{ value: '*', text: '*', label: '*' }, ...values] : values))
.then(appendTemplateVariables);
};
@@ -112,27 +112,27 @@ export function QueryFieldsEditor({
<QueryInlineField label="Metric Name">
<SegmentAsync
value={query.metricName}
value={metricsQuery.metricName}
placeholder="Select metric name"
allowCustomValue
loadOptions={loadMetricNames}
onChange={({ value: metricName }) => onQueryChange({ ...query, metricName })}
onChange={({ value: metricName }) => onQueryChange({ ...metricsQuery, metricName })}
/>
</QueryInlineField>
<QueryInlineField label="Stats">
<Stats
stats={datasource.standardStatistics.map(toOption)}
values={query.statistics}
onChange={statistics => onQueryChange({ ...query, statistics })}
values={metricsQuery.statistics}
onChange={statistics => onQueryChange({ ...metricsQuery, statistics })}
variableOptionGroup={variableOptionGroup}
/>
</QueryInlineField>
<QueryInlineField label="Dimensions">
<Dimensions
dimensions={query.dimensions}
onChange={dimensions => onQueryChange({ ...query, dimensions })}
dimensions={metricsQuery.dimensions}
onChange={dimensions => onQueryChange({ ...metricsQuery, dimensions })}
loadKeys={() => datasource.getDimensionKeys(query.namespace, query.region).then(appendTemplateVariables)}
loadValues={loadDimensionValues}
/>

View File

@@ -0,0 +1,48 @@
import React, { PureComponent } from 'react';
import { ExploreQueryFieldProps, ExploreMode } from '@grafana/data';
import { Segment } from '@grafana/ui';
import { CloudWatchQuery } from '../types';
import { CloudWatchDatasource } from '../datasource';
import { QueryInlineField } from './';
import { MetricsQueryEditor } from './MetricsQueryEditor';
import LogsQueryEditor from './LogsQueryEditor';
import { config } from '@grafana/runtime';
export type Props = ExploreQueryFieldProps<CloudWatchDatasource, CloudWatchQuery>;
interface State {
queryMode: ExploreMode;
}
export class PanelQueryEditor extends PureComponent<Props, State> {
state: State = { queryMode: (this.props.query.queryMode as ExploreMode) ?? ExploreMode.Metrics };
onQueryModeChange(mode: ExploreMode) {
this.setState({
queryMode: mode,
});
}
render() {
const { queryMode } = this.state;
const cloudwatchLogsDisabled = !config.featureToggles.cloudwatchLogs;
return (
<>
{!cloudwatchLogsDisabled && (
<QueryInlineField label="Query Mode">
<Segment
value={queryMode}
options={[
{ label: 'Metrics', value: ExploreMode.Metrics },
{ label: 'Logs', value: ExploreMode.Logs },
]}
onChange={({ value }) => this.onQueryModeChange(value ?? ExploreMode.Metrics)}
/>
</QueryInlineField>
)}
{queryMode === ExploreMode.Logs ? <LogsQueryEditor {...this.props} /> : <MetricsQueryEditor {...this.props} />}
</>
);
}
}

View File

@@ -2,4 +2,6 @@ export { Stats } from './Stats';
export { Dimensions } from './Dimensions';
export { QueryInlineField, QueryField } from './Forms';
export { Alias } from './Alias';
export { QueryFieldsEditor } from './QueryFieldsEditor';
export { MetricsQueryFieldsEditor } from './MetricsQueryFieldsEditor';
export { PanelQueryEditor } from './PanelQueryEditor';
export { CloudWatchLogsQueryEditor } from './LogsQueryEditor';

View File

@@ -13,15 +13,41 @@ import {
dateMath,
ScopedVars,
TimeRange,
DataFrame,
resultsToDataFrames,
DataQueryResponse,
LoadingState,
toDataFrame,
guessFieldTypes,
FieldType,
LogRowModel,
} from '@grafana/data';
import { getBackendSrv } from '@grafana/runtime';
import { TemplateSrv } from 'app/features/templating/template_srv';
import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { ThrottlingErrorMessage } from './components/ThrottlingErrorMessage';
import memoizedDebounce from './memoizedDebounce';
import { CloudWatchJsonData, CloudWatchQuery } from './types';
import {
CloudWatchQuery,
CloudWatchJsonData,
CloudWatchMetricsQuery,
CloudWatchLogsQuery,
CloudWatchLogsQueryStatus,
DescribeLogGroupsRequest,
TSDBResponse,
MetricRequest,
GetLogGroupFieldsRequest,
GetLogGroupFieldsResponse,
LogAction,
GetLogEventsRequest,
} from './types';
import { from, empty, Observable } from 'rxjs';
import { delay, expand, map, mergeMap, tap, finalize, catchError } from 'rxjs/operators';
import { CloudWatchLanguageProvider } from './language_provider';
const TSDB_QUERY_ENDPOINT = '/api/tsdb/query';
import { VariableWithMultiSupport } from 'app/features/templating/types';
import { RowContextOptions } from '@grafana/ui/src/components/Logs/LogRowContextProvider';
const displayAlert = (datasourceName: string, region: string) =>
store.dispatch(
@@ -37,7 +63,11 @@ const displayAlert = (datasourceName: string, region: string) =>
const displayCustomError = (title: string, message: string) =>
store.dispatch(notifyApp(createErrorNotification(title, message)));
export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery, CloudWatchJsonData> {
// TODO: Temporary times here, could just change to some fixed number.
const MAX_ATTEMPTS = 8;
const POLLING_TIMES = [100, 200, 500, 1000];
export class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery, CloudWatchJsonData> {
type: any;
proxyUrl: any;
defaultRegion: any;
@@ -45,6 +75,8 @@ export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery,
datasourceName: string;
debouncedAlert: (datasourceName: string, region: string) => void;
debouncedCustomAlert: (title: string, message: string) => void;
logQueries: Set<{ id: string; region: string }>;
languageProvider: CloudWatchLanguageProvider;
/** @ngInject */
constructor(
@@ -60,52 +92,78 @@ export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery,
this.standardStatistics = ['Average', 'Maximum', 'Minimum', 'Sum', 'SampleCount'];
this.debouncedAlert = memoizedDebounce(displayAlert, AppNotificationTimeout.Error);
this.debouncedCustomAlert = memoizedDebounce(displayCustomError, AppNotificationTimeout.Error);
this.logQueries = new Set<{ id: string; region: string }>();
this.languageProvider = new CloudWatchLanguageProvider(this);
}
query(options: DataQueryRequest<CloudWatchQuery>) {
options = angular.copy(options);
const queries = _.filter(options.targets, item => {
return (
(item.id !== '' || item.hide !== true) &&
((!!item.region && !!item.namespace && !!item.metricName && !_.isEmpty(item.statistics)) ||
item.expression?.length > 0)
);
}).map(item => {
item.region = this.replace(this.getActualRegion(item.region), options.scopedVars, true, 'region');
item.namespace = this.replace(item.namespace, options.scopedVars, true, 'namespace');
item.metricName = this.replace(item.metricName, options.scopedVars, true, 'metric name');
item.dimensions = this.convertDimensionFormat(item.dimensions, options.scopedVars);
item.statistics = item.statistics.map(stat => this.replace(stat, options.scopedVars, true, 'statistics'));
item.period = String(this.getPeriod(item, options)); // use string format for period in graph query, and alerting
item.id = this.templateSrv.replace(item.id, options.scopedVars);
item.expression = this.templateSrv.replace(item.expression, options.scopedVars);
const firstTarget = options.targets[0];
// valid ExtendedStatistics is like p90.00, check the pattern
const hasInvalidStatistics = item.statistics.some(s => {
if (s.indexOf('p') === 0) {
const matches = /^p\d{2}(?:\.\d{1,2})?$/.exec(s);
return !matches || matches[0] !== s;
if (firstTarget.queryMode === 'Logs') {
const queryParams = options.targets.map((target: CloudWatchLogsQuery) => ({
queryString: target.expression,
refId: target.refId,
logGroupNames: target.logGroupNames,
region: this.replace(this.getActualRegion(target.region), options.scopedVars, true, 'region'),
}));
return this.makeLogActionRequest('StartQuery', queryParams, options.scopedVars).pipe(
mergeMap(dataFrames =>
this.logsQuery(
dataFrames.map(dataFrame => ({
queryId: dataFrame.fields[0].values.get(0),
region: dataFrame.meta?.custom?.['Region'] ?? 'default',
refId: dataFrame.refId,
}))
)
)
);
}
const queries = options.targets
.filter(
item =>
(item.id !== '' || item.hide !== true) &&
item.queryMode !== 'Logs' &&
((!!item.region && !!item.namespace && !!item.metricName && !_.isEmpty(item.statistics)) ||
item.expression?.length > 0)
)
.map((item: CloudWatchMetricsQuery) => {
item.region = this.replace(this.getActualRegion(item.region), options.scopedVars, true, 'region');
item.namespace = this.replace(item.namespace, options.scopedVars, true, 'namespace');
item.metricName = this.replace(item.metricName, options.scopedVars, true, 'metric name');
item.dimensions = this.convertDimensionFormat(item.dimensions, options.scopedVars);
item.statistics = item.statistics.map(stat => this.replace(stat, options.scopedVars, true, 'statistics'));
item.period = String(this.getPeriod(item, options)); // use string format for period in graph query, and alerting
item.id = this.templateSrv.replace(item.id, options.scopedVars);
item.expression = this.templateSrv.replace(item.expression, options.scopedVars);
// valid ExtendedStatistics is like p90.00, check the pattern
const hasInvalidStatistics = item.statistics.some(s => {
if (s.indexOf('p') === 0) {
const matches = /^p\d{2}(?:\.\d{1,2})?$/.exec(s);
return !matches || matches[0] !== s;
}
return false;
});
if (hasInvalidStatistics) {
throw { message: 'Invalid extended statistics' };
}
return false;
});
if (hasInvalidStatistics) {
throw { message: 'Invalid extended statistics' };
}
return _.extend(
{
return {
refId: item.refId,
intervalMs: options.intervalMs,
maxDataPoints: options.maxDataPoints,
datasourceId: this.id,
type: 'timeSeriesQuery',
},
item
);
});
...item,
};
});
// No valid targets, return the empty result to save a round trip.
if (_.isEmpty(queries)) {
@@ -121,11 +179,128 @@ export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery,
return this.performTimeSeriesQuery(request, options.range);
}
logsQuery(queryParams: Array<{ queryId: string; limit?: number; region: string }>): Observable<DataQueryResponse> {
this.logQueries.clear();
queryParams.forEach(param => this.logQueries.add({ id: param.queryId, region: param.region }));
return withTeardown(
this.makeLogActionRequest('GetQueryResults', queryParams).pipe(
expand((dataFrames, i) => {
return dataFrames.every(
dataFrame => dataFrame.meta?.custom?.['Status'] === CloudWatchLogsQueryStatus.Complete
) || i >= MAX_ATTEMPTS
? empty()
: this.makeLogActionRequest('GetQueryResults', queryParams).pipe(
delay(POLLING_TIMES[Math.min(i, POLLING_TIMES.length - 1)])
);
}),
tap(dataFrames => {
dataFrames.forEach((dataframe, i) => {
if (
[
CloudWatchLogsQueryStatus.Complete,
CloudWatchLogsQueryStatus.Cancelled,
CloudWatchLogsQueryStatus.Failed,
].includes(dataframe.meta?.custom?.['Status'])
) {
this.logQueries.delete({ id: queryParams[i].queryId, region: queryParams[i].region });
}
});
}),
map(dataFrames => {
const correctedFrames = dataFrames.map(frame => correctFrameTypes(frame));
return {
data: correctedFrames,
key: 'test-key',
state: correctedFrames.every(
dataFrame => dataFrame.meta?.custom?.['Status'] === CloudWatchLogsQueryStatus.Complete
)
? LoadingState.Done
: LoadingState.Loading,
};
})
),
() => this.stopQueries()
);
}
stopQueries() {
if (this.logQueries.size > 0) {
this.makeLogActionRequest(
'StopQuery',
[...this.logQueries.values()].map(logQuery => ({ queryId: logQuery.id, region: logQuery.region })),
null,
false
).pipe(finalize(() => this.logQueries.clear()));
}
}
async describeLogGroups(params: DescribeLogGroupsRequest): Promise<string[]> {
const dataFrames = await this.makeLogActionRequest('DescribeLogGroups', [params]).toPromise();
const logGroupNames = dataFrames[0].fields[0].values.toArray();
return logGroupNames && logGroupNames.length > 0 ? logGroupNames : [];
}
async getLogGroupFields(params: GetLogGroupFieldsRequest): Promise<GetLogGroupFieldsResponse> {
const dataFrames = await this.makeLogActionRequest('GetLogGroupFields', [params]).toPromise();
const fieldNames = dataFrames[0].fields[0].values.toArray();
const fieldPercentages = dataFrames[0].fields[1].values.toArray();
const getLogGroupFieldsResponse = {
logGroupFields: fieldNames.map((val, i) => ({ name: val, percent: fieldPercentages[i] })) ?? [],
};
return getLogGroupFieldsResponse;
}
getLogRowContext = async (
row: LogRowModel,
{ limit = 10, direction = 'BACKWARD' }: RowContextOptions = {}
): Promise<{ data: DataFrame[] }> => {
let logStreamField = null;
let logField = null;
for (const field of row.dataFrame.fields) {
if (field.name === '@logStream') {
logStreamField = field;
if (logField !== null) {
break;
}
} else if (field.name === '@log') {
logField = field;
if (logStreamField !== null) {
break;
}
}
}
const requestParams: GetLogEventsRequest = {
limit,
startFromHead: direction !== 'BACKWARD',
logGroupName: parseLogGroupName(logField!.values.get(row.rowIndex)),
logStreamName: logStreamField!.values.get(row.rowIndex),
};
if (direction === 'BACKWARD') {
requestParams.endTime = row.timeEpochMs;
} else {
requestParams.startTime = row.timeEpochMs;
}
const dataFrames = await this.makeLogActionRequest('GetLogEvents', [requestParams]).toPromise();
return {
data: dataFrames,
};
};
get variables() {
return this.templateSrv.getVariables().map(v => `$${v.name}`);
}
getPeriod(target: any, options: any) {
getPeriod(target: CloudWatchMetricsQuery, options: any) {
let period = this.templateSrv.replace(target.period, options.scopedVars);
if (period && period.toLowerCase() !== 'auto') {
if (/^\d+$/.test(period)) {
@@ -143,7 +318,7 @@ export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery,
}
buildCloudwatchConsoleUrl(
{ region, namespace, metricName, dimensions, statistics, expression }: CloudWatchQuery,
{ region, namespace, metricName, dimensions, statistics, expression }: CloudWatchMetricsQuery,
start: string,
end: string,
title: string,
@@ -193,9 +368,9 @@ export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery,
)}`;
}
performTimeSeriesQuery(request: any, { from, to }: TimeRange): Promise<any> {
return this.awsRequest('/api/tsdb/query', request)
.then((res: any) => {
performTimeSeriesQuery(request: MetricRequest, { from, to }: TimeRange): Promise<any> {
return this.awsRequest(TSDB_QUERY_ENDPOINT, request)
.then((res: TSDBResponse) => {
if (!res.results) {
return { data: [] };
}
@@ -248,8 +423,8 @@ export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery,
if (/^Throttling:.*/.test(err.data.message)) {
const failedRedIds = Object.keys(err.data.results);
const regionsAffected = Object.values(request.queries).reduce(
(res: string[], { refId, region }: CloudWatchQuery) =>
!failedRedIds.includes(refId) || res.includes(region) ? res : [...res, region],
(res: string[], { refId, region }) =>
(refId && !failedRedIds.includes(refId)) || res.includes(region) ? res : [...res, region],
[]
) as string[];
@@ -264,40 +439,76 @@ export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery,
});
}
transformSuggestDataFromTable(suggestData: any) {
return _.map(suggestData.results['metricFindQuery'].tables[0].rows, v => {
return {
text: v[0],
value: v[1],
label: v[1],
};
});
transformSuggestDataFromTable(suggestData: TSDBResponse) {
return suggestData.results['metricFindQuery'].tables[0].rows.map(([text, value]) => ({
text,
value,
label: value,
}));
}
doMetricQueryRequest(subtype: any, parameters: any) {
doMetricQueryRequest(subtype: string, parameters: any) {
const range = this.timeSrv.timeRange();
return this.awsRequest('/api/tsdb/query', {
return this.awsRequest(TSDB_QUERY_ENDPOINT, {
from: range.from.valueOf().toString(),
to: range.to.valueOf().toString(),
queries: [
_.extend(
{
refId: 'metricFindQuery',
intervalMs: 1, // dummy
maxDataPoints: 1, // dummy
datasourceId: this.id,
type: 'metricFindQuery',
subtype: subtype,
},
parameters
),
{
refId: 'metricFindQuery',
intervalMs: 1, // dummy
maxDataPoints: 1, // dummy
datasourceId: this.id,
type: 'metricFindQuery',
subtype: subtype,
...parameters,
},
],
}).then((r: any) => {
}).then((r: TSDBResponse) => {
return this.transformSuggestDataFromTable(r);
});
}
getRegions() {
makeLogActionRequest(
subtype: LogAction,
queryParams: any[],
scopedVars?: any,
makeReplacements = true
): Observable<DataFrame[]> {
const range = this.timeSrv.timeRange();
const requestParams = {
from: range.from.valueOf().toString(),
to: range.to.valueOf().toString(),
queries: queryParams.map((param: any) => ({
refId: 'A',
intervalMs: 1, // dummy
maxDataPoints: 1, // dummy
datasourceId: this.id,
type: 'logAction',
subtype: subtype,
...param,
})),
};
if (makeReplacements) {
requestParams.queries.forEach(
query => (query.region = this.replace(this.getActualRegion(this.defaultRegion), scopedVars, true, 'region'))
);
}
return from(this.awsRequest(TSDB_QUERY_ENDPOINT, requestParams)).pipe(
map(response => resultsToDataFrames(response)),
catchError(err => {
if (err.data?.error) {
throw err.data.error;
}
throw err;
})
);
}
getRegions(): Promise<Array<{ label: string; value: string; text: string }>> {
return this.doMetricQueryRequest('regions', null).then((regions: any) => [
{ label: 'default', value: 'default', text: 'default' },
...regions,
@@ -454,9 +665,7 @@ export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery,
annotationQuery(options: any) {
const annotation = options.annotation;
const statistics = _.map(annotation.statistics, s => {
return this.templateSrv.replace(s);
});
const statistics = annotation.statistics.map((s: any) => this.templateSrv.replace(s));
const defaultPeriod = annotation.prefixMatching ? '' : '300';
let period = annotation.period || defaultPeriod;
period = parseInt(period, 10);
@@ -472,31 +681,25 @@ export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery,
alarmNamePrefix: annotation.alarmNamePrefix || '',
};
return this.awsRequest('/api/tsdb/query', {
return this.awsRequest(TSDB_QUERY_ENDPOINT, {
from: options.range.from.valueOf().toString(),
to: options.range.to.valueOf().toString(),
queries: [
_.extend(
{
refId: 'annotationQuery',
intervalMs: 1, // dummy
maxDataPoints: 1, // dummy
datasourceId: this.id,
type: 'annotationQuery',
},
parameters
),
{
refId: 'annotationQuery',
datasourceId: this.id,
type: 'annotationQuery',
...parameters,
},
],
}).then((r: any) => {
return _.map(r.results['annotationQuery'].tables[0].rows, v => {
return {
annotation: annotation,
time: Date.parse(v[0]),
title: v[1],
tags: [v[2]],
text: v[3],
};
});
}).then((r: TSDBResponse) => {
return r.results['annotationQuery'].tables[0].rows.map(v => ({
annotation: annotation,
time: Date.parse(v[0]),
title: v[1],
tags: [v[2]],
text: v[3],
}));
});
}
@@ -518,36 +721,39 @@ export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery,
const metricName = 'EstimatedCharges';
const dimensions = {};
return this.getDimensionValues(region, namespace, metricName, 'ServiceName', dimensions).then(() => {
return { status: 'success', message: 'Data source is working' };
});
return this.getDimensionValues(region, namespace, metricName, 'ServiceName', dimensions).then(() => ({
status: 'success',
message: 'Data source is working',
}));
}
awsRequest(url: string, data: any) {
async awsRequest(url: string, data: MetricRequest) {
const options = {
method: 'POST',
url,
data,
};
return getBackendSrv()
.datasourceRequest(options)
.then((result: any) => {
return result.data;
});
const result = await getBackendSrv().datasourceRequest(options);
return result.data;
}
getDefaultRegion() {
return this.defaultRegion;
}
getActualRegion(region: string) {
if (region === 'default' || _.isEmpty(region)) {
getActualRegion(region?: string) {
if (region === 'default' || region === undefined || region === '') {
return this.getDefaultRegion();
}
return region;
}
showContextToggle() {
return true;
}
convertToCloudWatchTime(date: any, roundUp: any) {
if (_.isString(date)) {
date = dateMath.parse(date, roundUp);
@@ -594,3 +800,39 @@ export default class CloudWatchDatasource extends DataSourceApi<CloudWatchQuery,
return this.templateSrv.replace(target, scopedVars);
}
}
function withTeardown<T = any>(observable: Observable<T>, onUnsubscribe: () => void): Observable<T> {
return new Observable<T>(subscriber => {
const innerSub = observable.subscribe({
next: val => subscriber.next(val),
error: err => subscriber.next(err),
complete: () => subscriber.complete(),
});
return () => {
innerSub.unsubscribe();
onUnsubscribe();
};
});
}
function correctFrameTypes(frame: DataFrame): DataFrame {
frame.fields.forEach(field => {
if (field.type === FieldType.string) {
field.type = FieldType.other;
}
});
const correctedFrame = guessFieldTypes(frame);
// const timeField = correctedFrame.fields.find(field => field.name === '@timestamp');
// if (timeField) {
// timeField.type = FieldType.time;
// }
return correctedFrame;
}
function parseLogGroupName(logIdentifier: string): string {
const colonIndex = logIdentifier.lastIndexOf(':');
return logIdentifier.substr(colonIndex + 1);
}

View File

@@ -0,0 +1,429 @@
// Libraries
import _ from 'lodash';
// Services & Utils
import syntax, {
QUERY_COMMANDS,
FUNCTIONS,
AGGREGATION_FUNCTIONS_STATS,
STRING_FUNCTIONS,
DATETIME_FUNCTIONS,
IP_FUNCTIONS,
BOOLEAN_FUNCTIONS,
NUMERIC_OPERATORS,
} from './syntax';
// Types
import { CloudWatchQuery } from './types';
import { dateTime, AbsoluteTimeRange, LanguageProvider, HistoryItem } from '@grafana/data';
import { CloudWatchDatasource } from './datasource';
import { CompletionItem, TypeaheadInput, TypeaheadOutput, Token } from '@grafana/ui';
import { Grammar } from 'prismjs';
const HISTORY_ITEM_COUNT = 10;
const HISTORY_COUNT_CUTOFF = 1000 * 60 * 60 * 24; // 24h
const NS_IN_MS = 1000000;
export const LABEL_REFRESH_INTERVAL = 1000 * 30; // 30sec
const wrapLabel = (label: string) => ({ label });
export const rangeToParams = (range: AbsoluteTimeRange) => ({ start: range.from * NS_IN_MS, end: range.to * NS_IN_MS });
export type CloudWatchHistoryItem = HistoryItem<CloudWatchQuery>;
type TypeaheadContext = {
history?: CloudWatchHistoryItem[];
absoluteRange?: AbsoluteTimeRange;
logGroupNames?: string[];
};
export function addHistoryMetadata(item: CompletionItem, history: CloudWatchHistoryItem[]): CompletionItem {
const cutoffTs = Date.now() - HISTORY_COUNT_CUTOFF;
const historyForItem = history.filter(h => h.ts > cutoffTs && h.query.expression === item.label);
let hint = `Queried ${historyForItem.length} times in the last 24h.`;
const recent = historyForItem[0];
if (recent) {
const lastQueried = dateTime(recent.ts).fromNow();
hint = `${hint} Last queried ${lastQueried}.`;
}
return {
...item,
documentation: hint,
};
}
export class CloudWatchLanguageProvider extends LanguageProvider {
logLabelOptions: any[];
logLabelFetchTs?: number;
started: boolean;
initialRange: AbsoluteTimeRange;
datasource: CloudWatchDatasource;
constructor(datasource: CloudWatchDatasource, initialValues?: any) {
super();
this.datasource = datasource;
Object.assign(this, initialValues);
}
// Strip syntax chars
cleanText = (s: string) => s.replace(/[()]/g, '').trim();
getSyntax(): Grammar {
return syntax;
}
request = (url: string, params?: any): Promise<{ data: { data: string[] } }> => {
return this.datasource.awsRequest(url, params);
};
start = () => {
if (!this.startTask) {
this.startTask = Promise.resolve().then(() => {
this.started = true;
return [];
});
}
return this.startTask;
};
fetchFields = _.throttle(async (logGroups: string[]) => {
const results = await Promise.all(
logGroups.map(logGroup => this.datasource.getLogGroupFields({ logGroupName: logGroup }))
);
return [
...new Set<string>(
results.reduce((acc: string[], cur) => acc.concat(cur.logGroupFields?.map(f => f.name) as string[]), [])
).values(),
];
}, 30 * 1000);
/**
* Return suggestions based on input that can be then plugged into a typeahead dropdown.
* Keep this DOM-free for testing
* @param input
* @param context Is optional in types but is required in case we are doing getLabelCompletionItems
* @param context.absoluteRange Required in case we are doing getLabelCompletionItems
* @param context.history Optional used only in getEmptyCompletionItems
*/
async provideCompletionItems(input: TypeaheadInput, context?: TypeaheadContext): Promise<TypeaheadOutput> {
//console.log('Providing completion items...');
const { value } = input;
// Get tokens
const tokens = value?.data.get('tokens');
if (!tokens || !tokens.length) {
return { suggestions: [] };
}
const curToken: Token = tokens.filter(
(token: any) =>
token.offsets.start <= value!.selection?.start?.offset && token.offsets.end >= value!.selection?.start?.offset
)[0];
const isFirstToken = curToken.prev === null || curToken.prev === undefined;
const prevToken = prevNonWhitespaceToken(curToken);
const funcsWithFieldArgs = [
'avg',
'count',
'count_distinct',
'earliest',
'latest',
'sortsFirst',
'sortsLast',
'max',
'min',
'pct',
'stddev',
'ispresent',
'fromMillis',
'toMillis',
'isempty',
'isblank',
'isValidIp',
'isValidIpV4',
'isValidIpV6',
'isIpInSubnet',
'isIpv4InSubnet',
'isIpv6InSubnet',
].map(funcName => funcName.toLowerCase());
if (curToken.content === '(' && prevToken != null) {
if (funcsWithFieldArgs.includes(prevToken.content.toLowerCase()) && prevToken.types.includes('function')) {
const suggs = await this.getFieldCompletionItems(context?.logGroupNames ?? []);
return suggs;
}
}
// if (prevToken === null) {
// return {
// suggestions: [],
// };
// }
// if (prevToken) {
// console.log(`Previous token: '${prevToken.content}'`);
// }
const isCommandStart = isFirstToken || (!isFirstToken && prevToken?.types.includes('command-separator'));
//console.log(`Is command start? ${isCommandStart}`);
if (isCommandStart) {
return this.getCommandCompletionItems();
} else if (!isFirstToken) {
if (prevToken?.types.includes('keyword')) {
return this.handleKeyword(prevToken, context);
}
if (prevToken?.types.includes('comparison-operator')) {
const suggs = await this.getFieldCompletionItems(context?.logGroupNames ?? []);
const boolFuncSuggs = this.getBoolFuncCompletionItems();
const numFuncSuggs = this.getNumericFuncCompletionItems();
suggs.suggestions.push(...boolFuncSuggs.suggestions, ...numFuncSuggs.suggestions);
return suggs;
}
const commandToken = this.findCommandToken(curToken);
if (commandToken !== null) {
const typeaheadOutput = await this.handleCommand(commandToken, curToken, context);
return typeaheadOutput;
}
}
return {
suggestions: [],
};
}
handleKeyword = async (token: Token, context?: TypeaheadContext): Promise<TypeaheadOutput | null> => {
if (token.content.toLowerCase() === 'by') {
const suggs = await this.getFieldCompletionItems(context?.logGroupNames ?? []);
const functionSuggestions = [
{ prefixMatch: true, label: 'Functions', items: STRING_FUNCTIONS.concat(DATETIME_FUNCTIONS, IP_FUNCTIONS) },
];
suggs.suggestions.push(...functionSuggestions);
return suggs;
}
return null;
};
handleCommand = async (commandToken: Token, curToken: Token, context: TypeaheadContext): Promise<TypeaheadOutput> => {
const queryCommand = commandToken.content.toLowerCase();
const prevToken = prevNonWhitespaceToken(curToken);
const currentTokenIsFirstArg = prevToken === commandToken;
// console.log(
// `Query Command: '${queryCommand}'. Previous token: '${prevToken}'. First arg? ${currentTokenIsFirstArg}`
// );
if (queryCommand === 'sort') {
if (currentTokenIsFirstArg) {
return await this.getFieldCompletionItems(context.logGroupNames ?? []);
} else if (prevToken?.types.includes('field-name')) {
// suggest sort options
return {
suggestions: [
{
prefixMatch: true,
label: 'Sort Order',
items: [
{
label: 'asc',
},
{ label: 'desc' },
],
},
],
};
}
}
if (queryCommand === 'parse') {
if (currentTokenIsFirstArg) {
return await this.getFieldCompletionItems(context.logGroupNames ?? []);
}
}
let typeaheadOutput: TypeaheadOutput | null = null;
if (
(commandToken.next?.types.includes('whitespace') && commandToken.next.next === null) ||
nextNonWhitespaceToken(commandToken) === curToken ||
(curToken.content === ',' && curToken.types.includes('punctuation')) ||
(curToken.prev?.content === ',' && curToken.prev.types.includes('punctuation'))
) {
if (['display', 'fields'].includes(queryCommand)) {
// Current token comes straight after command OR after comma
typeaheadOutput = await this.getFieldCompletionItems(context.logGroupNames ?? []);
typeaheadOutput.suggestions.push(...this.getFunctionCompletionItems().suggestions);
return typeaheadOutput;
} else if (queryCommand === 'stats') {
typeaheadOutput = this.getStatsAggCompletionItems();
} else if (queryCommand === 'filter') {
if (currentTokenIsFirstArg) {
const sugg = await this.getFieldCompletionItems(context.logGroupNames ?? []);
const boolFuncs = this.getBoolFuncCompletionItems();
sugg.suggestions.push(...boolFuncs.suggestions);
return sugg;
}
}
if (
(curToken.content === ',' && curToken.types.includes('punctuation')) ||
(commandToken.next?.types.includes('whitespace') && commandToken.next.next === null)
) {
typeaheadOutput?.suggestions.forEach(group => {
group.skipFilter = true;
});
}
return typeaheadOutput!;
}
return { suggestions: [] };
};
findCommandToken = (startToken: Token): Token | null => {
let thisToken = { ...startToken };
while (thisToken.prev !== null) {
thisToken = thisToken.prev;
const isFirstCommand = thisToken.types.includes('query-command') && thisToken.prev === null;
if (thisToken.types.includes('command-separator') || isFirstCommand) {
// next token should be command
if (!isFirstCommand && thisToken.next?.types.includes('query-command')) {
return thisToken.next;
} else {
return thisToken;
}
}
}
return null;
};
getBeginningCompletionItems = (context: TypeaheadContext): TypeaheadOutput => {
return {
suggestions: [
...this.getEmptyCompletionItems(context).suggestions,
...this.getCommandCompletionItems().suggestions,
],
};
};
getEmptyCompletionItems(context: TypeaheadContext): TypeaheadOutput {
const history = context?.history;
const suggestions = [];
if (history?.length) {
const historyItems = _.chain(history)
.map(h => h.query.expression)
.filter()
.uniq()
.take(HISTORY_ITEM_COUNT)
.map(wrapLabel)
.map((item: CompletionItem) => addHistoryMetadata(item, history))
.value();
suggestions.push({
prefixMatch: true,
skipSort: true,
label: 'History',
items: historyItems,
});
}
return { suggestions };
}
getCommandCompletionItems = (): TypeaheadOutput => {
return { suggestions: [{ prefixMatch: true, label: 'Commands', items: QUERY_COMMANDS }] };
};
getFunctionCompletionItems = (): TypeaheadOutput => {
return { suggestions: [{ prefixMatch: true, label: 'Functions', items: FUNCTIONS }] };
};
getStatsAggCompletionItems = (): TypeaheadOutput => {
return { suggestions: [{ prefixMatch: true, label: 'Functions', items: AGGREGATION_FUNCTIONS_STATS }] };
};
getBoolFuncCompletionItems = (): TypeaheadOutput => {
return {
suggestions: [
{
prefixMatch: true,
label: 'Functions',
items: BOOLEAN_FUNCTIONS,
},
],
};
};
getNumericFuncCompletionItems = (): TypeaheadOutput => {
return {
suggestions: [
{
prefixMatch: true,
label: 'Functions',
items: NUMERIC_OPERATORS,
},
],
};
};
getFieldCompletionItems = async (logGroups: string[]): Promise<TypeaheadOutput> => {
//console.log(`Fetching fields... ${logGroups}`);
const fields = await this.fetchFields(logGroups);
//console.log(fields);
return {
suggestions: [
{
prefixMatch: true,
label: 'Fields',
items: fields.map(field => ({
label: field,
insertText: field.match(/@?[_a-zA-Z]+[_.0-9a-zA-Z]*/) ? field : `\`${field}\``,
})),
},
],
};
};
}
function nextNonWhitespaceToken(token: Token): Token | null {
let curToken = token;
while (curToken.next) {
if (curToken.next.types.includes('whitespace')) {
curToken = curToken.next;
} else {
return curToken.next;
}
}
return null;
}
function prevNonWhitespaceToken(token: Token): Token | null {
let curToken = token;
while (curToken.prev) {
if (curToken.prev.types.includes('whitespace')) {
curToken = curToken.prev;
} else {
return curToken.prev;
}
}
return null;
}

View File

@@ -1,15 +1,20 @@
import './query_parameter_ctrl';
import { DataSourcePlugin } from '@grafana/data';
import { ConfigEditor } from './components/ConfigEditor';
import { QueryEditor } from './components/QueryEditor';
import CloudWatchDatasource from './datasource';
import { CloudWatchDatasource } from './datasource';
import { CloudWatchAnnotationsQueryCtrl } from './annotations_query_ctrl';
import { CloudWatchJsonData, CloudWatchQuery } from './types';
import { CloudWatchLogsQueryEditor } from './components/LogsQueryEditor';
import { PanelQueryEditor } from './components/PanelQueryEditor';
import LogsCheatSheet from './components/LogsCheatSheet';
import { CombinedMetricsEditor } from './components/CombinedMetricsEditor';
export const plugin = new DataSourcePlugin<CloudWatchDatasource, CloudWatchQuery, CloudWatchJsonData>(
CloudWatchDatasource
)
.setExploreStartPage(LogsCheatSheet)
.setConfigEditor(ConfigEditor)
.setQueryEditor(QueryEditor)
.setExploreQueryField(QueryEditor)
.setQueryEditor(PanelQueryEditor)
.setExploreMetricsQueryField(CombinedMetricsEditor)
.setExploreLogsQueryField(CloudWatchLogsQueryEditor)
.setAnnotationQueryCtrl(CloudWatchAnnotationsQueryCtrl);

View File

@@ -13,11 +13,8 @@
<div class="gf-form">
<label class="gf-form-label query-keyword width-8">Metric</label>
<metric-segment
segment="namespaceSegment"
get-options="getNamespaces()"
on-change="namespaceChanged()"
></metric-segment>
<metric-segment segment="namespaceSegment" get-options="getNamespaces()" on-change="namespaceChanged()">
</metric-segment>
<metric-segment segment="metricSegment" get-options="getMetrics()" on-change="metricChanged()"></metric-segment>
</div>
@@ -26,11 +23,8 @@
</div>
<div class="gf-form" ng-repeat="segment in statSegments">
<metric-segment
segment="segment"
get-options="getStatSegments(segment, $index)"
on-change="statSegmentChanged(segment, $index)"
></metric-segment>
<metric-segment segment="segment" get-options="getStatSegments(segment, $index)"
on-change="statSegmentChanged(segment, $index)"></metric-segment>
</div>
<div class="gf-form gf-form--grow">
@@ -41,12 +35,8 @@
<div class="gf-form-inline" ng-if="target.expression.length === 0">
<div class="gf-form">
<label class="gf-form-label query-keyword width-8">Dimensions</label>
<metric-segment
ng-repeat="segment in dimSegments"
segment="segment"
get-options="getDimSegments(segment, $index)"
on-change="dimSegmentChanged(segment, $index)"
></metric-segment>
<metric-segment ng-repeat="segment in dimSegments" segment="segment" get-options="getDimSegments(segment, $index)"
on-change="dimSegmentChanged(segment, $index)"></metric-segment>
</div>
<div class="gf-form gf-form--grow">
@@ -58,31 +48,16 @@
<div class="gf-form">
<label class=" gf-form-label query-keyword width-8 ">
Id
<info-popover mode="right-normal "
>Id can include numbers, letters, and underscore, and must start with a lowercase letter.</info-popover
>
<info-popover mode="right-normal ">Id can include numbers, letters, and underscore, and must start with a
lowercase letter.</info-popover>
</label>
<input
type="text "
class="gf-form-input "
ng-model="target.id "
spellcheck="false"
ng-pattern="/^[a-z][a-zA-Z0-9_]*$/"
ng-model-onblur
ng-change="onChange() "
/>
<input type="text " class="gf-form-input " ng-model="target.id " spellcheck="false"
ng-pattern="/^[a-z][a-zA-Z0-9_]*$/" ng-model-onblur ng-change="onChange() " />
</div>
<div class="gf-form max-width-30 ">
<label class="gf-form-label query-keyword width-7 ">Expression</label>
<input
type="text "
class="gf-form-input "
ng-model="target.expression
"
spellcheck="false"
ng-model-onblur
ng-change="onChange() "
/>
<input type="text " class="gf-form-input " ng-model="target.expression
" spellcheck="false" ng-model-onblur ng-change="onChange() " />
</div>
</div>
@@ -92,27 +67,13 @@
Min period
<info-popover mode="right-normal ">Minimum interval between points in seconds</info-popover>
</label>
<input
type="text "
class="gf-form-input "
ng-model="target.period "
spellcheck="false"
placeholder="auto
"
ng-model-onblur
ng-change="onChange() "
/>
<input type="text " class="gf-form-input " ng-model="target.period " spellcheck="false" placeholder="auto
" ng-model-onblur ng-change="onChange() " />
</div>
<div class="gf-form max-width-30 ">
<label class="gf-form-label query-keyword width-7 ">Alias</label>
<input
type="text "
class="gf-form-input "
ng-model="target.alias "
spellcheck="false"
ng-model-onblur
ng-change="onChange() "
/>
<input type="text " class="gf-form-input " ng-model="target.alias " spellcheck="false" ng-model-onblur
ng-change="onChange() " />
<info-popover mode="right-absolute ">
Alias replacement variables:
<ul ng-non-bindable>

View File

@@ -4,6 +4,7 @@
"id": "cloudwatch",
"category": "cloud",
"metrics": true,
"logs": true,
"alerting": true,
"annotations": true,
"includes": [

View File

@@ -30,7 +30,7 @@ export class CloudWatchQueryParameterCtrl {
memo.push(uiSegmentSrv.newKeyValue(value));
return memo;
},
[]
[] as any
);
$scope.statSegments = _.map($scope.target.statistics, stat => {
@@ -84,7 +84,7 @@ export class CloudWatchQueryParameterCtrl {
}
return memo;
},
[]
[] as any
);
$scope.ensurePlusButton($scope.statSegments);
@@ -106,7 +106,7 @@ export class CloudWatchQueryParameterCtrl {
}
const target = $scope.target;
let query = Promise.resolve([]);
let query = Promise.resolve([] as any[]);
if (segment.type === 'key' || segment.type === 'plus-button') {
query = $scope.datasource.getDimensionKeys($scope.target.namespace, $scope.target.region);

View File

@@ -1,10 +1,10 @@
import '../datasource';
import CloudWatchDatasource from '../datasource';
import { CloudWatchDatasource } from '../datasource';
import * as redux from 'app/store/store';
import { DataSourceInstanceSettings, dateMath } from '@grafana/data';
import { TemplateSrv } from 'app/features/templating/template_srv';
import { CustomVariable } from 'app/features/templating/all';
import { CloudWatchQuery } from '../types';
import { CloudWatchQuery, CloudWatchMetricsQuery } from '../types';
import { backendSrv } from 'app/core/services/backend_srv'; // will use the version in __mocks__
import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { convertToStoreState } from '../../../../../test/helpers/convertToStoreState';
@@ -46,14 +46,13 @@ describe('CloudWatchDatasource', () => {
jest.clearAllMocks();
});
describe('When performing CloudWatch query', () => {
let requestParams: { queries: CloudWatchQuery[] };
describe('When performing CloudWatch metrics query', () => {
const query = {
range: defaultTimeRange,
rangeRaw: { from: 1483228800, to: 1483232400 },
targets: [
{
type: 'Metrics',
expression: '',
refId: 'A',
region: 'us-east-1',
@@ -72,6 +71,7 @@ describe('CloudWatchDatasource', () => {
timings: [null],
results: {
A: {
type: 'Metrics',
error: '',
refId: 'A',
meta: { gmdMeta: [] },
@@ -93,25 +93,27 @@ describe('CloudWatchDatasource', () => {
};
beforeEach(() => {
datasourceRequestMock.mockImplementation(params => {
requestParams = params.data;
datasourceRequestMock.mockImplementation(() => {
return Promise.resolve({ data: response });
});
});
it('should generate the correct query', done => {
ctx.ds.query(query).then(() => {
const params = requestParams.queries[0];
expect(params.namespace).toBe(query.targets[0].namespace);
expect(params.metricName).toBe(query.targets[0].metricName);
expect(params.dimensions['InstanceId']).toStrictEqual(['i-12345678']);
expect(params.statistics).toEqual(query.targets[0].statistics);
expect(params.period).toBe(query.targets[0].period);
done();
});
it('should generate the correct query', async () => {
await ctx.ds.query(query);
expect(datasourceRequestMock.mock.calls[0][0].data.queries).toMatchObject(
expect.arrayContaining([
expect.objectContaining({
namespace: query.targets[0].namespace,
metricName: query.targets[0].metricName,
dimensions: { InstanceId: ['i-12345678'] },
statistics: query.targets[0].statistics,
period: query.targets[0].period,
}),
])
);
});
it('should generate the correct query with interval variable', done => {
it('should generate the correct query with interval variable', async () => {
templateSrv.init([
new CustomVariable(
{
@@ -130,6 +132,7 @@ describe('CloudWatchDatasource', () => {
rangeRaw: { from: 1483228800, to: 1483232400 },
targets: [
{
type: 'Metrics',
refId: 'A',
region: 'us-east-1',
namespace: 'AWS/EC2',
@@ -143,11 +146,8 @@ describe('CloudWatchDatasource', () => {
],
};
ctx.ds.query(query).then(() => {
const params = requestParams.queries[0];
expect(params.period).toBe('600');
done();
});
await ctx.ds.query(query);
expect(datasourceRequestMock.mock.calls[0][0].data.queries[0].period).toEqual('600');
});
it.each(['pNN.NN', 'p9', 'p99.', 'p99.999'])('should cancel query for invalid extended statistics (%s)', stat => {
@@ -156,6 +156,7 @@ describe('CloudWatchDatasource', () => {
rangeRaw: { from: 1483228800, to: 1483232400 },
targets: [
{
type: 'Metrics',
refId: 'A',
region: 'us-east-1',
namespace: 'AWS/EC2',
@@ -181,8 +182,7 @@ describe('CloudWatchDatasource', () => {
describe('a correct cloudwatch url should be built for each time series in the response', () => {
beforeEach(() => {
datasourceRequestMock.mockImplementation(params => {
requestParams = params.data;
datasourceRequestMock.mockImplementation(() => {
return Promise.resolve({ data: response });
});
});
@@ -238,6 +238,7 @@ describe('CloudWatchDatasource', () => {
describe('and throttling exception is thrown', () => {
const partialQuery = {
type: 'Metrics',
namespace: 'AWS/EC2',
metricName: 'CPUUtilization',
dimensions: {
@@ -376,6 +377,7 @@ describe('CloudWatchDatasource', () => {
rangeRaw: { from: 1483228800, to: 1483232400 },
targets: [
{
type: 'Metrics',
refId: 'A',
region: 'default',
namespace: 'AWS/EC2',
@@ -402,6 +404,7 @@ describe('CloudWatchDatasource', () => {
rangeRaw: { from: 1483228800, to: 1483232400 },
targets: [
{
type: 'Metrics',
refId: 'A',
region: 'us-east-1',
namespace: 'AWS/ApplicationELB',
@@ -463,7 +466,7 @@ describe('CloudWatchDatasource', () => {
});
describe('When performing CloudWatch query with template variables', () => {
let requestParams: { queries: CloudWatchQuery[] };
let requestParams: { queries: CloudWatchMetricsQuery[] };
beforeEach(() => {
const variables = [
new CustomVariable(
@@ -534,6 +537,7 @@ describe('CloudWatchDatasource', () => {
rangeRaw: { from: 1483228800, to: 1483232400 },
targets: [
{
type: 'Metrics',
refId: 'A',
region: 'us-east-1',
namespace: 'TestNamespace',
@@ -559,6 +563,7 @@ describe('CloudWatchDatasource', () => {
rangeRaw: { from: 1483228800, to: 1483232400 },
targets: [
{
type: 'Metrics',
refId: 'A',
region: 'us-east-1',
namespace: 'TestNamespace',
@@ -592,6 +597,7 @@ describe('CloudWatchDatasource', () => {
rangeRaw: { from: 1483228800, to: 1483232400 },
targets: [
{
type: 'Metrics',
refId: 'A',
region: 'us-east-1',
namespace: 'TestNamespace',
@@ -621,6 +627,7 @@ describe('CloudWatchDatasource', () => {
rangeRaw: { from: 1483228800, to: 1483232400 },
targets: [
{
type: 'Metrics',
refId: 'A',
region: 'us-east-1',
namespace: 'TestNamespace',

View File

@@ -0,0 +1,367 @@
import { Grammar } from 'prismjs';
import { CompletionItem } from '@grafana/ui';
export const QUERY_COMMANDS: CompletionItem[] = [
{
label: 'fields',
documentation: 'Retrieves the specified fields from log events',
},
{ label: 'display', documentation: 'Specifies which fields to display in the query results' },
{
label: 'filter',
insertText: 'filter',
documentation: 'Filters the results of a query based on one or more conditions',
},
{
label: 'stats',
insertText: 'stats',
documentation: 'Calculates aggregate statistics based on the values of log fields',
},
{ label: 'sort', documentation: 'Sorts the retrieved log events' },
{ label: 'limit', documentation: 'Specifies the number of log events returned by the query' },
{
label: 'parse',
documentation:
'Extracts data from a log field, creating one or more ephemeral fields that you can process further in the query',
},
];
export const COMPARISON_OPERATORS = ['=', '!=', '<', '<=', '>', '>='];
export const ARITHMETIC_OPERATORS = ['+', '-', '*', '/', '^', '%'];
export const NUMERIC_OPERATORS = [
{
label: 'abs',
detail: 'abs(a)',
documentation: 'Absolute value.',
},
{
label: 'ceil',
detail: 'ceil(a)',
documentation: 'Round to ceiling (the smallest integer that is greater than the value of a).',
},
{
label: 'floor',
detail: 'floor(a)',
documentation: 'Round to floor (the largest integer that is smaller than the value of a).',
},
{
label: 'greatest',
detail: 'greatest(a,b, ... z)',
documentation: 'Returns the largest value.',
},
{
label: 'least',
detail: 'least(a, b, ... z)',
documentation: 'Returns the smallest value.',
},
{
label: 'log',
detail: 'log(a)',
documentation: 'Natural logarithm.',
},
{
label: 'sqrt',
detail: 'sqrt(a)',
documentation: 'Square root.',
},
];
export const GENERAL_FUNCTIONS = [
{
label: 'ispresent',
detail: 'ispresent(fieldname)',
documentation: 'Returns true if the field exists.',
},
{
label: 'coalesce',
detail: 'coalesce(fieldname1, fieldname2, ... fieldnamex)',
documentation: 'Returns the first non-null value from the list.',
},
];
export const STRING_FUNCTIONS = [
{
label: 'isempty',
detail: 'isempty(fieldname)',
documentation: 'Returns true if the field is missing or is an empty string.',
},
{
label: 'isblank',
detail: 'isblank(fieldname)',
documentation: 'Returns true if the field is missing, an empty string, or contains only white space.',
},
{
label: 'concat',
detail: 'concat(string1, string2, ... stringz)',
documentation: 'Concatenates the strings.',
},
{
label: 'ltrim',
detail: 'ltrim(string) or ltrim(string1, string2)',
documentation:
'Remove white space from the left of the string. If the function has a second string argument, it removes the characters of string2 from the left of string1.',
},
{
label: 'rtrim',
detail: 'rtrim(string) or rtrim(string1, string2)',
documentation:
'Remove white space from the right of the string. If the function has a second string argument, it removes the characters of string2 from the right of string1.',
},
{
label: 'trim',
detail: 'trim(string) or trim(string1, string2)',
documentation:
'Remove white space from both ends of the string. If the function has a second string argument, it removes the characters of string2 from both sides of string1.',
},
{
label: 'strlen',
detail: 'strlen(string)',
documentation: 'Returns the length of the string in Unicode code points.',
},
{
label: 'toupper',
detail: 'toupper(string)',
documentation: 'Converts the string to uppercase.',
},
{
label: 'tolower',
detail: 'tolower(string)',
documentation: 'Converts the string to lowercase.',
},
{
label: 'substr',
detail: 'substr(string1, x), or substr(string1, x, y)',
documentation:
'Returns a substring from the index specified by the number argument to the end of the string. If the function has a second number argument, it contains the length of the substring to be retrieved.',
},
{
label: 'replace',
detail: 'replace(string1, string2, string3)',
documentation: 'Replaces all instances of string2 in string1 with string3.',
},
{
label: 'strcontains',
detail: 'strcontains(string1, string2)',
documentation: 'Returns 1 if string1 contains string2 and 0 otherwise.',
},
];
export const DATETIME_FUNCTIONS = [
{
label: 'bin',
detail: 'bin(period)',
documentation: 'Rounds the value of @timestamp to the given period and then truncates.',
},
{
label: 'datefloor',
detail: 'datefloor(a, period)',
documentation: 'Truncates the timestamp to the given period.',
},
{
label: 'dateceil',
detail: 'dateceil(a, period)',
documentation: 'Rounds up the timestamp to the given period and then truncates.',
},
{
label: 'fromMillis',
detail: 'fromMillis(fieldname)',
documentation:
'Interprets the input field as the number of milliseconds since the Unix epoch and converts it to a timestamp.',
},
{
label: 'toMillis',
detail: 'toMillis(fieldname)',
documentation:
'Converts the timestamp found in the named field into a number representing the milliseconds since the Unix epoch.',
},
];
export const IP_FUNCTIONS = [
{
label: 'isValidIp',
detail: 'isValidIp(fieldname)',
documentation: 'Returns true if the field is a valid v4 or v6 IP address.',
},
{
label: 'isValidIpV4',
detail: 'isValidIpV4(fieldname)',
documentation: 'Returns true if the field is a valid v4 IP address.',
},
{
label: 'isValidIpV6',
detail: 'isValidIpV6(fieldname)',
documentation: 'Returns true if the field is a valid v6 IP address.',
},
{
label: 'isIpInSubnet',
detail: 'isIpInSubnet(fieldname, string)',
documentation: 'Returns true if the field is a valid v4 or v6 IP address within the specified v4 or v6 subnet.',
},
{
label: 'isIpv4InSubnet',
detail: 'isIpv4InSubnet(fieldname, string)',
documentation: 'Returns true if the field is a valid v4 IP address within the specified v4 subnet.',
},
{
label: 'isIpv6InSubnet',
insertText: 'isIpv6InSubnet',
detail: 'isIpv6InSubnet(fieldname, string)',
documentation: 'Returns true if the field is a valid v6 IP address within the specified v6 subnet.',
},
];
export const BOOLEAN_FUNCTIONS = [
{
label: 'ispresent',
detail: 'ispresent(fieldname)',
documentation: 'Returns true if the field exists.',
},
{
label: 'isempty',
detail: 'isempty(fieldname)',
documentation: 'Returns true if the field is missing or is an empty string.',
},
{
label: 'isblank',
detail: 'isblank(fieldname)',
documentation: 'Returns true if the field is missing, an empty string, or contains only white space.',
},
{
label: 'strcontains',
detail: 'strcontains(string1, string2)',
documentation: 'Returns 1 if string1 contains string2 and 0 otherwise.',
},
...IP_FUNCTIONS,
];
export const AGGREGATION_FUNCTIONS_STATS = [
{
label: 'avg',
detail: 'avg(NumericFieldname)',
documentation: 'The average of the values in the specified field.',
},
{
label: 'count',
detail: 'count(fieldname) or count(*)',
documentation: 'Counts the log records.',
},
{
label: 'count_distinct',
detail: 'count_distinct(fieldname)',
documentation: 'Returns the number of unique values for the field.',
},
{
label: 'max',
detail: 'max(fieldname)',
documentation: 'The maximum of the values for this log field in the queried logs.',
},
{
label: 'min',
detail: 'min(fieldname)',
documentation: 'The minimum of the values for this log field in the queried logs.',
},
{
label: 'pct',
detail: 'pct(fieldname, value)',
documentation: 'A percentile indicates the relative standing of a value in a datas.',
},
{
label: 'stddev',
detail: 'stddev(NumericFieldname)',
documentation: 'The standard deviation of the values in the specified field.',
},
{
label: 'sum',
detail: 'sum(NumericFieldname)',
documentation: 'The sum of the values in the specified field.',
},
];
export const NON_AGGREGATION_FUNCS_STATS = [
{
label: 'earliest',
detail: 'earliest(fieldname)',
documentation:
'Returns the value of fieldName from the log event that has the earliest time stamp in the queried logs.',
},
{
label: 'latest',
detail: 'latest(fieldname)',
documentation:
'Returns the value of fieldName from the log event that has the latest time stamp in the queried logs.',
},
{
label: 'sortsFirst',
detail: 'sortsFirst(fieldname)',
documentation: 'Returns the value of fieldName that sorts first in the queried logs.',
},
{
label: 'sortsLast',
detail: 'sortsLast(fieldname)',
documentation: 'Returns the value of fieldName that sorts last in the queried logs.',
},
];
export const STATS_FUNCS = [...AGGREGATION_FUNCTIONS_STATS, ...NON_AGGREGATION_FUNCS_STATS];
export const KEYWORDS = ['as', 'like', 'by', 'in', 'desc', 'asc'];
export const FUNCTIONS = [
...NUMERIC_OPERATORS,
...GENERAL_FUNCTIONS,
...STRING_FUNCTIONS,
...DATETIME_FUNCTIONS,
...IP_FUNCTIONS,
...STATS_FUNCS,
];
const tokenizer: Grammar = {
comment: {
pattern: /^#.*/,
greedy: true,
},
backticks: {
pattern: /`.*?`/,
alias: 'string',
greedy: true,
},
quote: {
pattern: /".*?"/,
alias: 'string',
greedy: true,
},
regex: {
pattern: /\/.*?\/(?=\||\s*$|,)/,
greedy: true,
},
'query-command': {
pattern: new RegExp(`\\b(?:${QUERY_COMMANDS.map(command => command.label).join('|')})\\b`, 'i'),
alias: 'function',
},
function: {
pattern: new RegExp(`\\b(?:${FUNCTIONS.map(f => f.label).join('|')})`, 'i'),
},
keyword: {
pattern: new RegExp(`(\\s+)(${KEYWORDS.join('|')})(?=\\s+)`, 'i'),
lookbehind: true,
},
// 'log-group-name': {
// pattern: /[\.\-_/#A-Za-z0-9]+/,
// },
'field-name': {
pattern: /(@?[_a-zA-Z]+[_.0-9a-zA-Z]*)|(`((\\`)|([^`]))*?`)/,
greedy: true,
},
number: /\b-?\d+((\.\d*)?([eE][+-]?\d+)?)?\b/,
'command-separator': {
pattern: /\|/,
alias: 'punctuation',
},
'comparison-operator': {
pattern: /([<>]=?)|(!?=)/,
},
punctuation: /[{}()`,.]/,
whitespace: /\s+/,
};
export default tokenizer;

View File

@@ -1,19 +1,53 @@
import { DataQuery, SelectableValue, DataSourceJsonData } from '@grafana/data';
export interface CloudWatchQuery extends DataQuery {
export interface CloudWatchMetricsQuery extends DataQuery {
queryMode: 'Metrics';
apiMode: 'Logs' | 'Metrics'; // TEMP: Remove when logs/metrics unification is done
id: string;
region: string;
namespace: string;
expression: string;
metricName: string;
dimensions: { [key: string]: string | string[] };
statistics: string[];
period: string;
expression: string;
alias: string;
matchExact: boolean;
}
export interface AnnotationQuery extends CloudWatchQuery {
export type LogAction =
| 'DescribeLogGroups'
| 'GetQueryResults'
| 'GetLogGroupFields'
| 'GetLogEvents'
| 'StartQuery'
| 'StopQuery';
export enum CloudWatchLogsQueryStatus {
Scheduled = 'Scheduled',
Running = 'Running',
Complete = 'Complete',
Failed = 'Failed',
Cancelled = 'Cancelled',
}
export interface CloudWatchLogsQuery extends DataQuery {
queryMode: 'Logs';
apiMode: 'Logs' | 'Metrics'; // TEMP: Remove when logs/metrics unification is done
id: string;
region: string;
namespace: string;
expression: string;
logGroupNames: string[];
}
export type CloudWatchQuery = CloudWatchMetricsQuery | CloudWatchLogsQuery;
export interface AnnotationQuery extends CloudWatchMetricsQuery {
prefixMatching: boolean;
actionPrefix: string;
alarmNamePrefix: string;
@@ -32,3 +66,270 @@ export interface CloudWatchSecureJsonData {
accessKey: string;
secretKey: string;
}
export interface GetQueryResultsRequest {
/**
* The ID number of the query.
*/
queryId: string;
}
export interface ResultField {
/**
* The log event field.
*/
field?: string;
/**
* The value of this field.
*/
value?: string;
}
export interface QueryStatistics {
/**
* The number of log events that matched the query string.
*/
recordsMatched?: number;
/**
* The total number of log events scanned during the query.
*/
recordsScanned?: number;
/**
* The total number of bytes in the log events scanned during the query.
*/
bytesScanned?: number;
}
export type QueryStatus = 'Scheduled' | 'Running' | 'Complete' | 'Failed' | 'Cancelled' | string;
export interface GetLogEventsRequest {
/**
* The name of the log group.
*/
logGroupName: string;
/**
* The name of the log stream.
*/
logStreamName: string;
/**
* The start of the time range, expressed as the number of milliseconds after Jan 1, 1970 00:00:00 UTC. Events with a timestamp equal to this time or later than this time are included. Events with a timestamp earlier than this time are not included.
*/
startTime?: number;
/**
* The end of the time range, expressed as the number of milliseconds after Jan 1, 1970 00:00:00 UTC. Events with a timestamp equal to or later than this time are not included.
*/
endTime?: number;
/**
* The token for the next set of items to return. (You received this token from a previous call.) Using this token works only when you specify true for startFromHead.
*/
nextToken?: string;
/**
* The maximum number of log events returned. If you don't specify a value, the maximum is as many log events as can fit in a response size of 1 MB, up to 10,000 log events.
*/
limit?: number;
/**
* If the value is true, the earliest log events are returned first. If the value is false, the latest log events are returned first. The default value is false. If you are using nextToken in this operation, you must specify true for startFromHead.
*/
startFromHead?: boolean;
}
export interface GetQueryResultsResponse {
/**
* The log events that matched the query criteria during the most recent time it ran. The results value is an array of arrays. Each log event is one object in the top-level array. Each of these log event objects is an array of field/value pairs.
*/
results?: ResultField[][];
/**
* Includes the number of log events scanned by the query, the number of log events that matched the query criteria, and the total number of bytes in the log events that were scanned.
*/
statistics?: QueryStatistics;
/**
* The status of the most recent running of the query. Possible values are Cancelled, Complete, Failed, Running, Scheduled, Timeout, and Unknown. Queries time out after 15 minutes of execution. To avoid having your queries time out, reduce the time range being searched, or partition your query into a number of queries.
*/
status?: QueryStatus;
}
export interface DescribeLogGroupsRequest {
/**
* The prefix to match.
*/
logGroupNamePrefix?: string;
/**
* The token for the next set of items to return. (You received this token from a previous call.)
*/
nextToken?: string;
/**
* The maximum number of items returned. If you don't specify a value, the default is up to 50 items.
*/
limit?: number;
refId?: string;
region?: string;
}
export interface TSDBResponse<T = any> {
results: Record<string, TSDBQueryResult<T>>;
message?: string;
}
export interface TSDBQueryResult<T = any> {
refId: string;
series: TSDBTimeSeries[];
tables: Array<TSDBTable<T>>;
dataframes: number[][];
error?: string;
meta?: any;
}
export interface TSDBTable<T = any> {
columns: Array<{ text: string }>;
rows: T[];
}
export interface TSDBTimeSeries {
name: string;
points: TSDBTimePoint[];
tags?: Record<string, string>;
}
export type TSDBTimePoint = [number, number];
export interface LogGroup {
/**
* The name of the log group.
*/
logGroupName?: string;
/**
* The creation time of the log group, expressed as the number of milliseconds after Jan 1, 1970 00:00:00 UTC.
*/
creationTime?: number;
retentionInDays?: number;
/**
* The number of metric filters.
*/
metricFilterCount?: number;
/**
* The Amazon Resource Name (ARN) of the log group.
*/
arn?: string;
/**
* The number of bytes stored.
*/
storedBytes?: number;
/**
* The Amazon Resource Name (ARN) of the CMK to use when encrypting log data.
*/
kmsKeyId?: string;
}
export interface DescribeLogGroupsResponse {
/**
* The log groups.
*/
logGroups?: LogGroup[];
nextToken?: string;
}
export interface GetLogGroupFieldsRequest {
/**
* The name of the log group to search.
*/
logGroupName: string;
/**
* The time to set as the center of the query. If you specify time, the 8 minutes before and 8 minutes after this time are searched. If you omit time, the past 15 minutes are queried. The time value is specified as epoch time, the number of seconds since January 1, 1970, 00:00:00 UTC.
*/
time?: number;
}
export interface LogGroupField {
/**
* The name of a log field.
*/
name?: string;
/**
* The percentage of log events queried that contained the field.
*/
percent?: number;
}
export interface GetLogGroupFieldsResponse {
/**
* The array of fields found in the query. Each object in the array contains the name of the field, along with the percentage of time it appeared in the log events that were queried.
*/
logGroupFields?: LogGroupField[];
}
export interface StartQueryRequest {
/**
* The log group on which to perform the query. A StartQuery operation must include a logGroupNames or a logGroupName parameter, but not both.
*/
logGroupName?: string;
/**
* The list of log groups to be queried. You can include up to 20 log groups. A StartQuery operation must include a logGroupNames or a logGroupName parameter, but not both.
*/
logGroupNames?: string[];
/**
* The beginning of the time range to query. The range is inclusive, so the specified start time is included in the query. Specified as epoch time, the number of seconds since January 1, 1970, 00:00:00 UTC.
*/
startTime: number;
/**
* The end of the time range to query. The range is inclusive, so the specified end time is included in the query. Specified as epoch time, the number of seconds since January 1, 1970, 00:00:00 UTC.
*/
endTime: number;
/**
* The query string to use. For more information, see CloudWatch Logs Insights Query Syntax.
*/
queryString: string;
/**
* The maximum number of log events to return in the query. If the query string uses the fields command, only the specified fields and their values are returned. The default is 1000.
*/
limit?: number;
}
export interface StartQueryResponse {
/**
* The unique ID of the query.
*/
queryId?: string;
}
export interface MetricRequest {
from: string;
to: string;
queries: MetricQuery[];
debug?: boolean;
}
interface MetricQuery {
[key: string]: any;
datasourceId: number;
refId?: string;
maxDataPoints?: number;
intervalMs?: number;
}
// interface TsdbQuery {
// TimeRange *TimeRange
// Queries []*Query
// Debug bool
// }
// type Query struct {
// RefId string
// Model *simplejson.Json
// DataSource *models.DataSource
// MaxDataPoints int64
// IntervalMs int64
// }
export interface CloudWatchMetricsAnnotation {
namespace: string;
metricName: string;
expression: string;
dimensions: {};
region: string;
id: string;
alias: string;
statistics: string[];
matchExact: true;
prefixMatching: false;
actionPrefix: string;
alarmNamePrefix: string;
}

View File

@@ -0,0 +1,69 @@
import { useState, useEffect } from 'react';
import Prism, { Grammar } from 'prismjs';
import { AbsoluteTimeRange } from '@grafana/data';
import { useRefMounted } from 'app/core/hooks/useRefMounted';
import { CloudWatchLanguageProvider } from './language_provider';
const PRISM_SYNTAX = 'cloudwatch';
/**
* Initialise the language provider. Returns a languageProviderInitialized boolean cause there does not seem other way
* to know if the provider is already initialised or not. By the initialisation it modifies the provided
* languageProvider directly.
*/
const useInitLanguageProvider = (languageProvider: CloudWatchLanguageProvider, absoluteRange: AbsoluteTimeRange) => {
const mounted = useRefMounted();
const [languageProviderInitialized, setLanguageProviderInitialized] = useState(false);
// Async
const initializeLanguageProvider = async () => {
languageProvider.initialRange = absoluteRange;
await languageProvider.start();
if (mounted.current) {
setLanguageProviderInitialized(true);
}
};
useEffect(() => {
initializeLanguageProvider();
}, []);
return languageProviderInitialized;
};
/**
* Returns syntax from languageProvider and initialises global Prism syntax. Waits until languageProvider itself is
* initialised (outside of this hook).
*/
const useCloudwatchSyntax = (languageProvider: CloudWatchLanguageProvider, languageProviderInitialized: boolean) => {
// State
const [syntax, setSyntax] = useState<Grammar | null>(null);
// Effects
useEffect(() => {
if (languageProviderInitialized) {
const syntax = languageProvider.getSyntax();
Prism.languages[PRISM_SYNTAX] = syntax;
setSyntax(syntax);
}
}, [languageProviderInitialized, languageProvider]);
return {
isSyntaxReady: !!syntax,
syntax,
};
};
/**
* Initializes given language provider, exposes Loki syntax and enables loading label option values
*/
export const useCloudWatchSyntax = (languageProvider: CloudWatchLanguageProvider, absoluteRange: AbsoluteTimeRange) => {
const languageProviderInitialized = useInitLanguageProvider(languageProvider, absoluteRange);
const { isSyntaxReady, syntax } = useCloudwatchSyntax(languageProvider, languageProviderInitialized);
return {
isSyntaxReady,
syntax,
};
};

View File

@@ -13,6 +13,7 @@ export const LokiQueryEditor = memo(function LokiQueryEditor(props: Props) {
const { query, data, datasource, onChange, onRunQuery } = props;
let absolute: AbsoluteTimeRange;
if (data && data.request) {
const { range } = data.request;
absolute = {

View File

@@ -43,6 +43,7 @@ import {
import { LiveStreams, LokiLiveTarget } from './live_streams';
import LanguageProvider from './language_provider';
import { serializeParams } from '../../../core/utils/fetch';
import { RowContextOptions } from '@grafana/ui/src/components/Logs/LogRowContextProvider';
export type RangeQueryOptions = Pick<DataQueryRequest<LokiQuery>, 'range' | 'intervalMs' | 'maxDataPoints' | 'reverse'>;
export const DEFAULT_MAX_LINES = 1000;
@@ -57,11 +58,6 @@ const DEFAULT_QUERY_PARAMS: Partial<LokiRangeQueryRequest> = {
query: '',
};
interface LokiContextQueryOptions {
direction?: 'BACKWARD' | 'FORWARD';
limit?: number;
}
export class LokiDatasource extends DataSourceApi<LokiQuery, LokiOptions> {
private streams = new LiveStreams();
languageProvider: LanguageProvider;
@@ -384,7 +380,7 @@ export class LokiDatasource extends DataSourceApi<LokiQuery, LokiOptions> {
return Math.ceil(date.valueOf() * 1e6);
}
getLogRowContext = (row: LogRowModel, options?: LokiContextQueryOptions): Promise<{ data: DataFrame[] }> => {
getLogRowContext = (row: LogRowModel, options?: RowContextOptions): Promise<{ data: DataFrame[] }> => {
const target = this.prepareLogRowContextQueryTarget(
row,
(options && options.limit) || 10,
@@ -525,6 +521,10 @@ export class LokiDatasource extends DataSourceApi<LokiQuery, LokiOptions> {
return annotations;
}
showContextToggle = (row?: LogRowModel) => {
return row.searchWords && row.searchWords.length > 0;
};
throwUnless = (err: any, condition: boolean, target: LokiQuery) => {
if (condition) {
return of(err);

View File

@@ -1,6 +1,14 @@
import _ from 'lodash';
import { colors } from '@grafana/ui';
import { getColorFromHexRgbOrName, TimeRange, FieldType, Field, DataFrame, getTimeField } from '@grafana/data';
import {
getColorFromHexRgbOrName,
TimeRange,
FieldType,
Field,
DataFrame,
getTimeField,
dateTime,
} from '@grafana/data';
import TimeSeries from 'app/core/time_series2';
import config from 'app/core/config';
@@ -42,7 +50,7 @@ export class DataProcessor {
const datapoints = [];
for (let r = 0; r < series.length; r++) {
datapoints.push([field.values.get(r), timeField.values.get(r)]);
datapoints.push([field.values.get(r), dateTime(timeField.values.get(r)).valueOf()]);
}
list.push(this.toTimeSeries(field, name, i, j, datapoints, list.length, range));
}

View File

@@ -43,6 +43,7 @@ export const getGraphSeriesModel = (
let fieldColumnIndex = -1;
for (const series of dataFrames) {
const { timeField } = getTimeField(series);
if (!timeField) {
continue;
}