Brojd/improve e2e tests dx (#3516)

# What this PR does
- introduce e2e tests in Tilt
- support e2e tests commands in Makefile
- stabilize local setup

## Which issue(s) this PR fixes
https://github.com/grafana/oncall/issues/3492

## Checklist

- [x] Unit, integration, and e2e (if applicable) tests updated
- [x] Documentation added (or `pr:no public docs` PR label added if not
required)
- [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not
required)
This commit is contained in:
Dominik Broj 2023-12-15 09:58:25 +01:00 committed by GitHub
parent 96d3e18eb6
commit 92fa509d22
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 193 additions and 125 deletions

2
.gitignore vendored
View file

@ -10,3 +10,5 @@ venv
yarn.lock yarn.lock
node_modules node_modules
test-results

8
.prettierrc.js Normal file
View file

@ -0,0 +1,8 @@
overrides: [
{
files: ["*.yml", "*.yaml"],
options: {
singleQuote: false,
},
},
];

View file

@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Added
- Support e2e tests in Tilt and Makefile ([#3516](https://github.com/grafana/oncall/pull/3516))
## v1.3.80 (2023-12-14) ## v1.3.80 (2023-12-14)
### Added ### Added

View file

@ -197,6 +197,15 @@ engine-manage: ## run Django's `manage.py` script, inside of a docker container
## https://docs.djangoproject.com/en/4.1/ref/django-admin/#django-admin-makemigrations ## https://docs.djangoproject.com/en/4.1/ref/django-admin/#django-admin-makemigrations
$(call run_engine_docker_command,python manage.py $(CMD)) $(call run_engine_docker_command,python manage.py $(CMD))
test-e2e: ## run the e2e tests in headless mode
yarn --cwd grafana-plugin test:e2e
test-e2e-watch: ## start e2e tests in watch mode
yarn --cwd grafana-plugin test:e2e:watch
test-e2e-show-report: ## open last e2e test report
yarn --cwd grafana-plugin playwright show-report
ui-test: ## run the UI tests ui-test: ## run the UI tests
$(call run_ui_docker_command,yarn test) $(call run_ui_docker_command,yarn test)

View file

@ -1,3 +1,4 @@
load('ext://uibutton', 'cmd_button', 'location', 'text_input', 'bool_input')
running_under_parent_tiltfile = os.getenv("TILT_PARENT", "false") == "true" running_under_parent_tiltfile = os.getenv("TILT_PARENT", "false") == "true"
# The user/pass that you will login to Grafana with # The user/pass that you will login to Grafana with
grafana_admin_user_pass = os.getenv("GRAFANA_ADMIN_USER_PASS", "oncall") grafana_admin_user_pass = os.getenv("GRAFANA_ADMIN_USER_PASS", "oncall")
@ -36,7 +37,7 @@ docker_build_sub(
"localhost:63628/oncall/engine:dev", "localhost:63628/oncall/engine:dev",
context="./engine", context="./engine",
cache_from=["grafana/oncall:latest", "grafana/oncall:dev"], cache_from=["grafana/oncall:latest", "grafana/oncall:dev"],
ignore=["./grafana-plugin/test-results/", "./grafana-plugin/dist/", "./grafana-plugin/e2e-tests/"], ignore=["./test-results/", "./grafana-plugin/dist/", "./grafana-plugin/e2e-tests/"],
child_context=".", child_context=".",
target="dev", target="dev",
extra_cmds=["ADD ./grafana-plugin/src/plugin.json /etc/grafana-plugin/src/plugin.json"], extra_cmds=["ADD ./grafana-plugin/src/plugin.json /etc/grafana-plugin/src/plugin.json"],
@ -54,10 +55,56 @@ local_resource(
"build-ui", "build-ui",
labels=["OnCallUI"], labels=["OnCallUI"],
cmd="cd grafana-plugin && yarn install && yarn build:dev", cmd="cd grafana-plugin && yarn install && yarn build:dev",
serve_cmd="cd grafana-plugin && ONCALL_API_URL=http://oncall-dev-engine:8080 yarn watch", serve_cmd="cd grafana-plugin && yarn watch",
allow_parallel=True, allow_parallel=True,
) )
local_resource(
"e2e-tests",
labels=["E2eTests"],
cmd="cd grafana-plugin && yarn test:e2e",
trigger_mode=TRIGGER_MODE_MANUAL,
auto_init=False,
resource_deps=["build-ui", "grafana", "grafana-oncall-app-provisioning-configmap", "engine"]
)
cmd_button(
name="E2E Tests - headless run",
argv=["sh", "-c", "yarn --cwd ./grafana-plugin test:e2e $STOP_ON_FIRST_FAILURE"],
text="Restart headless run",
resource="e2e-tests",
icon_name="replay",
inputs=[
text_input("BROWSERS", "Browsers (e.g. \"chromium,firefox,webkit\")", "chromium", "chromium,firefox,webkit"),
bool_input("REPORTER", "Use HTML reporter", True, 'html', 'line'),
bool_input("STOP_ON_FIRST_FAILURE", "Stop on first failure", True, "-x", ""),
]
)
cmd_button(
name="E2E Tests - open watch mode",
argv=["sh", "-c", "yarn --cwd grafana-plugin test:e2e:watch"],
text="Open watch mode",
resource="e2e-tests",
icon_name="visibility",
)
cmd_button(
name="E2E Tests - show report",
argv=["sh", "-c", "yarn --cwd grafana-plugin playwright show-report"],
text="Show last HTML report",
resource="e2e-tests",
icon_name="assignment",
)
cmd_button(
name="E2E Tests - stop current run",
argv=["sh", "-c", "kill -9 $(pgrep -f test:e2e)"],
text="Stop",
resource="e2e-tests",
icon_name="dangerous",
)
yaml = helm("helm/oncall", name=HELM_PREFIX, values=["./dev/helm-local.yml", "./dev/helm-local.dev.yml"]) yaml = helm("helm/oncall", name=HELM_PREFIX, values=["./dev/helm-local.yml", "./dev/helm-local.dev.yml"])
k8s_yaml(yaml) k8s_yaml(yaml)

View file

@ -243,13 +243,18 @@ are run on pull request CI builds. New features should ideally include a new/mod
To run these tests locally simply do the following: To run these tests locally simply do the following:
```bash 1. Install Playwright dependencies with `npx playwright install`
npx playwright install # install playwright dependencies 2. [Launch the environment](#launch-the-environment)
cp ./grafana-plugin/e2e-tests/.env.example ./grafana-plugin/e2e-tests/.env 3. Then you interact with tests in 2 different ways:
# you may need to tweak the values in ./grafana-plugin/.env according to your local setup 1. Using `Tilt` - open _E2eTests_ section where you will find 4 buttons:
cd grafana-plugin 1. Restart headless run (you can configure browsers, reporter and failure allowance there)
yarn test:e2e 2. Open watch mode
``` 3. Show last HTML report
4. Stop (stops any pending e2e test process)
2. Using `make`:
1. `make test:e2e` to start headless run
2. `make test:e2e:watch` to open watch mode
3. `make test:e2e:show:report` to open last HTML report
## Helm unit tests ## Helm unit tests

View file

@ -1,4 +1,4 @@
base_url: localhost:30001 base_url: localhost:8080
base_url_protocol: http base_url_protocol: http
env: env:
- name: GRAFANA_CLOUD_NOTIFICATIONS_ENABLED - name: GRAFANA_CLOUD_NOTIFICATIONS_ENABLED

View file

@ -1,5 +1,4 @@
node_modules node_modules
frontend_enterprise frontend_enterprise
.DS_Store .DS_Store
test-results
playwright-report playwright-report

View file

@ -16,7 +16,6 @@ grafana-plugin.yml
frontend_enterprise frontend_enterprise
# playwright # playwright
/test-results/
/playwright-report/ /playwright-report/
/playwright/.cache/ /playwright/.cache/
/e2e-tests/storageState.json /e2e-tests/storageState.json

View file

@ -1,5 +1,3 @@
BASE_URL=http://localhost:30002/grafana
ONCALL_API_URL=http://oncall-dev-engine-external:8080/
GRAFANA_VIEWER_USERNAME=viewer GRAFANA_VIEWER_USERNAME=viewer
GRAFANA_VIEWER_PASSWORD=viewer GRAFANA_VIEWER_PASSWORD=viewer
GRAFANA_EDITOR_USERNAME=editor GRAFANA_EDITOR_USERNAME=editor

View file

@ -6,9 +6,6 @@ import { createIntegrationAndSendDemoAlert } from '../utils/integrations';
import { createOnCallSchedule } from '../utils/schedule'; import { createOnCallSchedule } from '../utils/schedule';
test('we can create an oncall schedule + receive an alert', async ({ adminRolePage }) => { test('we can create an oncall schedule + receive an alert', async ({ adminRolePage }) => {
// this test does a lot of stuff, lets give it adequate time to do its thing
test.slow();
const { page, userName } = adminRolePage; const { page, userName } = adminRolePage;
const escalationChainName = generateRandomValue(); const escalationChainName = generateRandomValue();
const integrationName = generateRandomValue(); const integrationName = generateRandomValue();

View file

@ -1,6 +1,6 @@
import {expect, test} from "../fixtures"; import { expect, test } from '../fixtures';
import {createEscalationChain, EscalationStep, selectEscalationStepValue} from "../utils/escalationChain"; import { createEscalationChain, EscalationStep, selectEscalationStepValue } from '../utils/escalationChain';
import {generateRandomValue} from "../utils/forms"; import { generateRandomValue } from '../utils/forms';
test('escalation policy does not go back to "Default" after adding users to notify', async ({ adminRolePage }) => { test('escalation policy does not go back to "Default" after adding users to notify', async ({ adminRolePage }) => {
const { page, userName } = adminRolePage; const { page, userName } = adminRolePage;
@ -13,7 +13,5 @@ test('escalation policy does not go back to "Default" after adding users to noti
// reload and check if important is still selected // reload and check if important is still selected
await page.reload(); await page.reload();
await page.waitForLoadState('networkidle'); await expect(page.getByText('Important')).toBeVisible();
expect(await page.locator('text=Important').isVisible()).toBe(true);
}); });

View file

@ -1,6 +1,8 @@
import { OrgRole } from '@grafana/data'; import { OrgRole } from '@grafana/data';
import { test as setup, chromium, expect, Page, BrowserContext, FullConfig, APIRequestContext } from '@playwright/test'; import { test as setup, chromium, expect, Page, BrowserContext, FullConfig, APIRequestContext } from '@playwright/test';
import { getOnCallApiUrl } from 'utils/consts';
import { VIEWER_USER_STORAGE_STATE, EDITOR_USER_STORAGE_STATE, ADMIN_USER_STORAGE_STATE } from '../playwright.config'; import { VIEWER_USER_STORAGE_STATE, EDITOR_USER_STORAGE_STATE, ADMIN_USER_STORAGE_STATE } from '../playwright.config';
import GrafanaAPIClient from './utils/clients/grafana'; import GrafanaAPIClient from './utils/clients/grafana';
@ -13,7 +15,6 @@ import {
GRAFANA_VIEWER_USERNAME, GRAFANA_VIEWER_USERNAME,
IS_CLOUD, IS_CLOUD,
IS_OPEN_SOURCE, IS_OPEN_SOURCE,
ONCALL_API_URL,
} from './utils/constants'; } from './utils/constants';
import { clickButton, getInputByName } from './utils/forms'; import { clickButton, getInputByName } from './utils/forms';
import { goToGrafanaPage } from './utils/navigation'; import { goToGrafanaPage } from './utils/navigation';
@ -59,17 +60,26 @@ const configureOnCallPlugin = async (page: Page): Promise<void> => {
* go to the oncall plugin configuration page and wait for the page to be loaded * go to the oncall plugin configuration page and wait for the page to be loaded
*/ */
await goToGrafanaPage(page, '/plugins/grafana-oncall-app'); await goToGrafanaPage(page, '/plugins/grafana-oncall-app');
await page.waitForSelector('text=Configure Grafana OnCall'); await page.waitForTimeout(2000);
/** // if plugin is configured, go to OnCall
* we may need to fill in the OnCall API URL if it is not set in the process.env const isConfigured = (await page.getByText('Connected to OnCall').count()) >= 1;
* of the frontend build if (isConfigured) {
*/ await page.getByRole('link', { name: 'Open Grafana OnCall' }).click();
const onCallApiUrlInput = getInputByName(page, 'onCallApiUrl'); return;
const pluginIsAutoConfigured = (await onCallApiUrlInput.count()) === 0; }
if (!pluginIsAutoConfigured) { // otherwise we may need to reconfigure the plugin
await onCallApiUrlInput.fill(ONCALL_API_URL); const needToReconfigure = (await page.getByText('try removing your plugin configuration').count()) >= 1;
if (needToReconfigure) {
await clickButton({ page, buttonText: 'Remove current configuration' });
await clickButton({ page, buttonText: /^Remove$/ });
}
await page.waitForTimeout(2000);
const needToEnterOnCallApiUrl = await page.getByText(/Connected to OnCall/).isHidden();
if (needToEnterOnCallApiUrl) {
await getInputByName(page, 'onCallApiUrl').fill(getOnCallApiUrl() || 'http://oncall-dev-engine:8080');
await clickButton({ page, buttonText: 'Connect' }); await clickButton({ page, buttonText: 'Connect' });
} }
@ -88,13 +98,6 @@ const configureOnCallPlugin = async (page: Page): Promise<void> => {
* https://github.com/grafana/incident/blob/main/plugin/e2e/global-setup.ts * https://github.com/grafana/incident/blob/main/plugin/e2e/global-setup.ts
*/ */
setup('Configure Grafana OnCall plugin', async ({ request }, { config }) => { setup('Configure Grafana OnCall plugin', async ({ request }, { config }) => {
/**
* Unconditionally marks the setup as "slow", giving it triple the default timeout.
* This is mostly useful for the rare case for Cloud Grafana instances where the instance may be down/unavailable
* and we need to poll it until it is available
*/
setup.slow();
if (IS_CLOUD) { if (IS_CLOUD) {
await grafanaApiClient.pollInstanceUntilItIsHealthy(request); await grafanaApiClient.pollInstanceUntilItIsHealthy(request);
} }

View file

@ -1,6 +1,7 @@
import { test, Page, expect } from '../fixtures'; import { test, Page, expect } from '../fixtures';
import { generateRandomValue, selectDropdownValue } from '../utils/forms'; import { generateRandomValue, selectDropdownValue } from '../utils/forms';
import { createIntegration } from '../utils/integrations'; import { createIntegration, searchIntegrationAndAssertItsPresence } from '../utils/integrations';
import { goToOnCallPage } from '../utils/navigation';
const HEARTBEAT_SETTINGS_FORM_TEST_ID = 'heartbeat-settings-form'; const HEARTBEAT_SETTINGS_FORM_TEST_ID = 'heartbeat-settings-form';
@ -12,7 +13,8 @@ test.describe("updating an integration's heartbeat interval works", async () =>
}; };
test('change heartbeat interval', async ({ adminRolePage: { page } }) => { test('change heartbeat interval', async ({ adminRolePage: { page } }) => {
await createIntegration({ page, integrationName: generateRandomValue() }); const integrationName = generateRandomValue();
await createIntegration({ page, integrationName });
await _openHeartbeatSettingsForm(page); await _openHeartbeatSettingsForm(page);
@ -42,7 +44,8 @@ test.describe("updating an integration's heartbeat interval works", async () =>
}); });
test('send heartbeat', async ({ adminRolePage: { page } }) => { test('send heartbeat', async ({ adminRolePage: { page } }) => {
await createIntegration({ page, integrationName: generateRandomValue() }); const integrationName = generateRandomValue();
await createIntegration({ page, integrationName });
await _openHeartbeatSettingsForm(page); await _openHeartbeatSettingsForm(page);
@ -59,6 +62,9 @@ test.describe("updating an integration's heartbeat interval works", async () =>
*/ */
await page.request.get(endpoint); await page.request.get(endpoint);
await page.reload({ waitUntil: 'networkidle' }); await page.reload({ waitUntil: 'networkidle' });
await goToOnCallPage(page, 'integrations');
await searchIntegrationAndAssertItsPresence({ page, integrationName });
await page.getByTestId('heartbeat-badge').waitFor(); await page.getByTestId('heartbeat-badge').waitFor();
}); });
}); });

View file

@ -17,7 +17,6 @@ test('Integrations table shows data in Monitoring Systems and Direct Paging tabs
await createIntegration({ await createIntegration({
page, page,
integrationSearchText: 'Alertmanager', integrationSearchText: 'Alertmanager',
shouldGoToIntegrationsPage: false,
integrationName: ALERTMANAGER_INTEGRATION_NAME, integrationName: ALERTMANAGER_INTEGRATION_NAME,
}); });
await page.waitForTimeout(1000); await page.waitForTimeout(1000);
@ -32,7 +31,6 @@ test('Integrations table shows data in Monitoring Systems and Direct Paging tabs
await createIntegration({ await createIntegration({
page, page,
integrationSearchText: 'Direct paging', integrationSearchText: 'Direct paging',
shouldGoToIntegrationsPage: false,
integrationName: DIRECT_PAGING_INTEGRATION_NAME, integrationName: DIRECT_PAGING_INTEGRATION_NAME,
}); });
await page.waitForTimeout(1000); await page.waitForTimeout(1000);
@ -40,15 +38,13 @@ test('Integrations table shows data in Monitoring Systems and Direct Paging tabs
await page.getByRole('tab', { name: 'Tab Integrations' }).click(); await page.getByRole('tab', { name: 'Tab Integrations' }).click();
// By default Monitoring Systems tab is opened and newly created integrations are visible except Direct Paging one // By default Monitoring Systems tab is opened and newly created integrations are visible except Direct Paging one
await searchIntegrationAndAssertItsPresence({ page, integrationsTable, integrationName: WEBHOOK_INTEGRATION_NAME }); await searchIntegrationAndAssertItsPresence({ page, integrationName: WEBHOOK_INTEGRATION_NAME });
await searchIntegrationAndAssertItsPresence({ await searchIntegrationAndAssertItsPresence({
page, page,
integrationsTable,
integrationName: ALERTMANAGER_INTEGRATION_NAME, integrationName: ALERTMANAGER_INTEGRATION_NAME,
}); });
await searchIntegrationAndAssertItsPresence({ await searchIntegrationAndAssertItsPresence({
page, page,
integrationsTable,
integrationName: DIRECT_PAGING_INTEGRATION_NAME, integrationName: DIRECT_PAGING_INTEGRATION_NAME,
visibleExpected: false, visibleExpected: false,
}); });
@ -57,19 +53,16 @@ test('Integrations table shows data in Monitoring Systems and Direct Paging tabs
await page.getByRole('tab', { name: 'Tab Manual Direct Paging' }).click(); await page.getByRole('tab', { name: 'Tab Manual Direct Paging' }).click();
await searchIntegrationAndAssertItsPresence({ await searchIntegrationAndAssertItsPresence({
page, page,
integrationsTable,
integrationName: WEBHOOK_INTEGRATION_NAME, integrationName: WEBHOOK_INTEGRATION_NAME,
visibleExpected: false, visibleExpected: false,
}); });
await searchIntegrationAndAssertItsPresence({ await searchIntegrationAndAssertItsPresence({
page, page,
integrationsTable,
integrationName: ALERTMANAGER_INTEGRATION_NAME, integrationName: ALERTMANAGER_INTEGRATION_NAME,
visibleExpected: false, visibleExpected: false,
}); });
await searchIntegrationAndAssertItsPresence({ await searchIntegrationAndAssertItsPresence({
page, page,
integrationsTable,
integrationName: 'Direct paging', integrationName: 'Direct paging',
}); });
}); });

View file

@ -103,6 +103,7 @@ test.describe('maintenance mode works', () => {
await createEscalationChain(page, escalationChainName, EscalationStep.NotifyUsers, userName); await createEscalationChain(page, escalationChainName, EscalationStep.NotifyUsers, userName);
await createIntegration({ page, integrationName }); await createIntegration({ page, integrationName });
await page.waitForTimeout(1000);
await assignEscalationChainToIntegration(page, escalationChainName); await assignEscalationChainToIntegration(page, escalationChainName);
await enableMaintenanceMode(page, maintenanceModeType); await enableMaintenanceMode(page, maintenanceModeType);
@ -110,8 +111,6 @@ test.describe('maintenance mode works', () => {
}; };
test('debug mode', async ({ adminRolePage: { page, userName } }) => { test('debug mode', async ({ adminRolePage: { page, userName } }) => {
test.slow();
const { escalationChainName, integrationName } = await createIntegrationAndEscalationChainAndEnableMaintenanceMode( const { escalationChainName, integrationName } = await createIntegrationAndEscalationChainAndEnableMaintenanceMode(
page, page,
userName, userName,
@ -128,7 +127,6 @@ test.describe('maintenance mode works', () => {
}); });
test('"maintenance" mode', async ({ adminRolePage: { page, userName } }) => { test('"maintenance" mode', async ({ adminRolePage: { page, userName } }) => {
test.slow();
const { integrationName } = await createIntegrationAndEscalationChainAndEnableMaintenanceMode( const { integrationName } = await createIntegrationAndEscalationChainAndEnableMaintenanceMode(
page, page,
userName, userName,

View file

@ -1,5 +1,4 @@
export const BASE_URL = process.env.BASE_URL || 'http://localhost:3000'; export const BASE_URL = process.env.BASE_URL || 'http://localhost:3000';
export const ONCALL_API_URL = process.env.ONCALL_API_URL || 'http://host.docker.internal:8080';
export const MAILSLURP_API_KEY = process.env.MAILSLURP_API_KEY; export const MAILSLURP_API_KEY = process.env.MAILSLURP_API_KEY;
export const GRAFANA_VIEWER_USERNAME = process.env.GRAFANA_VIEWER_USERNAME || 'viewer'; export const GRAFANA_VIEWER_USERNAME = process.env.GRAFANA_VIEWER_USERNAME || 'viewer';

View file

@ -22,7 +22,7 @@ type SelectDropdownValueArgs = {
type ClickButtonArgs = { type ClickButtonArgs = {
page: Page; page: Page;
buttonText: string; buttonText: string | RegExp;
// if provided, use this Locator as the root of our search for the button // if provided, use this Locator as the root of our search for the button
startingLocator?: Locator; startingLocator?: Locator;
}; };

View file

@ -1,4 +1,4 @@
import { Locator, Page, expect } from '@playwright/test'; import { Page, expect } from '@playwright/test';
import { clickButton, generateRandomValue, selectDropdownValue } from './forms'; import { clickButton, generateRandomValue, selectDropdownValue } from './forms';
import { goToOnCallPage } from './navigation'; import { goToOnCallPage } from './navigation';
@ -38,17 +38,24 @@ export const createIntegration = async ({
.click(); .click();
// fill in the required inputs // fill in the required inputs
(await page.waitForSelector('input[name="verbal_name"]', { state: 'attached' })).fill(integrationName); await page.getByPlaceholder('Integration Name').fill(integrationName);
(await page.waitForSelector('textarea[name="description_short"]', { state: 'attached' })).fill( await page.getByPlaceholder('Integration Description').fill('Here goes your integration description');
'Here goes your integration description' await page.getByTestId('update-integration-button').focus();
); await page.getByTestId('update-integration-button').click();
const grafanaUpdateBtn = page.getByTestId('update-integration-button'); await goToOnCallPage(page, 'integrations');
await grafanaUpdateBtn.click(); await searchIntegrationAndAssertItsPresence({ page, integrationName });
await page.getByRole('link', { name: integrationName }).click();
}; };
export const assignEscalationChainToIntegration = async (page: Page, escalationChainName: string): Promise<void> => { export const assignEscalationChainToIntegration = async (page: Page, escalationChainName: string): Promise<void> => {
await page.getByTestId('integration-escalation-chain-not-selected').click(); const notSelected = page.getByTestId('integration-escalation-chain-not-selected');
if (await notSelected.isHidden()) {
await clickButton({ page, buttonText: 'Add route' });
await page.waitForTimeout(500);
}
await notSelected.last().click();
// assign the escalation chain to the integration // assign the escalation chain to the integration
await selectDropdownValue({ await selectDropdownValue({
@ -56,7 +63,7 @@ export const assignEscalationChainToIntegration = async (page: Page, escalationC
selectType: 'grafanaSelect', selectType: 'grafanaSelect',
placeholderText: 'Select Escalation Chain', placeholderText: 'Select Escalation Chain',
value: escalationChainName, value: escalationChainName,
startingLocator: page.getByTestId('escalation-chain-select'), startingLocator: page.getByTestId('escalation-chain-select').last(),
}); });
}; };
@ -92,11 +99,9 @@ export const filterIntegrationsTableAndGoToDetailPage = async (page: Page, integ
export const searchIntegrationAndAssertItsPresence = async ({ export const searchIntegrationAndAssertItsPresence = async ({
page, page,
integrationName, integrationName,
integrationsTable,
visibleExpected = true, visibleExpected = true,
}: { }: {
page: Page; page: Page;
integrationsTable: Locator;
integrationName: string; integrationName: string;
visibleExpected?: boolean; visibleExpected?: boolean;
}) => { }) => {
@ -105,6 +110,7 @@ export const searchIntegrationAndAssertItsPresence = async ({
.filter({ hasText: /^Search or filter results\.\.\.$/ }) .filter({ hasText: /^Search or filter results\.\.\.$/ })
.nth(1) .nth(1)
.click(); .click();
const integrationsTable = page.getByTestId('integrations-table');
await page.keyboard.insertText(integrationName); await page.keyboard.insertText(integrationName);
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');
await page.waitForTimeout(2000); await page.waitForTimeout(2000);

View file

@ -1,13 +1,15 @@
import type { Page, Response } from '@playwright/test'; import type { Page } from '@playwright/test';
import { BASE_URL } from './constants'; import { BASE_URL } from './constants';
type GrafanaPage = '/plugins/grafana-oncall-app'; type GrafanaPage = '/plugins/grafana-oncall-app';
type OnCallPage = 'alert-groups' | 'integrations' | 'escalations' | 'schedules' | 'users'; type OnCallPage = 'alert-groups' | 'integrations' | 'escalations' | 'schedules' | 'users';
const _goToPage = (page: Page, url = ''): Promise<Response> => page.goto(`${BASE_URL}${url}`); const _goToPage = async (page: Page, url = '') => page.goto(`${BASE_URL}${url}`);
export const goToGrafanaPage = (page: Page, url: GrafanaPage): Promise<Response> => _goToPage(page, url); export const goToGrafanaPage = async (page: Page, url: GrafanaPage) => _goToPage(page, url);
export const goToOnCallPage = (page: Page, onCallPage: OnCallPage): Promise<Response> => export const goToOnCallPage = async (page: Page, onCallPage: OnCallPage) => {
_goToPage(page, `/a/grafana-oncall-app/${onCallPage}`); await _goToPage(page, `/a/grafana-oncall-app/${onCallPage}`);
await page.waitForTimeout(1000);
};

View file

@ -1,4 +1,4 @@
import { PlaywrightTestProject, defineConfig, devices } from '@playwright/test'; import { PlaywrightTestProject, defineConfig, devices, PlaywrightTestConfig } from '@playwright/test';
import path from 'path'; import path from 'path';
/** /**
@ -12,7 +12,11 @@ export const EDITOR_USER_STORAGE_STATE = path.join(__dirname, 'e2e-tests/.auth/e
export const ADMIN_USER_STORAGE_STATE = path.join(__dirname, 'e2e-tests/.auth/admin.json'); export const ADMIN_USER_STORAGE_STATE = path.join(__dirname, 'e2e-tests/.auth/admin.json');
const IS_CI = !!process.env.CI; const IS_CI = !!process.env.CI;
const BROWSERS = process.env.BROWSERS || 'chromium firefox webkit'; const BROWSERS = process.env.BROWSERS || 'chromium';
const REPORTER_WITH_DEFAULT = process.env.REPORTER || 'html';
const REPORTER = (
process.env.REPORTER === 'html' ? [['html', { open: 'never' }]] : REPORTER_WITH_DEFAULT
) as PlaywrightTestConfig['reporter'];
const SETUP_PROJECT_NAME = 'setup'; const SETUP_PROJECT_NAME = 'setup';
const getEnabledBrowsers = (browsers: PlaywrightTestProject[]) => const getEnabledBrowsers = (browsers: PlaywrightTestProject[]) =>
@ -25,16 +29,18 @@ export default defineConfig({
testDir: './e2e-tests', testDir: './e2e-tests',
/* Maximum time all the tests can run for. */ /* Maximum time all the tests can run for. */
globalTimeout: 20 * 60 * 1000, // 20 minutes globalTimeout: 20 * 60 * 1_000, // 20 minutes
reporter: REPORTER,
/* Maximum time one test can run for. */ /* Maximum time one test can run for. */
timeout: 60 * 1000, timeout: 60_000,
expect: { expect: {
/** /**
* Maximum time expect() should wait for the condition to be met. * Maximum time expect() should wait for the condition to be met.
* For example in `await expect(locator).toHaveText();` * For example in `await expect(locator).toHaveText();`
*/ */
timeout: 10000, timeout: 6_000,
}, },
/* Run tests in files in parallel */ /* Run tests in files in parallel */
fullyParallel: false, fullyParallel: false,
@ -46,10 +52,10 @@ export default defineConfig({
* NOTE: until we fix this issue (https://github.com/grafana/oncall/issues/1692) which occasionally leads * NOTE: until we fix this issue (https://github.com/grafana/oncall/issues/1692) which occasionally leads
* to flaky tests.. let's allow 1 retry per test * to flaky tests.. let's allow 1 retry per test
*/ */
retries: IS_CI ? 1 : 0, retries: 1,
workers: 2, workers: 2,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html', // reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: { use: {
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
@ -59,7 +65,7 @@ export default defineConfig({
trace: 'on', trace: 'on',
video: 'on', video: 'on',
headless: IS_CI, headless: true,
}, },
/* Configure projects for major browsers. The final list is filtered based on BROWSERS env var */ /* Configure projects for major browsers. The final list is filtered based on BROWSERS env var */
@ -109,8 +115,9 @@ export default defineConfig({
// }, // },
]), ]),
/* Folder for test artifacts such as screenshots, videos, traces, etc. */ /* Folder for test artifacts such as screenshots, videos, traces, etc.
// outputDir: 'test-results/', Set outside of grafana-plugin to prevent refreshing Grafana UI during e2e test runs */
outputDir: '../test-results/',
/* Run your local dev server before starting the tests */ /* Run your local dev server before starting the tests */
// webServer: { // webServer: {

View file

@ -18,6 +18,7 @@ interface TooltipBadgeProps {
customIcon?: React.ReactNode; customIcon?: React.ReactNode;
addPadding?: boolean; addPadding?: boolean;
placement?; placement?;
testId?: string;
onHover?: () => void; onHover?: () => void;
} }
@ -36,11 +37,9 @@ const TooltipBadge: FC<TooltipBadgeProps> = (props) => {
icon, icon,
customIcon, customIcon,
className, className,
...rest testId,
} = props; } = props;
const testId = rest['data-testid'];
return ( return (
<Tooltip <Tooltip
placement={placement || 'bottom-start'} placement={placement || 'bottom-start'}

View file

@ -5,7 +5,7 @@ import { useLocation } from 'react-router-dom';
import { OnCallPluginConfigPageProps } from 'types'; import { OnCallPluginConfigPageProps } from 'types';
import PluginState, { PluginStatusResponseBase } from 'state/plugin'; import PluginState, { PluginStatusResponseBase } from 'state/plugin';
import { FALLBACK_LICENSE, GRAFANA_LICENSE_OSS } from 'utils/consts'; import { FALLBACK_LICENSE, getOnCallApiUrl, GRAFANA_LICENSE_OSS, hasPluginBeenConfigured } from 'utils/consts';
import ConfigurationForm from './parts/ConfigurationForm'; import ConfigurationForm from './parts/ConfigurationForm';
import RemoveCurrentConfigurationButton from './parts/RemoveCurrentConfigurationButton'; import RemoveCurrentConfigurationButton from './parts/RemoveCurrentConfigurationButton';
@ -46,7 +46,8 @@ export const removePluginConfiguredQueryParams = (pluginIsEnabled: boolean): voi
const PluginConfigPage: FC<OnCallPluginConfigPageProps> = ({ const PluginConfigPage: FC<OnCallPluginConfigPageProps> = ({
plugin: { plugin: {
meta: { jsonData, enabled: pluginIsEnabled }, meta,
meta: { enabled: pluginIsEnabled },
}, },
}) => { }) => {
const { search } = useLocation(); const { search } = useLocation();
@ -75,11 +76,8 @@ const PluginConfigPage: FC<OnCallPluginConfigPageProps> = ({
const [resettingPlugin, setResettingPlugin] = useState<boolean>(false); const [resettingPlugin, setResettingPlugin] = useState<boolean>(false);
const [pluginResetError, setPluginResetError] = useState<string>(null); const [pluginResetError, setPluginResetError] = useState<string>(null);
const pluginMetaOnCallApiUrl = jsonData?.onCallApiUrl;
const processEnvOnCallApiUrl = process.env.ONCALL_API_URL; // don't destructure this, will break how webpack supplies this
const onCallApiUrl = pluginMetaOnCallApiUrl || processEnvOnCallApiUrl;
const licenseType = pluginIsConnected?.license || FALLBACK_LICENSE; const licenseType = pluginIsConnected?.license || FALLBACK_LICENSE;
const onCallApiUrl = getOnCallApiUrl(meta);
const resetQueryParams = useCallback(() => removePluginConfiguredQueryParams(pluginIsEnabled), [pluginIsEnabled]); const resetQueryParams = useCallback(() => removePluginConfiguredQueryParams(pluginIsEnabled), [pluginIsEnabled]);
@ -110,12 +108,12 @@ const PluginConfigPage: FC<OnCallPluginConfigPageProps> = ({
* Supplying the env var basically allows to skip the configuration form * Supplying the env var basically allows to skip the configuration form
* (check webpack.config.js to see how this is set) * (check webpack.config.js to see how this is set)
*/ */
if (!pluginMetaOnCallApiUrl && processEnvOnCallApiUrl) { if (!hasPluginBeenConfigured(meta) && onCallApiUrl) {
/** /**
* onCallApiUrl is not yet saved in the grafana plugin settings, but has been supplied as an env var * onCallApiUrl is not yet saved in the grafana plugin settings, but has been supplied as an env var
* lets auto-trigger a self-hosted plugin install w/ the onCallApiUrl passed in as an env var * lets auto-trigger a self-hosted plugin install w/ the onCallApiUrl passed in as an env var
*/ */
const errorMsg = await PluginState.selfHostedInstallPlugin(processEnvOnCallApiUrl, true); const errorMsg = await PluginState.selfHostedInstallPlugin(onCallApiUrl, true);
if (errorMsg) { if (errorMsg) {
setPluginConnectionCheckError(errorMsg); setPluginConnectionCheckError(errorMsg);
setCheckingIfPluginIsConnected(false); setCheckingIfPluginIsConnected(false);
@ -146,7 +144,7 @@ const PluginConfigPage: FC<OnCallPluginConfigPageProps> = ({
if (!pluginConfiguredRedirect) { if (!pluginConfiguredRedirect) {
configurePluginAndUpdatePluginStatus(); configurePluginAndUpdatePluginStatus();
} }
}, [pluginMetaOnCallApiUrl, processEnvOnCallApiUrl, onCallApiUrl, pluginConfiguredRedirect]); }, [onCallApiUrl, pluginConfiguredRedirect]);
const resetMessages = useCallback(() => { const resetMessages = useCallback(() => {
setPluginResetError(null); setPluginResetError(null);
@ -210,9 +208,7 @@ const PluginConfigPage: FC<OnCallPluginConfigPageProps> = ({
</> </>
); );
} else if (!pluginIsConnected) { } else if (!pluginIsConnected) {
content = ( content = <ConfigurationForm onSuccessfulSetup={triggerUpdatePluginStatus} defaultOnCallApiUrl={onCallApiUrl} />;
<ConfigurationForm onSuccessfulSetup={triggerUpdatePluginStatus} defaultOnCallApiUrl={processEnvOnCallApiUrl} />
);
} else { } else {
// plugin is fully connected and synced // plugin is fully connected and synced
const pluginLink = ( const pluginLink = (

View file

@ -420,7 +420,7 @@ class Integration extends React.Component<IntegrationProps, IntegrationState> {
Autoresolve: Autoresolve:
</Text> </Text>
<Text type="primary"> <Text type="primary">
{IntegrationHelper.truncateLine(templates['resolve_condition_template'] || 'disabled')} {IntegrationHelper.truncateLine(templates?.['resolve_condition_template'] || 'disabled')}
</Text> </Text>
</div> </div>
@ -1131,7 +1131,7 @@ const IntegrationHeader: React.FC<IntegrationHeaderProps> = ({
{alertReceiveChannel.maintenance_till && ( {alertReceiveChannel.maintenance_till && (
<TooltipBadge <TooltipBadge
data-testid="maintenance-mode-remaining-time-tooltip" testId="maintenance-mode-remaining-time-tooltip"
borderType="primary" borderType="primary"
icon="pause" icon="pause"
text={IntegrationHelper.getMaintenanceText(alertReceiveChannel.maintenance_till)} text={IntegrationHelper.getMaintenanceText(alertReceiveChannel.maintenance_till)}
@ -1193,7 +1193,6 @@ const IntegrationHeader: React.FC<IntegrationHeaderProps> = ({
return ( return (
<TooltipBadge <TooltipBadge
data-testid="heartbeat-badge"
text={undefined} text={undefined}
className={cx('heartbeat-badge')} className={cx('heartbeat-badge')}
borderType={heartbeatStatus ? 'success' : 'danger'} borderType={heartbeatStatus ? 'success' : 'danger'}

View file

@ -440,6 +440,7 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
<div> <div>
{alertReceiveChannel.is_available_for_integration_heartbeat && heartbeat?.last_heartbeat_time_verbal && ( {alertReceiveChannel.is_available_for_integration_heartbeat && heartbeat?.last_heartbeat_time_verbal && (
<TooltipBadge <TooltipBadge
testId="heartbeat-badge"
text={undefined} text={undefined}
className={cx('heartbeat-badge')} className={cx('heartbeat-badge')}
placement="top" placement="top"

View file

@ -37,6 +37,7 @@ import { retryFailingPromises } from 'utils/async';
import { import {
APP_VERSION, APP_VERSION,
CLOUD_VERSION_REGEX, CLOUD_VERSION_REGEX,
getOnCallApiUrl,
GRAFANA_LICENSE_CLOUD, GRAFANA_LICENSE_CLOUD,
GRAFANA_LICENSE_OSS, GRAFANA_LICENSE_OSS,
PLUGIN_ROOT, PLUGIN_ROOT,
@ -167,7 +168,7 @@ export class RootBaseStore {
*/ */
async setupPlugin(meta: OnCallAppPluginMeta) { async setupPlugin(meta: OnCallAppPluginMeta) {
this.initializationError = null; this.initializationError = null;
this.onCallApiUrl = meta.jsonData?.onCallApiUrl; this.onCallApiUrl = getOnCallApiUrl(meta);
if (!FaroHelper.faro) { if (!FaroHelper.faro) {
FaroHelper.initializeFaro(this.onCallApiUrl); FaroHelper.initializeFaro(this.onCallApiUrl);
@ -180,7 +181,7 @@ export class RootBaseStore {
if (this.isOpenSource() && !meta.secureJsonFields?.onCallApiToken) { if (this.isOpenSource() && !meta.secureJsonFields?.onCallApiToken) {
// Reinstall plugin if onCallApiToken is missing // Reinstall plugin if onCallApiToken is missing
const errorMsg = await PluginState.selfHostedInstallPlugin(process.env.ONCALL_API_URL, true); const errorMsg = await PluginState.selfHostedInstallPlugin(this.onCallApiUrl, true);
if (errorMsg) { if (errorMsg) {
return this.setupPluginError(errorMsg); return this.setupPluginError(errorMsg);
} }
@ -310,8 +311,8 @@ export class RootBaseStore {
await this.slackStore.installSlackIntegration(); await this.slackStore.installSlackIntegration();
} }
@action.bound
async getApiUrlForSettings() { async getApiUrlForSettings() {
const settings = await PluginState.getGrafanaPluginSettings(); return this.onCallApiUrl;
return settings.jsonData?.onCallApiUrl;
} }
} }

View file

@ -17,6 +17,8 @@ jest.mock('grafana/app/core/core', () => ({
}, },
})); }));
const onCallApiUrl = 'http://oncall-dev-engine:8080';
const isUserActionAllowed = isUserActionAllowedOriginal as jest.Mock<ReturnType<typeof isUserActionAllowedOriginal>>; const isUserActionAllowed = isUserActionAllowedOriginal as jest.Mock<ReturnType<typeof isUserActionAllowedOriginal>>;
const generatePluginData = ( const generatePluginData = (
@ -32,7 +34,6 @@ describe('rootBaseStore', () => {
}); });
test("onCallApiUrl is not set in the plugin's meta jsonData", async () => { test("onCallApiUrl is not set in the plugin's meta jsonData", async () => {
// mocks/setup
const rootBaseStore = new RootBaseStore(); const rootBaseStore = new RootBaseStore();
// test // test
@ -43,9 +44,7 @@ describe('rootBaseStore', () => {
}); });
test('when there is an issue checking the plugin connection, the error is properly handled', async () => { test('when there is an issue checking the plugin connection, the error is properly handled', async () => {
// mocks/setup
const errorMsg = 'ohhh noooo error'; const errorMsg = 'ohhh noooo error';
const onCallApiUrl = 'http://asdfasdf.com';
const rootBaseStore = new RootBaseStore(); const rootBaseStore = new RootBaseStore();
PluginState.updatePluginStatus = jest.fn().mockResolvedValueOnce(errorMsg); PluginState.updatePluginStatus = jest.fn().mockResolvedValueOnce(errorMsg);
@ -61,8 +60,6 @@ describe('rootBaseStore', () => {
}); });
test('currently undergoing maintenance', async () => { test('currently undergoing maintenance', async () => {
// mocks/setup
const onCallApiUrl = 'http://asdfasdf.com';
const rootBaseStore = new RootBaseStore(); const rootBaseStore = new RootBaseStore();
const maintenanceMessage = 'mncvnmvcmnvkjdjkd'; const maintenanceMessage = 'mncvnmvcmnvkjdjkd';
@ -82,8 +79,6 @@ describe('rootBaseStore', () => {
}); });
test('anonymous user', async () => { test('anonymous user', async () => {
// mocks/setup
const onCallApiUrl = 'http://asdfasdf.com';
const rootBaseStore = new RootBaseStore(); const rootBaseStore = new RootBaseStore();
PluginState.updatePluginStatus = jest.fn().mockResolvedValueOnce({ PluginState.updatePluginStatus = jest.fn().mockResolvedValueOnce({
@ -108,8 +103,6 @@ describe('rootBaseStore', () => {
}); });
test('the plugin is not installed, and allow_signup is false', async () => { test('the plugin is not installed, and allow_signup is false', async () => {
// mocks/setup
const onCallApiUrl = 'http://asdfasdf.com';
const rootBaseStore = new RootBaseStore(); const rootBaseStore = new RootBaseStore();
PluginState.updatePluginStatus = jest.fn().mockResolvedValueOnce({ PluginState.updatePluginStatus = jest.fn().mockResolvedValueOnce({
@ -137,8 +130,6 @@ describe('rootBaseStore', () => {
}); });
test('plugin is not installed, user is not an Admin', async () => { test('plugin is not installed, user is not an Admin', async () => {
// mocks/setup
const onCallApiUrl = 'http://asdfasdf.com';
const rootBaseStore = new RootBaseStore(); const rootBaseStore = new RootBaseStore();
contextSrv.user.orgRole = OrgRole.Viewer; contextSrv.user.orgRole = OrgRole.Viewer;
@ -174,8 +165,6 @@ describe('rootBaseStore', () => {
{ is_installed: false, token_ok: true }, { is_installed: false, token_ok: true },
{ is_installed: true, token_ok: false }, { is_installed: true, token_ok: false },
])('signup is allowed, user is an admin, plugin installation is triggered', async (scenario) => { ])('signup is allowed, user is an admin, plugin installation is triggered', async (scenario) => {
// mocks/setup
const onCallApiUrl = 'http://asdfasdf.com';
const rootBaseStore = new RootBaseStore(); const rootBaseStore = new RootBaseStore();
const mockedLoadCurrentUser = jest.fn(); const mockedLoadCurrentUser = jest.fn();
@ -219,8 +208,6 @@ describe('rootBaseStore', () => {
expected_result: false, expected_result: false,
}, },
])('signup is allowed, licensedAccessControlEnabled, various roles and permissions', async (scenario) => { ])('signup is allowed, licensedAccessControlEnabled, various roles and permissions', async (scenario) => {
// mocks/setup
const onCallApiUrl = 'http://asdfasdf.com';
const rootBaseStore = new RootBaseStore(); const rootBaseStore = new RootBaseStore();
const mockedLoadCurrentUser = jest.fn(); const mockedLoadCurrentUser = jest.fn();
@ -261,8 +248,6 @@ describe('rootBaseStore', () => {
}); });
test('plugin is not installed, signup is allowed, the user is an admin, and plugin installation throws an error', async () => { test('plugin is not installed, signup is allowed, the user is an admin, and plugin installation throws an error', async () => {
// mocks/setup
const onCallApiUrl = 'http://asdfasdf.com';
const rootBaseStore = new RootBaseStore(); const rootBaseStore = new RootBaseStore();
const installPluginError = new Error('asdasdfasdfasf'); const installPluginError = new Error('asdasdfasdfasf');
const humanReadableErrorMsg = 'asdfasldkfjaksdjflk'; const humanReadableErrorMsg = 'asdfasldkfjaksdjflk';
@ -304,8 +289,6 @@ describe('rootBaseStore', () => {
}); });
test('when the plugin is installed, a data sync is triggered', async () => { test('when the plugin is installed, a data sync is triggered', async () => {
// mocks/setup
const onCallApiUrl = 'http://asdfasdf.com';
const rootBaseStore = new RootBaseStore(); const rootBaseStore = new RootBaseStore();
const mockedLoadCurrentUser = jest.fn(); const mockedLoadCurrentUser = jest.fn();
@ -333,8 +316,6 @@ describe('rootBaseStore', () => {
}); });
test('when the plugin is installed, and the data sync returns an error, it is properly handled', async () => { test('when the plugin is installed, and the data sync returns an error, it is properly handled', async () => {
// mocks/setup
const onCallApiUrl = 'http://asdfasdf.com';
const rootBaseStore = new RootBaseStore(); const rootBaseStore = new RootBaseStore();
const mockedLoadCurrentUser = jest.fn(); const mockedLoadCurrentUser = jest.fn();
const updatePluginStatusError = 'asdasdfasdfasf'; const updatePluginStatusError = 'asdasdfasdfasf';

View file

@ -1,3 +1,5 @@
import { OnCallAppPluginMeta } from 'types';
import plugin from '../../package.json'; // eslint-disable-line import plugin from '../../package.json'; // eslint-disable-line
// Navbar // Navbar
@ -30,6 +32,13 @@ export const ONCALL_PROD = 'https://oncall-prod-us-central-0.grafana.net/oncall'
export const ONCALL_OPS = 'https://oncall-ops-us-east-0.grafana.net/oncall'; export const ONCALL_OPS = 'https://oncall-ops-us-east-0.grafana.net/oncall';
export const ONCALL_DEV = 'https://oncall-dev-us-central-0.grafana.net/oncall'; export const ONCALL_DEV = 'https://oncall-dev-us-central-0.grafana.net/oncall';
// Single source of truth on the frontend for OnCall API URL
export const getOnCallApiUrl = (meta?: OnCallAppPluginMeta) =>
meta?.jsonData?.onCallApiUrl || process.env.ONCALL_API_URL;
// If the plugin has never been configured, onCallApiUrl will be undefined in the plugin's jsonData
export const hasPluginBeenConfigured = (meta?: OnCallAppPluginMeta) => Boolean(meta?.jsonData?.onCallApiUrl);
// Faro // Faro
export const FARO_ENDPOINT_DEV = export const FARO_ENDPOINT_DEV =
'https://faro-collector-prod-us-central-0.grafana.net/collect/fb03e474a96cf867f4a34590c002984c'; 'https://faro-collector-prod-us-central-0.grafana.net/collect/fb03e474a96cf867f4a34590c002984c';