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:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user