Merge remote-tracking branch 'origin/main' into grafana-git-ui-sync
This commit is contained in:
commit
9d250f8a85
98 files changed
+2920
-1128
No files matched your search
+2
-8
@@ -3147,8 +3147,7 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Do not use export all (\`export * from ...\`)", "0"]
|
||||
],
|
||||
"public/app/features/connections/tabs/ConnectData/ConnectData.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"public/app/features/connections/tabs/ConnectData/NoAccessModal/NoAccessModal.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
|
||||
@@ -5034,12 +5033,7 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "4"]
|
||||
],
|
||||
"public/app/features/expressions/components/Threshold.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "2"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "3"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "4"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "5"]
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"]
|
||||
],
|
||||
"public/app/features/expressions/guards.ts:5381": [
|
||||
[0, 0, 0, "\'@grafana/runtime/src/utils/DataSourceWithBackend\' import is restricted from being used by a pattern. Import from the public export instead.", "0"],
|
||||
|
||||
@@ -759,6 +759,7 @@ embed.go @grafana/grafana-as-code
|
||||
/.github/workflows/add-to-whats-new.yml @grafana/docs-tooling
|
||||
/.github/workflows/auto-triager/ @grafana/plugins-platform-frontend
|
||||
/.github/workflows/alerting-swagger-gen.yml @grafana/alerting-backend
|
||||
/.github/workflows/alerting-update-module.yml @grafana/alerting-backend
|
||||
/.github/workflows/auto-milestone.yml @grafana/grafana-developer-enablement-squad
|
||||
/.github/workflows/backport.yml @grafana/grafana-developer-enablement-squad
|
||||
/.github/workflows/bump-version.yml @grafana/grafana-developer-enablement-squad
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
name: Update Alerting Module
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
update-grafana:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 4.2.2
|
||||
|
||||
- name: Check if update branch exists
|
||||
run: |
|
||||
if git ls-remote --heads origin update-alerting-module | grep -q 'update-alerting-module'; then
|
||||
echo "Branch 'update-alerting-module' already exists. There might be an open PR with Grafana updates."
|
||||
echo "Please review and merge/close the existing PR before running this workflow again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # 5.3.0
|
||||
with:
|
||||
"go-version-file": "go.mod"
|
||||
|
||||
- name: Extract current commit hash of alerting module
|
||||
id: current-commit
|
||||
run: |
|
||||
FROM_COMMIT=$(go list -m -json github.com/grafana/alerting | jq -r '.Version' | grep -oP '(?<=-)[a-f0-9]+$')
|
||||
echo "from_commit=$FROM_COMMIT" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get latest commit
|
||||
id: latest-commit
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
TO_COMMIT=$(gh api repos/grafana/alerting/commits/main --jq '.sha')
|
||||
if [ -z "$TO_COMMIT" ]; then
|
||||
echo "Failed to fetch latest commit"
|
||||
exit 1
|
||||
fi
|
||||
echo "to_commit=$TO_COMMIT" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Compare commit hashes
|
||||
run: |
|
||||
FROM_COMMIT="${{ steps.current-commit.outputs.from_commit }}"
|
||||
TO_COMMIT="${{ steps.latest-commit.outputs.to_commit }}"
|
||||
|
||||
# Compare just the length of the shorter hash
|
||||
SHORT_TO_COMMIT="${TO_COMMIT:0:${#FROM_COMMIT}}"
|
||||
|
||||
if [ "$FROM_COMMIT" = "$SHORT_TO_COMMIT" ]; then
|
||||
echo "Current version ($FROM_COMMIT) is already at latest ($SHORT_TO_COMMIT). No update needed."
|
||||
exit 0
|
||||
fi
|
||||
echo "Updates available: $FROM_COMMIT -> $TO_COMMIT"
|
||||
|
||||
- name: Check for commit history
|
||||
id: check-commits
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
# get all commits that contains 'Alerting:' in the message
|
||||
ALERTING_COMMITS=$(gh api repos/grafana/alerting/compare/${{ steps.current-commit.outputs.from_commit }}...${{ steps.latest-commit.outputs.to_commit }} \
|
||||
--jq '.commits[].commit.message | split("\n")[0]') || true
|
||||
|
||||
# Use printf instead of echo -e for better multiline handling
|
||||
printf "%s\n" "$ALERTING_COMMITS"
|
||||
|
||||
# make the list for markdown and replace PR numbers with links
|
||||
ALERTING_COMMITS_FORMATTED=$(echo "$ALERTING_COMMITS" | while read -r line; do echo "- $line" | sed -E 's/\(#([0-9]+)\)/[#\1](https:\/\/github.com\/grafana\/grafana\/pull\/\1)/g'; done)
|
||||
|
||||
echo "alerting_commits<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$ALERTING_COMMITS_FORMATTED" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Update alerting module
|
||||
env:
|
||||
GOSUMDB: off
|
||||
run: |
|
||||
go get github.com/grafana/alerting@${{ steps.latest-commit.outputs.to_commit }}
|
||||
make update-workspace
|
||||
|
||||
- id: get-secrets
|
||||
uses: grafana/shared-workflows/actions/get-vault-secrets@28361cdb22223e5f1e34358c86c20908e7248760 # 1.1.0
|
||||
with:
|
||||
repo_secrets: |
|
||||
GITHUB_APP_ID=github-app:app-id
|
||||
GITHUB_APP_PRIVATE_KEY=github-app:private-key
|
||||
|
||||
- name: "Generate token"
|
||||
id: generate_token
|
||||
uses: actions/create-github-app-token@0d564482f06ca65fa9e77e2510873638c82206f2 # 1.11.5
|
||||
with:
|
||||
app-id: ${{ env.GITHUB_APP_ID }}
|
||||
private-key: ${{ env.GITHUB_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@67ccf781d68cd99b580ae25a5c18a1cc84ffff1f # 7.0.6
|
||||
id: create-pr
|
||||
with:
|
||||
token: '${{ steps.generate_token.outputs.token }}'
|
||||
title: 'Alerting: Update alerting module to ${{ steps.latest-commit.outputs.to_commit }}'
|
||||
branch: alerting/update-alerting-module
|
||||
delete-branch: true
|
||||
body: |
|
||||
Updates Grafana Alerting module to latest version.
|
||||
|
||||
Compare changes: https://github.com/grafana/alerting/compare/${{ steps.current-commit.outputs.from_commit }}...${{ steps.latest-commit.outputs.to_commit }}
|
||||
<details>
|
||||
<summary>Commits</summary>
|
||||
|
||||
${{ steps.check-commits.outputs.alerting_commits }}
|
||||
|
||||
</details>
|
||||
|
||||
Created by: [GitHub Action Job](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})
|
||||
- name: Add PR URL to Summary
|
||||
if: steps.create-pr.outputs.pull-request-url != ''
|
||||
run: |
|
||||
echo "## Pull Request Created" >> $GITHUB_STEP_SUMMARY
|
||||
echo "🔗 [View Pull Request](${{ steps.create-pr.outputs.pull-request-url }})" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -2,7 +2,7 @@
|
||||
aliases:
|
||||
- ../data-sources/postgres/
|
||||
- ../features/datasources/postgres/
|
||||
description: Guide for using PostgreSQL in Grafana
|
||||
description: Introduction to the PostgreSQL data source in Grafana.
|
||||
keywords:
|
||||
- grafana
|
||||
- postgresql
|
||||
@@ -16,506 +16,58 @@ menuTitle: PostgreSQL
|
||||
title: PostgreSQL data source
|
||||
weight: 1200
|
||||
refs:
|
||||
provisioning-data-sources:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/provisioning/#datasources
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/provisioning/#datasources
|
||||
variables:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/
|
||||
add-template-variables-interval-ms:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/add-template-variables/#__interval_ms
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/add-template-variables/#__interval_ms
|
||||
add-template-variables-interval:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/add-template-variables/#__interval
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/add-template-variables/#__interval
|
||||
annotate-visualizations:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/build-dashboards/annotate-visualizations/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/build-dashboards/annotate-visualizations/
|
||||
configure-standard-options-display-name:
|
||||
configure-postgres-data-source:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/configure-standard-options/#display-name
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/postgres/configure/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/configure-standard-options/#display-name
|
||||
data-source-management:
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/postgres/configure/
|
||||
postgres-query-editor:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/data-source-management/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/postgres/query-editor/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/data-source-management/
|
||||
variable-syntax-advanced-variable-format-options:
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/postgres/query-editor/
|
||||
alerting:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/variable-syntax/#advanced-variable-format-options
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/variable-syntax/#advanced-variable-format-options
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/alerting/
|
||||
transformations:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/query-transform-data/transform-data/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/visualizations/panels-visualizations/query-transform-data/transform-data/
|
||||
visualizations:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/visualizations/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/
|
||||
---
|
||||
|
||||
# PostgreSQL data source
|
||||
|
||||
Grafana ships with a built-in PostgreSQL data source plugin that allows you to query and visualize data from a PostgreSQL compatible database.
|
||||
Grafana includes a built-in PostgreSQL data source plugin, enabling you to query and visualize data from any PostgreSQL-compatible database. You don't need to install a plugin to add the PostgreSQL data source to your Grafana instance.
|
||||
|
||||
For instructions on how to add a data source to Grafana, refer to the [administration documentation](ref:data-source-management).
|
||||
Only users with the organization administrator role can add data sources.
|
||||
Administrators can also [configure the data source via YAML](#provision-the-data-source) with Grafana's provisioning system.
|
||||
Grafana offers several configuration options for this data source as well as a visual and code-based query editor.
|
||||
|
||||
## Get started with the PostgreSQL data source
|
||||
|
||||
The following documents will help you get started with the PostgreSQL data source in Grafana:
|
||||
|
||||
- [Configure the PostgreSQL data source](ref:configure-postgres-data-source)
|
||||
- [PostgreSQL query editor](ref:postgres-query-editor)
|
||||
|
||||
After you have configured the data source you can:
|
||||
|
||||
- Create a variety of [visualizations](ref:visualizations)
|
||||
- Add [annotations](ref:annotate-visualizations)
|
||||
- Set up [alerting](ref:alerting)
|
||||
- Add [transformations](ref:transformations)
|
||||
|
||||
View a PostgreSQL overview on Grafana Play:
|
||||
|
||||
{{< docs/play title="PostgreSQL Overview" url="https://play.grafana.org/d/ddvpgdhiwjvuod/postgresql-overview" >}}
|
||||
|
||||
## PostgreSQL settings
|
||||
|
||||
To configure basic settings for the data source, complete the following steps:
|
||||
|
||||
1. Click **Connections** in the left-side menu.
|
||||
1. Under Your connections, click **Data sources**.
|
||||
1. Enter `PostgreSQL` in the search bar.
|
||||
1. Select **PostgreSQL**.
|
||||
|
||||
The **Settings** tab of the data source is displayed.
|
||||
|
||||
1. Set the data source's basic configuration options:
|
||||
|
||||
| Name | Description |
|
||||
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Name** | The data source name. This is how you refer to the data source in panels and queries. |
|
||||
| **Default** | Default data source means that it will be pre-selected for new panels. |
|
||||
| **Host** | The IP address/hostname and optional port of your PostgreSQL instance. _Do not_ include the database name. The connection string for connecting to Postgres will not be correct and it may cause errors. |
|
||||
| **Database** | Name of your PostgreSQL database. |
|
||||
| **User** | Database user's login/username |
|
||||
| **Password** | Database user's password |
|
||||
| **SSL Mode** | Determines whether or with what priority a secure SSL TCP/IP connection will be negotiated with the server. When SSL Mode is disabled, SSL Method and Auth Details would not be visible. |
|
||||
| **SSL Auth Details Method** | Determines whether the SSL Auth details will be configured as a file path or file content. |
|
||||
| **SSL Auth Details Value** | File path or file content of SSL root certificate, client certificate and client key |
|
||||
| **Max open** | The maximum number of open connections to the database, default `100`. |
|
||||
| **Max idle** | The maximum number of connections in the idle connection pool, default `100`. |
|
||||
| **Auto (max idle)** | If set will set the maximum number of idle connections to the number of maximum open connections. Default is `true`. |
|
||||
| **Max lifetime** | The maximum amount of time in seconds a connection may be reused, default `14400`/4 hours. |
|
||||
| **Version** | Determines which functions are available in the query builder. |
|
||||
| **TimescaleDB** | A time-series database built as a PostgreSQL extension. When enabled, Grafana uses `time_bucket` in the `$__timeGroup` macro to display TimescaleDB specific aggregate functions in the query builder. For more information, see [TimescaleDB documentation](https://docs.timescale.com/timescaledb/latest/tutorials/grafana/grafana-timescalecloud/#connect-timescaledb-and-grafana). |
|
||||
|
||||
### Min time interval
|
||||
|
||||
A lower limit for the [`$__interval`](ref:add-template-variables-interval) and [`$__interval_ms`](ref:add-template-variables-interval-ms) variables.
|
||||
Recommended to be set to write frequency, for example `1m` if your data is written every minute.
|
||||
This option can also be overridden/configured in a dashboard panel under data source options. It's important to note that this value **needs** to be formatted as a
|
||||
number followed by a valid time identifier, e.g. `1m` (1 minute) or `30s` (30 seconds). The following time identifiers are supported:
|
||||
|
||||
| Identifier | Description |
|
||||
| ---------- | ----------- |
|
||||
| `y` | year |
|
||||
| `M` | month |
|
||||
| `w` | week |
|
||||
| `d` | day |
|
||||
| `h` | hour |
|
||||
| `m` | minute |
|
||||
| `s` | second |
|
||||
| `ms` | millisecond |
|
||||
|
||||
### Database user permissions (Important!)
|
||||
|
||||
The database user you specify when you add the data source should only be granted SELECT permissions on
|
||||
the specified database and tables you want to query. Grafana does not validate that the query is safe. The query
|
||||
could include any SQL statement. For example, statements like `DELETE FROM user;` and `DROP TABLE user;` would be
|
||||
executed. To protect against this we **highly** recommend you create a specific PostgreSQL user with restricted permissions.
|
||||
|
||||
Example:
|
||||
|
||||
```sql
|
||||
CREATE USER grafanareader WITH PASSWORD 'password';
|
||||
GRANT USAGE ON SCHEMA schema TO grafanareader;
|
||||
GRANT SELECT ON schema.table TO grafanareader;
|
||||
```
|
||||
|
||||
Make sure the user does not get any unwanted privileges from the public role.
|
||||
|
||||
## Query builder
|
||||
|
||||
{{< figure src="/static/img/docs/screenshot-postgres-query-editor.png" class="docs-image--no-shadow" caption="PostgreSQL query builder" >}}
|
||||
|
||||
The PostgreSQL query builder is available when editing a panel using a PostgreSQL data source. The built query can be run by pressing the `Run query` button in the top right corner of the editor.
|
||||
|
||||
### Format
|
||||
|
||||
The response from PostgreSQL can be formatted as either a table or as a time series. To use the time series format one of the columns must be named `time`.
|
||||
|
||||
### Dataset and table selection
|
||||
|
||||
The dataset dropdown will be populated with the configured database to which the user has access.
|
||||
The table dropdown is populated with the tables that are available within that database.
|
||||
|
||||
### Columns and Aggregation functions (SELECT)
|
||||
|
||||
Using the dropdown, select a column to include in the data. You can also specify an optional aggregation function.
|
||||
|
||||
Add further value columns by clicking the plus button and another column dropdown appears.
|
||||
|
||||
{{< docs/shared source="grafana" lookup="datasources/sql-query-builder-macros.md" version="<GRAFANA_VERSION>" >}}
|
||||
|
||||
### Filter data (WHERE)
|
||||
|
||||
To add a filter, toggle the **Filter** switch at the top of the editor.
|
||||
This reveals a **Filter by column value** section with two dropdown selectors.
|
||||
|
||||
Use the first dropdown to choose whether all of the filters need to match (`AND`), or if only one of the filters needs to match (`OR`).
|
||||
Use the second dropdown to choose a filter.
|
||||
|
||||
To filter on more columns, click the plus (`+`) button to the right of the condition dropdown.
|
||||
|
||||
To remove a filter, click the `x` button next to that filter's dropdown.
|
||||
|
||||
After selecting a date type column, you can choose Macros from the operators list and select timeFilter which will add the $\_\_timeFilter macro to the query with the selected date column.
|
||||
|
||||
### Group By
|
||||
|
||||
To group the results by column, flip the group switch at the top of the editor. You can then choose which column to group the results by. The group by clause can be removed by pressing the X button.
|
||||
|
||||
### Preview
|
||||
|
||||
By flipping the preview switch at the top of the editor, you can get a preview of the SQL query generated by the query builder.
|
||||
|
||||
### Provision the data source
|
||||
|
||||
It's now possible to configure data sources using config files with Grafana's provisioning system. You can read more about how it works and all the settings you can set for data sources on the [provisioning docs page](ref:provisioning-data-sources).
|
||||
|
||||
#### Provisioning example
|
||||
|
||||
```yaml
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: Postgres
|
||||
type: postgres
|
||||
url: localhost:5432
|
||||
user: grafana
|
||||
secureJsonData:
|
||||
password: 'Password!'
|
||||
jsonData:
|
||||
database: grafana
|
||||
sslmode: 'disable' # disable/require/verify-ca/verify-full
|
||||
maxOpenConns: 100
|
||||
maxIdleConns: 100
|
||||
maxIdleConnsAuto: true
|
||||
connMaxLifetime: 14400
|
||||
postgresVersion: 903 # 903=9.3, 904=9.4, 905=9.5, 906=9.6, 1000=10
|
||||
timescaledb: false
|
||||
```
|
||||
|
||||
{{% admonition type="note" %}}
|
||||
In the above code, the `postgresVersion` value of `10` refers to version PostgreSQL 10 and above.
|
||||
{{% /admonition %}}
|
||||
|
||||
#### Troubleshoot provisioning
|
||||
|
||||
If you encounter metric request errors or other issues:
|
||||
|
||||
- Make sure your data source YAML file parameters exactly match the example. This includes parameter names and use of quotation marks.
|
||||
- Make sure the `database` name is not included in the `url`.
|
||||
|
||||
## Code editor
|
||||
|
||||
{{< figure src="/static/img/docs/v92/sql_code_editor.png" class="docs-image--no-shadow" >}}
|
||||
|
||||
To make advanced queries, switch to the code editor by clicking `code` in the top right corner of the editor. The code editor support autocompletion of tables, columns, SQL keywords, standard sql functions, Grafana template variables and Grafana macros. Columns cannot be completed before a table has been specified.
|
||||
|
||||
You can expand the code editor by pressing the `chevron` pointing downwards in the lower right corner of the code editor.
|
||||
|
||||
`CTRL/CMD + Return` works as a keyboard shortcut to run the query.
|
||||
|
||||
## Macros
|
||||
|
||||
Macros can be used within a query to simplify syntax and allow for dynamic parts.
|
||||
|
||||
| Macro example | Description |
|
||||
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `$__time(dateColumn)` | Will be replaced by an expression to convert to a UNIX timestamp and rename the column to `time_sec`. For example, _UNIX_TIMESTAMP(dateColumn) as time_sec_ |
|
||||
| `$__timeEpoch(dateColumn)` | Will be replaced by an expression to convert to a UNIX timestamp and rename the column to `time_sec`. For example, _UNIX_TIMESTAMP(dateColumn) as time_sec_ |
|
||||
| `$__timeFilter(dateColumn)` | Will be replaced by a time range filter using the specified column name. For example, _dateColumn BETWEEN FROM_UNIXTIME(1494410783) AND FROM_UNIXTIME(1494410983)_ |
|
||||
| `$__timeFrom()` | Will be replaced by the start of the currently active time selection. For example, _FROM_UNIXTIME(1494410783)_ |
|
||||
| `$__timeTo()` | Will be replaced by the end of the currently active time selection. For example, _FROM_UNIXTIME(1494410983)_ |
|
||||
| `$__timeGroup(dateColumn,'5m')` | Will be replaced by an expression usable in GROUP BY clause. For example, *cast(cast(UNIX_TIMESTAMP(dateColumn)/(300) as signed)*300 as signed),\* |
|
||||
| `$__timeGroup(dateColumn,'5m', 0)` | Same as above but with a fill parameter so missing points in that series will be added by grafana and 0 will be used as value (only works with time series queries). |
|
||||
| `$__timeGroup(dateColumn,'5m', NULL)` | Same as above but NULL will be used as value for missing points (only works with time series queries). |
|
||||
| `$__timeGroup(dateColumn,'5m', previous)` | Same as above but the previous value in that series will be used as fill value if no value has been seen yet NULL will be used (only works with time series queries). |
|
||||
| `$__timeGroupAlias(dateColumn,'5m')` | Will be replaced identical to $\_\_timeGroup but with an added column alias. |
|
||||
| `$__unixEpochFilter(dateColumn)` | Will be replaced by a time range filter using the specified column name with times represented as Unix timestamp. For example, _dateColumn > 1494410783 AND dateColumn < 1494497183_ |
|
||||
| `$__unixEpochFrom()` | Will be replaced by the start of the currently active time selection as Unix timestamp. For example, _1494410783_ |
|
||||
| `$__unixEpochTo()` | Will be replaced by the end of the currently active time selection as Unix timestamp. For example, _1494497183_ |
|
||||
| `$__unixEpochNanoFilter(dateColumn)` | Will be replaced by a time range filter using the specified column name with times represented as nanosecond timestamp. For example, _dateColumn > 1494410783152415214 AND dateColumn < 1494497183142514872_ |
|
||||
| `$__unixEpochNanoFrom()` | Will be replaced by the start of the currently active time selection as nanosecond timestamp. For example, _1494410783152415214_ |
|
||||
| `$__unixEpochNanoTo()` | Will be replaced by the end of the currently active time selection as nanosecond timestamp. For example, _1494497183142514872_ |
|
||||
| `$__unixEpochGroup(dateColumn,'5m', [fillmode])` | Same as $\_\_timeGroup but for times stored as Unix timestamp (`fillMode` only works with time series queries). |
|
||||
| `$__unixEpochGroupAlias(dateColumn,'5m', [fillmode])` | Same as above but also adds a column alias (`fillMode` only works with time series queries). |
|
||||
|
||||
## Table queries
|
||||
|
||||
If the `Format as` query option is set to `Table` then you can basically do any type of SQL query. The table panel will automatically show the results of whatever columns and rows your query returns.
|
||||
|
||||
Query editor with example query:
|
||||
|
||||

|
||||
|
||||
The query:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
title as "Title",
|
||||
"user".login as "Created By",
|
||||
dashboard.created as "Created On"
|
||||
FROM dashboard
|
||||
INNER JOIN "user" on "user".id = dashboard.created_by
|
||||
WHERE $__timeFilter(dashboard.created)
|
||||
```
|
||||
|
||||
You can control the name of the Table panel columns by using regular `as ` SQL column selection syntax.
|
||||
|
||||
The resulting table panel:
|
||||
|
||||

|
||||
|
||||
## Time series queries
|
||||
|
||||
If you set Format as to _Time series_, then the query must have a column named time that returns either a SQL datetime or any numeric datatype representing Unix epoch in seconds. In addition, result sets of time series queries must be sorted by time for panels to properly visualize the result.
|
||||
|
||||
A time series query result is returned in a [wide data frame format](https://grafana.com/developers/plugin-tools/key-concepts/data-frames#wide-format). Any column except time or of type string transforms into value fields in the data frame query result. Any string column transforms into field labels in the data frame query result.
|
||||
|
||||
> For backward compatibility, there's an exception to the above rule for queries that return three columns including a string column named metric. Instead of transforming the metric column into field labels, it becomes the field name, and then the series name is formatted as the value of the metric column. See the example with the metric column below.
|
||||
|
||||
To optionally customize the default series name formatting, refer to [Standard options definitions](ref:configure-standard-options-display-name).
|
||||
|
||||
**Example with `metric` column:**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
$__timeGroupAlias("time_date_time",'5m'),
|
||||
min("value_double"),
|
||||
'min' as metric
|
||||
FROM test_data
|
||||
WHERE $__timeFilter("time_date_time")
|
||||
GROUP BY time
|
||||
ORDER BY time
|
||||
```
|
||||
|
||||
Data frame result:
|
||||
|
||||
```text
|
||||
+---------------------+-----------------+
|
||||
| Name: time | Name: min |
|
||||
| Labels: | Labels: |
|
||||
| Type: []time.Time | Type: []float64 |
|
||||
+---------------------+-----------------+
|
||||
| 2020-01-02 03:05:00 | 3 |
|
||||
| 2020-01-02 03:10:00 | 6 |
|
||||
+---------------------+-----------------+
|
||||
```
|
||||
|
||||
**Example using the fill parameter in the $\_\_timeGroupAlias macro to convert null values to be zero instead:**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
$__timeGroupAlias("createdAt",'5m',0),
|
||||
sum(value) as value,
|
||||
hostname
|
||||
FROM test_data
|
||||
WHERE
|
||||
$__timeFilter("createdAt")
|
||||
GROUP BY time, hostname
|
||||
ORDER BY time
|
||||
```
|
||||
|
||||
Given the data frame result in the following example and using the graph panel, you will get two series named _value 10.0.1.1_ and _value 10.0.1.2_. To render the series with a name of _10.0.1.1_ and _10.0.1.2_ , use a [Standard options definitions](ref:configure-standard-options-display-name) display value of `${__field.labels.hostname}`.
|
||||
|
||||
Data frame result:
|
||||
|
||||
```text
|
||||
+---------------------+---------------------------+---------------------------+
|
||||
| Name: time | Name: value | Name: value |
|
||||
| Labels: | Labels: hostname=10.0.1.1 | Labels: hostname=10.0.1.2 |
|
||||
| Type: []time.Time | Type: []float64 | Type: []float64 |
|
||||
+---------------------+---------------------------+---------------------------+
|
||||
| 2020-01-02 03:05:00 | 3 | 4 |
|
||||
| 2020-01-02 03:10:00 | 6 | 7 |
|
||||
+---------------------+---------------------------+---------------------------+
|
||||
```
|
||||
|
||||
**Example with multiple columns:**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
$__timeGroupAlias("time_date_time",'5m'),
|
||||
min("value_double") as "min_value",
|
||||
max("value_double") as "max_value"
|
||||
FROM test_data
|
||||
WHERE $__timeFilter("time_date_time")
|
||||
GROUP BY time
|
||||
ORDER BY time
|
||||
```
|
||||
|
||||
Data frame result:
|
||||
|
||||
```text
|
||||
+---------------------+-----------------+-----------------+
|
||||
| Name: time | Name: min_value | Name: max_value |
|
||||
| Labels: | Labels: | Labels: |
|
||||
| Type: []time.Time | Type: []float64 | Type: []float64 |
|
||||
+---------------------+-----------------+-----------------+
|
||||
| 2020-01-02 03:04:00 | 3 | 4 |
|
||||
| 2020-01-02 03:05:00 | 6 | 7 |
|
||||
+---------------------+-----------------+-----------------+
|
||||
```
|
||||
|
||||
## Templating
|
||||
|
||||
Instead of hard-coding things like server, application and sensor name in your metric queries you can use variables in their place. Variables are shown as dropdown select boxes at the top of the dashboard. These dropdowns make it easy to change the data being displayed in your dashboard.
|
||||
|
||||
Refer to [Templates and variables](ref:variables) for an introduction to the templating feature and the different types of template variables.
|
||||
|
||||
### Query variable
|
||||
|
||||
If you add a template variable of the type `Query`, you can write a PostgreSQL query that can
|
||||
return things like measurement names, key names or key values that are shown as a dropdown select box.
|
||||
|
||||
For example, you can have a variable that contains all values for the `hostname` column in a table if you specify a query like this in the templating variable _Query_ setting.
|
||||
|
||||
```sql
|
||||
SELECT hostname FROM host
|
||||
```
|
||||
|
||||
A query can return multiple columns and Grafana will automatically create a list from them. For example, the query below will return a list with values from `hostname` and `hostname2`.
|
||||
|
||||
```sql
|
||||
SELECT host.hostname, other_host.hostname2 FROM host JOIN other_host ON host.city = other_host.city
|
||||
```
|
||||
|
||||
To use time range dependent macros like `$__timeFilter(column)` in your query the refresh mode of the template variable needs to be set to _On Time Range Change_.
|
||||
|
||||
```sql
|
||||
SELECT event_name FROM event_log WHERE $__timeFilter(time_column)
|
||||
```
|
||||
|
||||
Another option is a query that can create a key/value variable. The query should return two columns that are named `__text` and `__value`. The `__text` column value should be unique (if it is not unique then the first value is used). The options in the dropdown will have a text and value that allows you to have a friendly name as text and an id as the value. An example query with `hostname` as the text and `id` as the value:
|
||||
|
||||
```sql
|
||||
SELECT hostname AS __text, id AS __value FROM host
|
||||
```
|
||||
|
||||
You can also create nested variables. Using a variable named `region`, you could have
|
||||
the hosts variable only show hosts from the current selected region with a query like this (if `region` is a multi-value variable then use the `IN` comparison operator rather than `=` to match against multiple values):
|
||||
|
||||
```sql
|
||||
SELECT hostname FROM host WHERE region IN($region)
|
||||
```
|
||||
|
||||
#### Using `__searchFilter` to filter results in Query Variable
|
||||
|
||||
Using `__searchFilter` in the query field will filter the query result based on what the user types in the dropdown select box.
|
||||
When nothing has been entered by the user the default value for `__searchFilter` is `%`.
|
||||
|
||||
> Important that you surround the `__searchFilter` expression with quotes as Grafana does not do this for you.
|
||||
|
||||
The example below shows how to use `__searchFilter` as part of the query field to enable searching for `hostname` while the user types in the dropdown select box.
|
||||
|
||||
Query
|
||||
|
||||
```sql
|
||||
SELECT hostname FROM my_host WHERE hostname LIKE '$__searchFilter'
|
||||
```
|
||||
|
||||
### Using Variables in Queries
|
||||
|
||||
Template variable values are only quoted when the template variable is a `multi-value`.
|
||||
|
||||
If the variable is a multi-value variable then use the `IN` comparison operator rather than `=` to match against multiple values.
|
||||
|
||||
There are two syntaxes:
|
||||
|
||||
`$<varname>` Example with a template variable named `hostname`:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
atimestamp as time,
|
||||
aint as value
|
||||
FROM table
|
||||
WHERE $__timeFilter(atimestamp) and hostname in($hostname)
|
||||
ORDER BY atimestamp ASC
|
||||
```
|
||||
|
||||
`[[varname]]` Example with a template variable named `hostname`:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
atimestamp as time,
|
||||
aint as value
|
||||
FROM table
|
||||
WHERE $__timeFilter(atimestamp) and hostname in([[hostname]])
|
||||
ORDER BY atimestamp ASC
|
||||
```
|
||||
|
||||
#### Disabling quoting for multi-value variables
|
||||
|
||||
Grafana automatically creates a quoted, comma-separated string for multi-value variables. For example: if `server01` and `server02` are selected then it will be formatted as: `'server01', 'server02'`. To disable quoting, use the csv formatting option for variables:
|
||||
|
||||
`${servers:csv}`
|
||||
|
||||
Read more about variable formatting options in the [Variables](ref:variable-syntax-advanced-variable-format-options) documentation.
|
||||
|
||||
## Annotations
|
||||
|
||||
[Annotations](ref:annotate-visualizations) allow you to overlay rich event information on top of graphs. You add annotation queries via the Dashboard menu / Annotations view.
|
||||
|
||||
**Example query using time column with epoch values:**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
epoch_time as time,
|
||||
metric1 as text,
|
||||
concat_ws(', ', metric1::text, metric2::text) as tags
|
||||
FROM
|
||||
public.test_data
|
||||
WHERE
|
||||
$__unixEpochFilter(epoch_time)
|
||||
```
|
||||
|
||||
**Example region query using time and timeend columns with epoch values:**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
epoch_time as time,
|
||||
epoch_time_end as timeend,
|
||||
metric1 as text,
|
||||
concat_ws(', ', metric1::text, metric2::text) as tags
|
||||
FROM
|
||||
public.test_data
|
||||
WHERE
|
||||
$__unixEpochFilter(epoch_time)
|
||||
```
|
||||
|
||||
**Example query using time column of native SQL date/time data type:**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
native_date_time as time,
|
||||
metric1 as text,
|
||||
concat_ws(', ', metric1::text, metric2::text) as tags
|
||||
FROM
|
||||
public.test_data
|
||||
WHERE
|
||||
$__timeFilter(native_date_time)
|
||||
```
|
||||
|
||||
| Name | Description |
|
||||
| --------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| `time` | The name of the date/time field. Could be a column with a native SQL date/time data type or epoch value. |
|
||||
| `timeend` | Optional name of the end date/time field. Could be a column with a native SQL date/time data type or epoch value. |
|
||||
| `text` | Event description field. |
|
||||
| `tags` | Optional field name to use for event tags as a comma separated string. |
|
||||
|
||||
## Alerting
|
||||
|
||||
Time series queries should work in alerting conditions. Table formatted queries are not yet supported in alert rule
|
||||
conditions.
|
||||
@@ -0,0 +1,194 @@
|
||||
---
|
||||
description: This document provides instructions for configuring the PostgreSQL data source.
|
||||
keywords:
|
||||
- grafana
|
||||
- postgresql
|
||||
- guide
|
||||
labels:
|
||||
products:
|
||||
- cloud
|
||||
- enterprise
|
||||
- oss
|
||||
menuTitle: Configure the PostgreSQL data source
|
||||
title: Configure the PostgreSQL data source
|
||||
weight: 10
|
||||
refs:
|
||||
provisioning-data-sources:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/provisioning/#datasources
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/provisioning/#datasources
|
||||
variables:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/
|
||||
add-template-variables-interval-ms:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/add-template-variables/#__interval_ms
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/add-template-variables/#__interval_ms
|
||||
add-template-variables-interval:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/add-template-variables/#__interval
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/add-template-variables/#__interval
|
||||
data-source-management:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/data-source-management/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/data-source-management/
|
||||
variable-syntax-advanced-variable-format-options:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/variable-syntax/#advanced-variable-format-options
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/variable-syntax/#advanced-variable-format-options
|
||||
---
|
||||
|
||||
# Configure the PostgreSQL data source
|
||||
|
||||
This document provides instructions for configuring the PostgreSQL data source and explains available configuration options. For general information on managing data sources refer to [Data source management](ref:data-source-management).
|
||||
|
||||
## Before you begin
|
||||
|
||||
You must have the `Organization administrator` role to configure the Postgres data source.
|
||||
Organization administrators can also [configure the data source via YAML](#provision-the-data-source) with the Grafana provisioning system.
|
||||
|
||||
Grafana comes with a built-in PostgreSQL data source plugin, eliminating the need to install a plugin.
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
When adding a data source, the database user you specify should have only `SELECT` permissions on the relevant database and tables. Grafana does not validate the safety of queries, which means they can include potentially harmful SQL statements, such as `USE otherdb;` or `DROP TABLE user;`, that could be executed. To mitigate this risk, Grafana strongly recommends creating a dedicated PostgreSQL user with restricted permissions.
|
||||
{{< /admonition >}}
|
||||
|
||||
Example:
|
||||
|
||||
```sql
|
||||
CREATE USER grafanareader WITH PASSWORD 'password';
|
||||
GRANT USAGE ON SCHEMA schema TO grafanareader;
|
||||
GRANT SELECT ON schema.table TO grafanareader;
|
||||
```
|
||||
|
||||
## Add the PostgreSQL data source
|
||||
|
||||
Complete the following steps to set up a new PostgreSQL data source:
|
||||
|
||||
1. Click **Connections** in the left-side menu.
|
||||
1. Click **Add new connection**
|
||||
1. Type `PostgreSQL` in the search bar.
|
||||
1. Select the **PostgreSQL data source**.
|
||||
1. Click **Add new data source** in the upper right.
|
||||
|
||||
You are taken to the **Settings** tab where you will configure the data source.
|
||||
|
||||
## PostgreSQL configuration options
|
||||
|
||||
Following is a list of PostgreSQL configuration options:
|
||||
|
||||
- **Name** - Sets the name you use to refer to the data source in panels and queries. Examples: `PostgreSQL-DB-1`.
|
||||
- **Default** - Toggle to set this specific PostgreSQL data source as the default pre-selected data source in panels and visualizations.
|
||||
|
||||
**Connection section:**
|
||||
|
||||
- **Host URL** - The IP address/hostname and optional port of your PostgreSQL instance.
|
||||
- **Database name** - The name of your PostgreSQL database.
|
||||
|
||||
**Authentication section:**
|
||||
|
||||
- **Username** - Enter the username used to connect to your PostgreSQL database.
|
||||
- **Password** - Enter the password used to connect to the PostgreSQL database.
|
||||
- **TLS/SSL Mode** - Determines whether or with what priority a secure SSL TCP/IP connection will be negotiated with the server. When **TLS/SSL Mode** is disabled, **TLS/SSL Method** and **TLS/SSL Auth Details** aren't visible options.
|
||||
- **TLS/SSL Method** - Determines how TLS/SSL certificates are configured.
|
||||
- **File system path** - This option allows you to configure certificates by specifying paths to existing certificates on the local file system where Grafana is running. Ensure this file is readable by the user executing the Grafana process.
|
||||
- **Certificate content** - This option allows you to configure certificate by specifying their content. The content is stored and encrypted in the Grafana database. When connecting to the database, the certificates are saved as files, on the local filesystem, in the Grafana data path.
|
||||
|
||||
**TLS/SSL Auth Details**
|
||||
|
||||
If you select the TLS/SSL Mode options **require**, **verify-ca** or **verify-full** and **file system path** the following are required:
|
||||
|
||||
- **TLS/SSL Root Certificate** - Specify the path to the root certificate file.
|
||||
- **TLS/SSL Client Certificate** - Specify the path to the client certificate and ensure the file is accessible to the user running the Grafana process.
|
||||
- **TLS/SSL Client Key** - Specify the path to the client key file and ensure the file is accessible to the user running the Grafana process.
|
||||
|
||||
If you select the TLS/SSL Mode option **require** and TLS/SSL Method certificate content the following are required:
|
||||
|
||||
- **TLS/SSL Client Certificate** - Provide the client certificate.
|
||||
- **TLS/SSL Client Key** - Provide the client key.
|
||||
|
||||
If you select the TLS/SSL Mode options **verify-ca** or **verify-full** with the TLS/SSL Method certificate content the following are required:
|
||||
|
||||
- **TLS/SSL Client Certificate** - Provide the client certificate.
|
||||
- **TLS/SSL Root Certificate** - Provide the root certificate.
|
||||
- **TLS/SSL Client Key** - Provide the client key.
|
||||
|
||||
**PostgreSQL Options:**
|
||||
|
||||
- **Version** - Determines which functions are available in the query builder. The default is the current version.
|
||||
- **Min time interval** - Defines a lower limit for the auto group by by time interval. Grafana recommends aligning this setting with the data write frequency. For example, set it to `1m` if your data is written every minute. Refer to [Min time interval](#min-time-interval) for format examples.
|
||||
- **TimescaleDB** - A time-series database built as a PostgreSQL extension. When enabled, Grafana uses `time_bucket` in the `$__timeGroup` macro to display TimescaleDB specific aggregate functions in the query builder. For more information, refer to [TimescaleDB documentation](https://docs.timescale.com/timescaledb/latest/tutorials/grafana/grafana-timescalecloud/#connect-timescaledb-and-grafana).
|
||||
|
||||
**Connection limits:**
|
||||
|
||||
- **Max open** - The maximum number of open connections to the database. The default `100`.
|
||||
- **Auto max idle** - Toggle to set the maximum number of idle connections to the number of maximum open connections. This setting is toggled on by default.
|
||||
- **Max idle** - The maximum number of connections in the idle connection pool. The default `100`.
|
||||
- **Max lifetime** - The maximum amount of time in seconds a connection may be reused. The default is `14400`, or 4 hours.
|
||||
|
||||
**Private data source connect** - _Only for Grafana Cloud users._ Private data source connect, or PDC, allows you to establish a private, secured connection between a Grafana Cloud instance, or stack, and data sources secured within a private network. Click the drop-down to locate the URL for PDC. For more information regarding Grafana PDC refer to [Private data source connect (PDC)](https://grafana.com/docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/).
|
||||
|
||||
Click **Manage private data source connect** to be taken to your PDC connection page, where you’ll find your PDC configuration details.
|
||||
|
||||
After you have added your PostgreSQL connection settings, click **Save & test** to test and save the data source connection.
|
||||
|
||||
### Min time interval
|
||||
|
||||
The **Min time interval** setting defines a lower limit for the [`$__interval`](ref:add-template-variables-interval) and [`$__interval_ms`](ref:add-template-variables-interval-ms) variables.
|
||||
|
||||
This option can also be configured or overridden in the dashboard panel under the data source settings.
|
||||
|
||||
This value must be formatted as a number followed by a valid time identifier:
|
||||
|
||||
| Identifier | Description |
|
||||
| ---------- | ----------- |
|
||||
| `y` | year |
|
||||
| `M` | month |
|
||||
| `w` | week |
|
||||
| `d` | day |
|
||||
| `h` | hour |
|
||||
| `m` | minute |
|
||||
| `s` | second |
|
||||
| `ms` | millisecond |
|
||||
|
||||
## Provision the data source
|
||||
|
||||
You can define and configure the data source in YAML files with [provisioning](/docs/grafana/<GRAFANA_VERSION>/administration/provisioning/#data-sources).
|
||||
For more information about provisioning, and available configuration options, refer to [Provision Grafana](ref:provisioning-data-sources).
|
||||
|
||||
### PostgreSQL provisioning example
|
||||
|
||||
```yaml
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: Postgres
|
||||
type: postgres
|
||||
url: localhost:5432
|
||||
user: grafana
|
||||
secureJsonData:
|
||||
password: 'Password!'
|
||||
jsonData:
|
||||
database: grafana
|
||||
sslmode: 'disable' # disable/require/verify-ca/verify-full
|
||||
maxOpenConns: 100
|
||||
maxIdleConns: 100
|
||||
maxIdleConnsAuto: true
|
||||
connMaxLifetime: 14400
|
||||
postgresVersion: 903 # 903=9.3, 904=9.4, 905=9.5, 906=9.6, 1000=10
|
||||
timescaledb: false
|
||||
```
|
||||
|
||||
#### Troubleshoot provisioning issues
|
||||
|
||||
If you encounter metric request errors or other issues:
|
||||
|
||||
- Ensure that the parameters in your data source YAML file precisely match the example provided, including parameter names and the correct use of quotation marks.
|
||||
- Verify that the database name _isn't_ included in the URL.
|
||||
@@ -0,0 +1,410 @@
|
||||
---
|
||||
description: This document describes the PostgreSQL query editor in Grafana.
|
||||
keywords:
|
||||
- grafana
|
||||
- postgresql
|
||||
- guide
|
||||
labels:
|
||||
products:
|
||||
- cloud
|
||||
- enterprise
|
||||
- oss
|
||||
menuTitle: PostgreSQL query editor
|
||||
title: PostgreSQL query editor
|
||||
weight: 20
|
||||
refs:
|
||||
variables:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/
|
||||
add-template-variables-interval:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/add-template-variables/#__interval
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/add-template-variables/#__interval
|
||||
explore:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/explore/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/explore/
|
||||
query-transform-data:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/query-transform-data/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/visualizations/panels-visualizations/query-transform-data/
|
||||
query-editor:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/query-transform-data/#query-editors
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/visualizations/panels-visualizations/query-transform-data/#query-editors
|
||||
alert-rules:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/alert-rules/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/alerting/alerting-rules/
|
||||
template-annotations-and-labels:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/alerting-rules/templates/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/alerting/alerting-rules/templates/
|
||||
templates:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/#templates
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/visualizations/dashboards/variables/#templates
|
||||
---
|
||||
|
||||
# PostgreSQL query editor
|
||||
|
||||
Grafana query editors are unique for each data source.
|
||||
|
||||
For general information on Grafana query editors, refer to [Query editors](ref:query-editor).
|
||||
|
||||
For general information on querying data sources in Grafana, refer to [Query and transform data](ref:query-transform-data).
|
||||
|
||||
The PostgreSQL query editor is located on the [Explore page](ref:explore). You can also access the PostgreSQL query editor from a dashboard panel. Click the ellipsis in the upper right of the panel and select **Edit**.
|
||||
|
||||
{{< figure src="/static/img/docs/screenshot-postgres-query-editor.png" class="docs-image--no-shadow" caption="PostgreSQL query builder" >}}
|
||||
|
||||
## PostgreSQL query editor components
|
||||
|
||||
The PostgreSQL query editor has two modes: **Builder** and **Code**.
|
||||
|
||||
Builder mode helps you build a query using a visual interface. Code mode allows for advanced querying and offers support for complex SQL query writing.
|
||||
|
||||
### PostgreSQL Builder mode
|
||||
|
||||
The following components will help you build a PostgreSQL query:
|
||||
|
||||
- **Format** - Select a format response from the drop-down for the PostgreSQL query. The default is **Table**. If you use the **Time series** format option, one of the columns must be `time`. Refer to [Time series queries](#time-series-queries) for more information.
|
||||
- **Table** - Select a table from the drop-down. Tables correspond to the chosen database.
|
||||
- **Data operations** - _Optional_ Select an aggregation from the drop-down. You can add multiple data operations by clicking the **+ sign**. Click the **garbage can icon** to remove data operations.
|
||||
- **Column** - Select a column on which to run the aggregation.
|
||||
- **Alias** - _Optional_ Add an alias from the drop-down. You can also add your own alias by typing it in the box and clicking **Enter**. Remove an alias by clicking the **X**.
|
||||
- **Filter** - Toggle to add filters.
|
||||
- **Filter by column value** - _Optional_ If you toggle **Filter** you can add a column to filter by from the drop-down. To filter on more columns, click the **+ sign** to the right of the condition drop-down. You can choose a variety of operators from the drop-down next to the condition. When multiple filters are added you can add an `AND` operator to display all true conditions or an `OR` operator to display any true conditions. Use the second drop-down to choose a filter. To remove a filter, click the `X` button next to that filter's drop-down. After selecting a date type column, you can choose **Macros** from the operators list and select `timeFilter` which will add the `$\_\_timeFilter` macro to the query with the selected date column.
|
||||
- **Group** - Toggle to add **Group by column**.
|
||||
- **Group by column** - Select a column to filter by from the drop-down. Click the **+sign** to filter by multiple columns. Click the **X** to remove a filter.
|
||||
- **Order** - Toggle to add an `ORDER BY` statement.
|
||||
- **Order by** - Select a column to order by from the drop-down. Select ascending (`ASC`) or descending (`DESC`) order.
|
||||
- **Limit** - You can add an optional limit on the number of retrieved results. Default is 50.
|
||||
- **Preview** - Toggle for a preview of the SQL query generated by the query builder. Preview is toggled on by default.
|
||||
|
||||
## PostgreSQL Code mode
|
||||
|
||||
To create advanced queries, switch to **Code mode** by clicking **Code** in the upper right of the editor window. Code mode supports the auto-completion of tables, columns, SQL keywords, standard SQL functions, Grafana template variables, and Grafana macros. Columns cannot be completed before a table has been specified.
|
||||
|
||||
{{< figure src="/static/img/docs/v92/sql_code_editor.png" class="docs-image--no-shadow" >}}
|
||||
|
||||
Select **Table** or **Time Series** as the format. Click the **{}** in the bottom right to format the query. Click the **downward caret** to expand the Code mode editor. **CTRL/CMD + Return** serves as a keyboard shortcut to execute the query.
|
||||
|
||||
{{< admonition type="warning" >}}
|
||||
Changes made to a query in Code mode will not transfer to Builder mode and will be discarded. You will be prompted to copy your code to the clipboard to save any changes.
|
||||
{{< /admonition >}}
|
||||
|
||||
## Macros
|
||||
|
||||
You can add macros to your queries to simplify the syntax and enable dynamic elements, such as date range filters.
|
||||
|
||||
| Macro example | Description |
|
||||
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `$__time(dateColumn)` | Replaces the value with an expression to convert to a UNIX timestamp and renames the column to `time_sec`. Example: `UNIX_TIMESTAMP(dateColumn) AS time_sec`. |
|
||||
| `$__timeEpoch(dateColumn)` | Replaces the value with an expression to convert to a UNIX Epoch timestamp and renames the column to `time_sec`. Example: `UNIX_TIMESTAMP(dateColumn) AS time_sec`. |
|
||||
| `$__timeFilter(dateColumn)` | Replaces the value a time range filter using the specified column name. Example: `dateColumn BETWEEN FROM_UNIXTIME(1494410783) AND FROM_UNIXTIME(1494410983)` |
|
||||
| `$__timeFrom()` | Replaces the value with the start of the currently active time selection. Example: `FROM_UNIXTIME(1494410783)` |
|
||||
| `$__timeTo()` | Replaces the value with the end of the currently active time selection. Example: `FROM_UNIXTIME(1494410983)` |
|
||||
| `$__timeGroup(dateColumn,'5m')` | Replaces the value with an expression suitable for use in a `GROUP BY` clause. Example: `cast(cast(UNIX_TIMESTAMP(dateColumn)/(300) AS signed)*300 AS signed)` |
|
||||
| `$__timeGroup(dateColumn,'5m', 0)` | Same as the `$__timeGroup(dateColumn,'5m')` macro, but includes a fill parameter to ensure missing points in the series are added by Grafana, using 0 as the default value. **This applies only to time series queries.** |
|
||||
| `$__timeGroup(dateColumn,'5m', NULL)` | Same as the `$__timeGroup(dateColumn,'5m', 0)` but `NULL` is used as the value for missing points. _This applies only to time series queries._ |
|
||||
| `$__timeGroup(dateColumn,'5m', previous)` | Same as the `$__timeGroup(dateColumn,'5m', previous)` macro, but uses the previous value in the series as the fill value. If no previous value exists, it uses `NULL`. _This applies only to time series queries._ |
|
||||
| `$__timeGroupAlias(dateColumn,'5m')` | Replaces the value identical to `$__timeGroup` but with an added column alias. |
|
||||
| `$__unixEpochFilter(dateColumn)` | Replaces the value by a time range filter using the specified column name with times represented as a UNIX timestamp. Example: `dateColumn > 1494410783 AND dateColumn < 1494497183` |
|
||||
| `$__unixEpochFrom()` | Replaces the value with the start of the currently active time selection as a UNIX timestamp. Example: `1494410783` |
|
||||
| `$__unixEpochTo()` | Replaces the value with the end of the currently active time selection as a UNIX timestamp. Example: `1494497183` |
|
||||
| `$__unixEpochNanoFilter(dateColumn)` | Replaces the value with a time range filter using the specified column name with time represented as a nanosecond timestamp. Example: `dateColumn > 1494410783152415214 AND dateColumn < 1494497183142514872` |
|
||||
| `$__unixEpochNanoFrom()` | Replaces the value with the start of the currently active time selection as a nanosecond timestamp. Example: `1494410783152415214` |
|
||||
| `$__unixEpochNanoTo()` | Replaces the value with the end of the currently active time selection as nanosecond timestamp. Example: `1494497183142514872` |
|
||||
| `$__unixEpochGroup(dateColumn,'5m', [fillmode])` | Same as `$__timeGroup` but for times stored as Unix timestamp. `fillMode` only works with time series queries. |
|
||||
| `$__unixEpochGroupAlias(dateColumn,'5m', [fillmode])` | Same as `$__timeGroup` but also adds a column alias. `fillMode` only works with time series queries. |
|
||||
|
||||
## Table SQL queries
|
||||
|
||||
If the **Format** option is set to **Table**, you can execute virtually any type of SQL query. The Table panel will automatically display the resulting columns and rows from your query.
|
||||
|
||||

|
||||
|
||||
You can change or customize the name of a Table panel column by using the SQL keyword `AS` syntax.
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
title as "Title",
|
||||
"user".login as "Created By",
|
||||
dashboard.created as "Created On"
|
||||
FROM dashboard
|
||||
INNER JOIN "user" on "user".id = dashboard.created_by
|
||||
WHERE $__timeFilter(dashboard.created)
|
||||
```
|
||||
|
||||
## Time series queries
|
||||
|
||||
Set the **Format** option to **Time series** to create and run time series queries.
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
To run a time series query you must include a column named `time` that returns either a SQL `datetime` value or a numeric datatype representing the UNIX epoch time in seconds. Additionally, the query results must be sorted by the `time` column for proper visualization in panels.
|
||||
{{< /admonition >}}
|
||||
|
||||
The examples in this section refer to the data in the following table:
|
||||
|
||||
```text
|
||||
+---------------------+--------------+---------------------+----------+
|
||||
| time_date_time | value_double | CreatedAt | hostname |
|
||||
+---------------------+--------------+---------------------+----------+
|
||||
| 2020-01-02 03:05:00 | 3.0 | 2020-01-02 03:05:00 | 10.0.1.1 |
|
||||
| 2020-01-02 03:06:00 | 4.0 | 2020-01-02 03:06:00 | 10.0.1.2 |
|
||||
| 2020-01-02 03:10:00 | 6.0 | 2020-01-02 03:10:00 | 10.0.1.1 |
|
||||
| 2020-01-02 03:11:00 | 7.0 | 2020-01-02 03:11:00 | 10.0.1.2 |
|
||||
| 2020-01-02 03:20:00 | 5.0 | 2020-01-02 03:20:00 | 10.0.1.2 |
|
||||
+---------------------+--------------+---------------------+----------+
|
||||
```
|
||||
|
||||
Time series query results are returned in [wide data frame format](https://grafana.com/developers/plugin-tools/key-concepts/data-frames#wide-format). In the data frame query result, any column, except for time or string-type columns, transforms into value fields. String columns, on the other hand, become field labels.
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
For backward compatibility, an exception to this rule applies to queries that return three columns, one of which is a string column named `metric`. Instead of converting the metric column into field labels, it is used as the field name, while the series name is set to its value. See the following example for reference.
|
||||
{{< /admonition >}}
|
||||
|
||||
**Example with `metric` column:**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
$__timeGroupAlias("time_date_time",'5m'),
|
||||
min("value_double"),
|
||||
'min' as metric
|
||||
FROM test_data
|
||||
WHERE $__timeFilter("time_date_time")
|
||||
GROUP BY time
|
||||
ORDER BY time
|
||||
```
|
||||
|
||||
Data frame result:
|
||||
|
||||
```text
|
||||
+---------------------+-----------------+
|
||||
| Name: time | Name: min |
|
||||
| Labels: | Labels: |
|
||||
| Type: []time.Time | Type: []float64 |
|
||||
+---------------------+-----------------+
|
||||
| 2020-01-02 03:05:00 | 3 |
|
||||
| 2020-01-02 03:10:00 | 6 |
|
||||
+---------------------+-----------------+
|
||||
```
|
||||
|
||||
To customize default series name formatting, refer to [Standard options definitions](ref:configure-standard-options-display-name).
|
||||
|
||||
Following are time series query examples.
|
||||
|
||||
**Example using the fill parameter in the $\_\_timeGroupAlias macro to convert null values to be zero instead:**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
$__timeGroupAlias("createdAt",'5m',0),
|
||||
sum(value) as value,
|
||||
hostname
|
||||
FROM test_data
|
||||
WHERE
|
||||
$__timeFilter("createdAt")
|
||||
GROUP BY time, hostname
|
||||
ORDER BY time
|
||||
```
|
||||
|
||||
Based on the data frame result in the following example, the time series panel will generate two series named _value 10.0.1.1_ and _value 10.0.1.2_. To display the series names as _10.0.1.1_ and _10.0.1.2_, use the [Standard options definitions](ref:configure-standard-options-display-name) display value `${__field.labels.hostname}`.
|
||||
|
||||
Data frame result:
|
||||
|
||||
```text
|
||||
+---------------------+---------------------------+---------------------------+
|
||||
| Name: time | Name: value | Name: value |
|
||||
| Labels: | Labels: hostname=10.0.1.1 | Labels: hostname=10.0.1.2 |
|
||||
| Type: []time.Time | Type: []float64 | Type: []float64 |
|
||||
+---------------------+---------------------------+---------------------------+
|
||||
| 2020-01-02 03:05:00 | 3 | 4 |
|
||||
| 2020-01-02 03:10:00 | 6 | 7 |
|
||||
+---------------------+---------------------------+---------------------------+
|
||||
```
|
||||
|
||||
**Example with multiple columns:**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
$__timeGroupAlias("time_date_time",'5m'),
|
||||
min("value_double") as "min_value",
|
||||
max("value_double") as "max_value"
|
||||
FROM test_data
|
||||
WHERE $__timeFilter("time_date_time")
|
||||
GROUP BY time
|
||||
ORDER BY time
|
||||
```
|
||||
|
||||
Data frame result:
|
||||
|
||||
```text
|
||||
+---------------------+-----------------+-----------------+
|
||||
| Name: time | Name: min_value | Name: max_value |
|
||||
| Labels: | Labels: | Labels: |
|
||||
| Type: []time.Time | Type: []float64 | Type: []float64 |
|
||||
+---------------------+-----------------+-----------------+
|
||||
| 2020-01-02 03:04:00 | 3 | 4 |
|
||||
| 2020-01-02 03:05:00 | 6 | 7 |
|
||||
+---------------------+-----------------+-----------------+
|
||||
```
|
||||
|
||||
## Templating
|
||||
|
||||
Instead of hard coding values like server, application, or sensor names in your metric queries, you can use variables. Variables appear as drop-down select boxes at the top of the dashboard. These drop-downs make it easy to change the data being displayed in your dashboard.
|
||||
|
||||
Refer to [Templates](ref:templates) for an introduction to creating template variables as well as the different types.
|
||||
|
||||
### Query variable
|
||||
|
||||
If you add a `Query` template variable you can write a PostgreSQL query to retrieve items such as measurement names, key names, or key values, which will be displayed in the drop-down menu.
|
||||
|
||||
For example, you can use a variable to retrieve all the values from the `hostname` column in a table by creating the following query in the templating variable _Query_ setting.
|
||||
|
||||
```sql
|
||||
SELECT hostname FROM host
|
||||
```
|
||||
|
||||
A query can return multiple columns, and Grafana will automatically generate a list based on the query results. For example, the following query returns a list with values from `hostname` and `hostname2`.
|
||||
|
||||
```sql
|
||||
SELECT host.hostname, other_host.hostname2 FROM host JOIN other_host ON host.city = other_host.city
|
||||
```
|
||||
|
||||
To use time range dependent macros like `$__timeFilter(column)` in your query, you must set the template variable's refresh mode to _On Time Range Change_.
|
||||
|
||||
```sql
|
||||
SELECT event_name FROM event_log WHERE $__timeFilter(time_column)
|
||||
```
|
||||
|
||||
Another option is a query that can create a key/value variable. The query should return two columns that are named `__text` and `__value`. The `__text` column must contain unique values (if not, only the first value is used). This allows the drop-down options to display a text-friendly name as the text while using an ID as the value. For example, a query could use `hostname` as the text and `id` as the value:
|
||||
|
||||
```sql
|
||||
SELECT hostname AS __text, id AS __value FROM host
|
||||
```
|
||||
|
||||
You can also create nested variables. For example, if you have a variable named `region`, you can configure the `hosts` variable to display only the hosts within the currently selected region as shown in the following example. If `region` is a multi-value variable, use the `IN` operator instead of `=` to match multiple values.
|
||||
|
||||
```sql
|
||||
SELECT hostname FROM host WHERE region IN($region)
|
||||
```
|
||||
|
||||
#### Using `__searchFilter` to filter results in Query Variable
|
||||
|
||||
Using `__searchFilter` in the query field allows the query results to be filtered based on the user’s input in the drop-down selection box. If you do not enter anything, the default value for `__searchFilter` is `%`.
|
||||
|
||||
Note that you must enclose the `__searchFilter` expression in quotes as Grafana does not add them automatically.
|
||||
|
||||
The following example demonstrates how to use `__searchFilter` in the query field to enable real-time searching for `hostname` as the user type in the drop-down selection box.
|
||||
|
||||
```sql
|
||||
SELECT hostname FROM my_host WHERE hostname LIKE '$__searchFilter'
|
||||
```
|
||||
|
||||
### Using Variables in Queries
|
||||
|
||||
Template variable values are only quoted when the template variable is a `multi-value`.
|
||||
|
||||
If the variable is a multi-value variable, use the `IN` comparison operator instead of `=` to match against multiple values.
|
||||
|
||||
You can use two different syntaxes:
|
||||
|
||||
`$<varname>` Example with a template variable named `hostname`:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
atimestamp as time,
|
||||
aint as value
|
||||
FROM table
|
||||
WHERE $__timeFilter(atimestamp) and hostname in($hostname)
|
||||
ORDER BY atimestamp ASC
|
||||
```
|
||||
|
||||
`[[varname]]` Example with a template variable named `hostname`:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
atimestamp as time,
|
||||
aint as value
|
||||
FROM table
|
||||
WHERE $__timeFilter(atimestamp) and hostname in([[hostname]])
|
||||
ORDER BY atimestamp ASC
|
||||
```
|
||||
|
||||
#### Disabling quoting for multi-value variables
|
||||
|
||||
Grafana automatically formats multi-value variables as a quoted, comma-separated string. For example, if `server01` and `server02` are selected, they are formatted as `'server01'`, `'server02'`. To remove the quotes, enable the CSV formatting option for the variables:
|
||||
|
||||
`${servers:csv}`
|
||||
|
||||
Read more about variable formatting options in the [Variables](ref:variable-syntax-advanced-variable-format-options) documentation.
|
||||
|
||||
## Annotations
|
||||
|
||||
[Annotations](ref:annotate-visualizations) allow you to overlay rich event information on top of graphs. Add annotation queries via the **Dashboard settings > Annotations view**.
|
||||
|
||||
**Example query using a `time` column with epoch values:**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
epoch_time as time,
|
||||
metric1 as text,
|
||||
concat_ws(', ', metric1::text, metric2::text) as tags
|
||||
FROM
|
||||
public.test_data
|
||||
WHERE
|
||||
$__unixEpochFilter(epoch_time)
|
||||
```
|
||||
|
||||
**Example region query using `time` and `timeend` columns with epoch values:**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
epoch_time as time,
|
||||
epoch_time_end as timeend,
|
||||
metric1 as text,
|
||||
concat_ws(', ', metric1::text, metric2::text) as tags
|
||||
FROM
|
||||
public.test_data
|
||||
WHERE
|
||||
$__unixEpochFilter(epoch_time)
|
||||
```
|
||||
|
||||
**Example query using a `time` column with a native SQL date/time data type:**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
native_date_time as time,
|
||||
metric1 as text,
|
||||
concat_ws(', ', metric1::text, metric2::text) as tags
|
||||
FROM
|
||||
public.test_data
|
||||
WHERE
|
||||
$__timeFilter(native_date_time)
|
||||
```
|
||||
|
||||
| Name | Description |
|
||||
| --------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| `time` | The name of the date/time field, which can be a column with a native SQL date/time data type or epoch value. |
|
||||
| `timeend` | Optional name of the end date/time field, which can be a column with a native SQL date/time data type or epoch value. |
|
||||
| `text` | Event description field. |
|
||||
| `tags` | Optional field name to use for event tags as a comma-separated string. |
|
||||
|
||||
## Alerting
|
||||
|
||||
Use time series queries to create alerts. Table formatted queries aren't yet supported in alert rule conditions.
|
||||
|
||||
For more information regarding alerting refer to the following:
|
||||
|
||||
- [Alert rules](ref:alert-rules)
|
||||
- [Template annotations and labels](ref:template-annotations-and-labels)
|
||||
@@ -12,7 +12,7 @@ hero:
|
||||
level: 1
|
||||
width: 100
|
||||
height: 100
|
||||
description: Use the Drilldown apps to investigate and identify issues using telemetry data.
|
||||
description: Use the Grafana Drilldown apps to investigate and identify issues using telemetry data.
|
||||
cards:
|
||||
title_class: pt-0 lh-1
|
||||
items:
|
||||
@@ -40,6 +40,8 @@ The Grafana Drilldown apps are designed for effortless data exploration through
|
||||
|
||||
Easily explore telemetry signals with these specialized tools, tailored specifically for the Grafana databases to provide quick and accurate insights.
|
||||
|
||||
{{< docs/shared source="grafana" lookup="plugins/rename-note.md" version="<GRAFANA_VERSION>" >}}
|
||||
|
||||
To learn more, read:
|
||||
|
||||
- [From multi-line queries to no-code investigations: meeting Grafana users where they are](https://grafana.com/blog/2024/10/22/from-multi-line-queries-to-no-code-investigations-meeting-grafana-users-where-they-are/)
|
||||
|
||||
@@ -16,7 +16,9 @@ weight: 200
|
||||
|
||||
Grafana Metrics Drilldown is a query-less experience for browsing **Prometheus-compatible** metrics. Quickly find related metrics with just a few simple clicks, without needing to write PromQL queries to retrieve metrics.
|
||||
|
||||
With Grafana Metrics Drilldown, you can:
|
||||
{{< docs/shared source="grafana" lookup="plugins/rename-note.md" version="<GRAFANA_VERSION>" >}}
|
||||
|
||||
With Metrics Drilldown, you can:
|
||||
|
||||
- Easily segment metrics based on their labels, so you can immediately spot anomalies and identify issues.
|
||||
- Automatically display the optimal visualization for each metric type (gauge vs. counter, for example) without manual setup.
|
||||
@@ -25,13 +27,13 @@ With Grafana Metrics Drilldown, you can:
|
||||
- View a history of user steps when navigating through metrics and their filters.
|
||||
- Seamlessly pivot to related telemetry, including log data.
|
||||
|
||||
{{< docs/play title="Grafana Metrics Drilldown" url="https://play.grafana.org/explore/metrics/trail?from=now-1h&to=now&var-ds=grafanacloud-demoinfra-prom&var-filters=&refresh=&metricPrefix=all" >}}
|
||||
{{< docs/play title="Metrics Drilldown" url="https://play.grafana.org/explore/metrics/trail?from=now-1h&to=now&var-ds=grafanacloud-demoinfra-prom&var-filters=&refresh=&metricPrefix=all" >}}
|
||||
|
||||
You can access Grafana Metrics Drilldown either as a standalone experience or as part of Grafana dashboards.
|
||||
You can access Metrics Drilldown either as a standalone experience or as part of Grafana dashboards.
|
||||
|
||||
## Standalone experience
|
||||
|
||||
To access Grafana Metrics Drilldown as a standalone experience:
|
||||
To access Metrics Drilldown as a standalone experience:
|
||||
|
||||
1. Click the arrow next to **Drilldown** in the Grafana left-side menu and click **Metrics**. You are taken to an overview page that shows recent metrics, bookmarks, and the option to select a new metric exploration.
|
||||
1. To get started with a new exploration, click **Let's start!**.
|
||||
@@ -63,7 +65,7 @@ After you have gathered your metrics exploration data you can:
|
||||
|
||||
## Dashboard experience
|
||||
|
||||
To access Grafana Metrics Drilldown via a dashboard:
|
||||
To access Metrics Drilldown via a dashboard:
|
||||
|
||||
1. Navigate to your dashboard.
|
||||
1. Select a time series panel.
|
||||
|
||||
@@ -24,7 +24,7 @@ MS SQL Server can be installed on Windows or Linux operating systems and also on
|
||||
|
||||
You can install MS SQL Server on the host running Grafana or on a remote server. To install the software from the [downloads page](https://www.microsoft.com/en-us/sql-server/sql-server-downloads), follow their setup prompts.
|
||||
|
||||
If you are on a Windows host but want to use Grafana and MS SQL data source on a Linux environment, refer to the [WSL to set up your Grafana development environment](/blog/2021/03/03/.how-to-set-up-a-grafana-development-environment-on-a-windows-pc-using-wsl). This will allow you to leverage the resources available in [grafana/grafana](https://github.com/grafana/grafana) GitHub repository. Here you will find a collection of supported data sources, including MS SQL Server, along with test data and pre-configured dashboards for use.
|
||||
If you are on a Windows host but want to use Grafana and MS SQL data source on a Linux environment, refer to the [WSL to set up your Grafana development environment](/blog/2021/03/03/how-to-set-up-a-grafana-development-environment-on-a-windows-pc-using-wsl) blog post. This will allow you to leverage the resources available in [grafana/grafana](https://github.com/grafana/grafana) GitHub repository. Here you will find a collection of supported data sources, including MS SQL Server, along with test data and pre-configured dashboards for use.
|
||||
|
||||
#### Add the MS SQL data source
|
||||
|
||||
|
||||
@@ -210,6 +210,7 @@ Experimental features might be changed or removed without prior notice.
|
||||
| `timeRangeProvider` | Enables time pickers sync |
|
||||
| `prometheusUsesCombobox` | Use new combobox component for Prometheus query editor |
|
||||
| `playlistsReconciler` | Enables experimental reconciler for playlists |
|
||||
| `exploreMetricsRelatedLogs` | Display Related Logs in Grafana Metrics Drilldown |
|
||||
| `prometheusSpecialCharsInLabelValues` | Adds support for quotes and special characters in label values for Prometheus queries |
|
||||
| `enableExtensionsAdminPage` | Enables the extension admin page regardless of development mode |
|
||||
| `enableSCIM` | Enables SCIM support for user and group management |
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
headless: true
|
||||
labels:
|
||||
products:
|
||||
- enterprise
|
||||
- oss
|
||||
---
|
||||
|
||||
[//]: # 'This file contains a rename note for Explore to Drilldown apps.'
|
||||
[//]: # 'This shared file is included in a lot of files. Check the app docs in'
|
||||
[//]: # 'drilldown-traces, drilldown-logs, drilldown-profiles, grafana, and website/grafana-cloud.'
|
||||
[//]: # 'If you make changes to this file, verify that the meaning and content are not changed in any place where the file is included.'
|
||||
[//]: # 'Any links should be fully qualified and not relative: /docs/grafana/ instead of ../grafana/.'
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
The Grafana Explore apps have changed to Grafana Drilldown apps.
|
||||
For example, Explore Logs is now Logs Drilldown.
|
||||
To learn more, read [Grafana Drilldown apps: the improved queryless experience known as the Explore apps](https://grafana.com/blog/2025/02/20/grafana-drilldown-apps-the-improved-queryless-experience-formerly-known-as-the-explore-apps/).
|
||||
{{< /admonition >}}
|
||||
@@ -56,7 +56,7 @@ require (
|
||||
github.com/go-openapi/runtime v0.28.0 // @grafana/alerting-backend
|
||||
github.com/go-openapi/strfmt v0.23.0 // @grafana/alerting-backend
|
||||
github.com/go-redis/redis/v8 v8.11.5 // @grafana/grafana-backend-group
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // @grafana/grafana-backend-group
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // @grafana/grafana-backend-group
|
||||
github.com/go-sql-driver/mysql v1.8.1 // @grafana/grafana-search-and-storage
|
||||
github.com/go-stack/stack v1.8.1 // @grafana/grafana-backend-group
|
||||
github.com/gobwas/glob v0.2.3 // @grafana/grafana-backend-group
|
||||
@@ -74,7 +74,7 @@ require (
|
||||
github.com/googleapis/gax-go/v2 v2.14.1 // @grafana/grafana-backend-group
|
||||
github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group
|
||||
github.com/gorilla/websocket v1.5.3 // @grafana/grafana-app-platform-squad
|
||||
github.com/grafana/alerting v0.0.0-20250219153626-c475b1a572b0 // @grafana/alerting-backend
|
||||
github.com/grafana/alerting v0.0.0-20250221202230-9d7e00921e44 // @grafana/alerting-backend
|
||||
github.com/grafana/authlib v0.0.0-20250219100139-6a3b1bbb50e7 // @grafana/identity-access-team
|
||||
github.com/grafana/authlib/types v0.0.0-20250219092154-21ce22b49f31 // @grafana/identity-access-team
|
||||
github.com/grafana/dashboard-linter v0.0.0-20241106223805-1e7999311752 // @grafana/grafana-app-platform-squad
|
||||
@@ -115,7 +115,6 @@ require (
|
||||
github.com/jmoiron/sqlx v1.3.5 // @grafana/grafana-backend-group
|
||||
github.com/json-iterator/go v1.1.12 // @grafana/grafana-backend-group
|
||||
github.com/lib/pq v1.10.9 // @grafana/grafana-backend-group
|
||||
github.com/linkedin/goavro/v2 v2.10.0 // @grafana/grafana-backend-group
|
||||
github.com/m3db/prometheus_remote_client_golang v0.4.4 // @grafana/grafana-backend-group
|
||||
github.com/madflojo/testcerts v1.1.1 // @grafana/alerting-backend
|
||||
github.com/magefile/mage v1.15.0 // @grafana/grafana-developer-enablement-squad
|
||||
|
||||
@@ -1294,8 +1294,8 @@ github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC
|
||||
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
|
||||
github.com/go-resty/resty/v2 v2.15.3 h1:bqff+hcqAflpiF591hhJzNdkRsFhlB96CYfBwSFvql8=
|
||||
github.com/go-resty/resty/v2 v2.15.3/go.mod h1:0fHAoK7JoBy/Ch36N8VFeMsK7xQOHhvWaC3iOktwmIU=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
|
||||
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
@@ -1528,8 +1528,8 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg
|
||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grafana/alerting v0.0.0-20250219153626-c475b1a572b0 h1:LGH+tVzHCDrR9hsltmkP4jmNRg5IreQw5CNFbJKlnts=
|
||||
github.com/grafana/alerting v0.0.0-20250219153626-c475b1a572b0/go.mod h1:hdGB3dSl8Ma9Rjo2YiAEAjMkZ5HiNJbNDqRKDefRZrM=
|
||||
github.com/grafana/alerting v0.0.0-20250221202230-9d7e00921e44 h1:vboqvbAO0s0CTALHnqfmNvhCP1ziBcZNpYDbORqvOgg=
|
||||
github.com/grafana/alerting v0.0.0-20250221202230-9d7e00921e44/go.mod h1:hdGB3dSl8Ma9Rjo2YiAEAjMkZ5HiNJbNDqRKDefRZrM=
|
||||
github.com/grafana/authlib v0.0.0-20250219100139-6a3b1bbb50e7 h1:NTMmow+74I3Jb033xhbRgWQS7A//5TDhiM4tl7bsVP4=
|
||||
github.com/grafana/authlib v0.0.0-20250219100139-6a3b1bbb50e7/go.mod h1:T3X4z0ejGfJOiOmZLFeKCRT/yxWJq/RtclAc/PHj/w4=
|
||||
github.com/grafana/authlib/types v0.0.0-20250219092154-21ce22b49f31 h1:EokLC5grHwLPs4tXW8T6E8187H1e5G9AP0QQ5B60HbA=
|
||||
@@ -1870,8 +1870,6 @@ github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/linkedin/goavro/v2 v2.10.0 h1:eTBIRoInBM88gITGXYtUSqqxLTFXfOsJBiX8ZMW0o4U=
|
||||
github.com/linkedin/goavro/v2 v2.10.0/go.mod h1:UgQUb2N/pmueQYH9bfqFioWxzYCZXSfF8Jw03O5sjqA=
|
||||
github.com/linode/linodego v1.43.0 h1:sGeBB3caZt7vKBoPS5p4AVzmlG4JoqQOdigIibx3egk=
|
||||
github.com/linode/linodego v1.43.0/go.mod h1:n4TMFu1UVNala+icHqrTEFFaicYSF74cSAUG5zkTwfA=
|
||||
github.com/lyft/protoc-gen-star v0.6.0/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA=
|
||||
|
||||
+37
-1
@@ -393,6 +393,7 @@ github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
|
||||
github.com/chenzhuoyu/iasm v0.9.0 h1:9fhXjVzq5hUy2gkhhgHl95zG2cEAhw9OSGs8toWWAwo=
|
||||
github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
|
||||
github.com/chromedp/cdproto v0.0.0-20220208224320-6efb837e6bc2/go.mod h1:At5TxYYdxkbQL0TSefRjhLE3Q0lgvqKKMSFUglJ7i1U=
|
||||
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
|
||||
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
|
||||
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
|
||||
@@ -509,7 +510,9 @@ github.com/elastic/go-sysinfo v1.11.2 h1:mcm4OSYVMyws6+n2HIVMGkln5HOpo5Ie1ZmbbNn
|
||||
github.com/elastic/go-sysinfo v1.11.2/go.mod h1:GKqR8bbMK/1ITnez9NIsIfXQr25aLhRJa7AfT8HpBFQ=
|
||||
github.com/elastic/go-windows v1.0.1 h1:AlYZOldA+UJ0/2nBuqWdo90GFCgG9xuyw9SYzGUtJm0=
|
||||
github.com/elastic/go-windows v1.0.1/go.mod h1:FoVvqWSun28vaDQPbj2Elfc0JahhPB7WQEGa3c814Ss=
|
||||
github.com/elazarl/goproxy v1.3.0/go.mod h1:X/5W/t+gzDyLfHW4DrMdpjqYjpXsURlBt9lpBDxZZZQ=
|
||||
github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633 h1:H2pdYOb3KQ1/YsqVWoWNLQO+fusocsw354rqGTZtAgw=
|
||||
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
|
||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
|
||||
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
||||
@@ -547,10 +550,12 @@ github.com/go-kit/kit v0.13.0 h1:OoneCcHKHQ03LfBpoQCUfCluwd2Vt3ohz+kvbJneZAU=
|
||||
github.com/go-kit/kit v0.13.0/go.mod h1:phqEHMMUbyrCFCTgH48JueqrM3md2HcAZ8N3XE4FKDg=
|
||||
github.com/go-latex/latex v0.0.0-20231108140139-5c1ce85aa4ea h1:DfZQkvEbdmOe+JK2TMtBM+0I9GSdzE2y/L1/AmD8xKc=
|
||||
github.com/go-latex/latex v0.0.0-20231108140139-5c1ce85aa4ea/go.mod h1:Y7Vld91/HRbTBm7JwoI7HejdDB0u+e9AUBO9MB7yuZk=
|
||||
github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
|
||||
github.com/go-openapi/jsonreference v0.20.1/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
|
||||
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
|
||||
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
|
||||
github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw=
|
||||
@@ -562,6 +567,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.19.0 h1:ol+5Fu+cSq9JD7SoSqe04GMI92cbn0+wvQ3bZ8b/AU4=
|
||||
github.com/go-playground/validator/v10 v10.19.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
|
||||
github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 h1:TQcrn6Wq+sKGkpyPvppOz99zsMBaUOKXq6HSv655U1c=
|
||||
github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
@@ -580,7 +587,6 @@ github.com/golang-jwt/jwt v3.2.1+incompatible h1:73Z+4BJcrTC+KczS6WvTPvRGOp1WmfE
|
||||
github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||
github.com/golang/glog v1.2.3/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
|
||||
github.com/golang/glog v1.2.4/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
|
||||
github.com/gomarkdown/markdown v0.0.0-20230716120725-531d2d74bc12 h1:uK3X/2mt4tbSGoHvbLBHUny7CKiuwUip3MArtukol4E=
|
||||
github.com/gomarkdown/markdown v0.0.0-20230716120725-531d2d74bc12/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/gomodule/redigo v1.8.9 h1:Sl3u+2BI/kk+VEatbj0scLdrFhjPmbxOc1myhDP41ws=
|
||||
@@ -610,17 +616,29 @@ github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
|
||||
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
|
||||
github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4=
|
||||
github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q=
|
||||
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grafana/alerting v0.0.0-20250129195454-3e5b80036b7a/go.mod h1:QsnoKX/iYZxA4Cv+H+wC7uxutBD8qi8ZW5UJvD2TYmU=
|
||||
github.com/grafana/authlib v0.0.0-20250123104008-e99947858901/go.mod h1:/gYfphsNu9v1qYWXxpv1NSvMEMSwvdf8qb8YlgwIRl8=
|
||||
github.com/grafana/authlib/types v0.0.0-20250120144156-d6737a7dc8f5/go.mod h1:qYjSd1tmJiuVoSICp7Py9/zD54O9uQQA3wuM6Gg4DFM=
|
||||
github.com/grafana/authlib/types v0.0.0-20250120145936-5f0e28e7a87c/go.mod h1:qYjSd1tmJiuVoSICp7Py9/zD54O9uQQA3wuM6Gg4DFM=
|
||||
github.com/grafana/cloudflare-go v0.0.0-20230110200409-c627cf6792f2 h1:qhugDMdQ4Vp68H0tp/0iN17DM2ehRo1rLEdOFe/gB8I=
|
||||
github.com/grafana/cloudflare-go v0.0.0-20230110200409-c627cf6792f2/go.mod h1:w/aiO1POVIeXUQyl0VQSZjl5OAGDTL5aX+4v0RA1tcw=
|
||||
github.com/grafana/cog v0.0.23 h1:/0CCJ24Z8XXM2DnboSd2FzoIswUroqIZzVr8oJWmMQs=
|
||||
github.com/grafana/cog v0.0.23/go.mod h1:jrS9indvWuDs60RHEZpLaAkmZdgyoLKMOEUT0jiB1t0=
|
||||
github.com/grafana/go-gelf/v2 v2.0.1 h1:BOChP0h/jLeD+7F9mL7tq10xVkDG15he3T1zHuQaWak=
|
||||
github.com/grafana/go-gelf/v2 v2.0.1/go.mod h1:lexHie0xzYGwCgiRGcvZ723bSNyNI8ZRD4s0CLobh90=
|
||||
github.com/grafana/grafana-plugin-sdk-go v0.263.0/go.mod h1:U43Cnrj/9DNYyvFcNdeUWNjMXTKNB0jcTcQGpWKd2gw=
|
||||
github.com/grafana/grafana/apps/advisor v0.0.0-20250123151950-b066a6313173/go.mod h1:goSDiy3jtC2cp8wjpPZdUHRENcoSUHae1/Px/MDfddA=
|
||||
github.com/grafana/grafana/apps/advisor v0.0.0-20250220154326-6e5de80ef295/go.mod h1:9I1dKV3Dqr0NPR9Af0WJGxOytp5/6W3JLiNChOz8r+c=
|
||||
github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250121113133-e747350fee2d/go.mod h1:AvleS6icyPmcBjihtx5jYEvdzLmHGBp66NuE0AMR57A=
|
||||
github.com/grafana/grafana/apps/investigation v0.0.0-20250121113133-e747350fee2d/go.mod h1:HQprw3MmiYj5OUV9CZnkwA1FKDZBmYACuAB3oDvUOmI=
|
||||
github.com/grafana/grafana/apps/playlist v0.0.0-20250121113133-e747350fee2d/go.mod h1:DjJe5osrW/BKrzN9hAAOSElNWutj1bcriExa7iDP7kA=
|
||||
github.com/grafana/grafana/pkg/aggregator v0.0.0-20250121113133-e747350fee2d/go.mod h1:1sq0guad+G4SUTlBgx7SXfhnzy7D86K/LcVOtiQCiMA=
|
||||
github.com/grafana/grafana/pkg/build v0.0.0-20250220114259-be81314e2118/go.mod h1:STVpVboMYeBAfyn6Zw6XHhTHqUxzMy7pzRiVgk1l0W0=
|
||||
github.com/grafana/grafana/pkg/semconv v0.0.0-20250121113133-e747350fee2d/go.mod h1:tfLnBpPYgwrBMRz4EXqPCZJyCjEG4Ev37FSlXnocJ2c=
|
||||
github.com/grafana/grafana/pkg/storage/unified/apistore v0.0.0-20250121113133-e747350fee2d/go.mod h1:CXpwZ3Mkw6xVlGKc0SqUxqXCP3Uv182q6qAQnLaLxRg=
|
||||
github.com/grafana/tail v0.0.0-20230510142333-77b18831edf0 h1:bjh0PVYSVVFxzINqPFYJmAmJNrWPgnVjuSdYJGHmtFU=
|
||||
github.com/grafana/tail v0.0.0-20230510142333-77b18831edf0/go.mod h1:7t5XR+2IA8P2qggOAHTj/GCZfoLBle3OvNSYh1VkRBU=
|
||||
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA=
|
||||
@@ -631,8 +649,10 @@ github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645 h1
|
||||
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8=
|
||||
github.com/hamba/avro/v2 v2.27.0 h1:IAM4lQ0VzUIKBuo4qlAiLKfqALSrFC+zi1iseTtbBKU=
|
||||
github.com/hamba/avro/v2 v2.27.0/go.mod h1:jN209lopfllfrz7IGoZErlDz+AyUJ3vrBePQFZwYf5I=
|
||||
github.com/hashicorp/go-hclog v0.14.1/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
|
||||
github.com/hashicorp/go-msgpack/v2 v2.1.1 h1:xQEY9yB2wnHitoSzk/B9UjXWRQ67QKu5AOm8aFp8N3I=
|
||||
github.com/hashicorp/go-msgpack/v2 v2.1.1/go.mod h1:upybraOAblm4S7rx0+jeNy+CWWhzywQsSRV5033mMu4=
|
||||
github.com/hashicorp/go-plugin v1.6.2/go.mod h1:CkgLQ5CZqNmdL9U9JzM532t8ZiYQ35+pj3b1FD37R0Q=
|
||||
github.com/hashicorp/go-syslog v1.0.0 h1:KaodqZuhUoZereWVIYmpUgZysurB1kBLX2j0MwMrUAE=
|
||||
github.com/hashicorp/go.net v0.0.1 h1:sNCoNyDEvN1xa+X0baata4RdcpKwcMS6DH+xwfqPgjw=
|
||||
github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y=
|
||||
@@ -655,6 +675,7 @@ github.com/influxdata/tdigest v0.0.2-0.20210216194612-fc98d27c9e8b h1:i44CesU68Z
|
||||
github.com/influxdata/tdigest v0.0.2-0.20210216194612-fc98d27c9e8b/go.mod h1:Z0kXnxzbTC2qrx4NaIzYkE1k66+6oEDQTvL95hQFh5Y=
|
||||
github.com/influxdata/telegraf v1.16.3 h1:x0qeuSGGMg5y+YqP/5ZHwXZu3bcBrO8AAQOTNlYEb1c=
|
||||
github.com/influxdata/telegraf v1.16.3/go.mod h1:fX/6k7qpIqzVPWyeIamb0wN5hbwc0ANUaTS80lPYFB8=
|
||||
github.com/invopop/jsonschema v0.12.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
|
||||
github.com/iris-contrib/schema v0.0.6 h1:CPSBLyx2e91H2yJzPuhGuifVRnZBBJ3pCOMbOvPZaTw=
|
||||
github.com/iris-contrib/schema v0.0.6/go.mod h1:iYszG0IOsuIsfzjymw1kMzTL8YQcCWlm65f3wX8J5iA=
|
||||
github.com/jackc/fake v0.0.0-20150926172116-812a484cc733 h1:vr3AYkKovP8uR8AvSGGUK1IDqRa5lAAvEkZG1LKaCRc=
|
||||
@@ -727,6 +748,7 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/leodido/ragel-machinery v0.0.0-20190525184631-5f46317e436b h1:11UHH39z1RhZ5dc4y4r/4koJo6IYFgTRMe/LlwRTEw0=
|
||||
github.com/leodido/ragel-machinery v0.0.0-20190525184631-5f46317e436b/go.mod h1:WZxr2/6a/Ar9bMDc2rN/LJrE/hF6bXE4LPyDSIxwAfg=
|
||||
github.com/linkedin/goavro/v2 v2.10.0/go.mod h1:UgQUb2N/pmueQYH9bfqFioWxzYCZXSfF8Jw03O5sjqA=
|
||||
github.com/logrusorgru/aurora/v3 v3.0.0 h1:R6zcoZZbvVcGMvDCKo45A9U/lzYyzl5NfYIvznmDfE4=
|
||||
github.com/logrusorgru/aurora/v3 v3.0.0/go.mod h1:vsR12bk5grlLvLXAYrBsb5Oc/N+LxAlxggSjiwMnCUc=
|
||||
github.com/lufia/plan9stats v0.0.0-20220913051719-115f729f3c8c h1:VtwQ41oftZwlMnOEbMWQtSEUgU64U4s+GHk7hZK+jtY=
|
||||
@@ -738,6 +760,8 @@ github.com/mailgun/raymond/v2 v2.0.48 h1:5dmlB680ZkFG2RN/0lvTAghrSxIESeu9/2aeDqA
|
||||
github.com/mailgun/raymond/v2 v2.0.48/go.mod h1:lsgvL50kgt1ylcFJYZiULi5fjPBkkhNfj4KA0W54Z18=
|
||||
github.com/matryer/moq v0.3.3 h1:pScMH9VyrdT4S93yiLpVyU8rCDqGQr24uOyBxmktG5Q=
|
||||
github.com/matryer/moq v0.3.3/go.mod h1:RJ75ZZZD71hejp39j4crZLsEDszGk6iH4v4YsWFKH4s=
|
||||
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
|
||||
github.com/mfridman/xflag v0.1.0 h1:TWZrZwG1QklFX5S4j1vxfF1sZbZeZSGofMwPMLAF29M=
|
||||
github.com/mfridman/xflag v0.1.0/go.mod h1:/483ywM5ZO5SuMVjrIGquYNE5CzLrj5Ux/LxWWnjRaE=
|
||||
@@ -884,6 +908,7 @@ github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad h1:fiWzISvDn0Csy5H0iwgAuJ
|
||||
github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8=
|
||||
github.com/stoewer/parquet-cli v0.0.7 h1:rhdZODIbyMS3twr4OM3am8BPPT5pbfMcHLH93whDM5o=
|
||||
github.com/stoewer/parquet-cli v0.0.7/go.mod h1:bskxHdj8q3H1EmfuCqjViFoeO3NEvs5lzZAQvI8Nfjk=
|
||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/substrait-io/substrait v0.57.1 h1:GW8nnYfSowMseHR8Os82/X6lNtQGIK7p4p+lr6r+auw=
|
||||
github.com/substrait-io/substrait v0.57.1/go.mod h1:q9s+tjo+gK0lsA+SqYB0lhojNuxvdPdfYlGUP0hjbrA=
|
||||
github.com/substrait-io/substrait-go v1.2.0 h1:3ZNRkc8FYD7ifCagKEOZQtUcgMceMQfwo2N1NGaK4Q4=
|
||||
@@ -1040,9 +1065,14 @@ go.opentelemetry.io/contrib/detectors/gcp v1.32.0/go.mod h1:TVqo0Sda4Cv8gCIixd7L
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.53.0 h1:13K+tY7E8GJInkrvRiPAhC0gi/7vKjzDNhtmCf+QXG8=
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.53.0/go.mod h1:lyQF6xQ4iDnMg4sccNdFs1zf62xd79YI8vZqKjOTwMs=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0/go.mod h1:azvtTADFQJA8mX80jIH/akaE7h+dbm/sVuaHqN13w74=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.58.0/go.mod h1:HDBUsEjOuRC0EzKZ1bSaRGZWUBAzo+MhAcUUORSr4D0=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.58.0/go.mod h1:uosvgpqTcTXtcPQORTbEkZNDQTCDOgTz1fe6aLSyqrQ=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0/go.mod h1:jjdQuTGVsXV4vSs+CJ2qYDeDPf9yIJV23qlIzBm73Vg=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q=
|
||||
go.opentelemetry.io/contrib/propagators/b3 v1.27.0 h1:IjgxbomVrV9za6bRi8fWCNXENs0co37SZedQilP2hm0=
|
||||
go.opentelemetry.io/contrib/propagators/b3 v1.27.0/go.mod h1:Dv9obQz25lCisDvvs4dy28UPh974CxkahRDUPsY7y9E=
|
||||
go.opentelemetry.io/contrib/propagators/jaeger v1.33.0/go.mod h1:ku/EpGk44S5lyVMbtJRK2KFOnXEehxf6SDnhu1eZmjA=
|
||||
go.opentelemetry.io/contrib/samplers/jaegerremote v0.27.0/go.mod h1:IohbtCIY5Erb6wKnDddXOMNlG7GwyZnkrgcqjPmhpaA=
|
||||
go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=
|
||||
go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4=
|
||||
go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg=
|
||||
@@ -1136,6 +1166,8 @@ golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxb
|
||||
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
|
||||
golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg=
|
||||
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
|
||||
golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw=
|
||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||
gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0 h1:OE9mWmgKkjJyEmDAAtGMPjXu+YNeGvK9VTSHY6+Qihc=
|
||||
gonum.org/v1/plot v0.14.0 h1:+LBDVFYwFe4LHhdP8coW6296MBEY4nQ+Y4vuUpJopcE=
|
||||
gonum.org/v1/plot v0.14.0/go.mod h1:MLdR9424SJed+5VqC6MsouEpig9pZX2VZ57H9ko2bXU=
|
||||
@@ -1149,12 +1181,14 @@ google.golang.org/genproto/googleapis/api v0.0.0-20241202173237-19429a94021a/go.
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20241219192143-6b3ec007d9bb/go.mod h1:E5//3O5ZIG2l71Xnt+P/CYUY8Bxs8E7WMoZ9tlcMbAY=
|
||||
google.golang.org/genproto/googleapis/bytestream v0.0.0-20250102185135-69823020774d h1:NZBSeFsuFS5YrgHMW/8xfTbzNXMshQPNgq2Yb7xipEs=
|
||||
google.golang.org/genproto/googleapis/bytestream v0.0.0-20250102185135-69823020774d/go.mod h1:s4mHJ3FfG8P6A3O+gZ8TVqB3ufjOl9UG3ANCMMwCHmo=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98/go.mod h1:TUfxEVdsvPg18p6AslUXFoLdpED4oBnGwyqk3dV1XzM=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241219192143-6b3ec007d9bb/go.mod h1:lcTa1sDdWEIHMWlITnIczmw5w60CF9ffkb8Z+DVmmjA=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250106144421-5f5ef82da422/go.mod h1:3ENsm/5D1mzDyhpzeRi1NR784I0BcofWBoSc5QqqMK4=
|
||||
google.golang.org/grpc v1.58.3/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0=
|
||||
google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ=
|
||||
google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA=
|
||||
google.golang.org/grpc v1.69.2/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4=
|
||||
@@ -1185,6 +1219,8 @@ k8s.io/gengo/v2 v2.0.0-20240911193312-2b36238f13e9 h1:si3PfKm8dDYxgfbeA6orqrtLkv
|
||||
k8s.io/gengo/v2 v2.0.0-20240911193312-2b36238f13e9/go.mod h1:EJykeLsmFC60UQbYJezXkEsG2FLrt0GPNkU5iK5GWxU=
|
||||
k8s.io/klog v1.0.0 h1:Pt+yjF5aB1xDSVbau4VsWe+dQNzA0qv1LlXdC2dF6Q8=
|
||||
k8s.io/klog v1.0.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I=
|
||||
k8s.io/klog/v2 v2.80.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0=
|
||||
k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
||||
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
|
||||
modernc.org/cc/v3 v3.36.3 h1:uISP3F66UlixxWEcKuIWERa4TwrZENHSL8tWxZz8bHg=
|
||||
modernc.org/ccgo/v3 v3.16.9 h1:AXquSwg7GuMk11pIdw7fmO1Y/ybgazVkMhsZWCV0mHM=
|
||||
|
||||
@@ -391,9 +391,16 @@ export class PrometheusDatasource
|
||||
// - Issue that led to the introduction of utcOffsetSec: https://github.com/grafana/grafana/issues/17278
|
||||
// - Implementation PR: https://github.com/grafana/grafana/pull/17477
|
||||
let utcOffset = request.range.to.utcOffset();
|
||||
if (request.timezone !== 'browser') {
|
||||
if (request.timezone === 'browser') {
|
||||
// we need to check if the request is a relative or absolute range.
|
||||
// if it is absolute time range then utcOffset must be 0. we don't care the offset
|
||||
// because we are already sending the from and to values in utc. we don't need to adjust them again
|
||||
// for relative ranges we need utcOffset to adjust query range.
|
||||
utcOffset = this.isUsingRelativeTimeRange(request.range) ? utcOffset : 0;
|
||||
} else {
|
||||
utcOffset = tz(request.timezone).utcOffset();
|
||||
}
|
||||
|
||||
const processedTargets: PromQuery[] = [];
|
||||
const processedTarget = {
|
||||
...target,
|
||||
@@ -995,6 +1002,14 @@ export class PrometheusDatasource
|
||||
};
|
||||
}
|
||||
|
||||
isUsingRelativeTimeRange(range: TimeRange): boolean {
|
||||
if (typeof range.raw.from !== 'string' || typeof range.raw.to !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return range.raw.from.includes('now') || range.raw.to.includes('now');
|
||||
}
|
||||
|
||||
getDebounceTimeInMilliseconds(): number {
|
||||
switch (this.cacheLevel) {
|
||||
case PrometheusCacheLevel.Medium:
|
||||
|
||||
@@ -45,9 +45,10 @@ export interface Props {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
/** Tabs */
|
||||
tabs?: React.ReactNode;
|
||||
// TODO remove this prop next major version
|
||||
/**
|
||||
* @deprecated this is now default behaviour. content is always scrollable.
|
||||
* Whether the content should be wrapped in a ScrollContainer
|
||||
* Only change this if you intend to manage scroll behaviour yourself
|
||||
* (e.g. having a split pane with independent scrolling)
|
||||
**/
|
||||
scrollableContent?: boolean;
|
||||
/** Callback for closing the drawer */
|
||||
@@ -337,6 +338,7 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
padding: theme.spacing(2),
|
||||
height: '100%',
|
||||
flexGrow: 1,
|
||||
minHeight: 0,
|
||||
}),
|
||||
tabsWrapper: css({
|
||||
label: 'drawer-tabs',
|
||||
|
||||
@@ -41,6 +41,7 @@ func (hs *HTTPServer) GetAnnotations(c *contextmodel.ReqContext) response.Respon
|
||||
OrgID: c.SignedInUser.GetOrgID(),
|
||||
UserID: c.QueryInt64("userId"),
|
||||
AlertID: c.QueryInt64("alertId"),
|
||||
AlertUID: c.Query("alertUID"),
|
||||
DashboardID: c.QueryInt64("dashboardId"),
|
||||
DashboardUID: c.Query("dashboardUID"),
|
||||
PanelID: c.QueryInt64("panelId"),
|
||||
@@ -744,10 +745,15 @@ type GetAnnotationsParams struct {
|
||||
// in:query
|
||||
// required:false
|
||||
UserID int64 `json:"userId"`
|
||||
// Find annotations for a specified alert.
|
||||
// Find annotations for a specified alert rule by its ID.
|
||||
// deprecated: AlertID is deprecated and will be removed in future versions. Please use AlertUID instead.
|
||||
// in:query
|
||||
// required:false
|
||||
AlertID int64 `json:"alertId"`
|
||||
// Find annotations for a specified alert rule by its UID.
|
||||
// in:query
|
||||
// required:false
|
||||
AlertUID string `json:"alertUID"`
|
||||
// Find annotations that are scoped to a specific dashboard
|
||||
// in:query
|
||||
// required:false
|
||||
|
||||
@@ -48,9 +48,9 @@ func (rangedEvaluator) Kind() EvaluatorKind {
|
||||
// an AlertEvaluator depending on evaluation operator.
|
||||
func newAlertEvaluator(model ConditionEvalJSON) (evaluator, error) {
|
||||
switch model.Type {
|
||||
case "gt", "lt":
|
||||
case "gt", "lt", "eq", "ne", "gte", "lte":
|
||||
return newThresholdEvaluator(model)
|
||||
case "within_range", "outside_range":
|
||||
case "within_range", "outside_range", "within_range_included", "outside_range_included":
|
||||
return newRangedEvaluator(model)
|
||||
case "no_value":
|
||||
return &noValueEvaluator{}, nil
|
||||
@@ -70,6 +70,14 @@ func (e *thresholdEvaluator) Eval(reducedValue mathexp.Number) bool {
|
||||
return *fv > e.Threshold
|
||||
case "lt":
|
||||
return *fv < e.Threshold
|
||||
case "eq":
|
||||
return *fv == e.Threshold
|
||||
case "ne":
|
||||
return *fv != e.Threshold
|
||||
case "gte":
|
||||
return *fv >= e.Threshold
|
||||
case "lte":
|
||||
return *fv <= e.Threshold
|
||||
}
|
||||
|
||||
return false
|
||||
@@ -113,6 +121,10 @@ func (e *rangedEvaluator) Eval(reducedValue mathexp.Number) bool {
|
||||
return (e.Lower < *fv && e.Upper > *fv) || (e.Upper < *fv && e.Lower > *fv)
|
||||
case "outside_range":
|
||||
return (e.Upper < *fv && e.Lower < *fv) || (e.Upper > *fv && e.Lower > *fv)
|
||||
case "within_range_included":
|
||||
return (e.Lower <= *fv && e.Upper >= *fv) || (e.Upper <= *fv && e.Lower >= *fv)
|
||||
case "outside_range_included":
|
||||
return (e.Upper <= *fv && e.Lower <= *fv) || (e.Upper >= *fv && e.Lower >= *fv)
|
||||
}
|
||||
|
||||
return false
|
||||
|
||||
@@ -40,6 +40,48 @@ func TestThresholdEvaluator(t *testing.T) {
|
||||
inputNumber: newNumber(util.Pointer(1.0)),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "value 1 is eq 1: false",
|
||||
evaluator: &thresholdEvaluator{"eq", 1},
|
||||
inputNumber: newNumber(util.Pointer(1.0)),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "value 0 is eq 0: false",
|
||||
evaluator: &thresholdEvaluator{"eq", 0},
|
||||
inputNumber: newNumber(util.Pointer(0.0)),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "value 1 is eq 0: false",
|
||||
evaluator: &thresholdEvaluator{"eq", 0},
|
||||
inputNumber: newNumber(util.Pointer(1.0)),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "value 0 is eq 1: false",
|
||||
evaluator: &thresholdEvaluator{"eq", 1},
|
||||
inputNumber: newNumber(util.Pointer(0.0)),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "value 1 is ne 1: false",
|
||||
evaluator: &thresholdEvaluator{"ne", 1},
|
||||
inputNumber: newNumber(util.Pointer(1.0)),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "value 3 is gte 3: false",
|
||||
evaluator: &thresholdEvaluator{"gte", 3},
|
||||
inputNumber: newNumber(util.Pointer(3.0)),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "value 5 is lte 4: false",
|
||||
evaluator: &thresholdEvaluator{"lte", 4},
|
||||
inputNumber: newNumber(util.Pointer(5.0)),
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
@@ -712,8 +712,14 @@
|
||||
"enum": [
|
||||
"gt",
|
||||
"lt",
|
||||
"eq",
|
||||
"ne",
|
||||
"gte",
|
||||
"lte",
|
||||
"within_range",
|
||||
"outside_range"
|
||||
"outside_range",
|
||||
"within_range_included",
|
||||
"outside_range_included"
|
||||
],
|
||||
"x-enum-description": {}
|
||||
}
|
||||
@@ -744,8 +750,14 @@
|
||||
"enum": [
|
||||
"gt",
|
||||
"lt",
|
||||
"eq",
|
||||
"ne",
|
||||
"gte",
|
||||
"lte",
|
||||
"within_range",
|
||||
"outside_range"
|
||||
"outside_range",
|
||||
"within_range_included",
|
||||
"outside_range_included"
|
||||
],
|
||||
"x-enum-description": {}
|
||||
}
|
||||
@@ -1013,4 +1025,4 @@
|
||||
},
|
||||
"additionalProperties": true,
|
||||
"$schema": "https://json-schema.org/draft-04/schema#"
|
||||
}
|
||||
}
|
||||
@@ -754,8 +754,14 @@
|
||||
"enum": [
|
||||
"gt",
|
||||
"lt",
|
||||
"eq",
|
||||
"ne",
|
||||
"gte",
|
||||
"lte",
|
||||
"within_range",
|
||||
"outside_range"
|
||||
"outside_range",
|
||||
"within_range_included",
|
||||
"outside_range_included"
|
||||
],
|
||||
"x-enum-description": {}
|
||||
}
|
||||
@@ -786,8 +792,14 @@
|
||||
"enum": [
|
||||
"gt",
|
||||
"lt",
|
||||
"eq",
|
||||
"ne",
|
||||
"gte",
|
||||
"lte",
|
||||
"within_range",
|
||||
"outside_range"
|
||||
"outside_range",
|
||||
"within_range_included",
|
||||
"outside_range_included"
|
||||
],
|
||||
"x-enum-description": {}
|
||||
}
|
||||
@@ -1071,4 +1083,4 @@
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"$schema": "https://json-schema.org/draft-04/schema#"
|
||||
}
|
||||
}
|
||||
@@ -395,8 +395,14 @@
|
||||
"enum": [
|
||||
"gt",
|
||||
"lt",
|
||||
"eq",
|
||||
"ne",
|
||||
"gte",
|
||||
"lte",
|
||||
"within_range",
|
||||
"outside_range"
|
||||
"outside_range",
|
||||
"within_range_included",
|
||||
"outside_range_included"
|
||||
],
|
||||
"type": "string",
|
||||
"x-enum-description": {}
|
||||
@@ -427,8 +433,14 @@
|
||||
"enum": [
|
||||
"gt",
|
||||
"lt",
|
||||
"eq",
|
||||
"ne",
|
||||
"gte",
|
||||
"lte",
|
||||
"within_range",
|
||||
"outside_range"
|
||||
"outside_range",
|
||||
"within_range_included",
|
||||
"outside_range_included"
|
||||
],
|
||||
"type": "string",
|
||||
"x-enum-description": {}
|
||||
@@ -579,4 +591,4 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+96
-4
@@ -32,18 +32,30 @@ type ThresholdCommand struct {
|
||||
type ThresholdType string
|
||||
|
||||
const (
|
||||
ThresholdIsAbove ThresholdType = "gt"
|
||||
ThresholdIsBelow ThresholdType = "lt"
|
||||
ThresholdIsWithinRange ThresholdType = "within_range"
|
||||
ThresholdIsOutsideRange ThresholdType = "outside_range"
|
||||
ThresholdIsAbove ThresholdType = "gt"
|
||||
ThresholdIsBelow ThresholdType = "lt"
|
||||
ThresholdIsEqual ThresholdType = "eq"
|
||||
ThresholdIsNotEqual ThresholdType = "ne"
|
||||
ThresholdIsGreaterThanEqual ThresholdType = "gte"
|
||||
ThresholdIsLessThanEqual ThresholdType = "lte"
|
||||
ThresholdIsWithinRange ThresholdType = "within_range"
|
||||
ThresholdIsOutsideRange ThresholdType = "outside_range"
|
||||
ThresholdIsWithinRangeIncluded ThresholdType = "within_range_included"
|
||||
ThresholdIsOutsideRangeIncluded ThresholdType = "outside_range_included"
|
||||
)
|
||||
|
||||
var (
|
||||
supportedThresholdFuncs = []string{
|
||||
string(ThresholdIsAbove),
|
||||
string(ThresholdIsBelow),
|
||||
string(ThresholdIsEqual),
|
||||
string(ThresholdIsNotEqual),
|
||||
string(ThresholdIsGreaterThanEqual),
|
||||
string(ThresholdIsLessThanEqual),
|
||||
string(ThresholdIsWithinRange),
|
||||
string(ThresholdIsOutsideRange),
|
||||
string(ThresholdIsWithinRangeIncluded),
|
||||
string(ThresholdIsOutsideRangeIncluded),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -60,6 +72,16 @@ func NewThresholdCommand(refID, referenceVar string, thresholdFunc ThresholdType
|
||||
return nil, fmt.Errorf("incorrect number of arguments for threshold function '%s': got %d but need 2", thresholdFunc, len(conditions))
|
||||
}
|
||||
predicate = withinRangePredicate{left: conditions[0], right: conditions[1]}
|
||||
case ThresholdIsWithinRangeIncluded:
|
||||
if len(conditions) < 2 {
|
||||
return nil, fmt.Errorf("incorrect number of arguments for threshold function '%s': got %d but need 2", thresholdFunc, len(conditions))
|
||||
}
|
||||
predicate = withinRangeIncludedPredicate{left: conditions[0], right: conditions[1]}
|
||||
case ThresholdIsOutsideRangeIncluded:
|
||||
if len(conditions) < 2 {
|
||||
return nil, fmt.Errorf("incorrect number of arguments for threshold function '%s': got %d but need 2", thresholdFunc, len(conditions))
|
||||
}
|
||||
predicate = outsideRangeIncludedPredicate{left: conditions[0], right: conditions[1]}
|
||||
case ThresholdIsAbove:
|
||||
if len(conditions) < 1 {
|
||||
return nil, fmt.Errorf("incorrect number of arguments for threshold function '%s': got %d but need 1", thresholdFunc, len(conditions))
|
||||
@@ -70,6 +92,26 @@ func NewThresholdCommand(refID, referenceVar string, thresholdFunc ThresholdType
|
||||
return nil, fmt.Errorf("incorrect number of arguments for threshold function '%s': got %d but need 1", thresholdFunc, len(conditions))
|
||||
}
|
||||
predicate = lessThanPredicate{value: conditions[0]}
|
||||
case ThresholdIsEqual:
|
||||
if len(conditions) < 1 {
|
||||
return nil, fmt.Errorf("incorrect number of arguments for threshold function '%s': got %d but need 1", thresholdFunc, len(conditions))
|
||||
}
|
||||
predicate = equalPredicate{value: conditions[0]}
|
||||
case ThresholdIsNotEqual:
|
||||
if len(conditions) < 1 {
|
||||
return nil, fmt.Errorf("incorrect number of arguments for threshold function '%s': got %d but need 1", thresholdFunc, len(conditions))
|
||||
}
|
||||
predicate = notEqualPredicate{value: conditions[0]}
|
||||
case ThresholdIsGreaterThanEqual:
|
||||
if len(conditions) < 1 {
|
||||
return nil, fmt.Errorf("incorrect number of arguments for threshold function '%s': got %d but need 1", thresholdFunc, len(conditions))
|
||||
}
|
||||
predicate = greaterThanEqualPredicate{value: conditions[0]}
|
||||
case ThresholdIsLessThanEqual:
|
||||
if len(conditions) < 1 {
|
||||
return nil, fmt.Errorf("incorrect number of arguments for threshold function '%s': got %d but need 1", thresholdFunc, len(conditions))
|
||||
}
|
||||
predicate = lessThanEqualPredicate{value: conditions[0]}
|
||||
default:
|
||||
return nil, fmt.Errorf("expected threshold function to be one of [%s], got %s", strings.Join(supportedThresholdFuncs, ", "), thresholdFunc)
|
||||
}
|
||||
@@ -279,6 +321,24 @@ func (r outsideRangePredicate) Eval(f float64) bool {
|
||||
return f < r.left || f > r.right
|
||||
}
|
||||
|
||||
type withinRangeIncludedPredicate struct {
|
||||
left float64
|
||||
right float64
|
||||
}
|
||||
|
||||
func (r withinRangeIncludedPredicate) Eval(f float64) bool {
|
||||
return f >= r.left && f <= r.right
|
||||
}
|
||||
|
||||
type outsideRangeIncludedPredicate struct {
|
||||
left float64
|
||||
right float64
|
||||
}
|
||||
|
||||
func (r outsideRangeIncludedPredicate) Eval(f float64) bool {
|
||||
return f <= r.left || f >= r.right
|
||||
}
|
||||
|
||||
type lessThanPredicate struct {
|
||||
value float64
|
||||
}
|
||||
@@ -294,3 +354,35 @@ type greaterThanPredicate struct {
|
||||
func (r greaterThanPredicate) Eval(f float64) bool {
|
||||
return f > r.value
|
||||
}
|
||||
|
||||
type equalPredicate struct {
|
||||
value float64
|
||||
}
|
||||
|
||||
func (r equalPredicate) Eval(f float64) bool {
|
||||
return f == r.value
|
||||
}
|
||||
|
||||
type notEqualPredicate struct {
|
||||
value float64
|
||||
}
|
||||
|
||||
func (r notEqualPredicate) Eval(f float64) bool {
|
||||
return f != r.value
|
||||
}
|
||||
|
||||
type greaterThanEqualPredicate struct {
|
||||
value float64
|
||||
}
|
||||
|
||||
func (r greaterThanEqualPredicate) Eval(f float64) bool {
|
||||
return f >= r.value
|
||||
}
|
||||
|
||||
type lessThanEqualPredicate struct {
|
||||
value float64
|
||||
}
|
||||
|
||||
func (r lessThanEqualPredicate) Eval(f float64) bool {
|
||||
return f <= r.value
|
||||
}
|
||||
@@ -38,6 +38,26 @@ func TestNewThresholdCommand(t *testing.T) {
|
||||
args: []float64{0},
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
fn: "eq",
|
||||
args: []float64{0},
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
fn: "ne",
|
||||
args: []float64{0},
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
fn: "gte",
|
||||
args: []float64{0},
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
fn: "lte",
|
||||
args: []float64{0},
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
fn: "within_range",
|
||||
args: []float64{0, 1},
|
||||
@@ -48,6 +68,16 @@ func TestNewThresholdCommand(t *testing.T) {
|
||||
args: []float64{0, 1},
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
fn: "within_range_included",
|
||||
args: []float64{0, 1},
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
fn: "outside_range_included",
|
||||
args: []float64{0, 1},
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
fn: "gt",
|
||||
args: []float64{},
|
||||
@@ -60,6 +90,30 @@ func TestNewThresholdCommand(t *testing.T) {
|
||||
shouldError: true,
|
||||
expectedError: "incorrect number of arguments",
|
||||
},
|
||||
{
|
||||
fn: "eq",
|
||||
args: []float64{},
|
||||
shouldError: true,
|
||||
expectedError: "incorrect number of arguments",
|
||||
},
|
||||
{
|
||||
fn: "ne",
|
||||
args: []float64{},
|
||||
shouldError: true,
|
||||
expectedError: "incorrect number of arguments",
|
||||
},
|
||||
{
|
||||
fn: "gte",
|
||||
args: []float64{},
|
||||
shouldError: true,
|
||||
expectedError: "incorrect number of arguments",
|
||||
},
|
||||
{
|
||||
fn: "lte",
|
||||
args: []float64{},
|
||||
shouldError: true,
|
||||
expectedError: "incorrect number of arguments",
|
||||
},
|
||||
{
|
||||
fn: "within_range",
|
||||
args: []float64{0},
|
||||
@@ -72,6 +126,18 @@ func TestNewThresholdCommand(t *testing.T) {
|
||||
shouldError: true,
|
||||
expectedError: "incorrect number of arguments",
|
||||
},
|
||||
{
|
||||
fn: "within_range_included",
|
||||
args: []float64{0},
|
||||
shouldError: true,
|
||||
expectedError: "incorrect number of arguments",
|
||||
},
|
||||
{
|
||||
fn: "outside_range_included",
|
||||
args: []float64{0},
|
||||
shouldError: true,
|
||||
expectedError: "incorrect number of arguments",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
@@ -249,6 +315,22 @@ func TestIsSupportedThresholdFunc(t *testing.T) {
|
||||
function: ThresholdIsBelow,
|
||||
supported: true,
|
||||
},
|
||||
{
|
||||
function: ThresholdIsEqual,
|
||||
supported: true,
|
||||
},
|
||||
{
|
||||
function: ThresholdIsNotEqual,
|
||||
supported: true,
|
||||
},
|
||||
{
|
||||
function: ThresholdIsGreaterThanEqual,
|
||||
supported: true,
|
||||
},
|
||||
{
|
||||
function: ThresholdIsLessThanEqual,
|
||||
supported: true,
|
||||
},
|
||||
{
|
||||
function: ThresholdIsWithinRange,
|
||||
supported: true,
|
||||
@@ -257,6 +339,14 @@ func TestIsSupportedThresholdFunc(t *testing.T) {
|
||||
function: ThresholdIsOutsideRange,
|
||||
supported: true,
|
||||
},
|
||||
{
|
||||
function: ThresholdIsWithinRangeIncluded,
|
||||
supported: true,
|
||||
},
|
||||
{
|
||||
function: ThresholdIsOutsideRangeIncluded,
|
||||
supported: true,
|
||||
},
|
||||
{
|
||||
function: "foo",
|
||||
supported: false,
|
||||
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
_ "github.com/grpc-ecosystem/go-grpc-middleware/v2"
|
||||
_ "github.com/hashicorp/go-multierror"
|
||||
_ "github.com/hashicorp/golang-lru/v2"
|
||||
_ "github.com/linkedin/goavro/v2"
|
||||
_ "github.com/m3db/prometheus_remote_client_golang/promremote"
|
||||
_ "github.com/phpdave11/gofpdi"
|
||||
_ "github.com/robfig/cron/v3"
|
||||
|
||||
@@ -34,13 +34,13 @@ func ProvideRegistryServiceSink(
|
||||
investigationAppProvider *investigations.InvestigationsAppProvider,
|
||||
advisorAppProvider *advisor.AdvisorAppProvider,
|
||||
) (*Service, error) {
|
||||
cfgWrapper := func(ctx context.Context) *rest.Config {
|
||||
cfg := restConfigProvider.GetRestConfig(ctx)
|
||||
if cfg == nil {
|
||||
return nil
|
||||
cfgWrapper := func(ctx context.Context) (*rest.Config, error) {
|
||||
cfg, err := restConfigProvider.GetRestConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.APIPath = "/apis"
|
||||
return cfg
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
cfg := runner.RunnerConfig{
|
||||
|
||||
@@ -91,20 +91,22 @@ func (r *LokiHistorianStore) Get(ctx context.Context, query annotations.ItemQuer
|
||||
return make([]*annotations.ItemDTO, 0), nil
|
||||
}
|
||||
|
||||
rule := &ngmodels.AlertRule{}
|
||||
if query.AlertID != 0 {
|
||||
var err error
|
||||
rule, err = r.ruleStore.GetRuleByID(ctx, ngmodels.GetAlertRuleByIDQuery{OrgID: query.OrgID, ID: query.AlertID})
|
||||
var ruleUID string
|
||||
if query.AlertUID != "" {
|
||||
ruleUID = query.AlertUID
|
||||
} else if query.AlertID != 0 {
|
||||
rule, err := r.ruleStore.GetRuleByID(ctx, ngmodels.GetAlertRuleByIDQuery{OrgID: query.OrgID, ID: query.AlertID})
|
||||
if err != nil {
|
||||
if errors.Is(err, ngmodels.ErrAlertRuleNotFound) {
|
||||
return make([]*annotations.ItemDTO, 0), ErrLokiStoreNotFound.Errorf("rule with ID %d does not exist", query.AlertID)
|
||||
}
|
||||
return make([]*annotations.ItemDTO, 0), ErrLokiStoreInternal.Errorf("failed to query rule: %w", err)
|
||||
}
|
||||
ruleUID = rule.UID
|
||||
}
|
||||
|
||||
// No folders in the filter because it filter by Dashboard UID, and the request is already authorized.
|
||||
logQL, err := historian.BuildLogQuery(buildHistoryQuery(&query, accessResources.Dashboards, rule.UID), nil, r.client.MaxQuerySize())
|
||||
logQL, err := historian.BuildLogQuery(buildHistoryQuery(&query, accessResources.Dashboards, ruleUID), nil, r.client.MaxQuerySize())
|
||||
if err != nil {
|
||||
grafanaErr := errutil.Error{}
|
||||
if errors.As(err, &grafanaErr) {
|
||||
|
||||
@@ -108,7 +108,34 @@ func TestIntegrationAlertStateHistoryStore(t *testing.T) {
|
||||
require.Len(t, res, numTransitions)
|
||||
})
|
||||
|
||||
t.Run("should return ErrLokiStoreNotFound if rule is not found", func(t *testing.T) {
|
||||
t.Run("can query history by alert uid", func(t *testing.T) {
|
||||
rule := dashboardRules[dashboard1.UID][0]
|
||||
|
||||
fakeLokiClient.rangeQueryRes = []historian.Stream{
|
||||
historian.StatesToStream(ruleMetaFromRule(t, rule), transitions, map[string]string{}, log.NewNopLogger()),
|
||||
}
|
||||
|
||||
query := annotations.ItemQuery{
|
||||
OrgID: 1,
|
||||
AlertUID: rule.UID,
|
||||
From: start.UnixMilli(),
|
||||
To: start.Add(time.Second * time.Duration(numTransitions+1)).UnixMilli(),
|
||||
}
|
||||
res, err := store.Get(
|
||||
context.Background(),
|
||||
query,
|
||||
&annotation_ac.AccessResources{
|
||||
Dashboards: map[string]int64{
|
||||
dashboard1.UID: dashboard1.ID,
|
||||
},
|
||||
CanAccessDashAnnotations: true,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res, numTransitions)
|
||||
})
|
||||
|
||||
t.Run("should return ErrLokiStoreNotFound if rule is not found by ID", func(t *testing.T) {
|
||||
var rules = slices.Concat(maps.Values(dashboardRules)...)
|
||||
id := rand.Int63n(1000) // in Postgres ID is integer, so limit range
|
||||
// make sure id is not known
|
||||
@@ -137,6 +164,27 @@ func TestIntegrationAlertStateHistoryStore(t *testing.T) {
|
||||
require.ErrorIs(t, err, ErrLokiStoreNotFound)
|
||||
})
|
||||
|
||||
t.Run("should return empty response if rule is not found by UID", func(t *testing.T) {
|
||||
query := annotations.ItemQuery{
|
||||
OrgID: 1,
|
||||
AlertUID: "not-found-uid",
|
||||
From: start.UnixMilli(),
|
||||
To: start.Add(time.Second * time.Duration(numTransitions+1)).UnixMilli(),
|
||||
}
|
||||
res, err := store.Get(
|
||||
context.Background(),
|
||||
query,
|
||||
&annotation_ac.AccessResources{
|
||||
Dashboards: map[string]int64{
|
||||
dashboard1.UID: dashboard1.ID,
|
||||
},
|
||||
CanAccessDashAnnotations: true,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, res)
|
||||
})
|
||||
|
||||
t.Run("can query history by dashboard id", func(t *testing.T) {
|
||||
fakeLokiClient.rangeQueryRes = []historian.Stream{
|
||||
historian.StatesToStream(ruleMetaFromRule(t, dashboardRules[dashboard1.UID][0]), transitions, map[string]string{}, log.NewNopLogger()),
|
||||
|
||||
@@ -267,10 +267,10 @@ func (r *xormRepositoryImpl) Get(ctx context.Context, query annotations.ItemQuer
|
||||
annotation.updated,
|
||||
usr.email,
|
||||
usr.login,
|
||||
alert.name as alert_name
|
||||
r.title as alert_name
|
||||
FROM annotation
|
||||
LEFT OUTER JOIN ` + r.db.GetDialect().Quote("user") + ` as usr on usr.id = annotation.user_id
|
||||
LEFT OUTER JOIN alert on alert.id = annotation.alert_id
|
||||
LEFT OUTER JOIN alert_rule as r on r.id = annotation.alert_id
|
||||
INNER JOIN (
|
||||
SELECT a.id from annotation a
|
||||
`)
|
||||
@@ -287,6 +287,9 @@ func (r *xormRepositoryImpl) Get(ctx context.Context, query annotations.ItemQuer
|
||||
if query.AlertID != 0 {
|
||||
sql.WriteString(` AND a.alert_id = ?`)
|
||||
params = append(params, query.AlertID)
|
||||
} else if query.AlertUID != "" {
|
||||
sql.WriteString(` AND a.alert_id = (SELECT id FROM alert_rule WHERE uid = ? and org_id = ?)`)
|
||||
params = append(params, query.AlertUID, query.OrgID)
|
||||
}
|
||||
|
||||
if query.DashboardID != 0 {
|
||||
|
||||
@@ -11,6 +11,7 @@ type ItemQuery struct {
|
||||
To int64 `json:"to"`
|
||||
UserID int64 `json:"userId"`
|
||||
AlertID int64 `json:"alertId"`
|
||||
AlertUID string `json:"alertUID"`
|
||||
DashboardID int64 `json:"dashboardId"`
|
||||
DashboardUID string `json:"dashboardUID"`
|
||||
PanelID int64 `json:"panelId"`
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
)
|
||||
|
||||
type RunnerConfig struct {
|
||||
RestConfigGetter func(context.Context) *rest.Config
|
||||
RestConfigGetter func(context.Context) (*rest.Config, error)
|
||||
APIRegistrar builder.APIRegistrar
|
||||
}
|
||||
|
||||
@@ -49,9 +49,9 @@ func (r *APIGroupRunner) Run(ctx context.Context) error {
|
||||
|
||||
func (r *APIGroupRunner) Init(ctx context.Context) error {
|
||||
defer close(r.initialized)
|
||||
restConfig := r.config.RestConfigGetter(ctx)
|
||||
if restConfig == nil {
|
||||
return fmt.Errorf("rest config is nil")
|
||||
restConfig, err := r.config.RestConfigGetter(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range r.groups {
|
||||
appConfig := app.Config{
|
||||
|
||||
@@ -11,11 +11,10 @@ import (
|
||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/client-go/dynamic"
|
||||
"k8s.io/client-go/rest"
|
||||
|
||||
k8sUser "k8s.io/apiserver/pkg/authentication/user"
|
||||
k8sRequest "k8s.io/apiserver/pkg/endpoints/request"
|
||||
"k8s.io/client-go/dynamic"
|
||||
"k8s.io/client-go/rest"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
@@ -46,13 +45,13 @@ var _ K8sHandler = (*k8sHandler)(nil)
|
||||
type k8sHandler struct {
|
||||
namespacer request.NamespaceMapper
|
||||
gvr schema.GroupVersionResource
|
||||
restConfig func(context.Context) *rest.Config
|
||||
restConfig func(context.Context) (*rest.Config, error)
|
||||
searcher resource.ResourceIndexClient
|
||||
userService user.Service
|
||||
}
|
||||
|
||||
func NewK8sHandler(dual dualwrite.Service, namespacer request.NamespaceMapper, gvr schema.GroupVersionResource,
|
||||
restConfig func(context.Context) *rest.Config, dashStore dashboards.Store, userSvc user.Service, resourceClient resource.ResourceClient, sorter sort.Service) K8sHandler {
|
||||
restConfig func(context.Context) (*rest.Config, error), dashStore dashboards.Store, userSvc user.Service, resourceClient resource.ResourceClient, sorter sort.Service) K8sHandler {
|
||||
legacySearcher := legacysearcher.NewDashboardSearchClient(dashStore, sorter)
|
||||
searchClient := resource.NewSearchClient(dual, gvr.GroupResource(), resourceClient, legacySearcher)
|
||||
|
||||
@@ -79,9 +78,9 @@ func (h *k8sHandler) Get(ctx context.Context, name string, orgID int64, options
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
client, ok := h.getClient(newCtx, orgID)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
client, err := h.getClient(newCtx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return client.Get(newCtx, name, options, subresource...)
|
||||
@@ -97,9 +96,9 @@ func (h *k8sHandler) Create(ctx context.Context, obj *unstructured.Unstructured,
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
client, ok := h.getClient(newCtx, orgID)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
client, err := h.getClient(newCtx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return client.Create(newCtx, obj, v1.CreateOptions{})
|
||||
@@ -115,9 +114,9 @@ func (h *k8sHandler) Update(ctx context.Context, obj *unstructured.Unstructured,
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
client, ok := h.getClient(newCtx, orgID)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
client, err := h.getClient(newCtx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return client.Update(newCtx, obj, v1.UpdateOptions{})
|
||||
@@ -133,9 +132,9 @@ func (h *k8sHandler) Delete(ctx context.Context, name string, orgID int64, optio
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
client, ok := h.getClient(newCtx, orgID)
|
||||
if !ok {
|
||||
return nil
|
||||
client, err := h.getClient(newCtx, orgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return client.Delete(newCtx, name, options)
|
||||
@@ -151,9 +150,9 @@ func (h *k8sHandler) DeleteCollection(ctx context.Context, orgID int64) error {
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
client, ok := h.getClient(newCtx, orgID)
|
||||
if !ok {
|
||||
return fmt.Errorf("could not get k8s client")
|
||||
client, err := h.getClient(newCtx, orgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return client.DeleteCollection(newCtx, v1.DeleteOptions{}, v1.ListOptions{})
|
||||
@@ -169,9 +168,9 @@ func (h *k8sHandler) List(ctx context.Context, orgID int64, options v1.ListOptio
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
client, ok := h.getClient(newCtx, orgID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("could not get k8s client")
|
||||
client, err := h.getClient(newCtx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return client.List(newCtx, options)
|
||||
@@ -226,18 +225,18 @@ func (h *k8sHandler) GetUserFromMeta(ctx context.Context, userMeta string) (*use
|
||||
return u, err
|
||||
}
|
||||
|
||||
func (h *k8sHandler) getClient(ctx context.Context, orgID int64) (dynamic.ResourceInterface, bool) {
|
||||
cfg := h.restConfig(ctx)
|
||||
if cfg == nil {
|
||||
return nil, false
|
||||
func (h *k8sHandler) getClient(ctx context.Context, orgID int64) (dynamic.ResourceInterface, error) {
|
||||
cfg, err := h.restConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dyn, err := dynamic.NewForConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
return nil, fmt.Errorf("could not create dynamic client: %w", err)
|
||||
}
|
||||
|
||||
return dyn.Resource(h.gvr).Namespace(h.GetNamespace(orgID)), true
|
||||
return dyn.Resource(h.gvr).Namespace(h.GetNamespace(orgID)), nil
|
||||
}
|
||||
|
||||
func (h *k8sHandler) getK8sContext(ctx context.Context) (context.Context, context.CancelFunc, error) {
|
||||
|
||||
@@ -94,6 +94,6 @@ type MockTestRestConfig struct {
|
||||
cfg *rest.Config
|
||||
}
|
||||
|
||||
func (r MockTestRestConfig) GetRestConfig(ctx context.Context) *rest.Config {
|
||||
return r.cfg
|
||||
func (r MockTestRestConfig) GetRestConfig(ctx context.Context) (*rest.Config, error) {
|
||||
return r.cfg, nil
|
||||
}
|
||||
@@ -11,8 +11,10 @@ import (
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apimachinery/pkg/runtime/serializer"
|
||||
genericapifilters "k8s.io/apiserver/pkg/endpoints/filters"
|
||||
"k8s.io/apiserver/pkg/endpoints/responsewriter"
|
||||
genericapiserver "k8s.io/apiserver/pkg/server"
|
||||
"k8s.io/apiserver/pkg/util/notfoundhandler"
|
||||
clientrest "k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
aggregatorapiserver "k8s.io/kube-aggregator/pkg/apiserver"
|
||||
@@ -90,7 +92,7 @@ func init() {
|
||||
// The client Config gets initialized during the first call to
|
||||
// ProvideService.
|
||||
// Any call to GetRestConfig will block until we have a restConfig available
|
||||
func GetRestConfig(ctx context.Context) *clientrest.Config {
|
||||
func GetRestConfig(ctx context.Context) (*clientrest.Config, error) {
|
||||
<-ready
|
||||
return restConfig.GetRestConfig(ctx)
|
||||
}
|
||||
@@ -102,7 +104,7 @@ type Service interface {
|
||||
}
|
||||
|
||||
type RestConfigProvider interface {
|
||||
GetRestConfig(context.Context) *clientrest.Config
|
||||
GetRestConfig(context.Context) (*clientrest.Config, error)
|
||||
}
|
||||
|
||||
type DirectRestConfigProvider interface {
|
||||
@@ -242,11 +244,11 @@ func ProvideService(
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *service) GetRestConfig(ctx context.Context) *clientrest.Config {
|
||||
func (s *service) GetRestConfig(ctx context.Context) (*clientrest.Config, error) {
|
||||
if err := s.NamedService.AwaitRunning(ctx); err != nil {
|
||||
return nil
|
||||
return nil, fmt.Errorf("unable to get rest config: %w", err)
|
||||
}
|
||||
return s.restConfig
|
||||
return s.restConfig, nil
|
||||
}
|
||||
|
||||
func (s *service) IsDisabled() bool {
|
||||
@@ -363,8 +365,10 @@ func (s *service) start(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
notFoundHandler := notfoundhandler.New(Codecs, genericapifilters.NoMuxAndDiscoveryIncompleteKey)
|
||||
|
||||
// Create the server
|
||||
server, err := serverConfig.Complete().New("grafana-apiserver", genericapiserver.NewEmptyDelegate())
|
||||
server, err := serverConfig.Complete().New("grafana-apiserver", genericapiserver.NewEmptyDelegateWithCustomHandler(notFoundHandler))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ func RegisterRBACAuthZService(
|
||||
if folderAPIURL == "" {
|
||||
folderStore = store.NewSQLFolderStore(db, tracer)
|
||||
} else {
|
||||
folderStore = store.NewAPIFolderStore(tracer, func(ctx context.Context) *rest.Config {
|
||||
folderStore = store.NewAPIFolderStore(tracer, func(ctx context.Context) (*rest.Config, error) {
|
||||
return &rest.Config{
|
||||
Host: folderAPIURL,
|
||||
WrapTransport: func(rt http.RoundTripper) http.RoundTripper {
|
||||
@@ -167,7 +167,7 @@ func RegisterRBACAuthZService(
|
||||
},
|
||||
QPS: 50,
|
||||
Burst: 100,
|
||||
}
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -102,13 +102,13 @@ func (s *SQLFolderStore) ListFolders(ctx context.Context, ns types.NamespaceInfo
|
||||
|
||||
var _ FolderStore = (*APIFolderStore)(nil)
|
||||
|
||||
func NewAPIFolderStore(tracer tracing.Tracer, configProvider func(ctx context.Context) *rest.Config) *APIFolderStore {
|
||||
func NewAPIFolderStore(tracer tracing.Tracer, configProvider func(ctx context.Context) (*rest.Config, error)) *APIFolderStore {
|
||||
return &APIFolderStore{tracer, configProvider}
|
||||
}
|
||||
|
||||
type APIFolderStore struct {
|
||||
tracer tracing.Tracer
|
||||
configProvider func(ctx context.Context) *rest.Config
|
||||
configProvider func(ctx context.Context) (*rest.Config, error)
|
||||
}
|
||||
|
||||
func (s *APIFolderStore) ListFolders(ctx context.Context, ns types.NamespaceInfo) ([]Folder, error) {
|
||||
@@ -150,7 +150,11 @@ func (s *APIFolderStore) ListFolders(ctx context.Context, ns types.NamespaceInfo
|
||||
}
|
||||
|
||||
func (s *APIFolderStore) client(ctx context.Context, namespace string) (dynamic.ResourceInterface, error) {
|
||||
client, err := dynamic.NewForConfig(s.configProvider(ctx))
|
||||
cfg, err := s.configProvider(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client, err := dynamic.NewForConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1509,11 +1509,11 @@ var (
|
||||
},
|
||||
{
|
||||
Name: "exploreMetricsRelatedLogs",
|
||||
Description: "Display Related Logs in Explore Metrics",
|
||||
Description: "Display Related Logs in Grafana Metrics Drilldown",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaObservabilityMetricsSquad,
|
||||
FrontendOnly: true,
|
||||
HideFromDocs: true,
|
||||
HideFromDocs: false,
|
||||
},
|
||||
{
|
||||
Name: "prometheusSpecialCharsInLabelValues",
|
||||
|
||||
@@ -808,7 +808,7 @@ const (
|
||||
FlagPasswordlessMagicLinkAuthentication = "passwordlessMagicLinkAuthentication"
|
||||
|
||||
// FlagExploreMetricsRelatedLogs
|
||||
// Display Related Logs in Explore Metrics
|
||||
// Display Related Logs in Grafana Metrics Drilldown
|
||||
FlagExploreMetricsRelatedLogs = "exploreMetricsRelatedLogs"
|
||||
|
||||
// FlagPrometheusSpecialCharsInLabelValues
|
||||
|
||||
@@ -1609,15 +1609,17 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "exploreMetricsRelatedLogs",
|
||||
"resourceVersion": "1730125602673",
|
||||
"creationTimestamp": "2024-11-05T16:28:43Z"
|
||||
"resourceVersion": "1740088730007",
|
||||
"creationTimestamp": "2024-11-05T16:28:43Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2025-02-20 21:58:50.007589 +0000 UTC"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"description": "Display Related Logs in Explore Metrics",
|
||||
"description": "Display Related Logs in Grafana Metrics Drilldown",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/observability-metrics",
|
||||
"frontend": true,
|
||||
"hideFromDocs": true
|
||||
"frontend": true
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -48,10 +48,10 @@ type rcp struct {
|
||||
Host string
|
||||
}
|
||||
|
||||
func (r rcp) GetRestConfig(ctx context.Context) *clientrest.Config {
|
||||
func (r rcp) GetRestConfig(ctx context.Context) (*clientrest.Config, error) {
|
||||
return &clientrest.Config{
|
||||
Host: r.Host,
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestIntegrationFolderServiceViaUnifiedStorage(t *testing.T) {
|
||||
|
||||
@@ -91,6 +91,13 @@ func ContactPointToContactPointExport(cp definitions.ContactPoint) (notify.APIRe
|
||||
}
|
||||
integration = append(integration, el)
|
||||
}
|
||||
for _, i := range cp.Jira {
|
||||
el, err := marshallIntegration(j, "jira", i, i.DisableResolveMessage)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
integration = append(integration, el)
|
||||
}
|
||||
for _, i := range cp.Kafka {
|
||||
el, err := marshallIntegration(j, "kafka", i, i.DisableResolveMessage)
|
||||
if err != nil {
|
||||
@@ -271,6 +278,11 @@ func parseIntegration(json jsoniter.API, result *definitions.ContactPoint, recei
|
||||
if err = json.Unmarshal(data, &integration); err == nil {
|
||||
result.Googlechat = append(result.Googlechat, integration)
|
||||
}
|
||||
case "jira":
|
||||
integration := definitions.JiraIntegration{DisableResolveMessage: disable}
|
||||
if err = json.Unmarshal(data, &integration); err == nil {
|
||||
result.Jira = append(result.Jira, integration)
|
||||
}
|
||||
case "kafka":
|
||||
integration := definitions.KafkaIntegration{DisableResolveMessage: disable}
|
||||
if err = json.Unmarshal(data, &integration); err == nil {
|
||||
|
||||
@@ -62,6 +62,29 @@ type GooglechatIntegration struct {
|
||||
Message *string `json:"message,omitempty" yaml:"message,omitempty" hcl:"message"`
|
||||
}
|
||||
|
||||
type JiraIntegration struct {
|
||||
DisableResolveMessage *bool `json:"-" yaml:"-" hcl:"disable_resolve_message"`
|
||||
|
||||
URL string `yaml:"api_url,omitempty" json:"api_url,omitempty" hcl:"api_url"`
|
||||
Project string `yaml:"project,omitempty" json:"project,omitempty" hcl:"project"`
|
||||
IssueType string `yaml:"issue_type,omitempty" json:"issue_type,omitempty" hcl:"issue_type"`
|
||||
|
||||
Summary *string `yaml:"summary,omitempty" json:"summary,omitempty" hcl:"summary"`
|
||||
Description *string `yaml:"description,omitempty" json:"description,omitempty" hcl:"description"`
|
||||
Labels *[]string `yaml:"labels,omitempty" json:"labels,omitempty" hcl:"labels"`
|
||||
Priority *string `yaml:"priority,omitempty" json:"priority,omitempty" hcl:"priority"`
|
||||
ReopenTransition *string `yaml:"reopen_transition,omitempty" json:"reopen_transition,omitempty" hcl:"reopen_transition"`
|
||||
ResolveTransition *string `yaml:"resolve_transition,omitempty" json:"resolve_transition,omitempty" hcl:"resolve_transition"`
|
||||
WontFixResolution *string `yaml:"wont_fix_resolution,omitempty" json:"wont_fix_resolution,omitempty" hcl:"wont_fix_resolution"`
|
||||
ReopenDuration *string `yaml:"reopen_duration,omitempty" json:"reopen_duration,omitempty" hcl:"reopen_duration"`
|
||||
DedupKeyFieldName *string `yaml:"dedup_key_field,omitempty" json:"dedup_key_field,omitempty" hcl:"dedup_key_field"`
|
||||
Fields *map[string]any `yaml:"fields,omitempty" json:"fields,omitempty" hcl:"fields"`
|
||||
|
||||
User *Secret `yaml:"user,omitempty" json:"user,omitempty" hcl:"user"`
|
||||
Password *Secret `yaml:"password,omitempty" json:"password,omitempty" hcl:"password"`
|
||||
Token *Secret `yaml:"api_token,omitempty" json:"api_token,omitempty" hcl:"api_token"`
|
||||
}
|
||||
|
||||
type KafkaIntegration struct {
|
||||
DisableResolveMessage *bool `json:"-" yaml:"-" hcl:"disable_resolve_message"`
|
||||
|
||||
@@ -321,6 +344,7 @@ type ContactPoint struct {
|
||||
Discord []DiscordIntegration `json:"discord" yaml:"discord" hcl:"discord,block"`
|
||||
Email []EmailIntegration `json:"email" yaml:"email" hcl:"email,block"`
|
||||
Googlechat []GooglechatIntegration `json:"googlechat" yaml:"googlechat" hcl:"googlechat,block"`
|
||||
Jira []JiraIntegration `json:"jira" yaml:"jira" hcl:"jira,block"`
|
||||
Kafka []KafkaIntegration `json:"kafka" yaml:"kafka" hcl:"kafka,block"`
|
||||
Line []LineIntegration `json:"line" yaml:"line" hcl:"line,block"`
|
||||
Mqtt []MqttIntegration `json:"mqtt" yaml:"mqtt" hcl:"mqtt,block"`
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/alerting/receivers/jira"
|
||||
alertingMqtt "github.com/grafana/alerting/receivers/mqtt"
|
||||
alertingOpsgenie "github.com/grafana/alerting/receivers/opsgenie"
|
||||
alertingPagerduty "github.com/grafana/alerting/receivers/pagerduty"
|
||||
@@ -365,7 +366,7 @@ func GetAvailableNotifiers() []*NotifierPlugin {
|
||||
InputType: InputTypeText,
|
||||
PropertyName: "details",
|
||||
},
|
||||
{ //New in 11.1
|
||||
{ // New in 11.1
|
||||
Label: "URL",
|
||||
Description: "The URL to send API requests to",
|
||||
Element: ElementTypeInput,
|
||||
@@ -1708,6 +1709,154 @@ func GetAvailableNotifiers() []*NotifierPlugin {
|
||||
},
|
||||
},
|
||||
},
|
||||
{ // Since Grafana 11.6
|
||||
Type: "jira",
|
||||
Name: "Jira",
|
||||
Description: "Creates Jira issues from alerts",
|
||||
Heading: "Jira settings",
|
||||
Options: []NotifierOption{
|
||||
{
|
||||
Label: "API URL of Jira instance, including version of API",
|
||||
Element: ElementTypeInput,
|
||||
InputType: InputTypeText,
|
||||
Placeholder: "https://grafana.atlassian.net/rest/api/3",
|
||||
PropertyName: "api_url",
|
||||
Description: "Supported v2 or v3 APIs",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Label: "HTTP Basic Authentication - Username",
|
||||
Element: ElementTypeInput,
|
||||
InputType: InputTypeText,
|
||||
PropertyName: "user",
|
||||
Description: "Username to use for Jira authentication.",
|
||||
Secure: true,
|
||||
Required: false,
|
||||
},
|
||||
{
|
||||
Label: "HTTP Basic Authentication - Password",
|
||||
Element: ElementTypeInput,
|
||||
InputType: InputTypePassword,
|
||||
PropertyName: "password",
|
||||
// Go to https://id.atlassian.com/manage-profile/security/api-tokens to obtain a token.
|
||||
Description: "Password to use for Jira authentication.",
|
||||
Secure: true,
|
||||
Required: false,
|
||||
},
|
||||
{
|
||||
Label: "Authorization Header - Personal Access Token",
|
||||
Element: ElementTypeInput,
|
||||
InputType: InputTypePassword,
|
||||
PropertyName: "api_token",
|
||||
// Go to https://confluence.atlassian.com/enterprise/using-personal-access-tokens-1026032365.html for how to obtain a token.
|
||||
Description: "Personal Access Token that is used as a bearer authorization header.",
|
||||
Secure: true,
|
||||
Required: false,
|
||||
},
|
||||
{
|
||||
Label: "Project Key",
|
||||
Description: "The project key associated with the relevant Jira project",
|
||||
Element: ElementTypeInput,
|
||||
InputType: InputTypeText,
|
||||
Placeholder: "Grafana",
|
||||
PropertyName: "project",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Label: "Issue Type",
|
||||
Description: "The type of the Jira issue (e.g., Bug, Task, Story). You can use templates to customize this field.",
|
||||
Element: ElementTypeInput,
|
||||
InputType: InputTypeText,
|
||||
Placeholder: "Task",
|
||||
Required: true,
|
||||
PropertyName: "issue_type",
|
||||
},
|
||||
{
|
||||
Label: "Summary",
|
||||
Description: fmt.Sprintf("The summary of the Jira issue. You can use templates to customize this field. Maximum length is %d characters.", jira.MaxSummaryLenRunes),
|
||||
Element: ElementTypeInput,
|
||||
InputType: InputTypeText,
|
||||
Placeholder: jira.DefaultSummary,
|
||||
PropertyName: "summary",
|
||||
},
|
||||
{
|
||||
Label: "Description",
|
||||
Description: fmt.Sprintf("The description of the Jira issue. You can use templates to customize this field. Maximum length is %d characters.", jira.MaxDescriptionLenRunes),
|
||||
Element: ElementTypeTextArea,
|
||||
InputType: InputTypeText,
|
||||
Placeholder: jira.DefaultDescription,
|
||||
PropertyName: "description",
|
||||
},
|
||||
{
|
||||
Label: "Labels",
|
||||
Description: "Labels to assign to the Jira issue. You can use templates to customize this field.",
|
||||
Element: ElementStringArray,
|
||||
Placeholder: "",
|
||||
PropertyName: "labels",
|
||||
},
|
||||
{
|
||||
Label: "Priority",
|
||||
Description: "The priority of the Jira issue (e.g., High, Medium, Low). You can use templates to customize this field.",
|
||||
Element: ElementTypeInput,
|
||||
InputType: InputTypeText,
|
||||
Placeholder: jira.DefaultPriority,
|
||||
PropertyName: "priority",
|
||||
Required: false,
|
||||
},
|
||||
{
|
||||
Label: "Resolve Transition",
|
||||
Description: `Name of the workflow transition to resolve an issue. The target status must have the category "done". If not set, the issue will not be resolved.`,
|
||||
Element: ElementTypeInput,
|
||||
InputType: InputTypeText,
|
||||
Placeholder: "",
|
||||
PropertyName: "resolve_transition",
|
||||
Required: false,
|
||||
},
|
||||
{
|
||||
Label: "Reopen Transition",
|
||||
Description: `Name of the workflow transition to resolve an issue. The target status must not have the category "done". If not set, the issue will not be reopened.`,
|
||||
Element: ElementTypeInput,
|
||||
InputType: InputTypeText,
|
||||
Placeholder: "",
|
||||
PropertyName: "reopen_transition",
|
||||
Required: false,
|
||||
},
|
||||
{
|
||||
Label: "Reopen Duration",
|
||||
Description: "Reopen the issue when it is not older than this value in minutes. Otherwise, create a new issue.",
|
||||
Element: ElementTypeInput,
|
||||
InputType: InputTypeText,
|
||||
Placeholder: "10m",
|
||||
PropertyName: "reopen_duration",
|
||||
},
|
||||
{
|
||||
Label: "\"Won't fix\" Transition",
|
||||
Description: `If reopen transition is defined, ignore issues with that resolution.`,
|
||||
Element: ElementTypeInput,
|
||||
InputType: InputTypeText,
|
||||
Placeholder: "",
|
||||
PropertyName: "wont_fix_resolution",
|
||||
Required: false,
|
||||
},
|
||||
{
|
||||
Label: "Custom field ID for deduplication",
|
||||
Description: "Id of the custom field where the deduplication key should be stored. Otherwise, it is added to labels in format 'ALERT($KEY).'",
|
||||
Element: ElementTypeInput,
|
||||
InputType: InputTypeText,
|
||||
Placeholder: "10000",
|
||||
ValidationRule: "^[0-9]+$",
|
||||
PropertyName: "dedup_key_field",
|
||||
},
|
||||
{
|
||||
Label: "Custom Field Data",
|
||||
Description: "Custom field data to set on the Jira issue.",
|
||||
Element: ElementTypeKeyValueMap,
|
||||
InputType: InputTypeText,
|
||||
Placeholder: "",
|
||||
PropertyName: "fields",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,20 +27,38 @@ func TestGetSecretKeysForContactPointType(t *testing.T) {
|
||||
{receiverType: "prometheus-alertmanager", expectedSecretFields: []string{"basicAuthPassword"}},
|
||||
{receiverType: "discord", expectedSecretFields: []string{"url"}},
|
||||
{receiverType: "googlechat", expectedSecretFields: []string{"url"}},
|
||||
{receiverType: "line", expectedSecretFields: []string{"token"}},
|
||||
{receiverType: "LINE", expectedSecretFields: []string{"token"}},
|
||||
{receiverType: "threema", expectedSecretFields: []string{"api_secret"}},
|
||||
{receiverType: "opsgenie", expectedSecretFields: []string{"apiKey"}},
|
||||
{receiverType: "webex", expectedSecretFields: []string{"bot_token"}},
|
||||
{receiverType: "sns", expectedSecretFields: []string{"sigv4.access_key", "sigv4.secret_key"}},
|
||||
{receiverType: "mqtt", expectedSecretFields: []string{"password", "tlsConfig.caCertificate", "tlsConfig.clientCertificate", "tlsConfig.clientKey"}},
|
||||
{receiverType: "jira", expectedSecretFields: []string{"user", "password", "api_token"}},
|
||||
}
|
||||
n := GetAvailableNotifiers()
|
||||
allTypes := make(map[string]struct{}, len(n))
|
||||
for _, plugin := range n {
|
||||
allTypes[plugin.Type] = struct{}{}
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
delete(allTypes, testCase.receiverType)
|
||||
t.Run(testCase.receiverType, func(t *testing.T) {
|
||||
got, err := GetSecretKeysForContactPointType(testCase.receiverType)
|
||||
require.NoError(t, err)
|
||||
t.Logf("got secret fields: %#v", got)
|
||||
require.ElementsMatch(t, testCase.expectedSecretFields, got)
|
||||
})
|
||||
}
|
||||
|
||||
for integrationType := range allTypes {
|
||||
t.Run(integrationType, func(t *testing.T) {
|
||||
got, err := GetSecretKeysForContactPointType(integrationType)
|
||||
require.NoError(t, err)
|
||||
require.Emptyf(t, got, "secret keys for %s should be empty", integrationType)
|
||||
})
|
||||
}
|
||||
|
||||
require.Emptyf(t, allTypes, "not all types are covered: %s", allTypes)
|
||||
}
|
||||
|
||||
func Test_getSecretFields(t *testing.T) {
|
||||
|
||||
@@ -45,6 +45,8 @@ const (
|
||||
ElementTypeSubform = "subform"
|
||||
// ElementSubformArray will render a multiple sub-forms with schema defined in SubformOptions
|
||||
ElementSubformArray = "subform_array"
|
||||
// ElementStringArray will render a set of fields to manage an array of strings.
|
||||
ElementStringArray = "string_array"
|
||||
)
|
||||
|
||||
// InputType is the type of input that can be rendered in the frontend.
|
||||
|
||||
@@ -423,6 +423,9 @@ func TestRemoveSecretsForContactPoint(t *testing.T) {
|
||||
"webhook": func(settings map[string]any) { // add additional field to the settings because valid config does not allow it to be specified along with password
|
||||
settings["authorization_credentials"] = "test-authz-creds"
|
||||
},
|
||||
"jira": func(settings map[string]any) { // add additional field to the settings because valid config does not allow it to be specified along with password
|
||||
settings["api_token"] = "test-token"
|
||||
},
|
||||
}
|
||||
|
||||
configs := notify.AllKnownConfigsForTesting
|
||||
|
||||
@@ -191,7 +191,7 @@ require (
|
||||
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
|
||||
github.com/gorilla/mux v1.8.1 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/grafana/alerting v0.0.0-20250219153626-c475b1a572b0 // indirect
|
||||
github.com/grafana/alerting v0.0.0-20250221202230-9d7e00921e44 // indirect
|
||||
github.com/grafana/authlib v0.0.0-20250219100139-6a3b1bbb50e7 // indirect
|
||||
github.com/grafana/dataplane/sdata v0.0.9 // indirect
|
||||
github.com/grafana/dskit v0.0.0-20241105154643-a6b453a88040 // indirect
|
||||
|
||||
@@ -564,8 +564,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grafana/alerting v0.0.0-20250219153626-c475b1a572b0 h1:LGH+tVzHCDrR9hsltmkP4jmNRg5IreQw5CNFbJKlnts=
|
||||
github.com/grafana/alerting v0.0.0-20250219153626-c475b1a572b0/go.mod h1:hdGB3dSl8Ma9Rjo2YiAEAjMkZ5HiNJbNDqRKDefRZrM=
|
||||
github.com/grafana/alerting v0.0.0-20250221202230-9d7e00921e44 h1:vboqvbAO0s0CTALHnqfmNvhCP1ziBcZNpYDbORqvOgg=
|
||||
github.com/grafana/alerting v0.0.0-20250221202230-9d7e00921e44/go.mod h1:hdGB3dSl8Ma9Rjo2YiAEAjMkZ5HiNJbNDqRKDefRZrM=
|
||||
github.com/grafana/authlib v0.0.0-20250219100139-6a3b1bbb50e7 h1:NTMmow+74I3Jb033xhbRgWQS7A//5TDhiM4tl7bsVP4=
|
||||
github.com/grafana/authlib v0.0.0-20250219100139-6a3b1bbb50e7/go.mod h1:T3X4z0ejGfJOiOmZLFeKCRT/yxWJq/RtclAc/PHj/w4=
|
||||
github.com/grafana/authlib/types v0.0.0-20250219092154-21ce22b49f31 h1:EokLC5grHwLPs4tXW8T6E8187H1e5G9AP0QQ5B60HbA=
|
||||
|
||||
@@ -118,7 +118,7 @@ require (
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
|
||||
github.com/gorilla/mux v1.8.1 // indirect
|
||||
github.com/grafana/alerting v0.0.0-20250219153626-c475b1a572b0 // indirect
|
||||
github.com/grafana/alerting v0.0.0-20250221202230-9d7e00921e44 // indirect
|
||||
github.com/grafana/dataplane/sdata v0.0.9 // indirect
|
||||
github.com/grafana/grafana-app-sdk/logging v0.30.0 // indirect
|
||||
github.com/grafana/grafana-aws-sdk v0.31.5 // indirect
|
||||
|
||||
@@ -405,8 +405,8 @@ github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2z
|
||||
github.com/gorilla/mux v1.7.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/grafana/alerting v0.0.0-20250219153626-c475b1a572b0 h1:LGH+tVzHCDrR9hsltmkP4jmNRg5IreQw5CNFbJKlnts=
|
||||
github.com/grafana/alerting v0.0.0-20250219153626-c475b1a572b0/go.mod h1:hdGB3dSl8Ma9Rjo2YiAEAjMkZ5HiNJbNDqRKDefRZrM=
|
||||
github.com/grafana/alerting v0.0.0-20250221202230-9d7e00921e44 h1:vboqvbAO0s0CTALHnqfmNvhCP1ziBcZNpYDbORqvOgg=
|
||||
github.com/grafana/alerting v0.0.0-20250221202230-9d7e00921e44/go.mod h1:hdGB3dSl8Ma9Rjo2YiAEAjMkZ5HiNJbNDqRKDefRZrM=
|
||||
github.com/grafana/authlib v0.0.0-20250219100139-6a3b1bbb50e7 h1:NTMmow+74I3Jb033xhbRgWQS7A//5TDhiM4tl7bsVP4=
|
||||
github.com/grafana/authlib v0.0.0-20250219100139-6a3b1bbb50e7/go.mod h1:T3X4z0ejGfJOiOmZLFeKCRT/yxWJq/RtclAc/PHj/w4=
|
||||
github.com/grafana/authlib/types v0.0.0-20250219092154-21ce22b49f31 h1:EokLC5grHwLPs4tXW8T6E8187H1e5G9AP0QQ5B60HbA=
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestIntegrationSQLStorageBackend(t *testing.T) {
|
||||
err = backend.Init(testutil.NewDefaultTestContext(t))
|
||||
require.NoError(t, err)
|
||||
return backend
|
||||
})
|
||||
}, nil)
|
||||
// Run single instance tests with in-process notifier.
|
||||
unitest.RunStorageBackendTest(t, func(ctx context.Context) resource.StorageBackend {
|
||||
dbstore := infraDB.InitTestDB(t)
|
||||
@@ -63,7 +63,7 @@ func TestIntegrationSQLStorageBackend(t *testing.T) {
|
||||
err = backend.Init(testutil.NewDefaultTestContext(t))
|
||||
require.NoError(t, err)
|
||||
return backend
|
||||
})
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func TestClientServer(t *testing.T) {
|
||||
|
||||
@@ -2,6 +2,8 @@ package test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -12,59 +14,63 @@ import (
|
||||
"github.com/grafana/authlib/authn"
|
||||
"github.com/grafana/authlib/types"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
infraDB "github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/sql"
|
||||
"github.com/grafana/grafana/pkg/util/testutil"
|
||||
)
|
||||
|
||||
// Test names for the storage backend test suite
|
||||
const (
|
||||
TestHappyPath = "happy path"
|
||||
TestWatchWriteEvents = "watch write events from latest"
|
||||
TestList = "list"
|
||||
TestBlobSupport = "blob support"
|
||||
TestGetResourceStats = "get resource stats"
|
||||
TestListHistory = "list history"
|
||||
)
|
||||
|
||||
type NewBackendFunc func(ctx context.Context) resource.StorageBackend
|
||||
|
||||
// TestCase defines a test case for the storage backend
|
||||
type TestCase struct {
|
||||
name string
|
||||
fn func(*testing.T, resource.StorageBackend)
|
||||
}
|
||||
|
||||
// StorageBackendTestSuite defines the test suite for storage backend
|
||||
type StorageBackendTestSuite struct {
|
||||
newBackend NewBackendFunc
|
||||
cases []TestCase
|
||||
}
|
||||
|
||||
// NewStorageBackendTestSuite creates a new test suite
|
||||
func NewStorageBackendTestSuite(newBackend NewBackendFunc) *StorageBackendTestSuite {
|
||||
return &StorageBackendTestSuite{
|
||||
newBackend: newBackend,
|
||||
cases: []TestCase{
|
||||
{"happy path", runTestIntegrationBackendHappyPath},
|
||||
{"watch write events from latest", runTestIntegrationBackendWatchWriteEventsFromLastest},
|
||||
{"list", runTestIntegrationBackendList},
|
||||
{"blob support", runTestIntegrationBlobSupport},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Run executes all test cases in the suite
|
||||
func (s *StorageBackendTestSuite) Run(t *testing.T) {
|
||||
for _, tc := range s.cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
tc.fn(t, s.newBackend(context.Background()))
|
||||
})
|
||||
}
|
||||
// TestOptions configures which tests to run
|
||||
type TestOptions struct {
|
||||
SkipTests map[string]bool // tests to skip
|
||||
}
|
||||
|
||||
// RunStorageBackendTest runs the storage backend test suite
|
||||
func RunStorageBackendTest(t *testing.T, newBackend NewBackendFunc) {
|
||||
suite := NewStorageBackendTestSuite(newBackend)
|
||||
suite.Run(t)
|
||||
}
|
||||
|
||||
func runTestIntegrationBackendHappyPath(t *testing.T, backend resource.StorageBackend) {
|
||||
func RunStorageBackendTest(t *testing.T, newBackend NewBackendFunc, opts *TestOptions) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
if opts == nil {
|
||||
opts = &TestOptions{}
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
fn func(*testing.T, resource.StorageBackend)
|
||||
}{
|
||||
{TestHappyPath, runTestIntegrationBackendHappyPath},
|
||||
{TestWatchWriteEvents, runTestIntegrationBackendWatchWriteEventsFromLastest},
|
||||
{TestList, runTestIntegrationBackendList},
|
||||
{TestBlobSupport, runTestIntegrationBlobSupport},
|
||||
{TestGetResourceStats, runTestIntegrationBackendGetResourceStats},
|
||||
{TestListHistory, runTestIntegrationBackendListHistory},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if shouldSkip := opts.SkipTests[tc.name]; shouldSkip {
|
||||
t.Logf("Skipping test: %s", tc.name)
|
||||
continue
|
||||
}
|
||||
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
tc.fn(t, newBackend(context.Background()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func runTestIntegrationBackendHappyPath(t *testing.T, backend resource.StorageBackend) {
|
||||
ctx := types.WithAuthInfo(context.Background(), authn.NewAccessTokenAuthInfo(authn.Claims[authn.AccessTokenClaims]{
|
||||
Claims: jwt.Claims{
|
||||
Subject: "testuser",
|
||||
@@ -90,12 +96,6 @@ func runTestIntegrationBackendHappyPath(t *testing.T, backend resource.StorageBa
|
||||
rv3, err = writeEvent(ctx, backend, "item3", resource.WatchEvent_ADDED)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, rv3, rv2)
|
||||
|
||||
stats, err := backend.GetResourceStats(ctx, "", 0)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, stats, 1)
|
||||
require.Equal(t, int64(3), stats[0].Count)
|
||||
require.Equal(t, rv3, stats[0].ResourceVersion)
|
||||
})
|
||||
|
||||
t.Run("Update item2", func(t *testing.T) {
|
||||
@@ -128,7 +128,7 @@ func runTestIntegrationBackendHappyPath(t *testing.T, backend resource.StorageBa
|
||||
require.Equal(t, "item2 ADDED", string(resp.Value))
|
||||
})
|
||||
|
||||
t.Run("PrepareList latest", func(t *testing.T) {
|
||||
t.Run("List latest", func(t *testing.T) {
|
||||
resp, err := server.List(ctx, &resource.ListRequest{
|
||||
Options: &resource.ListOptions{
|
||||
Key: &resource.ResourceKey{
|
||||
@@ -143,7 +143,7 @@ func runTestIntegrationBackendHappyPath(t *testing.T, backend resource.StorageBa
|
||||
require.Len(t, resp.Items, 2)
|
||||
require.Equal(t, "item2 MODIFIED", string(resp.Items[0].Value))
|
||||
require.Equal(t, "item3 ADDED", string(resp.Items[1].Value))
|
||||
require.Equal(t, rv5, resp.ResourceVersion)
|
||||
require.GreaterOrEqual(t, resp.ResourceVersion, rv5) // rv5 is the latest resource version
|
||||
})
|
||||
|
||||
t.Run("Watch events", func(t *testing.T) {
|
||||
@@ -176,14 +176,103 @@ func runTestIntegrationBackendHappyPath(t *testing.T, backend resource.StorageBa
|
||||
})
|
||||
}
|
||||
|
||||
func runTestIntegrationBackendWatchWriteEventsFromLastest(t *testing.T, backend resource.StorageBackend) {
|
||||
if infraDB.IsTestDbSQLite() {
|
||||
t.Skip("TODO: test blocking, skipping to unblock Enterprise until we fix this")
|
||||
}
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
func runTestIntegrationBackendGetResourceStats(t *testing.T, backend resource.StorageBackend) {
|
||||
ctx := testutil.NewTestContext(t, time.Now().Add(5*time.Second))
|
||||
|
||||
sortFunc := func(a, b resource.ResourceStats) int {
|
||||
if a.Namespace != b.Namespace {
|
||||
return strings.Compare(a.Namespace, b.Namespace)
|
||||
}
|
||||
if a.Group != b.Group {
|
||||
return strings.Compare(a.Group, b.Group)
|
||||
}
|
||||
return strings.Compare(a.Resource, b.Resource)
|
||||
}
|
||||
// Create resources across different namespaces/groups
|
||||
_, err := writeEvent(ctx, backend, "item1", resource.WatchEvent_ADDED,
|
||||
WithNamespace("ns1"),
|
||||
WithGroup("group"),
|
||||
WithResource("resource1"))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = writeEvent(ctx, backend, "item2", resource.WatchEvent_ADDED,
|
||||
WithNamespace("ns1"),
|
||||
WithGroup("group"),
|
||||
WithResource("resource1"))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = writeEvent(ctx, backend, "item3", resource.WatchEvent_ADDED,
|
||||
WithNamespace("ns1"),
|
||||
WithGroup("group"),
|
||||
WithResource("resource2"))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = writeEvent(ctx, backend, "item4", resource.WatchEvent_ADDED,
|
||||
WithNamespace("ns2"),
|
||||
WithGroup("group"),
|
||||
WithResource("resource1"))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = writeEvent(ctx, backend, "item5", resource.WatchEvent_ADDED,
|
||||
WithNamespace("ns2"),
|
||||
WithGroup("group"),
|
||||
WithResource("resource1"))
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("Get stats for ns1", func(t *testing.T) {
|
||||
stats, err := backend.GetResourceStats(ctx, "ns1", 0)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, stats, 2)
|
||||
|
||||
// Sort results for consistent testing
|
||||
slices.SortFunc(stats, sortFunc)
|
||||
|
||||
// Check first resource stats
|
||||
require.Equal(t, "ns1", stats[0].Namespace)
|
||||
require.Equal(t, "group", stats[0].Group)
|
||||
require.Equal(t, "resource1", stats[0].Resource)
|
||||
require.Equal(t, int64(2), stats[0].Count)
|
||||
require.Greater(t, stats[0].ResourceVersion, int64(0))
|
||||
|
||||
// Check second resource stats
|
||||
require.Equal(t, "ns1", stats[1].Namespace)
|
||||
require.Equal(t, "group", stats[1].Group)
|
||||
require.Equal(t, "resource2", stats[1].Resource)
|
||||
require.Equal(t, int64(1), stats[1].Count)
|
||||
require.Greater(t, stats[1].ResourceVersion, int64(0))
|
||||
})
|
||||
|
||||
t.Run("Get stats for ns2", func(t *testing.T) {
|
||||
stats, err := backend.GetResourceStats(ctx, "ns2", 0)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, stats, 1)
|
||||
|
||||
require.Equal(t, "ns2", stats[0].Namespace)
|
||||
require.Equal(t, "group", stats[0].Group)
|
||||
require.Equal(t, "resource1", stats[0].Resource)
|
||||
require.Equal(t, int64(2), stats[0].Count)
|
||||
require.Greater(t, stats[0].ResourceVersion, int64(0))
|
||||
})
|
||||
|
||||
t.Run("Get stats with minimum count", func(t *testing.T) {
|
||||
stats, err := backend.GetResourceStats(ctx, "ns1", 1)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, stats, 1)
|
||||
|
||||
require.Equal(t, "ns1", stats[0].Namespace)
|
||||
require.Equal(t, "group", stats[0].Group)
|
||||
require.Equal(t, "resource1", stats[0].Resource)
|
||||
require.Equal(t, int64(2), stats[0].Count)
|
||||
})
|
||||
|
||||
t.Run("Get stats for non-existent namespace", func(t *testing.T) {
|
||||
stats, err := backend.GetResourceStats(ctx, "non-existent", 0)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, stats)
|
||||
})
|
||||
}
|
||||
|
||||
func runTestIntegrationBackendWatchWriteEventsFromLastest(t *testing.T, backend resource.StorageBackend) {
|
||||
ctx := testutil.NewTestContext(t, time.Now().Add(5*time.Second))
|
||||
|
||||
// Create a few resources before initing the watch
|
||||
@@ -201,22 +290,15 @@ func runTestIntegrationBackendWatchWriteEventsFromLastest(t *testing.T, backend
|
||||
}
|
||||
|
||||
func runTestIntegrationBackendList(t *testing.T, backend resource.StorageBackend) {
|
||||
if infraDB.IsTestDbSQLite() {
|
||||
t.Skip("TODO: test blocking, skipping to unblock Enterprise until we fix this")
|
||||
}
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx := testutil.NewTestContext(t, time.Now().Add(5*time.Second))
|
||||
server := newServer(t, backend)
|
||||
|
||||
// Create a few resources before starting the watch
|
||||
rv1, _ := writeEvent(ctx, backend, "item1", resource.WatchEvent_ADDED)
|
||||
require.Greater(t, rv1, int64(0))
|
||||
rv2, _ := writeEvent(ctx, backend, "item2", resource.WatchEvent_ADDED) // rv=2 - will be modified at rv=6
|
||||
rv2, _ := writeEvent(ctx, backend, "item2", resource.WatchEvent_ADDED)
|
||||
require.Greater(t, rv2, rv1)
|
||||
rv3, _ := writeEvent(ctx, backend, "item3", resource.WatchEvent_ADDED) // rv=3 - will be deleted at rv=7
|
||||
rv3, _ := writeEvent(ctx, backend, "item3", resource.WatchEvent_ADDED)
|
||||
require.Greater(t, rv3, rv2)
|
||||
rv4, _ := writeEvent(ctx, backend, "item4", resource.WatchEvent_ADDED)
|
||||
require.Greater(t, rv4, rv3)
|
||||
@@ -344,6 +426,14 @@ func runTestIntegrationBackendList(t *testing.T, backend resource.StorageBackend
|
||||
require.Equal(t, rv8, continueToken.ResourceVersion)
|
||||
require.Equal(t, int64(4), continueToken.StartOffset)
|
||||
})
|
||||
}
|
||||
|
||||
func runTestIntegrationBackendListHistory(t *testing.T, backend resource.StorageBackend) {
|
||||
ctx := testutil.NewTestContext(t, time.Now().Add(5*time.Second))
|
||||
server := newServer(t, backend)
|
||||
|
||||
rv1, _ := writeEvent(ctx, backend, "item1", resource.WatchEvent_ADDED)
|
||||
require.Greater(t, rv1, int64(0))
|
||||
|
||||
// add 5 events for item1 - should be saved to history
|
||||
rvHistory1, err := writeEvent(ctx, backend, "item1", resource.WatchEvent_MODIFIED)
|
||||
@@ -424,10 +514,6 @@ func runTestIntegrationBackendList(t *testing.T, backend resource.StorageBackend
|
||||
}
|
||||
|
||||
func runTestIntegrationBlobSupport(t *testing.T, backend resource.StorageBackend) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx := testutil.NewTestContext(t, time.Now().Add(5*time.Second))
|
||||
server := newServer(t, backend)
|
||||
store, ok := backend.(resource.BlobSupport)
|
||||
@@ -472,7 +558,71 @@ func runTestIntegrationBlobSupport(t *testing.T, backend resource.StorageBackend
|
||||
})
|
||||
}
|
||||
|
||||
func writeEvent(ctx context.Context, store resource.StorageBackend, name string, action resource.WatchEvent_Type) (int64, error) {
|
||||
// WriteEventOption is a function that modifies WriteEventOptions
|
||||
type WriteEventOption func(*WriteEventOptions)
|
||||
|
||||
// WithNamespace sets the namespace for the write event
|
||||
func WithNamespace(namespace string) WriteEventOption {
|
||||
return func(o *WriteEventOptions) {
|
||||
o.Namespace = namespace
|
||||
}
|
||||
}
|
||||
|
||||
// WithGroup sets the group for the write event
|
||||
func WithGroup(group string) WriteEventOption {
|
||||
return func(o *WriteEventOptions) {
|
||||
o.Group = group
|
||||
}
|
||||
}
|
||||
|
||||
// WithResource sets the resource for the write event
|
||||
func WithResource(resource string) WriteEventOption {
|
||||
return func(o *WriteEventOptions) {
|
||||
o.Resource = resource
|
||||
}
|
||||
}
|
||||
|
||||
// WithFolder sets the folder for the write event
|
||||
func WithFolder(folder string) WriteEventOption {
|
||||
return func(o *WriteEventOptions) {
|
||||
o.Folder = folder
|
||||
}
|
||||
}
|
||||
|
||||
// WithValue sets the value for the write event
|
||||
func WithValue(value []byte) WriteEventOption {
|
||||
return func(o *WriteEventOptions) {
|
||||
o.Value = value
|
||||
}
|
||||
}
|
||||
|
||||
type WriteEventOptions struct {
|
||||
Namespace string
|
||||
Group string
|
||||
Resource string
|
||||
Folder string
|
||||
Value []byte
|
||||
}
|
||||
|
||||
func writeEvent(ctx context.Context, store resource.StorageBackend, name string, action resource.WatchEvent_Type, opts ...WriteEventOption) (int64, error) {
|
||||
// Default options
|
||||
options := WriteEventOptions{
|
||||
Namespace: "namespace",
|
||||
Group: "group",
|
||||
Resource: "resource",
|
||||
Folder: "folderuid",
|
||||
}
|
||||
|
||||
// Apply options
|
||||
for _, opt := range opts {
|
||||
opt(&options)
|
||||
}
|
||||
|
||||
// Set default value if not provided
|
||||
if options.Value == nil {
|
||||
options.Value = []byte(name + " " + resource.WatchEvent_Type_name[int32(action)])
|
||||
}
|
||||
|
||||
res := &unstructured.Unstructured{
|
||||
Object: map[string]any{},
|
||||
}
|
||||
@@ -480,14 +630,15 @@ func writeEvent(ctx context.Context, store resource.StorageBackend, name string,
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
meta.SetFolder("folderuid")
|
||||
meta.SetFolder(options.Folder)
|
||||
|
||||
return store.WriteEvent(ctx, resource.WriteEvent{
|
||||
Type: action,
|
||||
Value: []byte(name + " " + resource.WatchEvent_Type_name[int32(action)]),
|
||||
Value: options.Value,
|
||||
Key: &resource.ResourceKey{
|
||||
Namespace: "namespace",
|
||||
Group: "group",
|
||||
Resource: "resource",
|
||||
Namespace: options.Namespace,
|
||||
Group: options.Group,
|
||||
Resource: options.Resource,
|
||||
Name: name,
|
||||
},
|
||||
Object: meta,
|
||||
|
||||
@@ -1392,9 +1392,15 @@ func TestIntegrationCRUD(t *testing.T) {
|
||||
t.Run("should return secrets in secureFields but not settings", func(t *testing.T) {
|
||||
for _, integration := range get.Spec.Integrations {
|
||||
t.Run(integration.Type, func(t *testing.T) {
|
||||
expected := notify.AllKnownConfigsForTesting[strings.ToLower(integration.Type)]
|
||||
var fields map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(expected.Config), &fields))
|
||||
secretFields, err := channels_config.GetSecretKeysForContactPointType(integration.Type)
|
||||
require.NoError(t, err)
|
||||
for _, field := range secretFields {
|
||||
if _, ok := fields[field]; !ok { // skip field that is not in the original setting
|
||||
continue
|
||||
}
|
||||
assert.Contains(t, integration.SecureFields, field)
|
||||
assert.Truef(t, integration.SecureFields[field], "secure field should be always true")
|
||||
|
||||
|
||||
@@ -1833,10 +1833,16 @@
|
||||
{
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Find annotations for a specified alert.",
|
||||
"description": "Find annotations for a specified alert rule by its ID.\ndeprecated: AlertID is deprecated and will be removed in future versions. Please use AlertUID instead.",
|
||||
"name": "alertId",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Find annotations for a specified alert rule by its UID.",
|
||||
"name": "alertUID",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
|
||||
@@ -12,6 +12,7 @@ import { KioskMode } from 'app/types';
|
||||
import { RouteDescriptor } from '../../navigation/types';
|
||||
import { buildBreadcrumbs } from '../Breadcrumbs/utils';
|
||||
|
||||
import { logDuplicateUnifiedHistoryEntryEvent } from './History/eventsTracking';
|
||||
import { ReturnToPreviousProps } from './ReturnToPrevious/ReturnToPrevious';
|
||||
import { HistoryEntry, TOP_BAR_LEVEL_HEIGHT } from './types';
|
||||
|
||||
@@ -153,6 +154,13 @@ export class AppChromeService {
|
||||
if (isSamePath) {
|
||||
entries[0] = newEntry;
|
||||
} else {
|
||||
if (lastEntry && lastEntry.name === newEntry.name) {
|
||||
logDuplicateUnifiedHistoryEntryEvent({
|
||||
entryName: newEntry.name,
|
||||
lastEntryURL: lastEntry.url,
|
||||
newEntryURL: newEntry.url,
|
||||
});
|
||||
}
|
||||
entries = [newEntry, ...entries];
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { NavToolbarSeparator } from '../NavToolbar/NavToolbarSeparator';
|
||||
import { HistoryEntry } from '../types';
|
||||
|
||||
import { HistoryWrapper } from './HistoryWrapper';
|
||||
import { logUnifiedHistoryDrawerInteractionEvent } from './eventsTracking';
|
||||
|
||||
export function HistoryContainer() {
|
||||
const [showHistoryDrawer, onToggleShowHistoryDrawer] = useToggle(false);
|
||||
@@ -50,7 +51,10 @@ export function HistoryContainer() {
|
||||
return (
|
||||
<>
|
||||
<ToolbarButton
|
||||
onClick={onToggleShowHistoryDrawer}
|
||||
onClick={() => {
|
||||
onToggleShowHistoryDrawer();
|
||||
logUnifiedHistoryDrawerInteractionEvent({ type: 'open' });
|
||||
}}
|
||||
iconOnly
|
||||
icon="history"
|
||||
aria-label={t('nav.history-container.drawer-tittle', 'History')}
|
||||
@@ -59,7 +63,10 @@ export function HistoryContainer() {
|
||||
{showHistoryDrawer && (
|
||||
<Drawer
|
||||
title={t('nav.history-container.drawer-tittle', 'History')}
|
||||
onClose={onToggleShowHistoryDrawer}
|
||||
onClose={() => {
|
||||
onToggleShowHistoryDrawer();
|
||||
logUnifiedHistoryDrawerInteractionEvent({ type: 'close' });
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
<HistoryWrapper onClose={() => onToggleShowHistoryDrawer(false)} />
|
||||
|
||||
@@ -5,10 +5,13 @@ import { useState } from 'react';
|
||||
import { FieldType, GrafanaTheme2, store } from '@grafana/data';
|
||||
import { Button, Card, IconButton, Space, Stack, Text, useStyles2, Box, Sparkline, useTheme2, Icon } from '@grafana/ui';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { formatDate } from 'app/core/internationalization/dates';
|
||||
|
||||
import { HISTORY_LOCAL_STORAGE_KEY } from '../AppChromeService';
|
||||
import { HistoryEntry } from '../types';
|
||||
|
||||
import { logClickUnifiedHistoryEntryEvent, logUnifiedHistoryShowMoreEvent } from './eventsTracking';
|
||||
|
||||
export function HistoryWrapper({ onClose }: { onClose: () => void }) {
|
||||
const history = store.getObject<HistoryEntry[]>(HISTORY_LOCAL_STORAGE_KEY, []).filter((entry) => {
|
||||
return moment(entry.time).isAfter(moment().subtract(2, 'day').startOf('day'));
|
||||
@@ -60,7 +63,14 @@ export function HistoryWrapper({ onClose }: { onClose: () => void }) {
|
||||
</Box>
|
||||
{history.length > numItemsToShow && (
|
||||
<Box paddingLeft={2}>
|
||||
<Button variant="secondary" fill="text" onClick={() => setNumItemsToShow(numItemsToShow + 5)}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
fill="text"
|
||||
onClick={() => {
|
||||
setNumItemsToShow(numItemsToShow + 5);
|
||||
logUnifiedHistoryShowMoreEvent();
|
||||
}}
|
||||
>
|
||||
{t('nav.history-wrapper.show-more', 'Show more')}
|
||||
</Button>
|
||||
</Box>
|
||||
@@ -115,6 +125,7 @@ function HistoryEntryAppView({ entry, isSelected, onClick }: ItemProps) {
|
||||
onClick={() => {
|
||||
store.setObject('CLICKING_HISTORY', true);
|
||||
onClick();
|
||||
logClickUnifiedHistoryEntryEvent({ entryURL: url });
|
||||
}}
|
||||
href={url}
|
||||
isCompact={true}
|
||||
@@ -129,7 +140,7 @@ function HistoryEntryAppView({ entry, isSelected, onClick }: ItemProps) {
|
||||
))}
|
||||
</div>
|
||||
<Text variant="bodySmall" color="secondary">
|
||||
{moment(time).format('h:mm A')}
|
||||
{formatDate(time, { timeStyle: 'short' })}
|
||||
</Text>
|
||||
{sparklineData && (
|
||||
<Sparkline
|
||||
@@ -170,6 +181,7 @@ function HistoryEntryAppView({ entry, isSelected, onClick }: ItemProps) {
|
||||
onClick={() => {
|
||||
store.setObject('CLICKING_HISTORY', true);
|
||||
onClick();
|
||||
logClickUnifiedHistoryEntryEvent({ entryURL: view.url, subEntry: 'timeRange' });
|
||||
}}
|
||||
isCompact={true}
|
||||
className={view.time === selectedViewTime ? undefined : styles.subCard}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
|
||||
const UNIFIED_HISTORY_ENTRY_CLICKED = 'grafana_unified_history_entry_clicked';
|
||||
const UNIFIED_HISTORY_ENTRY_DUPLICATED = 'grafana_unified_history_duplicated_entry_rendered';
|
||||
const UNIFIED_HISTORY_DRAWER_INTERACTION = 'grafana_unified_history_drawer_interaction';
|
||||
const UNIFIED_HISTORY_DRAWER_SHOW_MORE = 'grafana_unified_history_show_more';
|
||||
|
||||
//Currently just 'timeRange' is supported
|
||||
//in short term, we could add 'templateVariables' for example
|
||||
type subEntryTypes = 'timeRange';
|
||||
|
||||
//Whether the user opens or closes the `HistoryDrawer`
|
||||
type UnifiedHistoryDrawerInteraction = 'open' | 'close';
|
||||
|
||||
interface UnifiedHistoryEntryClicked {
|
||||
//We will also work with the current URL but we will get this from Rudderstack data
|
||||
//URL to return to
|
||||
entryURL: string;
|
||||
//In the case we want to go back to a specific query param, currently just a specific time range
|
||||
subEntry?: subEntryTypes;
|
||||
}
|
||||
|
||||
interface UnifiedHistoryEntryDuplicated {
|
||||
// Common name of the history entries
|
||||
entryName: string;
|
||||
// URL of the last entry
|
||||
lastEntryURL: string;
|
||||
// URL of the new entry
|
||||
newEntryURL: string;
|
||||
}
|
||||
|
||||
//Event triggered when a user clicks on an entry of the `HistoryDrawer`
|
||||
export const logClickUnifiedHistoryEntryEvent = ({ entryURL, subEntry }: UnifiedHistoryEntryClicked) => {
|
||||
reportInteraction(UNIFIED_HISTORY_ENTRY_CLICKED, {
|
||||
entryURL,
|
||||
subEntry,
|
||||
});
|
||||
};
|
||||
|
||||
//Event triggered when history entry name matches the previous one
|
||||
//so we keep track of duplicated entries and be able to analyze them
|
||||
export const logDuplicateUnifiedHistoryEntryEvent = ({
|
||||
entryName,
|
||||
lastEntryURL,
|
||||
newEntryURL,
|
||||
}: UnifiedHistoryEntryDuplicated) => {
|
||||
reportInteraction(UNIFIED_HISTORY_ENTRY_DUPLICATED, {
|
||||
entryName,
|
||||
lastEntryURL,
|
||||
newEntryURL,
|
||||
});
|
||||
};
|
||||
|
||||
//We keep track of users open and closing the drawer
|
||||
export const logUnifiedHistoryDrawerInteractionEvent = ({ type }: { type: UnifiedHistoryDrawerInteraction }) => {
|
||||
reportInteraction(UNIFIED_HISTORY_DRAWER_INTERACTION, {
|
||||
type,
|
||||
});
|
||||
};
|
||||
|
||||
//We keep track of users clicking on the `Show more` button
|
||||
export const logUnifiedHistoryShowMoreEvent = () => {
|
||||
reportInteraction(UNIFIED_HISTORY_DRAWER_SHOW_MORE);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Fragment } from 'react';
|
||||
import Skeleton from 'react-loading-skeleton';
|
||||
|
||||
import { Text } from '@grafana/ui';
|
||||
import { ScrollContainer, Text } from '@grafana/ui';
|
||||
import { SkeletonComponent, attachSkeleton } from '@grafana/ui/src/unstable';
|
||||
|
||||
import { Settings } from './AdminSettings';
|
||||
@@ -12,28 +12,30 @@ interface Props {
|
||||
|
||||
const AdminSettingsTableComponent = ({ settings }: Props) => {
|
||||
return (
|
||||
<table className="filter-table">
|
||||
<tbody>
|
||||
{Object.entries(settings).map(([sectionName, sectionSettings], i) => (
|
||||
<Fragment key={`section-${i}`}>
|
||||
<tr>
|
||||
<td>
|
||||
<Text color="info" weight="bold">
|
||||
{sectionName}
|
||||
</Text>
|
||||
</td>
|
||||
<td />
|
||||
</tr>
|
||||
{Object.entries(sectionSettings).map(([settingName, settingValue], j) => (
|
||||
<tr key={`property-${j}`}>
|
||||
<td style={{ paddingLeft: '25px' }}>{settingName}</td>
|
||||
<td style={{ whiteSpace: 'break-spaces' }}>{settingValue}</td>
|
||||
<ScrollContainer overflowY="visible" overflowX="auto" width="100%">
|
||||
<table className="filter-table">
|
||||
<tbody>
|
||||
{Object.entries(settings).map(([sectionName, sectionSettings], i) => (
|
||||
<Fragment key={`section-${i}`}>
|
||||
<tr>
|
||||
<td>
|
||||
<Text color="info" weight="bold">
|
||||
{sectionName}
|
||||
</Text>
|
||||
</td>
|
||||
<td />
|
||||
</tr>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{Object.entries(sectionSettings).map(([settingName, settingValue], j) => (
|
||||
<tr key={`property-${j}`}>
|
||||
<td style={{ paddingLeft: '25px' }}>{settingName}</td>
|
||||
<td style={{ whiteSpace: 'break-spaces' }}>{settingValue}</td>
|
||||
</tr>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</ScrollContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -42,34 +44,36 @@ const randomValues = new Array(50).fill(null).map(() => Math.random());
|
||||
|
||||
const AdminSettingsTableSkeleton: SkeletonComponent = ({ rootProps }) => {
|
||||
return (
|
||||
<table className="filter-table" {...rootProps}>
|
||||
<tbody>
|
||||
{randomValues.map((randomValue, index) => {
|
||||
const isSection = index === 0 || randomValue > 0.9;
|
||||
<ScrollContainer overflowY="visible" overflowX="auto" width="100%">
|
||||
<table className="filter-table" {...rootProps}>
|
||||
<tbody>
|
||||
{randomValues.map((randomValue, index) => {
|
||||
const isSection = index === 0 || randomValue > 0.9;
|
||||
|
||||
return (
|
||||
<Fragment key={index}>
|
||||
{isSection && (
|
||||
return (
|
||||
<Fragment key={index}>
|
||||
{isSection && (
|
||||
<tr>
|
||||
<td className="admin-settings-section">
|
||||
<Skeleton width={getRandomInRange(40, 80, randomValue)} />
|
||||
</td>
|
||||
<td />
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td className="admin-settings-section">
|
||||
<Skeleton width={getRandomInRange(40, 80, randomValue)} />
|
||||
<td style={{ paddingLeft: '25px' }}>
|
||||
<Skeleton width={getRandomInRange(60, 100, randomValue)} />
|
||||
</td>
|
||||
<td>
|
||||
<Skeleton width={getRandomInRange(80, 320, randomValue)} />
|
||||
</td>
|
||||
<td />
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td style={{ paddingLeft: '25px' }}>
|
||||
<Skeleton width={getRandomInRange(60, 100, randomValue)} />
|
||||
</td>
|
||||
<td>
|
||||
<Skeleton width={getRandomInRange(80, 320, randomValue)} />
|
||||
</td>
|
||||
</tr>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</ScrollContainer>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
|
||||
return {
|
||||
container: css({
|
||||
padding: '36px 79px',
|
||||
padding: theme.spacing(4),
|
||||
background: theme.components.panel.background,
|
||||
}),
|
||||
footer: css({
|
||||
@@ -22,7 +22,7 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
}),
|
||||
header: css({
|
||||
height: '137px',
|
||||
padding: '40px 0 0 79px',
|
||||
padding: theme.spacing(4, 0, 0, 4),
|
||||
position: 'relative',
|
||||
background: `url('${backgroundUrl}') right`,
|
||||
}),
|
||||
@@ -52,7 +52,7 @@ export function LicenseChrome({ header, editionNotice, subheader, children }: Pr
|
||||
background: '#0A1C36',
|
||||
position: 'absolute',
|
||||
top: '19px',
|
||||
left: '71%',
|
||||
right: '5%',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useEffect, useState } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { config, GrafanaBootConfig } from '@grafana/runtime';
|
||||
import { LinkButton, useStyles2 } from '@grafana/ui';
|
||||
import { LinkButton, Stack, useStyles2 } from '@grafana/ui';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
|
||||
@@ -43,7 +43,13 @@ export const ServerStats = () => {
|
||||
<Trans i18nKey="admin.server-settings.not-found">No stats found.</Trans>
|
||||
</p>
|
||||
) : (
|
||||
<div className={styles.row}>
|
||||
<Stack
|
||||
gap={2}
|
||||
direction={{
|
||||
xs: 'column',
|
||||
md: 'row',
|
||||
}}
|
||||
>
|
||||
<ServerStatsCard
|
||||
isLoading={isLoading}
|
||||
content={[
|
||||
@@ -59,7 +65,7 @@ export const ServerStats = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className={styles.doubleRow}>
|
||||
<Stack direction="column" gap={2}>
|
||||
<ServerStatsCard
|
||||
isLoading={isLoading}
|
||||
content={[{ name: 'Data sources', value: stats?.datasources }]}
|
||||
@@ -80,7 +86,7 @@ export const ServerStats = () => {
|
||||
</LinkButton>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
<ServerStatsCard
|
||||
isLoading={isLoading}
|
||||
content={[
|
||||
@@ -98,7 +104,7 @@ export const ServerStats = () => {
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@@ -133,27 +139,6 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
title: css({
|
||||
marginBottom: theme.spacing(4),
|
||||
}),
|
||||
row: css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
|
||||
'& > div:not(:last-of-type)': {
|
||||
marginRight: theme.spacing(2),
|
||||
},
|
||||
|
||||
'& > div': {
|
||||
width: '33.3%',
|
||||
},
|
||||
}),
|
||||
doubleRow: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
|
||||
'& > div:first-of-type': {
|
||||
marginBottom: theme.spacing(2),
|
||||
},
|
||||
}),
|
||||
notFound: css({
|
||||
fontSize: theme.typography.h6.fontSize,
|
||||
textAlign: 'center',
|
||||
|
||||
@@ -50,6 +50,7 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(2),
|
||||
margin: 0,
|
||||
padding: theme.spacing(2),
|
||||
}),
|
||||
indent: css({
|
||||
|
||||
@@ -29,6 +29,26 @@ export class ThresholdMapper {
|
||||
thresholds.push({ value: value, op: 'lt', visible });
|
||||
break;
|
||||
}
|
||||
case 'eq': {
|
||||
const value = evaluator.params[0];
|
||||
thresholds.push({ value: value, op: 'eq', visible });
|
||||
break;
|
||||
}
|
||||
case 'ne': {
|
||||
const value = evaluator.params[0];
|
||||
thresholds.push({ value: value, op: 'ne', visible });
|
||||
break;
|
||||
}
|
||||
case 'gte': {
|
||||
const value = evaluator.params[0];
|
||||
thresholds.push({ value: value, op: 'ge', visible });
|
||||
break;
|
||||
}
|
||||
case 'lte': {
|
||||
const value = evaluator.params[0];
|
||||
thresholds.push({ value: value, op: 'le', visible });
|
||||
break;
|
||||
}
|
||||
case 'outside_range': {
|
||||
const value1 = evaluator.params[0];
|
||||
const value2 = evaluator.params[1];
|
||||
@@ -56,6 +76,33 @@ export class ThresholdMapper {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'outside_range_included': {
|
||||
const value1 = evaluator.params[0];
|
||||
const value2 = evaluator.params[1];
|
||||
|
||||
if (value1 >= value2) {
|
||||
thresholds.push({ value: value1, op: 'ge', visible });
|
||||
thresholds.push({ value: value2, op: 'le', visible });
|
||||
} else {
|
||||
thresholds.push({ value: value1, op: 'le', visible });
|
||||
thresholds.push({ value: value2, op: 'ge', visible });
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case 'within_range_included': {
|
||||
const value1 = evaluator.params[0];
|
||||
const value2 = evaluator.params[1];
|
||||
|
||||
if (value1 >= value2) {
|
||||
thresholds.push({ value: value1, op: 'le', visible });
|
||||
thresholds.push({ value: value2, op: 'ge', visible });
|
||||
} else {
|
||||
thresholds.push({ value: value1, op: 'ge', visible });
|
||||
thresholds.push({ value: value2, op: 'le', visible });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -32,16 +32,28 @@ const alertStateSortScore = {
|
||||
export enum EvalFunction {
|
||||
'IsAbove' = 'gt',
|
||||
'IsBelow' = 'lt',
|
||||
'IsEqual' = 'eq',
|
||||
'IsNotEqual' = 'ne',
|
||||
'IsGreaterThanEqual' = 'gte',
|
||||
'IsLessThanEqual' = 'lte',
|
||||
'IsOutsideRange' = 'outside_range',
|
||||
'IsWithinRange' = 'within_range',
|
||||
'IsWithinRangeIncluded' = 'within_range_included',
|
||||
'IsOutsideRangeIncluded' = 'outside_range_included',
|
||||
'HasNoValue' = 'no_value',
|
||||
}
|
||||
|
||||
const evalFunctions = [
|
||||
{ value: EvalFunction.IsAbove, text: 'IS ABOVE' },
|
||||
{ value: EvalFunction.IsBelow, text: 'IS BELOW' },
|
||||
{ value: EvalFunction.IsEqual, text: 'IS EQUAL TO' },
|
||||
{ value: EvalFunction.IsNotEqual, text: 'IS NOT EQUAL TO' },
|
||||
{ value: EvalFunction.IsGreaterThanEqual, text: 'IS ABOVE OR EQUAL TO' },
|
||||
{ value: EvalFunction.IsLessThanEqual, text: 'IS BELOW OR EQUAL TO' },
|
||||
{ value: EvalFunction.IsOutsideRange, text: 'IS OUTSIDE RANGE' },
|
||||
{ value: EvalFunction.IsWithinRange, text: 'IS WITHIN RANGE' },
|
||||
{ value: EvalFunction.IsOutsideRangeIncluded, text: 'IS OUTSIDE RANGE INCLUDED' },
|
||||
{ value: EvalFunction.IsWithinRangeIncluded, text: 'IS WITHIN RANGE INCLUDED' },
|
||||
{ value: EvalFunction.HasNoValue, text: 'HAS NO VALUE' },
|
||||
];
|
||||
|
||||
|
||||
@@ -197,6 +197,7 @@ export const trackAlertRuleFormError = (
|
||||
export const trackNewGrafanaAlertRuleFormSavedSuccess = (payload: {
|
||||
simplifiedQueryEditor: boolean;
|
||||
simplifiedNotificationEditor: boolean;
|
||||
canBeTransformedToSimpleQuery: boolean;
|
||||
}) => {
|
||||
reportInteraction('grafana_alerting_grafana_rule_creation_new_success', payload);
|
||||
};
|
||||
|
||||
@@ -538,5 +538,10 @@ const getCommonQueryStyles = (theme: GrafanaTheme2) => ({
|
||||
});
|
||||
|
||||
function isRangeEvaluator(evaluator: { params: number[]; type: EvalFunction }) {
|
||||
return evaluator.type === EvalFunction.IsWithinRange || evaluator.type === EvalFunction.IsOutsideRange;
|
||||
return (
|
||||
evaluator.type === EvalFunction.IsWithinRange ||
|
||||
evaluator.type === EvalFunction.IsOutsideRange ||
|
||||
evaluator.type === EvalFunction.IsOutsideRangeIncluded ||
|
||||
evaluator.type === EvalFunction.IsWithinRangeIncluded
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ describe('annotations', () => {
|
||||
it('should fetch annotation for an alertId', () => {
|
||||
const ALERT_ID = 'abc123';
|
||||
fetchAnnotations(ALERT_ID);
|
||||
expect(get).toBeCalledWith('/api/annotations', { alertId: ALERT_ID });
|
||||
expect(get).toBeCalledWith('/api/annotations', { alertUID: ALERT_ID });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { getBackendSrv } from '@grafana/runtime';
|
||||
import { StateHistoryItem } from 'app/types/unified-alerting';
|
||||
|
||||
export function fetchAnnotations(alertId: string): Promise<StateHistoryItem[]> {
|
||||
export function fetchAnnotations(alertUID: string): Promise<StateHistoryItem[]> {
|
||||
return getBackendSrv()
|
||||
.get('/api/annotations', {
|
||||
alertId,
|
||||
alertUID,
|
||||
})
|
||||
.then((result) => {
|
||||
return result?.sort(sortStateHistory);
|
||||
|
||||
+14
-2
@@ -22,6 +22,7 @@ import {
|
||||
isGrafanaRulerRulePaused,
|
||||
isRecordingRuleByType,
|
||||
} from 'app/features/alerting/unified/utils/rules';
|
||||
import { isExpressionQuery } from 'app/features/expressions/guards';
|
||||
import { RuleGroupIdentifier, RuleIdentifier, RuleWithLocation } from 'app/types/unified-alerting';
|
||||
import { PostableRuleGrafanaRuleDTO, RulerRuleDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
@@ -45,6 +46,10 @@ import {
|
||||
formValuesFromPrefill,
|
||||
translateRouteParamToRuleType,
|
||||
} from '../../../rule-editor/formDefaults';
|
||||
import {
|
||||
areQueriesTransformableToSimpleCondition,
|
||||
isExpressionQueryInAlert,
|
||||
} from '../../../rule-editor/formProcessing';
|
||||
import { RuleFormType, RuleFormValues } from '../../../types/rule-form';
|
||||
import {
|
||||
MANUAL_ROUTING_KEY,
|
||||
@@ -155,13 +160,20 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
|
||||
if (!existing) {
|
||||
// when creating a new rule, we save the manual routing setting , and editorSettings.simplifiedQueryEditor to the local storage
|
||||
storeInLocalStorageValues(values);
|
||||
// save the rule to the rule group
|
||||
await addRuleToRuleGroup.execute(ruleGroupIdentifier, ruleDefinition, evaluateEvery);
|
||||
grafanaTypeRule &&
|
||||
// track the new Grafana-managed rule creation in the analytics
|
||||
if (grafanaTypeRule) {
|
||||
const dataQueries = values.queries.filter((query) => !isExpressionQuery(query.model));
|
||||
const expressionQueries = values.queries.filter((query) => isExpressionQueryInAlert(query));
|
||||
trackNewGrafanaAlertRuleFormSavedSuccess({
|
||||
simplifiedQueryEditor: values.editorSettings?.simplifiedQueryEditor ?? false,
|
||||
simplifiedNotificationEditor: values.editorSettings?.simplifiedNotificationEditor ?? false,
|
||||
}); // new Grafana-managed rule
|
||||
canBeTransformedToSimpleQuery: areQueriesTransformableToSimpleCondition(dataQueries, expressionQueries),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// when updating an existing rule
|
||||
const ruleIdentifier = fromRulerRuleAndRuleGroupIdentifier(ruleGroupIdentifier, existing.rule);
|
||||
await updateRuleInRuleGroup.execute(
|
||||
ruleGroupIdentifier,
|
||||
|
||||
@@ -279,6 +279,53 @@ export function getThresholdsForQueries(queries: AlertQuery[], condition: string
|
||||
);
|
||||
}
|
||||
|
||||
if (type === EvalFunction.IsWithinRangeIncluded) {
|
||||
thresholds[refId].config.steps.push(
|
||||
...[
|
||||
{
|
||||
value: -Infinity,
|
||||
color: 'transparent',
|
||||
},
|
||||
{
|
||||
value: values[0],
|
||||
color: config.theme2.colors.error.main,
|
||||
},
|
||||
{
|
||||
value: values[1],
|
||||
color: config.theme2.colors.error.main,
|
||||
},
|
||||
{
|
||||
value: values[1],
|
||||
color: 'transparent',
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
if (type === EvalFunction.IsOutsideRangeIncluded) {
|
||||
thresholds[refId].config.steps.push(
|
||||
...[
|
||||
{
|
||||
value: -Infinity,
|
||||
color: config.theme2.colors.error.main,
|
||||
},
|
||||
// we have to duplicate this value, or the graph will not display the handle in the right color
|
||||
{
|
||||
value: values[0],
|
||||
color: config.theme2.colors.error.main,
|
||||
},
|
||||
{
|
||||
value: values[0],
|
||||
color: 'transparent',
|
||||
},
|
||||
{
|
||||
value: values[1],
|
||||
color: config.theme2.colors.error.main,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// now also sort the threshold values, if we don't then they will look weird in the time series panel
|
||||
// TODO this doesn't work for negative values for now, those need to be sorted inverse
|
||||
thresholds[refId].config.steps.sort((a, b) => a.value - b.value);
|
||||
@@ -292,7 +339,10 @@ export function getThresholdsForQueries(queries: AlertQuery[], condition: string
|
||||
|
||||
function isRangeCondition(condition: ClassicCondition) {
|
||||
return (
|
||||
condition.evaluator.type === EvalFunction.IsWithinRange || condition.evaluator.type === EvalFunction.IsOutsideRange
|
||||
condition.evaluator.type === EvalFunction.IsWithinRange ||
|
||||
condition.evaluator.type === EvalFunction.IsOutsideRange ||
|
||||
condition.evaluator.type === EvalFunction.IsOutsideRangeIncluded ||
|
||||
condition.evaluator.type === EvalFunction.IsWithinRangeIncluded
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,13 +26,12 @@ const History = ({ rule }: HistoryProps) => {
|
||||
? StateHistoryImplementation.Loki
|
||||
: StateHistoryImplementation.Annotations;
|
||||
|
||||
const ruleID = rule.grafana_alert.id ?? '';
|
||||
const ruleUID = rule.grafana_alert.uid;
|
||||
|
||||
return (
|
||||
<Suspense fallback={'Loading...'}>
|
||||
{implementation === StateHistoryImplementation.Loki && <LokiStateHistory ruleUID={ruleUID} />}
|
||||
{implementation === StateHistoryImplementation.Annotations && <AnnotationsStateHistory alertId={ruleID} />}
|
||||
{implementation === StateHistoryImplementation.Annotations && <AnnotationsStateHistory ruleUID={ruleUID} />}
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
+3
-3
@@ -27,16 +27,16 @@ type StateHistoryMap = Record<string, StateHistoryRowItem[]>;
|
||||
type StateHistoryRow = DynamicTableItemProps<StateHistoryRowItem>;
|
||||
|
||||
interface Props {
|
||||
alertId: string;
|
||||
ruleUID: string;
|
||||
}
|
||||
|
||||
const StateHistory = ({ alertId }: Props) => {
|
||||
const StateHistory = ({ ruleUID }: Props) => {
|
||||
const [textFilter, setTextFilter] = useState<string>('');
|
||||
const handleTextFilter = useCallback((event: FormEvent<HTMLInputElement>) => {
|
||||
setTextFilter(event.currentTarget.value);
|
||||
}, []);
|
||||
|
||||
const { loading, error, result = [] } = useManagedAlertStateHistory(alertId);
|
||||
const { loading, error, result = [] } = useManagedAlertStateHistory(ruleUID);
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
|
||||
@@ -8,15 +8,15 @@ import { AsyncRequestState } from '../utils/redux';
|
||||
|
||||
import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector';
|
||||
|
||||
export function useManagedAlertStateHistory(alertId: string) {
|
||||
export function useManagedAlertStateHistory(ruleUID: string) {
|
||||
const dispatch = useDispatch();
|
||||
const history = useUnifiedAlertingSelector<AsyncRequestState<StateHistoryItem[]>>(
|
||||
(state) => state.managedAlertStateHistory
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchGrafanaAnnotationsAction(alertId));
|
||||
}, [dispatch, alertId]);
|
||||
dispatch(fetchGrafanaAnnotationsAction(ruleUID));
|
||||
}, [dispatch, ruleUID]);
|
||||
|
||||
return history;
|
||||
}
|
||||
@@ -61,7 +61,7 @@ function useStateHistoryModal() {
|
||||
<Suspense fallback={'Loading...'}>
|
||||
{implementation === StateHistoryImplementation.Loki && <LokiStateHistory ruleUID={rule.grafana_alert.uid} />}
|
||||
{implementation === StateHistoryImplementation.Annotations && (
|
||||
<AnnotationsStateHistory alertId={rule.grafana_alert.id ?? ''} />
|
||||
<AnnotationsStateHistory ruleUID={rule.grafana_alert.id ?? ''} />
|
||||
)}
|
||||
</Suspense>
|
||||
</Modal>
|
||||
|
||||
@@ -183,7 +183,7 @@ export function fetchAllPromRulesAction(
|
||||
|
||||
export const fetchGrafanaAnnotationsAction = createAsyncThunk(
|
||||
'unifiedalerting/fetchGrafanaAnnotations',
|
||||
(alertId: string): Promise<StateHistoryItem[]> => withSerializedError(fetchAnnotations(alertId))
|
||||
(ruleUID: string): Promise<StateHistoryItem[]> => withSerializedError(fetchAnnotations(ruleUID))
|
||||
);
|
||||
|
||||
interface UpdateAlertManagerConfigActionOptions {
|
||||
|
||||
@@ -1,12 +1,41 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { PluginType } from '@grafana/data';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import UpdateAllButton from 'app/features/plugins/admin/components/UpdateAllButton';
|
||||
import UpdateAllModal from 'app/features/plugins/admin/components/UpdateAllModal';
|
||||
import { useGetUpdatable } from 'app/features/plugins/admin/state/hooks';
|
||||
|
||||
import { AddNewConnection } from '../tabs/ConnectData';
|
||||
|
||||
export function AddNewConnectionPage() {
|
||||
const { isLoading: areUpdatesLoading, updatablePlugins } = useGetUpdatable();
|
||||
const updatableDSPlugins = updatablePlugins.filter((plugin) => plugin.type === PluginType.datasource);
|
||||
const [showUpdateModal, setShowUpdateModal] = useState(false);
|
||||
const disableUpdateAllButton = updatableDSPlugins.length <= 0 || areUpdatesLoading;
|
||||
|
||||
const onUpdateAll = () => {
|
||||
setShowUpdateModal(true);
|
||||
};
|
||||
|
||||
const updateAllButton = (
|
||||
<UpdateAllButton
|
||||
disabled={disableUpdateAllButton}
|
||||
onUpdateAll={onUpdateAll}
|
||||
updatablePluginsLength={updatableDSPlugins.length}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Page navId={'connections-add-new-connection'}>
|
||||
<Page navId={'connections-add-new-connection'} actions={updateAllButton}>
|
||||
<Page.Contents>
|
||||
<AddNewConnection />
|
||||
<UpdateAllModal
|
||||
isOpen={showUpdateModal}
|
||||
isLoading={areUpdatesLoading}
|
||||
onDismiss={() => setShowUpdateModal(false)}
|
||||
plugins={updatableDSPlugins}
|
||||
/>
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -2,8 +2,19 @@ import { css } from '@emotion/css';
|
||||
import * as React from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Card, Grid, useStyles2 } from '@grafana/ui';
|
||||
import { PluginAngularBadge } from 'app/features/plugins/admin/components/Badges';
|
||||
import { featureEnabled } from '@grafana/runtime';
|
||||
import { Card, Grid, useStyles2, Stack, Badge } from '@grafana/ui';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import {
|
||||
PluginAngularBadge,
|
||||
PluginDeprecatedBadge,
|
||||
PluginDisabledBadge,
|
||||
PluginInstalledBadge,
|
||||
PluginUpdateAvailableBadge,
|
||||
} from 'app/features/plugins/admin/components/Badges';
|
||||
import { getBadgeColor } from 'app/features/plugins/admin/components/Badges/sharedStyles';
|
||||
import { isPluginUpdatable } from 'app/features/plugins/admin/helpers';
|
||||
import { CatalogPlugin } from 'app/features/plugins/admin/types';
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
heading: css({
|
||||
@@ -40,13 +51,28 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
}),
|
||||
});
|
||||
|
||||
export type CardGridItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
url: string;
|
||||
function PluginEnterpriseBadgeWithoutSignature() {
|
||||
const customBadgeStyles = useStyles2(getBadgeColor);
|
||||
|
||||
if (featureEnabled('enterprise.plugins')) {
|
||||
return <Badge text={t('get-enterprise.title', 'Enterprise')} color="blue" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge
|
||||
icon="lock"
|
||||
role="img"
|
||||
aria-label={t('lock-icon', 'lock icon')}
|
||||
text={t('get-enterprise.title', 'Enterprise')}
|
||||
color="darkgrey"
|
||||
className={customBadgeStyles}
|
||||
title={t('get-enterprise.requires-license', 'Requires a Grafana Enterprise license')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export type CardGridItem = CatalogPlugin & {
|
||||
logo?: string;
|
||||
angularDetected?: boolean;
|
||||
};
|
||||
|
||||
export interface CardGridProps {
|
||||
@@ -75,12 +101,16 @@ export const CardGrid = ({ items, onClickItem }: CardGridProps) => {
|
||||
<Card.Figure align="center" className={styles.figure}>
|
||||
<img className={styles.logo} src={item.logo} alt="" />
|
||||
</Card.Figure>
|
||||
|
||||
{item.angularDetected ? (
|
||||
<Card.Meta className={styles.meta}>
|
||||
<PluginAngularBadge />
|
||||
</Card.Meta>
|
||||
) : null}
|
||||
<Card.Meta className={styles.meta}>
|
||||
<Stack height="auto" wrap="wrap">
|
||||
{item.isEnterprise && <PluginEnterpriseBadgeWithoutSignature />}
|
||||
{item.isDeprecated && <PluginDeprecatedBadge />}
|
||||
{item.isInstalled && <PluginInstalledBadge />}
|
||||
{item.isDisabled && <PluginDisabledBadge error={item.error} />}
|
||||
{isPluginUpdatable(item) && <PluginUpdateAvailableBadge plugin={item} />}
|
||||
{item.angularDetected && <PluginAngularBadge />}
|
||||
</Stack>
|
||||
</Card.Meta>
|
||||
</Card>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import { TestProvider } from 'test/helpers/TestProvider';
|
||||
|
||||
import { PluginType } from '@grafana/data';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { getCatalogPluginMock, getPluginsStateMock } from 'app/features/plugins/admin/__mocks__';
|
||||
import { CatalogPlugin } from 'app/features/plugins/admin/types';
|
||||
@@ -17,7 +18,8 @@ jest.mock('@grafana/runtime', () => ({
|
||||
useChromeHeaderHeight: jest.fn(),
|
||||
}));
|
||||
|
||||
const renderPage = (plugins: CatalogPlugin[] = []): RenderResult => {
|
||||
const renderPage = (plugins: CatalogPlugin[] = [], path = '/add-new-connection'): RenderResult => {
|
||||
locationService.push(path);
|
||||
return render(
|
||||
<TestProvider storeState={{ plugins: getPluginsStateMock(plugins) }}>
|
||||
<AddNewConnection />
|
||||
@@ -31,7 +33,7 @@ const mockCatalogDataSourcePlugin = getCatalogPluginMock({
|
||||
id: 'sample-data-source',
|
||||
});
|
||||
|
||||
describe('Angular badge', () => {
|
||||
describe('Badges', () => {
|
||||
test('does not show angular badge for non-angular plugins', async () => {
|
||||
renderPage([
|
||||
getCatalogPluginMock({
|
||||
@@ -61,6 +63,28 @@ describe('Angular badge', () => {
|
||||
});
|
||||
expect(screen.queryByText('Angular')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows enterprise and deprecated badges for plugins', async () => {
|
||||
renderPage([
|
||||
getCatalogPluginMock({
|
||||
id: 'test-plugin',
|
||||
name: 'test Plugin',
|
||||
type: PluginType.datasource,
|
||||
isEnterprise: true,
|
||||
}),
|
||||
getCatalogPluginMock({
|
||||
id: 'test2-plugin',
|
||||
name: 'test2 Plugin',
|
||||
type: PluginType.datasource,
|
||||
isDeprecated: true,
|
||||
}),
|
||||
]);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('test Plugin')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('Enterprise')).toBeVisible();
|
||||
expect(screen.queryByText('Deprecated')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Add new connection', () => {
|
||||
@@ -81,20 +105,57 @@ describe('Add new connection', () => {
|
||||
|
||||
expect(await screen.findByText('Sample data source')).toBeVisible();
|
||||
});
|
||||
test('should list plugins with update when filtering by update', async () => {
|
||||
const { queryByText } = renderPage(
|
||||
[
|
||||
getCatalogPluginMock({
|
||||
id: 'plugin-1',
|
||||
name: 'Plugin 1',
|
||||
isInstalled: true,
|
||||
hasUpdate: true,
|
||||
type: PluginType.datasource,
|
||||
}),
|
||||
getCatalogPluginMock({ id: 'plugin-2', name: 'Plugin 2', isInstalled: false }),
|
||||
getCatalogPluginMock({
|
||||
id: 'plugin-3',
|
||||
name: 'Plugin 3',
|
||||
isInstalled: true,
|
||||
hasUpdate: true,
|
||||
type: PluginType.datasource,
|
||||
}),
|
||||
getCatalogPluginMock({ id: 'plugin-4', name: 'Plugin 4', isInstalled: true, isCore: true }),
|
||||
],
|
||||
'/add-new-connection?filterBy=has-update'
|
||||
);
|
||||
|
||||
await waitFor(() => expect(queryByText('Plugin 1')).toBeInTheDocument());
|
||||
expect(queryByText('Plugin 3')).toBeInTheDocument();
|
||||
|
||||
expect(queryByText('Plugin 2')).not.toBeInTheDocument();
|
||||
expect(queryByText('Plugin 4')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders card if search term matches', async () => {
|
||||
renderPage([getCatalogPluginMock(), mockCatalogDataSourcePlugin]);
|
||||
const searchField = await screen.findByRole('textbox');
|
||||
renderPage(
|
||||
[
|
||||
getCatalogPluginMock({ type: PluginType.datasource, id: 'test1', name: 'test33' }),
|
||||
getCatalogPluginMock({ id: 'test2', type: PluginType.datasource, name: 'querymatches' }),
|
||||
],
|
||||
'/add-new-connection?filterBy=all&sortBy=nameAsc&search=querymatches'
|
||||
);
|
||||
expect(await screen.findByText('querymatches')).toBeVisible();
|
||||
});
|
||||
|
||||
await userEvent.type(searchField, 'ampl');
|
||||
expect(await screen.findByText('Sample data source')).toBeVisible();
|
||||
test('renders no results if search term does not match', async () => {
|
||||
renderPage(
|
||||
[
|
||||
getCatalogPluginMock({ type: PluginType.datasource, id: 'test1', name: 'test33' }),
|
||||
getCatalogPluginMock({ id: 'test2', type: PluginType.datasource, name: 'querymatches' }),
|
||||
],
|
||||
'/add-new-connection?filterBy=all&sortBy=nameAsc&search=dfvdfv'
|
||||
);
|
||||
|
||||
await userEvent.clear(searchField);
|
||||
await userEvent.type(searchField, 'cramp');
|
||||
expect(screen.queryByText('No results matching your query were found')).toBeInTheDocument();
|
||||
|
||||
await userEvent.clear(searchField);
|
||||
expect(await screen.findByText('Sample data source')).toBeVisible();
|
||||
expect(await screen.findByText('No results matching your query were found')).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows a "No access" modal if the user does not have permissions to create datasources', async () => {
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useMemo, useState, FormEvent, MouseEvent } from 'react';
|
||||
import { useMemo, useState, MouseEvent } from 'react';
|
||||
import { useLocation } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { GrafanaTheme2, PluginType } from '@grafana/data';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { useStyles2, LoadingPlaceholder, EmptyState } from '@grafana/ui';
|
||||
import { PluginType, GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import { locationSearchToObject, reportInteraction } from '@grafana/runtime';
|
||||
import { LoadingPlaceholder, EmptyState, Field, RadioButtonGroup, Tooltip, Combobox, useStyles2 } from '@grafana/ui';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { useQueryParams } from 'app/core/hooks/useQueryParams';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
import { HorizontalGroup } from 'app/features/plugins/admin/components/HorizontalGroup';
|
||||
import { RoadmapLinks } from 'app/features/plugins/admin/components/RoadmapLinks';
|
||||
import { useGetAll } from 'app/features/plugins/admin/state/hooks';
|
||||
import { SearchField } from 'app/features/plugins/admin/components/SearchField';
|
||||
import { Sorters } from 'app/features/plugins/admin/helpers';
|
||||
import { useHistory } from 'app/features/plugins/admin/hooks/useHistory';
|
||||
import { useGetAll, useIsRemotePluginsAvailable } from 'app/features/plugins/admin/state/hooks';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
|
||||
import { ROUTES } from '../../constants';
|
||||
@@ -16,7 +21,6 @@ import { ROUTES } from '../../constants';
|
||||
import { CardGrid, type CardGridItem } from './CardGrid';
|
||||
import { CategoryHeader } from './CategoryHeader';
|
||||
import { NoAccessModal } from './NoAccessModal';
|
||||
import { Search } from './Search';
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
spacer: css({
|
||||
@@ -28,6 +32,11 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
modalContent: css({
|
||||
overflow: 'visible',
|
||||
}),
|
||||
actionBar: css({
|
||||
[theme.breakpoints.up('xl')]: {
|
||||
marginLeft: 'auto',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
export function AddNewConnection() {
|
||||
@@ -35,32 +44,34 @@ export function AddNewConnection() {
|
||||
const searchTerm = queryParams.search ? String(queryParams.search) : '';
|
||||
const [isNoAccessModalOpen, setIsNoAccessModalOpen] = useState(false);
|
||||
const [focusedItem, setFocusedItem] = useState<CardGridItem | null>(null);
|
||||
const styles = useStyles2(getStyles);
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const locationSearch = locationSearchToObject(location.search);
|
||||
const sortBy = (locationSearch.sortBy as Sorters) || Sorters.nameAsc;
|
||||
const filterBy = locationSearch.filterBy?.toString() || 'all';
|
||||
const canCreateDataSources = contextSrv.hasPermission(AccessControlAction.DataSourcesCreate);
|
||||
|
||||
const handleSearchChange = (e: FormEvent<HTMLInputElement>) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const handleSearchChange = (val: string) => {
|
||||
setQueryParams({
|
||||
search: e.currentTarget.value.toLowerCase(),
|
||||
search: val,
|
||||
});
|
||||
};
|
||||
const remotePluginsAvailable = useIsRemotePluginsAvailable();
|
||||
|
||||
const { error, plugins, isLoading } = useGetAll({
|
||||
keyword: searchTerm,
|
||||
type: PluginType.datasource,
|
||||
});
|
||||
|
||||
const cardGridItems = useMemo(
|
||||
() =>
|
||||
plugins.map((plugin) => ({
|
||||
id: plugin.id,
|
||||
name: plugin.name,
|
||||
description: plugin.description,
|
||||
logo: plugin.info.logos.small,
|
||||
url: ROUTES.DataSourcesDetails.replace(':id', plugin.id),
|
||||
angularDetected: plugin.angularDetected,
|
||||
})),
|
||||
[plugins]
|
||||
const { error, plugins, isLoading } = useGetAll(
|
||||
{
|
||||
keyword: searchTerm,
|
||||
type: PluginType.datasource,
|
||||
isInstalled: filterBy === 'installed' ? true : undefined,
|
||||
hasUpdate: filterBy === 'has-update' ? true : undefined,
|
||||
},
|
||||
sortBy
|
||||
);
|
||||
const filterByOptions = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'installed', label: 'Installed' },
|
||||
{ value: 'has-update', label: 'New Updates' },
|
||||
];
|
||||
|
||||
const onClickCardGridItem = (e: MouseEvent<HTMLElement>, item: CardGridItem) => {
|
||||
if (!canCreateDataSources) {
|
||||
@@ -85,20 +96,86 @@ export function AddNewConnection() {
|
||||
setFocusedItem(null);
|
||||
};
|
||||
|
||||
const cardGridItems = useMemo(
|
||||
() =>
|
||||
plugins.map((plugin) => ({
|
||||
...plugin,
|
||||
logo: plugin.info.logos.small,
|
||||
url: ROUTES.DataSourcesDetails.replace(':id', plugin.id),
|
||||
})),
|
||||
[plugins]
|
||||
);
|
||||
|
||||
const onSortByChange = (value: SelectableValue<string>) => {
|
||||
history.push({ query: { sortBy: value.value } });
|
||||
};
|
||||
|
||||
const onFilterByChange = (value: string) => {
|
||||
history.push({ query: { filterBy: value } });
|
||||
};
|
||||
|
||||
const showNoResults = useMemo(() => !isLoading && !error && plugins.length < 1, [isLoading, error, plugins]);
|
||||
const categoryHeaderLabel = t('connections.connect-data.category-header-label', 'Data sources');
|
||||
|
||||
return (
|
||||
<>
|
||||
{focusedItem && <NoAccessModal item={focusedItem} isOpen={isNoAccessModalOpen} onDismiss={closeModal} />}
|
||||
<Search onChange={handleSearchChange} value={searchTerm} />
|
||||
{/* We need this extra spacing when there are no filters */}
|
||||
<div className={styles.spacer} />
|
||||
<HorizontalGroup wrap>
|
||||
<Field label={t('common.search', 'Search')}>
|
||||
<SearchField value={searchTerm} onSearch={handleSearchChange} />
|
||||
</Field>
|
||||
<HorizontalGroup className={styles.actionBar}>
|
||||
{/* Filter by installed / all */}
|
||||
{remotePluginsAvailable ? (
|
||||
<Field label={t('plugins.filter.state', 'State')}>
|
||||
<RadioButtonGroup value={filterBy} onChange={onFilterByChange} options={filterByOptions} />
|
||||
</Field>
|
||||
) : (
|
||||
<Tooltip
|
||||
content={t(
|
||||
'plugins.filter.disabled',
|
||||
'This filter has been disabled because the Grafana server cannot access grafana.com'
|
||||
)}
|
||||
placement="top"
|
||||
>
|
||||
<div>
|
||||
<Field label={t('plugins.filter.state', 'State')}>
|
||||
<RadioButtonGroup
|
||||
disabled={true}
|
||||
value={filterBy}
|
||||
onChange={onFilterByChange}
|
||||
options={filterByOptions}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Sorting */}
|
||||
<Field label={t('plugins.filter.sort', 'Sort')}>
|
||||
<Combobox
|
||||
aria-label={t('plugins.filter.sort-list', 'Sort Plugins List')}
|
||||
width={24}
|
||||
value={sortBy?.toString()}
|
||||
onChange={onSortByChange}
|
||||
options={[
|
||||
{ value: 'nameAsc', label: 'By name (A-Z)' },
|
||||
{ value: 'nameDesc', label: 'By name (Z-A)' },
|
||||
{ value: 'updated', label: 'By updated date' },
|
||||
{ value: 'published', label: 'By published date' },
|
||||
{ value: 'downloads', label: 'By downloads' },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
</HorizontalGroup>
|
||||
</HorizontalGroup>
|
||||
<CategoryHeader iconName="database" label={categoryHeaderLabel} />
|
||||
{isLoading ? (
|
||||
<LoadingPlaceholder text="Loading..." />
|
||||
<LoadingPlaceholder text={t('common.loading', 'Loading...')} />
|
||||
) : !!error ? (
|
||||
<p>Error: {error.message}</p>
|
||||
<Trans i18nKey="alerting.policies.update-errors.error-code" values={{ error: error.message }}>
|
||||
Error message: "{{ error: error.message }}"
|
||||
</Trans>
|
||||
) : (
|
||||
<CardGrid items={cardGridItems} onClickItem={onClickCardGridItem} />
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { waitFor } from '@testing-library/react';
|
||||
|
||||
import { withinQueryHistory } from './setup';
|
||||
import { withinQueryHistory, withinQueryLibrary } from './setup';
|
||||
|
||||
export const assertQueryHistoryExists = async (query: string) => {
|
||||
const selector = withinQueryHistory();
|
||||
@@ -23,10 +23,10 @@ export const assertQueryHistory = async (expectedQueryTexts: string[]) => {
|
||||
};
|
||||
|
||||
export const assertQueryLibraryTemplateExists = async (datasource: string, description: string) => {
|
||||
const selector = withinQueryHistory();
|
||||
const selector = withinQueryLibrary();
|
||||
await waitFor(() => {
|
||||
const cell = selector.getByRole('cell', {
|
||||
name: new RegExp(`query template for ${datasource.toLowerCase()}: ${description.toLowerCase()}`, 'i'),
|
||||
const cell = selector.getByRole('radio', {
|
||||
name: description,
|
||||
});
|
||||
|
||||
expect(cell).toBeInTheDocument();
|
||||
|
||||
@@ -43,8 +43,8 @@ export const openQueryLibrary = async () => {
|
||||
const button = screen.getByRole('button', { name: 'Add query from library' });
|
||||
await userEvent.click(button);
|
||||
await waitFor(async () => {
|
||||
screen.getByRole('tab', {
|
||||
name: /query library/i,
|
||||
screen.getByRole('dialog', {
|
||||
name: 'Drawer title Query library',
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -333,6 +333,11 @@ export const withinQueryHistory = () => {
|
||||
return within(container);
|
||||
};
|
||||
|
||||
export const withinQueryLibrary = () => {
|
||||
const container = screen.getByRole('dialog', { name: 'Drawer title Query library' });
|
||||
return within(container);
|
||||
};
|
||||
|
||||
const exploreTestsHelper: { setupExplore: typeof setupExplore; tearDownExplore?: (options?: TearDownOptions) => void } =
|
||||
{
|
||||
setupExplore,
|
||||
|
||||
@@ -69,7 +69,10 @@ export const Condition = ({ condition, index, onChange, onRemoveCondition, refId
|
||||
});
|
||||
|
||||
const isRange =
|
||||
condition.evaluator.type === EvalFunction.IsWithinRange || condition.evaluator.type === EvalFunction.IsOutsideRange;
|
||||
condition.evaluator.type === EvalFunction.IsWithinRange ||
|
||||
condition.evaluator.type === EvalFunction.IsOutsideRange ||
|
||||
condition.evaluator.type === EvalFunction.IsOutsideRangeIncluded ||
|
||||
condition.evaluator.type === EvalFunction.IsWithinRangeIncluded;
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
|
||||
@@ -5,8 +5,9 @@ import * as React from 'react';
|
||||
import { FormEvent, useEffect, useReducer } from 'react';
|
||||
|
||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import { InlineField, InlineFieldRow, InlineSwitch, Input, Select, useStyles2, Stack } from '@grafana/ui';
|
||||
import { InlineField, InlineFieldRow, InlineSwitch, Input, Select, Stack, useStyles2 } from '@grafana/ui';
|
||||
import { config } from 'app/core/config';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { EvalFunction } from 'app/features/alerting/state/alertDef';
|
||||
|
||||
import { ClassicCondition, ExpressionQuery, thresholdFunctions } from '../types';
|
||||
@@ -81,7 +82,9 @@ export const Threshold = ({ labelWidth, onChange, refIds, query, onError, useHys
|
||||
|
||||
const isRange =
|
||||
conditionInState.evaluator.type === EvalFunction.IsWithinRange ||
|
||||
conditionInState.evaluator.type === EvalFunction.IsOutsideRange;
|
||||
conditionInState.evaluator.type === EvalFunction.IsOutsideRange ||
|
||||
conditionInState.evaluator.type === EvalFunction.IsOutsideRangeIncluded ||
|
||||
conditionInState.evaluator.type === EvalFunction.IsWithinRangeIncluded;
|
||||
|
||||
const hysteresisEnabled = Boolean(config.featureToggles?.recoveryThreshold) && useHysteresis;
|
||||
|
||||
@@ -155,7 +158,7 @@ export const Threshold = ({ labelWidth, onChange, refIds, query, onError, useHys
|
||||
<div onMouseDown={onHysteresisCheckDown}>
|
||||
<InlineSwitch
|
||||
showLabel={true}
|
||||
label="Custom recovery threshold"
|
||||
label={t('alerting.rule-form.threshold.recovery.title', 'Custom recovery threshold')}
|
||||
value={hasHysteresis}
|
||||
onChange={onHysteresisCheckChange}
|
||||
className={styles.switch}
|
||||
@@ -211,111 +214,286 @@ function RecoveryThresholdRow({ isRange, condition, onError, dispatch, allowOnbl
|
||||
}
|
||||
|
||||
function RecoveryForRange({ allowOnblur }: RecoveryProps) {
|
||||
if (condition.evaluator.type === EvalFunction.IsWithinRange) {
|
||||
return (
|
||||
<InlineFieldRow className={styles.hysteresis}>
|
||||
<InlineField label="Stop alerting when outside range" labelWidth={'auto'}>
|
||||
<Stack direction="row" gap={0}>
|
||||
<div className={styles.range}>
|
||||
<InlineField invalid={Boolean(errorMsgFrom)} error={errorMsgFrom} className={styles.noMargin}>
|
||||
<Input
|
||||
type="number"
|
||||
width={10}
|
||||
onBlur={(event) => allowOnblur.current && onUnloadValueChange(event, 0)}
|
||||
defaultValue={condition.unloadEvaluator?.params[0]}
|
||||
/>
|
||||
</InlineField>
|
||||
</div>
|
||||
<ToLabel />
|
||||
<div className={styles.range}>
|
||||
<InlineField invalid={Boolean(errorMsgTo)} error={errorMsgTo}>
|
||||
<Input
|
||||
type="number"
|
||||
width={10}
|
||||
onBlur={(event) => allowOnblur.current && onUnloadValueChange(event, 1)}
|
||||
defaultValue={condition.unloadEvaluator?.params[1]}
|
||||
/>
|
||||
</InlineField>
|
||||
</div>
|
||||
</Stack>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<InlineFieldRow className={styles.hysteresis}>
|
||||
<InlineField label="Stop alerting when inside range" labelWidth={'auto'}>
|
||||
<Stack direction="row" gap={0}>
|
||||
<div className={styles.range}>
|
||||
<InlineField invalid={Boolean(errorMsgFrom)} error={errorMsgFrom}>
|
||||
<Input
|
||||
type="number"
|
||||
width={10}
|
||||
onBlur={(event) => allowOnblur.current && onUnloadValueChange(event, 0)}
|
||||
defaultValue={condition.unloadEvaluator?.params[0]}
|
||||
/>
|
||||
</InlineField>
|
||||
</div>
|
||||
switch (condition.evaluator.type) {
|
||||
case EvalFunction.IsWithinRange:
|
||||
if (condition.evaluator.type === EvalFunction.IsWithinRange) {
|
||||
return (
|
||||
<InlineFieldRow className={styles.hysteresis}>
|
||||
<InlineField
|
||||
label={t(
|
||||
'alerting.rule-form.threshold.recovery.stop-alerting-outside-range',
|
||||
'Stop alerting when outside range'
|
||||
)}
|
||||
labelWidth={'auto'}
|
||||
>
|
||||
<Stack direction="row" gap={0}>
|
||||
<div className={styles.range}>
|
||||
<InlineField invalid={Boolean(errorMsgFrom)} error={errorMsgFrom} className={styles.noMargin}>
|
||||
<Input
|
||||
type="number"
|
||||
width={10}
|
||||
onBlur={(event) => allowOnblur.current && onUnloadValueChange(event, 0)}
|
||||
defaultValue={condition.unloadEvaluator?.params[0]}
|
||||
/>
|
||||
</InlineField>
|
||||
</div>
|
||||
<ToLabel />
|
||||
<div className={styles.range}>
|
||||
<InlineField invalid={Boolean(errorMsgTo)} error={errorMsgTo}>
|
||||
<Input
|
||||
type="number"
|
||||
width={10}
|
||||
onBlur={(event) => allowOnblur.current && onUnloadValueChange(event, 1)}
|
||||
defaultValue={condition.unloadEvaluator?.params[1]}
|
||||
/>
|
||||
</InlineField>
|
||||
</div>
|
||||
</Stack>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
);
|
||||
}
|
||||
case EvalFunction.IsOutsideRange:
|
||||
return (
|
||||
<InlineFieldRow className={styles.hysteresis}>
|
||||
<InlineField
|
||||
label={t(
|
||||
'alerting.rule-form.threshold.recovery.stop-alerting-inside-range',
|
||||
'Stop alerting when inside range'
|
||||
)}
|
||||
labelWidth={'auto'}
|
||||
>
|
||||
<Stack direction="row" gap={0}>
|
||||
<div className={styles.range}>
|
||||
<InlineField invalid={Boolean(errorMsgFrom)} error={errorMsgFrom}>
|
||||
<Input
|
||||
type="number"
|
||||
width={10}
|
||||
onBlur={(event) => allowOnblur.current && onUnloadValueChange(event, 0)}
|
||||
defaultValue={condition.unloadEvaluator?.params[0]}
|
||||
/>
|
||||
</InlineField>
|
||||
</div>
|
||||
|
||||
<ToLabel />
|
||||
<div className={styles.range}>
|
||||
<InlineField invalid={Boolean(errorMsgTo)} error={errorMsgTo}>
|
||||
<Input
|
||||
type="number"
|
||||
width={10}
|
||||
onBlur={(event) => allowOnblur.current && onUnloadValueChange(event, 1)}
|
||||
defaultValue={condition.unloadEvaluator?.params[1]}
|
||||
/>
|
||||
</InlineField>
|
||||
</div>
|
||||
</Stack>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
);
|
||||
<ToLabel />
|
||||
<div className={styles.range}>
|
||||
<InlineField invalid={Boolean(errorMsgTo)} error={errorMsgTo}>
|
||||
<Input
|
||||
type="number"
|
||||
width={10}
|
||||
onBlur={(event) => allowOnblur.current && onUnloadValueChange(event, 1)}
|
||||
defaultValue={condition.unloadEvaluator?.params[1]}
|
||||
/>
|
||||
</InlineField>
|
||||
</div>
|
||||
</Stack>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
);
|
||||
case EvalFunction.IsOutsideRangeIncluded:
|
||||
return (
|
||||
<InlineFieldRow className={styles.hysteresis}>
|
||||
<InlineField
|
||||
label={t(
|
||||
'alerting.rule-form.threshold.recovery.stop-alerting-inside-range',
|
||||
'Stop alerting when inside range'
|
||||
)}
|
||||
labelWidth={'auto'}
|
||||
>
|
||||
<Stack direction="row" gap={0}>
|
||||
<div className={styles.range}>
|
||||
<InlineField invalid={Boolean(errorMsgFrom)} error={errorMsgFrom}>
|
||||
<Input
|
||||
type="number"
|
||||
width={10}
|
||||
onBlur={(event) => allowOnblur.current && onUnloadValueChange(event, 0)}
|
||||
defaultValue={condition.unloadEvaluator?.params[0]}
|
||||
/>
|
||||
</InlineField>
|
||||
</div>
|
||||
<ToLabel />
|
||||
<div className={styles.range}>
|
||||
<InlineField invalid={Boolean(errorMsgTo)} error={errorMsgTo}>
|
||||
<Input
|
||||
type="number"
|
||||
width={10}
|
||||
onBlur={(event) => allowOnblur.current && onUnloadValueChange(event, 1)}
|
||||
defaultValue={condition.unloadEvaluator?.params[1]}
|
||||
/>
|
||||
</InlineField>
|
||||
</div>
|
||||
</Stack>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
);
|
||||
case EvalFunction.IsWithinRangeIncluded:
|
||||
return (
|
||||
<InlineFieldRow className={styles.hysteresis}>
|
||||
<InlineField
|
||||
label={t(
|
||||
'alerting.rule-form.threshold.recovery.stop-alerting-outside-range',
|
||||
'Stop alerting when outside range'
|
||||
)}
|
||||
labelWidth={'auto'}
|
||||
>
|
||||
<Stack direction="row" gap={0}>
|
||||
<div className={styles.range}>
|
||||
<InlineField invalid={Boolean(errorMsgFrom)} error={errorMsgFrom}>
|
||||
<Input
|
||||
type="number"
|
||||
width={10}
|
||||
onBlur={(event) => allowOnblur.current && onUnloadValueChange(event, 0)}
|
||||
defaultValue={condition.unloadEvaluator?.params[0]}
|
||||
/>
|
||||
</InlineField>
|
||||
</div>
|
||||
<ToLabel />
|
||||
<div className={styles.range}>
|
||||
<InlineField invalid={Boolean(errorMsgTo)} error={errorMsgTo}>
|
||||
<Input
|
||||
type="number"
|
||||
width={10}
|
||||
onBlur={(event) => allowOnblur.current && onUnloadValueChange(event, 1)}
|
||||
defaultValue={condition.unloadEvaluator?.params[1]}
|
||||
/>
|
||||
</InlineField>
|
||||
</div>
|
||||
</Stack>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function RecoveryForSingleValue({ allowOnblur }: RecoveryProps) {
|
||||
if (condition.evaluator.type === EvalFunction.IsAbove) {
|
||||
return (
|
||||
<InlineFieldRow className={styles.hysteresis}>
|
||||
<InlineField
|
||||
label="Stop alerting when below"
|
||||
labelWidth={'auto'}
|
||||
invalid={Boolean(invalidErrorMsg)}
|
||||
error={invalidErrorMsg}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
width={10}
|
||||
onBlur={(event) => {
|
||||
allowOnblur.current && onUnloadValueChange(event, 0);
|
||||
}}
|
||||
defaultValue={condition.unloadEvaluator?.params[0]}
|
||||
/>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<InlineFieldRow className={styles.hysteresis}>
|
||||
<InlineField
|
||||
label="Stop alerting when above"
|
||||
labelWidth={'auto'}
|
||||
invalid={Boolean(invalidErrorMsg)}
|
||||
error={invalidErrorMsg}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
width={10}
|
||||
onBlur={(event) => {
|
||||
allowOnblur.current && onUnloadValueChange(event, 0);
|
||||
}}
|
||||
defaultValue={condition.unloadEvaluator?.params[0]}
|
||||
/>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
);
|
||||
switch (condition.evaluator.type) {
|
||||
case EvalFunction.IsAbove:
|
||||
return (
|
||||
<InlineFieldRow className={styles.hysteresis}>
|
||||
<InlineField
|
||||
label={t('alerting.rule-form.threshold.recovery.stop-alerting-bellow', 'Stop alerting when below')}
|
||||
labelWidth={'auto'}
|
||||
invalid={Boolean(invalidErrorMsg)}
|
||||
error={invalidErrorMsg}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
width={10}
|
||||
onBlur={(event) => {
|
||||
allowOnblur.current && onUnloadValueChange(event, 0);
|
||||
}}
|
||||
defaultValue={condition.unloadEvaluator?.params[0]}
|
||||
/>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
);
|
||||
case EvalFunction.IsBelow:
|
||||
return (
|
||||
<InlineFieldRow className={styles.hysteresis}>
|
||||
<InlineField
|
||||
label={t('alerting.rule-form.threshold.recovery.stop-alerting-above', 'Stop alerting when above')}
|
||||
labelWidth={'auto'}
|
||||
invalid={Boolean(invalidErrorMsg)}
|
||||
error={invalidErrorMsg}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
width={10}
|
||||
onBlur={(event) => {
|
||||
allowOnblur.current && onUnloadValueChange(event, 0);
|
||||
}}
|
||||
defaultValue={condition.unloadEvaluator?.params[0]}
|
||||
/>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
);
|
||||
case EvalFunction.IsEqual:
|
||||
return (
|
||||
<InlineFieldRow className={styles.hysteresis}>
|
||||
<InlineField
|
||||
label={t('alerting.rule-form.threshold.recovery.stop-alerting-equal', 'Stop alerting when equal to')}
|
||||
labelWidth={'auto'}
|
||||
invalid={Boolean(invalidErrorMsg)}
|
||||
error={invalidErrorMsg}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
width={10}
|
||||
onBlur={(event) => {
|
||||
allowOnblur.current && onUnloadValueChange(event, 0);
|
||||
}}
|
||||
defaultValue={condition.unloadEvaluator?.params[0]}
|
||||
/>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
);
|
||||
case EvalFunction.IsNotEqual:
|
||||
return (
|
||||
<InlineFieldRow className={styles.hysteresis}>
|
||||
<InlineField
|
||||
label={t(
|
||||
'alerting.rule-form.threshold.recovery.stop-alerting-not-equal',
|
||||
'Stop alerting when not equal to'
|
||||
)}
|
||||
labelWidth={'auto'}
|
||||
invalid={Boolean(invalidErrorMsg)}
|
||||
error={invalidErrorMsg}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
width={10}
|
||||
onBlur={(event) => {
|
||||
allowOnblur.current && onUnloadValueChange(event, 0);
|
||||
}}
|
||||
defaultValue={condition.unloadEvaluator?.params[0]}
|
||||
/>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
);
|
||||
case EvalFunction.IsGreaterThanEqual:
|
||||
return (
|
||||
<InlineFieldRow className={styles.hysteresis}>
|
||||
<InlineField
|
||||
label={t('alerting.rule-form.threshold.recovery.stop-alerting-less', 'Stop alerting when less than')}
|
||||
labelWidth={'auto'}
|
||||
invalid={Boolean(invalidErrorMsg)}
|
||||
error={invalidErrorMsg}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
width={10}
|
||||
onBlur={(event) => {
|
||||
allowOnblur.current && onUnloadValueChange(event, 0);
|
||||
}}
|
||||
defaultValue={condition.unloadEvaluator?.params[0]}
|
||||
/>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
);
|
||||
case EvalFunction.IsLessThanEqual:
|
||||
return (
|
||||
<InlineFieldRow className={styles.hysteresis}>
|
||||
<InlineField
|
||||
label={t('alerting.rule-form.threshold.recovery.stop-alerting-more', 'Stop alerting when more than')}
|
||||
labelWidth={'auto'}
|
||||
invalid={Boolean(invalidErrorMsg)}
|
||||
error={invalidErrorMsg}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
width={10}
|
||||
onBlur={(event) => {
|
||||
allowOnblur.current && onUnloadValueChange(event, 0);
|
||||
}}
|
||||
defaultValue={condition.unloadEvaluator?.params[0]}
|
||||
/>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,12 +104,30 @@ function getUnloadEvaluatorTypeFromEvaluatorType(type: EvalFunction) {
|
||||
if (type === EvalFunction.IsBelow) {
|
||||
return EvalFunction.IsAbove;
|
||||
}
|
||||
if (type === EvalFunction.IsEqual) {
|
||||
return EvalFunction.IsNotEqual;
|
||||
}
|
||||
if (type === EvalFunction.IsNotEqual) {
|
||||
return EvalFunction.IsEqual;
|
||||
}
|
||||
if (type === EvalFunction.IsGreaterThanEqual) {
|
||||
return EvalFunction.IsLessThanEqual;
|
||||
}
|
||||
if (type === EvalFunction.IsLessThanEqual) {
|
||||
return EvalFunction.IsGreaterThanEqual;
|
||||
}
|
||||
if (type === EvalFunction.IsWithinRange) {
|
||||
return EvalFunction.IsOutsideRange;
|
||||
}
|
||||
if (type === EvalFunction.IsOutsideRange) {
|
||||
return EvalFunction.IsWithinRange;
|
||||
}
|
||||
if (type === EvalFunction.IsWithinRangeIncluded) {
|
||||
return EvalFunction.IsOutsideRangeIncluded;
|
||||
}
|
||||
if (type === EvalFunction.IsOutsideRangeIncluded) {
|
||||
return EvalFunction.IsWithinRangeIncluded;
|
||||
}
|
||||
return EvalFunction.IsBelow;
|
||||
}
|
||||
|
||||
@@ -126,7 +144,12 @@ export function isInvalid(condition: ClassicCondition) {
|
||||
const { type, params: loadParams } = evaluator;
|
||||
const { params: unloadParams } = unloadEvaluator;
|
||||
|
||||
if (type === EvalFunction.IsWithinRange || type === EvalFunction.IsOutsideRange) {
|
||||
if (
|
||||
type === EvalFunction.IsWithinRange ||
|
||||
type === EvalFunction.IsOutsideRange ||
|
||||
type === EvalFunction.IsWithinRangeIncluded ||
|
||||
type === EvalFunction.IsOutsideRangeIncluded
|
||||
) {
|
||||
if (unloadParams[0] === undefined || Number.isNaN(unloadParams[0])) {
|
||||
return { errorMsgFrom: 'This value cannot be empty' };
|
||||
}
|
||||
@@ -149,6 +172,26 @@ export function isInvalid(condition: ClassicCondition) {
|
||||
return { errorMsg: `Enter a number more than or equal to ${firstParamInEvaluator}` };
|
||||
}
|
||||
break;
|
||||
case EvalFunction.IsEqual:
|
||||
if (firstParamInUnloadEvaluator === firstParamInEvaluator) {
|
||||
return { errorMsg: `Enter a different number than ${firstParamInEvaluator}` };
|
||||
}
|
||||
break;
|
||||
case EvalFunction.IsNotEqual:
|
||||
if (firstParamInUnloadEvaluator !== firstParamInEvaluator) {
|
||||
return { errorMsg: `Enter the same number as ${firstParamInEvaluator}` };
|
||||
}
|
||||
break;
|
||||
case EvalFunction.IsGreaterThanEqual:
|
||||
if (firstParamInUnloadEvaluator >= firstParamInEvaluator) {
|
||||
return { errorMsg: `Enter a number less than ${firstParamInEvaluator}` };
|
||||
}
|
||||
break;
|
||||
case EvalFunction.IsLessThanEqual:
|
||||
if (firstParamInUnloadEvaluator <= firstParamInEvaluator) {
|
||||
return { errorMsg: `Enter a number more than ${firstParamInEvaluator}` };
|
||||
}
|
||||
break;
|
||||
case EvalFunction.IsOutsideRange:
|
||||
if (firstParamInUnloadEvaluator < firstParamInEvaluator) {
|
||||
return { errorMsgFrom: `Enter a number more than or equal to ${firstParamInEvaluator}` };
|
||||
@@ -165,6 +208,22 @@ export function isInvalid(condition: ClassicCondition) {
|
||||
return { errorMsgTo: `Enter a number be more than or equal to ${secondParamInEvaluator}` };
|
||||
}
|
||||
break;
|
||||
case EvalFunction.IsOutsideRangeIncluded:
|
||||
if (firstParamInUnloadEvaluator <= firstParamInEvaluator) {
|
||||
return { errorMsgFrom: `Enter a number more than ${firstParamInEvaluator}` };
|
||||
}
|
||||
if (secondParamInUnloadEvaluator >= secondParamInEvaluator) {
|
||||
return { errorMsgTo: `Enter a number less than ${secondParamInEvaluator}` };
|
||||
}
|
||||
break;
|
||||
case EvalFunction.IsWithinRangeIncluded:
|
||||
if (firstParamInUnloadEvaluator >= firstParamInEvaluator) {
|
||||
return { errorMsgFrom: `Enter a number less than ${firstParamInEvaluator}` };
|
||||
}
|
||||
if (secondParamInUnloadEvaluator <= secondParamInEvaluator) {
|
||||
return { errorMsgTo: `Enter a number be more than ${secondParamInEvaluator}` };
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new Error(`evaluator function type ${type} not supported.`);
|
||||
}
|
||||
|
||||
@@ -126,8 +126,14 @@ export const upsamplingTypes: Array<SelectableValue<string>> = [
|
||||
export const thresholdFunctions: Array<SelectableValue<EvalFunction>> = [
|
||||
{ value: EvalFunction.IsAbove, label: 'Is above' },
|
||||
{ value: EvalFunction.IsBelow, label: 'Is below' },
|
||||
{ value: EvalFunction.IsEqual, label: 'Is equal to' },
|
||||
{ value: EvalFunction.IsNotEqual, label: 'Is not equal to' },
|
||||
{ value: EvalFunction.IsGreaterThanEqual, label: 'Is above or equal to' },
|
||||
{ value: EvalFunction.IsLessThanEqual, label: 'Is below or equal to' },
|
||||
{ value: EvalFunction.IsWithinRange, label: 'Is within range' },
|
||||
{ value: EvalFunction.IsOutsideRange, label: 'Is outside range' },
|
||||
{ value: EvalFunction.IsWithinRangeIncluded, label: 'Is within range included' },
|
||||
{ value: EvalFunction.IsOutsideRangeIncluded, label: 'Is outside range included' },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useCallback } from 'react';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
import { ConfirmModal, EmptyState, TextLink } from '@grafana/ui';
|
||||
import { ConfirmModal, EmptyState, ScrollContainer, TextLink } from '@grafana/ui';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
import { getDashboardSnapshotSrv, Snapshot } from 'app/features/dashboard/services/SnapshotSrv';
|
||||
|
||||
@@ -62,7 +62,7 @@ export const SnapshotListTable = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ScrollContainer overflowY="visible" overflowX="auto" width="100%">
|
||||
<table className="filter-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -112,6 +112,6 @@ export const SnapshotListTable = () => {
|
||||
setRemoveSnapshot(undefined);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ScrollContainer>
|
||||
);
|
||||
};
|
||||
@@ -1,59 +0,0 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { useParams, useNavigationType } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { locationService } from '@grafana/runtime';
|
||||
|
||||
import { playlistSrv } from './PlaylistSrv';
|
||||
import PlaylistStartPage from './PlaylistStartPage';
|
||||
|
||||
// Mock the dependencies
|
||||
jest.mock('react-router-dom-v5-compat', () => ({
|
||||
useParams: jest.fn(),
|
||||
useNavigationType: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
locationService: {
|
||||
getHistory: jest.fn().mockReturnValue({
|
||||
goBack: jest.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('./PlaylistSrv', () => ({
|
||||
playlistSrv: {
|
||||
start: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('PlaylistStartPage', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks(); // Clear mocks after each test
|
||||
});
|
||||
|
||||
it('should call locationService.getHistory().goBack() when navigationType is "POP"', () => {
|
||||
// Arrange: Set up the mock return value for useParams and useNavigationType
|
||||
(useParams as jest.Mock).mockReturnValue({ uid: '123' });
|
||||
(useNavigationType as jest.Mock).mockReturnValue('POP');
|
||||
|
||||
// Act: Render the component
|
||||
render(<PlaylistStartPage />);
|
||||
|
||||
// Assert: Check if goBack was called
|
||||
expect(locationService.getHistory().goBack).toHaveBeenCalledTimes(1);
|
||||
expect(playlistSrv.start).not.toHaveBeenCalled(); // Should not call start
|
||||
});
|
||||
|
||||
it('should call playlistSrv.start() when navigationType is not "POP"', () => {
|
||||
// Arrange: Set up the mock return value for useParams and useNavigationType
|
||||
(useParams as jest.Mock).mockReturnValue({ uid: '123' });
|
||||
(useNavigationType as jest.Mock).mockReturnValue('PUSH'); // or any other type
|
||||
|
||||
// Act: Render the component
|
||||
render(<PlaylistStartPage />);
|
||||
|
||||
// Assert: Check if playlistSrv.start was called with correct uid
|
||||
expect(playlistSrv.start).toHaveBeenCalledWith('123');
|
||||
expect(locationService.getHistory().goBack).not.toHaveBeenCalled(); // Should not call goBack
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,10 @@
|
||||
import { useNavigationType, useParams } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { useParams } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { playlistSrv } from './PlaylistSrv';
|
||||
|
||||
// This is a react page that just redirects to new URLs
|
||||
export default function PlaylistStartPage() {
|
||||
const { uid = '' } = useParams();
|
||||
const navigationType = useNavigationType();
|
||||
if (navigationType === 'POP') {
|
||||
locationService.getHistory().goBack();
|
||||
} else {
|
||||
playlistSrv.start(uid);
|
||||
}
|
||||
playlistSrv.start(uid);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
|
||||
import UpdateAllButton from './UpdateAllButton';
|
||||
|
||||
describe('UpdateAllButton', () => {
|
||||
const onUpdateAllMock = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
onUpdateAllMock.mockClear();
|
||||
});
|
||||
|
||||
it('should display "No updates available" when disabled', () => {
|
||||
render(<UpdateAllButton disabled={true} onUpdateAll={onUpdateAllMock} updatablePluginsLength={0} />);
|
||||
|
||||
expect(screen.getByText('No updates available')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should display update count and be clickable when enabled', () => {
|
||||
render(<UpdateAllButton disabled={false} onUpdateAll={onUpdateAllMock} updatablePluginsLength={3} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveTextContent('Update all (3)');
|
||||
expect(button).toBeEnabled();
|
||||
|
||||
fireEvent.click(button);
|
||||
expect(onUpdateAllMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Button } from '@grafana/ui';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
|
||||
interface UpdateAllButtonProps {
|
||||
disabled: boolean;
|
||||
onUpdateAll: () => void;
|
||||
updatablePluginsLength: number;
|
||||
}
|
||||
|
||||
const UpdateAllButton = ({ disabled, onUpdateAll, updatablePluginsLength }: UpdateAllButtonProps) => {
|
||||
return (
|
||||
<Button disabled={disabled} onClick={onUpdateAll}>
|
||||
{disabled ? (
|
||||
<Trans i18nKey="plugins.catalog.no-updates-available">No updates available</Trans>
|
||||
) : (
|
||||
<Trans i18nKey="plugins.catalog.update-all.button" values={{ length: updatablePluginsLength }}>
|
||||
Update all ({{ length }})
|
||||
</Trans>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdateAllButton;
|
||||
@@ -4,9 +4,8 @@ import { useLocation } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { SelectableValue, GrafanaTheme2, PluginType } from '@grafana/data';
|
||||
import { locationSearchToObject } from '@grafana/runtime';
|
||||
import { Select, RadioButtonGroup, useStyles2, Tooltip, Field, Button } from '@grafana/ui';
|
||||
import { Select, RadioButtonGroup, useStyles2, Tooltip, Field } from '@grafana/ui';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
import { getNavModel } from 'app/core/selectors/navModel';
|
||||
import { ROUTES as CONNECTIONS_ROUTES } from 'app/features/connections/constants';
|
||||
import { useSelector } from 'app/types';
|
||||
@@ -15,6 +14,7 @@ import { HorizontalGroup } from '../components/HorizontalGroup';
|
||||
import { PluginList } from '../components/PluginList';
|
||||
import { RoadmapLinks } from '../components/RoadmapLinks';
|
||||
import { SearchField } from '../components/SearchField';
|
||||
import UpdateAllButton from '../components/UpdateAllButton';
|
||||
import { UpdateAllModal } from '../components/UpdateAllModal';
|
||||
import { Sorters } from '../helpers';
|
||||
import { useHistory } from '../hooks/useHistory';
|
||||
@@ -86,15 +86,17 @@ export default function Browse() {
|
||||
.
|
||||
</div>
|
||||
);
|
||||
const updateAll = (
|
||||
<Button disabled={disableUpdateAllButton} onClick={onUpdateAll}>
|
||||
<Trans i18nKey="plugins.catalog.update-all.button">Update all</Trans>
|
||||
{disableUpdateAllButton ? '' : ` (${updatablePlugins.length})`}
|
||||
</Button>
|
||||
|
||||
const updateAllButton = (
|
||||
<UpdateAllButton
|
||||
disabled={disableUpdateAllButton}
|
||||
onUpdateAll={onUpdateAll}
|
||||
updatablePluginsLength={updatablePlugins.length}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Page navModel={navModel} actions={updateAll} subTitle={subTitle}>
|
||||
<Page navModel={navModel} actions={updateAllButton} subTitle={subTitle}>
|
||||
<Page.Contents>
|
||||
<HorizontalGroup wrap>
|
||||
<Field label="Search">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Button, LoadingPlaceholder } from '@grafana/ui';
|
||||
import { Button, LoadingPlaceholder, ScrollContainer } from '@grafana/ui';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
import { UserDTO, UserOrg } from 'app/types';
|
||||
|
||||
@@ -30,46 +30,48 @@ export class UserOrganizations extends PureComponent<Props> {
|
||||
<Trans i18nKey="user-orgs.title">Organizations</Trans>
|
||||
</h3>
|
||||
|
||||
<table className="filter-table form-inline" data-testid={selectors.components.UserProfile.orgsTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<Trans i18nKey="user-orgs.name-column">Name</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans i18nKey="user-orgs.role-column">Role</Trans>
|
||||
</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{orgs.map((org: UserOrg, index) => {
|
||||
return (
|
||||
<tr key={index}>
|
||||
<td>{org.name}</td>
|
||||
<td>{org.role}</td>
|
||||
<td className="text-right">
|
||||
{org.orgId === user?.orgId ? (
|
||||
<Button variant="secondary" size="sm" disabled>
|
||||
<Trans i18nKey="user-orgs.current-org-button">Current</Trans>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
this.props.setUserOrg(org);
|
||||
}}
|
||||
>
|
||||
<Trans i18nKey="user-orgs.select-org-button">Select organisation</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<ScrollContainer overflowY="visible" overflowX="auto" width="100%">
|
||||
<table className="filter-table form-inline" data-testid={selectors.components.UserProfile.orgsTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<Trans i18nKey="user-orgs.name-column">Name</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans i18nKey="user-orgs.role-column">Role</Trans>
|
||||
</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{orgs.map((org: UserOrg, index) => {
|
||||
return (
|
||||
<tr key={index}>
|
||||
<td>{org.name}</td>
|
||||
<td>{org.role}</td>
|
||||
<td className="text-right">
|
||||
{org.orgId === user?.orgId ? (
|
||||
<Button variant="secondary" size="sm" disabled>
|
||||
<Trans i18nKey="user-orgs.current-org-button">Current</Trans>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
this.props.setUserOrg(org);
|
||||
}}
|
||||
>
|
||||
<Trans i18nKey="user-orgs.select-org-button">Select organisation</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</ScrollContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { css } from '@emotion/css';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Button, Icon, LoadingPlaceholder } from '@grafana/ui';
|
||||
import { Button, Icon, LoadingPlaceholder, ScrollContainer } from '@grafana/ui';
|
||||
import { TagBadge } from 'app/core/components/TagFilter/TagBadge';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
import { formatDate } from 'app/core/internationalization/dates';
|
||||
@@ -28,55 +28,57 @@ class UserSessions extends PureComponent<Props> {
|
||||
{sessions.length > 0 && (
|
||||
<>
|
||||
<h3 className="page-sub-heading">Sessions</h3>
|
||||
<table className="filter-table form-inline" data-testid={selectors.components.UserProfile.sessionsTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<Trans i18nKey="user-session.seen-at-column">Last seen</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans i18nKey="user-session.created-at-column">Logged on</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans i18nKey="user-session.ip-column">IP address</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans i18nKey="user-session.browser-column">Browser & OS</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans i18nKey="user-session.identity-provider-column">Identity Provider</Trans>
|
||||
</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{sessions.map((session: UserSession, index) => (
|
||||
<tr key={index}>
|
||||
{session.isActive ? <td>Now</td> : <td>{session.seenAt}</td>}
|
||||
<td>{formatDate(session.createdAt, { dateStyle: 'long' })}</td>
|
||||
<td>{session.clientIp}</td>
|
||||
<td>
|
||||
{session.browser} on {session.os} {session.osVersion}
|
||||
</td>
|
||||
<td>
|
||||
{session.authModule && <TagBadge label={session.authModule} removeIcon={false} count={0} />}
|
||||
</td>
|
||||
<td>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
tooltip={t('user-session.revoke', 'Revoke user session')}
|
||||
onClick={() => revokeUserSession(session.id)}
|
||||
aria-label={t('user-session.revoke', 'Revoke user session')}
|
||||
>
|
||||
<Icon name="power" />
|
||||
</Button>
|
||||
</td>
|
||||
<ScrollContainer overflowY="visible" overflowX="auto" width="100%">
|
||||
<table className="filter-table form-inline" data-testid={selectors.components.UserProfile.sessionsTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<Trans i18nKey="user-session.seen-at-column">Last seen</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans i18nKey="user-session.created-at-column">Logged on</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans i18nKey="user-session.ip-column">IP address</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans i18nKey="user-session.browser-column">Browser & OS</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans i18nKey="user-session.identity-provider-column">Identity Provider</Trans>
|
||||
</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{sessions.map((session: UserSession, index) => (
|
||||
<tr key={index}>
|
||||
{session.isActive ? <td>Now</td> : <td>{session.seenAt}</td>}
|
||||
<td>{formatDate(session.createdAt, { dateStyle: 'long' })}</td>
|
||||
<td>{session.clientIp}</td>
|
||||
<td>
|
||||
{session.browser} on {session.os} {session.osVersion}
|
||||
</td>
|
||||
<td>
|
||||
{session.authModule && <TagBadge label={session.authModule} removeIcon={false} count={0} />}
|
||||
</td>
|
||||
<td>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
tooltip={t('user-session.revoke', 'Revoke user session')}
|
||||
onClick={() => revokeUserSession(session.id)}
|
||||
aria-label={t('user-session.revoke', 'Revoke user session')}
|
||||
>
|
||||
<Icon name="power" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</ScrollContainer>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { css } from '@emotion/css';
|
||||
import pluralize from 'pluralize';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { connect, ConnectedProps } from 'react-redux';
|
||||
|
||||
import { OrgRole } from '@grafana/data';
|
||||
import { ConfirmModal, FilterInput, LinkButton, RadioButtonGroup, InlineField, EmptyState, Box } from '@grafana/ui';
|
||||
import { GrafanaTheme2, OrgRole } from '@grafana/data';
|
||||
import {
|
||||
ConfirmModal,
|
||||
FilterInput,
|
||||
LinkButton,
|
||||
RadioButtonGroup,
|
||||
InlineField,
|
||||
EmptyState,
|
||||
Box,
|
||||
Stack,
|
||||
useStyles2,
|
||||
} from '@grafana/ui';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import config from 'app/core/config';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
@@ -78,6 +89,7 @@ export const ServiceAccountsListPageUnconnected = ({
|
||||
const [isDisableModalOpen, setIsDisableModalOpen] = useState(false);
|
||||
const [newToken, setNewToken] = useState('');
|
||||
const [currentServiceAccount, setCurrentServiceAccount] = useState<ServiceAccountDTO | null>(null);
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
useEffect(() => {
|
||||
fetchServiceAccounts({ withLoadingIndicator: true });
|
||||
@@ -192,13 +204,13 @@ export const ServiceAccountsListPageUnconnected = ({
|
||||
}
|
||||
>
|
||||
<Page.Contents>
|
||||
<div className="page-action-bar">
|
||||
<Stack justifyContent="space-between" wrap="wrap">
|
||||
<InlineField grow>
|
||||
<FilterInput
|
||||
className={styles.filterInput}
|
||||
placeholder="Search service account by name"
|
||||
value={query}
|
||||
onChange={onQueryChange}
|
||||
width={50}
|
||||
/>
|
||||
</InlineField>
|
||||
<Box marginBottom={1}>
|
||||
@@ -208,7 +220,7 @@ export const ServiceAccountsListPageUnconnected = ({
|
||||
value={serviceAccountStateFilter}
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
</Stack>
|
||||
{!isLoading && !noServiceAccountsCreated && serviceAccounts.length === 0 && (
|
||||
<EmptyState
|
||||
variant="not-found"
|
||||
@@ -291,5 +303,11 @@ export const ServiceAccountsListPageUnconnected = ({
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
filterInput: css({
|
||||
maxWidth: theme.spacing(50),
|
||||
}),
|
||||
});
|
||||
|
||||
const ServiceAccountsListPage = connector(ServiceAccountsListPageUnconnected);
|
||||
export default ServiceAccountsListPage;
|
||||
@@ -528,6 +528,19 @@
|
||||
},
|
||||
"pause": {
|
||||
"label": "Pause evaluation"
|
||||
},
|
||||
"threshold": {
|
||||
"recovery": {
|
||||
"stop-alerting-above": "Stop alerting when above",
|
||||
"stop-alerting-bellow": "Stop alerting when below",
|
||||
"stop-alerting-equal": "Stop alerting when equal to",
|
||||
"stop-alerting-inside-range": "Stop alerting when inside range",
|
||||
"stop-alerting-less": "Stop alerting when less than",
|
||||
"stop-alerting-more": "Stop alerting when more than",
|
||||
"stop-alerting-not-equal": "Stop alerting when not equal to",
|
||||
"stop-alerting-outside-range": "Stop alerting when outside range",
|
||||
"title": "Custom recovery threshold"
|
||||
}
|
||||
}
|
||||
},
|
||||
"rule-groups": {
|
||||
@@ -810,6 +823,7 @@
|
||||
"collapse": "Collapse",
|
||||
"edit": "Edit",
|
||||
"help": "Help",
|
||||
"loading": "Loading...",
|
||||
"locale": {
|
||||
"default": "Default"
|
||||
},
|
||||
@@ -1642,6 +1656,10 @@
|
||||
"incomplete-request-error": "Sorry, I was unable to complete your request. Please try again.",
|
||||
"send-custom-feedback": "Send"
|
||||
},
|
||||
"get-enterprise": {
|
||||
"requires-license": "Requires a Grafana Enterprise license",
|
||||
"title": "Enterprise"
|
||||
},
|
||||
"grafana-ui": {
|
||||
"action-editor": {
|
||||
"button": {
|
||||
@@ -2045,6 +2063,7 @@
|
||||
"render-image-error-description": "An error occurred when generating the image"
|
||||
}
|
||||
},
|
||||
"lock-icon": "lock icon",
|
||||
"login": {
|
||||
"divider": {
|
||||
"connecting-text": "or"
|
||||
@@ -2839,10 +2858,11 @@
|
||||
},
|
||||
"plugins": {
|
||||
"catalog": {
|
||||
"no-updates-available": "No updates available",
|
||||
"update-all": {
|
||||
"all-plugins-updated": "All plugins updated!",
|
||||
"available-header": "Available",
|
||||
"button": "Update all",
|
||||
"button": "Update all ({{length}})",
|
||||
"cloud-update-message": "* It may take a few minutes for the plugins to be available for usage.",
|
||||
"error": "Error updating plugin:",
|
||||
"error-status-text": "failed - see error messages",
|
||||
@@ -2898,6 +2918,12 @@
|
||||
"empty-state": {
|
||||
"message": "No plugins found"
|
||||
},
|
||||
"filter": {
|
||||
"disabled": "This filter has been disabled because the Grafana server cannot access grafana.com",
|
||||
"sort": "Sort",
|
||||
"sort-list": "Sort Plugins List",
|
||||
"state": "State"
|
||||
},
|
||||
"plugin-help": {
|
||||
"error": "An error occurred when loading help.",
|
||||
"not-found": "No query help could be found."
|
||||
|
||||
@@ -528,6 +528,19 @@
|
||||
},
|
||||
"pause": {
|
||||
"label": "Päūşę ęväľūäŧįőʼn"
|
||||
},
|
||||
"threshold": {
|
||||
"recovery": {
|
||||
"stop-alerting-above": "Ŝŧőp äľęřŧįʼnģ ŵĥęʼn äþővę",
|
||||
"stop-alerting-bellow": "Ŝŧőp äľęřŧįʼnģ ŵĥęʼn þęľőŵ",
|
||||
"stop-alerting-equal": "Ŝŧőp äľęřŧįʼnģ ŵĥęʼn ęqūäľ ŧő",
|
||||
"stop-alerting-inside-range": "Ŝŧőp äľęřŧįʼnģ ŵĥęʼn įʼnşįđę řäʼnģę",
|
||||
"stop-alerting-less": "Ŝŧőp äľęřŧįʼnģ ŵĥęʼn ľęşş ŧĥäʼn",
|
||||
"stop-alerting-more": "Ŝŧőp äľęřŧįʼnģ ŵĥęʼn mőřę ŧĥäʼn",
|
||||
"stop-alerting-not-equal": "Ŝŧőp äľęřŧįʼnģ ŵĥęʼn ʼnőŧ ęqūäľ ŧő",
|
||||
"stop-alerting-outside-range": "Ŝŧőp äľęřŧįʼnģ ŵĥęʼn őūŧşįđę řäʼnģę",
|
||||
"title": "Cūşŧőm řęčővęřy ŧĥřęşĥőľđ"
|
||||
}
|
||||
}
|
||||
},
|
||||
"rule-groups": {
|
||||
@@ -810,6 +823,7 @@
|
||||
"collapse": "Cőľľäpşę",
|
||||
"edit": "Ēđįŧ",
|
||||
"help": "Ħęľp",
|
||||
"loading": "Ŀőäđįʼnģ...",
|
||||
"locale": {
|
||||
"default": "Đęƒäūľŧ"
|
||||
},
|
||||
@@ -1642,6 +1656,10 @@
|
||||
"incomplete-request-error": "Ŝőřřy, Ĩ ŵäş ūʼnäþľę ŧő čőmpľęŧę yőūř řęqūęşŧ. Pľęäşę ŧřy äģäįʼn.",
|
||||
"send-custom-feedback": "Ŝęʼnđ"
|
||||
},
|
||||
"get-enterprise": {
|
||||
"requires-license": "Ŗęqūįřęş ä Ğřäƒäʼnä Ēʼnŧęřpřįşę ľįčęʼnşę",
|
||||
"title": "Ēʼnŧęřpřįşę"
|
||||
},
|
||||
"grafana-ui": {
|
||||
"action-editor": {
|
||||
"button": {
|
||||
@@ -2045,6 +2063,7 @@
|
||||
"render-image-error-description": "Åʼn ęřřőř őččūřřęđ ŵĥęʼn ģęʼnęřäŧįʼnģ ŧĥę įmäģę"
|
||||
}
|
||||
},
|
||||
"lock-icon": "ľőčĸ įčőʼn",
|
||||
"login": {
|
||||
"divider": {
|
||||
"connecting-text": "őř"
|
||||
@@ -2839,10 +2858,11 @@
|
||||
},
|
||||
"plugins": {
|
||||
"catalog": {
|
||||
"no-updates-available": "Ńő ūpđäŧęş äväįľäþľę",
|
||||
"update-all": {
|
||||
"all-plugins-updated": "Åľľ pľūģįʼnş ūpđäŧęđ!",
|
||||
"available-header": "Åväįľäþľę",
|
||||
"button": "Ůpđäŧę äľľ",
|
||||
"button": "Ůpđäŧę äľľ ({{length}})",
|
||||
"cloud-update-message": "* Ĩŧ mäy ŧäĸę ä ƒęŵ mįʼnūŧęş ƒőř ŧĥę pľūģįʼnş ŧő þę äväįľäþľę ƒőř ūşäģę.",
|
||||
"error": "Ēřřőř ūpđäŧįʼnģ pľūģįʼn:",
|
||||
"error-status-text": "ƒäįľęđ - şęę ęřřőř męşşäģęş",
|
||||
@@ -2898,6 +2918,12 @@
|
||||
"empty-state": {
|
||||
"message": "Ńő pľūģįʼnş ƒőūʼnđ"
|
||||
},
|
||||
"filter": {
|
||||
"disabled": "Ŧĥįş ƒįľŧęř ĥäş þęęʼn đįşäþľęđ þęčäūşę ŧĥę Ğřäƒäʼnä şęřvęř čäʼnʼnőŧ äččęşş ģřäƒäʼnä.čőm",
|
||||
"sort": "Ŝőřŧ",
|
||||
"sort-list": "Ŝőřŧ Pľūģįʼnş Ŀįşŧ",
|
||||
"state": "Ŝŧäŧę"
|
||||
},
|
||||
"plugin-help": {
|
||||
"error": "Åʼn ęřřőř őččūřřęđ ŵĥęʼn ľőäđįʼnģ ĥęľp.",
|
||||
"not-found": "Ńő qūęřy ĥęľp čőūľđ þę ƒőūʼnđ."
|
||||
|
||||
@@ -15316,7 +15316,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Find annotations for a specified alert.",
|
||||
"description": "Find annotations for a specified alert rule by its ID.\ndeprecated: AlertID is deprecated and will be removed in future versions. Please use AlertUID instead.",
|
||||
"in": "query",
|
||||
"name": "alertId",
|
||||
"schema": {
|
||||
@@ -15324,6 +15324,14 @@
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Find annotations for a specified alert rule by its UID.",
|
||||
"in": "query",
|
||||
"name": "alertUID",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Find annotations that are scoped to a specific dashboard",
|
||||
"in": "query",
|
||||
|
||||
Reference in new issue
Block a user