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

* Datasource/Cloudwatch: Adds support for Cloudwatch Logs

* Fix rebase leftover

* Use jsurl for AWS url serialization

* WIP: Temporary workaround for CLIQ metrics

* Only allow up to 20 log groups to be selected

* WIP additional changes

* More changes based on feedback

* More changes based on PR feedback

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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