Compare commits
77 Commits
bugfix/pro
...
ash/react-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc7577f940 | ||
|
|
21ea3f2c2e | ||
|
|
59d3ef2504 | ||
|
|
ff7bab5753 | ||
|
|
9ba65ab73f | ||
|
|
fccece3ca0 | ||
|
|
d44cab9eaf | ||
|
|
3d3b4dd213 | ||
|
|
2947d41ea8 | ||
|
|
0acb030f46 | ||
|
|
658a1c8228 | ||
|
|
618316a2f7 | ||
|
|
a9c2117aa7 | ||
|
|
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
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
---
|
||||
aliases:
|
||||
- ../data-sources/aws-CloudWatch/
|
||||
- ../data-sources/aws-CloudWatch/preconfig-CloudWatch-dashboards/
|
||||
- ../data-sources/aws-CloudWatch/provision-CloudWatch/
|
||||
- CloudWatch/
|
||||
- preconfig-CloudWatch-dashboards/
|
||||
- provision-CloudWatch/
|
||||
- ../../data-sources/aws-cloudwatch/configure/
|
||||
- ../../data-sources/aws-cloudwatch/
|
||||
- ../../data-sources/aws-cloudwatch/preconfig-cloudwatch-dashboards/
|
||||
- ../../data-sources/aws-cloudwatch/provision-cloudwatch/
|
||||
- ../cloudwatch/
|
||||
- ../preconfig-cloudwatch-dashboards/
|
||||
- ../provision-cloudwatch/
|
||||
description: This document provides configuration instructions for the CloudWatch data source.
|
||||
keywords:
|
||||
- grafana
|
||||
@@ -25,11 +26,6 @@ refs:
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/visualizations/logs/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/visualizations/logs/
|
||||
explore:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/explore/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/explore/
|
||||
provisioning-data-sources:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/provisioning/#data-sources
|
||||
@@ -40,16 +36,6 @@ refs:
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/#aws
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/#aws
|
||||
alerting:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/alerting/
|
||||
build-dashboards:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/build-dashboards/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/build-dashboards/
|
||||
data-source-management:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/data-source-management/
|
||||
@@ -153,7 +139,7 @@ You must use both an access key ID and a secret access key to authenticate.
|
||||
|
||||
Grafana automatically creates a link to a trace in X-Ray data source if logs contain the `@xrayTraceId` field. To use this feature, you must already have an X-Ray data source configured. For details, see the [X-Ray data source docs](/grafana/plugins/grafana-X-Ray-datasource/). To view the X-Ray link, select the log row in either the Explore view or dashboard [Logs panel](ref:logs) to view the log details section.
|
||||
|
||||
To log the `@xrayTraceId`, refer to the [AWS X-Ray documentation](https://docs.amazonaws.cn/en_us/xray/latest/devguide/xray-services.html). To provide the field to Grafana, your log queries must also contain the `@xrayTraceId` field, for example by using the query `fields @message, @xrayTraceId`.
|
||||
To log the `@xrayTraceId`, refer to the [AWS X-Ray documentation](https://docs.aws.amazon.com/xray/latest/devguide/xray-services.html). To provide the field to Grafana, your log queries must also contain the `@xrayTraceId` field, for example by using the query `fields @message, @xrayTraceId`.
|
||||
|
||||
**Private data source connect** - _Only for Grafana Cloud users._
|
||||
|
||||
|
||||
@@ -34,11 +34,6 @@ refs:
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/query-transform-data/#navigate-the-query-tab
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/query-transform-data/#navigate-the-query-tab
|
||||
explore:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/explore/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/explore/
|
||||
alerting:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/
|
||||
@@ -183,7 +178,7 @@ If you use the expression field to reference another query, such as `queryA * 2`
|
||||
When you select `Builder` mode within the Metric search editor, a new Account field is displayed. Use the `Account` field to specify which of the linked monitoring accounts to target for the given query. By default, the `All` option is specified, which will target all linked accounts.
|
||||
|
||||
While in `Code` mode, you can specify any math expression. If the Monitoring account badge displays in the query editor header, all `SEARCH` expressions entered in this field will be cross-account by default and can query metrics from linked accounts. Note that while queries run cross-account, the autocomplete feature currently doesn't fetch cross-account resources, so you'll need to manually specify resource names when writing cross-account queries.
|
||||
You can limit the search to one or a set of accounts, as documented in the [AWS documentation](http://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Unified-Cross-Account.html).
|
||||
You can limit the search to one or a set of accounts, as documented in the [AWS documentation](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Unified-Cross-Account.html).
|
||||
|
||||
### Period macro
|
||||
|
||||
@@ -198,7 +193,7 @@ The link provided is valid for any account but displays the expected metrics onl
|
||||
|
||||
{{< figure src="/media/docs/cloudwatch/cloudwatch-deep-link-v12.1.png" caption="CloudWatch deep linking" >}}
|
||||
|
||||
This feature is not available for metrics based on [metric math expressions](#metric-math-expressions).
|
||||
This feature is not available for metrics based on [metric math expressions](#use-metric-math-expressions).
|
||||
|
||||
### Use Metric Insights syntax
|
||||
|
||||
@@ -319,9 +314,9 @@ The CloudWatch plugin monitors and troubleshoots applications that span multiple
|
||||
|
||||
To enable cross-account observability, complete the following steps:
|
||||
|
||||
1. Go to the [Amazon CloudWatch documentation](http://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Unified-Cross-Account.html) and follow the instructions for enabling cross-account observability.
|
||||
1. Go to the [Amazon CloudWatch documentation](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Unified-Cross-Account.html) and follow the instructions for enabling cross-account observability.
|
||||
|
||||
1. Add [two API actions](https://grafana.com//docs/grafana/latest/datasources/aws-cloudwatch/configure/#cross-account-observability-permissions) to the IAM policy attached to the role/user running the plugin.
|
||||
1. Add [two API actions](https://grafana.com/docs/grafana/latest/datasources/aws-cloudwatch/configure/#cross-account-observability-permissions) to the IAM policy attached to the role/user running the plugin.
|
||||
|
||||
Cross-account querying is available in the plugin through the **Logs**, **Metric search**, and **Metric Insights** modes.
|
||||
After you have configured it, you'll see a **Monitoring account** badge in the query editor header.
|
||||
|
||||
@@ -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(
|
||||
|
||||
16
package.json
16
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",
|
||||
@@ -347,6 +347,7 @@
|
||||
"date-fns": "4.1.0",
|
||||
"debounce-promise": "3.1.2",
|
||||
"diff": "^8.0.0",
|
||||
"downsample": "1.4.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-json-patch": "3.1.1",
|
||||
"file-saver": "2.0.5",
|
||||
@@ -388,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",
|
||||
@@ -461,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",
|
||||
|
||||
@@ -42,5 +42,6 @@ export enum DataTransformerID {
|
||||
formatTime = 'formatTime',
|
||||
formatString = 'formatString',
|
||||
regression = 'regression',
|
||||
smoothing = 'smoothing',
|
||||
groupToNestedTable = 'groupToNestedTable',
|
||||
}
|
||||
|
||||
@@ -1255,4 +1255,8 @@ export interface FeatureToggles {
|
||||
* Enables support for variables whose values can have multiple properties
|
||||
*/
|
||||
multiPropsVariables?: boolean;
|
||||
/**
|
||||
* Enables the ASAP smoothing transformation for time series data
|
||||
*/
|
||||
smoothingTransformation?: boolean;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -22,13 +21,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
"github.com/open-feature/go-sdk/openfeature"
|
||||
)
|
||||
|
||||
var openfeatureClient = openfeature.NewDefaultClient()
|
||||
|
||||
const (
|
||||
pluginPageFeatureFlagPrefix = "plugin-page-visible."
|
||||
)
|
||||
|
||||
type AuthOptions struct {
|
||||
@@ -154,12 +146,6 @@ func RoleAppPluginAuth(accessControl ac.AccessControl, ps pluginstore.Store, log
|
||||
return
|
||||
}
|
||||
|
||||
if !PageIsFeatureToggleEnabled(c.Req.Context(), c.Req.URL.Path) {
|
||||
logger.Debug("Forbidden experimental plugin page", "plugin", pluginID, "path", c.Req.URL.Path)
|
||||
accessForbidden(c)
|
||||
return
|
||||
}
|
||||
|
||||
permitted := true
|
||||
path := normalizeIncludePath(c.Req.URL.Path)
|
||||
hasAccess := ac.HasAccess(accessControl, c)
|
||||
@@ -308,18 +294,3 @@ func shouldForceLogin(c *contextmodel.ReqContext) bool {
|
||||
|
||||
return forceLogin
|
||||
}
|
||||
|
||||
// PageIsFeatureToggleEnabled checks if a page is enabled via OpenFeature feature flags.
|
||||
// It returns false if the feature flag is set and set to false.
|
||||
// The feature flag key format is: "plugin-page-visible.<path>"
|
||||
func PageIsFeatureToggleEnabled(ctx context.Context, path string) bool {
|
||||
flagKey := pluginPageFeatureFlagPrefix + filepath.Clean(path)
|
||||
enabled := openfeatureClient.Boolean(
|
||||
ctx,
|
||||
flagKey,
|
||||
true,
|
||||
openfeature.TransactionContext(ctx),
|
||||
)
|
||||
|
||||
return enabled
|
||||
}
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/open-feature/go-sdk/openfeature"
|
||||
"github.com/open-feature/go-sdk/openfeature/memprovider"
|
||||
oftesting "github.com/open-feature/go-sdk/openfeature/testing"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -33,8 +28,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
)
|
||||
|
||||
var openfeatureTestMutex sync.Mutex
|
||||
|
||||
func setupAuthMiddlewareTest(t *testing.T, identity *authn.Identity, authErr error) *contexthandler.ContextHandler {
|
||||
return contexthandler.ProvideService(setting.NewCfg(), &authntest.FakeService{
|
||||
ExpectedErr: authErr,
|
||||
@@ -429,60 +422,6 @@ func TestCanAdminPlugin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageIsFeatureToggleEnabled(t *testing.T) {
|
||||
type testCase struct {
|
||||
desc string
|
||||
path string
|
||||
flags map[string]bool
|
||||
expectedResult bool
|
||||
}
|
||||
|
||||
tests := []testCase{
|
||||
{
|
||||
desc: "returns true when feature flag is enabled",
|
||||
path: "/a/my-plugin/settings",
|
||||
flags: map[string]bool{
|
||||
pluginPageFeatureFlagPrefix + "/a/my-plugin/settings": true,
|
||||
},
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
desc: "returns false when feature flag is disabled",
|
||||
path: "/a/my-plugin/settings",
|
||||
flags: map[string]bool{
|
||||
pluginPageFeatureFlagPrefix + "/a/my-plugin/settings": false,
|
||||
},
|
||||
expectedResult: false,
|
||||
},
|
||||
{
|
||||
desc: "returns false when feature flag is disabled with trailing slash",
|
||||
path: "/a/my-plugin/settings/",
|
||||
flags: map[string]bool{
|
||||
pluginPageFeatureFlagPrefix + "/a/my-plugin/settings": false,
|
||||
},
|
||||
expectedResult: false,
|
||||
},
|
||||
{
|
||||
desc: "returns true when feature flag does not exist",
|
||||
path: "/a/my-plugin/settings",
|
||||
flags: map[string]bool{},
|
||||
expectedResult: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
setupTestProvider(t, tt.flags)
|
||||
|
||||
result := PageIsFeatureToggleEnabled(ctx, tt.path)
|
||||
|
||||
assert.Equal(t, tt.expectedResult, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func contextProvider(modifiers ...func(c *contextmodel.ReqContext)) web.Handler {
|
||||
return func(c *web.Context) {
|
||||
reqCtx := &contextmodel.ReqContext{
|
||||
@@ -498,38 +437,3 @@ func contextProvider(modifiers ...func(c *contextmodel.ReqContext)) web.Handler
|
||||
c.Req = c.Req.WithContext(ctxkey.Set(c.Req.Context(), reqCtx))
|
||||
}
|
||||
}
|
||||
|
||||
// setupTestProvider creates a test OpenFeature provider with the given flags.
|
||||
// Uses a global lock to prevent concurrent provider changes across tests.
|
||||
func setupTestProvider(t *testing.T, flags map[string]bool) oftesting.TestProvider {
|
||||
t.Helper()
|
||||
|
||||
// Lock to prevent concurrent provider changes
|
||||
openfeatureTestMutex.Lock()
|
||||
|
||||
testProvider := oftesting.NewTestProvider()
|
||||
flagsMap := map[string]memprovider.InMemoryFlag{}
|
||||
|
||||
for key, value := range flags {
|
||||
flagsMap[key] = memprovider.InMemoryFlag{
|
||||
DefaultVariant: "defaultVariant",
|
||||
Variants: map[string]any{
|
||||
"defaultVariant": value,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
testProvider.UsingFlags(t, flagsMap)
|
||||
|
||||
err := openfeature.SetProviderAndWait(testProvider)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() {
|
||||
testProvider.Cleanup()
|
||||
_ = openfeature.SetProviderAndWait(openfeature.NoopProvider{})
|
||||
// Unlock after cleanup to allow other tests to run
|
||||
openfeatureTestMutex.Unlock()
|
||||
})
|
||||
|
||||
return testProvider
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
package acimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
)
|
||||
|
||||
const (
|
||||
ossBasicRoleSeedLockName = "oss-ac-basic-role-seeder"
|
||||
ossBasicRoleSeedTimeout = 2 * time.Minute
|
||||
)
|
||||
|
||||
// refreshBasicRolePermissionsInDB ensures basic role permissions are fully derived from in-memory registrations
|
||||
func (s *Service) refreshBasicRolePermissionsInDB(ctx context.Context, rolesSnapshot map[string][]accesscontrol.Permission) error {
|
||||
if s.sql == nil || s.seeder == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
run := func(ctx context.Context) error {
|
||||
desired := map[accesscontrol.SeedPermission]struct{}{}
|
||||
for role, permissions := range rolesSnapshot {
|
||||
for _, permission := range permissions {
|
||||
desired[accesscontrol.SeedPermission{BuiltInRole: role, Action: permission.Action, Scope: permission.Scope}] = struct{}{}
|
||||
}
|
||||
}
|
||||
s.seeder.SetDesiredPermissions(desired)
|
||||
return s.seeder.Seed(ctx)
|
||||
}
|
||||
|
||||
if s.serverLock == nil {
|
||||
return run(ctx)
|
||||
}
|
||||
|
||||
var err error
|
||||
errLock := s.serverLock.LockExecuteAndRelease(ctx, ossBasicRoleSeedLockName, ossBasicRoleSeedTimeout, func(ctx context.Context) {
|
||||
err = run(ctx)
|
||||
})
|
||||
if errLock != nil {
|
||||
return errLock
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
package acimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/localcache"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/database"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/permreg"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/util/testutil"
|
||||
)
|
||||
|
||||
func TestIntegration_OSSBasicRolePermissions_PersistAndRefreshOnRegisterFixedRoles(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
ctx := context.Background()
|
||||
sql := db.InitTestDB(t)
|
||||
store := database.ProvideService(sql)
|
||||
|
||||
svc := ProvideOSSService(
|
||||
setting.NewCfg(),
|
||||
store,
|
||||
&resourcepermissions.FakeActionSetSvc{},
|
||||
localcache.ProvideService(),
|
||||
featuremgmt.WithFeatures(),
|
||||
tracing.InitializeTracerForTest(),
|
||||
sql,
|
||||
permreg.ProvidePermissionRegistry(),
|
||||
nil,
|
||||
)
|
||||
|
||||
require.NoError(t, svc.DeclareFixedRoles(accesscontrol.RoleRegistration{
|
||||
Role: accesscontrol.RoleDTO{
|
||||
Name: "fixed:test:role",
|
||||
Permissions: []accesscontrol.Permission{
|
||||
{Action: "test:read", Scope: ""},
|
||||
},
|
||||
},
|
||||
Grants: []string{string(org.RoleViewer)},
|
||||
}))
|
||||
|
||||
require.NoError(t, svc.RegisterFixedRoles(ctx))
|
||||
|
||||
// verify permission is persisted to DB for basic:viewer
|
||||
require.NoError(t, sql.WithDbSession(ctx, func(sess *db.Session) error {
|
||||
var role accesscontrol.Role
|
||||
ok, err := sess.Table("role").Where("uid = ?", accesscontrol.BasicRoleUIDPrefix+"viewer").Get(&role)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
|
||||
var count int64
|
||||
count, err = sess.Table("permission").Where("role_id = ? AND action = ? AND scope = ?", role.ID, "test:read", "").Count()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), count)
|
||||
return nil
|
||||
}))
|
||||
|
||||
// ensure RegisterFixedRoles refreshes it back to defaults
|
||||
require.NoError(t, sql.WithDbSession(ctx, func(sess *db.Session) error {
|
||||
ts := time.Now()
|
||||
var role accesscontrol.Role
|
||||
ok, err := sess.Table("role").Where("uid = ?", accesscontrol.BasicRoleUIDPrefix+"viewer").Get(&role)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
|
||||
_, err = sess.Exec("DELETE FROM permission WHERE role_id = ?", role.ID)
|
||||
require.NoError(t, err)
|
||||
p := accesscontrol.Permission{
|
||||
RoleID: role.ID,
|
||||
Action: "custom:keep",
|
||||
Scope: "",
|
||||
Created: ts,
|
||||
Updated: ts,
|
||||
}
|
||||
p.Kind, p.Attribute, p.Identifier = accesscontrol.SplitScope(p.Scope)
|
||||
_, err = sess.Table("permission").Insert(&p)
|
||||
return err
|
||||
}))
|
||||
|
||||
svc2 := ProvideOSSService(
|
||||
setting.NewCfg(),
|
||||
store,
|
||||
&resourcepermissions.FakeActionSetSvc{},
|
||||
localcache.ProvideService(),
|
||||
featuremgmt.WithFeatures(),
|
||||
tracing.InitializeTracerForTest(),
|
||||
sql,
|
||||
permreg.ProvidePermissionRegistry(),
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, svc2.DeclareFixedRoles(accesscontrol.RoleRegistration{
|
||||
Role: accesscontrol.RoleDTO{
|
||||
Name: "fixed:test:role",
|
||||
Permissions: []accesscontrol.Permission{
|
||||
{Action: "test:read", Scope: ""},
|
||||
},
|
||||
},
|
||||
Grants: []string{string(org.RoleViewer)},
|
||||
}))
|
||||
require.NoError(t, svc2.RegisterFixedRoles(ctx))
|
||||
|
||||
require.NoError(t, sql.WithDbSession(ctx, func(sess *db.Session) error {
|
||||
var role accesscontrol.Role
|
||||
ok, err := sess.Table("role").Where("uid = ?", accesscontrol.BasicRoleUIDPrefix+"viewer").Get(&role)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
|
||||
var count int64
|
||||
count, err = sess.Table("permission").Where("role_id = ? AND action = ? AND scope = ?", role.ID, "test:read", "").Count()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), count)
|
||||
|
||||
count, err = sess.Table("permission").Where("role_id = ? AND action = ?", role.ID, "custom:keep").Count()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(0), count)
|
||||
return nil
|
||||
}))
|
||||
}
|
||||
@@ -30,7 +30,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/migrator"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/permreg"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/pluginutils"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/seeding"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
@@ -97,12 +96,6 @@ func ProvideOSSService(
|
||||
roles: accesscontrol.BuildBasicRoleDefinitions(),
|
||||
store: store,
|
||||
permRegistry: permRegistry,
|
||||
sql: db,
|
||||
serverLock: lock,
|
||||
}
|
||||
|
||||
if backend, ok := store.(*database.AccessControlStore); ok {
|
||||
s.seeder = seeding.New(log.New("accesscontrol.seeder"), backend, backend)
|
||||
}
|
||||
|
||||
return s
|
||||
@@ -119,11 +112,8 @@ type Service struct {
|
||||
rolesMu sync.RWMutex
|
||||
roles map[string]*accesscontrol.RoleDTO
|
||||
store accesscontrol.Store
|
||||
seeder *seeding.Seeder
|
||||
permRegistry permreg.PermissionRegistry
|
||||
isInitialized bool
|
||||
sql db.DB
|
||||
serverLock *serverlock.ServerLockService
|
||||
}
|
||||
|
||||
func (s *Service) GetUsageStats(_ context.Context) map[string]any {
|
||||
@@ -441,54 +431,17 @@ func (s *Service) RegisterFixedRoles(ctx context.Context) error {
|
||||
defer span.End()
|
||||
|
||||
s.rolesMu.Lock()
|
||||
registrations := s.registrations.Slice()
|
||||
defer s.rolesMu.Unlock()
|
||||
|
||||
s.registrations.Range(func(registration accesscontrol.RoleRegistration) bool {
|
||||
s.registerRolesLocked(registration)
|
||||
return true
|
||||
})
|
||||
|
||||
s.isInitialized = true
|
||||
|
||||
rolesSnapshot := s.getBasicRolePermissionsLocked()
|
||||
s.rolesMu.Unlock()
|
||||
|
||||
if s.seeder != nil {
|
||||
if err := s.seeder.SeedRoles(ctx, registrations); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.seeder.RemoveAbsentRoles(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.refreshBasicRolePermissionsInDB(ctx, rolesSnapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getBasicRolePermissionsSnapshotFromRegistrationsLocked computes the desired basic role permissions from the
|
||||
// current registration list, using the shared seeding registration logic.
|
||||
//
|
||||
// it has to be called while holding the roles lock
|
||||
func (s *Service) getBasicRolePermissionsLocked() map[string][]accesscontrol.Permission {
|
||||
desired := map[accesscontrol.SeedPermission]struct{}{}
|
||||
s.registrations.Range(func(registration accesscontrol.RoleRegistration) bool {
|
||||
seeding.AppendDesiredPermissions(desired, s.log, ®istration.Role, registration.Grants, registration.Exclude, true)
|
||||
return true
|
||||
})
|
||||
|
||||
out := make(map[string][]accesscontrol.Permission)
|
||||
for sp := range desired {
|
||||
out[sp.BuiltInRole] = append(out[sp.BuiltInRole], accesscontrol.Permission{
|
||||
Action: sp.Action,
|
||||
Scope: sp.Scope,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// registerRolesLocked processes a single role registration and adds permissions to basic roles.
|
||||
// Must be called with s.rolesMu locked.
|
||||
func (s *Service) registerRolesLocked(registration accesscontrol.RoleRegistration) {
|
||||
@@ -521,7 +474,6 @@ func (s *Service) DeclarePluginRoles(ctx context.Context, ID, name string, regs
|
||||
defer span.End()
|
||||
|
||||
acRegs := pluginutils.ToRegistrations(ID, name, regs)
|
||||
updatedBasicRoles := false
|
||||
for _, r := range acRegs {
|
||||
if err := pluginutils.ValidatePluginRole(ID, r.Role); err != nil {
|
||||
return err
|
||||
@@ -548,23 +500,11 @@ func (s *Service) DeclarePluginRoles(ctx context.Context, ID, name string, regs
|
||||
if initialized {
|
||||
s.rolesMu.Lock()
|
||||
s.registerRolesLocked(r)
|
||||
updatedBasicRoles = true
|
||||
s.rolesMu.Unlock()
|
||||
s.cache.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
if updatedBasicRoles {
|
||||
s.rolesMu.RLock()
|
||||
rolesSnapshot := s.getBasicRolePermissionsLocked()
|
||||
s.rolesMu.RUnlock()
|
||||
|
||||
// plugin roles can be declared after startup - keep DB in sync
|
||||
if err := s.refreshBasicRolePermissionsInDB(ctx, rolesSnapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,623 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/seeding"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||
"github.com/grafana/grafana/pkg/util/xorm/core"
|
||||
)
|
||||
|
||||
const basicRolePermBatchSize = 500
|
||||
|
||||
// LoadRoles returns all fixed and plugin roles (global org) with permissions, indexed by role name.
|
||||
func (s *AccessControlStore) LoadRoles(ctx context.Context) (map[string]*accesscontrol.RoleDTO, error) {
|
||||
out := map[string]*accesscontrol.RoleDTO{}
|
||||
|
||||
err := s.sql.WithDbSession(ctx, func(sess *db.Session) error {
|
||||
type roleRow struct {
|
||||
ID int64 `xorm:"id"`
|
||||
OrgID int64 `xorm:"org_id"`
|
||||
Version int64 `xorm:"version"`
|
||||
UID string `xorm:"uid"`
|
||||
Name string `xorm:"name"`
|
||||
DisplayName string `xorm:"display_name"`
|
||||
Description string `xorm:"description"`
|
||||
Group string `xorm:"group_name"`
|
||||
Hidden bool `xorm:"hidden"`
|
||||
Updated time.Time `xorm:"updated"`
|
||||
Created time.Time `xorm:"created"`
|
||||
}
|
||||
|
||||
roles := []roleRow{}
|
||||
if err := sess.Table("role").
|
||||
Where("org_id = ?", accesscontrol.GlobalOrgID).
|
||||
Where("(name LIKE ? OR name LIKE ?)", accesscontrol.FixedRolePrefix+"%", accesscontrol.PluginRolePrefix+"%").
|
||||
Find(&roles); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(roles) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
roleIDs := make([]any, 0, len(roles))
|
||||
roleByID := make(map[int64]*accesscontrol.RoleDTO, len(roles))
|
||||
for _, r := range roles {
|
||||
dto := &accesscontrol.RoleDTO{
|
||||
ID: r.ID,
|
||||
OrgID: r.OrgID,
|
||||
Version: r.Version,
|
||||
UID: r.UID,
|
||||
Name: r.Name,
|
||||
DisplayName: r.DisplayName,
|
||||
Description: r.Description,
|
||||
Group: r.Group,
|
||||
Hidden: r.Hidden,
|
||||
Updated: r.Updated,
|
||||
Created: r.Created,
|
||||
}
|
||||
out[dto.Name] = dto
|
||||
roleByID[dto.ID] = dto
|
||||
roleIDs = append(roleIDs, dto.ID)
|
||||
}
|
||||
|
||||
type permRow struct {
|
||||
RoleID int64 `xorm:"role_id"`
|
||||
Action string `xorm:"action"`
|
||||
Scope string `xorm:"scope"`
|
||||
}
|
||||
perms := []permRow{}
|
||||
if err := sess.Table("permission").In("role_id", roleIDs...).Find(&perms); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, p := range perms {
|
||||
dto := roleByID[p.RoleID]
|
||||
if dto == nil {
|
||||
continue
|
||||
}
|
||||
dto.Permissions = append(dto.Permissions, accesscontrol.Permission{
|
||||
RoleID: p.RoleID,
|
||||
Action: p.Action,
|
||||
Scope: p.Scope,
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *AccessControlStore) SetRole(ctx context.Context, existingRole *accesscontrol.RoleDTO, wantedRole accesscontrol.RoleDTO) error {
|
||||
if existingRole == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return s.sql.WithDbSession(ctx, func(sess *db.Session) error {
|
||||
_, err := sess.Table("role").
|
||||
Where("id = ? AND org_id = ?", existingRole.ID, accesscontrol.GlobalOrgID).
|
||||
Update(map[string]any{
|
||||
"display_name": wantedRole.DisplayName,
|
||||
"description": wantedRole.Description,
|
||||
"group_name": wantedRole.Group,
|
||||
"hidden": wantedRole.Hidden,
|
||||
"updated": time.Now(),
|
||||
})
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (s *AccessControlStore) SetPermissions(ctx context.Context, existingRole *accesscontrol.RoleDTO, wantedRole accesscontrol.RoleDTO) error {
|
||||
if existingRole == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
type key struct{ Action, Scope string }
|
||||
existing := map[key]struct{}{}
|
||||
for _, p := range existingRole.Permissions {
|
||||
existing[key{p.Action, p.Scope}] = struct{}{}
|
||||
}
|
||||
desired := map[key]struct{}{}
|
||||
for _, p := range wantedRole.Permissions {
|
||||
desired[key{p.Action, p.Scope}] = struct{}{}
|
||||
}
|
||||
|
||||
toAdd := make([]accesscontrol.Permission, 0)
|
||||
toRemove := make([]accesscontrol.SeedPermission, 0)
|
||||
|
||||
now := time.Now()
|
||||
for k := range desired {
|
||||
if _, ok := existing[k]; ok {
|
||||
continue
|
||||
}
|
||||
perm := accesscontrol.Permission{
|
||||
RoleID: existingRole.ID,
|
||||
Action: k.Action,
|
||||
Scope: k.Scope,
|
||||
Created: now,
|
||||
Updated: now,
|
||||
}
|
||||
perm.Kind, perm.Attribute, perm.Identifier = accesscontrol.SplitScope(perm.Scope)
|
||||
toAdd = append(toAdd, perm)
|
||||
}
|
||||
|
||||
for k := range existing {
|
||||
if _, ok := desired[k]; ok {
|
||||
continue
|
||||
}
|
||||
toRemove = append(toRemove, accesscontrol.SeedPermission{Action: k.Action, Scope: k.Scope})
|
||||
}
|
||||
|
||||
if len(toAdd) == 0 && len(toRemove) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return s.sql.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
|
||||
if len(toRemove) > 0 {
|
||||
if err := DeleteRolePermissionTuples(sess, s.sql.GetDBType(), existingRole.ID, toRemove); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(toAdd) > 0 {
|
||||
_, err := sess.InsertMulti(toAdd)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *AccessControlStore) CreateRole(ctx context.Context, role accesscontrol.RoleDTO) error {
|
||||
now := time.Now()
|
||||
uid := role.UID
|
||||
if uid == "" && (strings.HasPrefix(role.Name, accesscontrol.FixedRolePrefix) || strings.HasPrefix(role.Name, accesscontrol.PluginRolePrefix)) {
|
||||
uid = accesscontrol.PrefixedRoleUID(role.Name)
|
||||
}
|
||||
r := accesscontrol.Role{
|
||||
OrgID: accesscontrol.GlobalOrgID,
|
||||
Version: role.Version,
|
||||
UID: uid,
|
||||
Name: role.Name,
|
||||
DisplayName: role.DisplayName,
|
||||
Description: role.Description,
|
||||
Group: role.Group,
|
||||
Hidden: role.Hidden,
|
||||
Created: now,
|
||||
Updated: now,
|
||||
}
|
||||
if r.Version == 0 {
|
||||
r.Version = 1
|
||||
}
|
||||
|
||||
return s.sql.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
|
||||
if _, err := sess.Insert(&r); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(role.Permissions) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// De-duplicate permissions on (action, scope) to avoid unique constraint violations.
|
||||
// Some role definitions may accidentally include duplicates.
|
||||
type permKey struct{ Action, Scope string }
|
||||
seen := make(map[permKey]struct{}, len(role.Permissions))
|
||||
|
||||
perms := make([]accesscontrol.Permission, 0, len(role.Permissions))
|
||||
for _, p := range role.Permissions {
|
||||
k := permKey{Action: p.Action, Scope: p.Scope}
|
||||
if _, ok := seen[k]; ok {
|
||||
continue
|
||||
}
|
||||
seen[k] = struct{}{}
|
||||
|
||||
perm := accesscontrol.Permission{
|
||||
RoleID: r.ID,
|
||||
Action: p.Action,
|
||||
Scope: p.Scope,
|
||||
Created: now,
|
||||
Updated: now,
|
||||
}
|
||||
perm.Kind, perm.Attribute, perm.Identifier = accesscontrol.SplitScope(perm.Scope)
|
||||
perms = append(perms, perm)
|
||||
}
|
||||
_, err := sess.InsertMulti(perms)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (s *AccessControlStore) DeleteRoles(ctx context.Context, roleUIDs []string) error {
|
||||
if len(roleUIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
uids := make([]any, 0, len(roleUIDs))
|
||||
for _, uid := range roleUIDs {
|
||||
uids = append(uids, uid)
|
||||
}
|
||||
|
||||
return s.sql.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
|
||||
type row struct {
|
||||
ID int64 `xorm:"id"`
|
||||
UID string `xorm:"uid"`
|
||||
}
|
||||
rows := []row{}
|
||||
if err := sess.Table("role").
|
||||
Where("org_id = ?", accesscontrol.GlobalOrgID).
|
||||
In("uid", uids...).
|
||||
Find(&rows); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
roleIDs := make([]any, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
roleIDs = append(roleIDs, r.ID)
|
||||
}
|
||||
|
||||
// Remove permissions and assignments first to avoid FK issues (if enabled).
|
||||
{
|
||||
args := append([]any{"DELETE FROM permission WHERE role_id IN (?" + strings.Repeat(",?", len(roleIDs)-1) + ")"}, roleIDs...)
|
||||
if _, err := sess.Exec(args...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
{
|
||||
args := append([]any{"DELETE FROM user_role WHERE role_id IN (?" + strings.Repeat(",?", len(roleIDs)-1) + ")"}, roleIDs...)
|
||||
if _, err := sess.Exec(args...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
{
|
||||
args := append([]any{"DELETE FROM team_role WHERE role_id IN (?" + strings.Repeat(",?", len(roleIDs)-1) + ")"}, roleIDs...)
|
||||
if _, err := sess.Exec(args...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
{
|
||||
args := append([]any{"DELETE FROM builtin_role WHERE role_id IN (?" + strings.Repeat(",?", len(roleIDs)-1) + ")"}, roleIDs...)
|
||||
if _, err := sess.Exec(args...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
args := append([]any{"DELETE FROM role WHERE org_id = ? AND uid IN (?" + strings.Repeat(",?", len(uids)-1) + ")", accesscontrol.GlobalOrgID}, uids...)
|
||||
_, err := sess.Exec(args...)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// OSS basic-role permission refresh uses seeding.Seeder.Seed() with a desired set computed in memory.
|
||||
// These methods implement the permission seeding part of seeding.SeedingBackend against the current permission table.
|
||||
func (s *AccessControlStore) LoadPrevious(ctx context.Context) (map[accesscontrol.SeedPermission]struct{}, error) {
|
||||
var out map[accesscontrol.SeedPermission]struct{}
|
||||
err := s.sql.WithDbSession(ctx, func(sess *db.Session) error {
|
||||
rows, err := LoadBasicRoleSeedPermissions(sess)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out = make(map[accesscontrol.SeedPermission]struct{}, len(rows))
|
||||
for _, r := range rows {
|
||||
r.Origin = ""
|
||||
out[r] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *AccessControlStore) Apply(ctx context.Context, added, removed []accesscontrol.SeedPermission, updated map[accesscontrol.SeedPermission]accesscontrol.SeedPermission) error {
|
||||
rolesToUpgrade := seeding.RolesToUpgrade(added, removed)
|
||||
|
||||
// Run the same OSS apply logic as ossBasicRoleSeedBackend.Apply inside a single transaction.
|
||||
return s.sql.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
|
||||
defs := accesscontrol.BuildBasicRoleDefinitions()
|
||||
builtinToRoleID, err := EnsureBasicRolesExist(sess, defs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
backend := &ossBasicRoleSeedBackend{
|
||||
sess: sess,
|
||||
now: time.Now(),
|
||||
builtinToRoleID: builtinToRoleID,
|
||||
desired: nil,
|
||||
dbType: s.sql.GetDBType(),
|
||||
}
|
||||
if err := backend.Apply(ctx, added, removed, updated); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return BumpBasicRoleVersions(sess, rolesToUpgrade)
|
||||
})
|
||||
}
|
||||
|
||||
// EnsureBasicRolesExist ensures the built-in basic roles exist in the role table and are bound in builtin_role.
|
||||
// It returns a mapping from builtin role name (for example "Admin") to role ID.
|
||||
func EnsureBasicRolesExist(sess *db.Session, defs map[string]*accesscontrol.RoleDTO) (map[string]int64, error) {
|
||||
uidToBuiltin := make(map[string]string, len(defs))
|
||||
uids := make([]any, 0, len(defs))
|
||||
for builtin, def := range defs {
|
||||
uidToBuiltin[def.UID] = builtin
|
||||
uids = append(uids, def.UID)
|
||||
}
|
||||
|
||||
type roleRow struct {
|
||||
ID int64 `xorm:"id"`
|
||||
UID string `xorm:"uid"`
|
||||
}
|
||||
|
||||
rows := []roleRow{}
|
||||
if err := sess.Table("role").
|
||||
Where("org_id = ?", accesscontrol.GlobalOrgID).
|
||||
In("uid", uids...).
|
||||
Find(&rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ts := time.Now()
|
||||
|
||||
builtinToRoleID := make(map[string]int64, len(defs))
|
||||
for _, r := range rows {
|
||||
br, ok := uidToBuiltin[r.UID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
builtinToRoleID[br] = r.ID
|
||||
}
|
||||
|
||||
for builtin, def := range defs {
|
||||
roleID, ok := builtinToRoleID[builtin]
|
||||
if !ok {
|
||||
role := accesscontrol.Role{
|
||||
OrgID: def.OrgID,
|
||||
Version: def.Version,
|
||||
UID: def.UID,
|
||||
Name: def.Name,
|
||||
DisplayName: def.DisplayName,
|
||||
Description: def.Description,
|
||||
Group: def.Group,
|
||||
Hidden: def.Hidden,
|
||||
Created: ts,
|
||||
Updated: ts,
|
||||
}
|
||||
if _, err := sess.Insert(&role); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
roleID = role.ID
|
||||
builtinToRoleID[builtin] = roleID
|
||||
}
|
||||
|
||||
has, err := sess.Table("builtin_role").
|
||||
Where("role_id = ? AND role = ? AND org_id = ?", roleID, builtin, accesscontrol.GlobalOrgID).
|
||||
Exist()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !has {
|
||||
br := accesscontrol.BuiltinRole{
|
||||
RoleID: roleID,
|
||||
OrgID: accesscontrol.GlobalOrgID,
|
||||
Role: builtin,
|
||||
Created: ts,
|
||||
Updated: ts,
|
||||
}
|
||||
if _, err := sess.Table("builtin_role").Insert(&br); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return builtinToRoleID, nil
|
||||
}
|
||||
|
||||
// DeleteRolePermissionTuples deletes permissions for a single role by (action, scope) pairs.
|
||||
//
|
||||
// It uses a row-constructor IN clause where supported (MySQL, Postgres, SQLite) and falls back
|
||||
// to a WHERE ... OR ... form for MSSQL.
|
||||
func DeleteRolePermissionTuples(sess *db.Session, dbType core.DbType, roleID int64, perms []accesscontrol.SeedPermission) error {
|
||||
if len(perms) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if dbType == migrator.MSSQL {
|
||||
// MSSQL doesn't support (action, scope) IN ((?,?),(?,?)) row constructors.
|
||||
where := make([]string, 0, len(perms))
|
||||
args := make([]any, 0, 1+len(perms)*2)
|
||||
args = append(args, roleID)
|
||||
for _, p := range perms {
|
||||
where = append(where, "(action = ? AND scope = ?)")
|
||||
args = append(args, p.Action, p.Scope)
|
||||
}
|
||||
_, err := sess.Exec(
|
||||
append([]any{
|
||||
"DELETE FROM permission WHERE role_id = ? AND (" + strings.Join(where, " OR ") + ")",
|
||||
}, args...)...,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
args := make([]any, 0, 1+len(perms)*2)
|
||||
args = append(args, roleID)
|
||||
for _, p := range perms {
|
||||
args = append(args, p.Action, p.Scope)
|
||||
}
|
||||
sql := "DELETE FROM permission WHERE role_id = ? AND (action, scope) IN (" +
|
||||
strings.Repeat("(?, ?),", len(perms)-1) + "(?, ?))"
|
||||
_, err := sess.Exec(append([]any{sql}, args...)...)
|
||||
return err
|
||||
}
|
||||
|
||||
type ossBasicRoleSeedBackend struct {
|
||||
sess *db.Session
|
||||
now time.Time
|
||||
builtinToRoleID map[string]int64
|
||||
desired map[accesscontrol.SeedPermission]struct{}
|
||||
dbType core.DbType
|
||||
}
|
||||
|
||||
func (b *ossBasicRoleSeedBackend) LoadPrevious(_ context.Context) (map[accesscontrol.SeedPermission]struct{}, error) {
|
||||
rows, err := LoadBasicRoleSeedPermissions(b.sess)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make(map[accesscontrol.SeedPermission]struct{}, len(rows))
|
||||
for _, r := range rows {
|
||||
// Ensure the key matches what OSS seeding uses (Origin is always empty for basic role refresh).
|
||||
r.Origin = ""
|
||||
out[r] = struct{}{}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (b *ossBasicRoleSeedBackend) LoadDesired(_ context.Context) (map[accesscontrol.SeedPermission]struct{}, error) {
|
||||
return b.desired, nil
|
||||
}
|
||||
|
||||
func (b *ossBasicRoleSeedBackend) Apply(_ context.Context, added, removed []accesscontrol.SeedPermission, updated map[accesscontrol.SeedPermission]accesscontrol.SeedPermission) error {
|
||||
// Delete removed permissions (this includes user-defined permissions that aren't in desired).
|
||||
if len(removed) > 0 {
|
||||
permsByRoleID := map[int64][]accesscontrol.SeedPermission{}
|
||||
for _, p := range removed {
|
||||
roleID, ok := b.builtinToRoleID[p.BuiltInRole]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
permsByRoleID[roleID] = append(permsByRoleID[roleID], p)
|
||||
}
|
||||
|
||||
for roleID, perms := range permsByRoleID {
|
||||
// Chunk to keep statement sizes and parameter counts bounded.
|
||||
if err := batch(len(perms), basicRolePermBatchSize, func(start, end int) error {
|
||||
return DeleteRolePermissionTuples(b.sess, b.dbType, roleID, perms[start:end])
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Insert added permissions and updated-target permissions.
|
||||
toInsertSeed := make([]accesscontrol.SeedPermission, 0, len(added)+len(updated))
|
||||
toInsertSeed = append(toInsertSeed, added...)
|
||||
for _, v := range updated {
|
||||
toInsertSeed = append(toInsertSeed, v)
|
||||
}
|
||||
if len(toInsertSeed) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// De-duplicate on (role_id, action, scope). This avoids unique constraint violations when:
|
||||
// - the same permission appears in both added and updated
|
||||
// - multiple plugin origins grant the same permission (Origin is not persisted in permission table)
|
||||
type permKey struct {
|
||||
RoleID int64
|
||||
Action string
|
||||
Scope string
|
||||
}
|
||||
seen := make(map[permKey]struct{}, len(toInsertSeed))
|
||||
|
||||
toInsert := make([]accesscontrol.Permission, 0, len(toInsertSeed))
|
||||
for _, p := range toInsertSeed {
|
||||
roleID, ok := b.builtinToRoleID[p.BuiltInRole]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
k := permKey{RoleID: roleID, Action: p.Action, Scope: p.Scope}
|
||||
if _, ok := seen[k]; ok {
|
||||
continue
|
||||
}
|
||||
seen[k] = struct{}{}
|
||||
|
||||
perm := accesscontrol.Permission{
|
||||
RoleID: roleID,
|
||||
Action: p.Action,
|
||||
Scope: p.Scope,
|
||||
Created: b.now,
|
||||
Updated: b.now,
|
||||
}
|
||||
perm.Kind, perm.Attribute, perm.Identifier = accesscontrol.SplitScope(perm.Scope)
|
||||
toInsert = append(toInsert, perm)
|
||||
}
|
||||
|
||||
return batch(len(toInsert), basicRolePermBatchSize, func(start, end int) error {
|
||||
// MySQL: ignore conflicts to make seeding idempotent under retries/concurrency.
|
||||
// Conflicts can happen if the same permission already exists (unique on role_id, action, scope).
|
||||
if b.dbType == migrator.MySQL {
|
||||
args := make([]any, 0, (end-start)*8)
|
||||
for i := start; i < end; i++ {
|
||||
p := toInsert[i]
|
||||
args = append(args, p.RoleID, p.Action, p.Scope, p.Kind, p.Attribute, p.Identifier, p.Updated, p.Created)
|
||||
}
|
||||
sql := append([]any{`INSERT IGNORE INTO permission (role_id, action, scope, kind, attribute, identifier, updated, created) VALUES ` +
|
||||
strings.Repeat("(?, ?, ?, ?, ?, ?, ?, ?),", end-start-1) + "(?, ?, ?, ?, ?, ?, ?, ?)"}, args...)
|
||||
_, err := b.sess.Exec(sql...)
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := b.sess.InsertMulti(toInsert[start:end])
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func batch(count, size int, eachFn func(start, end int) error) error {
|
||||
for i := 0; i < count; {
|
||||
end := i + size
|
||||
if end > count {
|
||||
end = count
|
||||
}
|
||||
if err := eachFn(i, end); err != nil {
|
||||
return err
|
||||
}
|
||||
i = end
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BumpBasicRoleVersions increments the role version for the given builtin basic roles (Viewer/Editor/Admin/Grafana Admin).
|
||||
// Unknown role names are ignored.
|
||||
func BumpBasicRoleVersions(sess *db.Session, basicRoles []string) error {
|
||||
if len(basicRoles) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
defs := accesscontrol.BuildBasicRoleDefinitions()
|
||||
uids := make([]any, 0, len(basicRoles))
|
||||
for _, br := range basicRoles {
|
||||
def, ok := defs[br]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
uids = append(uids, def.UID)
|
||||
}
|
||||
if len(uids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
sql := "UPDATE role SET version = version + 1 WHERE org_id = ? AND uid IN (?" + strings.Repeat(",?", len(uids)-1) + ")"
|
||||
_, err := sess.Exec(append([]any{sql, accesscontrol.GlobalOrgID}, uids...)...)
|
||||
return err
|
||||
}
|
||||
|
||||
// LoadBasicRoleSeedPermissions returns the current (builtin_role, action, scope) permissions granted to basic roles.
|
||||
// It sets Origin to empty.
|
||||
func LoadBasicRoleSeedPermissions(sess *db.Session) ([]accesscontrol.SeedPermission, error) {
|
||||
rows := []accesscontrol.SeedPermission{}
|
||||
err := sess.SQL(
|
||||
`SELECT role.display_name AS builtin_role, p.action, p.scope, '' AS origin
|
||||
FROM role INNER JOIN permission AS p ON p.role_id = role.id
|
||||
WHERE role.org_id = ? AND role.name LIKE 'basic:%'`,
|
||||
accesscontrol.GlobalOrgID,
|
||||
).Find(&rows)
|
||||
return rows, err
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/serverlock"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/authz/zanzana"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
@@ -131,9 +130,6 @@ func (r *ZanzanaReconciler) Run(ctx context.Context) error {
|
||||
// Reconcile schedules as job that will run and reconcile resources between
|
||||
// legacy access control and zanzana.
|
||||
func (r *ZanzanaReconciler) Reconcile(ctx context.Context) error {
|
||||
// Ensure we don't reconcile an empty/partial RBAC state before OSS has seeded basic role permissions.
|
||||
// This matters most during startup where fixed-role loading + basic-role permission refresh runs as another background service.
|
||||
r.waitForBasicRolesSeeded(ctx)
|
||||
r.reconcile(ctx)
|
||||
|
||||
// FIXME:
|
||||
@@ -149,57 +145,6 @@ func (r *ZanzanaReconciler) Reconcile(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ZanzanaReconciler) hasBasicRolePermissions(ctx context.Context) bool {
|
||||
var count int64
|
||||
// Basic role permissions are stored on "basic:%" roles in the global org (0).
|
||||
// In a fresh DB, this will be empty until fixed roles are registered and the basic role permission refresh runs.
|
||||
type row struct {
|
||||
Count int64 `xorm:"count"`
|
||||
}
|
||||
_ = r.store.WithDbSession(ctx, func(sess *db.Session) error {
|
||||
var rr row
|
||||
_, err := sess.SQL(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM role INNER JOIN permission AS p ON p.role_id = role.id
|
||||
WHERE role.org_id = ? AND role.name LIKE ?`,
|
||||
accesscontrol.GlobalOrgID,
|
||||
accesscontrol.BasicRolePrefix+"%",
|
||||
).Get(&rr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
count = rr.Count
|
||||
return nil
|
||||
})
|
||||
return count > 0
|
||||
}
|
||||
|
||||
func (r *ZanzanaReconciler) waitForBasicRolesSeeded(ctx context.Context) {
|
||||
// Best-effort: don't block forever. If we can't observe basic roles, proceed anyway.
|
||||
const (
|
||||
maxWait = 15 * time.Second
|
||||
interval = 1 * time.Second
|
||||
)
|
||||
|
||||
deadline := time.NewTimer(maxWait)
|
||||
defer deadline.Stop()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
if r.hasBasicRolePermissions(ctx) {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-deadline.C:
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ZanzanaReconciler) reconcile(ctx context.Context) {
|
||||
run := func(ctx context.Context, namespace string) (ok bool) {
|
||||
now := time.Now()
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
package dualwrite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
)
|
||||
|
||||
func TestZanzanaReconciler_hasBasicRolePermissions(t *testing.T) {
|
||||
env := setupTestEnv(t)
|
||||
|
||||
r := &ZanzanaReconciler{
|
||||
store: env.db,
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
require.False(t, r.hasBasicRolePermissions(ctx))
|
||||
|
||||
err := env.db.WithDbSession(ctx, func(sess *db.Session) error {
|
||||
now := time.Now()
|
||||
|
||||
_, err := sess.Exec(
|
||||
`INSERT INTO role (org_id, uid, name, display_name, group_name, description, hidden, version, created, updated)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
accesscontrol.GlobalOrgID,
|
||||
"basic_viewer_uid_test",
|
||||
accesscontrol.BasicRolePrefix+"viewer",
|
||||
"Viewer",
|
||||
"Basic",
|
||||
"Viewer role",
|
||||
false,
|
||||
1,
|
||||
now,
|
||||
now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var roleID int64
|
||||
if _, err := sess.SQL(`SELECT id FROM role WHERE org_id = ? AND uid = ?`, accesscontrol.GlobalOrgID, "basic_viewer_uid_test").Get(&roleID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = sess.Exec(
|
||||
`INSERT INTO permission (role_id, action, scope, kind, attribute, identifier, created, updated)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
roleID,
|
||||
"dashboards:read",
|
||||
"dashboards:*",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
now,
|
||||
now,
|
||||
)
|
||||
return err
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.True(t, r.hasBasicRolePermissions(ctx))
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package accesscontrol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -595,18 +594,3 @@ type QueryWithOrg struct {
|
||||
OrgId *int64 `json:"orgId"`
|
||||
Global bool `json:"global"`
|
||||
}
|
||||
|
||||
type SeedPermission struct {
|
||||
BuiltInRole string `xorm:"builtin_role"`
|
||||
Action string `xorm:"action"`
|
||||
Scope string `xorm:"scope"`
|
||||
Origin string `xorm:"origin"`
|
||||
}
|
||||
|
||||
type RoleStore interface {
|
||||
LoadRoles(ctx context.Context) (map[string]*RoleDTO, error)
|
||||
SetRole(ctx context.Context, existingRole *RoleDTO, wantedRole RoleDTO) error
|
||||
SetPermissions(ctx context.Context, existingRole *RoleDTO, wantedRole RoleDTO) error
|
||||
CreateRole(ctx context.Context, role RoleDTO) error
|
||||
DeleteRoles(ctx context.Context, roleUIDs []string) error
|
||||
}
|
||||
|
||||
@@ -1,451 +0,0 @@
|
||||
package seeding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/pluginutils"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol"
|
||||
)
|
||||
|
||||
type Seeder struct {
|
||||
log log.Logger
|
||||
roleStore accesscontrol.RoleStore
|
||||
backend SeedingBackend
|
||||
builtinsPermissions map[accesscontrol.SeedPermission]struct{}
|
||||
seededFixedRoles map[string]bool
|
||||
seededPluginRoles map[string]bool
|
||||
seededPlugins map[string]bool
|
||||
hasSeededAlready bool
|
||||
}
|
||||
|
||||
// SeedingBackend provides the seed-set specific operations needed to seed.
|
||||
type SeedingBackend interface {
|
||||
// LoadPrevious returns the currently stored permissions for previously seeded roles.
|
||||
LoadPrevious(ctx context.Context) (map[accesscontrol.SeedPermission]struct{}, error)
|
||||
|
||||
// Apply updates the database to match the desired permissions.
|
||||
Apply(ctx context.Context,
|
||||
added, removed []accesscontrol.SeedPermission,
|
||||
updated map[accesscontrol.SeedPermission]accesscontrol.SeedPermission,
|
||||
) error
|
||||
}
|
||||
|
||||
func New(log log.Logger, roleStore accesscontrol.RoleStore, backend SeedingBackend) *Seeder {
|
||||
return &Seeder{
|
||||
log: log,
|
||||
roleStore: roleStore,
|
||||
backend: backend,
|
||||
builtinsPermissions: map[accesscontrol.SeedPermission]struct{}{},
|
||||
seededFixedRoles: map[string]bool{},
|
||||
seededPluginRoles: map[string]bool{},
|
||||
seededPlugins: map[string]bool{},
|
||||
hasSeededAlready: false,
|
||||
}
|
||||
}
|
||||
|
||||
// SetDesiredPermissions replaces the in-memory desired permission set used by Seed().
|
||||
func (s *Seeder) SetDesiredPermissions(desired map[accesscontrol.SeedPermission]struct{}) {
|
||||
if desired == nil {
|
||||
s.builtinsPermissions = map[accesscontrol.SeedPermission]struct{}{}
|
||||
return
|
||||
}
|
||||
s.builtinsPermissions = desired
|
||||
}
|
||||
|
||||
// Seed loads current and desired permissions, diffs them (including scope updates), applies changes, and bumps versions.
|
||||
func (s *Seeder) Seed(ctx context.Context) error {
|
||||
previous, err := s.backend.LoadPrevious(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// - Do not remove plugin permissions when the plugin didn't register this run (Origin set but not in seededPlugins).
|
||||
// - Preserve legacy plugin app access permissions in the persisted seed set (these are granted by default).
|
||||
if len(previous) > 0 {
|
||||
filtered := make(map[accesscontrol.SeedPermission]struct{}, len(previous))
|
||||
for p := range previous {
|
||||
if p.Action == pluginaccesscontrol.ActionAppAccess {
|
||||
continue
|
||||
}
|
||||
if p.Origin != "" && !s.seededPlugins[p.Origin] {
|
||||
continue
|
||||
}
|
||||
filtered[p] = struct{}{}
|
||||
}
|
||||
previous = filtered
|
||||
}
|
||||
|
||||
added, removed, updated := s.permissionDiff(previous, s.builtinsPermissions)
|
||||
|
||||
if err := s.backend.Apply(ctx, added, removed, updated); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SeedRoles populates the database with the roles and their assignments
|
||||
// It will create roles that do not exist and update roles that have changed
|
||||
// Do not use for provisioning. Validation is not enforced.
|
||||
func (s *Seeder) SeedRoles(ctx context.Context, registrationList []accesscontrol.RoleRegistration) error {
|
||||
roleMap, err := s.roleStore.LoadRoles(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
missingRoles := make([]accesscontrol.RoleRegistration, 0, len(registrationList))
|
||||
|
||||
// Diff existing roles with the ones we want to seed.
|
||||
// If a role is missing, we add it to the missingRoles list
|
||||
for _, registration := range registrationList {
|
||||
registration := registration
|
||||
role, ok := roleMap[registration.Role.Name]
|
||||
switch {
|
||||
case registration.Role.IsFixed():
|
||||
s.seededFixedRoles[registration.Role.Name] = true
|
||||
case registration.Role.IsPlugin():
|
||||
s.seededPluginRoles[registration.Role.Name] = true
|
||||
// To be resilient to failed plugin loadings, we remember the plugins that have registered,
|
||||
// later we'll ignore permissions and roles of other plugins
|
||||
s.seededPlugins[pluginutils.PluginIDFromName(registration.Role.Name)] = true
|
||||
}
|
||||
|
||||
s.rememberPermissionAssignments(®istration.Role, registration.Grants, registration.Exclude)
|
||||
|
||||
if !ok {
|
||||
missingRoles = append(missingRoles, registration)
|
||||
continue
|
||||
}
|
||||
|
||||
if needsRoleUpdate(role, registration.Role) {
|
||||
if err := s.roleStore.SetRole(ctx, role, registration.Role); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if needsPermissionsUpdate(role, registration.Role) {
|
||||
if err := s.roleStore.SetPermissions(ctx, role, registration.Role); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, registration := range missingRoles {
|
||||
if err := s.roleStore.CreateRole(ctx, registration.Role); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func needsPermissionsUpdate(existingRole *accesscontrol.RoleDTO, wantedRole accesscontrol.RoleDTO) bool {
|
||||
if existingRole == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
if len(existingRole.Permissions) != len(wantedRole.Permissions) {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, p := range wantedRole.Permissions {
|
||||
found := false
|
||||
for _, ep := range existingRole.Permissions {
|
||||
if ep.Action == p.Action && ep.Scope == p.Scope {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func needsRoleUpdate(existingRole *accesscontrol.RoleDTO, wantedRole accesscontrol.RoleDTO) bool {
|
||||
if existingRole == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
if existingRole.Name != wantedRole.Name {
|
||||
return false
|
||||
}
|
||||
|
||||
if existingRole.DisplayName != wantedRole.DisplayName {
|
||||
return true
|
||||
}
|
||||
|
||||
if existingRole.Description != wantedRole.Description {
|
||||
return true
|
||||
}
|
||||
|
||||
if existingRole.Group != wantedRole.Group {
|
||||
return true
|
||||
}
|
||||
|
||||
if existingRole.Hidden != wantedRole.Hidden {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Deprecated: SeedRole is deprecated and should not be used.
|
||||
// SeedRoles only does boot up seeding and should not be used for runtime seeding.
|
||||
func (s *Seeder) SeedRole(ctx context.Context, role accesscontrol.RoleDTO, builtInRoles []string) error {
|
||||
addedPermissions := make(map[string]struct{}, len(role.Permissions))
|
||||
permissions := make([]accesscontrol.Permission, 0, len(role.Permissions))
|
||||
for _, p := range role.Permissions {
|
||||
key := fmt.Sprintf("%s:%s", p.Action, p.Scope)
|
||||
if _, ok := addedPermissions[key]; !ok {
|
||||
addedPermissions[key] = struct{}{}
|
||||
permissions = append(permissions, accesscontrol.Permission{Action: p.Action, Scope: p.Scope})
|
||||
}
|
||||
}
|
||||
|
||||
wantedRole := accesscontrol.RoleDTO{
|
||||
OrgID: accesscontrol.GlobalOrgID,
|
||||
Version: role.Version,
|
||||
UID: role.UID,
|
||||
Name: role.Name,
|
||||
DisplayName: role.DisplayName,
|
||||
Description: role.Description,
|
||||
Group: role.Group,
|
||||
Permissions: permissions,
|
||||
Hidden: role.Hidden,
|
||||
}
|
||||
roleMap, err := s.roleStore.LoadRoles(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existingRole := roleMap[wantedRole.Name]
|
||||
if existingRole == nil {
|
||||
if err := s.roleStore.CreateRole(ctx, wantedRole); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if needsRoleUpdate(existingRole, wantedRole) {
|
||||
if err := s.roleStore.SetRole(ctx, existingRole, wantedRole); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if needsPermissionsUpdate(existingRole, wantedRole) {
|
||||
if err := s.roleStore.SetPermissions(ctx, existingRole, wantedRole); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remember seeded roles
|
||||
if wantedRole.IsFixed() {
|
||||
s.seededFixedRoles[wantedRole.Name] = true
|
||||
}
|
||||
isPluginRole := wantedRole.IsPlugin()
|
||||
if isPluginRole {
|
||||
s.seededPluginRoles[wantedRole.Name] = true
|
||||
|
||||
// To be resilient to failed plugin loadings, we remember the plugins that have registered,
|
||||
// later we'll ignore permissions and roles of other plugins
|
||||
s.seededPlugins[pluginutils.PluginIDFromName(role.Name)] = true
|
||||
}
|
||||
|
||||
s.rememberPermissionAssignments(&wantedRole, builtInRoles, []string{})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Seeder) rememberPermissionAssignments(role *accesscontrol.RoleDTO, builtInRoles []string, excludedRoles []string) {
|
||||
AppendDesiredPermissions(s.builtinsPermissions, s.log, role, builtInRoles, excludedRoles, true)
|
||||
}
|
||||
|
||||
// AppendDesiredPermissions accumulates permissions from a role registration onto basic roles (Viewer/Editor/Admin/Grafana Admin).
|
||||
// - It expands parents via accesscontrol.BuiltInRolesWithParents.
|
||||
// - It can optionally ignore plugin app access permissions (which are granted by default).
|
||||
func AppendDesiredPermissions(
|
||||
out map[accesscontrol.SeedPermission]struct{},
|
||||
logger log.Logger,
|
||||
role *accesscontrol.RoleDTO,
|
||||
builtInRoles []string,
|
||||
excludedRoles []string,
|
||||
ignorePluginAppAccess bool,
|
||||
) {
|
||||
if out == nil || role == nil {
|
||||
return
|
||||
}
|
||||
|
||||
for builtInRole := range accesscontrol.BuiltInRolesWithParents(builtInRoles) {
|
||||
// Skip excluded grants
|
||||
if slices.Contains(excludedRoles, builtInRole) {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, perm := range role.Permissions {
|
||||
if ignorePluginAppAccess && perm.Action == pluginaccesscontrol.ActionAppAccess {
|
||||
logger.Debug("Role is attempting to grant access permission, but this permission is already granted by default and will be ignored",
|
||||
"role", role.Name, "permission", perm.Action, "scope", perm.Scope)
|
||||
continue
|
||||
}
|
||||
|
||||
sp := accesscontrol.SeedPermission{
|
||||
BuiltInRole: builtInRole,
|
||||
Action: perm.Action,
|
||||
Scope: perm.Scope,
|
||||
}
|
||||
|
||||
if role.IsPlugin() {
|
||||
sp.Origin = pluginutils.PluginIDFromName(role.Name)
|
||||
}
|
||||
|
||||
out[sp] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// permissionDiff returns:
|
||||
// - added: present in desired permissions, not in previous permissions
|
||||
// - removed: present in previous permissions, not in desired permissions
|
||||
// - updated: same role + action, but scope changed
|
||||
func (s *Seeder) permissionDiff(previous, desired map[accesscontrol.SeedPermission]struct{}) (added, removed []accesscontrol.SeedPermission, updated map[accesscontrol.SeedPermission]accesscontrol.SeedPermission) {
|
||||
addedSet := make(map[accesscontrol.SeedPermission]struct{}, 0)
|
||||
for n := range desired {
|
||||
if _, already := previous[n]; !already {
|
||||
addedSet[n] = struct{}{}
|
||||
} else {
|
||||
delete(previous, n)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any of the new permissions is actually an old permission with an updated scope
|
||||
updated = make(map[accesscontrol.SeedPermission]accesscontrol.SeedPermission, 0)
|
||||
for n := range addedSet {
|
||||
for p := range previous {
|
||||
if n.BuiltInRole == p.BuiltInRole && n.Action == p.Action {
|
||||
updated[p] = n
|
||||
delete(addedSet, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for p := range addedSet {
|
||||
added = append(added, p)
|
||||
}
|
||||
|
||||
for p := range previous {
|
||||
if p.Action == pluginaccesscontrol.ActionAppAccess &&
|
||||
p.Scope != pluginaccesscontrol.ScopeProvider.GetResourceAllScope() {
|
||||
// Allows backward compatibility with plugins that have been seeded before the grant ignore rule was added
|
||||
s.log.Info("This permission already existed so it will not be removed",
|
||||
"role", p.BuiltInRole, "permission", p.Action, "scope", p.Scope)
|
||||
continue
|
||||
}
|
||||
|
||||
removed = append(removed, p)
|
||||
}
|
||||
|
||||
return added, removed, updated
|
||||
}
|
||||
|
||||
func (s *Seeder) ClearBasicRolesPluginPermissions(ID string) {
|
||||
removable := []accesscontrol.SeedPermission{}
|
||||
|
||||
for key := range s.builtinsPermissions {
|
||||
if matchPermissionByPluginID(key, ID) {
|
||||
removable = append(removable, key)
|
||||
}
|
||||
}
|
||||
|
||||
for _, perm := range removable {
|
||||
delete(s.builtinsPermissions, perm)
|
||||
}
|
||||
}
|
||||
|
||||
func matchPermissionByPluginID(perm accesscontrol.SeedPermission, pluginID string) bool {
|
||||
if perm.Origin != pluginID {
|
||||
return false
|
||||
}
|
||||
actionTemplate := regexp.MustCompile(fmt.Sprintf("%s[.:]", pluginID))
|
||||
scopeTemplate := fmt.Sprintf(":%s", pluginID)
|
||||
return actionTemplate.MatchString(perm.Action) || strings.HasSuffix(perm.Scope, scopeTemplate)
|
||||
}
|
||||
|
||||
// RolesToUpgrade returns the unique basic roles that should have their version incremented.
|
||||
func RolesToUpgrade(added, removed []accesscontrol.SeedPermission) []string {
|
||||
set := map[string]struct{}{}
|
||||
for _, p := range added {
|
||||
set[p.BuiltInRole] = struct{}{}
|
||||
}
|
||||
for _, p := range removed {
|
||||
set[p.BuiltInRole] = struct{}{}
|
||||
}
|
||||
out := make([]string, 0, len(set))
|
||||
for r := range set {
|
||||
out = append(out, r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Seeder) ClearPluginRoles(ID string) {
|
||||
expectedPrefix := fmt.Sprintf("%s%s:", accesscontrol.PluginRolePrefix, ID)
|
||||
|
||||
for roleName := range s.seededPluginRoles {
|
||||
if strings.HasPrefix(roleName, expectedPrefix) {
|
||||
delete(s.seededPluginRoles, roleName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Seeder) MarkSeededAlready() {
|
||||
s.hasSeededAlready = true
|
||||
}
|
||||
|
||||
func (s *Seeder) HasSeededAlready() bool {
|
||||
return s.hasSeededAlready
|
||||
}
|
||||
|
||||
func (s *Seeder) RemoveAbsentRoles(ctx context.Context) error {
|
||||
roleMap, errGet := s.roleStore.LoadRoles(ctx)
|
||||
if errGet != nil {
|
||||
s.log.Error("failed to get fixed roles from store", "err", errGet)
|
||||
return errGet
|
||||
}
|
||||
|
||||
toRemove := []string{}
|
||||
for _, r := range roleMap {
|
||||
if r == nil {
|
||||
continue
|
||||
}
|
||||
if r.IsFixed() {
|
||||
if !s.seededFixedRoles[r.Name] {
|
||||
s.log.Info("role is not seeded anymore, mark it for deletion", "role", r.Name)
|
||||
toRemove = append(toRemove, r.UID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if r.IsPlugin() {
|
||||
if !s.seededPlugins[pluginutils.PluginIDFromName(r.Name)] {
|
||||
// To be resilient to failed plugin loadings
|
||||
// ignore stored roles related to plugins that have not registered this time
|
||||
s.log.Debug("plugin role has not been registered on this run skipping its removal", "role", r.Name)
|
||||
continue
|
||||
}
|
||||
if !s.seededPluginRoles[r.Name] {
|
||||
s.log.Info("role is not seeded anymore, mark it for deletion", "role", r.Name)
|
||||
toRemove = append(toRemove, r.UID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if errDelete := s.roleStore.DeleteRoles(ctx, toRemove); errDelete != nil {
|
||||
s.log.Error("failed to delete absent fixed and plugin roles", "err", errDelete)
|
||||
return errDelete
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -294,6 +294,9 @@ type DashboardProvisioning struct {
|
||||
ExternalID string `xorm:"external_id"`
|
||||
CheckSum string
|
||||
Updated int64
|
||||
|
||||
// note: only used when writing metadata to unified storage resources - not saved in legacy table.
|
||||
AllowUIUpdates bool `xorm:"-"`
|
||||
}
|
||||
|
||||
type DeleteDashboardCommand struct {
|
||||
|
||||
@@ -1942,6 +1942,7 @@ func (dr *DashboardServiceImpl) saveProvisionedDashboardThroughK8s(ctx context.C
|
||||
// HOWEVER, maybe OK to leave this for now and "fix" it by using file provisioning for mode 4
|
||||
m.Kind = utils.ManagerKindClassicFP // nolint:staticcheck
|
||||
m.Identity = provisioning.Name
|
||||
m.AllowsEdits = provisioning.AllowUIUpdates
|
||||
s.Path = provisioning.ExternalID
|
||||
s.Checksum = provisioning.CheckSum
|
||||
s.TimestampMillis = time.Unix(provisioning.Updated, 0).UnixMilli()
|
||||
|
||||
@@ -2075,6 +2075,13 @@ var (
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaDashboardsSquad,
|
||||
},
|
||||
{
|
||||
Name: "smoothingTransformation",
|
||||
Description: "Enables the ASAP smoothing transformation for time series data",
|
||||
Stage: FeatureStageExperimental,
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaDataProSquad,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
1
pkg/services/featuremgmt/toggles_gen.csv
generated
1
pkg/services/featuremgmt/toggles_gen.csv
generated
@@ -281,3 +281,4 @@ rudderstackUpgrade,experimental,@grafana/grafana-frontend-platform,false,false,t
|
||||
kubernetesAlertingHistorian,experimental,@grafana/alerting-squad,false,true,false
|
||||
useMTPlugins,experimental,@grafana/plugins-platform-backend,false,false,true
|
||||
multiPropsVariables,experimental,@grafana/dashboards-squad,false,false,true
|
||||
smoothingTransformation,experimental,@grafana/datapro,false,false,true
|
||||
|
||||
|
13
pkg/services/featuremgmt/toggles_gen.json
generated
13
pkg/services/featuremgmt/toggles_gen.json
generated
@@ -3293,6 +3293,19 @@
|
||||
"codeowner": "@grafana/dashboards-squad"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "smoothingTransformation",
|
||||
"resourceVersion": "1767349656275",
|
||||
"creationTimestamp": "2026-01-02T10:27:36Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables the ASAP smoothing transformation for time series data",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/datapro",
|
||||
"frontend": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "sqlExpressions",
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana/pkg/middleware"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
@@ -129,10 +128,6 @@ func (s *ServiceImpl) processAppPlugin(plugin pluginstore.Plugin, c *contextmode
|
||||
}
|
||||
|
||||
if include.Type == "page" {
|
||||
if !middleware.PageIsFeatureToggleEnabled(c.Req.Context(), include.Path) {
|
||||
s.log.Debug("Skipping page", "plugin", plugin.ID, "path", include.Path)
|
||||
continue
|
||||
}
|
||||
link := &navtree.NavLink{
|
||||
Text: include.Name,
|
||||
Icon: include.Icon,
|
||||
|
||||
@@ -358,6 +358,8 @@ func (fr *FileReader) saveDashboard(ctx context.Context, path string, folderID i
|
||||
Name: fr.Cfg.Name,
|
||||
Updated: resolvedFileInfo.ModTime().Unix(),
|
||||
CheckSum: jsonFile.checkSum,
|
||||
// adds `grafana.app/managerAllowsEdits` to the provisioned dashboards in unified storage. not used if in legacy.
|
||||
AllowUIUpdates: fr.Cfg.AllowUIUpdates,
|
||||
}
|
||||
_, err := fr.dashboardProvisioningService.SaveProvisionedDashboard(ctx, dash, dp)
|
||||
if err != nil {
|
||||
|
||||
@@ -33,6 +33,8 @@ import (
|
||||
)
|
||||
|
||||
func TestIntegrationFolderTreeZanzana(t *testing.T) {
|
||||
// TODO: Add back OSS seeding and enable this test
|
||||
t.Skip("Skipping folder tree test with Zanzana")
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
runIntegrationFolderTree(t, testinfra.GrafanaOpts{
|
||||
|
||||
@@ -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,7 +1,15 @@
|
||||
import { GrafanaConfig, locationUtil } from '@grafana/data';
|
||||
import * as folderHooks from 'app/api/clients/folder/v1beta1/hooks';
|
||||
import { backendSrv } from 'app/core/services/backend_srv';
|
||||
import { AnnoKeyFolder, AnnoKeyMessage, AnnoReloadOnParamsChange } from 'app/features/apiserver/types';
|
||||
import {
|
||||
AnnoKeyFolder,
|
||||
AnnoKeyManagerAllowsEdits,
|
||||
AnnoKeyManagerKind,
|
||||
AnnoKeyMessage,
|
||||
AnnoKeySourcePath,
|
||||
AnnoReloadOnParamsChange,
|
||||
ManagerKind,
|
||||
} from 'app/features/apiserver/types';
|
||||
import { DashboardDataDTO } from 'app/types/dashboard';
|
||||
|
||||
import { DashboardWithAccessInfo } from './types';
|
||||
@@ -215,6 +223,63 @@ describe('v1 dashboard API', () => {
|
||||
expect(result.meta.reloadOnParamsChange).toBe(true);
|
||||
});
|
||||
|
||||
describe('managed/provisioned dashboards', () => {
|
||||
it('should not mark dashboard as provisioned when manager allows UI edits', async () => {
|
||||
mockGet.mockResolvedValueOnce({
|
||||
...mockDashboardDto,
|
||||
metadata: {
|
||||
...mockDashboardDto.metadata,
|
||||
annotations: {
|
||||
[AnnoKeyManagerKind]: ManagerKind.Terraform,
|
||||
[AnnoKeyManagerAllowsEdits]: 'true',
|
||||
[AnnoKeySourcePath]: 'dashboards/test.json',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const api = new K8sDashboardAPI();
|
||||
const result = await api.getDashboardDTO('test');
|
||||
expect(result.meta.provisioned).toBe(false);
|
||||
expect(result.meta.provisionedExternalId).toBe('dashboards/test.json');
|
||||
});
|
||||
|
||||
it('should mark dashboard as provisioned when manager does not allow UI edits', async () => {
|
||||
mockGet.mockResolvedValueOnce({
|
||||
...mockDashboardDto,
|
||||
metadata: {
|
||||
...mockDashboardDto.metadata,
|
||||
annotations: {
|
||||
[AnnoKeyManagerKind]: ManagerKind.Terraform,
|
||||
[AnnoKeySourcePath]: 'dashboards/test.json',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const api = new K8sDashboardAPI();
|
||||
const result = await api.getDashboardDTO('test');
|
||||
expect(result.meta.provisioned).toBe(true);
|
||||
expect(result.meta.provisionedExternalId).toBe('dashboards/test.json');
|
||||
});
|
||||
|
||||
it('should not mark repository-managed dashboard as provisioned (locked)', async () => {
|
||||
mockGet.mockResolvedValueOnce({
|
||||
...mockDashboardDto,
|
||||
metadata: {
|
||||
...mockDashboardDto.metadata,
|
||||
annotations: {
|
||||
[AnnoKeyManagerKind]: ManagerKind.Repo,
|
||||
[AnnoKeySourcePath]: 'dashboards/test.json',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const api = new K8sDashboardAPI();
|
||||
const result = await api.getDashboardDTO('test');
|
||||
expect(result.meta.provisioned).toBe(false);
|
||||
expect(result.meta.provisionedExternalId).toBe('dashboards/test.json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveDashboard', () => {
|
||||
beforeEach(() => {
|
||||
locationUtil.initialize({
|
||||
|
||||
@@ -164,7 +164,11 @@ export class K8sDashboardAPI implements DashboardAPI<DashboardDTO, Dashboard> {
|
||||
const managerKind = annotations[AnnoKeyManagerKind];
|
||||
|
||||
if (managerKind) {
|
||||
result.meta.provisioned = annotations[AnnoKeyManagerAllowsEdits] === 'true' || managerKind === ManagerKind.Repo;
|
||||
// `meta.provisioned` is used by the save/delete UI to decide if a dashboard is locked
|
||||
// (i.e. it can't be saved from the UI). This should match the legacy behavior where
|
||||
// `allowUiUpdates: true` keeps the dashboard editable/savable.
|
||||
const allowsEdits = annotations[AnnoKeyManagerAllowsEdits] === 'true';
|
||||
result.meta.provisioned = !allowsEdits && managerKind !== ManagerKind.Repo;
|
||||
result.meta.provisionedExternalId = annotations[AnnoKeySourcePath];
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user