Compare commits
72 Commits
dependabot
...
ash/react-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc7577f940 | ||
|
|
21ea3f2c2e | ||
|
|
59d3ef2504 | ||
|
|
ff7bab5753 | ||
|
|
9ba65ab73f | ||
|
|
fccece3ca0 | ||
|
|
d44cab9eaf | ||
|
|
3d3b4dd213 | ||
|
|
d97c56c51e | ||
|
|
4752b45f81 | ||
|
|
43ce2acafc | ||
|
|
f2cb70b1e0 | ||
|
|
7a1938c23b | ||
|
|
03de5f59e6 | ||
|
|
494a663449 | ||
|
|
d9059ca7b2 | ||
|
|
c254cf1387 | ||
|
|
ebf6ba442b | ||
|
|
462b6354d0 | ||
|
|
7f1f3c6ba6 | ||
|
|
de6d2700b7 | ||
|
|
481dc3e630 | ||
|
|
0f46f38b77 | ||
|
|
2f3a4d0358 | ||
|
|
d0aec88ca8 | ||
|
|
4a0e9204b3 | ||
|
|
12c6e9615f | ||
|
|
7a0d7c5dec | ||
|
|
b035732a85 | ||
|
|
05ef468b41 | ||
|
|
730f10597a | ||
|
|
caff0e2d1e | ||
|
|
f10a494369 | ||
|
|
8d42d4a079 | ||
|
|
720f038981 | ||
|
|
b380ce2bfd | ||
|
|
68a83b73c9 | ||
|
|
3808ddf948 | ||
|
|
f1d654d2e3 | ||
|
|
b0798f24c5 | ||
|
|
4c90d10281 | ||
|
|
96614c4eca | ||
|
|
fd4a97e49e | ||
|
|
68e0ed782c | ||
|
|
5fbbf2ac4a | ||
|
|
e97d48d86b | ||
|
|
74c656713a | ||
|
|
470cd869f3 | ||
|
|
3ef28b727f | ||
|
|
afe54f6739 | ||
|
|
f7d8fd4986 | ||
|
|
c73db56467 | ||
|
|
37bd5ded3a | ||
|
|
418c1a4d5a | ||
|
|
d3e807d6e2 | ||
|
|
03a044a9a0 | ||
|
|
e861318c2d | ||
|
|
35633b756d | ||
|
|
eb77bf89df | ||
|
|
636c62862d | ||
|
|
737ee7c7bd | ||
|
|
13b5c3f974 | ||
|
|
1915a92eb2 | ||
|
|
94cad60654 | ||
|
|
9d9085075b | ||
|
|
efeac25952 | ||
|
|
0da94b11ee | ||
|
|
9aa86eb056 | ||
|
|
f6107150e0 | ||
|
|
cb90eddf84 | ||
|
|
141ed7bdbf | ||
|
|
d2bf550499 |
24
.github/workflows/pr-e2e-tests.yml
vendored
24
.github/workflows/pr-e2e-tests.yml
vendored
@@ -192,6 +192,30 @@ jobs:
|
||||
-f "output[summary]=${IMAGE}" \
|
||||
-f "output[text]=${IMAGE}"
|
||||
|
||||
# TODO remove this when delivering
|
||||
# This will push the temporary docker image to dockerhub
|
||||
push-docker-image-to-dockerhub:
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-grafana
|
||||
steps:
|
||||
- uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53
|
||||
with:
|
||||
name: grafana-docker-tar-gz
|
||||
path: .
|
||||
- uses: grafana/shared-workflows/actions/dockerhub-login@dockerhub-login/v1.0.2
|
||||
- name: Load & Push Docker image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
LOADED_IMAGE_NAME=$(docker load -i grafana.docker.tar.gz | sed 's/Loaded image: //g')
|
||||
DOCKER_IMAGE="grafana/grafana:dev-preview-react19"
|
||||
docker tag "${LOADED_IMAGE_NAME}" "${DOCKER_IMAGE}"
|
||||
docker push "${DOCKER_IMAGE}"
|
||||
|
||||
run-e2e-tests:
|
||||
needs:
|
||||
- build-grafana
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
"@types/lodash": "4.17.7",
|
||||
"@types/node": "24.9.2",
|
||||
"@types/prismjs": "1.26.4",
|
||||
"@types/react": "18.3.18",
|
||||
"@types/react-dom": "18.3.5",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/semver": "7.5.8",
|
||||
"@types/uuid": "9.0.8",
|
||||
"glob": "10.5.0",
|
||||
@@ -37,8 +37,8 @@
|
||||
"@grafana/runtime": "workspace:*",
|
||||
"@grafana/schema": "workspace:*",
|
||||
"@grafana/ui": "workspace:*",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-router-dom": "^6.22.0",
|
||||
"rxjs": "7.8.1",
|
||||
"tslib": "2.6.3"
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
"@types/lodash": "4.17.7",
|
||||
"@types/node": "24.9.2",
|
||||
"@types/prismjs": "1.26.4",
|
||||
"@types/react": "18.3.18",
|
||||
"@types/react-dom": "18.3.5",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/semver": "7.5.8",
|
||||
"@types/uuid": "9.0.8",
|
||||
"glob": "10.4.1",
|
||||
@@ -37,8 +37,8 @@
|
||||
"@grafana/runtime": "workspace:*",
|
||||
"@grafana/schema": "workspace:*",
|
||||
"@grafana/ui": "workspace:*",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-router-dom": "^6.22.0",
|
||||
"rxjs": "7.8.1",
|
||||
"tslib": "2.6.3"
|
||||
|
||||
@@ -14,9 +14,6 @@ test.describe(
|
||||
test('Graph panel is auto-migrated', async ({ gotoDashboardPage, page }) => {
|
||||
await gotoDashboardPage({ uid: DASHBOARD_ID });
|
||||
await expect(page.getByText(DASHBOARD_NAME)).toBeVisible();
|
||||
await expect(page.getByTestId(UPLOT_MAIN_DIV_SELECTOR).first()).toBeHidden();
|
||||
|
||||
await gotoDashboardPage({ uid: DASHBOARD_ID });
|
||||
|
||||
await expect(page.getByTestId(UPLOT_MAIN_DIV_SELECTOR).first()).toBeVisible();
|
||||
});
|
||||
@@ -24,9 +21,6 @@ test.describe(
|
||||
test('Annotation markers exist for time regions', async ({ gotoDashboardPage, selectors, page }) => {
|
||||
const dashboardPage = await gotoDashboardPage({ uid: DASHBOARD_ID });
|
||||
await expect(page.getByText(DASHBOARD_NAME)).toBeVisible();
|
||||
await expect(page.getByTestId(UPLOT_MAIN_DIV_SELECTOR).first()).toBeHidden();
|
||||
|
||||
await gotoDashboardPage({ uid: DASHBOARD_ID });
|
||||
|
||||
// Check Business Hours panel
|
||||
const businessHoursPanel = dashboardPage.getByGrafanaSelector(
|
||||
|
||||
15
package.json
15
package.json
@@ -140,8 +140,8 @@
|
||||
"@types/ol-ext": "npm:@siedlerchr/types-ol-ext@3.3.0",
|
||||
"@types/pluralize": "^0.0.33",
|
||||
"@types/prismjs": "1.26.5",
|
||||
"@types/react": "18.3.18",
|
||||
"@types/react-dom": "18.3.5",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/react-grid-layout": "1.3.5",
|
||||
"@types/react-highlight-words": "0.20.0",
|
||||
"@types/react-resizable": "3.0.8",
|
||||
@@ -239,7 +239,7 @@
|
||||
"prettier": "3.6.2",
|
||||
"prom-client": "^15.1.3",
|
||||
"publint": "^0.3.12",
|
||||
"react-refresh": "0.14.0",
|
||||
"react-refresh": "0.18.0",
|
||||
"react-select-event": "5.5.1",
|
||||
"redux-mock-store": "1.5.5",
|
||||
"rimraf": "6.0.1",
|
||||
@@ -389,9 +389,9 @@
|
||||
"pluralize": "^8.0.0",
|
||||
"prismjs": "1.30.0",
|
||||
"re-resizable": "6.11.2",
|
||||
"react": "18.3.1",
|
||||
"react": "19.2.1",
|
||||
"react-diff-viewer-continued": "^3.4.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-dom": "19.2.1",
|
||||
"react-draggable": "4.5.0",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-grid-layout": "patch:react-grid-layout@npm%3A1.4.4#~/.yarn/patches/react-grid-layout-npm-1.4.4-4024c5395b.patch",
|
||||
@@ -462,7 +462,10 @@
|
||||
"js-yaml@npm:4.1.0": "^4.1.0",
|
||||
"js-yaml@npm:=4.1.0": "^4.1.0",
|
||||
"nodemailer": "7.0.11",
|
||||
"@storybook/core@npm:8.6.2": "patch:@storybook/core@npm%3A8.6.2#~/.yarn/patches/@storybook-core-npm-8.6.2-8c752112c0.patch"
|
||||
"@storybook/core@npm:8.6.2": "patch:@storybook/core@npm%3A8.6.2#~/.yarn/patches/@storybook-core-npm-8.6.2-8c752112c0.patch",
|
||||
"pretty-format/react-is": "19.0.0",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1"
|
||||
},
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
|
||||
@@ -70,13 +70,13 @@
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/lodash": "^4",
|
||||
"@types/react": "18.3.18",
|
||||
"@types/react-dom": "18.3.5",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/tinycolor2": "^1",
|
||||
"i18next": "^25.5.2",
|
||||
"i18next-cli": "^1.24.22",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"rimraf": "^6.0.1",
|
||||
"rollup": "^4.22.4",
|
||||
|
||||
@@ -91,12 +91,12 @@
|
||||
"@types/lodash": "4.17.20",
|
||||
"@types/node": "24.10.1",
|
||||
"@types/papaparse": "5.3.16",
|
||||
"@types/react": "18.3.18",
|
||||
"@types/react-dom": "18.3.5",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
"esbuild": "0.25.8",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"rimraf": "6.0.1",
|
||||
"rollup": "^4.22.4",
|
||||
"rollup-plugin-esbuild": "6.2.1",
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
"@leeoniya/ufuzzy": "1.0.19",
|
||||
"d3": "^7.8.5",
|
||||
"lodash": "4.17.21",
|
||||
"react": "18.3.1",
|
||||
"react": "19.2.0",
|
||||
"react-use": "17.6.0",
|
||||
"react-virtualized-auto-sizer": "1.0.26",
|
||||
"tinycolor2": "1.6.0",
|
||||
@@ -80,7 +80,7 @@
|
||||
"@types/jest": "^29.5.4",
|
||||
"@types/lodash": "4.17.20",
|
||||
"@types/node": "24.10.1",
|
||||
"@types/react": "18.3.18",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-virtualized-auto-sizer": "1.0.8",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
"babel-jest": "29.7.0",
|
||||
|
||||
@@ -22,7 +22,7 @@ import { getBarColorByDiff, getBarColorByPackage, getBarColorByValue } from './c
|
||||
import { CollapseConfig, CollapsedMap, FlameGraphDataContainer, LevelItem } from './dataTransform';
|
||||
|
||||
type RenderOptions = {
|
||||
canvasRef: RefObject<HTMLCanvasElement>;
|
||||
canvasRef: RefObject<HTMLCanvasElement | null>;
|
||||
data: FlameGraphDataContainer;
|
||||
root: LevelItem;
|
||||
direction: 'children' | 'parents';
|
||||
@@ -373,7 +373,7 @@ function useColorFunction(
|
||||
);
|
||||
}
|
||||
|
||||
function useSetupCanvas(canvasRef: RefObject<HTMLCanvasElement>, wrapperWidth: number, numberOfLevels: number) {
|
||||
function useSetupCanvas(canvasRef: RefObject<HTMLCanvasElement | null>, wrapperWidth: number, numberOfLevels: number) {
|
||||
const [ctx, setCtx] = useState<CanvasRenderingContext2D>();
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
"react-i18next": "^15.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "18.3.18",
|
||||
"@types/react": "19.2.7",
|
||||
"rollup": "^4.22.4",
|
||||
"rollup-plugin-copy": "3.5.0",
|
||||
"typescript": "5.9.2"
|
||||
|
||||
@@ -36,10 +36,10 @@
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
"@types/jest": "^29.5.4",
|
||||
"@types/node": "24.10.1",
|
||||
"@types/react": "18.3.18",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/systemjs": "6.15.3",
|
||||
"jest": "^29.6.4",
|
||||
"react": "18.3.1",
|
||||
"react": "19.2.0",
|
||||
"ts-jest": "29.4.0",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.9.2"
|
||||
|
||||
@@ -84,3 +84,19 @@ global.ResizeObserver = class ResizeObserver {
|
||||
this.#isObserving = false;
|
||||
}
|
||||
};
|
||||
|
||||
global.MessageChannel = jest.fn().mockImplementation(() => {
|
||||
let onmessage;
|
||||
return {
|
||||
port1: {
|
||||
set onmessage(cb) {
|
||||
onmessage = cb;
|
||||
},
|
||||
},
|
||||
port2: {
|
||||
postMessage: (data) => {
|
||||
onmessage?.({ data });
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"version": "12.4.0-pre",
|
||||
"dependencies": {
|
||||
"react": "18.3.1",
|
||||
"react": "19.2.0",
|
||||
"terser-webpack-plugin": "5.3.14",
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
@@ -15,7 +15,7 @@
|
||||
"@swc/helpers": "^0.5.0",
|
||||
"@swc/jest": "^0.2.26",
|
||||
"@types/eslint": "9.6.1",
|
||||
"@types/react": "18.3.18",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/webpack-bundle-analyzer": "^4.7.0",
|
||||
"copy-webpack-plugin": "13.0.0",
|
||||
"eslint": "9.32.0",
|
||||
|
||||
@@ -64,8 +64,8 @@
|
||||
"@reduxjs/toolkit": "2.10.1",
|
||||
"@types/debounce-promise": "3.1.9",
|
||||
"@types/lodash": "4.17.20",
|
||||
"@types/react": "18.3.18",
|
||||
"@types/react-dom": "18.3.5",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/react-highlight-words": "0.20.0",
|
||||
"@types/react-window": "1.8.8",
|
||||
"@types/semver": "7.7.1",
|
||||
@@ -101,8 +101,8 @@
|
||||
"i18next-cli": "^1.24.22",
|
||||
"jest": "29.7.0",
|
||||
"jest-environment-jsdom": "29.7.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-select-event": "5.5.1",
|
||||
"rimraf": "6.0.1",
|
||||
"rollup": "^4.22.4",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { useDebounce } from 'react-use';
|
||||
|
||||
import { TimeRange } from '@grafana/data';
|
||||
@@ -225,7 +226,10 @@ export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: P
|
||||
newSelectedMetric === '' ? undefined : selector
|
||||
);
|
||||
|
||||
setMetrics(fetchedMetrics);
|
||||
// TODO why?!
|
||||
flushSync(() => {
|
||||
setMetrics(fetchedMetrics);
|
||||
});
|
||||
setSelectedMetric(newSelectedMetric);
|
||||
setLabelKeys(fetchedLabelKeys);
|
||||
setIsLoadingLabelKeys(false);
|
||||
|
||||
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
|
||||
}
|
||||
@@ -99,7 +99,7 @@ describe('MetricsLabelsSection', () => {
|
||||
onBlur: onBlur,
|
||||
variableEditor: undefined,
|
||||
}),
|
||||
expect.anything()
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
@@ -124,7 +124,7 @@ describe('MetricsLabelsSection', () => {
|
||||
labelsFilters: defaultQuery.labels,
|
||||
variableEditor: undefined,
|
||||
}),
|
||||
expect.anything()
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -78,12 +78,12 @@
|
||||
"@types/history": "4.7.11",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/lodash": "4.17.20",
|
||||
"@types/react": "18.3.18",
|
||||
"@types/react-dom": "18.3.5",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"esbuild": "0.25.8",
|
||||
"lodash": "4.17.21",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"rimraf": "6.0.1",
|
||||
"rollup": "^4.22.4",
|
||||
"rollup-plugin-esbuild": "6.2.1",
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
"@react-awesome-query-builder/ui": "6.6.15",
|
||||
"immutable": "5.1.4",
|
||||
"lodash": "4.17.21",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-select": "5.10.2",
|
||||
"react-use": "17.6.0",
|
||||
"react-virtualized-auto-sizer": "1.0.26",
|
||||
@@ -43,8 +43,8 @@
|
||||
"@types/jest": "^29.5.4",
|
||||
"@types/lodash": "4.17.20",
|
||||
"@types/node": "24.10.1",
|
||||
"@types/react": "18.3.18",
|
||||
"@types/react-dom": "18.3.5",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/react-virtualized-auto-sizer": "1.0.8",
|
||||
"@types/systemjs": "6.15.3",
|
||||
"@types/uuid": "10.0.0",
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"@hello-pangea/dnd": "18.0.1",
|
||||
"@monaco-editor/react": "4.7.0",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@rc-component/cascader": "1.9.0",
|
||||
"@rc-component/drawer": "1.3.0",
|
||||
"@rc-component/picker": "1.7.1",
|
||||
"@rc-component/slider": "1.0.1",
|
||||
@@ -105,7 +106,6 @@
|
||||
"monaco-editor": "0.34.1",
|
||||
"ol": "10.7.0",
|
||||
"prismjs": "1.30.0",
|
||||
"rc-cascader": "3.34.0",
|
||||
"react-calendar": "^6.0.0",
|
||||
"react-colorful": "5.6.1",
|
||||
"react-custom-scrollbars-2": "4.5.0",
|
||||
@@ -167,9 +167,9 @@
|
||||
"@types/mock-raf": "1.0.6",
|
||||
"@types/node": "24.10.1",
|
||||
"@types/prismjs": "1.26.5",
|
||||
"@types/react": "18.3.18",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-color": "3.0.13",
|
||||
"@types/react-dom": "18.3.5",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/react-highlight-words": "0.20.0",
|
||||
"@types/react-transition-group": "4.4.12",
|
||||
"@types/react-window": "1.8.8",
|
||||
@@ -190,8 +190,8 @@
|
||||
"msw": "^2.10.2",
|
||||
"msw-storybook-addon": "^2.0.5",
|
||||
"process": "^0.11.10",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-select-event": "^5.1.0",
|
||||
"rimraf": "6.0.1",
|
||||
"rollup": "^4.22.4",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import RCCascader, { FieldNames } from 'rc-cascader';
|
||||
import RCCascader, { FieldNames } from '@rc-component/cascader';
|
||||
import * as React from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
@@ -47,7 +47,7 @@ export const ButtonCascader = (props: ButtonCascaderProps) => {
|
||||
<RCCascader
|
||||
onChange={onChangeCascader(onChange)}
|
||||
loadData={onLoadDataCascader(loadData)}
|
||||
dropdownClassName={cx(cascaderStyles.dropdown, styles.popup)}
|
||||
popupClassName={cx(cascaderStyles.dropdown, styles.popup)}
|
||||
{...rest}
|
||||
expandIcon={null}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { css } from '@emotion/css';
|
||||
import RCCascader from '@rc-component/cascader';
|
||||
import memoize from 'micro-memoize';
|
||||
import RCCascader from 'rc-cascader';
|
||||
import { PureComponent } from 'react';
|
||||
import * as React from 'react';
|
||||
|
||||
@@ -279,7 +279,7 @@ class UnthemedCascader extends PureComponent<CascaderProps, CascaderState> {
|
||||
expandIcon={null}
|
||||
open={this.props.alwaysOpen}
|
||||
disabled={disabled}
|
||||
dropdownClassName={styles.dropdown}
|
||||
popupClassName={styles.dropdown}
|
||||
>
|
||||
<div className={disableDivFocus}>
|
||||
<Input
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BaseOptionType as RCCascaderOption, CascaderProps } from 'rc-cascader';
|
||||
import { BaseOptionType as RCCascaderOption, CascaderProps } from '@rc-component/cascader';
|
||||
|
||||
import { CascaderOption } from './Cascader';
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ interface ComboboxListProps<T extends string | number> {
|
||||
options: Array<ComboboxOption<T>>;
|
||||
highlightedIndex: number | null;
|
||||
selectedItems?: Array<ComboboxOption<T>>;
|
||||
scrollRef: React.RefObject<HTMLDivElement>;
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
getItemProps: UseComboboxPropGetters<ComboboxOption<T>>['getItemProps'];
|
||||
enableAllOption?: boolean;
|
||||
isMultiSelect?: boolean;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { autoUpdate, autoPlacement, size, useFloating } from '@floating-ui/react';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { CSSProperties, type RefObject, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { BOUNDARY_ELEMENT_ID } from '../../utils/floating';
|
||||
import { measureText } from '../../utils/measureText';
|
||||
@@ -21,7 +21,21 @@ const POPOVER_PADDING = 16;
|
||||
|
||||
const SCROLL_CONTAINER_PADDING = 8;
|
||||
|
||||
export const useComboboxFloat = (items: Array<ComboboxOption<string | number>>, isOpen: boolean) => {
|
||||
interface UseComboboxFloatReturn {
|
||||
inputRef: RefObject<HTMLInputElement | null>;
|
||||
floatingRef: RefObject<HTMLDivElement | null>;
|
||||
scrollRef: RefObject<HTMLDivElement | null>;
|
||||
floatStyles: CSSProperties & {
|
||||
width: number;
|
||||
maxWidth: number;
|
||||
maxHeight: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const useComboboxFloat = (
|
||||
items: Array<ComboboxOption<string | number>>,
|
||||
isOpen: boolean
|
||||
): UseComboboxFloatReturn => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const floatingRef = useRef<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useStyles2 } from '../../themes/ThemeContext';
|
||||
import { List } from '../List/List';
|
||||
|
||||
interface DataLinkSuggestionsProps {
|
||||
activeRef?: React.RefObject<HTMLDivElement>;
|
||||
activeRef?: React.RefObject<HTMLDivElement | null>;
|
||||
suggestions: VariableSuggestion[];
|
||||
activeIndex: number;
|
||||
onSuggestionSelect: (suggestion: VariableSuggestion) => void;
|
||||
@@ -103,7 +103,7 @@ DataLinkSuggestions.displayName = 'DataLinkSuggestions';
|
||||
interface DataLinkSuggestionsListProps extends DataLinkSuggestionsProps {
|
||||
label: string;
|
||||
activeIndexOffset: number;
|
||||
activeRef?: React.RefObject<HTMLDivElement>;
|
||||
activeRef?: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
const DataLinkSuggestionsList = React.memo(
|
||||
|
||||
@@ -13,7 +13,7 @@ describe('useListFocus', () => {
|
||||
|
||||
const testid = 'test';
|
||||
const getListElement = (
|
||||
ref: RefObject<HTMLUListElement>,
|
||||
ref: RefObject<HTMLUListElement | null>,
|
||||
handleKeys?: (event: KeyboardEvent) => void,
|
||||
onClick?: () => void
|
||||
) => (
|
||||
|
||||
@@ -7,7 +7,7 @@ const CAUGHT_KEYS = ['ArrowUp', 'ArrowDown', 'Home', 'End', 'Enter', 'Tab'];
|
||||
|
||||
/** @internal */
|
||||
export interface UseListFocusProps {
|
||||
localRef: RefObject<HTMLUListElement>;
|
||||
localRef: RefObject<HTMLUListElement | null>;
|
||||
options: TimeOption[];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { RefObject, useRef } from 'react';
|
||||
|
||||
export function useFocus(): [RefObject<HTMLInputElement>, () => void] {
|
||||
export function useFocus(): [RefObject<HTMLInputElement | null>, () => void] {
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
const setFocus = () => {
|
||||
ref.current && ref.current.focus();
|
||||
|
||||
@@ -13,7 +13,7 @@ describe('useMenuFocus', () => {
|
||||
|
||||
const testid = 'test';
|
||||
const getMenuElement = (
|
||||
ref: RefObject<HTMLDivElement>,
|
||||
ref: RefObject<HTMLDivElement | null>,
|
||||
handleKeys?: (event: KeyboardEvent) => void,
|
||||
handleFocus?: () => void,
|
||||
onClick?: () => void
|
||||
|
||||
@@ -6,7 +6,7 @@ const UNFOCUSED = -1;
|
||||
|
||||
/** @internal */
|
||||
export interface UseMenuFocusProps {
|
||||
localRef: RefObject<HTMLDivElement>;
|
||||
localRef: RefObject<HTMLDivElement | null>;
|
||||
isMenuOpen?: boolean;
|
||||
close?: () => void;
|
||||
onOpen?: (focusOnItem: (itemId: number) => void) => void;
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { HTMLAttributes, PropsWithChildren, type JSX } from 'react';
|
||||
import * as React from 'react';
|
||||
import { createElement, type HTMLAttributes, type PropsWithChildren, type HTMLElementType, type JSX } from 'react';
|
||||
|
||||
import { textUtil } from '@grafana/data';
|
||||
|
||||
export interface RenderUserContentAsHTMLProps<T = HTMLSpanElement>
|
||||
extends Omit<HTMLAttributes<T>, 'dangerouslySetInnerHTML'> {
|
||||
component?: keyof React.ReactHTML;
|
||||
component?: HTMLElementType;
|
||||
content: string;
|
||||
}
|
||||
|
||||
@@ -19,7 +18,7 @@ export function RenderUserContentAsHTML<T>({
|
||||
content,
|
||||
...rest
|
||||
}: PropsWithChildren<RenderUserContentAsHTMLProps<T>>): JSX.Element {
|
||||
return React.createElement(component || 'span', {
|
||||
return createElement(component || 'span', {
|
||||
dangerouslySetInnerHTML: { __html: textUtil.sanitize(content) },
|
||||
...rest,
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ export interface TableCellTooltipProps {
|
||||
field: Field;
|
||||
getActions: (field: Field, rowIdx: number) => ActionModel[];
|
||||
getTextColorForBackground: (bgColor: string) => string;
|
||||
gridRef: RefObject<DataGridHandle>;
|
||||
gridRef: RefObject<DataGridHandle | null>;
|
||||
height: number;
|
||||
placement?: TableCellTooltipPlacement;
|
||||
renderer: TableCellRenderer;
|
||||
|
||||
@@ -463,7 +463,7 @@ export function useColumnResize(
|
||||
return dataGridResizeHandler;
|
||||
}
|
||||
|
||||
export function useScrollbarWidth(ref: RefObject<DataGridHandle>, height: number) {
|
||||
export function useScrollbarWidth(ref: RefObject<DataGridHandle | null>, height: number) {
|
||||
const [scrollbarWidth, setScrollbarWidth] = useState(0);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
|
||||
@@ -49,7 +49,7 @@ interface RowsListProps {
|
||||
listHeight: number;
|
||||
width: number;
|
||||
cellHeight?: TableCellHeight;
|
||||
listRef: React.RefObject<VariableSizeList>;
|
||||
listRef: React.RefObject<VariableSizeList | null>;
|
||||
tableState: TableState;
|
||||
tableStyles: TableStyles;
|
||||
nestedDataField?: Field;
|
||||
|
||||
@@ -135,7 +135,7 @@ export const Table = memo((props: Props) => {
|
||||
// `useTableStateReducer`, which is needed to construct options for `useTable` (the hook that returns
|
||||
// `toggleAllRowsExpanded`), and if we used a variable, that variable would be undefined at the time
|
||||
// we initialize `useTableStateReducer`.
|
||||
const toggleAllRowsExpandedRef = useRef<(value?: boolean) => void>();
|
||||
const toggleAllRowsExpandedRef = useRef<(value?: boolean) => void>(null);
|
||||
|
||||
// Internal react table state reducer
|
||||
const stateReducer = useTableStateReducer({
|
||||
|
||||
@@ -14,8 +14,8 @@ import { GrafanaTableState } from './types';
|
||||
Select the scrollbar element from the VariableSizeList scope
|
||||
*/
|
||||
export function useFixScrollbarContainer(
|
||||
variableSizeListScrollbarRef: React.RefObject<HTMLDivElement>,
|
||||
tableDivRef: React.RefObject<HTMLDivElement>
|
||||
variableSizeListScrollbarRef: React.RefObject<HTMLDivElement | null>,
|
||||
tableDivRef: React.RefObject<HTMLDivElement | null>
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (variableSizeListScrollbarRef.current && tableDivRef.current) {
|
||||
@@ -43,7 +43,7 @@ export function useFixScrollbarContainer(
|
||||
*/
|
||||
export function useResetVariableListSizeCache(
|
||||
extendedState: GrafanaTableState,
|
||||
listRef: React.RefObject<VariableSizeList>,
|
||||
listRef: React.RefObject<VariableSizeList | null>,
|
||||
data: DataFrame,
|
||||
hasUniqueId: boolean
|
||||
) {
|
||||
|
||||
@@ -19,7 +19,7 @@ interface EventsCanvasProps {
|
||||
}
|
||||
|
||||
export function EventsCanvas({ id, events, renderEventMarker, mapEventToXYCoords, config }: EventsCanvasProps) {
|
||||
const plotInstance = useRef<uPlot>();
|
||||
const plotInstance = useRef<uPlot>(null);
|
||||
// render token required to re-render annotation markers. Rendering lines happens in uPlot and the props do not change
|
||||
// so we need to force the re-render when the draw hook was performed by uPlot
|
||||
const [renderToken, setRenderToken] = useState(0);
|
||||
|
||||
@@ -140,7 +140,7 @@ export const TooltipPlugin2 = ({
|
||||
|
||||
const [{ plot, isHovering, isPinned, contents, style, dismiss }, setState] = useReducer(mergeState, null, initState);
|
||||
|
||||
const sizeRef = useRef<TooltipContainerSize>();
|
||||
const sizeRef = useRef<TooltipContainerSize>(null);
|
||||
const styles = useStyles2(getStyles, maxWidth);
|
||||
|
||||
const renderRef = useRef(render);
|
||||
|
||||
@@ -96,7 +96,7 @@ export interface GraphNGState {
|
||||
export class GraphNG extends Component<GraphNGProps, GraphNGState> {
|
||||
static contextType = PanelContextRoot;
|
||||
panelContext: PanelContext = {} as PanelContext;
|
||||
private plotInstance: React.RefObject<uPlot>;
|
||||
private plotInstance: React.RefObject<uPlot | null>;
|
||||
|
||||
private subscription = new Subscription();
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ export const TooltipPlugin = ({
|
||||
renderTooltip,
|
||||
...otherProps
|
||||
}: TooltipPluginProps) => {
|
||||
const plotInstance = useRef<uPlot>();
|
||||
const plotInstance = useRef<uPlot>(null);
|
||||
const theme = useTheme2();
|
||||
const [focusedSeriesIdx, setFocusedSeriesIdx] = useState<number | null>(null);
|
||||
const [focusedPointIdx, setFocusedPointIdx] = useState<number | null>(null);
|
||||
|
||||
@@ -19,7 +19,7 @@ export function useDelayedSwitch(value: boolean, options: DelayOptions = {}): bo
|
||||
const { duration = 250, delay = 250 } = options;
|
||||
|
||||
const [delayedValue, setDelayedValue] = useState(value);
|
||||
const onStartTime = useRef<Date | undefined>();
|
||||
const onStartTime = useRef<Date | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useState } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
@@ -34,7 +35,10 @@ export const ForgottenPassword = () => {
|
||||
const sendEmail = async (formModel: EmailDTO) => {
|
||||
const res = await getBackendSrv().post('/api/user/password/send-reset-email', formModel);
|
||||
if (res) {
|
||||
setEmailSent(true);
|
||||
// TODO why?
|
||||
flushSync(() => {
|
||||
setEmailSent(true);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ const defaultMatchers = {
|
||||
* "Time as X" core component, expects ascending x
|
||||
*/
|
||||
export class GraphNG extends Component<GraphNGProps, GraphNGState> {
|
||||
private plotInstance: React.RefObject<uPlot>;
|
||||
private plotInstance: React.RefObject<uPlot | null>;
|
||||
|
||||
constructor(props: GraphNGProps) {
|
||||
super(props);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { act } from '@testing-library/react';
|
||||
import { comboboxTestSetup } from 'test/helpers/comboboxTestSetup';
|
||||
import { getSelectParent, selectOptionInTest } from 'test/helpers/selectOptionInTest';
|
||||
import { render, screen, userEvent, waitFor, within } from 'test/test-utils';
|
||||
@@ -29,7 +30,9 @@ const selectComboboxOptionInTest = async (input: HTMLElement, optionOrOptions: s
|
||||
};
|
||||
|
||||
const setup = async () => {
|
||||
const view = render(<SharedPreferences resourceUri="user" preferenceType="user" />);
|
||||
// TODO investigate why we need act
|
||||
// see https://github.com/testing-library/react-testing-library/issues/1375
|
||||
const view = await act(() => render(<SharedPreferences resourceUri="user" preferenceType="user" />));
|
||||
const themeSelect = await screen.findByRole('combobox', { name: 'Interface theme' });
|
||||
await waitFor(() => expect(themeSelect).not.toBeDisabled());
|
||||
return view;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
@@ -25,7 +26,10 @@ export const VerifyEmail = () => {
|
||||
getBackendSrv()
|
||||
.post('/api/user/signup', formModel)
|
||||
.then(() => {
|
||||
setEmailSent(true);
|
||||
// TODO why?
|
||||
flushSync(() => {
|
||||
setEmailSent(true);
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
const msg = err.data?.message || err;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { act, fireEvent } from '@testing-library/react';
|
||||
import { InitialEntry } from 'history';
|
||||
import { last } from 'lodash';
|
||||
import { Route, Routes } from 'react-router-dom-v5-compat';
|
||||
@@ -144,8 +145,11 @@ const fillOutForm = async ({
|
||||
};
|
||||
|
||||
const saveMuteTiming = async () => {
|
||||
const user = userEvent.setup();
|
||||
await user.click(await screen.findByText(/save time interval/i));
|
||||
// TODO investigate why we need act/fireEvent
|
||||
// see https://github.com/testing-library/react-testing-library/issues/1375
|
||||
const button = await screen.findByText(/save time interval/i);
|
||||
// eslint-disable-next-line testing-library/no-unnecessary-act, testing-library/prefer-user-event
|
||||
await act(() => fireEvent.click(button));
|
||||
};
|
||||
|
||||
setupMswServer();
|
||||
|
||||
@@ -2,7 +2,7 @@ import { HTMLAttributes } from 'react';
|
||||
|
||||
import { Button, IconSize } from '@grafana/ui';
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLButtonElement> {
|
||||
interface Props extends Omit<HTMLAttributes<HTMLButtonElement>, 'onToggle'> {
|
||||
isCollapsed: boolean;
|
||||
onToggle: (isCollapsed: boolean) => void;
|
||||
// Todo: this should be made compulsory for a11y purposes
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { act, fireEvent } from '@testing-library/react';
|
||||
import * as React from 'react';
|
||||
import { Route, Routes } from 'react-router-dom-v5-compat';
|
||||
import { Props } from 'react-virtualized-auto-sizer';
|
||||
import { render, userEvent, waitFor, waitForElementToBeRemoved } from 'test/test-utils';
|
||||
import { render, waitFor, waitForElementToBeRemoved } from 'test/test-utils';
|
||||
import { byRole, byTestId, byText } from 'testing-library-selector';
|
||||
|
||||
import { mockExportApi, setupMswServer } from '../../mockApi';
|
||||
@@ -72,14 +73,15 @@ describe('GrafanaModifyExport', () => {
|
||||
json: 'Json Export Content',
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderModifyExport(grafanaRulerRule.grafana_alert.uid);
|
||||
|
||||
await waitForElementToBeRemoved(() => ui.loading.get());
|
||||
expect(await ui.form.nameInput.find()).toHaveValue('Grafana-rule');
|
||||
|
||||
await user.click(ui.exportButton.get());
|
||||
// TODO investigate why we need act
|
||||
// see https://github.com/testing-library/react-testing-library/issues/1375
|
||||
// eslint-disable-next-line testing-library/no-unnecessary-act, testing-library/prefer-user-event
|
||||
await act(() => fireEvent.click(ui.exportButton.get()));
|
||||
|
||||
const drawer = await ui.exportDrawer.dialog.find();
|
||||
expect(drawer).toBeInTheDocument();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { act, fireEvent } from '@testing-library/react';
|
||||
import { render, testWithFeatureToggles, waitFor } from 'test/test-utils';
|
||||
import { byLabelText, byRole } from 'testing-library-selector';
|
||||
|
||||
@@ -156,7 +157,10 @@ groups:
|
||||
await user.click(await ui.dsImport.mimirDsOption.find());
|
||||
|
||||
// Click the import button
|
||||
await user.click(ui.importButton.get());
|
||||
// TODO investigate why we need act/fireEvent
|
||||
// see https://github.com/testing-library/react-testing-library/issues/1375
|
||||
// eslint-disable-next-line testing-library/no-unnecessary-act, testing-library/prefer-user-event
|
||||
await act(() => fireEvent.click(ui.importButton.get()));
|
||||
|
||||
// Verify confirmation dialog appears
|
||||
expect(await ui.confirmationModal.find()).toBeInTheDocument();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { act, fireEvent } from '@testing-library/react';
|
||||
import { Route, Routes } from 'react-router-dom-v5-compat';
|
||||
import { render, screen } from 'test/test-utils';
|
||||
import { byPlaceholderText, byRole, byTestId } from 'testing-library-selector';
|
||||
@@ -65,7 +66,10 @@ describe('new receiver', () => {
|
||||
await user.type(email, 'tester@grafana.com');
|
||||
|
||||
// try to test the contact point
|
||||
await user.click(await ui.testContactPointButton.find());
|
||||
// TODO investigate why we need act/fireEvent
|
||||
// see https://github.com/testing-library/react-testing-library/issues/1375
|
||||
// eslint-disable-next-line testing-library/no-unnecessary-act, testing-library/prefer-user-event
|
||||
await act(async () => fireEvent.click(await ui.testContactPointButton.find()));
|
||||
|
||||
expect(await ui.testContactPointModal.find()).toBeInTheDocument();
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { act } from '@testing-library/react';
|
||||
import type { JSX } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { render, screen, within } from 'test/test-utils';
|
||||
@@ -301,15 +302,20 @@ describe('AnnotationsField', function () {
|
||||
})
|
||||
);
|
||||
|
||||
render(
|
||||
<FormWrapper
|
||||
formValues={{
|
||||
annotations: [
|
||||
{ key: Annotation.dashboardUID, value: 'dash-test-uid' },
|
||||
{ key: Annotation.panelID, value: '1' },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
// TODO investigate why we need act
|
||||
// see https://github.com/testing-library/react-testing-library/issues/1375
|
||||
// eslint-disable-next-line testing-library/no-unnecessary-act
|
||||
await act(() =>
|
||||
render(
|
||||
<FormWrapper
|
||||
formValues={{
|
||||
annotations: [
|
||||
{ key: Annotation.dashboardUID, value: 'dash-test-uid' },
|
||||
{ key: Annotation.panelID, value: '1' },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
expect(await ui.dashboardAnnotation.find()).toBeInTheDocument();
|
||||
|
||||
@@ -479,7 +479,7 @@ describe('RuleViewer', () => {
|
||||
expect.objectContaining({
|
||||
ruleUid: 'test-rule-uid',
|
||||
}),
|
||||
expect.any(Object)
|
||||
undefined
|
||||
);
|
||||
expect(screen.getByTestId('enrichment-section')).toBeInTheDocument();
|
||||
});
|
||||
@@ -500,7 +500,7 @@ describe('RuleViewer', () => {
|
||||
expect.objectContaining({
|
||||
ruleUid: 'test-rule-uid',
|
||||
}),
|
||||
expect.any(Object)
|
||||
undefined
|
||||
);
|
||||
expect(screen.getByTestId('enrichment-section')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { act, fireEvent } from '@testing-library/react';
|
||||
import { produce } from 'immer';
|
||||
import { render } from 'test/test-utils';
|
||||
import { byRole, byText } from 'testing-library-selector';
|
||||
@@ -58,7 +59,7 @@ describe('Moving a Grafana managed rule', () => {
|
||||
|
||||
const ruleID = fromRulerRuleAndRuleGroupIdentifier(currentRuleGroupID, ruleToMove);
|
||||
|
||||
const { user } = render(
|
||||
render(
|
||||
<MoveRuleTestComponent
|
||||
currentRuleGroupIdentifier={currentRuleGroupID}
|
||||
targetRuleGroupIdentifier={targetRuleGroupID}
|
||||
@@ -66,7 +67,10 @@ describe('Moving a Grafana managed rule', () => {
|
||||
rule={ruleToMove}
|
||||
/>
|
||||
);
|
||||
await user.click(byRole('button').get());
|
||||
// TODO investigate why we need act/fireEvent
|
||||
// see https://github.com/testing-library/react-testing-library/issues/1375
|
||||
// eslint-disable-next-line testing-library/no-unnecessary-act, testing-library/prefer-user-event
|
||||
await act(() => fireEvent.click(byRole('button').get()));
|
||||
|
||||
expect(await byText(/success/i).find()).toBeInTheDocument();
|
||||
|
||||
@@ -87,7 +91,7 @@ describe('Moving a Grafana managed rule', () => {
|
||||
uid: 'does-not-exist',
|
||||
};
|
||||
|
||||
const { user } = render(
|
||||
render(
|
||||
<MoveRuleTestComponent
|
||||
currentRuleGroupIdentifier={currentRuleGroupID}
|
||||
targetRuleGroupIdentifier={currentRuleGroupID}
|
||||
@@ -95,7 +99,10 @@ describe('Moving a Grafana managed rule', () => {
|
||||
rule={grafanaRulerRule}
|
||||
/>
|
||||
);
|
||||
await user.click(byRole('button').get());
|
||||
// TODO investigate why we need act/fireEvent
|
||||
// see https://github.com/testing-library/react-testing-library/issues/1375
|
||||
// eslint-disable-next-line testing-library/no-unnecessary-act, testing-library/prefer-user-event
|
||||
await act(() => fireEvent.click(byRole('button').get()));
|
||||
|
||||
expect(await byText(/error/i).find()).toBeInTheDocument();
|
||||
});
|
||||
@@ -130,7 +137,7 @@ describe('Moving a Data source managed rule', () => {
|
||||
draft.grafana_alert.title = 'updated rule title';
|
||||
});
|
||||
|
||||
const { user } = render(
|
||||
render(
|
||||
<MoveRuleTestComponent
|
||||
currentRuleGroupIdentifier={currentRuleGroupID}
|
||||
targetRuleGroupIdentifier={targetRuleGroupID}
|
||||
@@ -138,7 +145,10 @@ describe('Moving a Data source managed rule', () => {
|
||||
rule={newRule}
|
||||
/>
|
||||
);
|
||||
await user.click(byRole('button').get());
|
||||
// TODO investigate why we need act/fireEvent
|
||||
// see https://github.com/testing-library/react-testing-library/issues/1375
|
||||
// eslint-disable-next-line testing-library/no-unnecessary-act, testing-library/prefer-user-event
|
||||
await act(() => fireEvent.click(byRole('button').get()));
|
||||
|
||||
expect(await byText(/success/i).find()).toBeInTheDocument();
|
||||
|
||||
@@ -167,7 +177,7 @@ describe('Moving a Data source managed rule', () => {
|
||||
|
||||
const ruleID = fromRulerRuleAndRuleGroupIdentifier(currentRuleGroupID, ruleToMove);
|
||||
|
||||
const { user } = render(
|
||||
render(
|
||||
<MoveRuleTestComponent
|
||||
currentRuleGroupIdentifier={currentRuleGroupID}
|
||||
targetRuleGroupIdentifier={targetRuleGroupID}
|
||||
@@ -175,7 +185,10 @@ describe('Moving a Data source managed rule', () => {
|
||||
rule={ruleToMove}
|
||||
/>
|
||||
);
|
||||
await user.click(byRole('button').get());
|
||||
// TODO investigate why we need act/fireEvent
|
||||
// see https://github.com/testing-library/react-testing-library/issues/1375
|
||||
// eslint-disable-next-line testing-library/no-unnecessary-act, testing-library/prefer-user-event
|
||||
await act(() => fireEvent.click(byRole('button').get()));
|
||||
|
||||
expect(await byText(/success/i).find()).toBeInTheDocument();
|
||||
|
||||
@@ -206,7 +219,7 @@ describe('Moving a Data source managed rule', () => {
|
||||
|
||||
const ruleID = fromRulerRuleAndRuleGroupIdentifier(currentRuleGroupID, ruleToMove);
|
||||
|
||||
const { user } = render(
|
||||
render(
|
||||
<MoveRuleTestComponent
|
||||
currentRuleGroupIdentifier={currentRuleGroupID}
|
||||
targetRuleGroupIdentifier={targetRuleGroupID}
|
||||
@@ -214,7 +227,10 @@ describe('Moving a Data source managed rule', () => {
|
||||
rule={ruleToMove}
|
||||
/>
|
||||
);
|
||||
await user.click(byRole('button').get());
|
||||
// TODO investigate why we need act/fireEvent
|
||||
// see https://github.com/testing-library/react-testing-library/issues/1375
|
||||
// eslint-disable-next-line testing-library/no-unnecessary-act, testing-library/prefer-user-event
|
||||
await act(() => fireEvent.click(byRole('button').get()));
|
||||
|
||||
expect(await byText(/success/i).find()).toBeInTheDocument();
|
||||
|
||||
@@ -239,7 +255,7 @@ describe('Moving a Data source managed rule', () => {
|
||||
draft.grafana_alert.title = 'updated rule title';
|
||||
});
|
||||
|
||||
const { user } = render(
|
||||
render(
|
||||
<MoveRuleTestComponent
|
||||
currentRuleGroupIdentifier={curentRuleGroupID}
|
||||
targetRuleGroupIdentifier={curentRuleGroupID}
|
||||
@@ -247,7 +263,10 @@ describe('Moving a Data source managed rule', () => {
|
||||
rule={newRule}
|
||||
/>
|
||||
);
|
||||
await user.click(byRole('button').get());
|
||||
// TODO investigate why we need act/fireEvent
|
||||
// see https://github.com/testing-library/react-testing-library/issues/1375
|
||||
// eslint-disable-next-line testing-library/no-unnecessary-act, testing-library/prefer-user-event
|
||||
await act(() => fireEvent.click(byRole('button').get()));
|
||||
|
||||
expect(await byText(/error/i).find()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -31,7 +31,6 @@ describe('pause rule', () => {
|
||||
expect(byText(/uninitialized/i).get()).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(byRole('button').get());
|
||||
expect(await byText(/loading/i).find()).toBeInTheDocument();
|
||||
|
||||
expect(await byText(/success/i).find()).toBeInTheDocument();
|
||||
expect(await byText(/result/i).find()).toBeInTheDocument();
|
||||
@@ -68,7 +67,6 @@ describe('pause rule', () => {
|
||||
expect(await byText(/uninitialized/i).find()).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(byRole('button').get());
|
||||
expect(await byText(/loading/i).find()).toBeInTheDocument();
|
||||
expect(byText(/success/i).query()).not.toBeInTheDocument();
|
||||
expect(await byText(/error:(.+)oops/i).find()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -84,8 +84,8 @@ export function useAsync<Result, Args extends unknown[] = unknown[]>(
|
||||
error: undefined,
|
||||
result: initialValue,
|
||||
});
|
||||
const promiseRef = useRef<Promise<Result>>();
|
||||
const argsRef = useRef<Args>();
|
||||
const promiseRef = useRef<Promise<Result>>(undefined);
|
||||
const argsRef = useRef<Args>(undefined);
|
||||
|
||||
const methods = useSyncedRef({
|
||||
execute(...params: Args) {
|
||||
|
||||
@@ -155,8 +155,8 @@ export function useRuleGroupConsistencyCheck() {
|
||||
const { isGroupInSync } = useRuleGroupIsInSync();
|
||||
const [groupConsistent, setGroupConsistent] = useState<boolean | undefined>();
|
||||
|
||||
const apiCheckInterval = useRef<ReturnType<typeof setTimeout> | undefined>();
|
||||
const timeoutInterval = useRef<ReturnType<typeof setTimeout> | undefined>();
|
||||
const apiCheckInterval = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const timeoutInterval = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -245,8 +245,8 @@ export function useRuleGroupConsistencyCheck() {
|
||||
export function usePrometheusConsistencyCheck() {
|
||||
const { matchingPromRuleExists } = useMatchingPromRuleExists();
|
||||
|
||||
const removalConsistencyInterval = useRef<number | undefined>();
|
||||
const creationConsistencyInterval = useRef<number | undefined>();
|
||||
const removalConsistencyInterval = useRef<number | undefined>(undefined);
|
||||
const creationConsistencyInterval = useRef<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { act, fireEvent } from '@testing-library/react';
|
||||
import { render, screen, waitFor, within } from 'test/test-utils';
|
||||
import { byRole } from 'testing-library-selector';
|
||||
|
||||
@@ -58,7 +59,10 @@ describe('RuleList - GroupedView', () => {
|
||||
});
|
||||
|
||||
it('should paginate through groups', async () => {
|
||||
const { user } = render(<GroupedView />);
|
||||
// TODO investigate why we need act
|
||||
// see https://github.com/testing-library/react-testing-library/issues/1375
|
||||
// eslint-disable-next-line testing-library/no-unnecessary-act
|
||||
await act(() => render(<GroupedView />));
|
||||
|
||||
const mimirSection = await ui.dsSection(/Mimir/).find();
|
||||
|
||||
@@ -73,7 +77,10 @@ describe('RuleList - GroupedView', () => {
|
||||
expect(firstPageGroups[39]).toHaveTextContent('test-group-40');
|
||||
|
||||
const loadMoreButton = await within(mimirSection).findByRole('button', { name: /Show more/i });
|
||||
await user.click(loadMoreButton);
|
||||
// TODO investigate why we need act/fireEvent
|
||||
// see https://github.com/testing-library/react-testing-library/issues/1375
|
||||
// eslint-disable-next-line testing-library/no-unnecessary-act, testing-library/prefer-user-event
|
||||
await act(() => fireEvent.click(loadMoreButton));
|
||||
|
||||
await waitFor(() => expect(loadMoreButton).toBeEnabled());
|
||||
|
||||
@@ -86,7 +93,10 @@ describe('RuleList - GroupedView', () => {
|
||||
});
|
||||
|
||||
it('should disable next button when there is no more data', async () => {
|
||||
const { user } = render(<GroupedView />);
|
||||
// TODO investigate why we need act
|
||||
// see https://github.com/testing-library/react-testing-library/issues/1375
|
||||
// eslint-disable-next-line testing-library/no-unnecessary-act
|
||||
await act(() => render(<GroupedView />));
|
||||
|
||||
const prometheusSection = await ui.dsSection(/Prometheus/).find();
|
||||
const promNamespace = await ui.namespace(/test-prometheus-namespace/).find(prometheusSection);
|
||||
@@ -96,17 +106,27 @@ describe('RuleList - GroupedView', () => {
|
||||
await ui.group('test-group-40').find(promNamespace);
|
||||
|
||||
// fetch page 2
|
||||
await user.click(await loadMoreButton.find(prometheusSection));
|
||||
// TODO investigate why we need act/fireEvent
|
||||
// see https://github.com/testing-library/react-testing-library/issues/1375
|
||||
// eslint-disable-next-line testing-library/no-unnecessary-act, testing-library/prefer-user-event
|
||||
await act(async () => fireEvent.click(await loadMoreButton.find(prometheusSection)));
|
||||
// await user.click(await loadMoreButton.find(prometheusSection));
|
||||
// we should now have all groups 1-80
|
||||
await ui.group('test-group-80').find(promNamespace);
|
||||
|
||||
// fetch page 3
|
||||
await user.click(await loadMoreButton.find(prometheusSection));
|
||||
// TODO investigate why we need act/fireEvent
|
||||
// see https://github.com/testing-library/react-testing-library/issues/1375
|
||||
// eslint-disable-next-line testing-library/no-unnecessary-act, testing-library/prefer-user-event
|
||||
await act(async () => fireEvent.click(await loadMoreButton.find(prometheusSection)));
|
||||
// we should now have all groups 1-120
|
||||
await ui.group('test-group-120').find(promNamespace);
|
||||
|
||||
// fetch page 4
|
||||
await user.click(await loadMoreButton.find(prometheusSection));
|
||||
// TODO investigate why we need act/fireEvent
|
||||
// see https://github.com/testing-library/react-testing-library/issues/1375
|
||||
// eslint-disable-next-line testing-library/no-unnecessary-act, testing-library/prefer-user-event
|
||||
await act(async () => fireEvent.click(await loadMoreButton.find(prometheusSection)));
|
||||
// we should now have all groups 1-130
|
||||
await ui.group('test-group-130').find(promNamespace);
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ type Props = {
|
||||
|
||||
function LoadMoreHelper({ handleLoad }: Props) {
|
||||
const intersectionRef = useRef<HTMLDivElement>(null);
|
||||
// TODO remove when react-use is fixed
|
||||
// see https://github.com/streamich/react-use/issues/2612
|
||||
// @ts-expect-error
|
||||
const intersection = useIntersection(intersectionRef, {
|
||||
root: null,
|
||||
threshold: 1,
|
||||
|
||||
@@ -387,7 +387,7 @@ interface ListModeProps {
|
||||
hasSearches: boolean;
|
||||
canSave: boolean;
|
||||
activeAction: ActiveAction;
|
||||
saveButtonRef: React.RefObject<HTMLButtonElement>;
|
||||
saveButtonRef: React.RefObject<HTMLButtonElement | null>;
|
||||
isLoading: boolean;
|
||||
onStartSave: () => void;
|
||||
/** Callback to complete save. Throws ValidationError on validation failure. */
|
||||
|
||||
@@ -67,7 +67,7 @@ describe('StandardAnnotationQueryEditor', () => {
|
||||
expect.objectContaining({
|
||||
query: expect.objectContaining({ queryType: 'defaultAnnotationsQuery', refId: 'initialAnnotationRef' }),
|
||||
}),
|
||||
expect.anything()
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
@@ -85,7 +85,7 @@ describe('StandardAnnotationQueryEditor', () => {
|
||||
expect.objectContaining({
|
||||
query: expect.objectContaining({ refId: 'initialAnnotationRef' }),
|
||||
}),
|
||||
expect.anything()
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
@@ -204,7 +204,7 @@ describe('StandardAnnotationQueryEditor', () => {
|
||||
refId: 'A',
|
||||
}),
|
||||
}),
|
||||
expect.anything()
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
@@ -242,7 +242,7 @@ describe('StandardAnnotationQueryEditor', () => {
|
||||
legendFormat: '{{method}} {{endpoint}}',
|
||||
}),
|
||||
}),
|
||||
expect.anything()
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
@@ -284,7 +284,7 @@ describe('StandardAnnotationQueryEditor', () => {
|
||||
refId: 'AnnoTarget',
|
||||
}),
|
||||
}),
|
||||
expect.anything()
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
@@ -320,7 +320,7 @@ describe('StandardAnnotationQueryEditor', () => {
|
||||
expr: 'up',
|
||||
}),
|
||||
}),
|
||||
expect.anything()
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ function useScopesRow(onApply: () => void) {
|
||||
function useGlobalScopesSearch(searchQuery: string, parentId?: string | null) {
|
||||
const { selectScope, searchAllNodes, getScopeNodes } = useScopeServicesState();
|
||||
const [actions, setActions] = useState<CommandPaletteAction[] | undefined>(undefined);
|
||||
const searchQueryRef = useRef<string>();
|
||||
const searchQueryRef = useRef<string>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if ((!parentId || parentId === 'scopes') && searchQuery && config.featureToggles.scopeSearchAllLevels) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, waitFor, screen, within, Matcher, getByRole } from '@testing-library/react';
|
||||
import { render, waitFor, screen, within, Matcher, getByRole, act, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { merge, uniqueId } from 'lodash';
|
||||
import { openMenu } from 'react-select-event';
|
||||
@@ -300,7 +300,9 @@ describe('CorrelationsPage', () => {
|
||||
await userEvent.click(screen.getByText('Regular expression'));
|
||||
await userEvent.type(screen.getByLabelText(/expression/i), 'test expression');
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: /add$/i }));
|
||||
// TODO investigate why we need act/fireEvent
|
||||
// see https://github.com/testing-library/react-testing-library/issues/1375
|
||||
await act(() => fireEvent.click(screen.getByRole('button', { name: /add$/i })));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.reportInteraction).toHaveBeenCalledWith('grafana_correlations_added');
|
||||
@@ -451,7 +453,9 @@ describe('CorrelationsPage', () => {
|
||||
await userEvent.clear(screen.getByRole('textbox', { name: /results field/i }));
|
||||
await userEvent.type(screen.getByRole('textbox', { name: /results field/i }), 'Line');
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /add$/i }));
|
||||
// TODO investigate why we need act/fireEvent
|
||||
// see https://github.com/testing-library/react-testing-library/issues/1375
|
||||
await act(() => fireEvent.click(screen.getByRole('button', { name: /add$/i })));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.reportInteraction).toHaveBeenCalledWith('grafana_correlations_added');
|
||||
@@ -518,7 +522,9 @@ describe('CorrelationsPage', () => {
|
||||
await userEvent.click(screen.getByRole('button', { name: /next$/i }));
|
||||
await userEvent.click(screen.getByRole('button', { name: /next$/i }));
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /save$/i }));
|
||||
// TODO investigate why we need act/fireEvent
|
||||
// see https://github.com/testing-library/react-testing-library/issues/1375
|
||||
await act(() => fireEvent.click(screen.getByRole('button', { name: /save$/i })));
|
||||
|
||||
expect(await screen.findByRole('cell', { name: /edited label$/i }, { timeout: 5000 })).toBeInTheDocument();
|
||||
|
||||
@@ -536,7 +542,9 @@ describe('CorrelationsPage', () => {
|
||||
const rowExpanderButton = within(tableRows[0]).getByRole('button', { name: /toggle row expanded/i });
|
||||
await userEvent.click(rowExpanderButton);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /next$/i }));
|
||||
// TODO investigate why we need act/fireEvent
|
||||
// see https://github.com/testing-library/react-testing-library/issues/1375
|
||||
await act(() => fireEvent.click(screen.getByRole('button', { name: /next$/i })));
|
||||
await userEvent.click(screen.getByRole('button', { name: /next$/i }));
|
||||
|
||||
// select Logfmt, be sure expression field is disabled
|
||||
@@ -575,7 +583,9 @@ describe('CorrelationsPage', () => {
|
||||
await userEvent.click(screen.getByRole('button', { name: /save$/i }));
|
||||
expect(screen.getByText('Please define an expression')).toBeInTheDocument();
|
||||
await userEvent.type(screen.getByLabelText(/expression/i), 'test expression');
|
||||
await userEvent.click(screen.getByRole('button', { name: /save$/i }));
|
||||
// TODO investigate why we need act/fireEvent
|
||||
// see https://github.com/testing-library/react-testing-library/issues/1375
|
||||
await act(() => fireEvent.click(screen.getByRole('button', { name: /save$/i })));
|
||||
await waitFor(() => {
|
||||
expect(mocks.reportInteraction).toHaveBeenCalledWith('grafana_correlations_edited');
|
||||
});
|
||||
@@ -726,7 +736,9 @@ describe('CorrelationsPage', () => {
|
||||
expect(descriptionInput).toBeInTheDocument();
|
||||
expect(descriptionInput).toHaveAttribute('readonly');
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /next$/i }));
|
||||
// TODO investigate why we need act/fireEvent
|
||||
// see https://github.com/testing-library/react-testing-library/issues/1375
|
||||
await act(() => fireEvent.click(screen.getByRole('button', { name: /next$/i })));
|
||||
await userEvent.click(screen.getByRole('button', { name: /next$/i }));
|
||||
|
||||
// expect the transformation to exist but be read only
|
||||
|
||||
@@ -58,7 +58,7 @@ jest.mock('app/features/plugins/extensions/getPluginExtensions', () => ({
|
||||
createPluginExtensionsGetter: () => getPluginExtensionsMock,
|
||||
}));
|
||||
|
||||
function setup({ routeProps }: { routeProps?: Partial<GrafanaRouteComponentProps> } = {}) {
|
||||
async function setup({ routeProps }: { routeProps?: Partial<GrafanaRouteComponentProps> } = {}) {
|
||||
const context = getGrafanaContextMock();
|
||||
const defaultRouteProps = getRouteComponentProps();
|
||||
const props: Props = {
|
||||
@@ -66,21 +66,29 @@ function setup({ routeProps }: { routeProps?: Partial<GrafanaRouteComponentProps
|
||||
...routeProps,
|
||||
};
|
||||
|
||||
const renderResult = render(
|
||||
<TestProvider grafanaContext={context}>
|
||||
<LocationServiceProvider service={locationService}>
|
||||
<DashboardScenePage {...props} />
|
||||
</LocationServiceProvider>
|
||||
</TestProvider>
|
||||
);
|
||||
|
||||
const rerender = (newProps: Props) => {
|
||||
renderResult.rerender(
|
||||
// react 19 changed how suspense rendering works
|
||||
// RTL hasn't caught up yet
|
||||
// see https://github.com/testing-library/react-testing-library/issues/1375
|
||||
// TODO remove this hack when RTL is updated. probably `render` itself will become async
|
||||
const renderResult = await act(async () =>
|
||||
render(
|
||||
<TestProvider grafanaContext={context}>
|
||||
<LocationServiceProvider service={locationService}>
|
||||
<DashboardScenePage {...newProps} />
|
||||
<DashboardScenePage {...props} />
|
||||
</LocationServiceProvider>
|
||||
</TestProvider>
|
||||
)
|
||||
);
|
||||
|
||||
const rerender = async (newProps: Props) => {
|
||||
await act(async () =>
|
||||
renderResult.rerender(
|
||||
<TestProvider grafanaContext={context}>
|
||||
<LocationServiceProvider service={locationService}>
|
||||
<DashboardScenePage {...newProps} />
|
||||
</LocationServiceProvider>
|
||||
</TestProvider>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -152,6 +160,8 @@ describe('DashboardScenePage', () => {
|
||||
beforeEach(() => {
|
||||
locationService.push('/d/my-dash-uid');
|
||||
getDashboardScenePageStateManager().clearDashboardCache();
|
||||
getDashboardScenePageStateManager().clearSceneCache();
|
||||
getDashboardScenePageStateManager().clearState();
|
||||
loadDashboardMock.mockClear();
|
||||
loadDashboardMock.mockResolvedValue({ dashboard: simpleDashboard, meta: { slug: '123' } });
|
||||
// hacky way because mocking autosizer does not work
|
||||
@@ -163,7 +173,7 @@ describe('DashboardScenePage', () => {
|
||||
});
|
||||
|
||||
it('Can render dashboard', async () => {
|
||||
setup();
|
||||
await setup();
|
||||
|
||||
await waitForDashboardToRender();
|
||||
|
||||
@@ -175,7 +185,7 @@ describe('DashboardScenePage', () => {
|
||||
});
|
||||
|
||||
it('routeReloadCounter should trigger reload', async () => {
|
||||
const { rerender, props } = setup();
|
||||
const { rerender, props } = await setup();
|
||||
|
||||
await waitForDashboardToRender();
|
||||
|
||||
@@ -190,13 +200,13 @@ describe('DashboardScenePage', () => {
|
||||
|
||||
props.location.state = { routeReloadCounter: 1 };
|
||||
|
||||
rerender(props);
|
||||
await rerender(props);
|
||||
|
||||
expect(await screen.findByTitle('Updated title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Can inspect panel', async () => {
|
||||
setup();
|
||||
await setup();
|
||||
|
||||
await waitForDashboardToRender();
|
||||
|
||||
@@ -218,7 +228,7 @@ describe('DashboardScenePage', () => {
|
||||
});
|
||||
|
||||
it('Can view panel in fullscreen', async () => {
|
||||
setup();
|
||||
await setup();
|
||||
|
||||
await waitForDashboardToRender();
|
||||
|
||||
@@ -238,7 +248,7 @@ describe('DashboardScenePage', () => {
|
||||
interval: {} as SystemDateFormatsState['interval'],
|
||||
useBrowserLocale: true,
|
||||
});
|
||||
setup();
|
||||
await setup();
|
||||
|
||||
await waitForDashboardToRenderWithTimeRange({
|
||||
from: '03/11/2025, 02:09:37 AM',
|
||||
@@ -257,7 +267,7 @@ describe('DashboardScenePage', () => {
|
||||
interval: {} as SystemDateFormatsState['interval'],
|
||||
useBrowserLocale: true,
|
||||
});
|
||||
setup();
|
||||
await setup();
|
||||
|
||||
await waitForDashboardToRenderWithTimeRange({
|
||||
from: '11.03.2025, 02:09:37',
|
||||
@@ -269,17 +279,17 @@ describe('DashboardScenePage', () => {
|
||||
describe('empty state', () => {
|
||||
it('Shows empty state when dashboard is empty', async () => {
|
||||
loadDashboardMock.mockResolvedValue({ dashboard: { uid: 'my-dash-uid', panels: [] }, meta: {} });
|
||||
setup();
|
||||
await setup();
|
||||
|
||||
expect(await screen.findByText('Start your new dashboard by adding a visualization')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows and hides empty state when panels are added and removed', async () => {
|
||||
setup();
|
||||
await setup();
|
||||
|
||||
await waitForDashboardToRender();
|
||||
|
||||
expect(await screen.queryByText('Start your new dashboard by adding a visualization')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Start your new dashboard by adding a visualization')).not.toBeInTheDocument();
|
||||
|
||||
// Hacking a bit, accessing private cache property to get access to the underlying DashboardScene object
|
||||
const dashboardScenesCache = getDashboardScenePageStateManager().getCache();
|
||||
@@ -289,7 +299,7 @@ describe('DashboardScenePage', () => {
|
||||
act(() => {
|
||||
dashboard.removePanel(panels[0]);
|
||||
});
|
||||
expect(await screen.queryByText('Start your new dashboard by adding a visualization')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Start your new dashboard by adding a visualization')).not.toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
dashboard.removePanel(panels[1]);
|
||||
@@ -301,14 +311,14 @@ describe('DashboardScenePage', () => {
|
||||
});
|
||||
|
||||
expect(await screen.findByTitle('Panel Added')).toBeInTheDocument();
|
||||
expect(await screen.queryByText('Start your new dashboard by adding a visualization')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Start your new dashboard by adding a visualization')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('home page', () => {
|
||||
it('should render the dashboard when the route is home', async () => {
|
||||
(useParams as jest.Mock).mockReturnValue({});
|
||||
setup({
|
||||
await setup({
|
||||
routeProps: {
|
||||
route: {
|
||||
...getRouteComponentProps().route,
|
||||
@@ -331,7 +341,7 @@ describe('DashboardScenePage', () => {
|
||||
loadDashboardMock.mockClear();
|
||||
loadDashboardMock.mockResolvedValue({ dashboard: { uid: 'my-dash-uid', panels: [] }, meta: {} });
|
||||
|
||||
setup();
|
||||
await setup();
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Refresh')).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.queryByText('Last 6 hours')).toBeInTheDocument());
|
||||
@@ -363,7 +373,7 @@ describe('DashboardScenePage', () => {
|
||||
isHandled: true,
|
||||
});
|
||||
|
||||
setup();
|
||||
await setup();
|
||||
|
||||
expect(await screen.findByTestId(selectors.components.EntityNotFound.container)).toBeInTheDocument();
|
||||
});
|
||||
@@ -387,7 +397,7 @@ describe('DashboardScenePage', () => {
|
||||
isHandled: true,
|
||||
});
|
||||
|
||||
setup();
|
||||
await setup();
|
||||
|
||||
expect(await screen.findByTestId('dashboard-page-error')).toBeInTheDocument();
|
||||
expect(await screen.findByTestId('dashboard-page-error')).toHaveTextContent('Internal server error');
|
||||
@@ -396,7 +406,7 @@ describe('DashboardScenePage', () => {
|
||||
it('should render error alert for runtime errors', async () => {
|
||||
setupLoadDashboardRuntimeErrorMock();
|
||||
|
||||
setup();
|
||||
await setup();
|
||||
|
||||
expect(await screen.findByTestId('dashboard-page-error')).toBeInTheDocument();
|
||||
expect(await screen.findByTestId('dashboard-page-error')).toHaveTextContent('Runtime error');
|
||||
@@ -411,7 +421,7 @@ describe('DashboardScenePage', () => {
|
||||
const manager = getDashboardScenePageStateManager();
|
||||
manager.setActiveManager('v2');
|
||||
|
||||
const { unmount } = setup();
|
||||
const { unmount } = await setup();
|
||||
|
||||
expect(manager['activeManager']).toBeInstanceOf(DashboardScenePageStateManagerV2);
|
||||
unmount();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { screen, waitForElementToBeRemoved } from '@testing-library/react';
|
||||
import { act, screen } from '@testing-library/react';
|
||||
import { Route, Routes } from 'react-router-dom-v5-compat';
|
||||
import { of } from 'rxjs';
|
||||
import { render } from 'test/test-utils';
|
||||
@@ -26,7 +26,7 @@ jest.mock('@grafana/runtime', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
function setup(token = 'an-access-token') {
|
||||
async function setup(token = 'an-access-token') {
|
||||
const pubdashProps: PublicDashboardSceneProps = {
|
||||
...getRouteComponentProps({
|
||||
route: {
|
||||
@@ -37,11 +37,19 @@ function setup(token = 'an-access-token') {
|
||||
}),
|
||||
};
|
||||
|
||||
return render(
|
||||
<Routes>
|
||||
<Route path="/public-dashboards/:accessToken" element={<PublicDashboardScenePage {...pubdashProps} />} />
|
||||
</Routes>,
|
||||
{ historyOptions: { initialEntries: [`/public-dashboards/${token}`] } }
|
||||
// TODO investigate why act is needed here
|
||||
// see https://github.com/testing-library/react-testing-library/issues/1375
|
||||
return await act(() =>
|
||||
render(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/public-dashboards/:accessToken"
|
||||
element={<PublicDashboardScenePage {...pubdashProps} />}
|
||||
key={token}
|
||||
/>
|
||||
</Routes>,
|
||||
{ historyOptions: { initialEntries: [`/public-dashboards/${token}`] } }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -115,7 +123,6 @@ const publicDashboardSceneSelector = e2eSelectors.pages.PublicDashboardScene;
|
||||
|
||||
describe('PublicDashboardScenePage', () => {
|
||||
beforeEach(() => {
|
||||
config.publicDashboardAccessToken = 'an-access-token';
|
||||
getDashboardScenePageStateManager().clearDashboardCache();
|
||||
setupLoadDashboardMock({ dashboard: simpleDashboard, meta: {} });
|
||||
|
||||
@@ -125,7 +132,9 @@ describe('PublicDashboardScenePage', () => {
|
||||
});
|
||||
|
||||
it('can render public dashboard', async () => {
|
||||
setup();
|
||||
const accessToken = 'an-access-token';
|
||||
config.publicDashboardAccessToken = accessToken;
|
||||
await setup(accessToken);
|
||||
|
||||
await waitForDashboardGridToRender();
|
||||
|
||||
@@ -139,7 +148,9 @@ describe('PublicDashboardScenePage', () => {
|
||||
});
|
||||
|
||||
it('cannot see menu panel', async () => {
|
||||
setup();
|
||||
const accessToken = 'cannot-see-menu-panel';
|
||||
config.publicDashboardAccessToken = accessToken;
|
||||
await setup(accessToken);
|
||||
|
||||
await waitForDashboardGridToRender();
|
||||
|
||||
@@ -148,7 +159,9 @@ describe('PublicDashboardScenePage', () => {
|
||||
});
|
||||
|
||||
it('shows time controls when it is not hidden', async () => {
|
||||
setup();
|
||||
const accessToken = 'shows-time-controls';
|
||||
config.publicDashboardAccessToken = accessToken;
|
||||
await setup(accessToken);
|
||||
|
||||
await waitForDashboardGridToRender();
|
||||
|
||||
@@ -158,7 +171,9 @@ describe('PublicDashboardScenePage', () => {
|
||||
});
|
||||
|
||||
it('does not render paused or deleted screen', async () => {
|
||||
setup();
|
||||
const accessToken = 'does-not-render-paused-or-deleted-screen';
|
||||
config.publicDashboardAccessToken = accessToken;
|
||||
await setup(accessToken);
|
||||
|
||||
await waitForDashboardGridToRender();
|
||||
|
||||
@@ -172,7 +187,7 @@ describe('PublicDashboardScenePage', () => {
|
||||
dashboard: { ...simpleDashboard, timepicker: { hidden: true } },
|
||||
meta: {},
|
||||
});
|
||||
setup(accessToken);
|
||||
await setup(accessToken);
|
||||
|
||||
await waitForDashboardGridToRender();
|
||||
|
||||
@@ -207,9 +222,7 @@ describe('given unavailable public dashboard', () => {
|
||||
},
|
||||
});
|
||||
|
||||
setup(accessToken);
|
||||
|
||||
await waitForElementToBeRemoved(screen.getByTestId(publicDashboardSceneSelector.loadingPage));
|
||||
await setup(accessToken);
|
||||
|
||||
expect(screen.queryByTestId(publicDashboardSceneSelector.page)).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId(publicDashboardSelector.NotAvailable.title)).toBeInTheDocument();
|
||||
@@ -239,9 +252,7 @@ describe('given unavailable public dashboard', () => {
|
||||
},
|
||||
});
|
||||
|
||||
setup(accessToken);
|
||||
|
||||
await waitForElementToBeRemoved(screen.getByTestId(publicDashboardSceneSelector.loadingPage));
|
||||
await setup(accessToken);
|
||||
|
||||
expect(screen.queryByTestId(publicDashboardSelector.page)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(publicDashboardSelector.NotAvailable.pausedDescription)).not.toBeInTheDocument();
|
||||
|
||||
@@ -49,7 +49,7 @@ export function SaveDashboardAsForm({ dashboard, changeInfo }: Props) {
|
||||
|
||||
const [contentSent, setContentSent] = useState<{ title?: string; folderUid?: string }>({});
|
||||
|
||||
const validationTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
const validationTimeoutRef = useRef<NodeJS.Timeout>(null);
|
||||
|
||||
// Validate title on form mount to catch invalid default values
|
||||
useEffect(() => {
|
||||
@@ -59,14 +59,18 @@ export function SaveDashboardAsForm({ dashboard, changeInfo }: Props) {
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimeout(validationTimeoutRef.current);
|
||||
if (validationTimeoutRef.current) {
|
||||
clearTimeout(validationTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleTitleChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
setValue('title', e.target.value, { shouldDirty: true });
|
||||
clearTimeout(validationTimeoutRef.current);
|
||||
if (validationTimeoutRef.current) {
|
||||
clearTimeout(validationTimeoutRef.current);
|
||||
}
|
||||
validationTimeoutRef.current = setTimeout(() => {
|
||||
trigger('title');
|
||||
}, 400);
|
||||
@@ -75,7 +79,9 @@ export function SaveDashboardAsForm({ dashboard, changeInfo }: Props) {
|
||||
);
|
||||
|
||||
const onSave = async (overwrite: boolean) => {
|
||||
clearTimeout(validationTimeoutRef.current);
|
||||
if (validationTimeoutRef.current) {
|
||||
clearTimeout(validationTimeoutRef.current);
|
||||
}
|
||||
|
||||
const isTitleValid = await trigger('title');
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { DashboardGridItem, RepeatDirection } from './DashboardGridItem';
|
||||
interface PanelWrapperProps {
|
||||
panel: VizPanel;
|
||||
isLazy: boolean;
|
||||
containerRef?: RefObject<HTMLDivElement>;
|
||||
containerRef?: RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
function PanelWrapper({ panel, isLazy, containerRef }: PanelWrapperProps) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { TabsLayoutManager } from '../layout-tabs/TabsLayoutManager';
|
||||
* Will scroll element into view. If element is not connected yet, it will try to expand rows
|
||||
* and switch tabs to make it visible.
|
||||
*/
|
||||
export function scrollCanvasElementIntoView(sceneObject: SceneObject, ref: React.RefObject<HTMLElement>) {
|
||||
export function scrollCanvasElementIntoView(sceneObject: SceneObject, ref: React.RefObject<HTMLElement | null>) {
|
||||
if (ref.current?.isConnected) {
|
||||
scrollIntoView(ref.current);
|
||||
return;
|
||||
|
||||
@@ -33,7 +33,7 @@ global.ResizeObserver = jest.fn().mockImplementation((callback) => {
|
||||
});
|
||||
|
||||
// Helper function to assign a mock div to a ref
|
||||
function assignMockDivToRef(ref: React.RefObject<HTMLDivElement>, mockDiv: HTMLDivElement) {
|
||||
function assignMockDivToRef(ref: React.RefObject<HTMLDivElement | null>, mockDiv: HTMLDivElement) {
|
||||
// Use type assertion to bypass readonly restriction in tests
|
||||
(ref as { current: HTMLDivElement | null }).current = mockDiv;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import grafanaTextLogoDarkSvg from 'img/grafana_text_logo_dark.svg';
|
||||
import grafanaTextLogoLightSvg from 'img/grafana_text_logo_light.svg';
|
||||
|
||||
interface SoloPanelPageLogoProps {
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
isHovered: boolean;
|
||||
hideLogo?: UrlQueryValue;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { screen, waitFor, within } from '@testing-library/react';
|
||||
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render } from 'test/test-utils';
|
||||
|
||||
@@ -154,7 +154,9 @@ describe('VersionSettings', () => {
|
||||
expect(within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row').length).toBe(VERSIONS_FETCH_LIMIT);
|
||||
|
||||
const showMoreButton = screen.getByRole('button', { name: /show more versions/i });
|
||||
await user.click(showMoreButton);
|
||||
// TODO investigate why we need act/fireEvent
|
||||
// see https://github.com/testing-library/react-testing-library/issues/1375
|
||||
act(() => fireEvent.click(showMoreButton));
|
||||
|
||||
expect(historySrv.getHistoryList).toBeCalledTimes(2);
|
||||
expect(screen.getByText(/Fetching more entries/i)).toBeInTheDocument();
|
||||
|
||||
@@ -21,7 +21,7 @@ export const usePanelLatestData = (
|
||||
options: GetDataOptions,
|
||||
checkSchema?: boolean
|
||||
): UsePanelLatestData => {
|
||||
const querySubscription = useRef<Unsubscribable>();
|
||||
const querySubscription = useRef<Unsubscribable>(null);
|
||||
const [latestData, setLatestData] = useState<PanelData>();
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -61,7 +61,7 @@ interface State {
|
||||
|
||||
class UnThemedTransformationsEditor extends React.PureComponent<TransformationsEditorProps, State> {
|
||||
subscription?: Unsubscribable;
|
||||
ref: RefObject<HTMLDivElement>;
|
||||
ref: RefObject<HTMLDivElement | null>;
|
||||
|
||||
constructor(props: TransformationsEditorProps) {
|
||||
super(props);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { act, screen, waitFor } from '@testing-library/react';
|
||||
import { Routes, Route } from 'react-router-dom-v5-compat';
|
||||
import { render } from 'test/test-utils';
|
||||
|
||||
@@ -62,7 +62,9 @@ describe('PublicDashboardPageProxy', () => {
|
||||
describe('when scene feature enabled', () => {
|
||||
it('should render PublicDashboardScenePage if publicDashboardsScene is enabled', async () => {
|
||||
config.featureToggles.publicDashboardsScene = true;
|
||||
setup({});
|
||||
// TODO investigate why we need act
|
||||
// see https://github.com/testing-library/react-testing-library/issues/1375
|
||||
await act(() => setup({}));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId(PublicDashboardScene.page)).toBeInTheDocument();
|
||||
|
||||
@@ -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()}`;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -64,7 +64,7 @@ export function useDatasource(dataSource: string | DataSourceRef | DataSourceIns
|
||||
|
||||
export interface KeybaordNavigatableListProps {
|
||||
keyboardEvents?: Observable<React.KeyboardEvent>;
|
||||
containerRef: React.RefObject<HTMLElement>;
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,7 +25,7 @@ interface State {
|
||||
}
|
||||
|
||||
export class ThresholdsEditor extends PureComponent<Props, State> {
|
||||
private latestThresholdInputRef: React.RefObject<HTMLInputElement>;
|
||||
private latestThresholdInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
@@ -46,6 +46,9 @@ export function ContentOutline({ scroller, panelId }: { scroller: HTMLElement |
|
||||
);
|
||||
const styles = useStyles2(getStyles, contentOutlineExpanded);
|
||||
const scrollerRef = useRef(scroller || null);
|
||||
// TODO remove when react-use is fixed
|
||||
// see https://github.com/streamich/react-use/issues/2612
|
||||
// @ts-expect-error
|
||||
const { y: verticalScroll } = useScroll(scrollerRef);
|
||||
const { outlineItems } = useContentOutlineContext() ?? { outlineItems: [] };
|
||||
const [activeSectionId, setActiveSectionId] = useState(outlineItems[0]?.id);
|
||||
|
||||
@@ -74,7 +74,7 @@ export const ExplorePaneContainer = connector(ExplorePaneContainerUnconnected);
|
||||
|
||||
function useStopQueries(exploreId: string) {
|
||||
const paneSelector = useMemo(() => getExploreItemSelector(exploreId), [exploreId]);
|
||||
const paneRef = useRef<ReturnType<typeof paneSelector>>();
|
||||
const paneRef = useRef<ReturnType<typeof paneSelector>>(null);
|
||||
paneRef.current = useSelector(paneSelector);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -44,7 +44,7 @@ export type QueryLibraryContextType = {
|
||||
onUpdateSuccess?: () => void,
|
||||
onSelectQuery?: (query: DataQuery) => void,
|
||||
datasourceFilters?: string[],
|
||||
parentRef?: React.RefObject<HTMLDivElement>
|
||||
parentRef?: React.RefObject<HTMLDivElement | null>
|
||||
) => ReactNode;
|
||||
|
||||
/**
|
||||
|
||||
@@ -63,7 +63,7 @@ type Props = {
|
||||
scrollElementClass?: string;
|
||||
traceProp: Trace;
|
||||
datasource: DataSourceApi<DataQuery, DataSourceJsonData, {}> | undefined;
|
||||
topOfViewRef?: RefObject<HTMLDivElement>;
|
||||
topOfViewRef?: RefObject<HTMLDivElement | null>;
|
||||
createSpanLink?: SpanLinkFunc;
|
||||
focusedSpanId?: string;
|
||||
createFocusSpanLink?: (traceId: string, spanId: string) => LinkModel<Field>;
|
||||
|
||||
@@ -27,7 +27,8 @@ const timeRange = {
|
||||
to: new Date(1000),
|
||||
} as unknown as TimeRange;
|
||||
|
||||
function getContent(result: React.ReactElement) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function getContent(result: React.ReactElement<any>) {
|
||||
return result.props.children.props.children[0];
|
||||
}
|
||||
|
||||
|
||||
@@ -561,7 +561,7 @@ const CardsContainer = ({
|
||||
mainContainerRef,
|
||||
}: {
|
||||
listOfContentCards: React.ReactNode[];
|
||||
mainContainerRef?: React.RefObject<HTMLDivElement>;
|
||||
mainContainerRef?: React.RefObject<HTMLDivElement | null>;
|
||||
}) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user