Merge branch 'main' into ash/user-event-upgrade
This commit is contained in:
@@ -38,56 +38,37 @@ To access Prometheus settings, hover your mouse over the **Configuration** (gear
|
||||
|
||||
## Prometheus query editor
|
||||
|
||||
Below you can find information and options for Prometheus query editor in dashboard and in Explore.
|
||||
Prometheus query editor is separated into 3 distinct modes that you can switch between. See docs for each section below.
|
||||
|
||||
### Query editor in dashboards
|
||||

|
||||
|
||||
Open a graph in edit mode by clicking the title > Edit (or by pressing `e` key while hovering over panel).
|
||||
At the top of the editor there is `Run query` button that will run the query and `Explain | Builder | Code` tabs to switch between the editor modes. If the query editor is in Builder mode there are additional elements explained in the Builder section.
|
||||
|
||||
{{< figure src="/static/img/docs/v45/prometheus_query_editor_still.png"
|
||||
animated-gif="/static/img/docs/v45/prometheus_query_editor.gif" >}}
|
||||
Each mode is synchronized with the other modes, so you can switch between them without losing your work, although there are some limitations. Some more complex queries are not yet supported in the builder mode. If you try to switch from `Code` to `Builder` with such query, editor will show a popup explaining that you can lose some parts of the query, and you can decide if you still want to continue to `Builder` mode or not.
|
||||
|
||||
| Name | Description |
|
||||
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `Query expression` | Prometheus query expression. For more information, refer to the [Prometheus documentation](http://prometheus.io/docs/querying/basics/). |
|
||||
| `Legend format` | Controls the name of the time series, using name or pattern. For example, `{{hostname}}` is replaced by the label value for the label `hostname`. |
|
||||
| `Step` | Use 'Minimum' or 'Maximum' step mode to set the lower or upper bounds respectively on the interval between data points. For example, set "minimum 1h" to hint that measurements are not frequent (taken hourly). Use the 'Exact' step mode to set a precise interval between data points. `$__interval` and `$__rate_interval` are supported. |
|
||||
| `Resolution` | `1/1` sets both the `$__interval` variable and the [`step` parameter of Prometheus range queries](https://prometheus.io/docs/prometheus/latest/querying/api/#range-queries) such that each pixel corresponds to one data point. For better performance, you can pick lower resolutions. `1/2` only retrieves a data point for every other pixel, and `1/10` retrieves one data point per 10 pixels. Both _Min time interval_ and _Step_ limit the final value of `$__interval` and `step`. |
|
||||
| `Metric lookup` | Search for metric names in this input field. |
|
||||
| `Format as` | You can switch between `Table` `Time series` or `Heatmap` options. The `Table` option works only in the Table panel. `Heatmap` displays metrics of the Histogram type on a Heatmap panel. Under the hood, it converts cumulative histograms to regular ones and sorts series by the bucket bound. |
|
||||
| `Instant` | Perform an "instant" query to return only the latest value that Prometheus has scraped for the requested time series. Instant queries can return results much faster than normal range queries. Use them to look up label sets. |
|
||||
| `Min time interval` | This value multiplied by the denominator from the _Resolution_ setting sets a lower limit to both the `$__interval` variable and the [`step` parameter of Prometheus range queries](https://prometheus.io/docs/prometheus/latest/querying/api/#range-queries). Defaults to _Scrape interval_ as specified in the data source options. |
|
||||
| `Exemplars` | Run and show exemplars in the graph. |
|
||||
### Code mode
|
||||
|
||||
> **Note:** Grafana modifies the request dates for queries to align them with the dynamically calculated step. This ensures consistent display of metrics data, but it can result in a small gap of data at the right edge of a graph.
|
||||

|
||||
|
||||
#### Instant queries in dashboards
|
||||
Code mode allows you to write raw queries in a textual editor. It implements advanced autocomplete features and syntax highlighting to help with writing complex queries. In addition, it also contains `Metrics browser` to further aid with writing queries (see more docs below).
|
||||
|
||||
The Prometheus data source allows you to run "instant" queries, which query only the latest value.
|
||||
You can visualize the results in a table panel to see all available labels of a timeseries.
|
||||
For more information about Prometheus query language, refer to the [Prometheus documentation](http://prometheus.io/docs/querying/basics/).
|
||||
|
||||
Instant query results are made up only of one data point per series but can be shown in the graph panel with the help of [series overrides]({{< relref "../visualizations/graph-panel.md#series-overrides" >}}).
|
||||
To show them in the graph as a latest value point, add a series override and select `Points > true`.
|
||||
To show a horizontal line across the whole graph, add a series override and select `Transform > constant`.
|
||||
#### Autocomplete
|
||||
|
||||
> Support for constant series overrides is available from Grafana v6.4
|
||||

|
||||
|
||||
### Query editor in Explore
|
||||
Autocomplete kicks automatically in appropriate times during typing. Use `ctrl/cmd + space` to trigger autocomplete manually when needed. Autocomplete can suggest both static functions, aggregations and keywords but also dynamic items like metrics and labels. Autocomplete dropdown also shows documentation for the suggested items, either static one or dynamic metric documentation where available.
|
||||
|
||||
| Name | Description |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `Query expression` | Prometheus query expression, check out the [Prometheus documentation](http://prometheus.io/docs/querying/basics/). |
|
||||
| `Step` | [`Step` parameter of Prometheus range queries](https://prometheus.io/docs/prometheus/latest/querying/api/#range-queries). Time units can be used here, for example: 5s, 1m, 3h, 1d, 1y. Default unit if no unit specified is `s` (seconds). |
|
||||
| `Query type` | `Range`, `Instant`, or `Both`. When running **Range query**, the result of the query is displayed in graph and table. Instant query returns only the latest value that Prometheus has scraped for the requested time series and it is displayed in the table. When **Both** is selected, both instant query and range query is run. Result of range query is displayed in graph and the result of instant query is displayed in the table. |
|
||||
| `Exemplars` | Run and show exemplars in the graph. |
|
||||
In [Explore]({{< relref "../explore/_index.md" >}}) use `shift + enter` to run the query.
|
||||
|
||||
### Metrics browser
|
||||
#### Metrics browser
|
||||
|
||||
The metrics browser allows you to quickly find metrics and select relevant labels to build basic queries.
|
||||
When you open the browser you will see all available metrics and labels.
|
||||
If supported by your Prometheus instance, each metric will show its HELP and TYPE as a tooltip.
|
||||
|
||||
{{< figure src="/static/img/docs/v8/prometheus_metrics_browser.png" class="docs-image--no-shadow" max-width="800px" caption="Screenshot of the metrics browser for Prometheus" >}}
|
||||

|
||||
|
||||
When you select a metric, the browser narrows down the available labels to show only the ones applicable to the metric.
|
||||
You can then select one or more labels for which the available label values are shown in lists in the bottom section.
|
||||
@@ -95,14 +76,77 @@ Select one or more values for each label to tighten your query scope.
|
||||
|
||||
> **Note:** If you do not remember a metric name to start with, you can also select a few labels first, to narrow down the list and then find relevant label values.
|
||||
|
||||
All lists in the metrics browser have a search field above them to quickly filter for metrics or labels that match a certain string. The values section only has one search field. Its filtering applies to all labels to help you find values across labels once they have been selected, for example, among your labels `app`, `job`, `job_name` only one might with the value you are looking for.
|
||||
All lists in the metrics browser have a search field above them to quickly filter for metrics or labels that match a certain string. The values section only has one search field. It's filtering applies to all labels to help you find values across labels once they have been selected, for example, among your labels `app`, `job`, `job_name` only one might with the value you are looking for.
|
||||
|
||||
Once you are satisfied with your query, click "Use query" to run the query. The button "Use as rate query" adds a `rate(...)[$__interval]` around your query to help write queries for counter metrics.
|
||||
The "Validate selector" button will check with Prometheus how many time series are available for that selector.
|
||||
|
||||
#### Limitations
|
||||
#### Options
|
||||
|
||||
The metrics browser has a hard limit of 10,000 labels (keys) and 50,000 label values (including metric names). If your Prometheus instance returns more results, the browser will continue functioning. However, the result sets will be cut off above those maximum limits.
|
||||

|
||||
|
||||
| Name | Description |
|
||||
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `Legend` | Controls the name of the time series. Use predefined format or use custom format.<br/>`Auto` - only includes unique labels.<br/>`Verbose` - includes all labels.<br/>`Custom` - select will change to text input. Use use tamplating to select which labels will be included. For example, `{{hostname}}` is replaced by the label value for the label `hostname`. Clear the input and click outside the input to go back to select mode. |
|
||||
| `Min step` | Set the lower bounds on the interval between data points. For example, set "1h" to hint that measurements are not frequent (taken hourly). `$__interval` and `$__rate_interval` are supported. |
|
||||
| `Format` | You can switch between `Table` `Time series` or `Heatmap` options. The `Table` option works only in the Table panel. `Heatmap` displays metrics of the Histogram type on a Heatmap panel. Under the hood, it converts cumulative histograms to regular ones and sorts series by the bucket bound. |
|
||||
| `Type` | `Range` - Query returning a Range vector, a set of time series containing a range of data points over time for each time series.<br/>`Instant` - Perform an "instant" query to return only the latest value that Prometheus has scraped for the requested time series. Instant queries can return results much faster than normal range queries. Use them to look up label sets. Instant query results are made up only of one data point per series but can be shown in the graph panel in a dashboard with the help of [series overrides]({{< relref "../visualizations/graph-panel.md#series-overrides" >}}). To show them in the graph as a latest value point, add a series override and select `Points > true`. To show a horizontal line across the whole graph, add a series override and select `Transform > constant`. <br/>`Both` - Available only in Explore. Runs both range and instant query |
|
||||
| `Exemplars` | If on, run exemplars query with the regular query and show exemplars in the graph. |
|
||||
|
||||
> **Note:** Grafana modifies the request dates for queries to align them with the dynamically calculated step. This ensures consistent display of metrics data, but it can result in a small gap of data at the right edge of a graph.
|
||||
|
||||
### Builder mode
|
||||
|
||||
#### Toolbar
|
||||
|
||||
In addition to `Run query` button and mode switcher, in builder mode additional elements are available:
|
||||
|
||||
| Name | Description |
|
||||
| -------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Query patterns | A list of useful operation patterns that can be used to quickly add multiple operations to your query to achieve a specific goal. |
|
||||
| Raw query | Toggle to show raw query generated by the builder that will be sent to Prometheus instance. |
|
||||
|
||||
#### Metric and labels
|
||||
|
||||

|
||||
|
||||
Select a specific metric name from the dropdown list. List of available metrics is fetched from the Prometheus server based on selected time rage. Write into the select when the dropdown is open to search and filter the list.
|
||||
|
||||
Select desired labels and their values from the dropdown list. When metric is selected, available labels and their values are fetched from the server. Use the `+` button to add more labels. Use the `x` button to remove a label.
|
||||
|
||||
#### Operations
|
||||
|
||||

|
||||
|
||||
Use the `+ Operations` button to add operation to your query. Operations are grouped into sections for easier navigation. When the operations dropdown is open, write into the search input to search and filter operations list.
|
||||
|
||||
Operations in a query are shown as boxes in the operations section. Each has a header with a name and additional action buttons. Hover over the operation header to show the action buttons. Click the `v` button to quickly replace the operation with different one of the same type. Click the `info` button to open operations' description tooltip. Click the `x` button to remove the operation.
|
||||
|
||||
Operation can have additional parameters under the operation header. See the operation description or Prometheus docs for more details about each operation.
|
||||
|
||||
Some operations make sense only in specific order, if adding an operation would result in nonsensical query, operation will be added to the correct place. To order operations manually drag operation box by the operation name and drop in appropriate place.
|
||||
|
||||
##### Hints
|
||||
|
||||

|
||||
|
||||
In same cases the query editor can detect which operations would be most appropriate for a selected metric. In such cases it will show a hint next to the `+ Operations` button. Click on the hint to add the operations to your query.
|
||||
|
||||
#### Raw query
|
||||
|
||||

|
||||
|
||||
This section is shown only if the `Raw query` switch from the query editor top toolbar is set to `on`. It shows the raw query that will be created and executed by the query editor.
|
||||
|
||||
#### Options
|
||||
|
||||
Same set of option is available as in the `Code` mode. See the [Code mode options]({{< relref "#options" >}}) for details.
|
||||
|
||||
### Explain mode
|
||||
|
||||

|
||||
|
||||
Explain mode helps with understanding the query. It shows a step by step explanation of all query parts and the operations.
|
||||
|
||||
## Templating
|
||||
|
||||
|
||||
+1
-1
@@ -249,7 +249,7 @@
|
||||
"@grafana/aws-sdk": "0.0.35",
|
||||
"@grafana/data": "workspace:*",
|
||||
"@grafana/e2e-selectors": "workspace:*",
|
||||
"@grafana/experimental": "0.0.2-canary.22",
|
||||
"@grafana/experimental": "^0.0.2-canary.25",
|
||||
"@grafana/google-sdk": "0.0.3",
|
||||
"@grafana/lezer-logql": "^0.0.11",
|
||||
"@grafana/runtime": "workspace:*",
|
||||
|
||||
+2
-2
@@ -484,7 +484,7 @@ func (hs *HTTPServer) buildAlertNavLinks(c *models.ReqContext, uaVisibleForOrg b
|
||||
hasAccess := ac.HasAccess(hs.AccessControl, c)
|
||||
var alertChildNavs []*dtos.NavLink
|
||||
|
||||
if hasAccess(ac.ReqSignedIn, ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead), ac.EvalPermission(ac.ActionAlertingNotificationsExternalRead))) {
|
||||
if hasAccess(ac.ReqSignedIn, ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead), ac.EvalPermission(ac.ActionAlertingRuleExternalRead))) {
|
||||
alertChildNavs = append(alertChildNavs, &dtos.NavLink{
|
||||
Text: "Alert rules", Id: "alert-list", Url: hs.Cfg.AppSubURL + "/alerting/list", Icon: "list-ul",
|
||||
})
|
||||
@@ -572,7 +572,7 @@ func (hs *HTTPServer) buildCreateNavLinks(c *models.ReqContext) []*dtos.NavLink
|
||||
_, uaIsDisabledForOrg := hs.Cfg.UnifiedAlerting.DisabledOrgs[c.OrgId]
|
||||
uaVisibleForOrg := hs.Cfg.UnifiedAlerting.IsEnabled() && !uaIsDisabledForOrg
|
||||
|
||||
if uaVisibleForOrg {
|
||||
if uaVisibleForOrg && hasAccess(ac.ReqSignedIn, ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleCreate), ac.EvalPermission(ac.ActionAlertingRuleExternalWrite))) {
|
||||
children = append(children, &dtos.NavLink{
|
||||
Text: "Alert rule", SubTitle: "Create an alert rule", Id: "alert",
|
||||
Icon: "bell", Url: hs.Cfg.AppSubURL + "/alerting/new",
|
||||
|
||||
@@ -165,7 +165,7 @@ var (
|
||||
Version: 2,
|
||||
Permissions: accesscontrol.ConcatPermissions(rulesEditorRole.Role.Permissions, instancesEditorRole.Role.Permissions, notificationsEditorRole.Role.Permissions),
|
||||
},
|
||||
Grants: []string{string(models.ROLE_EDITOR)},
|
||||
Grants: []string{string(models.ROLE_EDITOR), string(models.ROLE_ADMIN)},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -107,8 +107,8 @@ func (ss *SQLStore) GetOrgUsers(ctx context.Context, query *models.GetOrgUsersQu
|
||||
whereParams = append(whereParams, query.UserID)
|
||||
}
|
||||
|
||||
whereConditions = append(whereConditions, fmt.Sprintf("%s.is_service_account = ?", dialect.Quote("user")))
|
||||
whereParams = append(whereParams, dialect.BooleanStr(false))
|
||||
whereConditions = append(whereConditions, fmt.Sprintf("%s.is_service_account = ?", ss.Dialect.Quote("user")))
|
||||
whereParams = append(whereParams, ss.Dialect.BooleanStr(false))
|
||||
|
||||
if ss.Cfg.IsFeatureToggleEnabled(featuremgmt.FlagAccesscontrol) && query.User != nil {
|
||||
acFilter, err := accesscontrol.Filter(query.User, "org_user.user_id", "users:id:", accesscontrol.ActionOrgUsersRead)
|
||||
@@ -121,7 +121,7 @@ func (ss *SQLStore) GetOrgUsers(ctx context.Context, query *models.GetOrgUsersQu
|
||||
|
||||
if query.Query != "" {
|
||||
queryWithWildcards := "%" + query.Query + "%"
|
||||
whereConditions = append(whereConditions, "(email "+dialect.LikeStr()+" ? OR name "+dialect.LikeStr()+" ? OR login "+dialect.LikeStr()+" ?)")
|
||||
whereConditions = append(whereConditions, "(email "+ss.Dialect.LikeStr()+" ? OR name "+ss.Dialect.LikeStr()+" ? OR login "+ss.Dialect.LikeStr()+" ?)")
|
||||
whereParams = append(whereParams, queryWithWildcards, queryWithWildcards, queryWithWildcards)
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ func (ss *SQLStore) SearchOrgUsers(ctx context.Context, query *models.SearchOrgU
|
||||
|
||||
if query.Query != "" {
|
||||
queryWithWildcards := "%" + query.Query + "%"
|
||||
whereConditions = append(whereConditions, "(email "+dialect.LikeStr()+" ? OR name "+dialect.LikeStr()+" ? OR login "+dialect.LikeStr()+" ?)")
|
||||
whereConditions = append(whereConditions, "(email "+ss.Dialect.LikeStr()+" ? OR name "+ss.Dialect.LikeStr()+" ? OR login "+ss.Dialect.LikeStr()+" ?)")
|
||||
whereParams = append(whereParams, queryWithWildcards, queryWithWildcards, queryWithWildcards)
|
||||
}
|
||||
|
||||
@@ -260,7 +260,7 @@ func (ss *SQLStore) RemoveOrgUser(ctx context.Context, cmd *models.RemoveOrgUser
|
||||
}
|
||||
}
|
||||
|
||||
// validate that after delete there is at least one user with admin role in org
|
||||
// validate that after delete, there is at least one user with admin role in org
|
||||
if err := validateOneAdminLeftInOrg(cmd.OrgId, sess); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -147,6 +147,10 @@ const getAnimStyles = (theme: GrafanaTheme2, animationDuration: number) => {
|
||||
const overlayTransition = {
|
||||
...commonTransition,
|
||||
transitionProperty: 'background-color, box-shadow, width',
|
||||
// this is needed to prevent a horizontal scrollbar during the animation on firefox
|
||||
'.scrollbar-view': {
|
||||
overflow: 'hidden !important',
|
||||
},
|
||||
};
|
||||
|
||||
const backdropTransition = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Button, HorizontalGroup, IconButton } from '@grafana/ui';
|
||||
import { Button, ConfirmModal, HorizontalGroup, IconButton } from '@grafana/ui';
|
||||
import { AmRouteReceiver, FormAmRoute } from '../../types/amroutes';
|
||||
import { prepareItems } from '../../utils/dynamicTable';
|
||||
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
|
||||
@@ -75,6 +75,7 @@ export const AmRoutesTable: FC<AmRoutesTableProps> = ({
|
||||
alertManagerSourceName,
|
||||
}) => {
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState<boolean>(false);
|
||||
const [expandedId, setExpandedId] = useState<string | number>();
|
||||
const permissions = getNotificationsPermissions(alertManagerSourceName);
|
||||
const canEditRoutes = contextSrv.hasPermission(permissions.update);
|
||||
@@ -135,27 +136,40 @@ export const AmRoutesTable: FC<AmRoutesTableProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<HorizontalGroup>
|
||||
<Button
|
||||
aria-label="Edit route"
|
||||
icon="pen"
|
||||
onClick={expandWithCustomContent}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<IconButton
|
||||
aria-label="Delete route"
|
||||
name="trash-alt"
|
||||
onClick={() => {
|
||||
<>
|
||||
<HorizontalGroup>
|
||||
<Button
|
||||
aria-label="Edit route"
|
||||
icon="pen"
|
||||
onClick={expandWithCustomContent}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<IconButton
|
||||
aria-label="Delete route"
|
||||
name="trash-alt"
|
||||
onClick={() => {
|
||||
setShowDeleteModal(true);
|
||||
}}
|
||||
type="button"
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
<ConfirmModal
|
||||
isOpen={showDeleteModal}
|
||||
title="Delete notification policy"
|
||||
body="Deleting this notification policy will permanently remove it. Are you sure you want to delete this policy?"
|
||||
confirmText="Yes, delete"
|
||||
icon="exclamation-triangle"
|
||||
onConfirm={() => {
|
||||
const newRoutes = deleteRoute(routes, item.data);
|
||||
onChange(newRoutes);
|
||||
}}
|
||||
type="button"
|
||||
onDismiss={() => setShowDeleteModal(false)}
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
</>
|
||||
);
|
||||
},
|
||||
size: '100px',
|
||||
|
||||
@@ -3,21 +3,22 @@ import { useFormContext } from 'react-hook-form';
|
||||
import { useDebounce } from 'react-use';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { css } from '@emotion/css';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { dateTime, GrafanaTheme2 } from '@grafana/data';
|
||||
import { Badge, useStyles2 } from '@grafana/ui';
|
||||
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
|
||||
import { RuleState } from '../rules/RuleState';
|
||||
import { useCombinedRuleNamespaces } from '../../hooks/useCombinedRuleNamespaces';
|
||||
import { Annotation } from '../../utils/constants';
|
||||
import { findAlertRulesWithMatchers } from '../../utils/matchers';
|
||||
import { findAlertInstancesWithMatchers } from '../../utils/matchers';
|
||||
import { fetchAllPromAndRulerRulesAction } from '../../state/actions';
|
||||
import { CombinedRule } from 'app/types/unified-alerting';
|
||||
import { Alert, AlertingRule } from 'app/types/unified-alerting';
|
||||
import { MatcherFieldValue, SilenceFormFields } from '../../types/silence-form';
|
||||
import { isAlertingRule } from '../../utils/rules';
|
||||
import { AlertStateTag } from '../rules/AlertStateTag';
|
||||
import { AlertLabels } from '../AlertLabels';
|
||||
|
||||
type MatchedRulesTableItemProps = DynamicTableItemProps<{
|
||||
matchedRule: CombinedRule;
|
||||
matchedInstance: Alert;
|
||||
}>;
|
||||
type MatchedRulesTableColumnProps = DynamicTableColumnProps<{ matchedRule: CombinedRule }>;
|
||||
type MatchedRulesTableColumnProps = DynamicTableColumnProps<{ matchedInstance: Alert }>;
|
||||
|
||||
export const MatchedSilencedRules = () => {
|
||||
const [matchedAlertRules, setMatchedAlertRules] = useState<MatchedRulesTableItemProps[]>([]);
|
||||
@@ -35,12 +36,15 @@ export const MatchedSilencedRules = () => {
|
||||
const combinedNamespaces = useCombinedRuleNamespaces();
|
||||
useDebounce(
|
||||
() => {
|
||||
const matchedRules = combinedNamespaces.flatMap((namespace) => {
|
||||
const matchedInstances = combinedNamespaces.flatMap((namespace) => {
|
||||
return namespace.groups.flatMap((group) => {
|
||||
return findAlertRulesWithMatchers(group.rules, matchers);
|
||||
return group.rules
|
||||
.map((combinedRule) => combinedRule.promRule)
|
||||
.filter((rule): rule is AlertingRule => isAlertingRule(rule))
|
||||
.flatMap((rule) => findAlertInstancesWithMatchers(rule.alerts ?? [], matchers));
|
||||
});
|
||||
});
|
||||
setMatchedAlertRules(matchedRules);
|
||||
setMatchedAlertRules(matchedInstances);
|
||||
},
|
||||
500,
|
||||
[combinedNamespaces, matchers]
|
||||
@@ -49,7 +53,7 @@ export const MatchedSilencedRules = () => {
|
||||
return (
|
||||
<div>
|
||||
<h4 className={styles.title}>
|
||||
Affected alerts
|
||||
Affected alert instances
|
||||
{matchedAlertRules.length > 0 ? (
|
||||
<Badge className={styles.badge} color="blue" text={matchedAlertRules.length} />
|
||||
) : null}
|
||||
@@ -75,24 +79,30 @@ function useColumns(): MatchedRulesTableColumnProps[] {
|
||||
{
|
||||
id: 'state',
|
||||
label: 'State',
|
||||
renderCell: function renderStateTag({ data: { matchedRule } }) {
|
||||
return <RuleState rule={matchedRule} isCreating={false} isDeleting={false} />;
|
||||
renderCell: function renderStateTag({ data: { matchedInstance } }) {
|
||||
return <AlertStateTag state={matchedInstance.state} />;
|
||||
},
|
||||
size: '160px',
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
label: 'Name',
|
||||
renderCell: function renderName({ data: { matchedRule } }) {
|
||||
return matchedRule.name;
|
||||
id: 'labels',
|
||||
label: 'Labels',
|
||||
renderCell: function renderName({ data: { matchedInstance } }) {
|
||||
return <AlertLabels labels={matchedInstance.labels} />;
|
||||
},
|
||||
size: '250px',
|
||||
},
|
||||
{
|
||||
id: 'summary',
|
||||
label: 'Summary',
|
||||
renderCell: function renderSummary({ data: { matchedRule } }) {
|
||||
return matchedRule.annotations[Annotation.summary] ?? '';
|
||||
id: 'created',
|
||||
label: 'Created',
|
||||
renderCell: function renderSummary({ data: { matchedInstance } }) {
|
||||
return (
|
||||
<>
|
||||
{matchedInstance.activeAt.startsWith('0001')
|
||||
? '-'
|
||||
: dateTime(matchedInstance.activeAt).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</>
|
||||
);
|
||||
},
|
||||
size: '400px',
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MatcherOperator } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { getMatcherQueryParams, findAlertRulesWithMatchers, parseQueryParamMatchers } from './matchers';
|
||||
import { mockCombinedRule } from '../mocks';
|
||||
import { getMatcherQueryParams, findAlertInstancesWithMatchers, parseQueryParamMatchers } from './matchers';
|
||||
import { mockPromAlert } from '../mocks';
|
||||
|
||||
describe('Unified Alerting matchers', () => {
|
||||
describe('getMatcherQueryParams tests', () => {
|
||||
@@ -39,42 +39,42 @@ describe('Unified Alerting matchers', () => {
|
||||
describe('matchLabelsToMatchers', () => {
|
||||
it('should match for equal', () => {
|
||||
const matchers = [{ name: 'foo', value: 'bar', operator: MatcherOperator.equal }];
|
||||
const rules = [mockCombinedRule({ labels: { foo: 'bar' } }), mockCombinedRule({ labels: { foo: 'baz' } })];
|
||||
const matchedRules = findAlertRulesWithMatchers(rules, matchers);
|
||||
const alerts = [mockPromAlert({ labels: { foo: 'bar' } }), mockPromAlert({ labels: { foo: 'baz' } })];
|
||||
const matchedAlerts = findAlertInstancesWithMatchers(alerts, matchers);
|
||||
|
||||
expect(matchedRules).toHaveLength(1);
|
||||
expect(matchedAlerts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should match for not equal', () => {
|
||||
const matchers = [{ name: 'foo', value: 'bar', operator: MatcherOperator.notEqual }];
|
||||
const rules = [mockCombinedRule({ labels: { foo: 'bar' } }), mockCombinedRule({ labels: { foo: 'baz' } })];
|
||||
const alerts = [mockPromAlert({ labels: { foo: 'bar' } }), mockPromAlert({ labels: { foo: 'baz' } })];
|
||||
|
||||
const matchedRules = findAlertRulesWithMatchers(rules, matchers);
|
||||
expect(matchedRules).toHaveLength(1);
|
||||
const matchedAlerts = findAlertInstancesWithMatchers(alerts, matchers);
|
||||
expect(matchedAlerts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should match for regex', () => {
|
||||
const matchers = [{ name: 'foo', value: 'bar', operator: MatcherOperator.regex }];
|
||||
const rules = [
|
||||
mockCombinedRule({ labels: { foo: 'bar' } }),
|
||||
mockCombinedRule({ labels: { foo: 'baz' } }),
|
||||
mockCombinedRule({ labels: { foo: 'bas' } }),
|
||||
const alerts = [
|
||||
mockPromAlert({ labels: { foo: 'bar' } }),
|
||||
mockPromAlert({ labels: { foo: 'baz' } }),
|
||||
mockPromAlert({ labels: { foo: 'bas' } }),
|
||||
];
|
||||
|
||||
const matchedRules = findAlertRulesWithMatchers(rules, matchers);
|
||||
expect(matchedRules).toHaveLength(1);
|
||||
const matchedAlerts = findAlertInstancesWithMatchers(alerts, matchers);
|
||||
expect(matchedAlerts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should not match regex', () => {
|
||||
const matchers = [{ name: 'foo', value: 'bar', operator: MatcherOperator.notRegex }];
|
||||
const rules = [
|
||||
mockCombinedRule({ labels: { foo: 'bar' } }),
|
||||
mockCombinedRule({ labels: { foo: 'baz' } }),
|
||||
mockCombinedRule({ labels: { foo: 'bas' } }),
|
||||
const alerts = [
|
||||
mockPromAlert({ labels: { foo: 'bar' } }),
|
||||
mockPromAlert({ labels: { foo: 'baz' } }),
|
||||
mockPromAlert({ labels: { foo: 'bas' } }),
|
||||
];
|
||||
|
||||
const matchedRules = findAlertRulesWithMatchers(rules, matchers);
|
||||
expect(matchedRules).toHaveLength(2);
|
||||
const matchedAlerts = findAlertInstancesWithMatchers(alerts, matchers);
|
||||
expect(matchedAlerts).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Labels } from '@grafana/data';
|
||||
import { parseMatcher } from './alertmanager';
|
||||
import { uniqBy } from 'lodash';
|
||||
import { MatcherFieldValue } from '../types/silence-form';
|
||||
import { CombinedRule } from 'app/types/unified-alerting';
|
||||
import { Alert } from 'app/types/unified-alerting';
|
||||
|
||||
// Parses a list of entries like like "['foo=bar', 'baz=~bad*']" into SilenceMatcher[]
|
||||
export function parseQueryParamMatchers(matcherPairs: string[]): Matcher[] {
|
||||
@@ -27,16 +27,19 @@ export const getMatcherQueryParams = (labels: Labels) => {
|
||||
return matcherUrlParams;
|
||||
};
|
||||
|
||||
interface MatchedRule {
|
||||
interface MatchedInstance {
|
||||
id: string;
|
||||
data: {
|
||||
matchedRule: CombinedRule;
|
||||
matchedInstance: Alert;
|
||||
};
|
||||
}
|
||||
|
||||
export const findAlertRulesWithMatchers = (rules: CombinedRule[], matchers: MatcherFieldValue[]): MatchedRule[] => {
|
||||
const hasMatcher = (rule: CombinedRule, matcher: MatcherFieldValue) => {
|
||||
return Object.entries(rule.labels).some(([key, value]) => {
|
||||
export const findAlertInstancesWithMatchers = (
|
||||
instances: Alert[],
|
||||
matchers: MatcherFieldValue[]
|
||||
): MatchedInstance[] => {
|
||||
const hasMatcher = (instance: Alert, matcher: MatcherFieldValue) => {
|
||||
return Object.entries(instance.labels).some(([key, value]) => {
|
||||
if (!matcher.name || !matcher.value) {
|
||||
return false;
|
||||
}
|
||||
@@ -56,13 +59,13 @@ export const findAlertRulesWithMatchers = (rules: CombinedRule[], matchers: Matc
|
||||
});
|
||||
};
|
||||
|
||||
const filteredRules = rules.filter((rule) => {
|
||||
return matchers.every((matcher) => hasMatcher(rule, matcher));
|
||||
const filteredInstances = instances.filter((instance) => {
|
||||
return matchers.every((matcher) => hasMatcher(instance, matcher));
|
||||
});
|
||||
const mappedRules = filteredRules.map((rule) => ({
|
||||
id: `${rule.namespace}-${rule.name}`,
|
||||
data: { matchedRule: rule },
|
||||
const mappedInstances = filteredInstances.map((instance) => ({
|
||||
id: `${instance.activeAt}-${instance.value}`,
|
||||
data: { matchedInstance: instance },
|
||||
}));
|
||||
|
||||
return mappedRules;
|
||||
return mappedInstances;
|
||||
};
|
||||
|
||||
+6
-6
@@ -1,13 +1,13 @@
|
||||
import { isEqual } from 'lodash';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { EditorList } from '@grafana/experimental';
|
||||
import { CloudWatchDatasource } from '../../datasource';
|
||||
import { CloudWatchMetricsQuery, Dimensions as DimensionsType } from '../../types';
|
||||
import { Dimensions as DimensionsType, DimensionsQuery } from '../../types';
|
||||
import { FilterItem } from './FilterItem';
|
||||
|
||||
export interface Props {
|
||||
query: CloudWatchMetricsQuery;
|
||||
query: DimensionsQuery;
|
||||
onChange: (dimensions: DimensionsType) => void;
|
||||
datasource: CloudWatchDatasource;
|
||||
dimensionKeys: Array<SelectableValue<string>>;
|
||||
@@ -43,8 +43,8 @@ const filterConditionsToDimensions = (filters: DimensionFilterCondition[]) => {
|
||||
};
|
||||
|
||||
export const Dimensions: React.FC<Props> = ({ query, datasource, dimensionKeys, disableExpressions, onChange }) => {
|
||||
const dimensionFilters = useMemo(() => dimensionsToFilterConditions(query.dimensions), [query.dimensions]);
|
||||
const [items, setItems] = useState<DimensionFilterCondition[]>(dimensionFilters);
|
||||
const [items, setItems] = useState<DimensionFilterCondition[]>([]);
|
||||
useEffect(() => setItems(dimensionsToFilterConditions(query.dimensions)), [query.dimensions]);
|
||||
const onDimensionsChange = (newItems: Array<Partial<DimensionFilterCondition>>) => {
|
||||
setItems(newItems);
|
||||
|
||||
@@ -67,7 +67,7 @@ export const Dimensions: React.FC<Props> = ({ query, datasource, dimensionKeys,
|
||||
|
||||
function makeRenderFilter(
|
||||
datasource: CloudWatchDatasource,
|
||||
query: CloudWatchMetricsQuery,
|
||||
query: DimensionsQuery,
|
||||
dimensionKeys: Array<SelectableValue<string>>,
|
||||
disableExpressions: boolean
|
||||
) {
|
||||
+3
-3
@@ -5,12 +5,12 @@ import { GrafanaTheme2, SelectableValue, toOption } from '@grafana/data';
|
||||
import { InputGroup, AccessoryButton } from '@grafana/experimental';
|
||||
import { Select, stylesFactory, useTheme2 } from '@grafana/ui';
|
||||
import { CloudWatchDatasource } from '../../datasource';
|
||||
import { CloudWatchMetricsQuery, Dimensions } from '../../types';
|
||||
import { Dimensions, DimensionsQuery } from '../../types';
|
||||
import { appendTemplateVariables } from '../../utils/utils';
|
||||
import { DimensionFilterCondition } from './Dimensions';
|
||||
|
||||
export interface Props {
|
||||
query: CloudWatchMetricsQuery;
|
||||
query: DimensionsQuery;
|
||||
datasource: CloudWatchDatasource;
|
||||
filter: DimensionFilterCondition;
|
||||
dimensionKeys: Array<SelectableValue<string>>;
|
||||
@@ -96,7 +96,7 @@ export const FilterItem: FunctionComponent<Props> = ({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<AccessoryButton aria-label="remove" icon="times" variant="secondary" onClick={onDelete} />
|
||||
<AccessoryButton aria-label="remove" icon="times" variant="secondary" onClick={onDelete} type="button" />
|
||||
</InputGroup>
|
||||
</div>
|
||||
);
|
||||
+82
-16
@@ -1,7 +1,7 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { select } from 'react-select-event';
|
||||
import { VariableQueryType } from '../../types';
|
||||
import { Dimensions, VariableQueryType } from '../../types';
|
||||
import { setupMockedDataSource } from '../../__mocks__/CloudWatchDataSource';
|
||||
import { VariableQueryEditor, Props } from './VariableQueryEditor';
|
||||
|
||||
@@ -11,7 +11,6 @@ const defaultQuery = {
|
||||
region: '',
|
||||
metricName: '',
|
||||
dimensionKey: '',
|
||||
dimensionFilters: '',
|
||||
ec2Filters: '',
|
||||
instanceID: '',
|
||||
attributeName: '',
|
||||
@@ -37,26 +36,42 @@ ds.datasource.getMetrics = jest.fn().mockResolvedValue([
|
||||
{ label: 'i3', value: 'i3' },
|
||||
{ label: 'j3', value: 'j3' },
|
||||
]);
|
||||
ds.datasource.getDimensionKeys = jest.fn().mockImplementation((namespace: string, region: string) => {
|
||||
if (region === 'a1') {
|
||||
return Promise.resolve([
|
||||
{ label: 'q4', value: 'q4' },
|
||||
{ label: 'r4', value: 'r4' },
|
||||
{ label: 's4', value: 's4' },
|
||||
]);
|
||||
}
|
||||
return Promise.resolve([{ label: 't4', value: 't4' }]);
|
||||
});
|
||||
ds.datasource.getDimensionKeys = jest
|
||||
.fn()
|
||||
.mockImplementation((namespace: string, region: string, dimensionFilters?: Dimensions) => {
|
||||
if (!!dimensionFilters) {
|
||||
return Promise.resolve([
|
||||
{ label: 's4', value: 's4' },
|
||||
{ label: 'v4', value: 'v4' },
|
||||
]);
|
||||
}
|
||||
if (region === 'a1') {
|
||||
return Promise.resolve([
|
||||
{ label: 'q4', value: 'q4' },
|
||||
{ label: 'r4', value: 'r4' },
|
||||
{ label: 's4', value: 's4' },
|
||||
]);
|
||||
}
|
||||
return Promise.resolve([{ label: 't4', value: 't4' }]);
|
||||
});
|
||||
ds.datasource.getDimensionValues = jest.fn().mockResolvedValue([
|
||||
{ label: 'foo', value: 'foo' },
|
||||
{ label: 'bar', value: 'bar' },
|
||||
]);
|
||||
ds.datasource.getVariables = jest.fn().mockReturnValue([]);
|
||||
|
||||
const onChange = jest.fn();
|
||||
const defaultProps: Props = {
|
||||
onChange: jest.fn(),
|
||||
onChange: onChange,
|
||||
query: defaultQuery,
|
||||
datasource: ds.datasource,
|
||||
onRunQuery: () => {},
|
||||
};
|
||||
|
||||
describe('VariableEditor', () => {
|
||||
beforeEach(() => {
|
||||
onChange.mockClear();
|
||||
});
|
||||
describe('and a new variable is created', () => {
|
||||
it('should trigger a query using the first query type in the array', async () => {
|
||||
const props = defaultProps;
|
||||
@@ -100,6 +115,56 @@ describe('VariableEditor', () => {
|
||||
expect(metricSelect).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it('should parse dimensionFilters correctly', async () => {
|
||||
const props = defaultProps;
|
||||
props.query = {
|
||||
...defaultQuery,
|
||||
queryType: VariableQueryType.DimensionValues,
|
||||
namespace: 'z2',
|
||||
region: 'a1',
|
||||
metricName: 'i3',
|
||||
dimensionKey: 's4',
|
||||
dimensionFilters: { s4: 'foo' },
|
||||
};
|
||||
render(<VariableQueryEditor {...props} />);
|
||||
|
||||
const filterItem = screen.getByTestId('cloudwatch-dimensions-filter-item');
|
||||
expect(filterItem).toBeInTheDocument();
|
||||
expect(within(filterItem).getByText('s4')).toBeInTheDocument();
|
||||
expect(within(filterItem).getByText('foo')).toBeInTheDocument();
|
||||
|
||||
// change filter key
|
||||
const keySelect = screen.getByRole('combobox', { name: 'Dimensions filter key' });
|
||||
// confirms getDimensionKeys was called with filter and that the element uses keysForDimensionFilter
|
||||
await select(keySelect, 'v4', {
|
||||
container: document.body,
|
||||
});
|
||||
expect(ds.datasource.getDimensionKeys).toHaveBeenCalledWith('z2', 'a1', {}, '');
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
...defaultQuery,
|
||||
queryType: VariableQueryType.DimensionValues,
|
||||
namespace: 'z2',
|
||||
region: 'a1',
|
||||
metricName: 'i3',
|
||||
dimensionKey: 's4',
|
||||
dimensionFilters: { v4: undefined },
|
||||
});
|
||||
|
||||
// set filter value
|
||||
const valueSelect = screen.getByRole('combobox', { name: 'Dimensions filter value' });
|
||||
await select(valueSelect, 'bar', {
|
||||
container: document.body,
|
||||
});
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
...defaultQuery,
|
||||
queryType: VariableQueryType.DimensionValues,
|
||||
namespace: 'z2',
|
||||
region: 'a1',
|
||||
metricName: 'i3',
|
||||
dimensionKey: 's4',
|
||||
dimensionFilters: { v4: 'bar' },
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('and a different region is selected', () => {
|
||||
it('should clear invalid fields', async () => {
|
||||
@@ -111,6 +176,7 @@ describe('VariableEditor', () => {
|
||||
region: 'a1',
|
||||
metricName: 'i3',
|
||||
dimensionKey: 's4',
|
||||
dimensionFilters: { s4: 'foo' },
|
||||
};
|
||||
render(<VariableQueryEditor {...props} />);
|
||||
|
||||
@@ -118,7 +184,6 @@ describe('VariableEditor', () => {
|
||||
expect(querySelect).toBeInTheDocument();
|
||||
expect(screen.queryByText('Dimension Values')).toBeInTheDocument();
|
||||
const regionSelect = screen.getByRole('combobox', { name: 'Region' });
|
||||
regionSelect.click();
|
||||
await select(regionSelect, 'b1', {
|
||||
container: document.body,
|
||||
});
|
||||
@@ -133,8 +198,9 @@ describe('VariableEditor', () => {
|
||||
region: 'b1',
|
||||
// metricName i3 exists in the new region and should not be removed
|
||||
metricName: 'i3',
|
||||
// dimensionKey s4 does not exist in the new region and should be removed
|
||||
// dimensionKey s4 and valueDimension do not exist in the new region and should be removed
|
||||
dimensionKey: '',
|
||||
dimensionFilters: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+25
-12
@@ -7,6 +7,8 @@ import { useDimensionKeys, useMetrics, useNamespaces, useRegions } from '../../h
|
||||
import { CloudWatchJsonData, CloudWatchQuery, VariableQuery, VariableQueryType } from '../../types';
|
||||
import { migrateVariableQuery } from '../../migrations';
|
||||
import { VariableQueryField } from './VariableQueryField';
|
||||
import { Dimensions } from '..';
|
||||
import { InlineField } from '@grafana/ui';
|
||||
|
||||
export type Props = QueryEditorProps<CloudWatchDatasource, CloudWatchQuery, CloudWatchJsonData, VariableQuery>;
|
||||
|
||||
@@ -25,11 +27,12 @@ const queryTypes: Array<{ value: string; label: string }> = [
|
||||
export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
|
||||
const parsedQuery = migrateVariableQuery(query);
|
||||
|
||||
const { region, namespace, metricName, dimensionKey } = parsedQuery;
|
||||
const { region, namespace, metricName, dimensionKey, dimensionFilters } = parsedQuery;
|
||||
const [regions, regionIsLoading] = useRegions(datasource);
|
||||
const namespaces = useNamespaces(datasource);
|
||||
const metrics = useMetrics(datasource, region, namespace);
|
||||
const dimensionKeys = useDimensionKeys(datasource, region, namespace, metricName);
|
||||
const keysForDimensionFilter = useDimensionKeys(datasource, region, namespace, metricName, dimensionFilters ?? {});
|
||||
|
||||
const onRegionChange = async (region: string) => {
|
||||
const validatedQuery = await sanitizeQuery({
|
||||
@@ -48,7 +51,10 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
|
||||
};
|
||||
|
||||
const onQueryChange = (newQuery: VariableQuery) => {
|
||||
onChange({ ...newQuery, refId: 'CloudWatchVariableQueryEditor-VariableQuery' });
|
||||
onChange({
|
||||
...newQuery,
|
||||
refId: 'CloudWatchVariableQueryEditor-VariableQuery',
|
||||
});
|
||||
};
|
||||
|
||||
// Reset dimensionValue parameters if namespace or region change
|
||||
@@ -58,7 +64,6 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
|
||||
await datasource.getMetrics(namespace, region).then((result: Array<SelectableValue<string>>) => {
|
||||
if (!result.find((metric) => metric.value === metricName)) {
|
||||
metricName = '';
|
||||
dimensionFilters = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -66,7 +71,7 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
|
||||
await datasource.getDimensionKeys(namespace, region).then((result: Array<SelectableValue<string>>) => {
|
||||
if (!result.find((key) => key.value === dimensionKey)) {
|
||||
dimensionKey = '';
|
||||
dimensionFilters = '';
|
||||
dimensionFilters = {};
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -86,7 +91,6 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
|
||||
VariableQueryType.DimensionKeys,
|
||||
VariableQueryType.DimensionValues,
|
||||
].includes(parsedQuery.queryType);
|
||||
|
||||
return (
|
||||
<>
|
||||
<VariableQueryField
|
||||
@@ -94,6 +98,7 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
|
||||
options={queryTypes}
|
||||
onChange={(value: VariableQueryType) => onQueryChange({ ...parsedQuery, queryType: value })}
|
||||
label="Query Type"
|
||||
inputId={`variable-query-type-${query.refId}`}
|
||||
/>
|
||||
{hasRegionField && (
|
||||
<VariableQueryField
|
||||
@@ -102,6 +107,7 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
|
||||
onChange={(value: string) => onRegionChange(value)}
|
||||
label="Region"
|
||||
isLoading={regionIsLoading}
|
||||
inputId={`variable-query-region-${query.refId}`}
|
||||
/>
|
||||
)}
|
||||
{hasNamespaceField && (
|
||||
@@ -110,6 +116,7 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
|
||||
options={namespaces}
|
||||
onChange={(value: string) => onNamespaceChange(value)}
|
||||
label="Namespace"
|
||||
inputId={`variable-query-namespace-${query.refId}`}
|
||||
/>
|
||||
)}
|
||||
{parsedQuery.queryType === VariableQueryType.DimensionValues && (
|
||||
@@ -119,20 +126,26 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
|
||||
options={metrics}
|
||||
onChange={(value: string) => onQueryChange({ ...parsedQuery, metricName: value })}
|
||||
label="Metric"
|
||||
inputId={`variable-query-metric-${query.refId}`}
|
||||
/>
|
||||
<VariableQueryField
|
||||
value={dimensionKey || null}
|
||||
options={dimensionKeys}
|
||||
onChange={(value: string) => onQueryChange({ ...parsedQuery, dimensionKey: value })}
|
||||
label="Dimension Key"
|
||||
inputId={`variable-query-dimension-key-${query.refId}`}
|
||||
/>
|
||||
<VariableTextField
|
||||
value={query.dimensionFilters}
|
||||
tooltip='A JSON object representing dimensions and the values to filter on. Ex. { "filter_name1": [ "filter_value1" ], "filter_name2": [ "*" ] }'
|
||||
placeholder='{"key":["value"]}'
|
||||
onBlur={(value: string) => onQueryChange({ ...parsedQuery, dimensionFilters: value })}
|
||||
label="Filters"
|
||||
/>
|
||||
<InlineField label="Dimensions" labelWidth={20} tooltip="Dimensions to filter the returned values on">
|
||||
<Dimensions
|
||||
query={{ ...parsedQuery, dimensions: parsedQuery.dimensionFilters }}
|
||||
onChange={(dimensions) => {
|
||||
onChange({ ...parsedQuery, dimensionFilters: dimensions });
|
||||
}}
|
||||
dimensionKeys={keysForDimensionFilter}
|
||||
disableExpressions={true}
|
||||
datasource={datasource}
|
||||
/>
|
||||
</InlineField>
|
||||
</>
|
||||
)}
|
||||
{parsedQuery.queryType === VariableQueryType.EBSVolumeIDs && (
|
||||
|
||||
+4
-3
@@ -10,9 +10,9 @@ interface VariableQueryFieldProps<T> {
|
||||
options: SelectableValue[];
|
||||
value: T | null;
|
||||
label: string;
|
||||
inputId?: string;
|
||||
allowCustomValue?: boolean;
|
||||
isLoading?: boolean;
|
||||
inputId?: string;
|
||||
}
|
||||
|
||||
export const VariableQueryField = <T extends string | VariableQueryType>({
|
||||
@@ -22,9 +22,10 @@ export const VariableQueryField = <T extends string | VariableQueryType>({
|
||||
options,
|
||||
allowCustomValue = false,
|
||||
isLoading = false,
|
||||
inputId = label,
|
||||
}: VariableQueryFieldProps<T>) => {
|
||||
return (
|
||||
<InlineField label={label} labelWidth={LABEL_WIDTH} htmlFor={'inline-field'}>
|
||||
<InlineField label={label} labelWidth={LABEL_WIDTH} htmlFor={inputId}>
|
||||
<Select
|
||||
menuShouldPortal
|
||||
aria-label={label}
|
||||
@@ -34,7 +35,7 @@ export const VariableQueryField = <T extends string | VariableQueryType>({
|
||||
onChange={({ value }) => onChange(value!)}
|
||||
options={options}
|
||||
isLoading={isLoading}
|
||||
inputId="inline-field"
|
||||
inputId={inputId}
|
||||
/>
|
||||
</InlineField>
|
||||
);
|
||||
|
||||
+1
-3
@@ -2,7 +2,6 @@ import { InlineField, Input } from '@grafana/ui';
|
||||
import React, { FC, useState } from 'react';
|
||||
|
||||
const LABEL_WIDTH = 20;
|
||||
const TEXT_WIDTH = 100;
|
||||
|
||||
interface VariableTextFieldProps {
|
||||
onBlur: (value: string) => void;
|
||||
@@ -15,14 +14,13 @@ interface VariableTextFieldProps {
|
||||
export const VariableTextField: FC<VariableTextFieldProps> = ({ label, onBlur, placeholder, value, tooltip }) => {
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
return (
|
||||
<InlineField label={label} labelWidth={LABEL_WIDTH} tooltip={tooltip}>
|
||||
<InlineField label={label} labelWidth={LABEL_WIDTH} tooltip={tooltip} grow>
|
||||
<Input
|
||||
aria-label={label}
|
||||
placeholder={placeholder}
|
||||
value={localValue}
|
||||
onChange={(e) => setLocalValue(e.currentTarget.value)}
|
||||
onBlur={() => onBlur(localValue)}
|
||||
width={TEXT_WIDTH}
|
||||
/>
|
||||
</InlineField>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { Dimensions } from './MetricStatEditor/Dimensions';
|
||||
export { Dimensions } from './Dimensions/Dimensions';
|
||||
export { QueryInlineField, QueryField } from './Forms';
|
||||
export { Alias } from './Alias';
|
||||
export { PanelQueryEditor } from './PanelQueryEditor';
|
||||
|
||||
@@ -206,7 +206,7 @@ describe('migration', () => {
|
||||
expect(query.namespace).toBe('AWS/RDS');
|
||||
expect(query.metricName).toBe('CPUUtilization');
|
||||
expect(query.dimensionKey).toBe('DBInstanceIdentifier');
|
||||
expect(query.dimensionFilters).toBe('');
|
||||
expect(query.dimensionFilters).toStrictEqual({});
|
||||
});
|
||||
});
|
||||
describe('and filter param is defined by user', () => {
|
||||
@@ -219,9 +219,18 @@ describe('migration', () => {
|
||||
expect(query.namespace).toBe('AWS/RDS');
|
||||
expect(query.metricName).toBe('CPUUtilization');
|
||||
expect(query.dimensionKey).toBe('DBInstanceIdentifier');
|
||||
expect(query.dimensionFilters).toBe('{"InstanceId":"$instance_id"}');
|
||||
expect(query.dimensionFilters).toStrictEqual({ InstanceId: '$instance_id' });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('when resource_arns query is used', () => {
|
||||
it('should parse the query', () => {
|
||||
const query = migrateVariableQuery('resource_arns(us-east-1,rds:db,{"environment":["$environment"]})');
|
||||
expect(query.queryType).toBe(VariableQueryType.ResourceArns);
|
||||
expect(query.region).toBe('us-east-1');
|
||||
expect(query.resourceType).toBe('rds:db');
|
||||
expect(query.tags).toBe('{"environment":["$environment"]}');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,7 +80,7 @@ export function migrateVariableQuery(rawQuery: string | VariableQuery): Variable
|
||||
region: '',
|
||||
metricName: '',
|
||||
dimensionKey: '',
|
||||
dimensionFilters: '',
|
||||
dimensionFilters: {},
|
||||
ec2Filters: '',
|
||||
instanceID: '',
|
||||
attributeName: '',
|
||||
@@ -122,7 +122,14 @@ export function migrateVariableQuery(rawQuery: string | VariableQuery): Variable
|
||||
newQuery.namespace = dimensionValuesQuery[2];
|
||||
newQuery.metricName = dimensionValuesQuery[3];
|
||||
newQuery.dimensionKey = dimensionValuesQuery[4];
|
||||
newQuery.dimensionFilters = dimensionValuesQuery[6] || '';
|
||||
newQuery.dimensionFilters = {};
|
||||
if (!!dimensionValuesQuery[6]) {
|
||||
try {
|
||||
newQuery.dimensionFilters = JSON.parse(dimensionValuesQuery[6]);
|
||||
} catch {
|
||||
throw new Error(`unable to migrate poorly formed filters: ${dimensionValuesQuery[6]}`);
|
||||
}
|
||||
}
|
||||
return newQuery;
|
||||
}
|
||||
|
||||
@@ -148,7 +155,7 @@ export function migrateVariableQuery(rawQuery: string | VariableQuery): Variable
|
||||
newQuery.queryType = VariableQueryType.ResourceArns;
|
||||
newQuery.region = resourceARNsQuery[1];
|
||||
newQuery.resourceType = resourceARNsQuery[2];
|
||||
newQuery.tags = JSON.parse(resourceARNsQuery[3]) || '';
|
||||
newQuery.tags = resourceARNsQuery[3] || '';
|
||||
return newQuery;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,13 @@ export interface SQLExpression {
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface DimensionsQuery extends DataQuery {
|
||||
namespace: string;
|
||||
region: string;
|
||||
metricName?: string;
|
||||
dimensions?: Dimensions;
|
||||
}
|
||||
|
||||
export interface CloudWatchMetricsQuery extends DataQuery {
|
||||
queryMode?: 'Metrics';
|
||||
metricQueryType?: MetricQueryType;
|
||||
@@ -386,7 +393,7 @@ export interface VariableQuery extends DataQuery {
|
||||
region: string;
|
||||
metricName: string;
|
||||
dimensionKey: string;
|
||||
dimensionFilters: string;
|
||||
dimensionFilters?: Dimensions;
|
||||
ec2Filters: string;
|
||||
instanceID: string;
|
||||
attributeName: string;
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { VariableQuery, VariableQueryType } from './types';
|
||||
import { CloudWatchVariableSupport } from './variables';
|
||||
import { setupMockedDataSource } from './__mocks__/CloudWatchDataSource';
|
||||
|
||||
const defaultQuery: VariableQuery = {
|
||||
queryType: VariableQueryType.Regions,
|
||||
namespace: 'foo',
|
||||
region: 'bar',
|
||||
metricName: '',
|
||||
dimensionKey: '',
|
||||
ec2Filters: '',
|
||||
instanceID: '',
|
||||
attributeName: '',
|
||||
resourceType: '',
|
||||
tags: '',
|
||||
refId: '',
|
||||
};
|
||||
|
||||
const ds = setupMockedDataSource();
|
||||
ds.datasource.getRegions = jest.fn().mockResolvedValue([{ label: 'a', value: 'a' }]);
|
||||
ds.datasource.getNamespaces = jest.fn().mockResolvedValue([{ label: 'b', value: 'b' }]);
|
||||
ds.datasource.getMetrics = jest.fn().mockResolvedValue([{ label: 'c', value: 'c' }]);
|
||||
ds.datasource.getDimensionKeys = jest.fn().mockResolvedValue([{ label: 'd', value: 'd' }]);
|
||||
const getDimensionValues = jest.fn().mockResolvedValue([{ label: 'e', value: 'e' }]);
|
||||
const getEbsVolumeIds = jest.fn().mockResolvedValue([{ label: 'f', value: 'f' }]);
|
||||
const getEc2InstanceAttribute = jest.fn().mockResolvedValue([{ label: 'g', value: 'g' }]);
|
||||
const getResourceARNs = jest.fn().mockResolvedValue([{ label: 'h', value: 'h' }]);
|
||||
|
||||
const variables = new CloudWatchVariableSupport(ds.datasource);
|
||||
|
||||
describe('variables', () => {
|
||||
it('should run regions', async () => {
|
||||
const result = await variables.execute({ ...defaultQuery });
|
||||
expect(result).toEqual([{ text: 'a', value: 'a', expandable: true }]);
|
||||
});
|
||||
|
||||
it('should run namespaces', async () => {
|
||||
const result = await variables.execute({ ...defaultQuery, queryType: VariableQueryType.Namespaces });
|
||||
expect(result).toEqual([{ text: 'b', value: 'b', expandable: true }]);
|
||||
});
|
||||
|
||||
it('should run metrics', async () => {
|
||||
const result = await variables.execute({ ...defaultQuery, queryType: VariableQueryType.Metrics });
|
||||
expect(result).toEqual([{ text: 'c', value: 'c', expandable: true }]);
|
||||
});
|
||||
|
||||
it('should run dimension keys', async () => {
|
||||
const result = await variables.execute({ ...defaultQuery, queryType: VariableQueryType.DimensionKeys });
|
||||
expect(result).toEqual([{ text: 'd', value: 'd', expandable: true }]);
|
||||
});
|
||||
|
||||
describe('dimension values', () => {
|
||||
const query = {
|
||||
...defaultQuery,
|
||||
queryType: VariableQueryType.DimensionValues,
|
||||
metricName: 'abc',
|
||||
dimensionKey: 'efg',
|
||||
dimensionFilters: { a: 'b' },
|
||||
};
|
||||
beforeEach(() => {
|
||||
ds.datasource.getDimensionValues = getDimensionValues;
|
||||
getDimensionValues.mockClear();
|
||||
});
|
||||
|
||||
it('should not run if dimension key not set', async () => {
|
||||
const result = await variables.execute({ ...query, dimensionKey: '' });
|
||||
expect(getDimensionValues).not.toBeCalled();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not run if metric name not set', async () => {
|
||||
const result = await variables.execute({ ...query, metricName: '' });
|
||||
expect(getDimensionValues).not.toBeCalled();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
it('should run if values are set', async () => {
|
||||
const result = await variables.execute(query);
|
||||
expect(getDimensionValues).toBeCalledWith(
|
||||
query.region,
|
||||
query.namespace,
|
||||
query.metricName,
|
||||
query.dimensionKey,
|
||||
query.dimensionFilters
|
||||
);
|
||||
expect(result).toEqual([{ text: 'e', value: 'e', expandable: true }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EBS volume ids', () => {
|
||||
beforeEach(() => {
|
||||
ds.datasource.getEbsVolumeIds = getEbsVolumeIds;
|
||||
getEbsVolumeIds.mockClear();
|
||||
});
|
||||
|
||||
it('should not run if instance id not set', async () => {
|
||||
const result = await variables.execute({ ...defaultQuery, queryType: VariableQueryType.EBSVolumeIDs });
|
||||
expect(getEbsVolumeIds).not.toBeCalled();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should run if instance id set', async () => {
|
||||
const result = await variables.execute({
|
||||
...defaultQuery,
|
||||
queryType: VariableQueryType.EBSVolumeIDs,
|
||||
instanceID: 'foo',
|
||||
});
|
||||
expect(getEbsVolumeIds).toBeCalledWith(defaultQuery.region, 'foo');
|
||||
expect(result).toEqual([{ text: 'f', value: 'f', expandable: true }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EC2 instance attributes', () => {
|
||||
const query = {
|
||||
...defaultQuery,
|
||||
queryType: VariableQueryType.EC2InstanceAttributes,
|
||||
attributeName: 'abc',
|
||||
ec2Filters: '{"a":["b"]}',
|
||||
};
|
||||
beforeEach(() => {
|
||||
ds.datasource.getEc2InstanceAttribute = getEc2InstanceAttribute;
|
||||
getEc2InstanceAttribute.mockClear();
|
||||
});
|
||||
|
||||
it('should not run if instance id not set', async () => {
|
||||
const result = await variables.execute({ ...query, attributeName: '' });
|
||||
expect(getEc2InstanceAttribute).not.toBeCalled();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should run if instance id set', async () => {
|
||||
const result = await variables.execute(query);
|
||||
expect(getEc2InstanceAttribute).toBeCalledWith(query.region, query.attributeName, { a: ['b'] });
|
||||
expect(result).toEqual([{ text: 'g', value: 'g', expandable: true }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resource arns', () => {
|
||||
const query = {
|
||||
...defaultQuery,
|
||||
queryType: VariableQueryType.ResourceArns,
|
||||
resourceType: 'abc',
|
||||
tags: '{"a":["b"]}',
|
||||
};
|
||||
beforeEach(() => {
|
||||
ds.datasource.getResourceARNs = getResourceARNs;
|
||||
getResourceARNs.mockClear();
|
||||
});
|
||||
|
||||
it('should not run if instance id not set', async () => {
|
||||
const result = await variables.execute({ ...query, resourceType: '' });
|
||||
expect(getResourceARNs).not.toBeCalled();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should run if instance id set', async () => {
|
||||
const result = await variables.execute(query);
|
||||
expect(getResourceARNs).toBeCalledWith(query.region, query.resourceType, { a: ['b'] });
|
||||
expect(result).toEqual([{ text: 'h', value: 'h', expandable: true }]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should run statistics', async () => {
|
||||
const result = await variables.execute({ ...defaultQuery, queryType: VariableQueryType.Statistics });
|
||||
expect(result).toEqual([
|
||||
{ text: 'Average', value: 'Average', expandable: true },
|
||||
{ text: 'Maximum', value: 'Maximum', expandable: true },
|
||||
{ text: 'Minimum', value: 'Minimum', expandable: true },
|
||||
{ text: 'Sum', value: 'Sum', expandable: true },
|
||||
{ text: 'SampleCount', value: 'SampleCount', expandable: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -91,11 +91,13 @@ export class CloudWatchVariableSupport extends CustomVariableSupport<CloudWatchD
|
||||
if (!dimensionKey || !metricName) {
|
||||
return [];
|
||||
}
|
||||
var filterJson = {};
|
||||
if (dimensionFilters) {
|
||||
filterJson = JSON.parse(dimensionFilters);
|
||||
}
|
||||
const keys = await this.datasource.getDimensionValues(region, namespace, metricName, dimensionKey, filterJson);
|
||||
const keys = await this.datasource.getDimensionValues(
|
||||
region,
|
||||
namespace,
|
||||
metricName,
|
||||
dimensionKey,
|
||||
dimensionFilters ?? {}
|
||||
);
|
||||
return keys.map((s: { label: string; value: string }) => ({
|
||||
text: s.label,
|
||||
value: s.value,
|
||||
@@ -119,7 +121,7 @@ export class CloudWatchVariableSupport extends CustomVariableSupport<CloudWatchD
|
||||
if (!attributeName) {
|
||||
return [];
|
||||
}
|
||||
var filterJson = {};
|
||||
let filterJson = {};
|
||||
if (ec2Filters) {
|
||||
filterJson = JSON.parse(ec2Filters);
|
||||
}
|
||||
@@ -135,7 +137,7 @@ export class CloudWatchVariableSupport extends CustomVariableSupport<CloudWatchD
|
||||
if (!resourceType) {
|
||||
return [];
|
||||
}
|
||||
var tagJson = {};
|
||||
let tagJson = {};
|
||||
if (tags) {
|
||||
tagJson = JSON.parse(tags);
|
||||
}
|
||||
|
||||
+4
-4
@@ -10,7 +10,7 @@ export default class SupportedNamespaces {
|
||||
'Microsoft.Cdn/cdnwebapplicationfirewallpolicies',
|
||||
'Microsoft.Cdn/profiles',
|
||||
'Microsoft.ClassicCompute/virtualMachines',
|
||||
'Microsoft.ClassicCompute/domainNames/slots/roles',
|
||||
'Microsoft.ClassicCompute/domainNames',
|
||||
'Microsoft.CognitiveServices/accounts',
|
||||
'Microsoft.Compute/virtualMachines',
|
||||
'Microsoft.Compute/virtualMachineScaleSets',
|
||||
@@ -96,7 +96,7 @@ export default class SupportedNamespaces {
|
||||
'Microsoft.Cdn/cdnwebapplicationfirewallpolicies',
|
||||
'Microsoft.Cdn/profiles',
|
||||
'Microsoft.ClassicCompute/virtualMachines',
|
||||
'Microsoft.ClassicCompute/domainNames/slots/roles',
|
||||
'Microsoft.ClassicCompute/domainNames',
|
||||
'Microsoft.CognitiveServices/accounts',
|
||||
'Microsoft.Compute/virtualMachines',
|
||||
'Microsoft.Compute/virtualMachineScaleSets',
|
||||
@@ -153,7 +153,7 @@ export default class SupportedNamespaces {
|
||||
'Microsoft.Cdn/cdnwebapplicationfirewallpolicies',
|
||||
'Microsoft.Cdn/profiles',
|
||||
'Microsoft.ClassicCompute/virtualMachines',
|
||||
'Microsoft.ClassicCompute/domainNames/slots/roles',
|
||||
'Microsoft.ClassicCompute/domainNames',
|
||||
'Microsoft.Compute/virtualMachines',
|
||||
'Microsoft.Compute/virtualMachineScaleSets',
|
||||
'Microsoft.DBforMySQL/servers',
|
||||
@@ -205,7 +205,7 @@ export default class SupportedNamespaces {
|
||||
'Microsoft.Cdn/cdnwebapplicationfirewallpolicies',
|
||||
'Microsoft.Cdn/profiles',
|
||||
'Microsoft.ClassicCompute/virtualMachines',
|
||||
'Microsoft.ClassicCompute/domainNames/slots/roles',
|
||||
'Microsoft.ClassicCompute/domainNames',
|
||||
'Microsoft.CognitiveServices/accounts',
|
||||
'Microsoft.Compute/virtualMachines',
|
||||
'Microsoft.Compute/virtualMachineScaleSets',
|
||||
|
||||
+12
-2
@@ -3,7 +3,7 @@ import { render, screen, getAllByRole, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { LokiQueryBuilder } from './LokiQueryBuilder';
|
||||
import { LokiDatasource } from '../../datasource';
|
||||
import { LokiVisualQuery } from '../types';
|
||||
import { LokiOperationId, LokiVisualQuery } from '../types';
|
||||
import { PanelData } from '@grafana/data';
|
||||
|
||||
const defaultQuery: LokiVisualQuery = {
|
||||
@@ -17,10 +17,20 @@ describe('LokiQueryBuilder', () => {
|
||||
datasource.languageProvider.fetchSeriesLabels = jest.fn().mockReturnValue({ job: ['a'], instance: ['b'] });
|
||||
await userEvent.click(screen.getByLabelText('Add'));
|
||||
const labels = screen.getByText(/Labels/);
|
||||
const selects = getAllByRole(labels.parentElement!, 'combobox');
|
||||
const selects = getAllByRole(labels.parentElement!.parentElement!.parentElement!, 'combobox');
|
||||
await userEvent.click(selects[3]);
|
||||
await waitFor(() => expect(screen.getByText('job')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows error for query with operations and no stream selector', async () => {
|
||||
setup({ labels: [], operations: [{ id: LokiOperationId.Logfmt, params: [] }] });
|
||||
expect(screen.getByText('You need to specify at least 1 label filter (stream selector)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no error for query with empty __line_contains operation and no stream selector', async () => {
|
||||
setup({ labels: [], operations: [{ id: LokiOperationId.LineContains, params: [''] }] });
|
||||
expect(screen.queryByText('You need to specify at least 1 label filter (stream selector)')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
function setup(query: LokiVisualQuery = defaultQuery, data?: PanelData) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { LokiVisualQuery } from '../types';
|
||||
import React, { useMemo } from 'react';
|
||||
import { LokiOperationId, LokiVisualQuery } from '../types';
|
||||
import { LokiDatasource } from '../../datasource';
|
||||
import { LabelFilters } from 'app/plugins/datasource/prometheus/querybuilder/shared/LabelFilters';
|
||||
import { OperationList } from 'app/plugins/datasource/prometheus/querybuilder/shared/OperationList';
|
||||
@@ -57,6 +57,18 @@ export const LokiQueryBuilder = React.memo<Props>(({ datasource, query, nested,
|
||||
return result[forLabelInterpolated] ?? [];
|
||||
};
|
||||
|
||||
const labelFilterError: string | undefined = useMemo(() => {
|
||||
const { labels, operations: op } = query;
|
||||
if (!labels.length && op.length) {
|
||||
// We don't want to show error for initial state with empty line contains operation
|
||||
if (op.length === 1 && op[0].id === LokiOperationId.LineContains && op[0].params[0] === '') {
|
||||
return undefined;
|
||||
}
|
||||
return 'You need to specify at least 1 label filter (stream selector)';
|
||||
}
|
||||
return undefined;
|
||||
}, [query]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditorRow>
|
||||
@@ -69,6 +81,7 @@ export const LokiQueryBuilder = React.memo<Props>(({ datasource, query, nested,
|
||||
}
|
||||
labelsFilters={query.labels}
|
||||
onChange={onChangeLabels}
|
||||
error={labelFilterError}
|
||||
/>
|
||||
</EditorRow>
|
||||
<OperationsEditorRow>
|
||||
|
||||
+11
-7
@@ -26,11 +26,15 @@ export interface State {
|
||||
export function LokiQueryBuilderContainer(props: Props) {
|
||||
const { query, onChange, onRunQuery, datasource } = props;
|
||||
const [state, dispatch] = useReducer(stateSlice.reducer, {
|
||||
expr: '',
|
||||
visQuery: {
|
||||
labels: [],
|
||||
operations: [{ id: '__line_contains', params: [''] }],
|
||||
},
|
||||
expr: query.expr,
|
||||
// Use initial visual query only if query.expr is empty string
|
||||
visQuery:
|
||||
query.expr === ''
|
||||
? {
|
||||
labels: [],
|
||||
operations: [{ id: '__line_contains', params: [''] }],
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// Only rebuild visual query if expr changes from outside
|
||||
@@ -56,13 +60,13 @@ export function LokiQueryBuilderContainer(props: Props) {
|
||||
onChange={onVisQueryChange}
|
||||
onRunQuery={onRunQuery}
|
||||
/>
|
||||
<QueryPreview query={query.expr} />
|
||||
{query.rawQuery && <QueryPreview query={query.expr} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const stateSlice = createSlice({
|
||||
name: 'prom-builder-container',
|
||||
name: 'loki-builder-container',
|
||||
initialState: { expr: '' } as State,
|
||||
reducers: {
|
||||
visualQueryChange: (state, action: PayloadAction<{ visQuery: LokiVisualQuery; expr: string }>) => {
|
||||
|
||||
+20
-20
@@ -82,29 +82,29 @@ describe('LokiQueryEditorSelector', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// it('Can enable preview', async () => {
|
||||
// const { onChange } = renderWithMode(QueryEditorMode.Builder);
|
||||
// expect(screen.queryByLabelText('selector')).not.toBeInTheDocument();
|
||||
it('Can enable raw query', async () => {
|
||||
const { onChange } = renderWithMode(QueryEditorMode.Builder);
|
||||
expect(screen.queryByLabelText('selector')).not.toBeInTheDocument();
|
||||
|
||||
// screen.getByLabelText('Preview').click();
|
||||
screen.getByLabelText('Raw query').click();
|
||||
|
||||
// expect(onChange).toBeCalledWith({
|
||||
// refId: 'A',
|
||||
// expr: defaultQuery.expr,
|
||||
// range: true,
|
||||
// editorMode: QueryEditorMode.Builder,
|
||||
// editorPreview: true,
|
||||
// });
|
||||
// });
|
||||
expect(onChange).toBeCalledWith({
|
||||
refId: 'A',
|
||||
expr: defaultQuery.expr,
|
||||
queryType: 'range',
|
||||
editorMode: QueryEditorMode.Builder,
|
||||
rawQuery: true,
|
||||
});
|
||||
});
|
||||
|
||||
// it('Should show preview', async () => {
|
||||
// renderWithProps({
|
||||
// editorPreview: true,
|
||||
// editorMode: QueryEditorMode.Builder,
|
||||
// expr: 'my_metric',
|
||||
// });
|
||||
// expect(screen.getByLabelText('selector').textContent).toBe('my_metric');
|
||||
// });
|
||||
it('Should show raw query', async () => {
|
||||
renderWithProps({
|
||||
rawQuery: true,
|
||||
editorMode: QueryEditorMode.Builder,
|
||||
expr: '{job="grafana"}',
|
||||
});
|
||||
expect(screen.getByLabelText('selector').textContent).toBe('{job="grafana"}');
|
||||
});
|
||||
|
||||
it('changes to code mode', async () => {
|
||||
const { onChange } = renderWithMode(QueryEditorMode.Builder);
|
||||
|
||||
+34
-31
@@ -1,10 +1,9 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { GrafanaTheme2, LoadingState } from '@grafana/data';
|
||||
import { LoadingState } from '@grafana/data';
|
||||
import { EditorHeader, EditorRows, FlexItem, InlineSelect, Space } from '@grafana/experimental';
|
||||
import { Button, useStyles2, ConfirmModal } from '@grafana/ui';
|
||||
import { Button, ConfirmModal } from '@grafana/ui';
|
||||
import { QueryEditorModeToggle } from 'app/plugins/datasource/prometheus/querybuilder/shared/QueryEditorModeToggle';
|
||||
import { QueryEditorMode } from 'app/plugins/datasource/prometheus/querybuilder/shared/types';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import React, { SyntheticEvent, useCallback, useEffect, useState } from 'react';
|
||||
import { LokiQueryEditorProps } from '../../components/types';
|
||||
import { lokiQueryModeller } from '../LokiQueryModeller';
|
||||
import { getQueryWithDefaults } from '../state';
|
||||
@@ -13,12 +12,15 @@ import { LokiQueryBuilderExplained } from './LokiQueryBuilderExplained';
|
||||
import { LokiQueryBuilderOptions } from './LokiQueryBuilderOptions';
|
||||
import { LokiQueryCodeEditor } from './LokiQueryCodeEditor';
|
||||
import { buildVisualQueryFromString } from '../parsing';
|
||||
import { QueryHeaderSwitch } from 'app/plugins/datasource/prometheus/querybuilder/shared/QueryHeaderSwitch';
|
||||
import { LokiQuery } from '../../types';
|
||||
|
||||
export const LokiQueryEditorSelector = React.memo<LokiQueryEditorProps>((props) => {
|
||||
const { onChange, onRunQuery, data } = props;
|
||||
const styles = useStyles2(getStyles);
|
||||
const query = getQueryWithDefaults(props.query);
|
||||
const [parseModalOpen, setParseModalOpen] = useState(false);
|
||||
const [dataIsStale, setDataIsStale] = useState(false);
|
||||
|
||||
const query = getQueryWithDefaults(props.query);
|
||||
|
||||
const onEditorModeChange = useCallback(
|
||||
(newMetricEditorMode: QueryEditorMode) => {
|
||||
@@ -36,6 +38,20 @@ export const LokiQueryEditorSelector = React.memo<LokiQueryEditorProps>((props)
|
||||
[onChange, query]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setDataIsStale(false);
|
||||
}, [data]);
|
||||
|
||||
const onChangeInternal = (query: LokiQuery) => {
|
||||
setDataIsStale(true);
|
||||
onChange(query);
|
||||
};
|
||||
|
||||
const onQueryPreviewChange = (event: SyntheticEvent<HTMLInputElement>) => {
|
||||
const isEnabled = event.currentTarget.checked;
|
||||
onChange({ ...query, rawQuery: isEnabled });
|
||||
};
|
||||
|
||||
// If no expr (ie new query) then default to builder
|
||||
const editorMode = query.editorMode ?? (query.expr ? QueryEditorMode.Code : QueryEditorMode.Builder);
|
||||
return (
|
||||
@@ -52,18 +68,6 @@ export const LokiQueryEditorSelector = React.memo<LokiQueryEditorProps>((props)
|
||||
onDismiss={() => setParseModalOpen(false)}
|
||||
/>
|
||||
<EditorHeader>
|
||||
<FlexItem grow={1} />
|
||||
<Button
|
||||
className={styles.runQuery}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
fill="outline"
|
||||
onClick={onRunQuery}
|
||||
icon={data?.state === LoadingState.Loading ? 'fa fa-spinner' : undefined}
|
||||
disabled={data?.state === LoadingState.Loading}
|
||||
>
|
||||
Run query
|
||||
</Button>
|
||||
<InlineSelect
|
||||
value={null}
|
||||
placeholder="Query patterns"
|
||||
@@ -78,6 +82,17 @@ export const LokiQueryEditorSelector = React.memo<LokiQueryEditorProps>((props)
|
||||
}}
|
||||
options={lokiQueryModeller.getQueryPatterns().map((x) => ({ label: x.name, value: x }))}
|
||||
/>
|
||||
<QueryHeaderSwitch label="Raw query" value={query.rawQuery} onChange={onQueryPreviewChange} />
|
||||
<FlexItem grow={1} />
|
||||
<Button
|
||||
variant={dataIsStale ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={onRunQuery}
|
||||
icon={data?.state === LoadingState.Loading ? 'fa fa-spinner' : undefined}
|
||||
disabled={data?.state === LoadingState.Loading}
|
||||
>
|
||||
Run query
|
||||
</Button>
|
||||
<QueryEditorModeToggle mode={editorMode!} onChange={onEditorModeChange} />
|
||||
</EditorHeader>
|
||||
<Space v={0.5} />
|
||||
@@ -87,7 +102,7 @@ export const LokiQueryEditorSelector = React.memo<LokiQueryEditorProps>((props)
|
||||
<LokiQueryBuilderContainer
|
||||
datasource={props.datasource}
|
||||
query={query}
|
||||
onChange={onChange}
|
||||
onChange={onChangeInternal}
|
||||
onRunQuery={props.onRunQuery}
|
||||
/>
|
||||
)}
|
||||
@@ -101,15 +116,3 @@ export const LokiQueryEditorSelector = React.memo<LokiQueryEditorProps>((props)
|
||||
});
|
||||
|
||||
LokiQueryEditorSelector.displayName = 'LokiQueryEditorSelector';
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
runQuery: css({
|
||||
color: theme.colors.text.secondary,
|
||||
}),
|
||||
switchLabel: css({
|
||||
color: theme.colors.text.secondary,
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -42,6 +42,8 @@ export interface LokiQuery extends DataQuery {
|
||||
/* @deprecated now use queryType */
|
||||
instant?: boolean;
|
||||
editorMode?: QueryEditorMode;
|
||||
/** Controls if the raw query text is shown */
|
||||
rawQuery?: boolean;
|
||||
}
|
||||
|
||||
export interface LokiOptions extends DataSourceJsonData {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { EditorField, EditorFieldGroup, EditorList } from '@grafana/experimental';
|
||||
import { EditorFieldGroup, EditorList } from '@grafana/experimental';
|
||||
import { Field } from '@grafana/ui';
|
||||
import { isEqual } from 'lodash';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { QueryBuilderLabelFilter } from '../shared/types';
|
||||
@@ -10,9 +11,10 @@ export interface Props {
|
||||
onChange: (labelFilters: QueryBuilderLabelFilter[]) => void;
|
||||
onGetLabelNames: (forLabel: Partial<QueryBuilderLabelFilter>) => Promise<SelectableValue[]>;
|
||||
onGetLabelValues: (forLabel: Partial<QueryBuilderLabelFilter>) => Promise<SelectableValue[]>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function LabelFilters({ labelsFilters, onChange, onGetLabelNames, onGetLabelValues }: Props) {
|
||||
export function LabelFilters({ labelsFilters, onChange, onGetLabelNames, onGetLabelValues, error }: Props) {
|
||||
const defaultOp = '=';
|
||||
const [items, setItems] = useState<Array<Partial<QueryBuilderLabelFilter>>>([{ op: defaultOp }]);
|
||||
|
||||
@@ -36,7 +38,7 @@ export function LabelFilters({ labelsFilters, onChange, onGetLabelNames, onGetLa
|
||||
|
||||
return (
|
||||
<EditorFieldGroup>
|
||||
<EditorField label="Labels">
|
||||
<Field label="Labels" error={error} invalid={!!error}>
|
||||
<EditorList
|
||||
items={items}
|
||||
onChange={onLabelsChange}
|
||||
@@ -51,7 +53,7 @@ export function LabelFilters({ labelsFilters, onChange, onGetLabelNames, onGetLa
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</EditorField>
|
||||
</Field>
|
||||
</EditorFieldGroup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { screen, getAllByRole } from '@testing-library/react';
|
||||
|
||||
export function getLabelSelects(index = 0) {
|
||||
const labels = screen.getByText(/Labels/);
|
||||
const selects = getAllByRole(labels.parentElement!, 'combobox');
|
||||
const selects = getAllByRole(labels.parentElement!.parentElement!.parentElement!, 'combobox');
|
||||
return {
|
||||
name: selects[3 * index],
|
||||
value: selects[3 * index + 2],
|
||||
|
||||
@@ -4135,9 +4135,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@grafana/experimental@npm:0.0.2-canary.22":
|
||||
version: 0.0.2-canary.22
|
||||
resolution: "@grafana/experimental@npm:0.0.2-canary.22"
|
||||
"@grafana/experimental@npm:^0.0.2-canary.25":
|
||||
version: 0.0.2-canary.25
|
||||
resolution: "@grafana/experimental@npm:0.0.2-canary.25"
|
||||
dependencies:
|
||||
"@types/uuid": ^8.3.3
|
||||
uuid: ^8.3.2
|
||||
@@ -4145,7 +4145,7 @@ __metadata:
|
||||
"@emotion/css": 11.1.3
|
||||
react: 17.0.1
|
||||
react-select: 5.2.1
|
||||
checksum: b9a64c0abc33798967c94e82e329925f75661eb23b4bbaf4d34fc0c95db1a535b95a240deb6e95fe08f2a2207859e599daf51b560813cf0e5c85468fa6d7a5cc
|
||||
checksum: 20532d6a1ff1bb7a98db71728bc34474f87663431fef349a53f4673d12b6356336b9250bc85028693e7bbfeec8e468d9910837f4062cb49239d581dc974c55c9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -20406,7 +20406,7 @@ __metadata:
|
||||
"@grafana/e2e": "workspace:*"
|
||||
"@grafana/e2e-selectors": "workspace:*"
|
||||
"@grafana/eslint-config": 3.0.0
|
||||
"@grafana/experimental": 0.0.2-canary.22
|
||||
"@grafana/experimental": ^0.0.2-canary.25
|
||||
"@grafana/google-sdk": 0.0.3
|
||||
"@grafana/lezer-logql": ^0.0.11
|
||||
"@grafana/runtime": "workspace:*"
|
||||
|
||||
Reference in New Issue
Block a user