mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
18
public/app/plugins/datasource/cloudwatch/aws_url.ts
Normal file
18
public/app/plugins/datasource/cloudwatch/aws_url.ts
Normal 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
|
||||
)}`;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
429
public/app/plugins/datasource/cloudwatch/language_provider.ts
Normal file
429
public/app/plugins/datasource/cloudwatch/language_provider.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"id": "cloudwatch",
|
||||
"category": "cloud",
|
||||
"metrics": true,
|
||||
"logs": true,
|
||||
"alerting": true,
|
||||
"annotations": true,
|
||||
"includes": [
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
367
public/app/plugins/datasource/cloudwatch/syntax.ts
Normal file
367
public/app/plugins/datasource/cloudwatch/syntax.ts
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export const getGraphSeriesModel = (
|
||||
let fieldColumnIndex = -1;
|
||||
for (const series of dataFrames) {
|
||||
const { timeField } = getTimeField(series);
|
||||
|
||||
if (!timeField) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user