Merge remote-tracking branch 'origin/main' into resource-store

This commit is contained in:
Ryan McKinley 2024-06-14 14:55:52 +03:00
commit d97d59ab38
104 changed files with 4896 additions and 4134 deletions

View File

@ -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"]

View File

@ -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
View File

@ -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

View File

@ -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

View File

@ -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.

View 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": ""
}

View 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": ""
}

View File

@ -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
View 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

View 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)).

View 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
```

View 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 };
});

View 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": []
}
}

View File

@ -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 };
});

View File

@ -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"
}
]
}
}

View File

@ -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 };
});

View File

@ -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"
}
]
}
}

View 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
```

View 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 };
});

View 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"
}
]
}

View File

@ -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();
});

View File

@ -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"');
});

View 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

View File

@ -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",

View File

@ -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();
});
});

View File

@ -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 (

View File

@ -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

View File

@ -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),

View 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,
},
});
}

View File

@ -0,0 +1,10 @@
import { keyframes } from '@emotion/css';
export const spin = keyframes({
'0%': {
transform: 'rotate(0deg)',
},
'100%': {
transform: 'rotate(359deg)',
},
});

View File

@ -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

View File

@ -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
}

View File

@ -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
}

View File

@ -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()
}

View File

@ -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{})

View File

@ -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
}

View File

@ -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)

View File

@ -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.

View File

@ -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.

View File

@ -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())

View File

@ -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) {

View File

@ -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
}

View File

@ -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

View File

@ -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{}

View File

@ -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{}

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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
}

View File

@ -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()
}

View File

@ -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,

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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
}

View File

@ -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
}

View File

@ -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,
}

View File

@ -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(),
}

View File

@ -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',
}),
});

View File

@ -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>
))}

View File

@ -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>
) : (

View 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 });
});

View File

@ -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} />

View File

@ -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>
);

View File

@ -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),

View File

@ -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' });
}),
];

View File

@ -21,7 +21,6 @@ const allHandlers = [
...folderHandlers,
...pluginsHandlers,
...silenceHandlers,
...alertRuleHandlers,
];
export default allHandlers;

View File

@ -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(),
];

View File

@ -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>
);
}

View File

@ -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({});

View File

@ -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);

View File

@ -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>) {

View File

@ -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();
});
}

View File

@ -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();
}

View File

@ -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>
);

View File

@ -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>&nbsp;
<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>
)}
</>
);

View File

@ -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

View File

@ -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 });
}
}
}

View File

@ -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>&nbsp;
<LinkButton fill="text" target="_blank" onClick={this.deleteSnapshot}>
<Trans i18nKey="share-modal.snapshot.delete-button">Delete snapshot.</Trans>
</LinkButton>
</div>
</>
</Stack>
);
}

View File

@ -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',
}),
};
}

View File

@ -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>
)}

View File

@ -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');
});

View File

@ -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,

View File

@ -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',
}),
};
}

View File

@ -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' });

View File

@ -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;

View File

@ -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

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -1,6 +0,0 @@
// Fixed Width Icons
// -------------------------
.#{$fa-css-prefix}-fw {
width: calc(18em / 14);
text-align: center;
}

File diff suppressed because it is too large Load Diff

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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