mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge remote-tracking branch 'origin/main' into resource-store
This commit is contained in:
commit
d97d59ab38
@ -1490,33 +1490,16 @@ exports[`better eslint`] = {
|
||||
"public/app/features/admin/UserOrgs.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "2"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "3"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "4"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "5"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "6"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "7"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "8"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "6"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "7"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "8"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "9"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "10"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "11"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "12"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "13"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "14"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "15"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "16"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "17"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "18"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "19"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "20"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "21"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "22"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "23"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "24"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "25"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "26"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "27"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "28"]
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "10"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "11"]
|
||||
],
|
||||
"public/app/features/admin/UserPermissions.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
@ -5417,9 +5400,7 @@ exports[`better eslint`] = {
|
||||
],
|
||||
"public/app/features/storage/RootView.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "2"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "3"]
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
|
||||
],
|
||||
"public/app/features/storage/StorageFolderPage.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
|
@ -13,6 +13,8 @@ node_modules
|
||||
/public/lib/monaco
|
||||
/scripts/grafana-server/tmp
|
||||
vendor
|
||||
e2e/custom-plugins
|
||||
playwright-report
|
||||
|
||||
# TS generate from cue by cuetsy
|
||||
**/*.gen.ts
|
||||
|
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@ -224,6 +224,7 @@
|
||||
/devenv/local-npm/ @grafana/frontend-ops
|
||||
/devenv/vscode/ @grafana/frontend-ops
|
||||
/devenv/setup.sh @grafana/grafana-backend-services-squad
|
||||
/devenv/plugins.yaml @grafana/plugins-platform-frontend
|
||||
|
||||
# Emails
|
||||
/emails/ @grafana/alerting-frontend
|
||||
|
@ -235,7 +235,7 @@ yarn e2e:dev
|
||||
|
||||
#### To run the Playwright tests:
|
||||
|
||||
**Note:** If you're using VS Code as your development editor, it's recommended to install the [Playwright test extension](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright). It allows you to run, debug and generate Playwright tests from within the editor. For more information about the extension and how to install it, refer to the [Playwright documentation](https://playwright.dev/docs/getting-started-vscode).
|
||||
**Note:** If you're using VS Code as your development editor, it's recommended to install the [Playwright test extension](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright). It allows you to run, debug and generate Playwright tests from within the editor. For more information about the extension and how to use reports to analyze failing tests, refer to the [Playwright documentation](https://playwright.dev/docs/getting-started-vscode).
|
||||
|
||||
Each version of Playwright needs specific versions of browser binaries to operate. You need to use the Playwright CLI to install these browsers.
|
||||
|
||||
@ -243,22 +243,16 @@ Each version of Playwright needs specific versions of browser binaries to operat
|
||||
yarn playwright install chromium
|
||||
```
|
||||
|
||||
To run all tests in a headless Chromium browser and display results in the terminal:
|
||||
To run all tests in a headless Chromium browser and display results in the terminal. This assumes you have Grafana running on port 3000.
|
||||
|
||||
```
|
||||
yarn e2e:playwright
|
||||
```
|
||||
|
||||
For a better developer experience, open the Playwright UI where you can visually walk through each step of the test and see what was happening before, during, and after each step.
|
||||
The following script starts a Grafana [development server](https://github.com/grafana/grafana/blob/main/scripts/grafana-server/start-server) (same server that is being used when running e2e tests in Drone CI) on port 3001 and runs the Playwright tests. The development server is provisioned with the [devenv](https://github.com/grafana/grafana/blob/main/contribute/developer-guide.md#add-data-sources) dashboards, data sources and apps.
|
||||
|
||||
```
|
||||
yarn e2e:playwright:ui
|
||||
```
|
||||
|
||||
To open the HTML reporter for the last test run session:
|
||||
|
||||
```
|
||||
yarn e2e:playwright:report
|
||||
yarn e2e:playwright:server
|
||||
```
|
||||
|
||||
## Configure Grafana for development
|
||||
|
@ -33,3 +33,5 @@ Playwright end-to-end tests for plugins should be added to the [`e2e/plugin-e2e`
|
||||
The script above assumes you have Grafana running on `localhost:3000`. You may change this by providing environment variables.
|
||||
|
||||
`HOST=127.0.0.1 PORT=3001 yarn e2e:playwright`
|
||||
|
||||
- `yarn e2e:playwright:server` will start a Grafana [development server](https://github.com/grafana/grafana/blob/main/scripts/grafana-server/start-server) on port 3001 and run the Playwright tests. The development server is provisioned with the [devenv](https://github.com/grafana/grafana/blob/main/contribute/developer-guide.md#add-data-sources) dashboards, data sources and apps.
|
||||
|
342
devenv/dev-dashboards/extensions/link-onclick-extensions.json
Normal file
342
devenv/dev-dashboards/extensions/link-onclick-extensions.json
Normal file
@ -0,0 +1,342 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": {
|
||||
"type": "grafana",
|
||||
"uid": "-- Grafana --"
|
||||
},
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"links": [],
|
||||
"liveNow": false,
|
||||
"panels": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "testdata"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "auto",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 7,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"title": "Link with one query",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "testdata"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"custom": {
|
||||
"align": "auto",
|
||||
"cellOptions": {
|
||||
"type": "auto"
|
||||
},
|
||||
"inspect": false
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 6,
|
||||
"x": 0,
|
||||
"y": 8
|
||||
},
|
||||
"id": 6,
|
||||
"options": {
|
||||
"cellHeight": "sm",
|
||||
"footer": {
|
||||
"countRows": false,
|
||||
"fields": "",
|
||||
"reducer": [
|
||||
"sum"
|
||||
],
|
||||
"show": false
|
||||
},
|
||||
"showHeader": true,
|
||||
"showRowNums": false
|
||||
},
|
||||
"pluginVersion": "10.1.0-55406pre",
|
||||
"title": "No extensions",
|
||||
"type": "table"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "testdata"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
}
|
||||
},
|
||||
"mappings": []
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 6,
|
||||
"x": 6,
|
||||
"y": 8
|
||||
},
|
||||
"id": 4,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"pieType": "pie",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "testdata"
|
||||
},
|
||||
"refId": "A",
|
||||
"scenarioId": "random_walk",
|
||||
"seriesCount": 4
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "testdata"
|
||||
},
|
||||
"hide": false,
|
||||
"refId": "B",
|
||||
"scenarioId": "random_walk",
|
||||
"seriesCount": 1
|
||||
}
|
||||
],
|
||||
"title": "Link with new name",
|
||||
"type": "piechart"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "testdata"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "auto",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 6,
|
||||
"x": 0,
|
||||
"y": 16
|
||||
},
|
||||
"id": 2,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "testdata"
|
||||
},
|
||||
"refId": "A",
|
||||
"scenarioId": "random_walk",
|
||||
"seriesCount": 1
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "testdata"
|
||||
},
|
||||
"hide": false,
|
||||
"refId": "B",
|
||||
"scenarioId": "random_walk",
|
||||
"seriesCount": 1
|
||||
}
|
||||
],
|
||||
"title": "Link with defaults",
|
||||
"type": "timeseries"
|
||||
}
|
||||
],
|
||||
"refresh": "",
|
||||
"schemaVersion": 38,
|
||||
"style": "dark",
|
||||
"tags": [],
|
||||
"templating": {
|
||||
"list": []
|
||||
},
|
||||
"time": {
|
||||
"from": "now-6h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "",
|
||||
"title": "Link Extensions (onClick)",
|
||||
"uid": "dbfb47c5-e5e5-4d28-8ac7-35f349b95946",
|
||||
"version": 1,
|
||||
"weekStart": ""
|
||||
}
|
237
devenv/dev-dashboards/extensions/link-path-extensions.json
Normal file
237
devenv/dev-dashboards/extensions/link-path-extensions.json
Normal file
@ -0,0 +1,237 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": {
|
||||
"type": "grafana",
|
||||
"uid": "-- Grafana --"
|
||||
},
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"id": 1,
|
||||
"links": [],
|
||||
"liveNow": false,
|
||||
"panels": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "testdata"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"custom": {
|
||||
"align": "auto",
|
||||
"cellOptions": {
|
||||
"type": "auto"
|
||||
},
|
||||
"inspect": false
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 6,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 6,
|
||||
"options": {
|
||||
"cellHeight": "sm",
|
||||
"footer": {
|
||||
"countRows": false,
|
||||
"fields": "",
|
||||
"reducer": [
|
||||
"sum"
|
||||
],
|
||||
"show": false
|
||||
},
|
||||
"showHeader": true,
|
||||
"showRowNums": false
|
||||
},
|
||||
"pluginVersion": "9.5.0-53420pre",
|
||||
"title": "No extensions",
|
||||
"type": "table"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "testdata"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
}
|
||||
},
|
||||
"mappings": []
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 6,
|
||||
"x": 6,
|
||||
"y": 0
|
||||
},
|
||||
"id": 4,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"pieType": "pie",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "testdata"
|
||||
},
|
||||
"refId": "A",
|
||||
"scenarioId": "random_walk",
|
||||
"seriesCount": 4
|
||||
}
|
||||
],
|
||||
"title": "Link with new name",
|
||||
"type": "piechart"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "testdata"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "auto",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 6,
|
||||
"x": 0,
|
||||
"y": 8
|
||||
},
|
||||
"id": 2,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"title": "Link with defaults",
|
||||
"type": "timeseries"
|
||||
}
|
||||
],
|
||||
"refresh": "",
|
||||
"schemaVersion": 38,
|
||||
"style": "dark",
|
||||
"tags": [],
|
||||
"templating": {
|
||||
"list": []
|
||||
},
|
||||
"time": {
|
||||
"from": "now-6h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "",
|
||||
"title": "Link Extensions (path)",
|
||||
"uid": "d1fbb077-cd44-4738-8c8a-d4e66748b719",
|
||||
"version": 3,
|
||||
"weekStart": ""
|
||||
}
|
@ -59,6 +59,8 @@
|
||||
"join-by-field": (import '../dev-dashboards/transforms/join-by-field.json'),
|
||||
"join-by-labels": (import '../dev-dashboards/transforms/join-by-labels.json'),
|
||||
"lazy_loading": (import '../dev-dashboards/panel-common/lazy_loading.json'),
|
||||
"link-onclick-extensions": (import '../dev-dashboards/extensions/link-onclick-extensions.json'),
|
||||
"link-path-extensions": (import '../dev-dashboards/extensions/link-path-extensions.json'),
|
||||
"linked-viz": (import '../dev-dashboards/panel-common/linked-viz.json'),
|
||||
"live-flakey": (import '../dev-dashboards/live/live-flakey.json'),
|
||||
"live-flakey-refresh": (import '../dev-dashboards/live/live-flakey-refresh.json'),
|
||||
|
19
devenv/plugins.yaml
Normal file
19
devenv/plugins.yaml
Normal file
@ -0,0 +1,19 @@
|
||||
apiVersion: 1
|
||||
|
||||
apps:
|
||||
- type: myorg-extensions-app
|
||||
org_id: 1
|
||||
org_name: Main Org.
|
||||
disabled: false
|
||||
- type: myorg-a-app
|
||||
org_id: 1
|
||||
org_name: Main Org.
|
||||
disabled: false
|
||||
- type: myorg-b-app
|
||||
org_id: 1
|
||||
org_name: Main Org.
|
||||
disabled: false
|
||||
- type: myorg-extensionpoint-app
|
||||
org_id: 1
|
||||
org_name: Main Org.
|
||||
disabled: false
|
5
e2e/custom-plugins/README.md
Normal file
5
e2e/custom-plugins/README.md
Normal file
@ -0,0 +1,5 @@
|
||||
# Custom plugins
|
||||
|
||||
Plugins in this directory will be installed when the e2e [test server](https://github.com/grafana/grafana/blob/main/scripts/grafana-server/start-server) is started. Optionally, you can provision the plugin by adding configuration to the [datasources.yaml](https://github.com/grafana/grafana/blob/extensions/add-e2e-tests/devenv/datasources.yaml) or to the [plugins.yaml](https://github.com/grafana/grafana/blob/extensions/add-e2e-tests/devenv/plugins.yaml).
|
||||
|
||||
These plugins are not being built as part of CI. Plugins in this directory are being version controlled, so make sure the bundle size is small. Only use dependencies provided by the runtime (see list of runtime dependencies [here](https://github.com/grafana/plugin-tools/blob/08b67179bdbf8847788c54aadb22654aa1a7c060/packages/create-plugin/templates/common/.config/webpack/webpack.config.ts#L36)).
|
12
e2e/custom-plugins/app-with-extension-point/README.md
Normal file
12
e2e/custom-plugins/app-with-extension-point/README.md
Normal file
@ -0,0 +1,12 @@
|
||||
# App with extension point
|
||||
|
||||
This app was initially copied from the [app-with-extension-point](https://github.com/grafana/grafana-plugin-examples/tree/main/examples/app-with-extension-point) example plugin. The plugin bundle is using AMD, but it's not minified and the plugin feature set is small so it should be possible to make changes in this file if necessary.
|
||||
|
||||
To test this app:
|
||||
|
||||
```sh
|
||||
# start e2e test instance (it will install this plugin)
|
||||
PORT=3000 ./scripts/grafana-server/start-server
|
||||
# run Playwright tests using Playwright VSCode extension or with the following script
|
||||
yarn e2e:playwright
|
||||
```
|
141
e2e/custom-plugins/app-with-extension-point/module.js
Normal file
141
e2e/custom-plugins/app-with-extension-point/module.js
Normal file
@ -0,0 +1,141 @@
|
||||
define(['@grafana/data', 'react', '@grafana/ui', '@grafana/runtime'], function (data, React, UI, runtime) {
|
||||
'use strict';
|
||||
|
||||
const styles = {
|
||||
container: 'main-app-body',
|
||||
actions: { button: 'action-button' },
|
||||
modal: { container: 'container', open: 'open-link' },
|
||||
appA: { container: 'a-app-body' },
|
||||
appB: { modal: 'b-app-modal' },
|
||||
};
|
||||
|
||||
function ModalComponent({ onDismiss, title, path }) {
|
||||
return React.createElement(
|
||||
UI.Modal,
|
||||
{ 'data-testid': styles.modal.container, title, isOpen: true, onDismiss },
|
||||
React.createElement(
|
||||
UI.VerticalGroup,
|
||||
{ spacing: 'sm' },
|
||||
React.createElement('p', null, 'Do you want to proceed in the current tab or open a new tab?')
|
||||
),
|
||||
React.createElement(
|
||||
UI.Modal.ButtonRow,
|
||||
null,
|
||||
React.createElement(UI.Button, { onClick: onDismiss, fill: 'outline', variant: 'secondary' }, 'Cancel'),
|
||||
React.createElement(
|
||||
UI.Button,
|
||||
{
|
||||
type: 'submit',
|
||||
variant: 'secondary',
|
||||
onClick: function () {
|
||||
window.open(data.locationUtil.assureBaseUrl(path), '_blank');
|
||||
onDismiss();
|
||||
},
|
||||
icon: 'external-link-alt',
|
||||
},
|
||||
'Open in new tab'
|
||||
),
|
||||
React.createElement(
|
||||
UI.Button,
|
||||
{
|
||||
'data-testid': styles.modal.open,
|
||||
type: 'submit',
|
||||
variant: 'primary',
|
||||
onClick: function () {
|
||||
runtime.locationService.push(path);
|
||||
},
|
||||
icon: 'apps',
|
||||
},
|
||||
'Open'
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ActionComponent({ extensions }) {
|
||||
const options = React.useMemo(
|
||||
function () {
|
||||
return extensions.reduce(function (acc, extension) {
|
||||
if (runtime.isPluginExtensionLink(extension)) {
|
||||
acc.push({ label: extension.title, title: extension.title, value: extension });
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
},
|
||||
[extensions]
|
||||
);
|
||||
|
||||
const [selected, setSelected] = React.useState();
|
||||
|
||||
return options.length === 0
|
||||
? React.createElement(UI.Button, null, 'Run default action')
|
||||
: React.createElement(
|
||||
React.Fragment,
|
||||
null,
|
||||
React.createElement(
|
||||
UI.ButtonGroup,
|
||||
null,
|
||||
React.createElement(
|
||||
UI.ToolbarButton,
|
||||
{
|
||||
key: 'default-action',
|
||||
variant: 'canvas',
|
||||
onClick: function () {
|
||||
alert('You triggered the default action');
|
||||
},
|
||||
},
|
||||
'Run default action'
|
||||
),
|
||||
React.createElement(UI.ButtonSelect, {
|
||||
'data-testid': styles.actions.button,
|
||||
key: 'select-extension',
|
||||
variant: 'canvas',
|
||||
options: options,
|
||||
onChange: function (e) {
|
||||
const extension = e.value;
|
||||
if (runtime.isPluginExtensionLink(extension)) {
|
||||
if (extension.path) setSelected(extension);
|
||||
if (extension.onClick) extension.onClick();
|
||||
}
|
||||
},
|
||||
})
|
||||
),
|
||||
selected &&
|
||||
selected.path &&
|
||||
React.createElement(ModalComponent, {
|
||||
title: selected.title,
|
||||
path: selected.path,
|
||||
onDismiss: function () {
|
||||
setSelected(undefined);
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
class RootComponent extends React.PureComponent {
|
||||
render() {
|
||||
const { extensions } = runtime.getPluginExtensions({
|
||||
extensionPointId: 'plugins/myorg-extensionpoint-app/actions',
|
||||
context: {},
|
||||
});
|
||||
|
||||
return React.createElement(
|
||||
'div',
|
||||
{ 'data-testid': styles.container, style: { marginTop: '5%' } },
|
||||
React.createElement(
|
||||
UI.HorizontalGroup,
|
||||
{ align: 'flex-start', justify: 'center' },
|
||||
React.createElement(
|
||||
UI.HorizontalGroup,
|
||||
null,
|
||||
React.createElement('span', null, 'Hello Grafana! These are the actions you can trigger from this plugin'),
|
||||
React.createElement(ActionComponent, { extensions: extensions })
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const plugin = new data.AppPlugin().setRootPage(RootComponent);
|
||||
return { plugin: plugin };
|
||||
});
|
38
e2e/custom-plugins/app-with-extension-point/plugin.json
Normal file
38
e2e/custom-plugins/app-with-extension-point/plugin.json
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/grafana/grafana/main/docs/sources/developers/plugins/plugin.schema.json",
|
||||
"type": "app",
|
||||
"name": "Extension Point App",
|
||||
"id": "myorg-extensionpoint-app",
|
||||
"preload": true,
|
||||
"info": {
|
||||
"keywords": ["app"],
|
||||
"description": "Show case how to add an extension point to your plugin",
|
||||
"author": {
|
||||
"name": "Myorg"
|
||||
},
|
||||
"logos": {
|
||||
"small": "img/logo.svg",
|
||||
"large": "img/logo.svg"
|
||||
},
|
||||
"screenshots": [],
|
||||
"version": "1.0.0",
|
||||
"updated": "2024-06-11"
|
||||
},
|
||||
"includes": [
|
||||
{
|
||||
"type": "page",
|
||||
"name": "Default",
|
||||
"path": "/a/myorg-extensionpoint-app",
|
||||
"role": "Admin",
|
||||
"addToNav": true,
|
||||
"defaultNav": true
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"grafanaDependency": ">=10.3.3",
|
||||
"plugins": []
|
||||
},
|
||||
"generated": {
|
||||
"extensions": []
|
||||
}
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
define(['@grafana/data', 'react'], function (data, React) {
|
||||
'use strict';
|
||||
|
||||
const styles = {
|
||||
container: 'a-app-body',
|
||||
};
|
||||
|
||||
class RootComponent extends React.PureComponent {
|
||||
render() {
|
||||
return React.createElement(
|
||||
'div',
|
||||
{ 'data-testid': styles.container, className: 'page-container' },
|
||||
'Hello Grafana!'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const plugin = new data.AppPlugin().setRootPage(RootComponent).configureExtensionLink({
|
||||
title: 'Go to A',
|
||||
description: 'Navigating to plugin A',
|
||||
extensionPointId: 'plugins/myorg-extensionpoint-app/actions',
|
||||
path: '/a/myorg-a-app/',
|
||||
});
|
||||
|
||||
return { plugin: plugin };
|
||||
});
|
@ -0,0 +1,45 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/grafana/grafana/main/docs/sources/developers/plugins/plugin.schema.json",
|
||||
"type": "app",
|
||||
"name": "A App",
|
||||
"id": "myorg-a-app",
|
||||
"preload": true,
|
||||
"info": {
|
||||
"keywords": ["app"],
|
||||
"description": "Will extend root app with ui extensions",
|
||||
"author": {
|
||||
"name": "Myorg"
|
||||
},
|
||||
"logos": {
|
||||
"small": "img/logo.svg",
|
||||
"large": "img/logo.svg"
|
||||
},
|
||||
"screenshots": [],
|
||||
"version": "%VERSION%",
|
||||
"updated": "%TODAY%"
|
||||
},
|
||||
"includes": [
|
||||
{
|
||||
"type": "page",
|
||||
"name": "Default",
|
||||
"path": "/a/myorg-a-app",
|
||||
"role": "Admin",
|
||||
"addToNav": false,
|
||||
"defaultNav": false
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"grafanaDependency": ">=10.3.3",
|
||||
"plugins": []
|
||||
},
|
||||
"generated": {
|
||||
"extensions": [
|
||||
{
|
||||
"extensionPointId": "plugins/myorg-extensionpoint-app/actions",
|
||||
"title": "Go to A",
|
||||
"description": "Navigating to pluging A",
|
||||
"type": "link"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
define(['react', '@grafana/data'], function (React, data) {
|
||||
'use strict';
|
||||
|
||||
class RootComponent extends React.PureComponent {
|
||||
render() {
|
||||
return React.createElement('div', { className: 'page-container' }, 'Hello Grafana!');
|
||||
}
|
||||
}
|
||||
|
||||
const modalId = 'b-app-modal';
|
||||
|
||||
const plugin = new data.AppPlugin().setRootPage(RootComponent).configureExtensionLink({
|
||||
title: 'Open from B',
|
||||
description: 'Open a modal from plugin B',
|
||||
extensionPointId: 'plugins/myorg-extensionpoint-app/actions',
|
||||
onClick: function (e, { openModal }) {
|
||||
openModal({
|
||||
title: 'Modal from app B',
|
||||
body: function () {
|
||||
return React.createElement('div', { 'data-testid': modalId }, 'From plugin B');
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return { plugin: plugin };
|
||||
});
|
@ -0,0 +1,45 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/grafana/grafana/main/docs/sources/developers/plugins/plugin.schema.json",
|
||||
"type": "app",
|
||||
"name": "B App",
|
||||
"id": "myorg-b-app",
|
||||
"preload": true,
|
||||
"info": {
|
||||
"keywords": ["app"],
|
||||
"description": "Will extend root app with ui extensions",
|
||||
"author": {
|
||||
"name": "Myorg"
|
||||
},
|
||||
"logos": {
|
||||
"small": "img/logo.svg",
|
||||
"large": "img/logo.svg"
|
||||
},
|
||||
"screenshots": [],
|
||||
"version": "%VERSION%",
|
||||
"updated": "%TODAY%"
|
||||
},
|
||||
"includes": [
|
||||
{
|
||||
"type": "page",
|
||||
"name": "Default",
|
||||
"path": "/a/myorg-b-app",
|
||||
"role": "Admin",
|
||||
"addToNav": false,
|
||||
"defaultNav": false
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"grafanaDependency": ">=10.3.3",
|
||||
"plugins": []
|
||||
},
|
||||
"generated": {
|
||||
"extensions": [
|
||||
{
|
||||
"extensionPointId": "plugins/myorg-extensionpoint-app/actions",
|
||||
"title": "Open from B",
|
||||
"description": "Open a modal from plugin B",
|
||||
"type": "link"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
12
e2e/custom-plugins/app-with-extensions/README.md
Normal file
12
e2e/custom-plugins/app-with-extensions/README.md
Normal file
@ -0,0 +1,12 @@
|
||||
# App with extensions
|
||||
|
||||
This app was initially copied from the [app-with-extensions](https://github.com/grafana/grafana-plugin-examples/tree/main/examples/app-with-extensions) example plugin. The plugin bundle is using AMD, but it's not minified and the plugin feature set is small so it should be possible to make changes in this file if necessary.
|
||||
|
||||
To test this app:
|
||||
|
||||
```sh
|
||||
# start e2e test instance (it will install this plugin)
|
||||
PORT=3000 ./scripts/grafana-server/start-server
|
||||
# run Playwright tests using Playwright VSCode extension or with the following script
|
||||
yarn e2e:playwright
|
||||
```
|
216
e2e/custom-plugins/app-with-extensions/module.js
Normal file
216
e2e/custom-plugins/app-with-extensions/module.js
Normal file
@ -0,0 +1,216 @@
|
||||
define(['react', '@grafana/data', '@grafana/ui', '@grafana/runtime', '@emotion/css', 'rxjs'], function (
|
||||
React,
|
||||
data,
|
||||
ui,
|
||||
runtime,
|
||||
css,
|
||||
rxjs
|
||||
) {
|
||||
'use strict';
|
||||
|
||||
const styles = {
|
||||
modalBody: 'ape-modal-body',
|
||||
mainPageContainer: 'ape-main-page-container',
|
||||
};
|
||||
|
||||
class RootComponent extends React.PureComponent {
|
||||
render() {
|
||||
return React.createElement(
|
||||
'div',
|
||||
{ 'data-testid': styles.mainPageContainer, className: 'page-container' },
|
||||
'Hello Grafana!'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const asyncWrapper = (fn) => {
|
||||
return function () {
|
||||
const gen = fn.apply(this, arguments);
|
||||
return new Promise((resolve, reject) => {
|
||||
function step(key, arg) {
|
||||
let info, value;
|
||||
try {
|
||||
info = gen[key](arg);
|
||||
value = info.value;
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
if (info.done) {
|
||||
resolve(value);
|
||||
} else {
|
||||
Promise.resolve(value).then(next, throw_);
|
||||
}
|
||||
}
|
||||
function next(value) {
|
||||
step('next', value);
|
||||
}
|
||||
function throw_(value) {
|
||||
step('throw', value);
|
||||
}
|
||||
next();
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const getStyles = (theme) => ({
|
||||
colorWeak: css.css`color: ${theme.colors.text.secondary};`,
|
||||
marginTop: css.css`margin-top: ${theme.spacing(3)};`,
|
||||
});
|
||||
|
||||
const updatePlugin = asyncWrapper(function* (pluginId, settings) {
|
||||
const response = runtime
|
||||
.getBackendSrv()
|
||||
.fetch({ url: `/api/plugins/${pluginId}/settings`, method: 'POST', data: settings });
|
||||
return rxjs.lastValueFrom(response);
|
||||
});
|
||||
|
||||
const handleUpdate = asyncWrapper(function* (pluginId, settings) {
|
||||
try {
|
||||
yield updatePlugin(pluginId, settings);
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Error while updating the plugin', error);
|
||||
}
|
||||
});
|
||||
|
||||
const configPageBody = ({ plugin }) => {
|
||||
const styles = getStyles(ui.useStyles2());
|
||||
const { enabled, jsonData } = plugin.meta;
|
||||
return React.createElement(
|
||||
'div',
|
||||
null,
|
||||
React.createElement(ui.Legend, null, 'Enable / Disable '),
|
||||
!enabled &&
|
||||
React.createElement(
|
||||
React.Fragment,
|
||||
null,
|
||||
React.createElement('div', { className: styles.colorWeak }, 'The plugin is currently not enabled.'),
|
||||
React.createElement(
|
||||
ui.Button,
|
||||
{
|
||||
className: styles.marginTop,
|
||||
variant: 'primary',
|
||||
onClick: () => handleUpdate(plugin.meta.id, { enabled: true, pinned: true, jsonData: jsonData }),
|
||||
},
|
||||
'Enable plugin'
|
||||
)
|
||||
),
|
||||
enabled &&
|
||||
React.createElement(
|
||||
React.Fragment,
|
||||
null,
|
||||
React.createElement('div', { className: styles.colorWeak }, 'The plugin is currently enabled.'),
|
||||
React.createElement(
|
||||
ui.Button,
|
||||
{
|
||||
className: styles.marginTop,
|
||||
variant: 'destructive',
|
||||
onClick: () => handleUpdate(plugin.meta.id, { enabled: false, pinned: false, jsonData: jsonData }),
|
||||
},
|
||||
'Disable plugin'
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const selectQueryModal = ({ targets = [], onDismiss }) => {
|
||||
const [selectedQuery, setSelectedQuery] = React.useState(targets[0]);
|
||||
return React.createElement(
|
||||
'div',
|
||||
{ 'data-testid': styles.modalBody },
|
||||
React.createElement(
|
||||
'p',
|
||||
null,
|
||||
'Please select the query you would like to use to create "something" in the plugin.'
|
||||
),
|
||||
React.createElement(
|
||||
ui.HorizontalGroup,
|
||||
null,
|
||||
targets.map((query) =>
|
||||
React.createElement(ui.FilterPill, {
|
||||
key: query.refId,
|
||||
label: query.refId,
|
||||
selected: query.refId === (selectedQuery ? selectedQuery.refId : null),
|
||||
onClick: () => setSelectedQuery(query),
|
||||
})
|
||||
)
|
||||
),
|
||||
React.createElement(
|
||||
ui.Modal.ButtonRow,
|
||||
null,
|
||||
React.createElement(ui.Button, { variant: 'secondary', fill: 'outline', onClick: onDismiss }, 'Cancel'),
|
||||
React.createElement(
|
||||
ui.Button,
|
||||
{
|
||||
disabled: !Boolean(selectedQuery),
|
||||
onClick: () => {
|
||||
onDismiss && onDismiss();
|
||||
alert(`You selected query "${selectedQuery.refId}"`);
|
||||
},
|
||||
},
|
||||
'OK'
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const plugin = new data.AppPlugin()
|
||||
.setRootPage(RootComponent)
|
||||
.addConfigPage({
|
||||
title: 'Configuration',
|
||||
icon: 'cog',
|
||||
body: configPageBody,
|
||||
id: 'configuration',
|
||||
})
|
||||
.configureExtensionLink({
|
||||
title: 'Open from time series or pie charts (path)',
|
||||
description: 'This link will only be visible on time series and pie charts',
|
||||
extensionPointId: data.PluginExtensionPoints.DashboardPanelMenu,
|
||||
path: `/a/myorg-extensions-app/`,
|
||||
configure: (context) => {
|
||||
if (context.dashboard?.title === 'Link Extensions (path)') {
|
||||
switch (context.pluginId) {
|
||||
case 'timeseries':
|
||||
return {};
|
||||
case 'piechart':
|
||||
return { title: `Open from ${context.pluginId}` };
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
.configureExtensionLink({
|
||||
title: 'Open from time series or pie charts (onClick)',
|
||||
description: 'This link will only be visible on time series and pie charts',
|
||||
extensionPointId: data.PluginExtensionPoints.DashboardPanelMenu,
|
||||
onClick: (_, { context, openModal }) => {
|
||||
const targets = context?.targets || [];
|
||||
const title = context?.title;
|
||||
if (!targets.length) return;
|
||||
if (targets.length > 1) {
|
||||
openModal({
|
||||
title: `Select query from "${title}"`,
|
||||
body: (props) => React.createElement(selectQueryModal, { ...props, targets: targets }),
|
||||
});
|
||||
} else {
|
||||
alert(`You selected query "${targets[0].refId}"`);
|
||||
}
|
||||
},
|
||||
configure: (context) => {
|
||||
if (context.dashboard?.title === 'Link Extensions (onClick)') {
|
||||
switch (context.pluginId) {
|
||||
case 'timeseries':
|
||||
return {};
|
||||
case 'piechart':
|
||||
return { title: `Open from ${context.pluginId}` };
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return { plugin: plugin };
|
||||
});
|
49
e2e/custom-plugins/app-with-extensions/plugin.json
Normal file
49
e2e/custom-plugins/app-with-extensions/plugin.json
Normal file
@ -0,0 +1,49 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/grafana/grafana/main/docs/sources/developers/plugins/plugin.schema.json",
|
||||
"type": "app",
|
||||
"name": "Extensions App",
|
||||
"id": "myorg-extensions-app",
|
||||
"preload": true,
|
||||
"info": {
|
||||
"keywords": ["app"],
|
||||
"description": "Example on how to extend grafana ui from a plugin",
|
||||
"author": {
|
||||
"name": "Myorg"
|
||||
},
|
||||
"logos": {
|
||||
"small": "img/logo.svg",
|
||||
"large": "img/logo.svg"
|
||||
},
|
||||
"screenshots": [],
|
||||
"version": "1.0.0",
|
||||
"updated": "2024-06-11"
|
||||
},
|
||||
"includes": [
|
||||
{
|
||||
"type": "page",
|
||||
"name": "Default",
|
||||
"path": "/a/myorg-extensions-app",
|
||||
"role": "Admin",
|
||||
"addToNav": true,
|
||||
"defaultNav": true
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"grafanaDependency": ">=10.3.3",
|
||||
"plugins": []
|
||||
},
|
||||
"extensions": [
|
||||
{
|
||||
"extensionPointId": "grafana/dashboard/panel/menu",
|
||||
"type": "link",
|
||||
"title": "Open from time series or pie charts (path)",
|
||||
"description": "This link will only be visible on time series and pie charts"
|
||||
},
|
||||
{
|
||||
"extensionPointId": "grafana/dashboard/panel/menu",
|
||||
"type": "link",
|
||||
"title": "Open from time series or pie charts (onClick)",
|
||||
"description": "This link will only be visible on time series and pie charts"
|
||||
}
|
||||
]
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
import { test, expect } from '@grafana/plugin-e2e';
|
||||
|
||||
const testIds = {
|
||||
container: 'main-app-body',
|
||||
actions: {
|
||||
button: 'action-button',
|
||||
},
|
||||
modal: {
|
||||
container: 'container',
|
||||
open: 'open-link',
|
||||
},
|
||||
appA: {
|
||||
container: 'a-app-body',
|
||||
},
|
||||
appB: {
|
||||
modal: 'b-app-modal',
|
||||
},
|
||||
};
|
||||
|
||||
const pluginId = 'myorg-extensionpoint-app';
|
||||
|
||||
test('should extend the actions menu with a link to a-app plugin', async ({ page }) => {
|
||||
await page.goto(`/a/${pluginId}/one`);
|
||||
await page.getByTestId(testIds.actions.button).click();
|
||||
await page.getByTestId(testIds.container).getByText('Go to A').click();
|
||||
await page.getByTestId(testIds.modal.open).click();
|
||||
await expect(page.getByTestId(testIds.appA.container)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should extend the actions menu with a command triggered from b-app plugin', async ({ page }) => {
|
||||
await page.goto(`/a/${pluginId}/one`);
|
||||
await page.getByTestId(testIds.actions.button).click();
|
||||
await page.getByTestId(testIds.container).getByText('Open from B').click();
|
||||
await expect(page.getByTestId(testIds.appB.modal)).toBeVisible();
|
||||
});
|
@ -0,0 +1,38 @@
|
||||
import { expect, test } from '@grafana/plugin-e2e';
|
||||
|
||||
const panelTitle = 'Link with defaults';
|
||||
const extensionTitle = 'Open from time series...';
|
||||
const testIds = {
|
||||
modal: {
|
||||
container: 'ape-modal-body',
|
||||
},
|
||||
mainPage: {
|
||||
container: 'ape-main-page-container',
|
||||
},
|
||||
};
|
||||
|
||||
const linkOnClickDashboardUid = 'dbfb47c5-e5e5-4d28-8ac7-35f349b95946';
|
||||
const linkPathDashboardUid = 'd1fbb077-cd44-4738-8c8a-d4e66748b719';
|
||||
|
||||
test('should add link extension (path) with defaults to time series panel', async ({ gotoDashboardPage, page }) => {
|
||||
const dashboardPage = await gotoDashboardPage({ uid: linkPathDashboardUid });
|
||||
const panel = await dashboardPage.getPanelByTitle(panelTitle);
|
||||
await panel.clickOnMenuItem(extensionTitle, { parentItem: 'Extensions' });
|
||||
await expect(page.getByTestId(testIds.mainPage.container)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should add link extension (onclick) with defaults to time series panel', async ({ gotoDashboardPage, page }) => {
|
||||
const dashboardPage = await gotoDashboardPage({ uid: linkOnClickDashboardUid });
|
||||
const panel = await dashboardPage.getPanelByTitle(panelTitle);
|
||||
await panel.clickOnMenuItem(extensionTitle, { parentItem: 'Extensions' });
|
||||
await expect(page.getByRole('dialog')).toContainText('Select query from "Link with defaults"');
|
||||
});
|
||||
|
||||
test('should add link extension (onclick) with new title to pie chart panel', async ({ gotoDashboardPage, page }) => {
|
||||
const panelTitle = 'Link with new name';
|
||||
const extensionTitle = 'Open from piechart';
|
||||
const dashboardPage = await gotoDashboardPage({ uid: linkOnClickDashboardUid });
|
||||
const panel = await dashboardPage.getPanelByTitle(panelTitle);
|
||||
await panel.clickOnMenuItem(extensionTitle, { parentItem: 'Extensions' });
|
||||
await expect(page.getByRole('dialog')).toContainText('Select query from "Link with new name"');
|
||||
});
|
23
e2e/plugin-e2e/start-and-run-suite
Executable file
23
e2e/plugin-e2e/start-and-run-suite
Executable file
@ -0,0 +1,23 @@
|
||||
#!/bin/bash
|
||||
|
||||
. scripts/grafana-server/variables
|
||||
|
||||
LICENSE_PATH=""
|
||||
|
||||
if [ "$1" = "enterprise" ]; then
|
||||
if [ "$2" != "dev" ] && [ "$2" != "debug" ]; then
|
||||
LICENSE_PATH=$2/license.jwt
|
||||
else
|
||||
LICENSE_PATH=$3/license.jwt
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$BASE_URL" != "" ]; then
|
||||
echo -e "BASE_URL set, skipping starting server"
|
||||
else
|
||||
# Start it in the background
|
||||
./scripts/grafana-server/start-server $LICENSE_PATH 2>&1 > scripts/grafana-server/server.log &
|
||||
./scripts/grafana-server/wait-for-grafana
|
||||
fi
|
||||
|
||||
PORT=3001 HOST=localhost yarn playwright test
|
@ -18,8 +18,7 @@
|
||||
"e2e:enterprise:dev": "./e2e/start-and-run-suite enterprise dev",
|
||||
"e2e:enterprise:debug": "./e2e/start-and-run-suite enterprise debug",
|
||||
"e2e:playwright": "yarn playwright test",
|
||||
"e2e:playwright:ui": "yarn playwright test --ui",
|
||||
"e2e:playwright:report": "yarn playwright show-report",
|
||||
"e2e:playwright:server": "./e2e/plugin-e2e/start-and-run-suite",
|
||||
"test": "jest --notify --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"test:coverage:changes": "jest --coverage --changedSince=origin/main",
|
||||
@ -94,6 +93,7 @@
|
||||
"@testing-library/jest-dom": "6.4.2",
|
||||
"@testing-library/react": "15.0.2",
|
||||
"@testing-library/user-event": "14.5.2",
|
||||
"@types/add": "^2",
|
||||
"@types/angular": "1.8.9",
|
||||
"@types/angular-route": "1.7.6",
|
||||
"@types/babel__core": "^7",
|
||||
@ -258,7 +258,7 @@
|
||||
"@grafana/prometheus": "workspace:*",
|
||||
"@grafana/runtime": "workspace:*",
|
||||
"@grafana/saga-icons": "workspace:*",
|
||||
"@grafana/scenes": "4.29.0",
|
||||
"@grafana/scenes": "^5.0.2",
|
||||
"@grafana/schema": "workspace:*",
|
||||
"@grafana/sql": "workspace:*",
|
||||
"@grafana/ui": "workspace:*",
|
||||
@ -398,7 +398,8 @@
|
||||
"uuid": "9.0.1",
|
||||
"visjs-network": "4.25.0",
|
||||
"whatwg-fetch": "3.6.20",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz"
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz",
|
||||
"yarn": "^1.22.22"
|
||||
},
|
||||
"resolutions": {
|
||||
"underscore": "1.13.6",
|
||||
|
@ -1,11 +0,0 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { Button } from './Button';
|
||||
|
||||
describe('Button', () => {
|
||||
it('spins the spinner when specified as an icon', () => {
|
||||
const { container } = render(<Button icon="spinner">Loading...</Button>);
|
||||
expect(container.querySelector('.fa-spin')).toBeInTheDocument();
|
||||
});
|
||||
});
|
@ -6,6 +6,7 @@ import { GrafanaTheme2, isIconName } from '@grafana/data';
|
||||
|
||||
import { useStyles2 } from '../../themes/ThemeContext';
|
||||
import { IconName, IconType, IconSize } from '../../types/icon';
|
||||
import { spin } from '../../utils/keyframes';
|
||||
|
||||
import { getIconRoot, getIconSubDir, getSvgSize } from './utils';
|
||||
|
||||
@ -33,6 +34,11 @@ const getIconStyles = (theme: GrafanaTheme2) => {
|
||||
orange: css({
|
||||
fill: theme.v1.palette.orange,
|
||||
}),
|
||||
spin: css({
|
||||
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
|
||||
animation: `${spin} 2s infinite linear`,
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
@ -58,7 +64,9 @@ export const Icon = React.forwardRef<SVGElement, IconProps>(
|
||||
styles.icon,
|
||||
className,
|
||||
type === 'mono' ? { [styles.orange]: name === 'favorite' } : '',
|
||||
iconName === 'spinner' && 'fa-spin'
|
||||
{
|
||||
[styles.spin]: iconName === 'spinner',
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
|
@ -7,6 +7,7 @@ import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useStyles2 } from '../../themes';
|
||||
import { IconSize, isIconSize } from '../../types';
|
||||
import { t } from '../../utils/i18n';
|
||||
import { spin } from '../../utils/keyframes';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import { getIconRoot, getIconSubDir } from '../Icon/utils';
|
||||
|
||||
@ -46,7 +47,6 @@ export const Spinner = ({
|
||||
// TODO remove once we fully remove the deprecated type
|
||||
if (typeof size !== 'string' || !isIconSize(size)) {
|
||||
const iconRoot = getIconRoot();
|
||||
const iconName = 'spinner';
|
||||
const subDir = getIconSubDir(iconName, 'default');
|
||||
const svgPath = `${iconRoot}${subDir}/${iconName}.svg`;
|
||||
return (
|
||||
@ -65,7 +65,7 @@ export const Spinner = ({
|
||||
src={svgPath}
|
||||
width={size}
|
||||
height={size}
|
||||
className={cx('fa-spin', deprecatedStyles.icon, className)}
|
||||
className={cx(styles.spin, deprecatedStyles.icon, className)}
|
||||
style={style}
|
||||
/>
|
||||
</div>
|
||||
@ -84,12 +84,7 @@ export const Spinner = ({
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cx(
|
||||
{
|
||||
'fa-spin': !prefersReducedMotion,
|
||||
},
|
||||
iconClassName
|
||||
)}
|
||||
className={cx(styles.spin, iconClassName)}
|
||||
name={iconName}
|
||||
size={size}
|
||||
aria-label={t('grafana-ui.spinner.aria-label', 'Loading')}
|
||||
@ -102,6 +97,11 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
inline: css({
|
||||
display: 'inline-block',
|
||||
}),
|
||||
spin: css({
|
||||
[theme.transitions.handleMotion('no-preference')]: {
|
||||
animation: `${spin} 2s infinite linear`,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
// TODO remove once we fully remove the deprecated type
|
||||
|
@ -3,6 +3,7 @@ import React from 'react';
|
||||
|
||||
import { useTheme2 } from '../ThemeContext';
|
||||
|
||||
import { getAccessibilityStyles } from './accessibility';
|
||||
import { getAgularPanelStyles } from './angularPanelStyles';
|
||||
import { getCardStyles } from './card';
|
||||
import { getCodeStyles } from './code';
|
||||
@ -24,6 +25,7 @@ export function GlobalStyles() {
|
||||
return (
|
||||
<Global
|
||||
styles={[
|
||||
getAccessibilityStyles(theme),
|
||||
getCodeStyles(theme),
|
||||
getElementStyles(theme),
|
||||
getExtraStyles(theme),
|
||||
|
18
packages/grafana-ui/src/themes/GlobalStyles/accessibility.ts
Normal file
18
packages/grafana-ui/src/themes/GlobalStyles/accessibility.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { css } from '@emotion/react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
export function getAccessibilityStyles(theme: GrafanaTheme2) {
|
||||
return css({
|
||||
'.sr-only': {
|
||||
position: 'absolute',
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
padding: 0,
|
||||
margin: '-1px',
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0, 0, 0, 0)',
|
||||
border: 0,
|
||||
},
|
||||
});
|
||||
}
|
10
packages/grafana-ui/src/utils/keyframes.ts
Normal file
10
packages/grafana-ui/src/utils/keyframes.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { keyframes } from '@emotion/css';
|
||||
|
||||
export const spin = keyframes({
|
||||
'0%': {
|
||||
transform: 'rotate(0deg)',
|
||||
},
|
||||
'100%': {
|
||||
transform: 'rotate(359deg)',
|
||||
},
|
||||
});
|
@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apimachinery/pkg/runtime/serializer"
|
||||
@ -29,6 +30,7 @@ type APIGroupBuilder interface {
|
||||
codecs serializer.CodecFactory,
|
||||
optsGetter generic.RESTOptionsGetter,
|
||||
desiredMode grafanarest.DualWriterMode,
|
||||
reg prometheus.Registerer,
|
||||
) (*genericapiserver.APIGroupInfo, error)
|
||||
|
||||
// Get OpenAPI definitions
|
||||
|
@ -25,6 +25,7 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/apiserver/endpoints/filters"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/options"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
// TODO: this is a temporary hack to make rest.Connecter work with resource level routes
|
||||
@ -128,6 +129,7 @@ func InstallAPIs(
|
||||
optsGetter generic.RESTOptionsGetter,
|
||||
builders []APIGroupBuilder,
|
||||
storageOpts *options.StorageOptions,
|
||||
reg prometheus.Registerer,
|
||||
) error {
|
||||
// dual writing is only enabled when the storage type is not legacy.
|
||||
// this is needed to support setting a default RESTOptionsGetter for new APIs that don't
|
||||
@ -136,7 +138,7 @@ func InstallAPIs(
|
||||
|
||||
for _, b := range builders {
|
||||
mode := b.GetDesiredDualWriterMode(dualWriteEnabled, storageOpts.DualWriterDesiredModes)
|
||||
g, err := b.GetAPIGroupInfo(scheme, codecs, optsGetter, mode)
|
||||
g, err := b.GetAPIGroupInfo(scheme, codecs, optsGetter, mode, reg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/kvstore"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
@ -95,25 +96,28 @@ const (
|
||||
Mode4
|
||||
)
|
||||
|
||||
// TODO: make this function private as there should only be one public way of setting the dual writing mode
|
||||
// NewDualWriter returns a new DualWriter.
|
||||
func NewDualWriter(mode DualWriterMode, legacy LegacyStorage, storage Storage) DualWriter {
|
||||
func NewDualWriter(mode DualWriterMode, legacy LegacyStorage, storage Storage, reg prometheus.Registerer) DualWriter {
|
||||
metrics := &dualWriterMetrics{}
|
||||
metrics.init(reg)
|
||||
switch mode {
|
||||
// It is not possible to initialize a mode 0 dual writer. Mode 0 represents
|
||||
// writing to legacy storage without `unifiedStorage` enabled.
|
||||
case Mode1:
|
||||
// read and write only from legacy storage
|
||||
return newDualWriterMode1(legacy, storage)
|
||||
return newDualWriterMode1(legacy, storage, metrics)
|
||||
case Mode2:
|
||||
// write to both, read from storage but use legacy as backup
|
||||
return newDualWriterMode2(legacy, storage)
|
||||
return newDualWriterMode2(legacy, storage, metrics)
|
||||
case Mode3:
|
||||
// write to both, read from storage only
|
||||
return newDualWriterMode3(legacy, storage)
|
||||
return newDualWriterMode3(legacy, storage, metrics)
|
||||
case Mode4:
|
||||
// read and write only from storage
|
||||
return newDualWriterMode4(legacy, storage)
|
||||
return newDualWriterMode4(legacy, storage, metrics)
|
||||
default:
|
||||
return newDualWriterMode1(legacy, storage)
|
||||
return newDualWriterMode1(legacy, storage, metrics)
|
||||
}
|
||||
}
|
||||
|
||||
@ -142,6 +146,7 @@ func SetDualWritingMode(
|
||||
storage Storage,
|
||||
entity string,
|
||||
desiredMode DualWriterMode,
|
||||
reg prometheus.Registerer,
|
||||
) (DualWriter, error) {
|
||||
toMode := map[string]DualWriterMode{
|
||||
// It is not possible to initialize a mode 0 dual writer. Mode 0 represents
|
||||
@ -200,5 +205,5 @@ func SetDualWritingMode(
|
||||
|
||||
// #TODO add support for other combinations of desired and current modes
|
||||
|
||||
return NewDualWriter(currentMode, legacy, storage), nil
|
||||
return NewDualWriter(currentMode, legacy, storage, reg), nil
|
||||
}
|
||||
|
@ -24,10 +24,8 @@ const mode1Str = "1"
|
||||
|
||||
// NewDualWriterMode1 returns a new DualWriter in mode 1.
|
||||
// Mode 1 represents writing to and reading from LegacyStorage.
|
||||
func newDualWriterMode1(legacy LegacyStorage, storage Storage) *DualWriterMode1 {
|
||||
metrics := &dualWriterMetrics{}
|
||||
metrics.init()
|
||||
return &DualWriterMode1{Legacy: legacy, Storage: storage, Log: klog.NewKlogr().WithName("DualWriterMode1"), dualWriterMetrics: metrics}
|
||||
func newDualWriterMode1(legacy LegacyStorage, storage Storage, dwm *dualWriterMetrics) *DualWriterMode1 {
|
||||
return &DualWriterMode1{Legacy: legacy, Storage: storage, Log: klog.NewKlogr().WithName("DualWriterMode1"), dualWriterMetrics: dwm}
|
||||
}
|
||||
|
||||
// Mode returns the mode of the dual writer.
|
||||
@ -56,13 +54,12 @@ func (d *DualWriterMode1) Create(ctx context.Context, original runtime.Object, c
|
||||
ctx, cancel := context.WithTimeoutCause(ctx, time.Second*10, errors.New("storage create timeout"))
|
||||
defer cancel()
|
||||
|
||||
objStorage, errEnrichObj := enrichLegacyObject(original, createdCopy, true)
|
||||
if errEnrichObj != nil {
|
||||
if err := enrichLegacyObject(original, createdCopy, true); err != nil {
|
||||
cancel()
|
||||
}
|
||||
|
||||
startStorage := time.Now()
|
||||
_, errObjectSt := d.Storage.Create(ctx, objStorage, createValidation, options)
|
||||
_, errObjectSt := d.Storage.Create(ctx, createdCopy, createValidation, options)
|
||||
d.recordStorageDuration(errObjectSt != nil, mode1Str, options.Kind, method, startStorage)
|
||||
}()
|
||||
|
||||
@ -204,8 +201,7 @@ func (d *DualWriterMode1) Update(ctx context.Context, name string, objInfo rest.
|
||||
|
||||
// if the object is found, create a new updateWrapper with the object found
|
||||
if foundObj != nil {
|
||||
resCopy, err := enrichLegacyObject(foundObj, resCopy, false)
|
||||
if err != nil {
|
||||
if err := enrichLegacyObject(foundObj, resCopy, false); err != nil {
|
||||
log.Error(err, "could not enrich object")
|
||||
cancel()
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
@ -22,6 +23,8 @@ var failingObj = &example.Pod{TypeMeta: metav1.TypeMeta{Kind: "foo"}, ObjectMeta
|
||||
var exampleList = &example.PodList{TypeMeta: metav1.TypeMeta{Kind: "foo"}, ListMeta: metav1.ListMeta{}, Items: []example.Pod{*exampleObj}}
|
||||
var anotherList = &example.PodList{Items: []example.Pod{*anotherObj}}
|
||||
|
||||
var p = prometheus.NewRegistry()
|
||||
|
||||
func TestMode1_Create(t *testing.T) {
|
||||
type testCase struct {
|
||||
input runtime.Object
|
||||
@ -68,7 +71,7 @@ func TestMode1_Create(t *testing.T) {
|
||||
tt.setupStorageFn(m)
|
||||
}
|
||||
|
||||
dw := NewDualWriter(Mode1, ls, us)
|
||||
dw := NewDualWriter(Mode1, ls, us, p)
|
||||
|
||||
obj, err := dw.Create(context.Background(), tt.input, func(context.Context, runtime.Object) error { return nil }, &metav1.CreateOptions{})
|
||||
|
||||
@ -131,7 +134,8 @@ func TestMode1_Get(t *testing.T) {
|
||||
tt.setupStorageFn(m, tt.input)
|
||||
}
|
||||
|
||||
dw := NewDualWriter(Mode1, ls, us)
|
||||
p := prometheus.NewRegistry()
|
||||
dw := NewDualWriter(Mode1, ls, us, p)
|
||||
|
||||
obj, err := dw.Get(context.Background(), tt.input, &metav1.GetOptions{})
|
||||
|
||||
@ -182,7 +186,7 @@ func TestMode1_List(t *testing.T) {
|
||||
tt.setupStorageFn(m)
|
||||
}
|
||||
|
||||
dw := NewDualWriter(Mode1, ls, us)
|
||||
dw := NewDualWriter(Mode1, ls, us, p)
|
||||
|
||||
_, err := dw.List(context.Background(), &metainternalversion.ListOptions{})
|
||||
|
||||
@ -237,7 +241,7 @@ func TestMode1_Delete(t *testing.T) {
|
||||
tt.setupStorageFn(m, tt.input)
|
||||
}
|
||||
|
||||
dw := NewDualWriter(Mode1, ls, us)
|
||||
dw := NewDualWriter(Mode1, ls, us, p)
|
||||
|
||||
obj, _, err := dw.Delete(context.Background(), tt.input, func(ctx context.Context, obj runtime.Object) error { return nil }, &metav1.DeleteOptions{})
|
||||
|
||||
@ -296,7 +300,7 @@ func TestMode1_DeleteCollection(t *testing.T) {
|
||||
tt.setupStorageFn(m, tt.input)
|
||||
}
|
||||
|
||||
dw := NewDualWriter(Mode1, ls, us)
|
||||
dw := NewDualWriter(Mode1, ls, us, p)
|
||||
|
||||
obj, err := dw.DeleteCollection(context.Background(), func(ctx context.Context, obj runtime.Object) error { return nil }, tt.input, &metainternalversion.ListOptions{})
|
||||
|
||||
@ -372,7 +376,7 @@ func TestMode1_Update(t *testing.T) {
|
||||
tt.setupGetFn(m, tt.input)
|
||||
}
|
||||
|
||||
dw := NewDualWriter(Mode1, ls, us)
|
||||
dw := NewDualWriter(Mode1, ls, us, p)
|
||||
|
||||
obj, _, err := dw.Update(context.Background(), tt.input, updatedObjInfoObj{}, func(ctx context.Context, obj runtime.Object) error { return nil }, func(ctx context.Context, obj, old runtime.Object) error { return nil }, false, &metav1.UpdateOptions{})
|
||||
|
||||
|
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion"
|
||||
@ -13,8 +14,6 @@ import (
|
||||
"k8s.io/apimachinery/pkg/selection"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
"k8s.io/klog/v2"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
)
|
||||
|
||||
type DualWriterMode2 struct {
|
||||
@ -28,10 +27,8 @@ const mode2Str = "2"
|
||||
|
||||
// NewDualWriterMode2 returns a new DualWriter in mode 2.
|
||||
// Mode 2 represents writing to LegacyStorage and Storage and reading from LegacyStorage.
|
||||
func newDualWriterMode2(legacy LegacyStorage, storage Storage) *DualWriterMode2 {
|
||||
metrics := &dualWriterMetrics{}
|
||||
metrics.init()
|
||||
return &DualWriterMode2{Legacy: legacy, Storage: storage, Log: klog.NewKlogr().WithName("DualWriterMode2"), dualWriterMetrics: metrics}
|
||||
func newDualWriterMode2(legacy LegacyStorage, storage Storage, dwm *dualWriterMetrics) *DualWriterMode2 {
|
||||
return &DualWriterMode2{Legacy: legacy, Storage: storage, Log: klog.NewKlogr().WithName("DualWriterMode2"), dualWriterMetrics: dwm}
|
||||
}
|
||||
|
||||
// Mode returns the mode of the dual writer.
|
||||
@ -54,9 +51,8 @@ func (d *DualWriterMode2) Create(ctx context.Context, original runtime.Object, c
|
||||
}
|
||||
d.recordLegacyDuration(false, mode2Str, options.Kind, method, startLegacy)
|
||||
|
||||
createdLegacy, err := enrichLegacyObject(original, created, true)
|
||||
if err != nil {
|
||||
return createdLegacy, err
|
||||
if err := enrichLegacyObject(original, created, true); err != nil {
|
||||
return created, err
|
||||
}
|
||||
|
||||
startStorage := time.Now()
|
||||
@ -266,7 +262,7 @@ func (d *DualWriterMode2) Update(ctx context.Context, name string, objInfo rest.
|
||||
|
||||
// if the object is found, create a new updateWrapper with the object found
|
||||
if foundObj != nil {
|
||||
obj, err = enrichLegacyObject(foundObj, obj, false)
|
||||
err = enrichLegacyObject(foundObj, obj, false)
|
||||
if err != nil {
|
||||
return obj, false, err
|
||||
}
|
||||
@ -347,15 +343,15 @@ func parseList(legacyList []runtime.Object) (metainternalversion.ListOptions, ma
|
||||
return options, indexMap, nil
|
||||
}
|
||||
|
||||
func enrichLegacyObject(originalObj, returnedObj runtime.Object, created bool) (runtime.Object, error) {
|
||||
func enrichLegacyObject(originalObj, returnedObj runtime.Object, created bool) error {
|
||||
accessorReturned, err := meta.Accessor(returnedObj)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
|
||||
accessorOriginal, err := meta.Accessor(originalObj)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
|
||||
accessorReturned.SetLabels(accessorOriginal.GetLabels())
|
||||
@ -374,10 +370,10 @@ func enrichLegacyObject(originalObj, returnedObj runtime.Object, created bool) (
|
||||
if created {
|
||||
accessorReturned.SetResourceVersion("")
|
||||
accessorReturned.SetUID("")
|
||||
return returnedObj, nil
|
||||
return nil
|
||||
}
|
||||
// otherwise, we propagate the original RV and UID
|
||||
accessorReturned.SetResourceVersion(accessorOriginal.GetResourceVersion())
|
||||
accessorReturned.SetUID(accessorOriginal.GetUID())
|
||||
return returnedObj, nil
|
||||
return nil
|
||||
}
|
||||
|
@ -67,7 +67,7 @@ func TestMode2_Create(t *testing.T) {
|
||||
tt.setupStorageFn(m, tt.input)
|
||||
}
|
||||
|
||||
dw := NewDualWriter(Mode2, ls, us)
|
||||
dw := NewDualWriter(Mode2, ls, us, p)
|
||||
|
||||
obj, err := dw.Create(context.Background(), tt.input, createFn, &metav1.CreateOptions{})
|
||||
|
||||
@ -143,7 +143,7 @@ func TestMode2_Get(t *testing.T) {
|
||||
tt.setupStorageFn(m, tt.input)
|
||||
}
|
||||
|
||||
dw := NewDualWriter(Mode2, ls, us)
|
||||
dw := NewDualWriter(Mode2, ls, us, p)
|
||||
|
||||
obj, err := dw.Get(context.Background(), tt.input, &metav1.GetOptions{})
|
||||
|
||||
@ -196,7 +196,7 @@ func TestMode2_List(t *testing.T) {
|
||||
tt.setupStorageFn(m)
|
||||
}
|
||||
|
||||
dw := NewDualWriter(Mode2, ls, us)
|
||||
dw := NewDualWriter(Mode2, ls, us, p)
|
||||
|
||||
obj, err := dw.List(context.Background(), &metainternalversion.ListOptions{})
|
||||
|
||||
@ -289,7 +289,7 @@ func TestMode2_Delete(t *testing.T) {
|
||||
tt.setupStorageFn(m, tt.input)
|
||||
}
|
||||
|
||||
dw := NewDualWriter(Mode2, ls, us)
|
||||
dw := NewDualWriter(Mode2, ls, us, p)
|
||||
|
||||
obj, _, err := dw.Delete(context.Background(), tt.input, func(context.Context, runtime.Object) error { return nil }, &metav1.DeleteOptions{})
|
||||
|
||||
@ -361,7 +361,7 @@ func TestMode2_DeleteCollection(t *testing.T) {
|
||||
tt.setupStorageFn(m)
|
||||
}
|
||||
|
||||
dw := NewDualWriter(Mode2, ls, us)
|
||||
dw := NewDualWriter(Mode2, ls, us, p)
|
||||
|
||||
obj, err := dw.DeleteCollection(context.Background(), func(ctx context.Context, obj runtime.Object) error { return nil }, &metav1.DeleteOptions{TypeMeta: metav1.TypeMeta{Kind: tt.input}}, &metainternalversion.ListOptions{})
|
||||
|
||||
@ -469,7 +469,7 @@ func TestMode2_Update(t *testing.T) {
|
||||
tt.setupStorageFn(m, tt.input)
|
||||
}
|
||||
|
||||
dw := NewDualWriter(Mode2, ls, us)
|
||||
dw := NewDualWriter(Mode2, ls, us, p)
|
||||
|
||||
obj, _, err := dw.Update(context.Background(), tt.input, updatedObjInfoObj{}, func(ctx context.Context, obj runtime.Object) error { return nil }, func(ctx context.Context, obj, old runtime.Object) error { return nil }, false, &metav1.UpdateOptions{})
|
||||
|
||||
@ -658,13 +658,13 @@ func TestEnrichReturnedObject(t *testing.T) {
|
||||
|
||||
for _, tt := range testCase {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
returned, err := enrichLegacyObject(tt.inputOriginal, tt.inputReturned, tt.isCreated)
|
||||
err := enrichLegacyObject(tt.inputOriginal, tt.inputReturned, tt.isCreated)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
accessorReturned, err := meta.Accessor(returned)
|
||||
accessorReturned, err := meta.Accessor(tt.inputReturned)
|
||||
assert.NoError(t, err)
|
||||
|
||||
accessorExpected, err := meta.Accessor(tt.expectedObject)
|
||||
|
@ -20,10 +20,8 @@ type DualWriterMode3 struct {
|
||||
|
||||
// newDualWriterMode3 returns a new DualWriter in mode 3.
|
||||
// Mode 3 represents writing to LegacyStorage and Storage and reading from Storage.
|
||||
func newDualWriterMode3(legacy LegacyStorage, storage Storage) *DualWriterMode3 {
|
||||
metrics := &dualWriterMetrics{}
|
||||
metrics.init()
|
||||
return &DualWriterMode3{Legacy: legacy, Storage: storage, Log: klog.NewKlogr().WithName("DualWriterMode3"), dualWriterMetrics: metrics}
|
||||
func newDualWriterMode3(legacy LegacyStorage, storage Storage, dwm *dualWriterMetrics) *DualWriterMode3 {
|
||||
return &DualWriterMode3{Legacy: legacy, Storage: storage, Log: klog.NewKlogr().WithName("DualWriterMode3"), dualWriterMetrics: dwm}
|
||||
}
|
||||
|
||||
// Mode returns the mode of the dual writer.
|
||||
|
@ -19,10 +19,8 @@ type DualWriterMode4 struct {
|
||||
|
||||
// newDualWriterMode4 returns a new DualWriter in mode 4.
|
||||
// Mode 4 represents writing and reading from Storage.
|
||||
func newDualWriterMode4(legacy LegacyStorage, storage Storage) *DualWriterMode4 {
|
||||
metrics := &dualWriterMetrics{}
|
||||
metrics.init()
|
||||
return &DualWriterMode4{Legacy: legacy, Storage: storage, Log: klog.NewKlogr().WithName("DualWriterMode4"), dualWriterMetrics: metrics}
|
||||
func newDualWriterMode4(legacy LegacyStorage, storage Storage, dwm *dualWriterMetrics) *DualWriterMode4 {
|
||||
return &DualWriterMode4{Legacy: legacy, Storage: storage, Log: klog.NewKlogr().WithName("DualWriterMode4"), dualWriterMetrics: dwm}
|
||||
}
|
||||
|
||||
// Mode returns the mode of the dual writer.
|
||||
|
@ -7,6 +7,7 @@ import (
|
||||
|
||||
playlist "github.com/grafana/grafana/pkg/apis/playlist/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/infra/kvstore"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
@ -45,7 +46,8 @@ func TestSetDualWritingMode(t *testing.T) {
|
||||
|
||||
kvStore := kvstore.WithNamespace(kvstore.NewFakeKVStore(), 0, "storage.dualwriting."+tt.stackID)
|
||||
|
||||
dw, err := SetDualWritingMode(context.Background(), kvStore, ls, us, playlist.GROUPRESOURCE, tt.desiredMode)
|
||||
p := prometheus.NewRegistry()
|
||||
dw, err := SetDualWritingMode(context.Background(), kvStore, ls, us, playlist.GROUPRESOURCE, tt.desiredMode, p)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.expectedMode, dw.Mode())
|
||||
|
||||
|
@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"k8s.io/klog/v2"
|
||||
)
|
||||
|
||||
type dualWriterMetrics struct {
|
||||
@ -37,10 +38,17 @@ var DualWriterOutcome = prometheus.NewHistogramVec(prometheus.HistogramOpts{
|
||||
NativeHistogramBucketFactor: 1.1,
|
||||
}, []string{"mode", "name", "method"})
|
||||
|
||||
func (m *dualWriterMetrics) init() {
|
||||
func (m *dualWriterMetrics) init(reg prometheus.Registerer) {
|
||||
log := klog.NewKlogr()
|
||||
m.legacy = DualWriterLegacyDuration
|
||||
m.storage = DualWriterStorageDuration
|
||||
m.outcome = DualWriterOutcome
|
||||
errLegacy := reg.Register(m.legacy)
|
||||
errStorage := reg.Register(m.storage)
|
||||
errOutcome := reg.Register(m.outcome)
|
||||
if errLegacy != nil || errStorage != nil || errOutcome != nil {
|
||||
log.Info("cloud migration metrics already registered")
|
||||
}
|
||||
}
|
||||
|
||||
func (m *dualWriterMetrics) recordLegacyDuration(isError bool, mode string, name string, method string, startFrom time.Time) {
|
||||
|
@ -165,7 +165,7 @@ func (o *APIServerOptions) RunAPIServer(config *genericapiserver.RecommendedConf
|
||||
|
||||
// Install the API Group+version
|
||||
// #TODO figure out how to configure storage type in o.Options.StorageOptions
|
||||
err = builder.InstallAPIs(grafanaAPIServer.Scheme, grafanaAPIServer.Codecs, server, config.RESTOptionsGetter, o.builders, o.Options.StorageOptions)
|
||||
err = builder.InstallAPIs(grafanaAPIServer.Scheme, grafanaAPIServer.Codecs, server, config.RESTOptionsGetter, o.builders, o.Options.StorageOptions, o.Options.MetricsOptions.MetricsRegisterer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -25,6 +25,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/provisioning"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
var _ builder.APIGroupBuilder = (*DashboardsAPIBuilder)(nil)
|
||||
@ -49,6 +50,7 @@ func RegisterAPIService(cfg *setting.Cfg, features featuremgmt.FeatureToggles,
|
||||
accessControl accesscontrol.AccessControl,
|
||||
provisioning provisioning.ProvisioningService,
|
||||
dashStore dashboards.Store,
|
||||
reg prometheus.Registerer,
|
||||
sql db.DB,
|
||||
) *DashboardsAPIBuilder {
|
||||
if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
|
||||
@ -115,6 +117,7 @@ func (b *DashboardsAPIBuilder) GetAPIGroupInfo(
|
||||
codecs serializer.CodecFactory, // pointer?
|
||||
optsGetter generic.RESTOptionsGetter,
|
||||
desiredMode grafanarest.DualWriterMode,
|
||||
reg prometheus.Registerer,
|
||||
) (*genericapiserver.APIGroupInfo, error) {
|
||||
apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(v0alpha1.GROUP, scheme, metav1.ParameterCodec, codecs)
|
||||
|
||||
@ -145,7 +148,7 @@ func (b *DashboardsAPIBuilder) GetAPIGroupInfo(
|
||||
if err := store.CompleteWithOptions(options); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
storage[resourceInfo.StoragePath()] = grafanarest.NewDualWriter(grafanarest.Mode1, legacyStore, store)
|
||||
storage[resourceInfo.StoragePath()] = grafanarest.NewDualWriter(grafanarest.Mode1, legacyStore, store, reg)
|
||||
}
|
||||
|
||||
// Summary
|
||||
|
@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
@ -71,6 +72,7 @@ func RegisterAPIService(
|
||||
cfg *setting.Cfg,
|
||||
features featuremgmt.FeatureToggles,
|
||||
sql db.DB,
|
||||
reg prometheus.Registerer,
|
||||
) *SnapshotsAPIBuilder {
|
||||
if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
|
||||
return nil // skip registration unless opting into experimental apis
|
||||
@ -129,6 +131,7 @@ func (b *SnapshotsAPIBuilder) GetAPIGroupInfo(
|
||||
codecs serializer.CodecFactory, // pointer?
|
||||
optsGetter generic.RESTOptionsGetter,
|
||||
_ grafanarest.DualWriterMode,
|
||||
_ prometheus.Registerer,
|
||||
) (*genericapiserver.APIGroupInfo, error) {
|
||||
apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(dashboardsnapshot.GROUP, scheme, metav1.ParameterCodec, codecs)
|
||||
storage := map[string]rest.Storage{}
|
||||
|
@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
@ -56,6 +57,7 @@ func RegisterAPIService(
|
||||
contextProvider PluginContextWrapper,
|
||||
pluginStore pluginstore.Store,
|
||||
accessControl accesscontrol.AccessControl,
|
||||
reg prometheus.Registerer,
|
||||
) (*DataSourceAPIBuilder, error) {
|
||||
// This requires devmode!
|
||||
if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
|
||||
@ -205,6 +207,7 @@ func (b *DataSourceAPIBuilder) GetAPIGroupInfo(
|
||||
codecs serializer.CodecFactory, // pointer?
|
||||
_ generic.RESTOptionsGetter,
|
||||
_ grafanarest.DualWriterMode,
|
||||
_ prometheus.Registerer,
|
||||
) (*genericapiserver.APIGroupInfo, error) {
|
||||
storage := map[string]rest.Storage{}
|
||||
|
||||
|
@ -25,6 +25,7 @@ import (
|
||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/infra/appcontext"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
var _ builder.APIGroupBuilder = (*TestingAPIBuilder)(nil)
|
||||
@ -41,7 +42,7 @@ func NewTestingAPIBuilder() *TestingAPIBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
func RegisterAPIService(features featuremgmt.FeatureToggles, apiregistration builder.APIRegistrar) *TestingAPIBuilder {
|
||||
func RegisterAPIService(features featuremgmt.FeatureToggles, apiregistration builder.APIRegistrar, reg prometheus.Registerer) *TestingAPIBuilder {
|
||||
if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
|
||||
return nil // skip registration unless opting into experimental apis
|
||||
}
|
||||
@ -92,6 +93,7 @@ func (b *TestingAPIBuilder) GetAPIGroupInfo(
|
||||
codecs serializer.CodecFactory, // pointer?
|
||||
_ generic.RESTOptionsGetter,
|
||||
_ grafanarest.DualWriterMode,
|
||||
_ prometheus.Registerer,
|
||||
) (*genericapiserver.APIGroupInfo, error) {
|
||||
b.codecs = codecs
|
||||
apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(b.gv.Group, scheme, metav1.ParameterCodec, codecs)
|
||||
|
@ -19,6 +19,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
var _ builder.APIGroupBuilder = (*FeatureFlagAPIBuilder)(nil)
|
||||
@ -40,6 +41,7 @@ func RegisterAPIService(features *featuremgmt.FeatureManager,
|
||||
accessControl accesscontrol.AccessControl,
|
||||
apiregistration builder.APIRegistrar,
|
||||
cfg *setting.Cfg,
|
||||
registerer prometheus.Registerer,
|
||||
) *FeatureFlagAPIBuilder {
|
||||
builder := NewFeatureFlagAPIBuilder(features, accessControl, cfg)
|
||||
apiregistration.RegisterAPI(builder)
|
||||
@ -89,6 +91,7 @@ func (b *FeatureFlagAPIBuilder) GetAPIGroupInfo(
|
||||
codecs serializer.CodecFactory, // pointer?
|
||||
_ generic.RESTOptionsGetter,
|
||||
_ grafanarest.DualWriterMode,
|
||||
_ prometheus.Registerer,
|
||||
) (*genericapiserver.APIGroupInfo, error) {
|
||||
apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(v0alpha1.GROUP, scheme, metav1.ParameterCodec, codecs)
|
||||
|
||||
|
@ -27,6 +27,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
var _ builder.APIGroupBuilder = (*FolderAPIBuilder)(nil)
|
||||
@ -47,6 +48,7 @@ func RegisterAPIService(cfg *setting.Cfg,
|
||||
apiregistration builder.APIRegistrar,
|
||||
folderSvc folder.Service,
|
||||
accessControl accesscontrol.AccessControl,
|
||||
registerer prometheus.Registerer,
|
||||
) *FolderAPIBuilder {
|
||||
if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
|
||||
return nil // skip registration unless opting into experimental apis
|
||||
@ -106,6 +108,7 @@ func (b *FolderAPIBuilder) GetAPIGroupInfo(
|
||||
codecs serializer.CodecFactory, // pointer?
|
||||
optsGetter generic.RESTOptionsGetter,
|
||||
desiredMode grafanarest.DualWriterMode,
|
||||
reg prometheus.Registerer,
|
||||
) (*genericapiserver.APIGroupInfo, error) {
|
||||
apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(v0alpha1.GROUP, scheme, metav1.ParameterCodec, codecs)
|
||||
|
||||
@ -145,7 +148,7 @@ func (b *FolderAPIBuilder) GetAPIGroupInfo(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
storage[resourceInfo.StoragePath()] = grafanarest.NewDualWriter(grafanarest.Mode1, legacyStore, store)
|
||||
storage[resourceInfo.StoragePath()] = grafanarest.NewDualWriter(grafanarest.Mode1, legacyStore, store, reg)
|
||||
}
|
||||
|
||||
apiGroupInfo.VersionedResourcesStorageMap[v0alpha1.VERSION] = storage
|
||||
|
@ -17,6 +17,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/apiserver/builder"
|
||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
var _ builder.APIGroupBuilder = (*PeakQAPIBuilder)(nil)
|
||||
@ -28,7 +29,7 @@ func NewPeakQAPIBuilder() *PeakQAPIBuilder {
|
||||
return &PeakQAPIBuilder{}
|
||||
}
|
||||
|
||||
func RegisterAPIService(features featuremgmt.FeatureToggles, apiregistration builder.APIRegistrar) *PeakQAPIBuilder {
|
||||
func RegisterAPIService(features featuremgmt.FeatureToggles, apiregistration builder.APIRegistrar, reg prometheus.Registerer) *PeakQAPIBuilder {
|
||||
if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
|
||||
return nil // skip registration unless opting into experimental apis
|
||||
}
|
||||
@ -73,6 +74,7 @@ func (b *PeakQAPIBuilder) GetAPIGroupInfo(
|
||||
codecs serializer.CodecFactory,
|
||||
optsGetter generic.RESTOptionsGetter,
|
||||
_ grafanarest.DualWriterMode, // dual write desired mode (not relevant)
|
||||
_ prometheus.Registerer, // prometheus registerer
|
||||
) (*genericapiserver.APIGroupInfo, error) {
|
||||
apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(peakq.GROUP, scheme, metav1.ParameterCodec, codecs)
|
||||
|
||||
|
@ -23,6 +23,7 @@ import (
|
||||
gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils"
|
||||
playlistsvc "github.com/grafana/grafana/pkg/services/playlist"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
var _ builder.APIGroupBuilder = (*PlaylistAPIBuilder)(nil)
|
||||
@ -39,12 +40,14 @@ func RegisterAPIService(p playlistsvc.Service,
|
||||
apiregistration builder.APIRegistrar,
|
||||
cfg *setting.Cfg,
|
||||
kvStore kvstore.KVStore,
|
||||
registerer prometheus.Registerer,
|
||||
) *PlaylistAPIBuilder {
|
||||
builder := &PlaylistAPIBuilder{
|
||||
service: p,
|
||||
namespacer: request.GetNamespaceMapper(cfg),
|
||||
gv: playlist.PlaylistResourceInfo.GroupVersion(),
|
||||
kvStore: kvstore.WithNamespace(kvStore, 0, "storage.dualwriting"),
|
||||
// register: newMetrics(registerer),
|
||||
}
|
||||
apiregistration.RegisterAPI(builder)
|
||||
return builder
|
||||
@ -94,6 +97,7 @@ func (b *PlaylistAPIBuilder) GetAPIGroupInfo(
|
||||
codecs serializer.CodecFactory, // pointer?
|
||||
optsGetter generic.RESTOptionsGetter,
|
||||
desiredMode grafanarest.DualWriterMode,
|
||||
reg prometheus.Registerer,
|
||||
) (*genericapiserver.APIGroupInfo, error) {
|
||||
apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(playlist.GROUP, scheme, metav1.ParameterCodec, codecs)
|
||||
storage := map[string]rest.Storage{}
|
||||
@ -133,7 +137,7 @@ func (b *PlaylistAPIBuilder) GetAPIGroupInfo(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dualWriter, err := grafanarest.SetDualWritingMode(context.Background(), b.kvStore, legacyStore, store, playlist.GROUPRESOURCE, desiredMode)
|
||||
dualWriter, err := grafanarest.SetDualWritingMode(context.Background(), b.kvStore, legacyStore, store, playlist.GROUPRESOURCE, desiredMode, reg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
package playlist
|
||||
|
||||
import (
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/registry/generic"
|
||||
genericregistry "k8s.io/apiserver/pkg/registry/generic/registry"
|
||||
@ -41,6 +42,14 @@ func newStorage(scheme *runtime.Scheme, optsGetter generic.RESTOptionsGetter, le
|
||||
|
||||
// Compare asserts on the equality of objects returned from both stores (object storage and legacy storage)
|
||||
func (s *storage) Compare(storageObj, legacyObj runtime.Object) bool {
|
||||
//TODO: define the comparison logic between a playlist returned by the storage and a playlist returned by the legacy storage
|
||||
return false
|
||||
accStr, err := meta.Accessor(storageObj)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
accLegacy, err := meta.Accessor(legacyObj)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return accStr.GetName() == accLegacy.GetName()
|
||||
}
|
||||
|
@ -9,15 +9,15 @@ const (
|
||||
metricsNamespace = "grafana"
|
||||
)
|
||||
|
||||
type metrics struct {
|
||||
type queryMetrics struct {
|
||||
dsRequests *prometheus.CounterVec
|
||||
|
||||
// older metric
|
||||
expressionsQuerySummary *prometheus.SummaryVec
|
||||
}
|
||||
|
||||
func newMetrics(reg prometheus.Registerer) *metrics {
|
||||
m := &metrics{
|
||||
func newQueryMetrics(reg prometheus.Registerer) *queryMetrics {
|
||||
m := &queryMetrics{
|
||||
dsRequests: prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Namespace: metricsNamespace,
|
||||
Subsystem: metricsSubSystem,
|
||||
|
@ -42,7 +42,7 @@ type QueryAPIBuilder struct {
|
||||
features featuremgmt.FeatureToggles
|
||||
|
||||
tracer tracing.Tracer
|
||||
metrics *metrics
|
||||
metrics *queryMetrics
|
||||
parser *queryParser
|
||||
client DataSourceClientSupplier
|
||||
registry query.DataSourceApiServerRegistry
|
||||
@ -81,7 +81,7 @@ func NewQueryAPIBuilder(features featuremgmt.FeatureToggles,
|
||||
client: client,
|
||||
registry: registry,
|
||||
parser: newQueryParser(reader, legacy, tracer),
|
||||
metrics: newMetrics(registerer),
|
||||
metrics: newQueryMetrics(registerer),
|
||||
tracer: tracer,
|
||||
features: features,
|
||||
queryTypes: queryTypes,
|
||||
@ -151,6 +151,7 @@ func (b *QueryAPIBuilder) GetAPIGroupInfo(
|
||||
codecs serializer.CodecFactory, // pointer?
|
||||
optsGetter generic.RESTOptionsGetter,
|
||||
_ grafanarest.DualWriterMode,
|
||||
_ prometheus.Registerer,
|
||||
) (*genericapiserver.APIGroupInfo, error) {
|
||||
gv := query.SchemeGroupVersion
|
||||
apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(gv.Group, scheme, metav1.ParameterCodec, codecs)
|
||||
|
@ -19,6 +19,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/apiserver/builder"
|
||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
var _ builder.APIGroupBuilder = (*ScopeAPIBuilder)(nil)
|
||||
@ -30,7 +31,7 @@ func NewScopeAPIBuilder() *ScopeAPIBuilder {
|
||||
return &ScopeAPIBuilder{}
|
||||
}
|
||||
|
||||
func RegisterAPIService(features featuremgmt.FeatureToggles, apiregistration builder.APIRegistrar) *ScopeAPIBuilder {
|
||||
func RegisterAPIService(features featuremgmt.FeatureToggles, apiregistration builder.APIRegistrar, reg prometheus.Registerer) *ScopeAPIBuilder {
|
||||
if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
|
||||
return nil // skip registration unless opting into experimental apis
|
||||
}
|
||||
@ -121,6 +122,7 @@ func (b *ScopeAPIBuilder) GetAPIGroupInfo(
|
||||
codecs serializer.CodecFactory,
|
||||
optsGetter generic.RESTOptionsGetter,
|
||||
_ grafanarest.DualWriterMode, // dual write desired mode (not relevant)
|
||||
_ prometheus.Registerer, // prometheus registerer
|
||||
) (*genericapiserver.APIGroupInfo, error) {
|
||||
apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(scope.GROUP, scheme, metav1.ParameterCodec, codecs)
|
||||
|
||||
|
@ -15,6 +15,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/apiserver/builder"
|
||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
var _ builder.APIGroupBuilder = (*ServiceAPIBuilder)(nil)
|
||||
@ -26,7 +27,7 @@ func NewServiceAPIBuilder() *ServiceAPIBuilder {
|
||||
return &ServiceAPIBuilder{}
|
||||
}
|
||||
|
||||
func RegisterAPIService(features featuremgmt.FeatureToggles, apiregistration builder.APIRegistrar) *ServiceAPIBuilder {
|
||||
func RegisterAPIService(features featuremgmt.FeatureToggles, apiregistration builder.APIRegistrar, registerer prometheus.Registerer) *ServiceAPIBuilder {
|
||||
if !features.IsEnabledGlobally(featuremgmt.FlagKubernetesAggregator) {
|
||||
return nil // skip registration unless opting into aggregator mode
|
||||
}
|
||||
@ -79,6 +80,7 @@ func (b *ServiceAPIBuilder) GetAPIGroupInfo(
|
||||
codecs serializer.CodecFactory,
|
||||
optsGetter generic.RESTOptionsGetter,
|
||||
_ grafanarest.DualWriterMode,
|
||||
_ prometheus.Registerer,
|
||||
) (*genericapiserver.APIGroupInfo, error) {
|
||||
apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(service.GROUP, scheme, metav1.ParameterCodec, codecs)
|
||||
|
||||
|
@ -24,6 +24,7 @@ import (
|
||||
|
||||
servicev0alpha1 "github.com/grafana/grafana/pkg/apis/service/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/service"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"gopkg.in/yaml.v3"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
@ -172,7 +173,7 @@ func CreateAggregatorConfig(commandOptions *options.Options, sharedConfig generi
|
||||
return NewConfig(aggregatorConfig, sharedInformerFactory, []builder.APIGroupBuilder{serviceAPIBuilder}, remoteServicesConfig), nil
|
||||
}
|
||||
|
||||
func CreateAggregatorServer(config *Config, delegateAPIServer genericapiserver.DelegationTarget) (*aggregatorapiserver.APIAggregator, error) {
|
||||
func CreateAggregatorServer(config *Config, delegateAPIServer genericapiserver.DelegationTarget, reg prometheus.Registerer) (*aggregatorapiserver.APIAggregator, error) {
|
||||
aggregatorConfig := config.KubeAggregatorConfig
|
||||
sharedInformerFactory := config.Informers
|
||||
remoteServicesConfig := config.RemoteServicesConfig
|
||||
@ -285,7 +286,13 @@ func CreateAggregatorServer(config *Config, delegateAPIServer genericapiserver.D
|
||||
})
|
||||
|
||||
for _, b := range config.Builders {
|
||||
serviceAPIGroupInfo, err := b.GetAPIGroupInfo(aggregatorscheme.Scheme, aggregatorscheme.Codecs, aggregatorConfig.GenericConfig.RESTOptionsGetter, grafanarest.Mode0)
|
||||
serviceAPIGroupInfo, err := b.GetAPIGroupInfo(
|
||||
aggregatorscheme.Scheme,
|
||||
aggregatorscheme.Codecs,
|
||||
aggregatorConfig.GenericConfig.RESTOptionsGetter,
|
||||
grafanarest.Mode0,
|
||||
reg,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ import (
|
||||
"path"
|
||||
|
||||
"github.com/grafana/dskit/services"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
@ -24,6 +25,7 @@ import (
|
||||
filestorage "github.com/grafana/grafana/pkg/apiserver/storage/file"
|
||||
"github.com/grafana/grafana/pkg/infra/appcontext"
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/metrics"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/middleware"
|
||||
"github.com/grafana/grafana/pkg/modules"
|
||||
@ -109,6 +111,7 @@ type service struct {
|
||||
builders []builder.APIGroupBuilder
|
||||
|
||||
tracing *tracing.TracingService
|
||||
metrics prometheus.Registerer
|
||||
|
||||
authorizer *authorizer.GrafanaAuthorizer
|
||||
}
|
||||
@ -131,6 +134,7 @@ func ProvideService(
|
||||
authorizer: authorizer.NewGrafanaAuthorizer(cfg, orgService),
|
||||
tracing: tracing,
|
||||
db: db, // For Unified storage
|
||||
metrics: metrics.ProvideRegisterer(),
|
||||
}
|
||||
|
||||
// This will be used when running as a dskit service
|
||||
@ -319,7 +323,7 @@ func (s *service) start(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// Install the API group+version
|
||||
err = builder.InstallAPIs(Scheme, Codecs, server, serverConfig.RESTOptionsGetter, builders, o.StorageOptions)
|
||||
err = builder.InstallAPIs(Scheme, Codecs, server, serverConfig.RESTOptionsGetter, builders, o.StorageOptions, s.metrics)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -329,7 +333,7 @@ func (s *service) start(ctx context.Context) error {
|
||||
|
||||
var runningServer *genericapiserver.GenericAPIServer
|
||||
if s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAggregator) {
|
||||
runningServer, err = s.startAggregator(transport, serverConfig, server)
|
||||
runningServer, err = s.startAggregator(transport, serverConfig, server, s.metrics)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -379,6 +383,7 @@ func (s *service) startAggregator(
|
||||
transport *roundTripperFunc,
|
||||
serverConfig *genericapiserver.RecommendedConfig,
|
||||
server *genericapiserver.GenericAPIServer,
|
||||
reg prometheus.Registerer,
|
||||
) (*genericapiserver.GenericAPIServer, error) {
|
||||
namespaceMapper := request.GetNamespaceMapper(s.cfg)
|
||||
|
||||
@ -387,7 +392,7 @@ func (s *service) startAggregator(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
aggregatorServer, err := aggregator.CreateAggregatorServer(aggregatorConfig, server)
|
||||
aggregatorServer, err := aggregator.CreateAggregatorServer(aggregatorConfig, server, reg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -15,7 +15,7 @@ type MetricsOptions struct {
|
||||
MetricsRegisterer prometheus.Registerer
|
||||
}
|
||||
|
||||
func NewMetrcicsOptions(logger log.Logger) *MetricsOptions {
|
||||
func NewMetricsOptions(logger log.Logger) *MetricsOptions {
|
||||
return &MetricsOptions{
|
||||
logger: logger,
|
||||
}
|
||||
|
@ -25,7 +25,7 @@ func New(logger log.Logger, codec runtime.Codec) *Options {
|
||||
ExtraOptions: options.NewExtraOptions(),
|
||||
RecommendedOptions: options.NewRecommendedOptions(codec),
|
||||
TracingOptions: NewTracingOptions(logger),
|
||||
MetricsOptions: NewMetrcicsOptions(logger),
|
||||
MetricsOptions: NewMetricsOptions(logger),
|
||||
ServerRunOptions: genericoptions.NewServerRunOptions(),
|
||||
StorageOptions: options.NewStorageOptions(),
|
||||
}
|
||||
|
@ -99,30 +99,30 @@ export class UserOrgs extends PureComponent<Props, State> {
|
||||
|
||||
const getOrgRowStyles = stylesFactory((theme: GrafanaTheme2) => {
|
||||
return {
|
||||
removeButton: css`
|
||||
margin-right: 0.6rem;
|
||||
text-decoration: underline;
|
||||
color: ${theme.v1.palette.blue95};
|
||||
`,
|
||||
label: css`
|
||||
font-weight: 500;
|
||||
`,
|
||||
disabledTooltip: css`
|
||||
display: flex;
|
||||
`,
|
||||
tooltipItem: css`
|
||||
margin-left: 5px;
|
||||
`,
|
||||
tooltipItemLink: css`
|
||||
color: ${theme.v1.palette.blue95};
|
||||
`,
|
||||
rolePickerWrapper: css`
|
||||
display: flex;
|
||||
`,
|
||||
rolePicker: css`
|
||||
flex: auto;
|
||||
margin-right: ${theme.spacing(1)};
|
||||
`,
|
||||
removeButton: css({
|
||||
marginRight: '0.6rem',
|
||||
textDecoration: 'underline',
|
||||
color: theme.v1.palette.blue95,
|
||||
}),
|
||||
label: css({
|
||||
fontWeight: 500,
|
||||
}),
|
||||
disabledTooltip: css({
|
||||
display: 'flex',
|
||||
}),
|
||||
tooltipItem: css({
|
||||
marginLeft: '5px',
|
||||
}),
|
||||
tooltipItemLink: css({
|
||||
color: theme.v1.palette.blue95,
|
||||
}),
|
||||
rolePickerWrapper: css({
|
||||
display: 'flex',
|
||||
}),
|
||||
rolePicker: css({
|
||||
flex: 'auto',
|
||||
marginRight: theme.spacing(1),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
@ -222,33 +222,29 @@ class UnThemedOrgRow extends PureComponent<OrgRowProps> {
|
||||
<td className="width-25">{org.role}</td>
|
||||
)}
|
||||
<td colSpan={1}>
|
||||
<div className="pull-right">
|
||||
{canChangeRole && (
|
||||
<ChangeOrgButton
|
||||
lockMessage={lockMessage}
|
||||
isExternalUser={isExternalUser}
|
||||
onChangeRoleClick={this.onChangeRoleClick}
|
||||
onCancelClick={this.onCancelClick}
|
||||
onOrgRoleSave={this.onOrgRoleSave}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{canChangeRole && (
|
||||
<ChangeOrgButton
|
||||
lockMessage={lockMessage}
|
||||
isExternalUser={isExternalUser}
|
||||
onChangeRoleClick={this.onChangeRoleClick}
|
||||
onCancelClick={this.onCancelClick}
|
||||
onOrgRoleSave={this.onOrgRoleSave}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
<td colSpan={1}>
|
||||
<div className="pull-right">
|
||||
{canRemoveFromOrg && (
|
||||
<ConfirmButton
|
||||
confirmText="Confirm removal"
|
||||
confirmVariant="destructive"
|
||||
onCancel={this.onCancelClick}
|
||||
onConfirm={this.onOrgRemove}
|
||||
>
|
||||
Remove from organization
|
||||
</ConfirmButton>
|
||||
)}
|
||||
</div>
|
||||
{canRemoveFromOrg && (
|
||||
<ConfirmButton
|
||||
confirmText="Confirm removal"
|
||||
confirmVariant="destructive"
|
||||
onCancel={this.onCancelClick}
|
||||
onConfirm={this.onOrgRemove}
|
||||
>
|
||||
Remove from organization
|
||||
</ConfirmButton>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@ -258,15 +254,15 @@ class UnThemedOrgRow extends PureComponent<OrgRowProps> {
|
||||
const OrgRow = withTheme2(UnThemedOrgRow);
|
||||
|
||||
const getAddToOrgModalStyles = stylesFactory(() => ({
|
||||
modal: css`
|
||||
width: 500px;
|
||||
`,
|
||||
buttonRow: css`
|
||||
text-align: center;
|
||||
`,
|
||||
modalContent: css`
|
||||
overflow: visible;
|
||||
`,
|
||||
modal: css({
|
||||
width: '500px',
|
||||
}),
|
||||
buttonRow: css({
|
||||
textAlign: 'center',
|
||||
}),
|
||||
modalContent: css({
|
||||
overflow: 'visible',
|
||||
}),
|
||||
}));
|
||||
|
||||
interface AddToOrgModalProps {
|
||||
@ -408,20 +404,20 @@ interface ChangeOrgButtonProps {
|
||||
}
|
||||
|
||||
const getChangeOrgButtonTheme = (theme: GrafanaTheme2) => ({
|
||||
disabledTooltip: css`
|
||||
display: flex;
|
||||
`,
|
||||
tooltipItemLink: css`
|
||||
color: ${theme.v1.palette.blue95};
|
||||
`,
|
||||
lockMessageClass: css`
|
||||
font-style: italic;
|
||||
margin-left: 1.8rem;
|
||||
margin-right: 0.6rem;
|
||||
`,
|
||||
icon: css`
|
||||
line-height: 2;
|
||||
`,
|
||||
disabledTooltip: css({
|
||||
display: 'flex',
|
||||
}),
|
||||
tooltipItemLink: css({
|
||||
color: theme.v1.palette.blue95,
|
||||
}),
|
||||
lockMessageClass: css({
|
||||
fontStyle: 'italic',
|
||||
marginLeft: '1.8rem',
|
||||
marginRight: '0.6rem',
|
||||
}),
|
||||
icon: css({
|
||||
lineHeight: 2,
|
||||
}),
|
||||
});
|
||||
|
||||
export function ChangeOrgButton({
|
||||
@ -511,15 +507,15 @@ export const ExternalUserTooltip = ({ lockMessage }: ExternalUserTooltipProps) =
|
||||
};
|
||||
|
||||
const getTooltipStyles = (theme: GrafanaTheme2) => ({
|
||||
disabledTooltip: css`
|
||||
display: flex;
|
||||
`,
|
||||
tooltipItemLink: css`
|
||||
color: ${theme.v1.palette.blue95};
|
||||
`,
|
||||
lockMessageClass: css`
|
||||
font-style: italic;
|
||||
margin-left: 1.8rem;
|
||||
margin-right: 0.6rem;
|
||||
`,
|
||||
disabledTooltip: css({
|
||||
display: 'flex',
|
||||
}),
|
||||
tooltipItemLink: css({
|
||||
color: theme.v1.palette.blue95,
|
||||
}),
|
||||
lockMessageClass: css({
|
||||
fontStyle: 'italic',
|
||||
marginLeft: '1.8rem',
|
||||
marginRight: '0.6rem',
|
||||
}),
|
||||
});
|
||||
|
@ -72,17 +72,15 @@ class BaseUserSessions extends PureComponent<Props, State> {
|
||||
<td>{session.clientIp}</td>
|
||||
<td>{`${session.browser} on ${session.os} ${session.osVersion}`}</td>
|
||||
<td>
|
||||
<div className="pull-right">
|
||||
{canLogout && (
|
||||
<ConfirmButton
|
||||
confirmText="Confirm logout"
|
||||
confirmVariant="destructive"
|
||||
onConfirm={this.onSessionRevoke(session.id)}
|
||||
>
|
||||
Force logout
|
||||
</ConfirmButton>
|
||||
)}
|
||||
</div>
|
||||
{canLogout && (
|
||||
<ConfirmButton
|
||||
confirmText="Confirm logout"
|
||||
confirmVariant="destructive"
|
||||
onConfirm={this.onSessionRevoke(session.id)}
|
||||
>
|
||||
Force logout
|
||||
</ConfirmButton>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
@ -32,7 +32,7 @@ export const LdapConnectionStatus = ({ ldapConnectionInfo }: Props) => {
|
||||
return serverInfo.cell.value ? (
|
||||
<Stack justifyContent="end">
|
||||
<Tooltip content="Connection is available">
|
||||
<Icon name="check" className="pull-right" />
|
||||
<Icon name="check" />
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
) : (
|
||||
|
74
public/app/features/alerting/unified/Receivers.test.tsx
Normal file
74
public/app/features/alerting/unified/Receivers.test.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import { selectOptionInTest } from 'test/helpers/selectOptionInTest';
|
||||
import { render, screen, waitFor, userEvent } from 'test/test-utils';
|
||||
|
||||
import {
|
||||
EXTERNAL_VANILLA_ALERTMANAGER_UID,
|
||||
setupVanillaAlertmanagerServer,
|
||||
} from 'app/features/alerting/unified/components/settings/__mocks__/server';
|
||||
import { setupMswServer } from 'app/features/alerting/unified/mockApi';
|
||||
import { grantUserPermissions, mockDataSource } from 'app/features/alerting/unified/mocks';
|
||||
import { setupDataSources } from 'app/features/alerting/unified/testSetup/datasources';
|
||||
import { DataSourceType } from 'app/features/alerting/unified/utils/datasource';
|
||||
import { AlertManagerDataSourceJsonData, AlertManagerImplementation } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
|
||||
import ContactPoints from './Receivers';
|
||||
|
||||
import 'core-js/stable/structured-clone';
|
||||
|
||||
const server = setupMswServer();
|
||||
|
||||
const mockDataSources = {
|
||||
[EXTERNAL_VANILLA_ALERTMANAGER_UID]: mockDataSource<AlertManagerDataSourceJsonData>({
|
||||
uid: EXTERNAL_VANILLA_ALERTMANAGER_UID,
|
||||
name: EXTERNAL_VANILLA_ALERTMANAGER_UID,
|
||||
type: DataSourceType.Alertmanager,
|
||||
jsonData: {
|
||||
implementation: AlertManagerImplementation.prometheus,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
grantUserPermissions([
|
||||
AccessControlAction.AlertingNotificationsRead,
|
||||
AccessControlAction.AlertingNotificationsWrite,
|
||||
AccessControlAction.AlertingNotificationsExternalRead,
|
||||
AccessControlAction.AlertingNotificationsExternalWrite,
|
||||
]);
|
||||
});
|
||||
|
||||
it('can save a contact point with a select dropdown', async () => {
|
||||
setupVanillaAlertmanagerServer(server);
|
||||
setupDataSources(mockDataSources[EXTERNAL_VANILLA_ALERTMANAGER_UID]);
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ContactPoints />, {
|
||||
historyOptions: {
|
||||
initialEntries: [`/alerting/notifications/receivers/new?alertmanager=${EXTERNAL_VANILLA_ALERTMANAGER_UID}`],
|
||||
},
|
||||
});
|
||||
|
||||
// Fill out contact point name
|
||||
const contactPointName = await screen.findByPlaceholderText(/name/i);
|
||||
await user.type(contactPointName, 'contact point with select');
|
||||
|
||||
// Select Telegram option (this is we expect the form to contain a dropdown)
|
||||
const integrationDropdown = screen.getByLabelText(/integration/i);
|
||||
await selectOptionInTest(integrationDropdown, /telegram/i);
|
||||
|
||||
// Fill out basic fields necessary for contact point to be saved
|
||||
const botToken = await screen.findByLabelText(/bot token/i);
|
||||
const chatId = await screen.findByLabelText(/chat id/i);
|
||||
|
||||
await user.type(botToken, 'sometoken');
|
||||
await user.type(chatId, '-123');
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /save contact point/i }));
|
||||
|
||||
// TODO: Have a better way to assert that the contact point was saved. This is instead asserting on some
|
||||
// text that's present on the list page, as there's a lot of overlap in text between the form and the list page
|
||||
await waitFor(() => expect(screen.getByText(/search by name or type/i)).toBeInTheDocument(), { timeout: 2000 });
|
||||
});
|
@ -3,7 +3,6 @@ import { Route, Switch } from 'react-router-dom';
|
||||
|
||||
import { withErrorBoundary } from '@grafana/ui';
|
||||
import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport';
|
||||
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
||||
|
||||
import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper';
|
||||
|
||||
@ -12,7 +11,7 @@ const EditContactPoint = SafeDynamicImport(() => import('./components/contact-po
|
||||
const NewContactPoint = SafeDynamicImport(() => import('./components/contact-points/NewContactPoint'));
|
||||
const GlobalConfig = SafeDynamicImport(() => import('./components/contact-points/components/GlobalConfig'));
|
||||
|
||||
const ContactPoints = (_props: GrafanaRouteComponentProps): JSX.Element => (
|
||||
const ContactPoints = (): JSX.Element => (
|
||||
<AlertmanagerPageWrapper navId="receivers" accessType="notification">
|
||||
<Switch>
|
||||
<Route exact={true} path="/alerting/notifications" component={ContactPointsV2} />
|
||||
|
@ -33,11 +33,9 @@ const Label = ({ label, value, icon, color, size = 'md' }: Props) => {
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
{value && (
|
||||
<div className={styles.value} title={value.toString()}>
|
||||
{value}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.value} title={value?.toString()}>
|
||||
{value ?? '-'}
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
|
@ -161,7 +161,7 @@ const OptionInput: FC<Props & { id: string; pathIndex?: string }> = ({
|
||||
)}
|
||||
control={control}
|
||||
name={name}
|
||||
defaultValue={option.defaultValue}
|
||||
defaultValue={option.defaultValue?.value}
|
||||
rules={{
|
||||
validate: {
|
||||
customValidator: (v) => (customValidator ? customValidator(v) : true),
|
||||
|
@ -2,7 +2,13 @@ import { delay, http, HttpResponse } from 'msw';
|
||||
import { SetupServerApi } from 'msw/lib/node';
|
||||
|
||||
import { setDataSourceSrv } from '@grafana/runtime';
|
||||
import { AlertManagerDataSourceJsonData, AlertManagerImplementation } from 'app/plugins/datasource/alertmanager/types';
|
||||
import {
|
||||
AlertManagerCortexConfig,
|
||||
AlertManagerDataSourceJsonData,
|
||||
AlertManagerImplementation,
|
||||
AlertmanagerReceiver,
|
||||
Receiver,
|
||||
} from 'app/plugins/datasource/alertmanager/types';
|
||||
|
||||
import { mockDataSource, MockDataSourceSrv } from '../../../mocks';
|
||||
import * as config from '../../../utils/config';
|
||||
@ -70,7 +76,7 @@ export function setupVanillaAlertmanagerServer(server: SetupServerApi) {
|
||||
|
||||
server.use(
|
||||
createVanillaAlertmanagerConfigurationHandler(EXTERNAL_VANILLA_ALERTMANAGER_UID),
|
||||
...createAlertmanagerConfigurationHandlers(PROVISIONED_MIMIR_ALERTMANAGER_UID)
|
||||
...createAlertmanagerConfigurationHandlers()
|
||||
);
|
||||
|
||||
return server;
|
||||
@ -82,11 +88,33 @@ const createExternalAlertmanagersHandler = () => {
|
||||
return http.get('/api/v1/ngalert/alertmanagers', () => HttpResponse.json(alertmanagers));
|
||||
};
|
||||
|
||||
const createAlertmanagerConfigurationHandlers = (name = 'grafana') => {
|
||||
const createAlertmanagerConfigurationHandlers = () => {
|
||||
// Dirty check to type guard against us having a non-Grafana managed receiver
|
||||
const contactPointIsAMReceiver = (receiver: Receiver): receiver is AlertmanagerReceiver => {
|
||||
return !receiver.grafana_managed_receiver_configs;
|
||||
};
|
||||
|
||||
return [
|
||||
http.get(`/api/alertmanager/${name}/config/api/v1/alerts`, () => HttpResponse.json(internalAlertmanagerConfig)),
|
||||
http.post(`/api/alertmanager/${name}/config/api/v1/alerts`, async () => {
|
||||
http.get(`/api/alertmanager/:name/config/api/v1/alerts`, () => HttpResponse.json(internalAlertmanagerConfig)),
|
||||
http.post<never, AlertManagerCortexConfig>(`/api/alertmanager/:name/config/api/v1/alerts`, async ({ request }) => {
|
||||
await delay(1000); // simulate some time
|
||||
|
||||
// Specifically mock and check for the case of an invalid telegram config,
|
||||
// and return a 400 error in this case
|
||||
// This is to test against us accidentally sending a `{label, value}` object instead of a string
|
||||
const body = await request.json();
|
||||
const invalidConfig = body.alertmanager_config.receivers?.some((receiver) => {
|
||||
if (!contactPointIsAMReceiver(receiver)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (receiver.telegram_configs || []).some((config) => typeof config.parse_mode === 'object');
|
||||
});
|
||||
|
||||
if (invalidConfig) {
|
||||
return HttpResponse.json({ message: 'bad request data' }, { status: 400 });
|
||||
}
|
||||
|
||||
return HttpResponse.json({ message: 'configuration created' });
|
||||
}),
|
||||
];
|
||||
|
@ -21,7 +21,6 @@ const allHandlers = [
|
||||
...folderHandlers,
|
||||
...pluginsHandlers,
|
||||
...silenceHandlers,
|
||||
...alertRuleHandlers,
|
||||
];
|
||||
|
||||
export default allHandlers;
|
||||
|
@ -1,75 +0,0 @@
|
||||
import { http, HttpResponse } from 'msw';
|
||||
|
||||
import {
|
||||
RulerGrafanaRuleDTO,
|
||||
RulerRuleGroupDTO,
|
||||
RulerRulesConfigDTO,
|
||||
} from '../../../../../../types/unified-alerting-dto';
|
||||
import { grafanaRulerRule, namespaceByUid, namespaces } from '../../alertRuleApi';
|
||||
|
||||
export const rulerRulesHandler = () => {
|
||||
return http.get(`/api/ruler/grafana/api/v1/rules`, () => {
|
||||
const response = Object.entries(namespaces).reduce<RulerRulesConfigDTO>((acc, [namespaceUid, groups]) => {
|
||||
acc[namespaceByUid[namespaceUid].name] = groups;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return HttpResponse.json<RulerRulesConfigDTO>(response);
|
||||
});
|
||||
};
|
||||
|
||||
export const rulerRuleNamespaceHandler = () => {
|
||||
return http.get<{ folderUid: string }>(`/api/ruler/grafana/api/v1/rules/:folderUid`, ({ params: { folderUid } }) => {
|
||||
// This mimic API response as closely as possible - Invalid folderUid returns 403
|
||||
const namespace = namespaces[folderUid];
|
||||
if (!namespace) {
|
||||
return new HttpResponse(null, { status: 403 });
|
||||
}
|
||||
|
||||
return HttpResponse.json<RulerRulesConfigDTO>({
|
||||
[namespaceByUid[folderUid].name]: namespaces[folderUid],
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const rulerRuleGroupHandler = () => {
|
||||
return http.get<{ folderUid: string; groupName: string }>(
|
||||
`/api/ruler/grafana/api/v1/rules/:folderUid/:groupName`,
|
||||
({ params: { folderUid, groupName } }) => {
|
||||
// This mimic API response as closely as possible.
|
||||
// Invalid folderUid returns 403 but invalid group will return 202 with empty list of rules
|
||||
const namespace = namespaces[folderUid];
|
||||
if (!namespace) {
|
||||
return new HttpResponse(null, { status: 403 });
|
||||
}
|
||||
|
||||
const matchingGroup = namespace.find((group) => group.name === groupName);
|
||||
return HttpResponse.json<RulerRuleGroupDTO>({
|
||||
name: groupName,
|
||||
interval: matchingGroup?.interval,
|
||||
rules: matchingGroup?.rules ?? [],
|
||||
});
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const getAlertRuleHandler = () => {
|
||||
const grafanaRules = new Map<string, RulerGrafanaRuleDTO>(
|
||||
[grafanaRulerRule].map((rule) => [rule.grafana_alert.uid, rule])
|
||||
);
|
||||
|
||||
return http.get<{ uid: string }>(`/api/ruler/grafana/api/v1/rule/:uid`, ({ params: { uid } }) => {
|
||||
const rule = grafanaRules.get(uid);
|
||||
if (!rule) {
|
||||
return new HttpResponse(null, { status: 404 });
|
||||
}
|
||||
return HttpResponse.json(rule);
|
||||
});
|
||||
};
|
||||
|
||||
export const alertRuleHandlers = [
|
||||
rulerRulesHandler(),
|
||||
rulerRuleNamespaceHandler(),
|
||||
rulerRuleGroupHandler(),
|
||||
getAlertRuleHandler(),
|
||||
];
|
@ -2,6 +2,7 @@
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
|
||||
import { PageLayoutType } from '@grafana/data';
|
||||
import { UrlSyncContextProvider } from '@grafana/scenes';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import PageLoader from 'app/core/components/PageLoader/PageLoader';
|
||||
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
||||
@ -78,10 +79,10 @@ export function DashboardScenePage({ match, route, queryParams, history }: Props
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<UrlSyncContextProvider scene={dashboard}>
|
||||
<dashboard.Component model={dashboard} key={dashboard.state.key} />
|
||||
<DashboardPrompt dashboard={dashboard} />
|
||||
</>
|
||||
</UrlSyncContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { advanceBy } from 'jest-date-mock';
|
||||
|
||||
import { BackendSrv, locationService, setBackendSrv } from '@grafana/runtime';
|
||||
import { getUrlSyncManager } from '@grafana/scenes';
|
||||
import { BackendSrv, setBackendSrv } from '@grafana/runtime';
|
||||
import store from 'app/core/store';
|
||||
import { DASHBOARD_FROM_LS_KEY } from 'app/features/dashboard/state/initDashboard';
|
||||
import { DashboardRoutes } from 'app/types';
|
||||
@ -95,40 +94,6 @@ describe('DashboardScenePageStateManager', () => {
|
||||
expect(loader.state.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('should initialize url sync', async () => {
|
||||
setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} });
|
||||
|
||||
locationService.partial({ from: 'now-5m', to: 'now' });
|
||||
|
||||
const loader = new DashboardScenePageStateManager({});
|
||||
await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
|
||||
const dash = loader.state.dashboard;
|
||||
|
||||
expect(dash!.state.$timeRange?.state.from).toEqual('now-5m');
|
||||
|
||||
getUrlSyncManager().cleanUp(dash!);
|
||||
|
||||
// try loading again (and hitting cache)
|
||||
locationService.partial({ from: 'now-10m', to: 'now' });
|
||||
|
||||
await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
|
||||
const dash2 = loader.state.dashboard;
|
||||
|
||||
expect(dash2!.state.$timeRange?.state.from).toEqual('now-10m');
|
||||
});
|
||||
|
||||
it('should not initialize url sync for embedded dashboards', async () => {
|
||||
setupLoadDashboardMock({ dashboard: { uid: 'fake-dash' }, meta: {} });
|
||||
|
||||
locationService.partial({ from: 'now-5m', to: 'now' });
|
||||
|
||||
const loader = new DashboardScenePageStateManager({});
|
||||
await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Embedded });
|
||||
const dash = loader.state.dashboard;
|
||||
|
||||
expect(dash!.state.$timeRange?.state.from).toEqual('now-6h');
|
||||
});
|
||||
|
||||
describe('New dashboards', () => {
|
||||
it('Should have new empty model with meta.isNew and should not be cached', async () => {
|
||||
const loader = new DashboardScenePageStateManager({});
|
||||
|
@ -182,10 +182,6 @@ export class DashboardScenePageStateManager extends StateManagerBase<DashboardSc
|
||||
restoreDashboardStateFromLocalStorage(dashboard);
|
||||
}
|
||||
|
||||
if (!(config.publicDashboardAccessToken && dashboard.state.controls?.state.hideTimeControls)) {
|
||||
dashboard.startUrlSync();
|
||||
}
|
||||
|
||||
this.setState({ dashboard: dashboard, isLoading: false });
|
||||
const measure = stopMeasure(LOAD_SCENE_MEASUREMENT);
|
||||
trackDashboardSceneLoaded(dashboard, measure?.duration);
|
||||
|
@ -3,7 +3,7 @@ import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { GrafanaTheme2, PageLayoutType } from '@grafana/data';
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
|
||||
import { SceneComponentProps } from '@grafana/scenes';
|
||||
import { SceneComponentProps, UrlSyncContextProvider } from '@grafana/scenes';
|
||||
import { Icon, Stack, useStyles2 } from '@grafana/ui';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import PageLoader from 'app/core/components/PageLoader/PageLoader';
|
||||
@ -55,7 +55,16 @@ export function PublicDashboardScenePage({ match, route }: Props) {
|
||||
return <PublicDashboardNotAvailable />;
|
||||
}
|
||||
|
||||
return <PublicDashboardSceneRenderer model={dashboard} />;
|
||||
// if no time picker render without url sync
|
||||
if (dashboard.state.controls?.state.hideTimeControls) {
|
||||
return <PublicDashboardSceneRenderer model={dashboard} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<UrlSyncContextProvider scene={dashboard}>
|
||||
<PublicDashboardSceneRenderer model={dashboard} />
|
||||
</UrlSyncContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function PublicDashboardSceneRenderer({ model }: SceneComponentProps<DashboardScene>) {
|
||||
|
@ -61,10 +61,7 @@ export function useSaveDashboard(isCopy = false) {
|
||||
|
||||
if (newUrl !== currentLocation.pathname) {
|
||||
setTimeout(() => {
|
||||
// Because the path changes we need to stop and restart url sync
|
||||
scene.stopUrlSync();
|
||||
locationService.push({ pathname: newUrl, search: currentLocation.search });
|
||||
scene.startUrlSync();
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -12,7 +12,6 @@ import {
|
||||
} from '@grafana/data';
|
||||
import { config, locationService } from '@grafana/runtime';
|
||||
import {
|
||||
getUrlSyncManager,
|
||||
SceneFlexLayout,
|
||||
sceneGraph,
|
||||
SceneGridLayout,
|
||||
@ -212,27 +211,15 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
|
||||
window.__grafanaSceneContext = prevSceneContext;
|
||||
clearKeyBindings();
|
||||
this._changeTracker.terminate();
|
||||
this.stopUrlSync();
|
||||
oldDashboardWrapper.destroy();
|
||||
dashboardWatcher.leave();
|
||||
};
|
||||
}
|
||||
|
||||
public startUrlSync() {
|
||||
if (!this.state.meta.isEmbedded) {
|
||||
getUrlSyncManager().initSync(this);
|
||||
}
|
||||
}
|
||||
|
||||
public stopUrlSync() {
|
||||
getUrlSyncManager().cleanUp(this);
|
||||
}
|
||||
|
||||
public onEnterEditMode = (fromExplore = false) => {
|
||||
this._fromExplore = fromExplore;
|
||||
// Save this state
|
||||
this._initialState = sceneUtils.cloneSceneObjectState(this.state);
|
||||
|
||||
this._initialUrlState = locationService.getLocation();
|
||||
|
||||
// Switch to edit mode
|
||||
@ -303,10 +290,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
|
||||
private exitEditModeConfirmed(restoreInitialState = true) {
|
||||
// No need to listen to changes anymore
|
||||
this._changeTracker.stopTrackingChanges();
|
||||
// Stop url sync before updating url
|
||||
this.stopUrlSync();
|
||||
|
||||
// Now we can update urls
|
||||
// We are updating url and removing editview and editPanel.
|
||||
// The initial url may be including edit view, edit panel or inspect query params if the user pasted the url,
|
||||
// hence we need to cleanup those query params to get back to the dashboard view. Otherwise url sync can trigger overlays.
|
||||
@ -330,8 +314,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
|
||||
// Do not restore
|
||||
this.setState({ isEditing: false });
|
||||
}
|
||||
// and start url sync again
|
||||
this.startUrlSync();
|
||||
|
||||
// Disable grid dragging
|
||||
this.propagateEditModeChange();
|
||||
}
|
||||
|
@ -5,8 +5,8 @@ import { TestProvider } from 'test/helpers/TestProvider';
|
||||
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { SceneGridLayout, SceneQueryRunner, SceneTimeRange, VizPanel } from '@grafana/scenes';
|
||||
import { config, locationService } from '@grafana/runtime';
|
||||
import { SceneGridLayout, SceneQueryRunner, SceneTimeRange, UrlSyncContextProvider, VizPanel } from '@grafana/scenes';
|
||||
import { playlistSrv } from 'app/features/playlist/PlaylistSrv';
|
||||
|
||||
import { buildPanelEditScene } from '../panel-edit/PanelEditor';
|
||||
@ -103,9 +103,8 @@ describe('NavToolbarActions', () => {
|
||||
});
|
||||
|
||||
it('Should show correct buttons when in settings menu', async () => {
|
||||
const { dashboard } = setup();
|
||||
setup();
|
||||
|
||||
dashboard.startUrlSync();
|
||||
await userEvent.click(await screen.findByText('Edit'));
|
||||
await userEvent.click(await screen.findByText('Settings'));
|
||||
|
||||
@ -118,6 +117,7 @@ describe('NavToolbarActions', () => {
|
||||
|
||||
it('Should show correct buttons when editing a new panel', async () => {
|
||||
const { dashboard } = setup();
|
||||
|
||||
await act(() => {
|
||||
dashboard.onEnterEditMode();
|
||||
const editingPanel = ((dashboard.state.body as SceneGridLayout).state.children[0] as DashboardGridItem).state
|
||||
@ -205,9 +205,13 @@ function setup() {
|
||||
|
||||
const context = getGrafanaContextMock();
|
||||
|
||||
locationService.push('/');
|
||||
|
||||
render(
|
||||
<TestProvider grafanaContext={context}>
|
||||
<ToolbarActions dashboard={dashboard} />
|
||||
<UrlSyncContextProvider scene={dashboard}>
|
||||
<ToolbarActions dashboard={dashboard} />
|
||||
</UrlSyncContextProvider>
|
||||
</TestProvider>
|
||||
);
|
||||
|
||||
|
@ -5,7 +5,7 @@ import { SelectableValue } from '@grafana/data';
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
|
||||
import { getBackendSrv } from '@grafana/runtime';
|
||||
import { SceneComponentProps, sceneGraph, SceneObjectBase, SceneObjectRef, VizPanel } from '@grafana/scenes';
|
||||
import { Button, ClipboardButton, Field, Input, Modal, RadioButtonGroup } from '@grafana/ui';
|
||||
import { Button, ClipboardButton, Field, Input, Modal, RadioButtonGroup, Stack } from '@grafana/ui';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
import { getTrackingSource, shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils';
|
||||
import { getDashboardSnapshotSrv, SnapshotSharingOptions } from 'app/features/dashboard/services/SnapshotSrv';
|
||||
@ -231,7 +231,7 @@ function ShareSnapshoTabRenderer({ model }: SceneComponentProps<ShareSnapshotTab
|
||||
|
||||
{/* When snapshot has been created - show link and allow copy/deletion */}
|
||||
{snapshotResult.value && (
|
||||
<>
|
||||
<Stack direction="column" gap={0}>
|
||||
<Field label={t('share-modal.snapshot.url-label', 'Snapshot URL')}>
|
||||
<Input
|
||||
data-testid={selectors.CopyUrlInput}
|
||||
@ -251,7 +251,7 @@ function ShareSnapshoTabRenderer({ model }: SceneComponentProps<ShareSnapshotTab
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="pull-right" style={{ padding: '5px' }}>
|
||||
<div style={{ alignSelf: 'flex-end', padding: '5px' }}>
|
||||
<Trans i18nKey="share-modal.snapshot.mistake-message">Did you make a mistake? </Trans>
|
||||
<Button
|
||||
fill="outline"
|
||||
@ -264,7 +264,7 @@ function ShareSnapshoTabRenderer({ model }: SceneComponentProps<ShareSnapshotTab
|
||||
<Trans i18nKey="share-modal.snapshot.delete-button">Delete snapshot.</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</Stack>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { config, locationService } from '@grafana/runtime';
|
||||
import { CustomVariable } from '@grafana/scenes';
|
||||
import { CustomVariable, getUrlSyncManager } from '@grafana/scenes';
|
||||
import { DashboardDataDTO } from 'app/types';
|
||||
|
||||
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
|
||||
@ -50,7 +50,8 @@ describe('dashboardSessionState', () => {
|
||||
restoreDashboardStateFromLocalStorage(scene);
|
||||
const variable = scene.state.$variables!.getByName('customVar') as CustomVariable;
|
||||
const timeRange = scene.state.$timeRange;
|
||||
scene.startUrlSync();
|
||||
|
||||
getUrlSyncManager().initSync(scene);
|
||||
|
||||
expect(variable!.state!.value).toEqual(['b']);
|
||||
expect(variable!.state!.text).toEqual(['b']);
|
||||
@ -63,11 +64,12 @@ describe('dashboardSessionState', () => {
|
||||
PRESERVED_SCENE_STATE_KEY,
|
||||
'?var-customVar=b&var-nonApplicableVar=b&from=now-5m&to=now&timezone=browser'
|
||||
);
|
||||
|
||||
const scene = buildTestScene();
|
||||
|
||||
restoreDashboardStateFromLocalStorage(scene);
|
||||
|
||||
expect(locationService.getSearch().toString()).toBe('var-customVar=b&from=now-5m&to=now&timezone=browser');
|
||||
expect(locationService.getLocation().search).toBe('?var-customVar=b&from=now-5m&to=now&timezone=browser');
|
||||
});
|
||||
|
||||
// handles case when user navigates back to a dashboard with the same state, i.e. using back button
|
||||
@ -79,7 +81,7 @@ describe('dashboardSessionState', () => {
|
||||
|
||||
restoreDashboardStateFromLocalStorage(scene);
|
||||
|
||||
expect(locationService.getSearch().toString()).toBe('var-customVar=b&from=now-6h&to=now&timezone=browser');
|
||||
expect(locationService.getLocation().search).toBe('?var-customVar=b&from=now-6h&to=now&timezone=browser');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -116,6 +118,7 @@ function buildTestScene() {
|
||||
version: 24,
|
||||
weekStart: '',
|
||||
};
|
||||
|
||||
const scene = transformSaveModelToScene({ dashboard: testDashboard, meta: {} });
|
||||
|
||||
// Removing data layers to avoid mocking built-in Grafana data source
|
||||
|
@ -17,10 +17,6 @@ export function restoreDashboardStateFromLocalStorage(dashboard: DashboardScene)
|
||||
preservedQueryParams.forEach((value, key) => {
|
||||
if (!currentQueryParams.has(key)) {
|
||||
currentQueryParams.append(key, value);
|
||||
} else {
|
||||
if (!currentQueryParams.getAll(key).includes(value)) {
|
||||
currentQueryParams.append(key, value);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -38,9 +34,7 @@ export function restoreDashboardStateFromLocalStorage(dashboard: DashboardScene)
|
||||
|
||||
const finalParams = currentQueryParams.toString();
|
||||
if (finalParams) {
|
||||
locationService.replace({
|
||||
search: finalParams,
|
||||
});
|
||||
locationService.replace({ search: finalParams });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -2,7 +2,7 @@ import React, { PureComponent } from 'react';
|
||||
|
||||
import { isEmptyObject, SelectableValue, VariableRefresh } from '@grafana/data';
|
||||
import { getBackendSrv } from '@grafana/runtime';
|
||||
import { Button, ClipboardButton, Field, Input, LinkButton, Modal, Select, Spinner } from '@grafana/ui';
|
||||
import { Button, ClipboardButton, Field, Input, LinkButton, Modal, Select, Spinner, Stack } from '@grafana/ui';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
||||
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
|
||||
@ -290,7 +290,7 @@ export class ShareSnapshot extends PureComponent<Props, State> {
|
||||
const { snapshotUrl } = this.state;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack direction="column" gap={0}>
|
||||
<Field label={t('share-modal.snapshot.url-label', 'Snapshot URL')}>
|
||||
<Input
|
||||
id="snapshot-url-input"
|
||||
@ -304,13 +304,13 @@ export class ShareSnapshot extends PureComponent<Props, State> {
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="pull-right" style={{ padding: '5px' }}>
|
||||
<div style={{ alignSelf: 'flex-end', padding: '5px' }}>
|
||||
<Trans i18nKey="share-modal.snapshot.mistake-message">Did you make a mistake? </Trans>
|
||||
<LinkButton fill="text" target="_blank" onClick={this.deleteSnapshot}>
|
||||
<Trans i18nKey="share-modal.snapshot.delete-button">Delete snapshot.</Trans>
|
||||
</LinkButton>
|
||||
</div>
|
||||
</>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -83,9 +83,8 @@ export function RootView({ root, onPathChange }: Props) {
|
||||
<InlineField grow>
|
||||
<FilterInput placeholder="Search Storage" value={searchQuery} onChange={setSearchQuery} />
|
||||
</InlineField>
|
||||
<Button className="pull-right" onClick={() => onPathChange('', StorageView.AddRoot)}>
|
||||
Add Root
|
||||
</Button>
|
||||
<div className="page-action-bar__spacer" />
|
||||
<Button onClick={() => onPathChange('', StorageView.AddRoot)}>Add Root</Button>
|
||||
</div>
|
||||
|
||||
<div>{renderRoots('', roots.base)}</div>
|
||||
@ -100,12 +99,12 @@ export function RootView({ root, onPathChange }: Props) {
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
secondaryTextColor: css`
|
||||
color: ${theme.colors.text.secondary};
|
||||
`,
|
||||
clickable: css`
|
||||
pointer-events: none;
|
||||
`,
|
||||
secondaryTextColor: css({
|
||||
color: theme.colors.text.secondary,
|
||||
}),
|
||||
clickable: css({
|
||||
pointerEvents: 'none',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -121,7 +121,7 @@ export class TeamGroupSync extends PureComponent<Props, State> {
|
||||
)}
|
||||
<div className="page-action-bar__spacer" />
|
||||
{groups.length > 0 && (
|
||||
<Button className="pull-right" onClick={this.onToggleAdding} disabled={isReadOnly}>
|
||||
<Button onClick={this.onToggleAdding} disabled={isReadOnly}>
|
||||
<Icon name="plus" /> Add group
|
||||
</Button>
|
||||
)}
|
||||
|
@ -71,7 +71,7 @@ describe('DataTrail', () => {
|
||||
});
|
||||
|
||||
it('should sync state with url', () => {
|
||||
expect(locationService.getSearchObject().metric).toBe('metric_bucket');
|
||||
expect(trail.getUrlState().metric).toBe('metric_bucket');
|
||||
});
|
||||
|
||||
it('should add history step', () => {
|
||||
@ -104,10 +104,6 @@ describe('DataTrail', () => {
|
||||
trail.state.$timeRange?.setState({ from: 'now-1h' });
|
||||
});
|
||||
|
||||
it('should sync state with url', () => {
|
||||
expect(locationService.getSearchObject().from).toBe('now-1h');
|
||||
});
|
||||
|
||||
it('should add history step', () => {
|
||||
expect(trail.state.history.state.steps[2].type).toBe('time');
|
||||
});
|
||||
@ -154,10 +150,6 @@ describe('DataTrail', () => {
|
||||
trail.state.$timeRange?.setState({ from: 'now-15m' });
|
||||
});
|
||||
|
||||
it('should sync state with url', () => {
|
||||
expect(locationService.getSearchObject().from).toBe('now-15m');
|
||||
});
|
||||
|
||||
it('should add history step', () => {
|
||||
expect(trail.state.history.state.steps[3].type).toBe('time');
|
||||
});
|
||||
@ -224,10 +216,6 @@ describe('DataTrail', () => {
|
||||
getFilterVar().setState({ filters: [{ key: 'zone', operator: '=', value: 'a' }] });
|
||||
});
|
||||
|
||||
it('should sync state with url', () => {
|
||||
expect(decodeURIComponent(locationService.getSearchObject()['var-filters']?.toString()!)).toBe('zone|=|a');
|
||||
});
|
||||
|
||||
it('should add history step', () => {
|
||||
expect(trail.state.history.state.steps[2].type).toBe('filters');
|
||||
});
|
||||
@ -276,12 +264,6 @@ describe('DataTrail', () => {
|
||||
getFilterVar().setState({ filters: [{ key: 'zone', operator: '=', value: 'b' }] });
|
||||
});
|
||||
|
||||
it('should sync state with url', () => {
|
||||
expect(decodeURIComponent(locationService.getSearchObject()['var-filters']?.toString()!)).toBe(
|
||||
'zone|=|b'
|
||||
);
|
||||
});
|
||||
|
||||
it('should add history step', () => {
|
||||
expect(trail.state.history.state.steps[3].type).toBe('filters');
|
||||
});
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
|
||||
import { AdHocVariableFilter, GrafanaTheme2, VariableHide, urlUtil } from '@grafana/data';
|
||||
import { AdHocVariableFilter, GrafanaTheme2, PageLayoutType, VariableHide, urlUtil } from '@grafana/data';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import {
|
||||
AdHocFiltersVariable,
|
||||
@ -25,6 +25,7 @@ import {
|
||||
VariableValueSelectors,
|
||||
} from '@grafana/scenes';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
|
||||
import { DataTrailSettings } from './DataTrailSettings';
|
||||
import { DataTrailHistory } from './DataTrailsHistory';
|
||||
@ -35,6 +36,7 @@ import { getTrailStore } from './TrailStore/TrailStore';
|
||||
import { MetricDatasourceHelper } from './helpers/MetricDatasourceHelper';
|
||||
import { reportChangeInLabelFilters } from './interactions';
|
||||
import { MetricSelectedEvent, trailDS, VAR_DATASOURCE, VAR_FILTERS } from './shared';
|
||||
import { getMetricName } from './utils';
|
||||
|
||||
export interface DataTrailState extends SceneObjectState {
|
||||
topScene?: SceneObject;
|
||||
@ -93,21 +95,11 @@ export class DataTrail extends SceneObjectBase<DataTrailState> {
|
||||
);
|
||||
}
|
||||
|
||||
// Disconnects the current step history state from the current state, to prevent changes affecting history state
|
||||
const currentState = this.state.history.state.steps[this.state.history.state.currentStep]?.trailState;
|
||||
if (currentState) {
|
||||
this.restoreFromHistoryStep(currentState);
|
||||
}
|
||||
|
||||
this.enableUrlSync();
|
||||
|
||||
// Save the current trail as a recent if the browser closes or reloads
|
||||
const saveRecentTrail = () => getTrailStore().setRecentTrail(this);
|
||||
window.addEventListener('unload', saveRecentTrail);
|
||||
|
||||
return () => {
|
||||
this.disableUrlSync();
|
||||
|
||||
if (!this.state.embedded) {
|
||||
saveRecentTrail();
|
||||
}
|
||||
@ -115,18 +107,6 @@ export class DataTrail extends SceneObjectBase<DataTrailState> {
|
||||
};
|
||||
}
|
||||
|
||||
private enableUrlSync() {
|
||||
if (!this.state.embedded) {
|
||||
getUrlSyncManager().initSync(this);
|
||||
}
|
||||
}
|
||||
|
||||
private disableUrlSync() {
|
||||
if (!this.state.embedded) {
|
||||
getUrlSyncManager().cleanUp(this);
|
||||
}
|
||||
}
|
||||
|
||||
protected _variableDependency = new VariableDependencyConfig(this, {
|
||||
variableNames: [VAR_DATASOURCE],
|
||||
onReferencedVariableValueChanged: (variable: SceneVariable) => {
|
||||
@ -167,8 +147,6 @@ export class DataTrail extends SceneObjectBase<DataTrailState> {
|
||||
}
|
||||
|
||||
public restoreFromHistoryStep(state: DataTrailState) {
|
||||
this.disableUrlSync();
|
||||
|
||||
if (!state.topScene && !state.metric) {
|
||||
// If the top scene for an is missing, correct it.
|
||||
state.topScene = new MetricSelectScene({});
|
||||
@ -184,8 +162,6 @@ export class DataTrail extends SceneObjectBase<DataTrailState> {
|
||||
const urlState = getUrlSyncManager().getUrlState(this);
|
||||
const fullUrl = urlUtil.renderUrl(locationService.getLocation().pathname, urlState);
|
||||
locationService.replace(fullUrl);
|
||||
|
||||
this.enableUrlSync();
|
||||
}
|
||||
|
||||
private _handleMetricSelectedEvent(evt: MetricSelectedEvent) {
|
||||
@ -227,24 +203,26 @@ export class DataTrail extends SceneObjectBase<DataTrailState> {
|
||||
}
|
||||
|
||||
static Component = ({ model }: SceneComponentProps<DataTrail>) => {
|
||||
const { controls, topScene, history, settings } = model.useState();
|
||||
const { controls, topScene, history, settings, metric } = model.useState();
|
||||
const styles = useStyles2(getStyles);
|
||||
const showHeaderForFirstTimeUsers = getTrailStore().recent.length < 2;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{showHeaderForFirstTimeUsers && <MetricsHeader />}
|
||||
<history.Component model={history} />
|
||||
{controls && (
|
||||
<div className={styles.controls}>
|
||||
{controls.map((control) => (
|
||||
<control.Component key={control.state.key} model={control} />
|
||||
))}
|
||||
<settings.Component model={settings} />
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.body}>{topScene && <topScene.Component model={topScene} />}</div>
|
||||
</div>
|
||||
<Page navId="explore/metrics" pageNav={{ text: getMetricName(metric) }} layout={PageLayoutType.Custom}>
|
||||
<div className={styles.container}>
|
||||
{showHeaderForFirstTimeUsers && <MetricsHeader />}
|
||||
<history.Component model={history} />
|
||||
{controls && (
|
||||
<div className={styles.controls}>
|
||||
{controls.map((control) => (
|
||||
<control.Component key={control.state.key} model={control} />
|
||||
))}
|
||||
<settings.Component model={settings} />
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.body}>{topScene && <topScene.Component model={topScene} />}</div>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
}
|
||||
@ -288,6 +266,8 @@ function getStyles(theme: GrafanaTheme2) {
|
||||
gap: theme.spacing(1),
|
||||
minHeight: '100%',
|
||||
flexDirection: 'column',
|
||||
background: theme.isLight ? theme.colors.background.primary : theme.colors.background.canvas,
|
||||
padding: theme.spacing(2, 3, 2, 3),
|
||||
}),
|
||||
body: css({
|
||||
flexGrow: 1,
|
||||
|
@ -1,11 +1,9 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { useEffect } from 'react';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
|
||||
import { GrafanaTheme2, PageLayoutType } from '@grafana/data';
|
||||
import { PageLayoutType } from '@grafana/data';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { SceneComponentProps, SceneObjectBase, SceneObjectState, getUrlSyncManager } from '@grafana/scenes';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
import { SceneComponentProps, SceneObjectBase, SceneObjectState, UrlSyncContextProvider } from '@grafana/scenes';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
|
||||
import { DataTrail } from './DataTrail';
|
||||
@ -13,7 +11,7 @@ import { DataTrailsHome } from './DataTrailsHome';
|
||||
import { MetricsHeader } from './MetricsHeader';
|
||||
import { getTrailStore } from './TrailStore/TrailStore';
|
||||
import { HOME_ROUTE, TRAILS_ROUTE } from './shared';
|
||||
import { getMetricName, getUrlForTrail, newMetricsTrail } from './utils';
|
||||
import { getUrlForTrail, newMetricsTrail } from './utils';
|
||||
|
||||
export interface DataTrailsAppState extends SceneObjectState {
|
||||
trail: DataTrail;
|
||||
@ -26,13 +24,12 @@ export class DataTrailsApp extends SceneObjectBase<DataTrailsAppState> {
|
||||
}
|
||||
|
||||
goToUrlForTrail(trail: DataTrail) {
|
||||
this.setState({ trail });
|
||||
locationService.push(getUrlForTrail(trail));
|
||||
this.setState({ trail });
|
||||
}
|
||||
|
||||
static Component = ({ model }: SceneComponentProps<DataTrailsApp>) => {
|
||||
const { trail, home } = model.useState();
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
@ -50,21 +47,7 @@ export class DataTrailsApp extends SceneObjectBase<DataTrailsAppState> {
|
||||
</Page>
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
exact={true}
|
||||
path={TRAILS_ROUTE}
|
||||
render={() => (
|
||||
<Page
|
||||
navId="explore/metrics"
|
||||
pageNav={{ text: getMetricName(trail.state.metric) }}
|
||||
layout={PageLayoutType.Custom}
|
||||
>
|
||||
<div className={styles.customPage}>
|
||||
<DataTrailView trail={trail} />
|
||||
</div>
|
||||
</Page>
|
||||
)}
|
||||
/>
|
||||
<Route exact={true} path={TRAILS_ROUTE} render={() => <DataTrailView trail={trail} />} />
|
||||
</Switch>
|
||||
);
|
||||
};
|
||||
@ -84,7 +67,11 @@ function DataTrailView({ trail }: { trail: DataTrail }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <trail.Component model={trail} />;
|
||||
return (
|
||||
<UrlSyncContextProvider scene={trail}>
|
||||
<trail.Component model={trail} />
|
||||
</UrlSyncContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
let dataTrailsApp: DataTrailsApp;
|
||||
@ -92,50 +79,10 @@ let dataTrailsApp: DataTrailsApp;
|
||||
export function getDataTrailsApp() {
|
||||
if (!dataTrailsApp) {
|
||||
dataTrailsApp = new DataTrailsApp({
|
||||
trail: getInitialTrail(),
|
||||
trail: newMetricsTrail(),
|
||||
home: new DataTrailsHome({}),
|
||||
});
|
||||
}
|
||||
|
||||
return dataTrailsApp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the initial trail for the app to work with based on the current URL
|
||||
*
|
||||
* It will either be a new trail that will be started based on the state represented
|
||||
* in the URL parameters, or it will be the most recently used trail (according to the trail store)
|
||||
* which has its current history step matching the URL parameters.
|
||||
*
|
||||
* The reason for trying to reinitialize from the recent trail is to resolve an issue
|
||||
* where refreshing the browser would wipe the step history. This allows you to preserve
|
||||
* it between browser refreshes, or when reaccessing the same URL.
|
||||
*/
|
||||
function getInitialTrail() {
|
||||
const newTrail = newMetricsTrail();
|
||||
|
||||
// Set the initial state of the newTrail based on the URL,
|
||||
// In case we are initializing from an externally created URL or a page reload
|
||||
getUrlSyncManager().initSync(newTrail);
|
||||
// Remove the URL sync for now. It will be restored on the trail if it is activated.
|
||||
getUrlSyncManager().cleanUp(newTrail);
|
||||
|
||||
// If one of the recent trails is a match to the newTrail derived from the current URL,
|
||||
// let's restore that trail so that a page refresh doesn't create a new trail.
|
||||
const recentMatchingTrail = getTrailStore().findMatchingRecentTrail(newTrail)?.resolve();
|
||||
|
||||
// If there is a matching trail, initialize with that. Otherwise, use the new trail.
|
||||
return recentMatchingTrail || newTrail;
|
||||
}
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
customPage: css({
|
||||
padding: theme.spacing(2, 3, 2, 3),
|
||||
background: theme.isLight ? theme.colors.background.primary : theme.colors.background.canvas,
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
@ -349,6 +349,7 @@ describe('TrailStore', () => {
|
||||
|
||||
describe('And time range is changed to now-15m to now', () => {
|
||||
let trail: DataTrail;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
localStorage.setItem(RECENT_TRAILS_KEY, JSON.stringify([{ history, currentStep: 1 }]));
|
||||
@ -357,6 +358,7 @@ describe('TrailStore', () => {
|
||||
trail = store.recent[0].resolve();
|
||||
const urlState = getUrlSyncManager().getUrlState(trail);
|
||||
locationService.partial(urlState);
|
||||
|
||||
trail.activate();
|
||||
trail.state.history.activate();
|
||||
trail.state.$timeRange?.setState({ from: 'now-15m' });
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { debounce, isEqual } from 'lodash';
|
||||
|
||||
import { urlUtil } from '@grafana/data';
|
||||
import { getUrlSyncManager, SceneObject, SceneObjectRef, SceneObjectUrlValues, sceneUtils } from '@grafana/scenes';
|
||||
import { dispatch } from 'app/store/store';
|
||||
|
||||
@ -77,9 +78,14 @@ export class TrailStore {
|
||||
});
|
||||
|
||||
const currentStep = t.currentStep ?? trail.state.history.state.steps.length - 1;
|
||||
|
||||
trail.state.history.setState({ currentStep });
|
||||
// The state change listeners aren't activated yet, so maually change to the current step state
|
||||
trail.setState(trail.state.history.state.steps[currentStep].trailState);
|
||||
|
||||
trail.setState(
|
||||
sceneUtils.cloneSceneObjectState(trail.state.history.state.steps[currentStep].trailState, {
|
||||
history: trail.state.history,
|
||||
})
|
||||
);
|
||||
|
||||
return trail;
|
||||
}
|
||||
@ -102,8 +108,8 @@ export class TrailStore {
|
||||
}
|
||||
|
||||
private _loadFromUrl(node: SceneObject, urlValues: SceneObjectUrlValues) {
|
||||
node.urlSync?.updateFromUrl(urlValues);
|
||||
node.forEachChild((child) => this._loadFromUrl(child, urlValues));
|
||||
const urlState = urlUtil.renderUrl('', urlValues);
|
||||
sceneUtils.syncStateFromSearchParams(node, new URLSearchParams(urlState));
|
||||
}
|
||||
|
||||
// Recent Trails
|
||||
@ -140,14 +146,6 @@ export class TrailStore {
|
||||
this._save();
|
||||
}
|
||||
|
||||
findMatchingRecentTrail(trail: DataTrail) {
|
||||
const matchUrlState = getUrlStateForComparison(trail);
|
||||
return this._recent.find((t) => {
|
||||
const urlState = getUrlStateForComparison(t.resolve());
|
||||
return isEqual(matchUrlState, urlState);
|
||||
});
|
||||
}
|
||||
|
||||
// Bookmarked Trails
|
||||
get bookmarks() {
|
||||
return this._bookmarks;
|
||||
|
@ -1832,3 +1832,17 @@ $easing: cubic-bezier(0, 0, 0.265, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pull-right {
|
||||
float: right;
|
||||
}
|
||||
.pull-left {
|
||||
float: left;
|
||||
}
|
||||
|
||||
/* makes the font 33% larger relative to the icon container */
|
||||
.#{$fa-css-prefix}-lg {
|
||||
font-size: calc(4em / 3);
|
||||
line-height: calc(3em / 4);
|
||||
vertical-align: -15%;
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
34
public/sass/base/font-awesome/_animated.scss
vendored
34
public/sass/base/font-awesome/_animated.scss
vendored
@ -1,34 +0,0 @@
|
||||
// Spinning Icons
|
||||
// --------------------------
|
||||
|
||||
.#{$fa-css-prefix}-spin {
|
||||
-webkit-animation: fa-spin 2s infinite linear;
|
||||
animation: fa-spin 2s infinite linear;
|
||||
}
|
||||
|
||||
.#{$fa-css-prefix}-pulse {
|
||||
-webkit-animation: fa-spin 1s infinite steps(8);
|
||||
animation: fa-spin 1s infinite steps(8);
|
||||
}
|
||||
|
||||
@-webkit-keyframes fa-spin {
|
||||
0% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
-webkit-transform: rotate(359deg);
|
||||
transform: rotate(359deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fa-spin {
|
||||
0% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
-webkit-transform: rotate(359deg);
|
||||
transform: rotate(359deg);
|
||||
}
|
||||
}
|
@ -1,41 +0,0 @@
|
||||
// Bordered & Pulled
|
||||
// -------------------------
|
||||
|
||||
.#{$fa-css-prefix}-border {
|
||||
padding: 0.2em 0.25em 0.15em;
|
||||
border: solid 0.08em $fa-border-color;
|
||||
border-radius: 0.1em;
|
||||
}
|
||||
|
||||
.#{$fa-css-prefix}-pull-left {
|
||||
float: left;
|
||||
}
|
||||
.#{$fa-css-prefix}-pull-right {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.#{$fa-css-prefix} {
|
||||
&.#{$fa-css-prefix}-pull-left {
|
||||
margin-right: 0.3em;
|
||||
}
|
||||
&.#{$fa-css-prefix}-pull-right {
|
||||
margin-left: 0.3em;
|
||||
}
|
||||
}
|
||||
|
||||
/* Deprecated as of 4.4.0 */
|
||||
.pull-right {
|
||||
float: right;
|
||||
}
|
||||
.pull-left {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.#{$fa-css-prefix} {
|
||||
&.pull-left {
|
||||
margin-right: 0.3em;
|
||||
}
|
||||
&.pull-right {
|
||||
margin-left: 0.3em;
|
||||
}
|
||||
}
|
11
public/sass/base/font-awesome/_core.scss
vendored
11
public/sass/base/font-awesome/_core.scss
vendored
@ -1,11 +0,0 @@
|
||||
// Base Class Definition
|
||||
// -------------------------
|
||||
|
||||
.#{$fa-css-prefix} {
|
||||
display: inline-block;
|
||||
font: normal normal normal #{$fa-font-size-base}/#{$fa-line-height-base} FontAwesome; // shortening font declaration
|
||||
font-size: inherit; // can't have font-size inherit on line above, so need to override
|
||||
text-rendering: auto; // optimizelegibility throws things off #1094
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
// Fixed Width Icons
|
||||
// -------------------------
|
||||
.#{$fa-css-prefix}-fw {
|
||||
width: calc(18em / 14);
|
||||
text-align: center;
|
||||
}
|
2139
public/sass/base/font-awesome/_icons.scss
vendored
2139
public/sass/base/font-awesome/_icons.scss
vendored
File diff suppressed because it is too large
Load Diff
21
public/sass/base/font-awesome/_larger.scss
vendored
21
public/sass/base/font-awesome/_larger.scss
vendored
@ -1,21 +0,0 @@
|
||||
// Icon Sizes
|
||||
// -------------------------
|
||||
|
||||
/* makes the font 33% larger relative to the icon container */
|
||||
.#{$fa-css-prefix}-lg {
|
||||
font-size: calc(4em / 3);
|
||||
line-height: calc(3em / 4);
|
||||
vertical-align: -15%;
|
||||
}
|
||||
.#{$fa-css-prefix}-2x {
|
||||
font-size: 2em !important;
|
||||
}
|
||||
.#{$fa-css-prefix}-3x {
|
||||
font-size: 3em;
|
||||
}
|
||||
.#{$fa-css-prefix}-4x {
|
||||
font-size: 4em;
|
||||
}
|
||||
.#{$fa-css-prefix}-5x {
|
||||
font-size: 5em;
|
||||
}
|
21
public/sass/base/font-awesome/_list.scss
vendored
21
public/sass/base/font-awesome/_list.scss
vendored
@ -1,21 +0,0 @@
|
||||
// List Icons
|
||||
// -------------------------
|
||||
|
||||
.#{$fa-css-prefix}-ul {
|
||||
padding-left: 0;
|
||||
margin-left: $fa-li-width;
|
||||
list-style-type: none;
|
||||
> li {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
.#{$fa-css-prefix}-li {
|
||||
position: absolute;
|
||||
left: -$fa-li-width;
|
||||
width: $fa-li-width;
|
||||
top: calc(2em / 14);
|
||||
text-align: center;
|
||||
&.#{$fa-css-prefix}-lg {
|
||||
left: -$fa-li-width + calc(4em / 14);
|
||||
}
|
||||
}
|
58
public/sass/base/font-awesome/_mixins.scss
vendored
58
public/sass/base/font-awesome/_mixins.scss
vendored
@ -1,58 +0,0 @@
|
||||
// Mixins
|
||||
// --------------------------
|
||||
|
||||
@mixin fa-icon() {
|
||||
display: inline-block;
|
||||
font: normal normal normal #{$fa-font-size-base}/#{$fa-line-height-base} FontAwesome; // shortening font declaration
|
||||
font-size: inherit; // can't have font-size inherit on line above, so need to override
|
||||
text-rendering: auto; // optimizelegibility throws things off #1094
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
@mixin fa-icon-rotate($degrees, $rotation) {
|
||||
-ms-filter: 'progid:DXImageTransform.Microsoft.BasicImage(rotation=#{$rotation})';
|
||||
-webkit-transform: rotate($degrees);
|
||||
-ms-transform: rotate($degrees);
|
||||
transform: rotate($degrees);
|
||||
}
|
||||
|
||||
@mixin fa-icon-flip($horiz, $vert, $rotation) {
|
||||
-ms-filter: 'progid:DXImageTransform.Microsoft.BasicImage(rotation=#{$rotation}, mirror=1)';
|
||||
-webkit-transform: scale($horiz, $vert);
|
||||
-ms-transform: scale($horiz, $vert);
|
||||
transform: scale($horiz, $vert);
|
||||
}
|
||||
|
||||
// Only display content to screen readers. A la Bootstrap 4.
|
||||
//
|
||||
// See: http://a11yproject.com/posts/how-to-hide-content/
|
||||
|
||||
@mixin sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
// Use in conjunction with .sr-only to only display content when it's focused.
|
||||
//
|
||||
// Useful for "Skip to main content" links; see http://www.w3.org/TR/2013/NOTE-WCAG20-TECHS-20130905/G1
|
||||
//
|
||||
// Credit: HTML5 Boilerplate
|
||||
|
||||
@mixin sr-only-focusable {
|
||||
&:active,
|
||||
&:focus {
|
||||
position: static;
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
clip: auto;
|
||||
}
|
||||
}
|
16
public/sass/base/font-awesome/_path.scss
vendored
16
public/sass/base/font-awesome/_path.scss
vendored
@ -1,16 +0,0 @@
|
||||
/* FONT PATH
|
||||
* -------------------------- */
|
||||
|
||||
@font-face {
|
||||
font-family: 'FontAwesome';
|
||||
src: url('#{$fa-font-path}/fontawesome-webfont.eot?v=#{$fa-version}');
|
||||
src:
|
||||
url('#{$fa-font-path}/fontawesome-webfont.eot?#iefix&v=#{$fa-version}') format('embedded-opentype'),
|
||||
url('#{$fa-font-path}/fontawesome-webfont.woff2?v=#{$fa-version}') format('woff2'),
|
||||
url('#{$fa-font-path}/fontawesome-webfont.woff?v=#{$fa-version}') format('woff'),
|
||||
url('#{$fa-font-path}/fontawesome-webfont.ttf?v=#{$fa-version}') format('truetype'),
|
||||
url('#{$fa-font-path}/fontawesome-webfont.svg?v=#{$fa-version}#fontawesomeregular') format('svg');
|
||||
// src: url('#{$fa-font-path}/FontAwesome.otf') format('opentype'); // used when developing fonts
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
@ -1,30 +0,0 @@
|
||||
// Rotated & Flipped Icons
|
||||
// -------------------------
|
||||
|
||||
.#{$fa-css-prefix}-rotate-90 {
|
||||
@include fa-icon-rotate(90deg, 1);
|
||||
}
|
||||
.#{$fa-css-prefix}-rotate-180 {
|
||||
@include fa-icon-rotate(180deg, 2);
|
||||
}
|
||||
.#{$fa-css-prefix}-rotate-270 {
|
||||
@include fa-icon-rotate(270deg, 3);
|
||||
}
|
||||
|
||||
.#{$fa-css-prefix}-flip-horizontal {
|
||||
@include fa-icon-flip(-1, 1, 0);
|
||||
}
|
||||
.#{$fa-css-prefix}-flip-vertical {
|
||||
@include fa-icon-flip(1, -1, 2);
|
||||
}
|
||||
|
||||
// Hook for IE8-9
|
||||
// -------------------------
|
||||
|
||||
:root .#{$fa-css-prefix}-rotate-90,
|
||||
:root .#{$fa-css-prefix}-rotate-180,
|
||||
:root .#{$fa-css-prefix}-rotate-270,
|
||||
:root .#{$fa-css-prefix}-flip-horizontal,
|
||||
:root .#{$fa-css-prefix}-flip-vertical {
|
||||
filter: none;
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
// Screen Readers
|
||||
// -------------------------
|
||||
|
||||
.sr-only {
|
||||
@include sr-only();
|
||||
}
|
||||
.sr-only-focusable {
|
||||
@include sr-only-focusable();
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user