Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
978c989f3e | ||
|
|
217427e072 | ||
|
|
585d24dafa | ||
|
|
fccece3ca0 | ||
|
|
d44cab9eaf | ||
|
|
3d3b4dd213 | ||
|
|
2947d41ea8 |
+1
@@ -6,6 +6,7 @@ description: Learn about RBAC Grafana provisioning and view an example YAML prov
|
||||
file that configures Grafana role assignments.
|
||||
labels:
|
||||
products:
|
||||
- cloud
|
||||
- enterprise
|
||||
menuTitle: Provisioning RBAC with Grafana
|
||||
title: Provisioning RBAC with Grafana
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
---
|
||||
aliases:
|
||||
- ../data-sources/aws-CloudWatch/
|
||||
- ../data-sources/aws-CloudWatch/preconfig-CloudWatch-dashboards/
|
||||
- ../data-sources/aws-CloudWatch/provision-CloudWatch/
|
||||
- CloudWatch/
|
||||
- preconfig-CloudWatch-dashboards/
|
||||
- provision-CloudWatch/
|
||||
- ../../data-sources/aws-cloudwatch/configure/
|
||||
- ../../data-sources/aws-cloudwatch/
|
||||
- ../../data-sources/aws-cloudwatch/preconfig-cloudwatch-dashboards/
|
||||
- ../../data-sources/aws-cloudwatch/provision-cloudwatch/
|
||||
- ../cloudwatch/
|
||||
- ../preconfig-cloudwatch-dashboards/
|
||||
- ../provision-cloudwatch/
|
||||
description: This document provides configuration instructions for the CloudWatch data source.
|
||||
keywords:
|
||||
- grafana
|
||||
@@ -25,11 +26,6 @@ refs:
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/visualizations/logs/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/visualizations/logs/
|
||||
explore:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/explore/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/explore/
|
||||
provisioning-data-sources:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/provisioning/#data-sources
|
||||
@@ -40,16 +36,6 @@ refs:
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/#aws
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/#aws
|
||||
alerting:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/alerting/
|
||||
build-dashboards:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/build-dashboards/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/build-dashboards/
|
||||
data-source-management:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/data-source-management/
|
||||
@@ -153,7 +139,7 @@ You must use both an access key ID and a secret access key to authenticate.
|
||||
|
||||
Grafana automatically creates a link to a trace in X-Ray data source if logs contain the `@xrayTraceId` field. To use this feature, you must already have an X-Ray data source configured. For details, see the [X-Ray data source docs](/grafana/plugins/grafana-X-Ray-datasource/). To view the X-Ray link, select the log row in either the Explore view or dashboard [Logs panel](ref:logs) to view the log details section.
|
||||
|
||||
To log the `@xrayTraceId`, refer to the [AWS X-Ray documentation](https://docs.amazonaws.cn/en_us/xray/latest/devguide/xray-services.html). To provide the field to Grafana, your log queries must also contain the `@xrayTraceId` field, for example by using the query `fields @message, @xrayTraceId`.
|
||||
To log the `@xrayTraceId`, refer to the [AWS X-Ray documentation](https://docs.aws.amazon.com/xray/latest/devguide/xray-services.html). To provide the field to Grafana, your log queries must also contain the `@xrayTraceId` field, for example by using the query `fields @message, @xrayTraceId`.
|
||||
|
||||
**Private data source connect** - _Only for Grafana Cloud users._
|
||||
|
||||
|
||||
@@ -34,11 +34,6 @@ refs:
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/query-transform-data/#navigate-the-query-tab
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/query-transform-data/#navigate-the-query-tab
|
||||
explore:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/explore/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/explore/
|
||||
alerting:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/
|
||||
@@ -183,7 +178,7 @@ If you use the expression field to reference another query, such as `queryA * 2`
|
||||
When you select `Builder` mode within the Metric search editor, a new Account field is displayed. Use the `Account` field to specify which of the linked monitoring accounts to target for the given query. By default, the `All` option is specified, which will target all linked accounts.
|
||||
|
||||
While in `Code` mode, you can specify any math expression. If the Monitoring account badge displays in the query editor header, all `SEARCH` expressions entered in this field will be cross-account by default and can query metrics from linked accounts. Note that while queries run cross-account, the autocomplete feature currently doesn't fetch cross-account resources, so you'll need to manually specify resource names when writing cross-account queries.
|
||||
You can limit the search to one or a set of accounts, as documented in the [AWS documentation](http://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Unified-Cross-Account.html).
|
||||
You can limit the search to one or a set of accounts, as documented in the [AWS documentation](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Unified-Cross-Account.html).
|
||||
|
||||
### Period macro
|
||||
|
||||
@@ -198,7 +193,7 @@ The link provided is valid for any account but displays the expected metrics onl
|
||||
|
||||
{{< figure src="/media/docs/cloudwatch/cloudwatch-deep-link-v12.1.png" caption="CloudWatch deep linking" >}}
|
||||
|
||||
This feature is not available for metrics based on [metric math expressions](#metric-math-expressions).
|
||||
This feature is not available for metrics based on [metric math expressions](#use-metric-math-expressions).
|
||||
|
||||
### Use Metric Insights syntax
|
||||
|
||||
@@ -319,9 +314,9 @@ The CloudWatch plugin monitors and troubleshoots applications that span multiple
|
||||
|
||||
To enable cross-account observability, complete the following steps:
|
||||
|
||||
1. Go to the [Amazon CloudWatch documentation](http://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Unified-Cross-Account.html) and follow the instructions for enabling cross-account observability.
|
||||
1. Go to the [Amazon CloudWatch documentation](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Unified-Cross-Account.html) and follow the instructions for enabling cross-account observability.
|
||||
|
||||
1. Add [two API actions](https://grafana.com//docs/grafana/latest/datasources/aws-cloudwatch/configure/#cross-account-observability-permissions) to the IAM policy attached to the role/user running the plugin.
|
||||
1. Add [two API actions](https://grafana.com/docs/grafana/latest/datasources/aws-cloudwatch/configure/#cross-account-observability-permissions) to the IAM policy attached to the role/user running the plugin.
|
||||
|
||||
Cross-account querying is available in the plugin through the **Logs**, **Metric search**, and **Metric Insights** modes.
|
||||
After you have configured it, you'll see a **Monitoring account** badge in the query editor header.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,834 +0,0 @@
|
||||
{
|
||||
"_comment": "Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/dashboards/prometheus_stats.json",
|
||||
"__inputs": [
|
||||
{
|
||||
"name": "DS_GDEV-PROMETHEUS",
|
||||
"label": "gdev-prometheus",
|
||||
"description": "",
|
||||
"type": "datasource",
|
||||
"pluginId": "prometheus",
|
||||
"pluginName": "Prometheus"
|
||||
}
|
||||
],
|
||||
"__requires": [
|
||||
{
|
||||
"type": "grafana",
|
||||
"id": "grafana",
|
||||
"name": "Grafana",
|
||||
"version": "8.1.0-pre"
|
||||
},
|
||||
{
|
||||
"type": "datasource",
|
||||
"id": "prometheus",
|
||||
"name": "Prometheus",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
{
|
||||
"type": "panel",
|
||||
"id": "stat",
|
||||
"name": "Stat",
|
||||
"version": ""
|
||||
},
|
||||
{
|
||||
"type": "panel",
|
||||
"id": "text",
|
||||
"name": "Text",
|
||||
"version": ""
|
||||
},
|
||||
{
|
||||
"type": "panel",
|
||||
"id": "timeseries",
|
||||
"name": "Time series",
|
||||
"version": ""
|
||||
}
|
||||
],
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": "-- Grafana --",
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"editable": true,
|
||||
"gnetId": null,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
"iteration": 1624859749459,
|
||||
"links": [
|
||||
{
|
||||
"icon": "info",
|
||||
"tags": [],
|
||||
"targetBlank": true,
|
||||
"title": "Grafana Docs",
|
||||
"tooltip": "",
|
||||
"type": "link",
|
||||
"url": "https://grafana.com/docs/grafana/latest/"
|
||||
},
|
||||
{
|
||||
"icon": "info",
|
||||
"tags": [],
|
||||
"targetBlank": true,
|
||||
"title": "Prometheus Docs",
|
||||
"type": "link",
|
||||
"url": "http://prometheus.io/docs/introduction/overview/"
|
||||
}
|
||||
],
|
||||
"panels": [
|
||||
{
|
||||
"cacheTimeout": null,
|
||||
"datasource": "${DS_GDEV-PROMETHEUS}",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"decimals": 1,
|
||||
"mappings": [
|
||||
{
|
||||
"options": {
|
||||
"match": "null",
|
||||
"result": {
|
||||
"text": "N/A"
|
||||
}
|
||||
},
|
||||
"type": "special"
|
||||
}
|
||||
],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "s"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 5,
|
||||
"w": 6,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 5,
|
||||
"interval": null,
|
||||
"links": [],
|
||||
"maxDataPoints": 100,
|
||||
"options": {
|
||||
"colorMode": "none",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "horizontal",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"text": {},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "8.1.0-pre",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "(time() - process_start_time_seconds{job=\"prometheus\", instance=~\"$node\"})",
|
||||
"intervalFactor": 2,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Uptime",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"cacheTimeout": null,
|
||||
"datasource": "${DS_GDEV-PROMETHEUS}",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"fixedColor": "rgb(31, 120, 193)",
|
||||
"mode": "fixed"
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "rgba(50, 172, 45, 0.97)",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "rgba(237, 129, 40, 0.89)",
|
||||
"value": 1
|
||||
},
|
||||
{
|
||||
"color": "rgba(245, 54, 54, 0.9)",
|
||||
"value": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "none"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 5,
|
||||
"w": 6,
|
||||
"x": 6,
|
||||
"y": 0
|
||||
},
|
||||
"id": 6,
|
||||
"interval": null,
|
||||
"links": [],
|
||||
"maxDataPoints": 100,
|
||||
"options": {
|
||||
"colorMode": "none",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "horizontal",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"text": {},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "8.1.0-pre",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "prometheus_local_storage_memory_series{instance=~\"$node\"}",
|
||||
"intervalFactor": 2,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Local Storage Memory Series",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"cacheTimeout": null,
|
||||
"datasource": "${DS_GDEV-PROMETHEUS}",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"mappings": [
|
||||
{
|
||||
"options": {
|
||||
"0": {
|
||||
"text": "Empty"
|
||||
}
|
||||
},
|
||||
"type": "value"
|
||||
}
|
||||
],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "rgba(50, 172, 45, 0.97)",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "rgba(237, 129, 40, 0.89)",
|
||||
"value": 500
|
||||
},
|
||||
{
|
||||
"color": "rgba(245, 54, 54, 0.9)",
|
||||
"value": 4000
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "none"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 5,
|
||||
"w": 6,
|
||||
"x": 12,
|
||||
"y": 0
|
||||
},
|
||||
"id": 7,
|
||||
"interval": null,
|
||||
"links": [],
|
||||
"maxDataPoints": 100,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "horizontal",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"text": {},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "8.1.0-pre",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "prometheus_local_storage_indexing_queue_length{instance=~\"$node\"}",
|
||||
"intervalFactor": 2,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Internal Storage Queue Length",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": null,
|
||||
"editable": true,
|
||||
"error": false,
|
||||
"gridPos": {
|
||||
"h": 5,
|
||||
"w": 6,
|
||||
"x": 18,
|
||||
"y": 0
|
||||
},
|
||||
"id": 9,
|
||||
"links": [],
|
||||
"options": {
|
||||
"content": "<span style=\"font-family: 'Open Sans', 'Helvetica Neue', Helvetica; font-size: 25px;vertical-align: text-top;color: #bbbfc2;margin-left: 10px;\">Prometheus</span>\n\n<p style=\"margin-top: 10px;\">You're using Prometheus, an open-source systems monitoring and alerting toolkit originally built at SoundCloud. For more information, check out the <a href=\"https://grafana.com/\">Grafana</a> and <a href=\"http://prometheus.io/\">Prometheus</a> projects.</p>",
|
||||
"mode": "html"
|
||||
},
|
||||
"pluginVersion": "8.1.0-pre",
|
||||
"style": {},
|
||||
"transparent": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"datasource": "${DS_GDEV-PROMETHEUS}",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 2,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"matcher": {
|
||||
"id": "byName",
|
||||
"options": "prometheus"
|
||||
},
|
||||
"properties": [
|
||||
{
|
||||
"id": "color",
|
||||
"value": {
|
||||
"fixedColor": "#C15C17",
|
||||
"mode": "fixed"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": {
|
||||
"id": "byName",
|
||||
"options": "{instance=\"localhost:9090\",job=\"prometheus\"}"
|
||||
},
|
||||
"properties": [
|
||||
{
|
||||
"id": "color",
|
||||
"value": {
|
||||
"fixedColor": "#C15C17",
|
||||
"mode": "fixed"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 6,
|
||||
"w": 18,
|
||||
"x": 0,
|
||||
"y": 5
|
||||
},
|
||||
"id": 3,
|
||||
"links": [],
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"pluginVersion": "8.1.0-pre",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "rate(prometheus_local_storage_ingested_samples_total{instance=~\"$node\"}[5m])",
|
||||
"interval": "",
|
||||
"intervalFactor": 2,
|
||||
"legendFormat": "{{job}}",
|
||||
"metric": "",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"timeFrom": null,
|
||||
"timeShift": null,
|
||||
"title": "Samples ingested (rate-5m)",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": null,
|
||||
"editable": true,
|
||||
"error": false,
|
||||
"gridPos": {
|
||||
"h": 6,
|
||||
"w": 4,
|
||||
"x": 18,
|
||||
"y": 5
|
||||
},
|
||||
"id": 8,
|
||||
"links": [],
|
||||
"options": {
|
||||
"content": "#### Samples Ingested\nThis graph displays the count of samples ingested by the Prometheus server, as measured over the last 5 minutes, per time series in the range vector. When troubleshooting an issue on IRC or GitHub, this is often the first stat requested by the Prometheus team. ",
|
||||
"mode": "markdown"
|
||||
},
|
||||
"pluginVersion": "8.1.0-pre",
|
||||
"style": {},
|
||||
"transparent": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"datasource": "${DS_GDEV-PROMETHEUS}",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 2,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"matcher": {
|
||||
"id": "byName",
|
||||
"options": "prometheus"
|
||||
},
|
||||
"properties": [
|
||||
{
|
||||
"id": "color",
|
||||
"value": {
|
||||
"fixedColor": "#F9BA8F",
|
||||
"mode": "fixed"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": {
|
||||
"id": "byName",
|
||||
"options": "{instance=\"localhost:9090\",interval=\"5s\",job=\"prometheus\"}"
|
||||
},
|
||||
"properties": [
|
||||
{
|
||||
"id": "color",
|
||||
"value": {
|
||||
"fixedColor": "#F9BA8F",
|
||||
"mode": "fixed"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 7,
|
||||
"w": 10,
|
||||
"x": 0,
|
||||
"y": 11
|
||||
},
|
||||
"id": 2,
|
||||
"links": [],
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"pluginVersion": "8.1.0-pre",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "rate(prometheus_target_interval_length_seconds_count{instance=~\"$node\"}[5m])",
|
||||
"intervalFactor": 2,
|
||||
"legendFormat": "{{job}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"timeFrom": null,
|
||||
"timeShift": null,
|
||||
"title": "Target Scrapes (last 5m)",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": "${DS_GDEV-PROMETHEUS}",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 2,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 7,
|
||||
"w": 8,
|
||||
"x": 10,
|
||||
"y": 11
|
||||
},
|
||||
"id": 14,
|
||||
"links": [],
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"pluginVersion": "8.1.0-pre",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "prometheus_target_interval_length_seconds{quantile!=\"0.01\", quantile!=\"0.05\",instance=~\"$node\"}",
|
||||
"interval": "",
|
||||
"intervalFactor": 2,
|
||||
"legendFormat": "{{quantile}} ({{interval}})",
|
||||
"metric": "",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"timeFrom": null,
|
||||
"timeShift": null,
|
||||
"title": "Scrape Duration",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": null,
|
||||
"editable": true,
|
||||
"error": false,
|
||||
"gridPos": {
|
||||
"h": 7,
|
||||
"w": 6,
|
||||
"x": 18,
|
||||
"y": 11
|
||||
},
|
||||
"id": 11,
|
||||
"links": [],
|
||||
"options": {
|
||||
"content": "#### Scrapes\nPrometheus scrapes metrics from instrumented jobs, either directly or via an intermediary push gateway for short-lived jobs. Target scrapes will show how frequently targets are scraped, as measured over the last 5 minutes, per time series in the range vector. Scrape Duration will show how long the scrapes are taking, with percentiles available as series. ",
|
||||
"mode": "markdown"
|
||||
},
|
||||
"pluginVersion": "8.1.0-pre",
|
||||
"style": {},
|
||||
"transparent": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"datasource": "${DS_GDEV-PROMETHEUS}",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 2,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "percentunit"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 7,
|
||||
"w": 18,
|
||||
"x": 0,
|
||||
"y": 18
|
||||
},
|
||||
"id": 12,
|
||||
"links": [],
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"pluginVersion": "8.1.0-pre",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "prometheus_evaluator_duration_seconds{quantile!=\"0.01\", quantile!=\"0.05\",instance=~\"$node\"}",
|
||||
"interval": "",
|
||||
"intervalFactor": 2,
|
||||
"legendFormat": "{{quantile}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"timeFrom": null,
|
||||
"timeShift": null,
|
||||
"title": "Rule Eval Duration",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": null,
|
||||
"editable": true,
|
||||
"error": false,
|
||||
"gridPos": {
|
||||
"h": 7,
|
||||
"w": 6,
|
||||
"x": 18,
|
||||
"y": 18
|
||||
},
|
||||
"id": 15,
|
||||
"links": [],
|
||||
"options": {
|
||||
"content": "#### Rule Evaluation Duration\nThis graph panel plots the duration for all evaluations to execute. The 50th percentile, 90th percentile and 99th percentile are shown as three separate series to help identify outliers that may be skewing the data.",
|
||||
"mode": "markdown"
|
||||
},
|
||||
"pluginVersion": "8.1.0-pre",
|
||||
"style": {},
|
||||
"transparent": true,
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"refresh": false,
|
||||
"revision": "1.0",
|
||||
"schemaVersion": 30,
|
||||
"tags": ["prometheus"],
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"allValue": null,
|
||||
"current": {},
|
||||
"datasource": "${DS_GDEV-PROMETHEUS}",
|
||||
"definition": "",
|
||||
"description": null,
|
||||
"error": null,
|
||||
"hide": 0,
|
||||
"includeAll": false,
|
||||
"label": "HOST:",
|
||||
"multi": false,
|
||||
"name": "node",
|
||||
"options": [],
|
||||
"query": {
|
||||
"query": "label_values(prometheus_build_info, instance)",
|
||||
"refId": "gdev-prometheus-node-Variable-Query"
|
||||
},
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"skipUrlSync": false,
|
||||
"sort": 1,
|
||||
"tagValuesQuery": "",
|
||||
"tagsQuery": "",
|
||||
"type": "query",
|
||||
"useTags": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"time": {
|
||||
"from": "now-5m",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {
|
||||
"now": true,
|
||||
"refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
|
||||
},
|
||||
"timezone": "browser",
|
||||
"title": "Prometheus Stats",
|
||||
"uid": "rpfmFFz7z",
|
||||
"version": 2
|
||||
}
|
||||
@@ -57,7 +57,7 @@ export class AppWrapper extends Component<AppWrapperProps, AppWrapperState> {
|
||||
|
||||
async componentDidMount() {
|
||||
this.setState({ ready: true });
|
||||
$('.preloader').remove();
|
||||
this.removePreloader();
|
||||
|
||||
// clear any old icon caches
|
||||
const cacheKeys = (await window.caches?.keys()) ?? [];
|
||||
@@ -68,6 +68,15 @@ export class AppWrapper extends Component<AppWrapperProps, AppWrapperState> {
|
||||
}
|
||||
}
|
||||
|
||||
removePreloader() {
|
||||
const preloader = document.querySelector('.preloader');
|
||||
if (preloader) {
|
||||
preloader.remove();
|
||||
} else {
|
||||
console.warn('Preloader element not found');
|
||||
}
|
||||
}
|
||||
|
||||
renderRoute = (route: RouteDescriptor) => {
|
||||
return (
|
||||
<Route
|
||||
|
||||
+1
@@ -78,6 +78,7 @@ export const BasicProvisionedDashboardsEmptyPage = ({ datasourceUid }: Props) =>
|
||||
sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE,
|
||||
libraryItemId: dashboard.uid,
|
||||
creationOrigin: CREATION_ORIGINS.DASHBOARD_LIBRARY_DATASOURCE_DASHBOARD,
|
||||
contentKind: CONTENT_KINDS.DATASOURCE_DASHBOARD,
|
||||
});
|
||||
|
||||
const templateUrl = `${DASHBOARD_LIBRARY_ROUTES.Template}?${params.toString()}`;
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { render } from 'test/test-utils';
|
||||
|
||||
import { CommunityDashboardSection } from './CommunityDashboardSection';
|
||||
import { fetchCommunityDashboards } from './api/dashboardLibraryApi';
|
||||
import { GnetDashboard } from './types';
|
||||
import { onUseCommunityDashboard } from './utils/communityDashboardHelpers';
|
||||
|
||||
jest.mock('./api/dashboardLibraryApi', () => ({
|
||||
fetchCommunityDashboards: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('./utils/communityDashboardHelpers', () => ({
|
||||
...jest.requireActual('./utils/communityDashboardHelpers'),
|
||||
onUseCommunityDashboard: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
getDataSourceSrv: () => ({
|
||||
getInstanceSettings: jest.fn((uid: string) => ({
|
||||
uid,
|
||||
name: `DataSource ${uid}`,
|
||||
type: 'test',
|
||||
})),
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockFetchCommunityDashboards = fetchCommunityDashboards as jest.MockedFunction<typeof fetchCommunityDashboards>;
|
||||
const mockOnUseCommunityDashboard = onUseCommunityDashboard as jest.MockedFunction<typeof onUseCommunityDashboard>;
|
||||
|
||||
const createMockGnetDashboard = (overrides: Partial<GnetDashboard> = {}): GnetDashboard => ({
|
||||
id: 1,
|
||||
name: 'Test Dashboard',
|
||||
description: 'Test Description',
|
||||
downloads: 2000,
|
||||
datasource: 'Prometheus',
|
||||
slug: 'test-dashboard',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const setup = async (
|
||||
props: Partial<React.ComponentProps<typeof CommunityDashboardSection>> = {},
|
||||
successScenario = true
|
||||
) => {
|
||||
const renderResult = render(
|
||||
<CommunityDashboardSection onShowMapping={jest.fn()} datasourceType="test" {...props} />,
|
||||
{
|
||||
historyOptions: {
|
||||
initialEntries: ['/test?dashboardLibraryDatasourceUid=test-datasource-uid'],
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (successScenario) {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Dashboard')).toBeInTheDocument();
|
||||
});
|
||||
}
|
||||
|
||||
return renderResult;
|
||||
};
|
||||
|
||||
describe('CommunityDashboardSection', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render', async () => {
|
||||
mockFetchCommunityDashboards.mockResolvedValue({
|
||||
page: 1,
|
||||
pages: 5,
|
||||
items: [
|
||||
createMockGnetDashboard(),
|
||||
createMockGnetDashboard({ id: 2, name: 'Test Dashboard 2' }),
|
||||
createMockGnetDashboard({ id: 3, name: 'Test Dashboard 3' }),
|
||||
],
|
||||
});
|
||||
|
||||
await setup();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Dashboard')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test Dashboard 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test Dashboard 3')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show error when fetching a specific community dashboard after clicking use dashboard button fails', async () => {
|
||||
mockFetchCommunityDashboards.mockResolvedValue({
|
||||
page: 1,
|
||||
pages: 5,
|
||||
items: [createMockGnetDashboard()],
|
||||
});
|
||||
|
||||
mockOnUseCommunityDashboard.mockRejectedValue(new Error('Failed to use community dashboard'));
|
||||
|
||||
const { user } = await setup();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Dashboard')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const useDashboardButton = screen.getByRole('button', { name: 'Use dashboard' });
|
||||
await user.click(useDashboardButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Error loading community dashboard')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show error when fetching community dashboards list fails', async () => {
|
||||
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
mockFetchCommunityDashboards.mockRejectedValue(new Error('Failed to fetch community dashboards'));
|
||||
|
||||
await setup(undefined, false);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Error loading community dashboards')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboards', expect.any(Error));
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
+86
-86
@@ -1,12 +1,12 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import { useAsync, useDebounce } from 'react-use';
|
||||
import { useAsyncFn, useAsyncRetry, useDebounce } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { getDataSourceSrv } from '@grafana/runtime';
|
||||
import { Button, useStyles2, Stack, Grid, EmptyState, Alert, Pagination, FilterInput } from '@grafana/ui';
|
||||
import { Button, useStyles2, Stack, Grid, EmptyState, Alert, FilterInput, Box } from '@grafana/ui';
|
||||
|
||||
import { DashboardCard } from './DashboardCard';
|
||||
import { MappingContext } from './SuggestedDashboardsModal';
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
getLogoUrl,
|
||||
buildDashboardDetails,
|
||||
onUseCommunityDashboard,
|
||||
COMMUNITY_PAGE_SIZE_QUERY,
|
||||
COMMUNITY_RESULT_SIZE,
|
||||
} from './utils/communityDashboardHelpers';
|
||||
|
||||
interface Props {
|
||||
@@ -31,8 +33,6 @@ interface Props {
|
||||
datasourceType?: string;
|
||||
}
|
||||
|
||||
// Constants for community dashboard pagination and API params
|
||||
const COMMUNITY_PAGE_SIZE = 9;
|
||||
const SEARCH_DEBOUNCE_MS = 500;
|
||||
const DEFAULT_SORT_ORDER = 'downloads';
|
||||
const DEFAULT_SORT_DIRECTION = 'desc';
|
||||
@@ -42,7 +42,6 @@ const INCLUDE_SCREENSHOTS = true;
|
||||
export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Props) => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const datasourceUid = searchParams.get('dashboardLibraryDatasourceUid');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const hasTrackedLoaded = useRef(false);
|
||||
|
||||
@@ -55,18 +54,12 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro
|
||||
[searchQuery]
|
||||
);
|
||||
|
||||
// Reset to page 1 when debounced search query changes
|
||||
useEffect(() => {
|
||||
if (debouncedSearchQuery) {
|
||||
setCurrentPage(1);
|
||||
}
|
||||
}, [debouncedSearchQuery]);
|
||||
|
||||
const {
|
||||
value: response,
|
||||
loading,
|
||||
error,
|
||||
} = useAsync(async () => {
|
||||
retry,
|
||||
} = useAsyncRetry(async () => {
|
||||
if (!datasourceUid) {
|
||||
return null;
|
||||
}
|
||||
@@ -80,8 +73,8 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro
|
||||
const apiResponse = await fetchCommunityDashboards({
|
||||
orderBy: DEFAULT_SORT_ORDER,
|
||||
direction: DEFAULT_SORT_DIRECTION,
|
||||
page: currentPage,
|
||||
pageSize: COMMUNITY_PAGE_SIZE,
|
||||
page: 1,
|
||||
pageSize: COMMUNITY_PAGE_SIZE_QUERY,
|
||||
includeLogo: INCLUDE_LOGO,
|
||||
includeScreenshots: INCLUDE_SCREENSHOTS,
|
||||
dataSourceSlugIn: ds.type,
|
||||
@@ -100,15 +93,14 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro
|
||||
}
|
||||
|
||||
return {
|
||||
dashboards: apiResponse.items,
|
||||
pages: apiResponse.pages,
|
||||
dashboards: apiResponse.items.slice(0, COMMUNITY_RESULT_SIZE),
|
||||
datasourceType: ds.type,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Error loading community dashboards', err);
|
||||
throw err;
|
||||
}
|
||||
}, [datasourceUid, currentPage, debouncedSearchQuery]);
|
||||
}, [datasourceUid, debouncedSearchQuery]);
|
||||
|
||||
// Track analytics only once on first successful load
|
||||
useEffect(() => {
|
||||
@@ -128,37 +120,49 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro
|
||||
|
||||
// Determine what to show in results area
|
||||
const dashboards = Array.isArray(response?.dashboards) ? response.dashboards : [];
|
||||
const totalPages = response?.pages || 1;
|
||||
const showEmptyState = !loading && (!response?.dashboards || response.dashboards.length === 0);
|
||||
const showError = !loading && error;
|
||||
|
||||
const onPreviewCommunityDashboard = (dashboard: GnetDashboard) => {
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
const [{ error: isPreviewDashboardError }, onPreviewCommunityDashboard] = useAsyncFn(
|
||||
async (dashboard: GnetDashboard) => {
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Track item click
|
||||
DashboardLibraryInteractions.itemClicked({
|
||||
contentKind: CONTENT_KINDS.COMMUNITY_DASHBOARD,
|
||||
datasourceTypes: [response.datasourceType],
|
||||
libraryItemId: String(dashboard.id),
|
||||
libraryItemTitle: dashboard.name,
|
||||
sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE,
|
||||
eventLocation: EVENT_LOCATIONS.MODAL_COMMUNITY_TAB,
|
||||
discoveryMethod: debouncedSearchQuery.trim() ? DISCOVERY_METHODS.SEARCH : DISCOVERY_METHODS.BROWSE,
|
||||
});
|
||||
// Track item click
|
||||
DashboardLibraryInteractions.itemClicked({
|
||||
contentKind: CONTENT_KINDS.COMMUNITY_DASHBOARD,
|
||||
datasourceTypes: [response.datasourceType],
|
||||
libraryItemId: String(dashboard.id),
|
||||
libraryItemTitle: dashboard.name,
|
||||
sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE,
|
||||
eventLocation: EVENT_LOCATIONS.MODAL_COMMUNITY_TAB,
|
||||
discoveryMethod: debouncedSearchQuery.trim() ? DISCOVERY_METHODS.SEARCH : DISCOVERY_METHODS.BROWSE,
|
||||
});
|
||||
|
||||
onUseCommunityDashboard({
|
||||
dashboard,
|
||||
datasourceUid: datasourceUid || '',
|
||||
datasourceType: response.datasourceType,
|
||||
eventLocation: EVENT_LOCATIONS.MODAL_COMMUNITY_TAB,
|
||||
onShowMapping,
|
||||
});
|
||||
};
|
||||
await onUseCommunityDashboard({
|
||||
dashboard,
|
||||
datasourceUid: datasourceUid || '',
|
||||
datasourceType: response.datasourceType,
|
||||
eventLocation: EVENT_LOCATIONS.MODAL_COMMUNITY_TAB,
|
||||
onShowMapping,
|
||||
});
|
||||
},
|
||||
[response, datasourceUid, debouncedSearchQuery, onShowMapping]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack direction="column" gap={2} height="100%">
|
||||
{isPreviewDashboardError && (
|
||||
<div>
|
||||
<Alert
|
||||
title={t('dashboard-library.community-error-title', 'Error loading community dashboard')}
|
||||
severity="error"
|
||||
>
|
||||
<Trans i18nKey="dashboard-library.community-error-description">Failed to load community dashboard.</Trans>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
<FilterInput
|
||||
placeholder={
|
||||
datasourceType
|
||||
@@ -183,7 +187,7 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro
|
||||
lg: 3,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: COMMUNITY_PAGE_SIZE }).map((_, i) => (
|
||||
{Array.from({ length: COMMUNITY_RESULT_SIZE }).map((_, i) => (
|
||||
<DashboardCard.Skeleton key={`skeleton-${i}`} />
|
||||
))}
|
||||
</Grid>
|
||||
@@ -197,7 +201,7 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro
|
||||
Failed to load community dashboards. Please try again.
|
||||
</Trans>
|
||||
</Alert>
|
||||
<Button variant="secondary" onClick={() => setCurrentPage(1)}>
|
||||
<Button variant="secondary" onClick={retry}>
|
||||
<Trans i18nKey="dashboard-library.retry">Retry</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
@@ -233,42 +237,47 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro
|
||||
)}
|
||||
</EmptyState>
|
||||
) : (
|
||||
<Grid
|
||||
gap={4}
|
||||
columns={{
|
||||
xs: 1,
|
||||
sm: dashboards.length >= 2 ? 2 : 1,
|
||||
lg: dashboards.length >= 3 ? 3 : dashboards.length >= 2 ? 2 : 1,
|
||||
}}
|
||||
>
|
||||
{dashboards.map((dashboard) => {
|
||||
const thumbnailUrl = getThumbnailUrl(dashboard);
|
||||
const logoUrl = getLogoUrl(dashboard);
|
||||
const imageUrl = thumbnailUrl || logoUrl;
|
||||
const isLogo = !thumbnailUrl;
|
||||
const details = buildDashboardDetails(dashboard);
|
||||
<Stack direction="column" gap={2}>
|
||||
<Grid
|
||||
gap={4}
|
||||
columns={{
|
||||
xs: 1,
|
||||
sm: dashboards.length >= 2 ? 2 : 1,
|
||||
lg: dashboards.length >= 3 ? 3 : dashboards.length >= 2 ? 2 : 1,
|
||||
}}
|
||||
>
|
||||
{dashboards.map((dashboard) => {
|
||||
const thumbnailUrl = getThumbnailUrl(dashboard);
|
||||
const logoUrl = getLogoUrl(dashboard);
|
||||
const imageUrl = thumbnailUrl || logoUrl;
|
||||
const isLogo = !thumbnailUrl;
|
||||
const details = buildDashboardDetails(dashboard);
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
key={dashboard.id}
|
||||
title={dashboard.name}
|
||||
imageUrl={imageUrl}
|
||||
dashboard={dashboard}
|
||||
onClick={() => onPreviewCommunityDashboard(dashboard)}
|
||||
isLogo={isLogo}
|
||||
details={details}
|
||||
kind="suggested_dashboard"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
return (
|
||||
<DashboardCard
|
||||
key={dashboard.id}
|
||||
title={dashboard.name}
|
||||
imageUrl={imageUrl}
|
||||
dashboard={dashboard}
|
||||
onClick={() => onPreviewCommunityDashboard(dashboard)}
|
||||
isLogo={isLogo}
|
||||
details={details}
|
||||
kind="suggested_dashboard"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
<Box display="flex" justifyContent="end" gap={2} paddingRight={1.5}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => window.open('https://grafana.com/grafana/dashboards/', '_blank')}
|
||||
>
|
||||
<Trans i18nKey="dashboard-library.browse-grafana-com">Browse Grafana.com</Trans>
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className={styles.paginationWrapper}>
|
||||
<Pagination currentPage={currentPage} numberOfPages={totalPages} onNavigate={setCurrentPage} />
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -277,18 +286,9 @@ function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
resultsContainer: css({
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
}),
|
||||
paginationWrapper: css({
|
||||
position: 'sticky',
|
||||
bottom: 0,
|
||||
backgroundColor: theme.colors.background.primary,
|
||||
padding: theme.spacing(2),
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
zIndex: 2,
|
||||
paddingBottom: theme.spacing(2),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,41 +1,8 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
import { render } from 'test/test-utils';
|
||||
|
||||
import { PluginDashboard } from 'app/types/plugins';
|
||||
|
||||
import { DashboardCard } from './DashboardCard';
|
||||
import { GnetDashboard } from './types';
|
||||
|
||||
// Helper functions for creating mock objects
|
||||
const createMockPluginDashboard = (overrides: Partial<PluginDashboard> = {}): PluginDashboard => ({
|
||||
dashboardId: 1,
|
||||
description: 'Test description',
|
||||
imported: false,
|
||||
importedRevision: 0,
|
||||
importedUri: '',
|
||||
importedUrl: '',
|
||||
path: '',
|
||||
pluginId: 'test-plugin',
|
||||
removed: false,
|
||||
revision: 1,
|
||||
slug: 'test-dashboard',
|
||||
title: 'Test Dashboard',
|
||||
uid: 'test-uid',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMockGnetDashboard = (overrides: Partial<GnetDashboard> = {}): GnetDashboard => ({
|
||||
id: 123,
|
||||
name: 'Test Dashboard',
|
||||
description: 'Test description',
|
||||
datasource: 'Prometheus',
|
||||
orgName: 'Test Org',
|
||||
userName: 'testuser',
|
||||
publishedAt: '',
|
||||
updatedAt: '',
|
||||
downloads: 0,
|
||||
...overrides,
|
||||
});
|
||||
import { createMockGnetDashboard, createMockPluginDashboard } from './utils/test-utils';
|
||||
|
||||
const createMockDetails = (overrides = {}) => ({
|
||||
id: '123',
|
||||
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
import { screen, waitFor, within } from '@testing-library/react';
|
||||
import { render } from 'test/test-utils';
|
||||
|
||||
import { locationService } from '@grafana/runtime';
|
||||
|
||||
import { DashboardLibrarySection } from './DashboardLibrarySection';
|
||||
import { fetchProvisionedDashboards } from './api/dashboardLibraryApi';
|
||||
import { DashboardLibraryInteractions } from './interactions';
|
||||
import { createMockPluginDashboard } from './utils/test-utils';
|
||||
|
||||
jest.mock('./api/dashboardLibraryApi', () => ({
|
||||
fetchProvisionedDashboards: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
getDataSourceSrv: () => ({
|
||||
getInstanceSettings: jest.fn((uid?: string) => {
|
||||
if (uid) {
|
||||
return {
|
||||
uid,
|
||||
name: `DataSource ${uid}`,
|
||||
type: 'test-datasource',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
}),
|
||||
locationService: {
|
||||
push: jest.fn(),
|
||||
getHistory: jest.fn(() => ({
|
||||
listen: jest.fn(() => jest.fn()),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('./interactions', () => ({
|
||||
...jest.requireActual('./interactions'),
|
||||
DashboardLibraryInteractions: {
|
||||
loaded: jest.fn(),
|
||||
itemClicked: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('./DashboardCard', () => {
|
||||
const DashboardCardComponent = ({ title, onClick }: { title: string; onClick: () => void }) => (
|
||||
<div data-testid={`dashboard-card-${title}`} onClick={onClick}>
|
||||
{title}
|
||||
</div>
|
||||
);
|
||||
|
||||
const DashboardCardSkeleton = () => <div data-testid="dashboard-card-skeleton">Skeleton</div>;
|
||||
|
||||
return {
|
||||
DashboardCard: Object.assign(DashboardCardComponent, {
|
||||
Skeleton: DashboardCardSkeleton,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const mockFetchProvisionedDashboards = fetchProvisionedDashboards as jest.MockedFunction<
|
||||
typeof fetchProvisionedDashboards
|
||||
>;
|
||||
const mockLocationServicePush = locationService.push as jest.MockedFunction<typeof locationService.push>;
|
||||
const mockDashboardLibraryInteractionsLoaded = DashboardLibraryInteractions.loaded as jest.MockedFunction<
|
||||
typeof DashboardLibraryInteractions.loaded
|
||||
>;
|
||||
const mockDashboardLibraryInteractionsItemClicked = DashboardLibraryInteractions.itemClicked as jest.MockedFunction<
|
||||
typeof DashboardLibraryInteractions.itemClicked
|
||||
>;
|
||||
|
||||
describe('DashboardLibrarySection', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render dashboards when they are available', async () => {
|
||||
const dashboards = [
|
||||
createMockPluginDashboard({ title: 'Dashboard 1', uid: 'uid-1' }),
|
||||
createMockPluginDashboard({ title: 'Dashboard 2', uid: 'uid-2' }),
|
||||
];
|
||||
|
||||
mockFetchProvisionedDashboards.mockResolvedValue(dashboards);
|
||||
|
||||
render(<DashboardLibrarySection />, {
|
||||
historyOptions: {
|
||||
initialEntries: ['/test?dashboardLibraryDatasourceUid=test-uid'],
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('dashboard-card-Dashboard 1')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('dashboard-card-Dashboard 2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show empty state when there are no dashboards', async () => {
|
||||
mockFetchProvisionedDashboards.mockResolvedValue([]);
|
||||
|
||||
render(<DashboardLibrarySection />, {
|
||||
historyOptions: {
|
||||
initialEntries: ['/test?dashboardLibraryDatasourceUid=test-uid'],
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No test-datasource provisioned dashboards found')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Provisioned dashboards are provided by data source plugins. You can find more plugins on Grafana.com.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
const browseButton = screen.getByRole('button', { name: 'Browse plugins' });
|
||||
expect(browseButton).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show empty state without datasource type when datasourceUid is not provided', async () => {
|
||||
mockFetchProvisionedDashboards.mockResolvedValue([]);
|
||||
|
||||
render(<DashboardLibrarySection />, {
|
||||
historyOptions: {
|
||||
initialEntries: ['/test'],
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No provisioned dashboards found')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render pagination when there are more than 9 dashboards', async () => {
|
||||
const dashboards = Array.from({ length: 18 }, (_, i) =>
|
||||
createMockPluginDashboard({ title: `Dashboard ${i + 1}`, uid: `uid-${i + 1}` })
|
||||
);
|
||||
|
||||
mockFetchProvisionedDashboards.mockResolvedValue(dashboards);
|
||||
|
||||
render(<DashboardLibrarySection />, {
|
||||
historyOptions: {
|
||||
initialEntries: ['/test?dashboardLibraryDatasourceUid=test-uid'],
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const pagination = screen.getByRole('navigation');
|
||||
expect(pagination).toBeInTheDocument();
|
||||
expect(within(pagination).getByText('1')).toBeInTheDocument();
|
||||
expect(within(pagination).getByText('2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not render pagination when there are 9 or fewer dashboards', async () => {
|
||||
const dashboards = Array.from({ length: 9 }, (_, i) =>
|
||||
createMockPluginDashboard({ title: `Dashboard ${i + 1}`, uid: `uid-${i + 1}` })
|
||||
);
|
||||
|
||||
mockFetchProvisionedDashboards.mockResolvedValue(dashboards);
|
||||
|
||||
render(<DashboardLibrarySection />, {
|
||||
historyOptions: {
|
||||
initialEntries: ['/test?dashboardLibraryDatasourceUid=test-uid'],
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('dashboard-card-Dashboard 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const pagination = screen.queryByRole('navigation');
|
||||
expect(pagination).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should navigate to template route when clicking on a dashboard', async () => {
|
||||
const dashboard = createMockPluginDashboard({
|
||||
title: 'Test Dashboard',
|
||||
uid: 'test-uid-123',
|
||||
pluginId: 'test-plugin',
|
||||
path: 'test/path.json',
|
||||
});
|
||||
|
||||
mockFetchProvisionedDashboards.mockResolvedValue([dashboard]);
|
||||
|
||||
render(<DashboardLibrarySection />, {
|
||||
historyOptions: {
|
||||
initialEntries: ['/test?dashboardLibraryDatasourceUid=test-uid'],
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('dashboard-card-Test Dashboard')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const dashboardCard = screen.getByTestId('dashboard-card-Test Dashboard');
|
||||
dashboardCard.click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLocationServicePush).toHaveBeenCalled();
|
||||
const callArgs = mockLocationServicePush.mock.calls[0][0];
|
||||
expect(callArgs).toContain('/dashboard/template');
|
||||
expect(callArgs).toContain('datasource=test-uid');
|
||||
|
||||
expect(callArgs).toContain('title=Test+Dashboard');
|
||||
expect(callArgs).toContain('pluginId=test-plugin');
|
||||
expect(callArgs).toContain('path=test%2Fpath.json');
|
||||
expect(callArgs).toContain('libraryItemId=test-uid-123');
|
||||
});
|
||||
});
|
||||
|
||||
it('should track analytics when dashboards are loaded', async () => {
|
||||
const dashboards = [
|
||||
createMockPluginDashboard({ title: 'Dashboard 1', uid: 'uid-1' }),
|
||||
createMockPluginDashboard({ title: 'Dashboard 2', uid: 'uid-2' }),
|
||||
];
|
||||
|
||||
mockFetchProvisionedDashboards.mockResolvedValue(dashboards);
|
||||
|
||||
render(<DashboardLibrarySection />, {
|
||||
historyOptions: {
|
||||
initialEntries: ['/test?dashboardLibraryDatasourceUid=test-uid'],
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('dashboard-card-Dashboard 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDashboardLibraryInteractionsLoaded).toHaveBeenCalledWith({
|
||||
numberOfItems: 2,
|
||||
contentKinds: ['datasource_dashboard'],
|
||||
datasourceTypes: ['test-datasource'],
|
||||
sourceEntryPoint: 'datasource_page',
|
||||
eventLocation: 'suggested_dashboards_modal_provisioned_tab',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should track analytics when a dashboard is clicked', async () => {
|
||||
const dashboard = createMockPluginDashboard({
|
||||
title: 'Test Dashboard',
|
||||
uid: 'test-uid-123',
|
||||
pluginId: 'test-plugin',
|
||||
});
|
||||
|
||||
mockFetchProvisionedDashboards.mockResolvedValue([dashboard]);
|
||||
|
||||
render(<DashboardLibrarySection />, {
|
||||
historyOptions: {
|
||||
initialEntries: ['/test?dashboardLibraryDatasourceUid=test-uid'],
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('dashboard-card-Test Dashboard')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const dashboardCard = screen.getByTestId('dashboard-card-Test Dashboard');
|
||||
dashboardCard.click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDashboardLibraryInteractionsItemClicked).toHaveBeenCalledWith({
|
||||
contentKind: 'datasource_dashboard',
|
||||
datasourceTypes: ['test-plugin'],
|
||||
libraryItemId: 'test-uid-123',
|
||||
libraryItemTitle: 'Test Dashboard',
|
||||
sourceEntryPoint: 'datasource_page',
|
||||
eventLocation: 'suggested_dashboards_modal_provisioned_tab',
|
||||
discoveryMethod: 'browse',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { render } from 'test/test-utils';
|
||||
|
||||
import { SuggestedDashboards } from './SuggestedDashboards';
|
||||
import { fetchCommunityDashboards, fetchProvisionedDashboards } from './api/dashboardLibraryApi';
|
||||
import { createMockGnetDashboard, createMockPluginDashboard } from './utils/test-utils';
|
||||
|
||||
jest.mock('./api/dashboardLibraryApi', () => ({
|
||||
fetchProvisionedDashboards: jest.fn(),
|
||||
fetchCommunityDashboards: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('./utils/communityDashboardHelpers', () => ({
|
||||
...jest.requireActual('./utils/communityDashboardHelpers'),
|
||||
onUseCommunityDashboard: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('./SuggestedDashboardsModal', () => ({
|
||||
SuggestedDashboardsModal: () => <div data-testid="suggested-dashboards-modal">Modal</div>,
|
||||
}));
|
||||
|
||||
jest.mock('./DashboardCard', () => {
|
||||
const DashboardCardComponent = ({ title, onClick }: { title: string; onClick: () => void }) => (
|
||||
<div data-testid={`dashboard-card-${title}`} onClick={onClick}>
|
||||
{title}
|
||||
</div>
|
||||
);
|
||||
|
||||
const DashboardCardSkeleton = () => <div data-testid="dashboard-card-skeleton">Skeleton</div>;
|
||||
|
||||
return {
|
||||
DashboardCard: Object.assign(DashboardCardComponent, {
|
||||
Skeleton: DashboardCardSkeleton,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
getDataSourceSrv: () => ({
|
||||
getInstanceSettings: jest.fn((uid?: string) => {
|
||||
if (uid) {
|
||||
return {
|
||||
uid,
|
||||
name: `DataSource ${uid}`,
|
||||
type: 'test-datasource',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('./interactions', () => ({
|
||||
...jest.requireActual('./interactions'),
|
||||
DashboardLibraryInteractions: {
|
||||
loaded: jest.fn(),
|
||||
itemClicked: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockFetchProvisionedDashboards = fetchProvisionedDashboards as jest.MockedFunction<
|
||||
typeof fetchProvisionedDashboards
|
||||
>;
|
||||
const mockFetchCommunityDashboards = fetchCommunityDashboards as jest.MockedFunction<typeof fetchCommunityDashboards>;
|
||||
|
||||
describe('SuggestedDashboards', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render when there are dashboards', async () => {
|
||||
mockFetchProvisionedDashboards.mockResolvedValue([createMockPluginDashboard()]);
|
||||
mockFetchCommunityDashboards.mockResolvedValue({
|
||||
page: 1,
|
||||
pages: 1,
|
||||
items: [createMockGnetDashboard()],
|
||||
});
|
||||
|
||||
render(<SuggestedDashboards datasourceUid="test-uid" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('suggested-dashboards')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not render when there are no dashboards', async () => {
|
||||
mockFetchProvisionedDashboards.mockResolvedValue([]);
|
||||
mockFetchCommunityDashboards.mockResolvedValue({
|
||||
page: 1,
|
||||
pages: 1,
|
||||
items: [],
|
||||
});
|
||||
|
||||
render(<SuggestedDashboards datasourceUid="test-uid" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('suggested-dashboards')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render provisioned dashboard cards', async () => {
|
||||
const provisionedDashboard = createMockPluginDashboard({ title: 'Provisioned Dashboard 1' });
|
||||
mockFetchProvisionedDashboards.mockResolvedValue([provisionedDashboard]);
|
||||
mockFetchCommunityDashboards.mockResolvedValue({
|
||||
page: 1,
|
||||
pages: 1,
|
||||
items: [],
|
||||
});
|
||||
|
||||
render(<SuggestedDashboards datasourceUid="test-uid" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('dashboard-card-Provisioned Dashboard 1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render community dashboard cards', async () => {
|
||||
const communityDashboard = createMockGnetDashboard({ name: 'Community Dashboard 1' });
|
||||
mockFetchProvisionedDashboards.mockResolvedValue([]);
|
||||
mockFetchCommunityDashboards.mockResolvedValue({
|
||||
page: 1,
|
||||
pages: 1,
|
||||
items: [communityDashboard],
|
||||
});
|
||||
|
||||
render(<SuggestedDashboards datasourceUid="test-uid" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('dashboard-card-Community Dashboard 1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show "View all" button when hasMoreDashboards is true', async () => {
|
||||
mockFetchProvisionedDashboards.mockResolvedValue([
|
||||
createMockPluginDashboard(),
|
||||
createMockPluginDashboard({ title: 'Provisioned Dashboard 2' }),
|
||||
]);
|
||||
mockFetchCommunityDashboards.mockResolvedValue({
|
||||
page: 1,
|
||||
pages: 1,
|
||||
items: [],
|
||||
});
|
||||
|
||||
render(<SuggestedDashboards datasourceUid="test-uid" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'View all' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not show "View all" button when hasMoreDashboards is false', async () => {
|
||||
mockFetchProvisionedDashboards.mockResolvedValue([createMockPluginDashboard()]);
|
||||
mockFetchCommunityDashboards.mockResolvedValue({
|
||||
page: 1,
|
||||
pages: 1,
|
||||
items: [createMockGnetDashboard()],
|
||||
});
|
||||
|
||||
render(<SuggestedDashboards datasourceUid="test-uid" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('button', { name: 'View all' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render title and subtitle with datasource type when datasourceUid is provided', async () => {
|
||||
mockFetchProvisionedDashboards.mockResolvedValue([createMockPluginDashboard()]);
|
||||
mockFetchCommunityDashboards.mockResolvedValue({
|
||||
page: 1,
|
||||
pages: 1,
|
||||
items: [],
|
||||
});
|
||||
|
||||
render(<SuggestedDashboards datasourceUid="test-uid" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Build a dashboard using suggested options for your test-datasource data source')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Browse and select from data-source provided or community dashboards')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,12 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import { useAsync } from 'react-use';
|
||||
import { useAsync, useAsyncFn } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { getDataSourceSrv, locationService } from '@grafana/runtime';
|
||||
import { Button, useStyles2, Grid } from '@grafana/ui';
|
||||
import { Button, useStyles2, Grid, Alert } from '@grafana/ui';
|
||||
import { PluginDashboard } from 'app/types/plugins';
|
||||
|
||||
import { DashboardCard } from './DashboardCard';
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
getLogoUrl,
|
||||
buildDashboardDetails,
|
||||
onUseCommunityDashboard,
|
||||
COMMUNITY_PAGE_SIZE_QUERY,
|
||||
COMMUNITY_RESULT_SIZE,
|
||||
} from './utils/communityDashboardHelpers';
|
||||
import { getProvisionedDashboardImageUrl } from './utils/provisionedDashboardHelpers';
|
||||
|
||||
@@ -43,7 +45,7 @@ type SuggestedDashboardsResult = {
|
||||
};
|
||||
|
||||
// Constants for suggested dashboards API params
|
||||
const SUGGESTED_COMMUNITY_PAGE_SIZE = 2;
|
||||
const MAX_SUGGESTED_DASHBOARDS_PREVIEW = 2;
|
||||
const DEFAULT_SORT_ORDER = 'downloads';
|
||||
const DEFAULT_SORT_DIRECTION = 'desc';
|
||||
const INCLUDE_SCREENSHOTS = true;
|
||||
@@ -91,14 +93,14 @@ export const SuggestedDashboards = ({ datasourceUid }: Props) => {
|
||||
orderBy: DEFAULT_SORT_ORDER,
|
||||
direction: DEFAULT_SORT_DIRECTION,
|
||||
page: 1,
|
||||
pageSize: SUGGESTED_COMMUNITY_PAGE_SIZE,
|
||||
pageSize: COMMUNITY_PAGE_SIZE_QUERY,
|
||||
includeScreenshots: INCLUDE_SCREENSHOTS,
|
||||
dataSourceSlugIn: ds.type,
|
||||
includeLogo: INCLUDE_LOGO,
|
||||
}),
|
||||
]);
|
||||
|
||||
const community = communityResponse.items;
|
||||
const community = communityResponse.items.slice(0, COMMUNITY_RESULT_SIZE);
|
||||
|
||||
// Mix: 1 provisioned + 2 community
|
||||
const mixed: MixedDashboard[] = [];
|
||||
@@ -130,7 +132,7 @@ export const SuggestedDashboards = ({ datasourceUid }: Props) => {
|
||||
|
||||
// Determine if there are more dashboards available beyond what we're showing
|
||||
// Show "View all" if: more than 1 provisioned exists OR we got the full page size of community dashboards
|
||||
const hasMoreDashboards = provisioned.length > 1 || community.length >= SUGGESTED_COMMUNITY_PAGE_SIZE;
|
||||
const hasMoreDashboards = provisioned.length > 1 || community.length > MAX_SUGGESTED_DASHBOARDS_PREVIEW;
|
||||
|
||||
return { dashboards: mixed, hasMoreDashboards };
|
||||
} catch (error) {
|
||||
@@ -233,35 +235,38 @@ export const SuggestedDashboards = ({ datasourceUid }: Props) => {
|
||||
locationService.push(`/dashboard/template?${params.toString()}`);
|
||||
};
|
||||
|
||||
const onPreviewCommunityDashboard = (dashboard: GnetDashboard) => {
|
||||
if (!datasourceUid) {
|
||||
return;
|
||||
}
|
||||
const [{ error: isPreviewCommunityDashboardError }, onPreviewCommunityDashboard] = useAsyncFn(
|
||||
async (dashboard: GnetDashboard) => {
|
||||
if (!datasourceUid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ds = getDataSourceSrv().getInstanceSettings(datasourceUid);
|
||||
if (!ds) {
|
||||
return;
|
||||
}
|
||||
const ds = getDataSourceSrv().getInstanceSettings(datasourceUid);
|
||||
if (!ds) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Track item click
|
||||
DashboardLibraryInteractions.itemClicked({
|
||||
contentKind: CONTENT_KINDS.COMMUNITY_DASHBOARD,
|
||||
datasourceTypes: [ds.type],
|
||||
libraryItemId: String(dashboard.id),
|
||||
libraryItemTitle: dashboard.name,
|
||||
sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE,
|
||||
eventLocation: EVENT_LOCATIONS.EMPTY_DASHBOARD,
|
||||
discoveryMethod: DISCOVERY_METHODS.BROWSE,
|
||||
});
|
||||
// Track item click
|
||||
DashboardLibraryInteractions.itemClicked({
|
||||
contentKind: CONTENT_KINDS.COMMUNITY_DASHBOARD,
|
||||
datasourceTypes: [ds.type],
|
||||
libraryItemId: String(dashboard.id),
|
||||
libraryItemTitle: dashboard.name,
|
||||
sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE,
|
||||
eventLocation: EVENT_LOCATIONS.EMPTY_DASHBOARD,
|
||||
discoveryMethod: DISCOVERY_METHODS.BROWSE,
|
||||
});
|
||||
|
||||
onUseCommunityDashboard({
|
||||
dashboard,
|
||||
datasourceUid,
|
||||
datasourceType: ds.type,
|
||||
eventLocation: EVENT_LOCATIONS.EMPTY_DASHBOARD,
|
||||
onShowMapping: onShowMapping,
|
||||
});
|
||||
};
|
||||
await onUseCommunityDashboard({
|
||||
dashboard,
|
||||
datasourceUid,
|
||||
datasourceType: ds.type,
|
||||
eventLocation: EVENT_LOCATIONS.EMPTY_DASHBOARD,
|
||||
onShowMapping: onShowMapping,
|
||||
});
|
||||
},
|
||||
[datasourceUid, onShowMapping]
|
||||
);
|
||||
|
||||
// Don't render if no dashboards or still loading
|
||||
if (!loading && (!result || result.dashboards.length === 0)) {
|
||||
@@ -297,7 +302,16 @@ export const SuggestedDashboards = ({ datasourceUid }: Props) => {
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isPreviewCommunityDashboardError && (
|
||||
<div>
|
||||
<Alert
|
||||
title={t('dashboard-library.community-error-title', 'Error loading community dashboard')}
|
||||
severity="error"
|
||||
>
|
||||
<Trans i18nKey="dashboard-library.community-error-description">Failed to load community dashboard.</Trans>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
<Grid
|
||||
gap={4}
|
||||
columns={{
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
import { render } from 'test/test-utils';
|
||||
|
||||
import { DashboardJson } from 'app/features/manage-dashboards/types';
|
||||
|
||||
import { SuggestedDashboardsModal } from './SuggestedDashboardsModal';
|
||||
import { CONTENT_KINDS, EVENT_LOCATIONS } from './interactions';
|
||||
|
||||
jest.mock('./DashboardLibrarySection', () => ({
|
||||
DashboardLibrarySection: () => <div data-testid="dashboard-library-section">Dashboard Library Section</div>,
|
||||
}));
|
||||
|
||||
jest.mock('./CommunityDashboardSection', () => ({
|
||||
CommunityDashboardSection: () => <div data-testid="community-dashboard-section">Community Dashboard Section</div>,
|
||||
}));
|
||||
|
||||
jest.mock('./CommunityDashboardMappingForm', () => ({
|
||||
CommunityDashboardMappingForm: () => (
|
||||
<div data-testid="community-dashboard-mapping-form">Community Dashboard Mapping Form</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('SuggestedDashboardsModal', () => {
|
||||
const defaultProps = {
|
||||
isOpen: true,
|
||||
onDismiss: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render when isOpen is true', () => {
|
||||
render(<SuggestedDashboardsModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render when isOpen is false', () => {
|
||||
render(<SuggestedDashboardsModal {...defaultProps} isOpen={false} />);
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render both tabs: Data-source provided and Community', () => {
|
||||
render(<SuggestedDashboardsModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByRole('tab', { name: 'Data-source provided' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'Community' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render tablist with both tabs', () => {
|
||||
render(<SuggestedDashboardsModal {...defaultProps} />);
|
||||
|
||||
const tablist = screen.getByRole('tablist');
|
||||
expect(tablist).toBeInTheDocument();
|
||||
|
||||
const tabs = screen.getAllByRole('tab');
|
||||
expect(tabs).toHaveLength(2);
|
||||
expect(tabs[0]).toHaveTextContent('Data-source provided');
|
||||
expect(tabs[1]).toHaveTextContent('Community');
|
||||
});
|
||||
|
||||
it('should render DashboardLibrarySection when activeView is datasource', () => {
|
||||
render(<SuggestedDashboardsModal {...defaultProps} defaultTab="datasource" />);
|
||||
|
||||
expect(screen.getByTestId('dashboard-library-section')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('community-dashboard-section')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('community-dashboard-mapping-form')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render CommunityDashboardSection when activeView is community', () => {
|
||||
render(<SuggestedDashboardsModal {...defaultProps} defaultTab="community" />);
|
||||
|
||||
expect(screen.getByTestId('community-dashboard-section')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('dashboard-library-section')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('community-dashboard-mapping-form')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render CommunityDashboardMappingForm when activeView is mapping', () => {
|
||||
render(
|
||||
<SuggestedDashboardsModal
|
||||
{...defaultProps}
|
||||
initialMappingContext={{
|
||||
dashboardName: 'Test Dashboard',
|
||||
dashboardJson: { title: 'Test Dashboard', panels: [], schemaVersion: 41 } as DashboardJson,
|
||||
unmappedDsInputs: [],
|
||||
constantInputs: [],
|
||||
existingMappings: [],
|
||||
onInterpolateAndNavigate: jest.fn(),
|
||||
eventLocation: EVENT_LOCATIONS.MODAL_COMMUNITY_TAB,
|
||||
contentKind: CONTENT_KINDS.COMMUNITY_DASHBOARD,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('community-dashboard-mapping-form')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('dashboard-library-section')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('community-dashboard-section')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+61
-28
@@ -3,6 +3,7 @@ import { DashboardJson } from 'app/features/manage-dashboards/types';
|
||||
import { PluginDashboard } from 'app/types/plugins';
|
||||
|
||||
import { GnetDashboard } from '../types';
|
||||
import { createMockGnetDashboard, createMockPluginDashboard } from '../utils/test-utils';
|
||||
|
||||
import {
|
||||
fetchCommunityDashboard,
|
||||
@@ -14,8 +15,16 @@ import {
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
getBackendSrv: jest.fn(),
|
||||
reportInteraction: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../interactions', () => ({
|
||||
...jest.requireActual('../interactions'),
|
||||
DashboardLibraryInteractions: {
|
||||
...jest.requireActual('../interactions').DashboardLibraryInteractions,
|
||||
communityDashboardFiltered: jest.fn(),
|
||||
},
|
||||
}));
|
||||
const mockGetBackendSrv = getBackendSrv as jest.MockedFunction<typeof getBackendSrv>;
|
||||
|
||||
// Helper to create mock BackendSrv
|
||||
@@ -26,31 +35,9 @@ const createMockBackendSrv = (overrides: Partial<BackendSrv> = {}): BackendSrv =
|
||||
}) as unknown as BackendSrv;
|
||||
|
||||
// Helper functions for creating mock objects
|
||||
const createMockGnetDashboard = (overrides: Partial<GnetDashboard> = {}): GnetDashboard => ({
|
||||
id: 1,
|
||||
name: 'Test Dashboard',
|
||||
description: 'Test Description',
|
||||
downloads: 100,
|
||||
datasource: 'Prometheus',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMockPluginDashboard = (overrides: Partial<PluginDashboard> = {}): PluginDashboard => ({
|
||||
dashboardId: 1,
|
||||
uid: 'dash-uid',
|
||||
title: 'Test Dashboard',
|
||||
pluginId: 'prometheus',
|
||||
path: 'dashboards/test.json',
|
||||
description: 'Test plugin dashboard',
|
||||
imported: false,
|
||||
importedRevision: 0,
|
||||
importedUri: '',
|
||||
importedUrl: '',
|
||||
removed: false,
|
||||
revision: 1,
|
||||
slug: 'test-dashboard',
|
||||
...overrides,
|
||||
});
|
||||
const createMockGnetDashboardWithDownloads = (overrides: Partial<GnetDashboard> = {}): GnetDashboard => {
|
||||
return createMockGnetDashboard({ ...overrides, downloads: 10000 });
|
||||
};
|
||||
|
||||
const defaultFetchParams: FetchCommunityDashboardsParams = {
|
||||
orderBy: 'downloads',
|
||||
@@ -80,8 +67,54 @@ describe('dashboardLibraryApi', () => {
|
||||
});
|
||||
|
||||
describe('fetchCommunityDashboards', () => {
|
||||
describe('filterNotSafeDashboards', () => {
|
||||
it('should filter out dashboards with panel types that can contain JavaScript code', async () => {
|
||||
const safeDashboard = createMockGnetDashboardWithDownloads({ id: 1 });
|
||||
const mockDashboards = [
|
||||
safeDashboard,
|
||||
createMockGnetDashboardWithDownloads({ id: 2, panelTypeSlugs: ['ae3e-plotly-panel'] }),
|
||||
];
|
||||
const mockResponse = {
|
||||
page: 1,
|
||||
pages: 5,
|
||||
items: mockDashboards,
|
||||
};
|
||||
mockGet.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await fetchCommunityDashboards(defaultFetchParams);
|
||||
|
||||
expect(result).toEqual({
|
||||
page: 1,
|
||||
pages: 5,
|
||||
items: [safeDashboard],
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter out dashboards with low downloads', async () => {
|
||||
const safeDashboard = createMockGnetDashboardWithDownloads({ id: 1 });
|
||||
const mockDashboards = [safeDashboard, createMockGnetDashboard({ id: 2, downloads: 999 })];
|
||||
const mockResponse = {
|
||||
page: 1,
|
||||
pages: 5,
|
||||
items: mockDashboards,
|
||||
};
|
||||
mockGet.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await fetchCommunityDashboards(defaultFetchParams);
|
||||
|
||||
expect(result).toEqual({
|
||||
page: 1,
|
||||
pages: 5,
|
||||
items: [safeDashboard],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should fetch community dashboards with correct query parameters', async () => {
|
||||
const mockDashboards = [createMockGnetDashboard({ id: 1 }), createMockGnetDashboard({ id: 2 })];
|
||||
const mockDashboards = [
|
||||
createMockGnetDashboardWithDownloads({ id: 1 }),
|
||||
createMockGnetDashboardWithDownloads({ id: 2 }),
|
||||
];
|
||||
const mockResponse = {
|
||||
page: 1,
|
||||
pages: 5,
|
||||
@@ -93,7 +126,7 @@ describe('dashboardLibraryApi', () => {
|
||||
const result = await fetchCommunityDashboards(defaultFetchParams);
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'/api/gnet/dashboards?orderBy=downloads&direction=desc&page=1&pageSize=10&includeLogo=1&includeScreenshots=true',
|
||||
'/api/gnet/dashboards?orderBy=downloads&direction=desc&page=1&pageSize=10&includeLogo=1&includeScreenshots=true&includePanelTypeSlugs=true',
|
||||
undefined,
|
||||
undefined,
|
||||
{ showErrorAlert: false }
|
||||
@@ -154,7 +187,7 @@ describe('dashboardLibraryApi', () => {
|
||||
});
|
||||
|
||||
it('should use fallback values when page/pages are missing', async () => {
|
||||
const items = [createMockGnetDashboard()];
|
||||
const items = [createMockGnetDashboardWithDownloads()];
|
||||
|
||||
mockGet.mockResolvedValue({
|
||||
items,
|
||||
|
||||
@@ -2,7 +2,35 @@ import { getBackendSrv } from '@grafana/runtime';
|
||||
import { DashboardJson } from 'app/features/manage-dashboards/types';
|
||||
import { PluginDashboard } from 'app/types/plugins';
|
||||
|
||||
import { GnetDashboardsResponse, Link } from '../types';
|
||||
import { GnetDashboard, GnetDashboardsResponse, Link } from '../types';
|
||||
|
||||
/**
|
||||
* Panel types that are known to allow JavaScript code execution.
|
||||
* These panels are filtered out due to security concerns.
|
||||
*/
|
||||
const UNSAFE_PANEL_TYPE_SLUGS = [
|
||||
'aceiot-svg-panel',
|
||||
'ae3e-plotly-panel',
|
||||
'gapit-htmlgraphics-panel',
|
||||
'marcusolsson-dynamictext-panel',
|
||||
'volkovlabs-echarts-panel',
|
||||
'volkovlabs-form-panel',
|
||||
];
|
||||
|
||||
/**
|
||||
* Minimum number of downloads required for a community dashboard to be shown as a suggestion.
|
||||
*
|
||||
* Rationale:
|
||||
* - Dashboards with higher download counts have been vetted by a larger community
|
||||
* - This acts as a heuristic for quality and trustworthiness
|
||||
* - Reduces risk of malicious or poorly-maintained dashboards
|
||||
*
|
||||
* Trade-offs:
|
||||
* - May filter out legitimate but less popular dashboards
|
||||
* - Newer dashboards with good content but low download counts won't be shown
|
||||
* - The threshold of 10,000 is somewhat arbitrary and may need tuning based on ecosystem growth
|
||||
*/
|
||||
const MIN_DOWNLOADS_FILTER = 10000;
|
||||
|
||||
/**
|
||||
* Parameters for fetching community dashboards from Grafana.com
|
||||
@@ -56,6 +84,7 @@ export async function fetchCommunityDashboards(
|
||||
pageSize: params.pageSize.toString(),
|
||||
includeLogo: params.includeLogo ? '1' : '0',
|
||||
includeScreenshots: params.includeScreenshots ? 'true' : 'false',
|
||||
includePanelTypeSlugs: 'true',
|
||||
});
|
||||
|
||||
if (params.dataSourceSlugIn) {
|
||||
@@ -69,13 +98,13 @@ export async function fetchCommunityDashboards(
|
||||
showErrorAlert: false,
|
||||
});
|
||||
|
||||
// Grafana.com API returns format: { page: number, pages: number, items: GnetDashboard[] }
|
||||
// We normalize it to use "dashboards" instead of "items" for consistency
|
||||
if (result && Array.isArray(result.items)) {
|
||||
const dashboards = filterNonSafeDashboards(result.items);
|
||||
|
||||
return {
|
||||
page: result.page || params.page,
|
||||
pages: result.pages || 1,
|
||||
items: result.items,
|
||||
items: dashboards,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -109,3 +138,20 @@ export async function fetchProvisionedDashboards(datasourceType: string): Promis
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// We only show dashboards with at least MIN_DOWNLOADS_FILTER downloads
|
||||
// They are already ordered by downloads amount
|
||||
const filterNonSafeDashboards = (dashboards: GnetDashboard[]): GnetDashboard[] => {
|
||||
return dashboards.filter((item: GnetDashboard) => {
|
||||
const hasUnsafePanelTypes = item.panelTypeSlugs?.some((slug: string) => UNSAFE_PANEL_TYPE_SLUGS.includes(slug));
|
||||
const hasLowDownloads = typeof item.downloads === 'number' && item.downloads < MIN_DOWNLOADS_FILTER;
|
||||
|
||||
if (hasUnsafePanelTypes || hasLowDownloads) {
|
||||
console.warn(
|
||||
`Community dashboard ${item.id} ${item.name} filtered out due to low downloads ${item.downloads} or panel types ${item.panelTypeSlugs?.join(', ')} that can embed JavaScript`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ export const EVENT_LOCATIONS = {
|
||||
MODAL_PROVISIONED_TAB: 'suggested_dashboards_modal_provisioned_tab',
|
||||
MODAL_COMMUNITY_TAB: 'suggested_dashboards_modal_community_tab',
|
||||
BROWSE_DASHBOARDS_PAGE: 'browse_dashboards_page',
|
||||
COMMUNITY_DASHBOARD_LOADED: 'community_dashboard_loaded',
|
||||
} as const;
|
||||
|
||||
export const CONTENT_KINDS = {
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface GnetDashboard {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
slug: string;
|
||||
downloads: number;
|
||||
datasource: string;
|
||||
screenshots?: Screenshot[];
|
||||
@@ -38,6 +39,7 @@ export interface GnetDashboard {
|
||||
orgSlug?: string;
|
||||
userId?: number;
|
||||
userName?: string;
|
||||
panelTypeSlugs?: string[];
|
||||
}
|
||||
|
||||
export interface GnetDashboardsResponse {
|
||||
|
||||
+330
-27
@@ -11,7 +11,6 @@ import { InputMapping, tryAutoMapDatasources, parseConstantInputs } from './auto
|
||||
import {
|
||||
buildDashboardDetails,
|
||||
buildGrafanaComUrl,
|
||||
createSlug,
|
||||
getLogoUrl,
|
||||
navigateToTemplate,
|
||||
onUseCommunityDashboard,
|
||||
@@ -27,6 +26,14 @@ jest.mock('./autoMapDatasources', () => ({
|
||||
parseConstantInputs: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../interactions', () => ({
|
||||
...jest.requireActual('../interactions'),
|
||||
DashboardLibraryInteractions: {
|
||||
...jest.requireActual('../interactions').DashboardLibraryInteractions,
|
||||
communityDashboardFiltered: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock function references
|
||||
const mockFetchCommunityDashboard = fetchCommunityDashboard as jest.MockedFunction<typeof fetchCommunityDashboard>;
|
||||
const mockTryAutoMapDatasources = tryAutoMapDatasources as jest.MockedFunction<typeof tryAutoMapDatasources>;
|
||||
@@ -43,6 +50,7 @@ const createMockGnetDashboard = (overrides: Partial<GnetDashboard> = {}): GnetDa
|
||||
publishedAt: '',
|
||||
updatedAt: '2025-11-05T16:55:41.000Z',
|
||||
downloads: 0,
|
||||
slug: 'test-dashboard',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -61,25 +69,11 @@ const createMockDashboardJson = (overrides: Partial<DashboardJson> = {}): Dashbo
|
||||
}) as DashboardJson;
|
||||
|
||||
describe('communityDashboardHelpers', () => {
|
||||
describe('createSlug', () => {
|
||||
it('should convert to lower case', () => {
|
||||
expect(createSlug('Test')).toBe('test');
|
||||
});
|
||||
|
||||
it('should replace non-alphanumeric characters with hyphens', () => {
|
||||
expect(createSlug('Test@#example')).toBe('test-example');
|
||||
});
|
||||
|
||||
it('should remove leading and trailing hyphens', () => {
|
||||
expect(createSlug('-test-')).toBe('test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildGrafanaComUrl', () => {
|
||||
it('should build a valid URL', () => {
|
||||
const gnetDashboard = createMockGnetDashboard({
|
||||
id: 1,
|
||||
name: 'Test',
|
||||
slug: 'test',
|
||||
});
|
||||
|
||||
expect(buildGrafanaComUrl(gnetDashboard)).toBe('https://grafana.com/grafana/dashboards/1-test/');
|
||||
@@ -91,6 +85,7 @@ describe('communityDashboardHelpers', () => {
|
||||
const gnetDashboard = createMockGnetDashboard({
|
||||
id: 1,
|
||||
name: 'Test',
|
||||
slug: 'test',
|
||||
datasource: 'Test',
|
||||
orgName: 'Org',
|
||||
updatedAt: '2025-11-05T16:55:41.000Z',
|
||||
@@ -170,6 +165,10 @@ describe('communityDashboardHelpers', () => {
|
||||
});
|
||||
|
||||
describe('onUseCommunityDashboard', () => {
|
||||
let consoleWarnSpy: jest.SpyInstance;
|
||||
let consoleErrorSpy: jest.SpyInstance;
|
||||
let locationServicePushSpy: jest.SpyInstance;
|
||||
|
||||
async function setup(options?: {
|
||||
dashboard?: Partial<GnetDashboard>;
|
||||
dashboardJson?: Partial<DashboardJson>;
|
||||
@@ -206,7 +205,16 @@ describe('communityDashboardHelpers', () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
locationServicePushSpy = jest.spyOn(locationService, 'push').mockImplementation();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
consoleWarnSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
locationServicePushSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should navigate directly when all datasources are auto-mapped and no constants', async () => {
|
||||
@@ -218,8 +226,8 @@ describe('communityDashboardHelpers', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(locationService.push).toHaveBeenCalled();
|
||||
expect(locationService.push).toHaveBeenCalledWith(
|
||||
expect(locationServicePushSpy).toHaveBeenCalled();
|
||||
expect(locationServicePushSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pathname: expect.any(String),
|
||||
search: expect.stringContaining('gnetId=123'),
|
||||
@@ -249,7 +257,7 @@ describe('communityDashboardHelpers', () => {
|
||||
});
|
||||
|
||||
expect(mockOnShowMapping).toHaveBeenCalled();
|
||||
expect(locationService.push).not.toHaveBeenCalled();
|
||||
expect(locationServicePushSpy).not.toHaveBeenCalled();
|
||||
expect(mockOnShowMapping).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
dashboardName: 'Test Dashboard',
|
||||
@@ -281,7 +289,7 @@ describe('communityDashboardHelpers', () => {
|
||||
});
|
||||
|
||||
expect(mockOnShowMapping).toHaveBeenCalled();
|
||||
expect(locationService.push).not.toHaveBeenCalled();
|
||||
expect(locationServicePushSpy).not.toHaveBeenCalled();
|
||||
expect(mockOnShowMapping).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
dashboardName: 'Test Dashboard',
|
||||
@@ -294,17 +302,312 @@ describe('communityDashboardHelpers', () => {
|
||||
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
mockFetchCommunityDashboard.mockRejectedValue(new Error('API failed'));
|
||||
|
||||
await onUseCommunityDashboard({
|
||||
dashboard: createMockGnetDashboard(),
|
||||
datasourceUid: 'test-ds-uid',
|
||||
datasourceType: 'prometheus',
|
||||
eventLocation: 'empty_dashboard',
|
||||
});
|
||||
await expect(
|
||||
onUseCommunityDashboard({
|
||||
dashboard: createMockGnetDashboard(),
|
||||
datasourceUid: 'test-ds-uid',
|
||||
datasourceType: 'prometheus',
|
||||
eventLocation: 'empty_dashboard',
|
||||
})
|
||||
).rejects.toThrow('API failed');
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error));
|
||||
expect(locationService.push).not.toHaveBeenCalled();
|
||||
expect(locationServicePushSpy).not.toHaveBeenCalled();
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('when the dashboard contains JavaScript code', () => {
|
||||
it('should throw an error if the dashboard contains JavaScript code in options', async () => {
|
||||
const dashboardJson = createMockDashboardJson({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
panels: [{ type: 'panel', options: { template: '{{ javascript:alert("XSS") }}' } } as any],
|
||||
});
|
||||
|
||||
await expect(setup({ dashboardJson })).rejects.toThrow(
|
||||
'Community dashboard 123 "Test Dashboard" might contain JavaScript code'
|
||||
);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error));
|
||||
expect(locationServicePushSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if the dashboard contains JavaScript code in targets/queries', async () => {
|
||||
const dashboardJson = createMockDashboardJson({
|
||||
panels: [
|
||||
{
|
||||
type: 'panel',
|
||||
options: {},
|
||||
targets: [
|
||||
{
|
||||
expr: 'function() { return bad(); }',
|
||||
refId: 'A',
|
||||
},
|
||||
],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
],
|
||||
});
|
||||
|
||||
await expect(setup({ dashboardJson })).rejects.toThrow(
|
||||
'Community dashboard 123 "Test Dashboard" might contain JavaScript code'
|
||||
);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error));
|
||||
expect(locationServicePushSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if the dashboard contains JavaScript code in transformations', async () => {
|
||||
const dashboardJson = createMockDashboardJson({
|
||||
panels: [
|
||||
{
|
||||
type: 'panel',
|
||||
options: {},
|
||||
transformations: [
|
||||
{
|
||||
id: 'calculateField',
|
||||
options: {
|
||||
mode: 'binary',
|
||||
binary: {
|
||||
reducer: 'sum',
|
||||
left: 'A',
|
||||
right: 'B',
|
||||
},
|
||||
replaceFields: false,
|
||||
alias: 'function() { alert("XSS"); }',
|
||||
},
|
||||
},
|
||||
],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
],
|
||||
});
|
||||
|
||||
await expect(setup({ dashboardJson })).rejects.toThrow(
|
||||
'Community dashboard 123 "Test Dashboard" might contain JavaScript code'
|
||||
);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error));
|
||||
expect(locationServicePushSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if the dashboard contains JavaScript code in fieldConfig', async () => {
|
||||
const dashboardJson = createMockDashboardJson({
|
||||
panels: [
|
||||
{
|
||||
type: 'panel',
|
||||
options: {},
|
||||
fieldConfig: {
|
||||
defaults: {
|
||||
custom: {
|
||||
displayMode: 'function() { return "bad"; }',
|
||||
},
|
||||
},
|
||||
overrides: [],
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
],
|
||||
});
|
||||
|
||||
await expect(setup({ dashboardJson })).rejects.toThrow(
|
||||
'Community dashboard 123 "Test Dashboard" might contain JavaScript code'
|
||||
);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error));
|
||||
expect(locationServicePushSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if the dashboard contains javascript: URLs in links', async () => {
|
||||
const dashboardJson = createMockDashboardJson({
|
||||
panels: [
|
||||
{
|
||||
type: 'panel',
|
||||
options: {},
|
||||
links: [
|
||||
{
|
||||
title: 'Bad Link',
|
||||
url: 'javascript:alert("XSS")',
|
||||
targetBlank: false,
|
||||
},
|
||||
],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
],
|
||||
});
|
||||
|
||||
await expect(setup({ dashboardJson })).rejects.toThrow(
|
||||
'Community dashboard 123 "Test Dashboard" might contain JavaScript code'
|
||||
);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error));
|
||||
expect(locationServicePushSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if the dashboard contains <script> tags in any property', async () => {
|
||||
const dashboardJson = createMockDashboardJson({
|
||||
panels: [
|
||||
{
|
||||
type: 'panel',
|
||||
options: {
|
||||
content: '<script>alert("XSS")</script>',
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
],
|
||||
});
|
||||
|
||||
await expect(setup({ dashboardJson })).rejects.toThrow(
|
||||
'Community dashboard 123 "Test Dashboard" might contain JavaScript code'
|
||||
);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error));
|
||||
expect(locationServicePushSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if the dashboard contains arrow functions', async () => {
|
||||
const dashboardJson = createMockDashboardJson({
|
||||
panels: [
|
||||
{
|
||||
type: 'panel',
|
||||
options: {
|
||||
customCode: '() => { alert("XSS"); }',
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
],
|
||||
});
|
||||
|
||||
await expect(setup({ dashboardJson })).rejects.toThrow(
|
||||
'Community dashboard 123 "Test Dashboard" might contain JavaScript code'
|
||||
);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error));
|
||||
expect(locationServicePushSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if the dashboard contains setTimeout or setInterval', async () => {
|
||||
const dashboardJson = createMockDashboardJson({
|
||||
panels: [
|
||||
{
|
||||
type: 'panel',
|
||||
options: {
|
||||
handler: 'setTimeout(() => alert("XSS"), 1000)',
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
],
|
||||
});
|
||||
|
||||
await expect(setup({ dashboardJson })).rejects.toThrow(
|
||||
'Community dashboard 123 "Test Dashboard" might contain JavaScript code'
|
||||
);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error));
|
||||
expect(locationServicePushSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if the dashboard contains suspicious key names like beforeRender', async () => {
|
||||
const dashboardJson = createMockDashboardJson({
|
||||
panels: [
|
||||
{
|
||||
type: 'panel',
|
||||
options: {},
|
||||
beforeRender: 'alert("XSS")',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
],
|
||||
});
|
||||
|
||||
await expect(setup({ dashboardJson })).rejects.toThrow(
|
||||
'Community dashboard 123 "Test Dashboard" might contain JavaScript code'
|
||||
);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error));
|
||||
expect(locationServicePushSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if the dashboard contains suspicious key names like afterRender', async () => {
|
||||
const dashboardJson = createMockDashboardJson({
|
||||
panels: [
|
||||
{
|
||||
type: 'panel',
|
||||
options: {},
|
||||
afterRender: 'alert("XSS")',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
],
|
||||
});
|
||||
|
||||
await expect(setup({ dashboardJson })).rejects.toThrow(
|
||||
'Community dashboard 123 "Test Dashboard" might contain JavaScript code'
|
||||
);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error));
|
||||
expect(locationServicePushSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if the dashboard contains suspicious key names like handler', async () => {
|
||||
const dashboardJson = createMockDashboardJson({
|
||||
panels: [
|
||||
{
|
||||
type: 'panel',
|
||||
options: {},
|
||||
handler: 'alert("XSS")',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
],
|
||||
});
|
||||
|
||||
await expect(setup({ dashboardJson })).rejects.toThrow(
|
||||
'Community dashboard 123 "Test Dashboard" might contain JavaScript code'
|
||||
);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error));
|
||||
expect(locationServicePushSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if the dashboard contains return statements', async () => {
|
||||
const dashboardJson = createMockDashboardJson({
|
||||
panels: [
|
||||
{
|
||||
type: 'panel',
|
||||
options: {
|
||||
customLogic: 'function test() { return malicious(); }',
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
],
|
||||
});
|
||||
|
||||
await expect(setup({ dashboardJson })).rejects.toThrow(
|
||||
'Community dashboard 123 "Test Dashboard" might contain JavaScript code'
|
||||
);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error));
|
||||
expect(locationServicePushSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if the dashboard contains event handlers like onclick', async () => {
|
||||
const dashboardJson = createMockDashboardJson({
|
||||
panels: [
|
||||
{
|
||||
type: 'panel',
|
||||
options: {
|
||||
html: '<div onclick="alert(\'XSS\')">Click me</div>',
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
],
|
||||
});
|
||||
|
||||
await expect(setup({ dashboardJson })).rejects.toThrow(
|
||||
'Community dashboard 123 "Test Dashboard" might contain JavaScript code'
|
||||
);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error));
|
||||
expect(locationServicePushSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+123
-14
@@ -1,5 +1,11 @@
|
||||
import { PanelModel } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { notifyApp } from 'app/core/actions';
|
||||
import { createErrorNotification } from 'app/core/copy/appNotification';
|
||||
import { DataSourceInput } from 'app/features/manage-dashboards/state/reducers';
|
||||
import { DashboardJson } from 'app/features/manage-dashboards/types';
|
||||
import { dispatch } from 'app/types/store';
|
||||
|
||||
import { DASHBOARD_LIBRARY_ROUTES } from '../../types';
|
||||
import { MappingContext } from '../SuggestedDashboardsModal';
|
||||
@@ -9,6 +15,12 @@ import { GnetDashboard, Link } from '../types';
|
||||
|
||||
import { InputMapping, tryAutoMapDatasources, parseConstantInputs, isDataSourceInput } from './autoMapDatasources';
|
||||
|
||||
// Constants for community dashboard pagination and API params
|
||||
// We want to get the most 6 downloaded dashboards, but we first query 12
|
||||
// to be sure the next filters we apply to that list doesn not reduce it below 6
|
||||
export const COMMUNITY_PAGE_SIZE_QUERY = 12;
|
||||
export const COMMUNITY_RESULT_SIZE = 6;
|
||||
|
||||
/**
|
||||
* Extract thumbnail URL from dashboard screenshots
|
||||
*/
|
||||
@@ -39,21 +51,11 @@ export function formatDate(dateString?: string): string {
|
||||
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create URL-friendly slug from dashboard name
|
||||
*/
|
||||
export function createSlug(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Grafana.com URL for a dashboard
|
||||
*/
|
||||
export function buildGrafanaComUrl(dashboard: GnetDashboard): string {
|
||||
return `https://grafana.com/grafana/dashboards/${dashboard.id}-${createSlug(dashboard.name)}/`;
|
||||
return `https://grafana.com/grafana/dashboards/${dashboard.id}-${dashboard.slug}/`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -121,12 +123,110 @@ interface UseCommunityDashboardParams {
|
||||
onShowMapping?: (context: MappingContext) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a panel contains JavaScript code using heuristic pattern matching.
|
||||
*
|
||||
* IMPORTANT: This is a heuristic-based detection, not a perfect mechanism.
|
||||
*
|
||||
* Patterns checked:
|
||||
* - HTML/Script tags: Direct XSS attack vectors
|
||||
* - Event handlers: Common JS injection points (onclick, onload, etc.)
|
||||
* - Function declarations: Actual executable code patterns
|
||||
* - eval/Function constructor: Dynamic code execution
|
||||
* - setTimeout/setInterval: Deferred code execution
|
||||
*
|
||||
* What we DON'T check:
|
||||
* - Panel title and description are excluded (already sanitized by Grafana's rendering layer)
|
||||
* - Only the panel's options and configuration are scanned
|
||||
*
|
||||
* @param panel - The panel model to check
|
||||
* @returns true if the panel might contain JavaScript code, false otherwise
|
||||
*/
|
||||
function canPanelContainJS(panel: PanelModel): boolean {
|
||||
// Create a copy of the panel without title and description, as they are already sanitized
|
||||
// This reduces false positives while still checking all other properties for JavaScript code
|
||||
const { title, description, ...panelWithoutSanitizedFields } = panel;
|
||||
|
||||
let panelJson: string;
|
||||
try {
|
||||
panelJson = JSON.stringify(panelWithoutSanitizedFields);
|
||||
} catch (e) {
|
||||
console.warn('Failed to stringify panel', e);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Patterns that indicate actual JavaScript code in values
|
||||
const valuePatterns = [
|
||||
/<script\b/i, // HTML script tags
|
||||
/\bon\w+\s*=\s*/i, // HTML event handlers: onclick=, onload=, etc.
|
||||
/\bjavascript\s*:/i,
|
||||
/\bfunction\s*\(/, // Anonymous function declarations: function(
|
||||
/\bfunction\s+[\w$]+\s*\(/, // Named function declarations: function name(
|
||||
/=>\s*\{[^}]*\breturn\b/, // Arrow function with return statement: () => { return ... }
|
||||
/\beval\s*\(/i, // eval() calls
|
||||
/\bnew\s+Function\s*\(/i, // new Function() constructor
|
||||
/\bsetTimeout\s*\(/i, // setTimeout calls
|
||||
/\bsetInterval\s*\(/i, // setInterval calls
|
||||
];
|
||||
|
||||
// Patterns for suspicious JSON keys that might indicate JS hooks
|
||||
const keyPatterns = [
|
||||
/"on[a-zA-Z]+"\s*:/, // Event handlers as keys (both camelCase and lowercase): "onClick": or "onclick":
|
||||
/"beforeRender"\s*:/i, // beforeRender hook as JSON key
|
||||
/"afterRender"\s*:/i, // afterRender hook as JSON key
|
||||
/"javascript"\s*:/i, // "javascript" as a key
|
||||
/"customCode"\s*:/i, // Common pattern for custom code injection
|
||||
/"script"\s*:/i, // "script" as a JSON key
|
||||
/"handler"\s*:/i, // "handler" as a JSON key - common for event handlers
|
||||
];
|
||||
|
||||
const hasSuspiciousValue = valuePatterns.some((pattern) => {
|
||||
if (pattern.test(panelJson)) {
|
||||
console.warn('Panel contains JavaScript code in value');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const hasSuspiciousKey = keyPatterns.some((pattern) => {
|
||||
if (pattern.test(panelJson)) {
|
||||
console.warn('Panel contains JavaScript code in key');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return hasSuspiciousValue || hasSuspiciousKey;
|
||||
}
|
||||
|
||||
function isPanelModel(panel: unknown): panel is PanelModel {
|
||||
if (!panel || typeof panel !== 'object') {
|
||||
return false;
|
||||
}
|
||||
return 'options' in panel && 'type' in panel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a dashboard contains JavaScript code. This is not a perfect check, but good enough
|
||||
* Used as a second filter after the first filter of panel types (see api/dashboardLibraryApi.ts)
|
||||
*/
|
||||
const canDashboardContainJS = (dashboard: DashboardJson): boolean => {
|
||||
return dashboard.panels?.some((panel) => {
|
||||
// Skip library panels - they don't have options/type and are already validated
|
||||
if (isPanelModel(panel)) {
|
||||
return canPanelContainJS(panel);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the flow when a user selects a community dashboard:
|
||||
* 1. Tracks analytics
|
||||
* 2. Fetches full dashboard JSON with __inputs
|
||||
* 3. Attempts auto-mapping of datasources
|
||||
* 4. Either navigates directly or shows mapping form
|
||||
* 3. Filters out dashboards that contain JavaScript code due to security reasons
|
||||
* 4. Attempts auto-mapping of datasources
|
||||
* 5. Either navigates directly or shows mapping form
|
||||
*/
|
||||
export async function onUseCommunityDashboard({
|
||||
dashboard,
|
||||
@@ -142,6 +242,10 @@ export async function onUseCommunityDashboard({
|
||||
const fullDashboard = await fetchCommunityDashboard(dashboard.id);
|
||||
const dashboardJson = fullDashboard.json;
|
||||
|
||||
if (canDashboardContainJS(dashboardJson)) {
|
||||
throw new Error(`Community dashboard ${dashboard.id} "${dashboard.name}" might contain JavaScript code`);
|
||||
}
|
||||
|
||||
// Parse datasource requirements from __inputs
|
||||
const dsInputs: DataSourceInput[] = dashboardJson.__inputs?.filter(isDataSourceInput) || [];
|
||||
|
||||
@@ -199,6 +303,11 @@ export async function onUseCommunityDashboard({
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading community dashboard:', err);
|
||||
// TODO: Show error notification
|
||||
dispatch(
|
||||
notifyApp(
|
||||
createErrorNotification(t('dashboard-library.community-error-title', 'Error loading community dashboard'))
|
||||
)
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { PluginDashboard } from 'app/types/plugins';
|
||||
|
||||
import { GnetDashboard } from '../types';
|
||||
|
||||
export const createMockPluginDashboard = (overrides: Partial<PluginDashboard> = {}): PluginDashboard => ({
|
||||
dashboardId: 1,
|
||||
uid: 'dash-uid',
|
||||
title: 'Test Provisioned Dashboard',
|
||||
description: 'Test plugin dashboard',
|
||||
path: 'dashboards/test.json',
|
||||
pluginId: 'prometheus',
|
||||
imported: false,
|
||||
importedRevision: 0,
|
||||
importedUri: '',
|
||||
importedUrl: '',
|
||||
removed: false,
|
||||
revision: 1,
|
||||
slug: 'test-dashboard',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export const createMockGnetDashboard = (overrides: Partial<GnetDashboard> = {}): GnetDashboard => ({
|
||||
id: 123,
|
||||
name: 'Test Dashboard',
|
||||
description: 'Test description',
|
||||
datasource: 'Prometheus',
|
||||
orgName: 'Test Org',
|
||||
userName: 'testuser',
|
||||
publishedAt: '',
|
||||
updatedAt: '',
|
||||
downloads: 0,
|
||||
slug: 'test-dashboard',
|
||||
...overrides,
|
||||
});
|
||||
@@ -1,13 +1,10 @@
|
||||
import $ from 'jquery';
|
||||
import _, { isFunction } from 'lodash'; // eslint-disable-line lodash/import-scope
|
||||
import moment from 'moment'; // eslint-disable-line no-restricted-imports
|
||||
import { isFunction } from 'lodash'; // eslint-disable-line lodash/import-scope
|
||||
|
||||
import { AppEvents, dateMath, UrlQueryMap, UrlQueryValue } from '@grafana/data';
|
||||
import { getBackendSrv, isFetchError, locationService } from '@grafana/runtime';
|
||||
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2';
|
||||
import { backendSrv } from 'app/core/services/backend_srv';
|
||||
import impressionSrv from 'app/core/services/impression_srv';
|
||||
import kbn from 'app/core/utils/kbn';
|
||||
import { getDashboardScenePageStateManager } from 'app/features/dashboard-scene/pages/DashboardScenePageStateManager';
|
||||
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||
import { DashboardDTO } from 'app/types/dashboard';
|
||||
@@ -68,7 +65,15 @@ abstract class DashboardLoaderSrvBase<T> implements DashboardLoaderSrvLike<T> {
|
||||
);
|
||||
}
|
||||
|
||||
private executeScript(result: any) {
|
||||
private async executeScript(result: any) {
|
||||
// Async-load dependencies used only in scripted dashboards to avoid them being in the main bundle, if not needed
|
||||
const [jQuery, moment, lodash, kbn] = await Promise.all([
|
||||
import('jquery'),
|
||||
import('moment'),
|
||||
import('lodash'),
|
||||
import('app/core/utils/kbn'),
|
||||
]);
|
||||
|
||||
const services = {
|
||||
dashboardSrv: getDashboardSrv(),
|
||||
datasourceSrv: getDatasourceSrv(),
|
||||
@@ -90,12 +95,12 @@ abstract class DashboardLoaderSrvBase<T> implements DashboardLoaderSrvLike<T> {
|
||||
locationService.getSearchObject(),
|
||||
kbn,
|
||||
dateMath,
|
||||
_,
|
||||
lodash,
|
||||
moment,
|
||||
window,
|
||||
document,
|
||||
$,
|
||||
$,
|
||||
jQuery,
|
||||
jQuery,
|
||||
services
|
||||
);
|
||||
|
||||
|
||||
@@ -560,6 +560,23 @@ describe('Language completion provider', () => {
|
||||
start: 1560153109000,
|
||||
});
|
||||
});
|
||||
|
||||
it('should interpolate variables in stream selector', async () => {
|
||||
const datasource = setup({});
|
||||
jest.spyOn(datasource, 'getTimeRangeParams').mockReturnValue({ start: 0, end: 1 });
|
||||
jest
|
||||
.spyOn(datasource, 'interpolateString')
|
||||
.mockImplementation((string: string) => string.replace(/\$test_var/g, 'age'));
|
||||
|
||||
const languageProvider = new LanguageProvider(datasource);
|
||||
languageProvider.request = jest.fn().mockResolvedValue([]);
|
||||
await languageProvider.fetchLabels({ streamSelector: '{age="new", $test_var="new"}' });
|
||||
expect(languageProvider.request).toHaveBeenCalledWith('labels', {
|
||||
end: 1,
|
||||
query: '{age="new", age="new"}',
|
||||
start: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter internal labels', async () => {
|
||||
|
||||
@@ -175,7 +175,8 @@ export default class LokiLanguageProvider extends LanguageProvider {
|
||||
const { start, end } = this.datasource.getTimeRangeParams(range);
|
||||
const params: Record<string, string | number> = { start, end };
|
||||
if (options?.streamSelector && options?.streamSelector !== EMPTY_SELECTOR) {
|
||||
params['query'] = options.streamSelector;
|
||||
const interpolatedStreamSelector = this.datasource.interpolateString(options.streamSelector);
|
||||
params['query'] = interpolatedStreamSelector;
|
||||
}
|
||||
const res = await this.request(url, params);
|
||||
if (Array.isArray(res)) {
|
||||
|
||||
@@ -3157,9 +3157,12 @@
|
||||
"table": "Tabulka"
|
||||
},
|
||||
"welcome-header": {
|
||||
"description-alert-activity": "",
|
||||
"description-alert-rules": "Definujte podmínku, která musí být splněna před spuštěním pravidla výstrahy",
|
||||
"description-configure-firing-alert-instances-routed-contact": "Nakonfigurujte způsob přesměrování instancí spouštění výstrah do kontaktních bodů",
|
||||
"description-configure-receives-notifications": "Nakonfigurujte, kdo obdrží oznámení a jak jsou odesílána",
|
||||
"href-text-alert-activity": "",
|
||||
"title-alert-activity": "",
|
||||
"title-alert-rules": "Pravidla výstrah",
|
||||
"title-contact-points": "Kontaktní body",
|
||||
"title-notification-policies": "Zásady oznamování"
|
||||
@@ -14508,6 +14511,17 @@
|
||||
"series-to-rows": "Řady na řádky"
|
||||
}
|
||||
},
|
||||
"smoothing": {
|
||||
"description": "",
|
||||
"effective-resolution": "",
|
||||
"effective-resolution-tooltip": "",
|
||||
"is-applicable-description": "",
|
||||
"name": "",
|
||||
"resolution": {
|
||||
"label": "",
|
||||
"tooltip": ""
|
||||
}
|
||||
},
|
||||
"sort-by-transformer-editor": {
|
||||
"description": {
|
||||
"sort-fields": "Seřadit pole v rámci."
|
||||
|
||||
@@ -3135,9 +3135,12 @@
|
||||
"table": "Tabelle"
|
||||
},
|
||||
"welcome-header": {
|
||||
"description-alert-activity": "",
|
||||
"description-alert-rules": "Legen Sie die Bedingung fest, die erfüllt sein muss, bevor eine Warnregel ausgelöst wird",
|
||||
"description-configure-firing-alert-instances-routed-contact": "Konfigurieren Sie, wie ausgelöste Warnungsinstanzen an Kontaktpunkte weitergeleitet werden",
|
||||
"description-configure-receives-notifications": "Konfigurieren Sie, wer Benachrichtigungen erhält und wie sie gesendet werden",
|
||||
"href-text-alert-activity": "",
|
||||
"title-alert-activity": "",
|
||||
"title-alert-rules": "Warnregeln",
|
||||
"title-contact-points": "Kontaktpunkte",
|
||||
"title-notification-policies": "Benachrichtigungsrichtlinien"
|
||||
@@ -14396,6 +14399,17 @@
|
||||
"series-to-rows": "Reihen zu Zeilen"
|
||||
}
|
||||
},
|
||||
"smoothing": {
|
||||
"description": "",
|
||||
"effective-resolution": "",
|
||||
"effective-resolution-tooltip": "",
|
||||
"is-applicable-description": "",
|
||||
"name": "",
|
||||
"resolution": {
|
||||
"label": "",
|
||||
"tooltip": ""
|
||||
}
|
||||
},
|
||||
"sort-by-transformer-editor": {
|
||||
"description": {
|
||||
"sort-fields": "Sortieren Sie Felder in einem Frame."
|
||||
|
||||
@@ -5799,7 +5799,8 @@
|
||||
"community-empty-title": "No community dashboards found",
|
||||
"community-empty-title-with-datasource": "No {{datasourceType}} community dashboards found",
|
||||
"community-error": "Failed to load community dashboards. Please try again.",
|
||||
"community-error-title": "Error loading community dashboards",
|
||||
"community-error-description": "Failed to load community dashboard.",
|
||||
"community-error-title": "Error loading community dashboard",
|
||||
"community-mapping-form": {
|
||||
"auto-mapped_one": "{{count}} datasources were automatically configured:",
|
||||
"auto-mapped_other": "{{count}} datasources were automatically configured:",
|
||||
|
||||
@@ -3135,9 +3135,12 @@
|
||||
"table": "Tabla"
|
||||
},
|
||||
"welcome-header": {
|
||||
"description-alert-activity": "",
|
||||
"description-alert-rules": "Define la condición que debe cumplirse antes de que se active una regla de alerta",
|
||||
"description-configure-firing-alert-instances-routed-contact": "Configurar cómo se enrutan las instancias de alerta de activación a los puntos de contacto",
|
||||
"description-configure-receives-notifications": "Configurar quién recibe las notificaciones y cómo se envían",
|
||||
"href-text-alert-activity": "",
|
||||
"title-alert-activity": "",
|
||||
"title-alert-rules": "Reglas de alerta",
|
||||
"title-contact-points": "Puntos de contacto",
|
||||
"title-notification-policies": "Políticas de notificación"
|
||||
@@ -14396,6 +14399,17 @@
|
||||
"series-to-rows": "Series a filas"
|
||||
}
|
||||
},
|
||||
"smoothing": {
|
||||
"description": "",
|
||||
"effective-resolution": "",
|
||||
"effective-resolution-tooltip": "",
|
||||
"is-applicable-description": "",
|
||||
"name": "",
|
||||
"resolution": {
|
||||
"label": "",
|
||||
"tooltip": ""
|
||||
}
|
||||
},
|
||||
"sort-by-transformer-editor": {
|
||||
"description": {
|
||||
"sort-fields": "Ordenar campos en un marco."
|
||||
|
||||
@@ -3135,9 +3135,12 @@
|
||||
"table": "Tableau"
|
||||
},
|
||||
"welcome-header": {
|
||||
"description-alert-activity": "",
|
||||
"description-alert-rules": "Définir la condition qui doit être remplie avant qu’une règle d’alerte ne se déclenche",
|
||||
"description-configure-firing-alert-instances-routed-contact": "Configurer la façon dont les instances d’alerte de déclenchement sont acheminées vers les points de contact",
|
||||
"description-configure-receives-notifications": "Configurer les destinataires des notifications et la manière dont elles sont envoyées",
|
||||
"href-text-alert-activity": "",
|
||||
"title-alert-activity": "",
|
||||
"title-alert-rules": "Règles d'alerte",
|
||||
"title-contact-points": "Points de contact",
|
||||
"title-notification-policies": "Règles de notification"
|
||||
@@ -14396,6 +14399,17 @@
|
||||
"series-to-rows": "Convertir la série en lignes"
|
||||
}
|
||||
},
|
||||
"smoothing": {
|
||||
"description": "",
|
||||
"effective-resolution": "",
|
||||
"effective-resolution-tooltip": "",
|
||||
"is-applicable-description": "",
|
||||
"name": "",
|
||||
"resolution": {
|
||||
"label": "",
|
||||
"tooltip": ""
|
||||
}
|
||||
},
|
||||
"sort-by-transformer-editor": {
|
||||
"description": {
|
||||
"sort-fields": "Triez les champs dans une trame."
|
||||
|
||||
@@ -3135,9 +3135,12 @@
|
||||
"table": "Táblázat"
|
||||
},
|
||||
"welcome-header": {
|
||||
"description-alert-activity": "",
|
||||
"description-alert-rules": "Határozza meg azt a feltételt, amelynek teljesülnie kell, mielőtt egy riasztási szabály aktiválódik",
|
||||
"description-configure-firing-alert-instances-routed-contact": "Konfigurálja, hogyan történjen az aktív riasztáspéldányok továbbítása a kapcsolattartási pontokhoz",
|
||||
"description-configure-receives-notifications": "Állítsa be, hogy ki kapjon értesítéseket, és hogyan legyenek elküldve",
|
||||
"href-text-alert-activity": "",
|
||||
"title-alert-activity": "",
|
||||
"title-alert-rules": "Riasztási szabályok",
|
||||
"title-contact-points": "Kapcsolattartási pontok",
|
||||
"title-notification-policies": "Értesítési irányelvek"
|
||||
@@ -14396,6 +14399,17 @@
|
||||
"series-to-rows": "Sorozatok sorokká alakítása"
|
||||
}
|
||||
},
|
||||
"smoothing": {
|
||||
"description": "",
|
||||
"effective-resolution": "",
|
||||
"effective-resolution-tooltip": "",
|
||||
"is-applicable-description": "",
|
||||
"name": "",
|
||||
"resolution": {
|
||||
"label": "",
|
||||
"tooltip": ""
|
||||
}
|
||||
},
|
||||
"sort-by-transformer-editor": {
|
||||
"description": {
|
||||
"sort-fields": "Mezők rendezése egy keretben."
|
||||
|
||||
@@ -3124,9 +3124,12 @@
|
||||
"table": "Tabel"
|
||||
},
|
||||
"welcome-header": {
|
||||
"description-alert-activity": "",
|
||||
"description-alert-rules": "Tentukan syarat yang harus dipenuhi sebelum aturan peringatan menyala",
|
||||
"description-configure-firing-alert-instances-routed-contact": "Konfigurasikan cara instans peringatan yang menyala dirutekan ke titik kontak",
|
||||
"description-configure-receives-notifications": "Konfigurasikan penerima pemberitahuan dan cara pemberitahuan dikirim",
|
||||
"href-text-alert-activity": "",
|
||||
"title-alert-activity": "",
|
||||
"title-alert-rules": "Aturan peringatan",
|
||||
"title-contact-points": "Titik kontak",
|
||||
"title-notification-policies": "Kebijakan pemberitahuan"
|
||||
@@ -14340,6 +14343,17 @@
|
||||
"series-to-rows": "Deret ke baris"
|
||||
}
|
||||
},
|
||||
"smoothing": {
|
||||
"description": "",
|
||||
"effective-resolution": "",
|
||||
"effective-resolution-tooltip": "",
|
||||
"is-applicable-description": "",
|
||||
"name": "",
|
||||
"resolution": {
|
||||
"label": "",
|
||||
"tooltip": ""
|
||||
}
|
||||
},
|
||||
"sort-by-transformer-editor": {
|
||||
"description": {
|
||||
"sort-fields": "Urutkan bidang dalam bingkai."
|
||||
|
||||
@@ -3135,9 +3135,12 @@
|
||||
"table": "Tabella"
|
||||
},
|
||||
"welcome-header": {
|
||||
"description-alert-activity": "",
|
||||
"description-alert-rules": "Definisci la condizione che deve essere soddisfatta prima che venga attivata una regola di avviso",
|
||||
"description-configure-firing-alert-instances-routed-contact": "Configura il modo in cui le istanze di avviso attivate vengono instradate ai punti di contatto",
|
||||
"description-configure-receives-notifications": "Configura chi riceve le notifiche e come vengono inviate",
|
||||
"href-text-alert-activity": "",
|
||||
"title-alert-activity": "",
|
||||
"title-alert-rules": "Regole di avviso",
|
||||
"title-contact-points": "Punti di contatto",
|
||||
"title-notification-policies": "Politiche di notifica"
|
||||
@@ -14396,6 +14399,17 @@
|
||||
"series-to-rows": "Serie a righe"
|
||||
}
|
||||
},
|
||||
"smoothing": {
|
||||
"description": "",
|
||||
"effective-resolution": "",
|
||||
"effective-resolution-tooltip": "",
|
||||
"is-applicable-description": "",
|
||||
"name": "",
|
||||
"resolution": {
|
||||
"label": "",
|
||||
"tooltip": ""
|
||||
}
|
||||
},
|
||||
"sort-by-transformer-editor": {
|
||||
"description": {
|
||||
"sort-fields": "Ordina i campi in un frame."
|
||||
|
||||
@@ -3124,9 +3124,12 @@
|
||||
"table": "テーブル"
|
||||
},
|
||||
"welcome-header": {
|
||||
"description-alert-activity": "",
|
||||
"description-alert-rules": "アラートルールが発生される前に満たすべき条件を定義します",
|
||||
"description-configure-firing-alert-instances-routed-contact": "発生中のアラートインスタンスを連絡先にルーティングする方法を設定",
|
||||
"description-configure-receives-notifications": "通知の受信者と送信方法を設定",
|
||||
"href-text-alert-activity": "",
|
||||
"title-alert-activity": "",
|
||||
"title-alert-rules": "アラートルール",
|
||||
"title-contact-points": "コンタクトポイント",
|
||||
"title-notification-policies": "通知ポリシー"
|
||||
@@ -14340,6 +14343,17 @@
|
||||
"series-to-rows": "系列を行に変換"
|
||||
}
|
||||
},
|
||||
"smoothing": {
|
||||
"description": "",
|
||||
"effective-resolution": "",
|
||||
"effective-resolution-tooltip": "",
|
||||
"is-applicable-description": "",
|
||||
"name": "",
|
||||
"resolution": {
|
||||
"label": "",
|
||||
"tooltip": ""
|
||||
}
|
||||
},
|
||||
"sort-by-transformer-editor": {
|
||||
"description": {
|
||||
"sort-fields": "フレーム内のフィールドを並べ替えます。"
|
||||
|
||||
@@ -3124,9 +3124,12 @@
|
||||
"table": "표"
|
||||
},
|
||||
"welcome-header": {
|
||||
"description-alert-activity": "",
|
||||
"description-alert-rules": "경고 규칙이 발동되기 전에 충족되어야 하는 조건을 정의합니다",
|
||||
"description-configure-firing-alert-instances-routed-contact": "경고 발생 인스턴스가 연락처로 라우팅되는 방식을 구성합니다",
|
||||
"description-configure-receives-notifications": "알림을 받는 사람과 전송 방식을 구성합니다",
|
||||
"href-text-alert-activity": "",
|
||||
"title-alert-activity": "",
|
||||
"title-alert-rules": "경고 규칙",
|
||||
"title-contact-points": "연락처",
|
||||
"title-notification-policies": "알림 정책"
|
||||
@@ -14340,6 +14343,17 @@
|
||||
"series-to-rows": "계열에서 행으로"
|
||||
}
|
||||
},
|
||||
"smoothing": {
|
||||
"description": "",
|
||||
"effective-resolution": "",
|
||||
"effective-resolution-tooltip": "",
|
||||
"is-applicable-description": "",
|
||||
"name": "",
|
||||
"resolution": {
|
||||
"label": "",
|
||||
"tooltip": ""
|
||||
}
|
||||
},
|
||||
"sort-by-transformer-editor": {
|
||||
"description": {
|
||||
"sort-fields": "프레임에서 필드를 정렬합니다."
|
||||
|
||||
@@ -3135,9 +3135,12 @@
|
||||
"table": "Tabel"
|
||||
},
|
||||
"welcome-header": {
|
||||
"description-alert-activity": "",
|
||||
"description-alert-rules": "Definieer de voorwaarde waaraan moet worden voldaan voordat een waarschuwingsregel geactiveerd wordt",
|
||||
"description-configure-firing-alert-instances-routed-contact": "Configureer hoe geactiveerde waarschuwingsinstanties worden gerouteerd naar contactpunten",
|
||||
"description-configure-receives-notifications": "Configureer wie meldingen ontvangt en hoe deze worden verzonden",
|
||||
"href-text-alert-activity": "",
|
||||
"title-alert-activity": "",
|
||||
"title-alert-rules": "Waarschuwingsregels",
|
||||
"title-contact-points": "Contactpunten",
|
||||
"title-notification-policies": "Meldingsbeleid"
|
||||
@@ -14396,6 +14399,17 @@
|
||||
"series-to-rows": "Reeksen naar rijen"
|
||||
}
|
||||
},
|
||||
"smoothing": {
|
||||
"description": "",
|
||||
"effective-resolution": "",
|
||||
"effective-resolution-tooltip": "",
|
||||
"is-applicable-description": "",
|
||||
"name": "",
|
||||
"resolution": {
|
||||
"label": "",
|
||||
"tooltip": ""
|
||||
}
|
||||
},
|
||||
"sort-by-transformer-editor": {
|
||||
"description": {
|
||||
"sort-fields": "Velden in een frame sorteren."
|
||||
|
||||
@@ -3157,9 +3157,12 @@
|
||||
"table": "Tabela"
|
||||
},
|
||||
"welcome-header": {
|
||||
"description-alert-activity": "",
|
||||
"description-alert-rules": "Zdefiniuj warunek, który musi zostać spełniony przed uruchomieniem reguły alertu",
|
||||
"description-configure-firing-alert-instances-routed-contact": "Skonfiguruj, w jaki sposób uruchamiane instancje alertów są kierowane do punktów kontaktu",
|
||||
"description-configure-receives-notifications": "Skonfiguruj, kto otrzymuje powiadomienia i jak są one wysyłane",
|
||||
"href-text-alert-activity": "",
|
||||
"title-alert-activity": "",
|
||||
"title-alert-rules": "Reguły alertu",
|
||||
"title-contact-points": "Punkty kontaktowe",
|
||||
"title-notification-policies": "Zasady powiadamiania"
|
||||
@@ -14508,6 +14511,17 @@
|
||||
"series-to-rows": "Szeregi do wierszy"
|
||||
}
|
||||
},
|
||||
"smoothing": {
|
||||
"description": "",
|
||||
"effective-resolution": "",
|
||||
"effective-resolution-tooltip": "",
|
||||
"is-applicable-description": "",
|
||||
"name": "",
|
||||
"resolution": {
|
||||
"label": "",
|
||||
"tooltip": ""
|
||||
}
|
||||
},
|
||||
"sort-by-transformer-editor": {
|
||||
"description": {
|
||||
"sort-fields": "Sortuj pola w ramce."
|
||||
|
||||
@@ -3135,9 +3135,12 @@
|
||||
"table": "Tabela"
|
||||
},
|
||||
"welcome-header": {
|
||||
"description-alert-activity": "",
|
||||
"description-alert-rules": "Defina a condição que deve ser atendida antes que uma regra de alerta seja acionada",
|
||||
"description-configure-firing-alert-instances-routed-contact": "Configure como as instâncias de alertas ativos são encaminhadas para os pontos de contato",
|
||||
"description-configure-receives-notifications": "Configure quem recebe notificações e como elas são enviadas",
|
||||
"href-text-alert-activity": "",
|
||||
"title-alert-activity": "",
|
||||
"title-alert-rules": "Regras de alerta",
|
||||
"title-contact-points": "Pontos de contato",
|
||||
"title-notification-policies": "Política de notificações"
|
||||
@@ -14396,6 +14399,17 @@
|
||||
"series-to-rows": "Série para fileiras"
|
||||
}
|
||||
},
|
||||
"smoothing": {
|
||||
"description": "",
|
||||
"effective-resolution": "",
|
||||
"effective-resolution-tooltip": "",
|
||||
"is-applicable-description": "",
|
||||
"name": "",
|
||||
"resolution": {
|
||||
"label": "",
|
||||
"tooltip": ""
|
||||
}
|
||||
},
|
||||
"sort-by-transformer-editor": {
|
||||
"description": {
|
||||
"sort-fields": "Classificar os campos em um quadro."
|
||||
|
||||
@@ -3135,9 +3135,12 @@
|
||||
"table": "Tabela"
|
||||
},
|
||||
"welcome-header": {
|
||||
"description-alert-activity": "",
|
||||
"description-alert-rules": "Definir a condição que deve ser cumprida antes de uma regra de alerta ser acionada",
|
||||
"description-configure-firing-alert-instances-routed-contact": "Configurar como as instâncias de alerta de ativação são encaminhadas para os pontos de contacto",
|
||||
"description-configure-receives-notifications": "Configurar quem recebe notificações e como são enviadas",
|
||||
"href-text-alert-activity": "",
|
||||
"title-alert-activity": "",
|
||||
"title-alert-rules": "Regras de alerta",
|
||||
"title-contact-points": "Pontos de contacto",
|
||||
"title-notification-policies": "Políticas de notificação"
|
||||
@@ -14396,6 +14399,17 @@
|
||||
"series-to-rows": "Série para linhas"
|
||||
}
|
||||
},
|
||||
"smoothing": {
|
||||
"description": "",
|
||||
"effective-resolution": "",
|
||||
"effective-resolution-tooltip": "",
|
||||
"is-applicable-description": "",
|
||||
"name": "",
|
||||
"resolution": {
|
||||
"label": "",
|
||||
"tooltip": ""
|
||||
}
|
||||
},
|
||||
"sort-by-transformer-editor": {
|
||||
"description": {
|
||||
"sort-fields": "Ordenar campos num quadro."
|
||||
|
||||
@@ -3157,9 +3157,12 @@
|
||||
"table": "Таблица"
|
||||
},
|
||||
"welcome-header": {
|
||||
"description-alert-activity": "",
|
||||
"description-alert-rules": "Задайте условие, которое должно быть выполнено до того, как автивируется правило оповещения.",
|
||||
"description-configure-firing-alert-instances-routed-contact": "Установите способ направления активных экземпляров оповещений в точки контакта.",
|
||||
"description-configure-receives-notifications": "Установите получателей уведомлений и способы их отправки.",
|
||||
"href-text-alert-activity": "",
|
||||
"title-alert-activity": "",
|
||||
"title-alert-rules": "Правила оповещения",
|
||||
"title-contact-points": "Точки контакта",
|
||||
"title-notification-policies": "Политики уведомления"
|
||||
@@ -14508,6 +14511,17 @@
|
||||
"series-to-rows": "Ряды в строки"
|
||||
}
|
||||
},
|
||||
"smoothing": {
|
||||
"description": "",
|
||||
"effective-resolution": "",
|
||||
"effective-resolution-tooltip": "",
|
||||
"is-applicable-description": "",
|
||||
"name": "",
|
||||
"resolution": {
|
||||
"label": "",
|
||||
"tooltip": ""
|
||||
}
|
||||
},
|
||||
"sort-by-transformer-editor": {
|
||||
"description": {
|
||||
"sort-fields": "Сортируйте поля в фрейме."
|
||||
|
||||
@@ -3135,9 +3135,12 @@
|
||||
"table": "Tabell"
|
||||
},
|
||||
"welcome-header": {
|
||||
"description-alert-activity": "",
|
||||
"description-alert-rules": "Definiera villkoret som måste uppfyllas innan en larmregel utlöses",
|
||||
"description-configure-firing-alert-instances-routed-contact": "Konfigurera hur utlösta larminstanser dirigeras till kontaktpunkter",
|
||||
"description-configure-receives-notifications": "Konfigurera vem som tar emot aviseringar och hur de skickas",
|
||||
"href-text-alert-activity": "",
|
||||
"title-alert-activity": "",
|
||||
"title-alert-rules": "Varningsregler",
|
||||
"title-contact-points": "Kontaktpunkter",
|
||||
"title-notification-policies": "Aviseringspolicyer"
|
||||
@@ -14396,6 +14399,17 @@
|
||||
"series-to-rows": "Serie till rader"
|
||||
}
|
||||
},
|
||||
"smoothing": {
|
||||
"description": "",
|
||||
"effective-resolution": "",
|
||||
"effective-resolution-tooltip": "",
|
||||
"is-applicable-description": "",
|
||||
"name": "",
|
||||
"resolution": {
|
||||
"label": "",
|
||||
"tooltip": ""
|
||||
}
|
||||
},
|
||||
"sort-by-transformer-editor": {
|
||||
"description": {
|
||||
"sort-fields": "Sortera fält i en ram."
|
||||
|
||||
@@ -3135,9 +3135,12 @@
|
||||
"table": "Tablo"
|
||||
},
|
||||
"welcome-header": {
|
||||
"description-alert-activity": "",
|
||||
"description-alert-rules": "Bir uyarı kuralının tetiklenmesi için karşılanması gereken koşulu tanımlayın",
|
||||
"description-configure-firing-alert-instances-routed-contact": "Tetiklenen uyarı örneklerinin iletişim noktalarına nasıl yönlendirileceğini yapılandırın",
|
||||
"description-configure-receives-notifications": "Bildirimlerin kime gönderileceğini ve nasıl gönderileceğini yapılandırın",
|
||||
"href-text-alert-activity": "",
|
||||
"title-alert-activity": "",
|
||||
"title-alert-rules": "Uyarı kuralları",
|
||||
"title-contact-points": "İletişim noktaları",
|
||||
"title-notification-policies": "Bildirim politikaları"
|
||||
@@ -14396,6 +14399,17 @@
|
||||
"series-to-rows": "Seriden satırlara"
|
||||
}
|
||||
},
|
||||
"smoothing": {
|
||||
"description": "",
|
||||
"effective-resolution": "",
|
||||
"effective-resolution-tooltip": "",
|
||||
"is-applicable-description": "",
|
||||
"name": "",
|
||||
"resolution": {
|
||||
"label": "",
|
||||
"tooltip": ""
|
||||
}
|
||||
},
|
||||
"sort-by-transformer-editor": {
|
||||
"description": {
|
||||
"sort-fields": "Alanları bir çerçevede sıralayın."
|
||||
|
||||
@@ -3124,9 +3124,12 @@
|
||||
"table": "表格"
|
||||
},
|
||||
"welcome-header": {
|
||||
"description-alert-activity": "",
|
||||
"description-alert-rules": "定义警报规则触发前必须满足的条件",
|
||||
"description-configure-firing-alert-instances-routed-contact": "配置如何将触发的警报实例路由到联络点",
|
||||
"description-configure-receives-notifications": "配置接收通知的人员以及通知发送方式",
|
||||
"href-text-alert-activity": "",
|
||||
"title-alert-activity": "",
|
||||
"title-alert-rules": "警报规则",
|
||||
"title-contact-points": "联络点",
|
||||
"title-notification-policies": "通知策略"
|
||||
@@ -14340,6 +14343,17 @@
|
||||
"series-to-rows": "序列到行"
|
||||
}
|
||||
},
|
||||
"smoothing": {
|
||||
"description": "",
|
||||
"effective-resolution": "",
|
||||
"effective-resolution-tooltip": "",
|
||||
"is-applicable-description": "",
|
||||
"name": "",
|
||||
"resolution": {
|
||||
"label": "",
|
||||
"tooltip": ""
|
||||
}
|
||||
},
|
||||
"sort-by-transformer-editor": {
|
||||
"description": {
|
||||
"sort-fields": "对帧中的字段进行排序。"
|
||||
|
||||
@@ -3124,9 +3124,12 @@
|
||||
"table": "表格"
|
||||
},
|
||||
"welcome-header": {
|
||||
"description-alert-activity": "",
|
||||
"description-alert-rules": "定義警報規則觸發前必須滿足的條件",
|
||||
"description-configure-firing-alert-instances-routed-contact": "設定如何將觸發的警報執行個體傳送至聯絡點",
|
||||
"description-configure-receives-notifications": "設定接收通知的對象以及傳送方式",
|
||||
"href-text-alert-activity": "",
|
||||
"title-alert-activity": "",
|
||||
"title-alert-rules": "警報規則",
|
||||
"title-contact-points": "聯絡點",
|
||||
"title-notification-policies": "通知政策"
|
||||
@@ -14340,6 +14343,17 @@
|
||||
"series-to-rows": "將序列轉為列"
|
||||
}
|
||||
},
|
||||
"smoothing": {
|
||||
"description": "",
|
||||
"effective-resolution": "",
|
||||
"effective-resolution-tooltip": "",
|
||||
"is-applicable-description": "",
|
||||
"name": "",
|
||||
"resolution": {
|
||||
"label": "",
|
||||
"tooltip": ""
|
||||
}
|
||||
},
|
||||
"sort-by-transformer-editor": {
|
||||
"description": {
|
||||
"sort-fields": "對框架中的欄位進行排序。"
|
||||
|
||||
Reference in New Issue
Block a user