Compare commits

...

30 Commits

Author SHA1 Message Date
Jack Westbrook b910e2e39e chore(tsgo): clean up some more types, disable js typechecks 2025-12-01 14:58:45 +01:00
Jack Westbrook 8a93d5331a chore(typescript): appease ts-go stricter type inference in callbacks 2025-12-01 12:28:44 +01:00
Jack Westbrook eb84c5d02f build(typescript): update tsconfigs to work with tsgo 2025-11-28 10:03:13 +01:00
Jack Westbrook ba380fd15d build(typescript): add tsgo and separate typecheck:tsgo npm script 2025-11-28 10:02:30 +01:00
Alex Khomenko 646fb2aa35 Provisioning: Add source link to provisioned dashboards (#114552)
* Provisioning: Add dashboard source link

* Fix type

* Refactor

* Simplify code

* more fixes

* Extract utils

* Switch to object params

* Fix types

* Move to existing file
2025-11-28 07:47:59 +02:00
Eric Shields 34e3c20250 Chore: tree is never undefined, so set type to non-optional (#114518) 2025-11-27 17:19:38 -08:00
grafana-pr-automation[bot] 48a8d54794 I18n: Download translations from Crowdin (#114565)
New Crowdin translations by GitHub Action

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-28 00:40:11 +00:00
Jesse David Peterson 62d83a1ba9 Histogram: Fix runaway bucket densification with extremely sparse + large datasets (#114557)
* test(histogram): failing test for runaway densification

* fix(histogram): maximum bucket densification avoids OOM error

* fix(histogram): handle multiple densified buckets
2025-11-27 19:50:47 +00:00
Georges Chaudy 5626dc50f8 feat(unified-storage): Add adaptive backoff to event notifier polling (#114401)
* use exponential backoff in notifier

* Enhance BadgerDB configuration in REST options with memory table size and number of memtables

* Enhance BadgerDB configuration in REST options by adding value threshold for LSM vs value log storage
2025-11-27 18:42:01 +01:00
Kevin Yu eea50c8e9b Elasticsearch: Update codeowner for elasticsearchImprovedParsing feature toggle (#114556) 2025-11-27 16:58:43 +00:00
owensmallwood df2f528612 Unified Storage: Adds overrides service to resource server (#113794)
* first pass of adding quotas service resource server

* passes prom reg as param

init quota service as part of server params

* init quota service as part of server params

* adds config and only creates quota service when overrides file path is defined

* when quota service enabled, check quota on create and log result

* update log message

* adds tests for quota service

* adds tests for config reloading when the file changes

* fix linter errors

* fix comment

* use startAndAwaitRunning

* Simplifies quotas service. Call manager.GetConfig() when getting quota instead of watching for changes.

* adds tracing to quotas service

* adds nsr attributes to traces when getting quotas and resource stats

* update comment

* update comment

remove check for nil overrides since it will (should) never happen

* fix linter error

* refactors naming to overrides service

checks quotas in separate function

* fix quotas naming

* fixes more quotas -> overrides naming

* use logger from ctx

* linter - remove trailing whitespace

* log FromContext() when checking quotas

* adds events to spans instead of create new spans

updates tenant -> namespace naming

few other minor fixes
2025-11-27 10:29:16 -06:00
Steve Simpson 7b8191ba42 Alerting: Add kubernetesAlertingHistorian feature toggle. (#114551) 2025-11-27 17:14:16 +01:00
Alan Martin ba58506ffd Notifications: Prevent triggering duplicate notifications (#114497)
* fix(notifications): prevent event listener re-registration on route changes

* refactor(notifications): rename alert handling functions for clarity

* refactor(notifications): simplify alert handling by using spread operator for payloads

* refactor(events): address feedback - update LegacyEmitter and LegacyEventHandler interfaces for improved type safety

* fix(events): ensure event handlers handle undefined events gracefully in tests

* test(notifications): add tests for event listener registration and cleanup in AppNotificationList
2025-11-27 16:12:26 +00:00
antonio f12cc5411d Docs: Add feature request guide for contributors (#114538)
* Docs: Add feature request guide for contributors

* prettier

* redo self contrib section

* all pretty no pity

* removed duplicate li
2025-11-27 16:09:04 +00:00
Marc M. 8daa228083 MetricFindValue: add missing "properties" field to the TS interface (#114486) 2025-11-27 16:38:42 +01:00
Steve Simpson cd797b6789 Alerting: Refactor api_ruler_history.go to allow code re-use. (#114548) 2025-11-27 16:37:09 +01:00
Isabel Matwawana c7ea3d17cc Docs: Fix alias for next and latest docs (#114547) 2025-11-27 10:09:33 -05:00
Daniele Stefano Ferru 8e4be891c5 Provisioning: add URL and Path in setting response (#114534)
* Provisioning: add URL and Path in setting response

* linting

* marking fields as non-required
2025-11-27 16:06:03 +01:00
Haris Rozajac 763067f8e1 Dashboard Schema V2: Force v2 when dashboardNewLayouts or v2DashboardAPI are enabled (#113548)
* SchemaV2: Convertion from v1beta1 to v2beta1

* Compare backend-frontend v1 convertion

* Compare backend-frontend v1 convertion

* Fix fe be diff

* Resolve DS issues

* Fix ds inconsistecnies

* fix legacy string value issues

* fix ds test

* fix layout issue

* update test

* Fix tests and issue with defaultConfig

* Update output

* Fix viz config convertion

* wip

* Fix v1 to v2 dashboard transformation differences

Major fixes implemented:
- Backend function names in conversion.go
- Backend group field logic for queries, annotations, and vizConfig
- Backend datasource resolution with map-based lookup
- Backend timezone handling (empty string vs browser)
- Backend annotation processing (empty array vs default annotation)
- Backend default values (editable, liveNow)
- Backend variable processing (definition, defaultKeys, refresh, refId)
- Backend panel layout (y position calculations)
- Backend VizConfig (Kind and Group fields, default values)
- Frontend snapshot issue (annotations not processing)
- Frontend datasource references (only when original has valid datasource)

Test results:
- annotation-conversions: PASSING (0 differences)
- dashboard-properties: 3 expected architectural differences
- panel-conversions: Multiple expected architectural differences
- variable-conversions: 7 expected architectural differences

All remaining differences are expected architectural choices between
backend persistence optimization and frontend UI consumption optimization.

* fix issues with panel and annotation queries with no datasource

* definition and regex

* Use proper v1beta1 resource when testing

* remove misc file

* fix ds provider test

* fix def ds test in response transformer

* fix remaining ResponseTransformers test

* timesettings, variable refresh, editable, liveNow, definition

* fix transformSceneToSaveModelSchemaV2 test

* revert legacyRow changes

* fix go lint issues

* normalize y coordinates when serializing a row

* clean up

* update tests

* use GetStringValue from schemaversion

* fix go lint - cyclomatic complexity

* update open api snapshot

* add migrated dashboards

* fix default panel type when panel type is not provided

* revert dash link changes for now

* fix

* fix nested panel issue and default ref in v1

* apply defaults to nested panels too

* update snapshots

* fix issues with annotations

* matchers, showLegend, annotations

* when converting also don't process queries that have only a refId

* fix issues with text var

* fix dash links

* default to collapse: false when serializing

* fix: filter refId from variable query specs in backend migration

- Add buildDataQueryKindForVariable function to filter refId for variables
- Remove default refId "A" in transformSingleQuery
- Only include __legacyStringValue for non-empty string queries
- Remove refId addition in transformSaveModelSchemaV2ToScene.getDataQueryForVariable
- Handle undefined queries gracefully in frontend and backend
- Ensure backend matches frontend behavior for query variable serialization

* fix: default variable refresh to 'never' to match frontend behavior

Change backend default for missing refresh field from 'onDashboardLoad'
to 'never' to match frontend defaultVariableRefresh() schema default

* fix: only include iconColor in annotations when it exists

- Frontend: Use defaultAnnotationQuerySpec().iconColor as fallback to match schema defaults
- Backend: Only set iconColor if it exists in v1 input (not using GetStringValue)
- Ensures iconColor is only included when present in original dashboard

* fix: use schema defaults for annotation enable, hide, and iconColor

- Use defaultAnnotationQuerySpec() to get schema defaults instead of hardcoded values
- Default enable to false (schema default) to match frontend behavior
- Use schema default for iconColor and hide fields
- Ensures consistency with frontend which uses defaultAnnotationQuerySpec() defaults

* fix: set collapse for hidden-header rows to match first explicit row

- When panels appear before the first explicit row, the hidden-header row's
  collapse should match the first explicit row's collapsed value
- Matches frontend behavior where collapse: panel.collapsed uses the next
  row panel's collapsed value
- Ensures consistency between frontend and backend when converting rows layout

* fix: handle constant variables with missing query value

- Frontend: Fix bug where undefined value was converted to string 'undefined'
  - Now defaults to empty string when value is undefined: value ? String(value) : ''
- Backend: Match frontend fix - default to empty string for text/value when query is missing
- Ensures consistency when constant variable query is missing from v1 dashboard

* Fix interval variable handling when query is missing

- Extract intervals from options when query is missing/empty (matches backend behavior)
- Handle undefined/null query in getIntervalsFromQueryString
- Handle missing current object/value in getCurrentValueForOldIntervalModel
- Update interval variable refresh to use literal 'onTimeRangeChanged' in schema
- Use defaultIntervalVariableSpec() for interval variable serialization
- Backend: Generate query string from options when query is missing

* Fix corrupted dashboard with systemRef override

* don't resolve types for template variables in datasource refs on the backend

* fix annotation and ds issues

* fix range and special mappings

* fix datasource var pluginId and regex

* add __systemRef to schema

* update v15 migration annotation to have a ds type because v2 keeps track of if type is in the initial save model, and if it's not it removes it, but for frontendOuput we are running transformSaveModelToScene which will then assign the type

* add migration fields since the backend applies automigrations in collapsed rows

* filter out queries in ResponseTransformer that only have refId field

* lint

* v2: add default query if queries are empty to match v1 behavior

* fix single migration test

* tracking test should have a defined spec otherwise datasource is removed and won't be tracked

* initialize default with default ds ref

* wip

* Do not assign DS if ds group is empty

* cleanup

* revert change in setupTests.ts

* clean up TODO

* query with only refId should not expect to have a group

* refactor: extract v0alpha1 to v1beta1 conversion logic into atomic function

- Extract ConvertDashboard_V0_to_V1beta1 into v0alpha1_to_v1beta1.go
- Extract prepareV0ConversionContext and migrateV0Dashboard helper functions
- Standardize v0.go to match v1.go pattern with inline multi-step conversions
- Implement Convert_V0_to_V2alpha1 using atomic functions (v0->v1beta1->v2alpha1)
- Implement Convert_V0_to_V2beta1 using atomic functions (v0->v1beta1->v2alpha1->v2beta1)
- Remove non-atomic v0alpha1_to_v2alpha1.go file

* test: add version-specific test files for conversion error handling

- Extract v0 conversion tests into v0_test.go
- Extract v1 conversion tests into v1_test.go
- Add v2 conversion tests in v2_test.go
- Ensure all error handling paths in conversion functions are covered
- Add tests for Convert_V0_to_V2alpha1 and Convert_V0_to_V2beta1 error paths
- Add tests for Convert_V1beta1_to_V2alpha1 and Convert_V1beta1_to_V2beta1 error paths
- Add tests for Convert_V2alpha1_to_V2beta1 error handling

* Fix tests

* Fix linter

* Clean up

* feat(dashboard): Add automatic data loss detection for dashboard conversions

Implements comprehensive data loss detection for all dashboard API version conversions.

Components Tracked:
• Panels (visualization + library panels)
• Queries (data source queries, excludes row panel queries)
• Annotations
• Links
• Variables (template variables)

Features:
• Automatic detection via withConversionMetrics wrapper (zero code changes)
• Error type: 'conversion_data_loss_error'
• Logs: panelsLost, queriesLost, annotationsLost, linksLost, variablesLost

Bugs Found:
• Fixed critical bug: metrics.go was silently swallowing ALL errors (return nil → return err)

Testing:
• TestDataLossDetectionOnAllInputFiles - runs all conversions with detailed logging
• V2→V0/V1 downgrades write output for debugging then skip (not yet implemented)
• All tests passing

* Run dashboards on schema v2 E2Es

* reveret unintended changes

* cleanup

* Reset active manager correctly according to toggles config

* Fix new dashboard being serialized as v1

* Rename toggle

---------

Co-authored-by: Ivan Ortega <ivanortegaalba@gmail.com>
Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
2025-11-27 15:52:42 +01:00
Marc M. eedb613a5e Dashboards: Don't store options when saving a dashboard with Query/Custom variables (#114540) 2025-11-27 15:41:38 +01:00
Alexander Akhmetov 42d3673d04 Alerting: Add rule_limits to rule list requests (#114176)
* Alerting: Add rule_limits to rule list requests

* Unify pagination limits calculation for GMA and DMA rules

* Fix limits, add tests

* Alerting: Rename filter functions and limit properties for clarity

- hasClientSideFilters → hasGrafanaClientSideFilters
- hasDatasourceFilters → hasDatasourceClientSideFilters
- gmaLimit → grafanaManagedLimit
- dmaLimit → datasourceManagedLimit

---------

Co-authored-by: Konrad Lalik <konradlalik@gmail.com>
2025-11-27 14:19:38 +00:00
Levente Balogh 8e73cc2f70 Dashboards: Cover the Switch variable in schema transformations - part 1. (#114293)
fix: cover the switch variable when transforming betwen v1 and v2 schemas
2025-11-27 15:14:42 +01:00
Alexander Zobnin 80fc87339a Zanzana: Role binding hooks (#114470)
* Zanzana: Role bindings hooks WIP

* Empty hooks for role bindings

* implement hooks for role bindings

* add tests

* apply review suggestions
2025-11-27 15:11:34 +01:00
Santiago 8515bcc6b0 Alerting: Use data source headers when remote writing (#114528) 2025-11-27 14:57:54 +01:00
Laura Fernández f872fd7f2f Chore: Update body-parser to v2.2.1 (#114539) 2025-11-27 14:06:27 +01:00
Rafael Bortolon Paulovic 4c869a21a4 feat(unified): data migration integration tests (#114418)
* feat: unified storage migrations integration tests

* chore: add comment and adjust db path name

* chore: refactor test cases into interface
2025-11-27 13:35:49 +01:00
Josh Hunt cffca37999 FS: Check session expiration and rotate if needed (#114433)
* FS: Check session expiration and rotate if needed

* Remove unused return values
2025-11-27 12:30:48 +00:00
Marc M. 95174454e3 ConditionalRendering: Fix for repeated items (#114160) 2025-11-27 12:05:15 +01:00
renovate-sh-app[bot] 1c8f4a745f chore(deps): update dependency node-forge to v1.3.2 [security] (#114522) 2025-11-27 11:47:52 +01:00
Alex Khomenko fe3e2bf9cb Provisioning: Unify resource and file list pages (#114508)
* Provisioning: Unify resources and files view

* Use interactive table

* Add tests

* Show status

* Omit root

* Fix status

* Fix link

* Tab spacing

* Cleanup

* Move funciton outside

* Add source link

* Hide source link for unsynced files

* Show folders sync status

* refactor

* Fix sync folder logic

* refactor

* fix unsynced files type

* Show external source link

* tweaks

* SHow pending for unsynced files
2025-11-27 12:45:25 +02:00
190 changed files with 5468 additions and 1450 deletions
+1 -1
View File
@@ -110,7 +110,7 @@ If you believe you've found a security vulnerability, please read our [security
### Suggest features
If you have an idea of how to improve Grafana, submit a [feature request](https://github.com/grafana/grafana/issues/new?template=1-feature_requests.md).
If you have an idea of how to improve Grafana, submit a [feature request](https://github.com/grafana/grafana/issues/new?template=1-feature_requests.md). To learn how to write an effective feature request, refer to [Create a feature request](contribute/create-feature-request.md).
We want to make Grafana accessible to even more people. Submit an [accessibility issue](https://github.com/grafana/grafana/issues/new?template=2-accessibility.md) to help us understand what we can improve.
@@ -43,6 +43,12 @@ type RepositoryView struct {
// For git, this is the target branch
Branch string `json:"branch,omitempty"`
// For git, this is the target URL
URL string `json:"url,omitempty"`
// For git, this is the target path
Path string `json:"path,omitempty"`
// The supported workflows
Workflows []Workflow `json:"workflows"`
}
@@ -1690,6 +1690,20 @@ func schema_pkg_apis_provisioning_v0alpha1_RepositoryView(ref common.ReferenceCa
Format: "",
},
},
"url": {
SchemaProps: spec.SchemaProps{
Description: "For git, this is the target URL",
Type: []string{"string"},
Format: "",
},
},
"path": {
SchemaProps: spec.SchemaProps{
Description: "For git, this is the target path",
Type: []string{"string"},
Format: "",
},
},
"workflows": {
SchemaProps: spec.SchemaProps{
Description: "The supported workflows",
+1
View File
@@ -5,6 +5,7 @@ We're excited that you're considering making a contribution to the Grafana proje
These are some good resources to explore for developers:
- [Create a pull request](create-pull-request.md)
- [Create a feature request](create-feature-request.md)
- [Developer guide](developer-guide.md)
- [Triage issues](triage-issues.md)
- [Merge a pull request](merge-pull-request.md)
+160
View File
@@ -0,0 +1,160 @@
# Create a feature request
Feature requests help us understand what you need from Grafana. This document guides you through writing effective feature requests that help maintainers understand your needs and prioritize improvements.
## Before you begin
We're excited to hear your ideas! Before you submit a feature request, consider these resources:
- Read the [Code of Conduct](../CODE_OF_CONDUCT.md) to understand our community guidelines.
- Search [existing feature requests](https://github.com/grafana/grafana/issues?q=is%3Aissue+is%3Aopen+label%3Atype%2Ffeature-request) to see if someone already suggested something similar.
- Discuss your idea in the [Grafana community forums](https://community.grafana.com/) to refine it and gather feedback.
## Your first feature request
When you're ready to submit a feature request, use the [feature request template](https://github.com/grafana/grafana/issues/new?template=1-feature_requests.md). The template has three sections that help maintainers understand what you need and why.
Here's an [example of how all three sections work together in an actual feature request](https://github.com/grafana/grafana/issues/105298) from the Grafana community. We'll analyze each section based on this example feature request.
### Why is this needed
This section describes the real problem or limitation you're facing.
Explain what's difficult, inefficient, or impossible with the current implementation. Focus on the problem rather than proposing a solution. This helps maintainers understand your use case and potentially find better solutions.
**What to include:**
- The specific problem or pain point you're experiencing
- How the current behavior falls short for your workflow
- Why this matters to you and your work
- A concrete example that clarifies the issue (optional but helpful)
**What to avoid:**
- Jumping directly to the solution (save that for the next section)
- Vague statements like "it would be nice if..."
- Assuming maintainers know your context or workflow
**Example of a strong answer:**
```
When using a datasource variable in dashboards and using the "Export" feature in a dashboard,
this will automatically create an input for the datasource(s) being used, but it will also
effectively override the use of the datasource variable in all panels.
This makes a confusing
experience when importing the dashboard, because users are prompted for an input, but the
selected datasource won't be reflected in the datasource variable, and any changes to the
datasource variable will not have any effect on the dashboard.
```
**Example of a weak answer:**
```
Dashboard export doesn't work well with variables.
```
The first example clearly explains what's broken, why it's confusing, and what the specific consequences are. The second example is too vague and doesn't explain the actual problem.
### What would you like to be added
This section describes what you want Grafana to do differently.
Be specific and concrete about the expected behavior. If you're suggesting a UI change, describe the interaction or include a screenshot or sketch. If it's data or API related, provide an example query or expected output.
**What to include:**
- Exactly what behavior you expect
- How the feature should work in practice
- Examples, screenshots, or code snippets that illustrate your idea
- Expected output or results
**What to avoid:**
- Vague or abstract descriptions
- Multiple unrelated features in one request (create separate requests instead)
- Implementation details unless they're critical to your request
**Example of a strong answer:**
```
Ideal behavior here would be that when using the export feature, either:
1. No inputs section is created for datasource types that are used as datasource variables.
2. IF an input is created, it should only be used to replace the currently selected value of
the datasource variable, rather than override the datasource in panels.
```
**Example of a weak answer:**
```
Fix the dashboard export feature.
```
The first example provides clear, actionable options for how the feature should work. The second example is too vague and doesn't specify what the fix should do.
### Who is this feature for?
This section describes who benefits from this feature and in what context.
Help maintainers understand the scope and impact of your request. Be specific about user types, workflows, or scenarios where this feature matters.
**What to include:**
- The type of user who needs this (for example, Tempo users, dashboard editors, plugin developers)
- Whether this affects all Grafana users or only those using specific features or data sources
- The workflow or use case this feature improves (optional but helpful)
**What to avoid:**
- Saying "everyone" without clarifying who actually needs it
- Being overly narrow if the feature has broader appeal
**Example of a strong answer:**
```
Any Grafana Dashboard users or authors that use datasource variables.
```
**Example of a weak answer:**
```
Dashboard users.
```
The first example identifies the specific users and the feature they use (datasource variables). The second example is too generic and doesn't clarify which users or workflow are affected.
## Best practices for feature requests
Follow these guidelines to increase the chances of your feature request being accepted:
### Keep it focused
Request one feature at a time. If you have multiple ideas, create separate feature requests for each one. This makes it easier to discuss, prioritize, and implement each feature independently.
### Research first
Before submitting, search for similar requests. If you find an existing request that's close to your idea, add your use case and context to that discussion instead of creating a duplicate.
### Provide context
The more context you provide, the better maintainers can understand your needs. Include:
- Your environment or setup (which data sources, plugins, or features you're using)
- Your workflow or process
- Why this matters to you
- Any workarounds you've tried
### Be open to alternatives
Maintainers might suggest different approaches to solve your problem. Be open to these alternatives as they might be easier to implement or more maintainable in the long term.
### Stay engaged
After submitting your feature request, monitor the discussion. Answer questions from maintainers and provide clarification when needed. This helps move your request forward.
## Contributing the feature yourself
If you want to implement the feature yourself, feel free to create a pull request following the [pull request guidelines](create-pull-request.md).
We welcome community contributions and appreciate your help making Grafana better!
@@ -1,6 +1,6 @@
---
aliases:
- ../../panels-visualizations/query-transform-data/sql-expressions/ # /docs/grafana/next/panels-visualizations/query-transform-data/sql-expressions/
- ../../../panels-visualizations/query-transform-data/sql-expressions/ # /docs/grafana/next/panels-visualizations/query-transform-data/sql-expressions/
labels:
products:
- cloud
@@ -4,6 +4,8 @@ import { test, expect, E2ESelectorGroups, DashboardPage, DashboardPageArgs } fro
import testDashboard from '../dashboards/DashboardWithAllConditionalRendering.json';
import { checkRepeatedPanelTitles } from './utils';
test.use({
featureToggles: {
kubernetesDashboards: true,
@@ -93,7 +95,7 @@ test.describe('Dashboard - Conditional Rendering - Load and Change', { tag: ['@d
test.afterAll(async ({ request }) => {
if (uid) {
await request.delete(`/apis/dashboard.grafana.app/v1beta1/namespaces/default/dashboards/${uid}`);
await request.delete(`/apis/dashboard.grafana.app/v1beta1/namespaces/stacks-12345/dashboards/${uid}`);
}
});
@@ -407,4 +409,52 @@ test.describe('Dashboard - Conditional Rendering - Load and Change', { tag: ['@d
await expect(getTabShowNotMatches(dashboardPage, selectors)).toBeVisible();
await expect(getTabHideNotMatches(dashboardPage, selectors)).not.toBeVisible();
});
test.describe('Variable repeat', () => {
const repeatOptions = ['a', 'b', 'c'];
async function failTestDataRequestForOption(page: Page, option: string) {
await page.route(/\/api\/ds\/query\?.*\bds_type=grafana-testdata-datasource/, async (route) => {
const rawPostData = route.request().postData();
if (!rawPostData) {
return;
}
// the first panel query has a label set to the current variable value
if (JSON.parse(rawPostData).queries[0].labels === `key=${option}`) {
await route.fulfill({ status: 500, body: '{}' });
} else {
await route.continue();
}
});
}
test('Hide when equals, hide when no data', async ({ page, gotoDashboardPage, selectors }) => {
const dashboardPage = await loadDashboard(page, gotoDashboardPage);
await getTab(dashboardPage, selectors, 'repeated items').click();
const optionForHiddenPanels = repeatOptions[0];
await failTestDataRequestForOption(page, optionForHiddenPanels);
await checkRepeatedPanelTitles(
dashboardPage,
selectors,
'Hide panel - ',
[
`custom variable equals ${optionForHiddenPanels} (current = ${optionForHiddenPanels})`,
`no data (current = ${optionForHiddenPanels})`,
],
true
);
const optionsForVisiblePanels = repeatOptions.slice(1);
await checkRepeatedPanelTitles(dashboardPage, selectors, 'Hide panel - ', [
...optionsForVisiblePanels.map((o) => `custom variable equals ${optionForHiddenPanels} (current = ${o})`),
...optionsForVisiblePanels.map((o) => `no data (current = ${o})`),
]);
});
});
});
+10 -4
View File
@@ -97,12 +97,18 @@ export async function checkRepeatedPanelTitles(
dashboardPage: DashboardPage,
selectors: E2ESelectorGroups,
title: string,
options: Array<string | number>
options: Array<string | number>,
expectHidden = false
) {
for (const option of options) {
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(`${title}${option}`))
).toBeVisible();
const titleLocator = dashboardPage.getByGrafanaSelector(
selectors.components.Panels.Panel.title(`${title}${option}`)
);
if (expectHidden) {
await expect(titleLocator).toBeHidden();
} else {
await expect(titleLocator).toBeVisible();
}
}
}
@@ -9,7 +9,8 @@ const NUM_NESTED_DASHBOARDS = 60;
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -4,7 +4,8 @@ import testDashboard from '../dashboards/TestDashboard.json';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -6,7 +6,8 @@ test.use({
featureToggles: {
scenes: true,
sharingDashboardImage: true, // Enable the export image feature
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -2,7 +2,8 @@ import { test, expect } from '@grafana/plugin-e2e';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -2,7 +2,8 @@ import { test, expect } from '@grafana/plugin-e2e';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -4,7 +4,8 @@ import testDashboard from '../dashboards/DataLinkWithoutSlugTest.json';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -4,7 +4,8 @@ import testDashboard from '../dashboards/DashboardLiveTest.json';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -2,7 +2,8 @@ import { test, expect } from '@grafana/plugin-e2e';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
dashboardScene: false, // this test is for the old sharing modal only used when scenes is turned off
},
});
@@ -2,7 +2,8 @@ import { test, expect } from '@grafana/plugin-e2e';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
dashboardScene: false, // this test is for the old sharing modal only used when scenes is turned off
},
});
@@ -3,7 +3,8 @@ import { test, expect } from '@grafana/plugin-e2e';
test.use({
featureToggles: {
scenes: true,
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -3,7 +3,8 @@ import { test, expect } from '@grafana/plugin-e2e';
test.use({
featureToggles: {
scenes: true,
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -5,7 +5,8 @@ import { SnapshotCreateResponse } from '../../public/app/features/dashboard/serv
test.use({
featureToggles: {
scenes: true,
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -5,7 +5,8 @@ const DASHBOARD_UID = 'HYaGDGIMk';
test.use({
timezoneId: 'Pacific/Easter',
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -7,7 +7,8 @@ const TIMEZONE_DASHBOARD_UID = 'd41dbaa2-a39e-4536-ab2b-caca52f1a9c8';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -16,7 +16,8 @@ test.use({
origins: [],
},
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -2,7 +2,8 @@ import { test, expect } from '@grafana/plugin-e2e';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -4,7 +4,8 @@ const PAGE_UNDER_TEST = 'edediimbjhdz4b/a-tall-dashboard';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -4,7 +4,8 @@ import testDashboard from '../dashboards/TestDashboard.json';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -4,7 +4,8 @@ const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -5,7 +5,8 @@ const DASHBOARD_NAME = 'Test variable output';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -52,7 +52,8 @@ async function assertPreviewValues(
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -5,7 +5,8 @@ const DASHBOARD_NAME = 'Test variable output';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -18,7 +18,8 @@ async function assertPreviewValues(
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -5,7 +5,8 @@ const DASHBOARD_NAME = 'Templating - Nested Template Variables';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -5,7 +5,8 @@ const DASHBOARD_NAME = 'Test variable output';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -4,7 +4,8 @@ const PAGE_UNDER_TEST = 'WVpf2jp7z/repeating-a-panel-horizontally';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -4,7 +4,8 @@ const PAGE_UNDER_TEST = 'OY8Ghjt7k/repeating-a-panel-vertically';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -4,7 +4,8 @@ const PAGE_UNDER_TEST = 'dtpl2Ctnk/repeating-an-empty-row';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -4,7 +4,8 @@ const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -4,7 +4,8 @@ const DASHBOARD_UID = 'ZqZnVvFZz';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
dashboardScene: false, // this test is for the old sharing modal only used when scenes is turned off
},
});
@@ -4,7 +4,8 @@ const DASHBOARD_UID = 'yBCC3aKGk';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -6,7 +6,8 @@ const PAGE_UNDER_TEST = 'AejrN1AMz';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -3308,6 +3308,170 @@
}
}
},
"panel-37": {
"kind": "Panel",
"spec": {
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"hidden": false,
"query": {
"group": "",
"kind": "DataQuery",
"spec": {},
"version": "v0"
},
"refId": "A"
}
}
],
"queryOptions": {},
"transformations": []
}
},
"description": "",
"id": 37,
"links": [],
"title": "Hide panel - custom variable equals a (current = ${myCustomVariable})",
"vizConfig": {
"group": "text",
"kind": "VizConfig",
"spec": {
"fieldConfig": {
"defaults": {},
"overrides": []
},
"options": {
"code": {
"language": "plaintext",
"showLineNumbers": false,
"showMiniMap": false
},
"content": "",
"mode": "markdown"
}
},
"version": "12.2.0-pre"
}
}
},
"panel-38": {
"kind": "Panel",
"spec": {
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"hidden": false,
"query": {
"datasource": {
"name": "PD8C576611E62080A"
},
"group": "grafana-testdata-datasource",
"kind": "DataQuery",
"spec": {
"labels": "key=$myCustomVariable",
"scenarioId": "random_walk",
"seriesCount": 1
},
"version": "v0"
},
"refId": "A"
}
}
],
"queryOptions": {},
"transformations": []
}
},
"description": "",
"id": 38,
"links": [],
"title": "Hide panel - no data (current = ${myCustomVariable})",
"vizConfig": {
"group": "timeseries",
"kind": "VizConfig",
"spec": {
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"showValues": false,
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
}
},
"version": "12.2.0-pre"
}
}
},
"panel-4": {
"kind": "Panel",
"spec": {
@@ -5091,6 +5255,80 @@
},
"title": "Tab - hide - time range <7d"
}
},
{
"kind": "TabsLayoutTab",
"spec": {
"layout": {
"kind": "AutoGridLayout",
"spec": {
"columnWidthMode": "standard",
"items": [
{
"kind": "AutoGridLayoutItem",
"spec": {
"conditionalRendering": {
"kind": "ConditionalRenderingGroup",
"spec": {
"condition": "and",
"items": [
{
"kind": "ConditionalRenderingVariable",
"spec": {
"operator": "equals",
"value": "a",
"variable": "myCustomVariable"
}
}
],
"visibility": "hide"
}
},
"element": {
"kind": "ElementReference",
"name": "panel-37"
},
"repeat": {
"mode": "variable",
"value": "myCustomVariable"
}
}
},
{
"kind": "AutoGridLayoutItem",
"spec": {
"conditionalRendering": {
"kind": "ConditionalRenderingGroup",
"spec": {
"condition": "and",
"items": [
{
"kind": "ConditionalRenderingData",
"spec": {
"value": false
}
}
],
"visibility": "hide"
}
},
"element": {
"kind": "ElementReference",
"name": "panel-38"
},
"repeat": {
"mode": "variable",
"value": "myCustomVariable"
}
}
}
],
"maxColumnCount": 3,
"rowHeightMode": "standard"
}
},
"title": "Tab - repeated items"
}
}
]
}
@@ -5122,6 +5360,39 @@
"query": "",
"skipUrlSync": false
}
},
{
"kind": "CustomVariable",
"spec": {
"allowCustomValue": false,
"current": {
"text": "All",
"value": "$__all"
},
"hide": "dontHide",
"includeAll": true,
"multi": false,
"name": "myCustomVariable",
"options": [
{
"selected": false,
"text": "a",
"value": "a"
},
{
"selected": false,
"text": "b",
"value": "b"
},
{
"selected": false,
"text": "c",
"value": "c"
}
],
"query": "a, b, c",
"skipUrlSync": false
}
}
]
},
+2 -1
View File
@@ -1,4 +1,5 @@
// @ts-check
// TODO: Migrate to Typescript.
// @ts-nocheck
const emotionPlugin = require('@emotion/eslint-plugin');
const restrictedGlobals = require('confusing-browser-globals');
const importPlugin = require('eslint-plugin-import');
+2
View File
@@ -66,6 +66,7 @@
"themes-generate": "yarn themes-schema && esbuild --target=es6 ./scripts/cli/generateSassVariableFiles.ts --bundle --platform=node --tsconfig=./scripts/cli/tsconfig.json | node",
"themes:usage": "eslint . --ignore-pattern '*.test.ts*' --ignore-pattern '*.spec.ts*' --cache --plugin '@grafana' --rule '{ @grafana/theme-token-usage: \"error\" }'",
"typecheck": "tsc --noEmit && yarn run packages:typecheck",
"typecheck:tsgo": "tsgo --noEmit",
"plugins:build-bundled": "echo 'bundled plugins are no longer supported'",
"watch": "yarn start -d watch,start core:start --watchTheme",
"i18n:stats": "node ./scripts/cli/reportI18nStats.mjs",
@@ -166,6 +167,7 @@
"@types/yargs": "17.0.33",
"@typescript-eslint/eslint-plugin": "8.38.0",
"@typescript-eslint/parser": "8.38.0",
"@typescript/native-preview": "^7.0.0-dev.20251128.1",
"autoprefixer": "10.4.21",
"babel-loader": "10.0.0",
"blob-polyfill": "9.0.20240710",
@@ -110,10 +110,9 @@ export function getInheritedProperties<T extends Route>(
...propertiesParentInherited,
} as const;
// @ts-expect-error we're using "keyof" for the property so the type checker can help us out but this makes the
// reduce function signature unhappy
const inherited = reduce(
inheritableProperties,
// @ts-expect-error we're using "keyof" for the property so the type checker can help us out but this makes the
(inheritedProperties: InheritableProperties, parentValue, property: keyof InheritableProperties) => {
const parentHasValue = parentValue != null;
@@ -1,4 +1,7 @@
{
"exclude": ["dist", "node_modules", "tests", "**/*.test.ts*", "**/*.story.tsx"],
"extends": "./tsconfig.json"
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "./src"
}
}
@@ -1581,6 +1581,8 @@ export type RepositoryView = {
branch?: string;
/** The k8s name for this repository */
name: string;
/** For git, this is the target path */
path?: string;
/** When syncing, where values are saved
Possible enum values:
@@ -1598,6 +1600,8 @@ export type RepositoryView = {
- `"gitlab"`
- `"local"` */
type: 'bitbucket' | 'git' | 'github' | 'gitlab' | 'local';
/** For git, this is the target URL */
url?: string;
/** The supported workflows */
workflows: ('branch' | 'write')[];
};
@@ -1,4 +1,7 @@
{
"extends": "./tsconfig.json",
"exclude": ["dist", "node_modules", "**/*.test.ts*"]
"exclude": ["dist", "node_modules", "**/*.test.ts*"],
"compilerOptions": {
"rootDir": "./src"
}
}
@@ -91,8 +91,10 @@ describe('EventBus', () => {
it('Supports legacy events', () => {
const bus = new EventBusSrv();
const events: LegacyEventPayload[] = [];
const handler = (event: LegacyEventPayload) => {
events.push(event);
const handler = (event?: LegacyEventPayload) => {
if (event) {
events.push(event);
}
};
bus.on(legacyEvent, handler);
@@ -111,7 +113,9 @@ describe('EventBus', () => {
const newEvents: AlertSuccessEvent[] = [];
bus.on(legacyEvent, (event) => {
legacyEvents.push(event);
if (event) {
legacyEvents.push(event);
}
});
bus.subscribe(AlertSuccessEvent, (event) => {
+2 -2
View File
@@ -133,12 +133,12 @@ export interface LegacyEmitter {
/**
* @deprecated use $on
*/
off<T>(event: AppEvent<T> | string, handler: (payload?: T) => void): void;
off<T>(event: AppEvent<T> | string, handler: LegacyEventHandler<T>): void;
}
/** @public */
export interface LegacyEventHandler<T> {
(payload: T): void;
(payload?: T): void;
wrapper?: (event: BusEvent) => void;
}
@@ -896,6 +896,42 @@ describe('getHistogramFields', () => {
}
`);
});
it('should prevent excessive densification when sparse histogram has large gaps', () => {
const result = getHistogramFields(
toDataFrame({
meta: {
type: DataFrameType.HeatmapCells,
},
fields: [
{ name: 'yMin', type: FieldType.number, values: [0.001, 1000] },
{ name: 'yMax', type: FieldType.number, values: [0.00101, 1010] },
{ name: 'count', type: FieldType.number, values: [10, 20] },
],
})
);
expect(result).toBeDefined();
expect(result!.counts[0].values.length).toBeLessThanOrEqual(1001);
});
it('should handle multiple observed buckets when hitting densification limit', () => {
const result = getHistogramFields(
toDataFrame({
meta: {
type: DataFrameType.HeatmapCells,
},
fields: [
{ name: 'yMin', type: FieldType.number, values: [0.001, 1000, 2000] },
{ name: 'yMax', type: FieldType.number, values: [0.00101, 1010, 2020] },
{ name: 'count', type: FieldType.number, values: [10, 20, 30] },
],
})
);
expect(result).toBeDefined();
expect(result!.counts[0].values.every((v) => !isNaN(v))).toBe(true);
});
});
describe('joinHistograms', () => {
@@ -210,6 +210,8 @@ export function getHistogramFields(frame: DataFrame): HistogramFields | undefine
let denseMins: number[] = [];
let denseMaxs: number[] = [];
const MAX_DENSIFIED_BUCKETS = 1000;
for (let i = 0; i < uniqueMaxs.length; i++) {
let curMax = uniqueMaxs[i];
let curMin = uniqueMins[i];
@@ -223,13 +225,17 @@ export function getHistogramFields(frame: DataFrame): HistogramFields | undefine
curMax = curMax * bucketFactor;
curMin = curMin * bucketFactor;
while (curMax < nextMax * 0.999999) {
while (curMax < nextMax * 0.999999 && denseMaxs.length < MAX_DENSIFIED_BUCKETS) {
denseMaxs.push(curMax);
denseMins.push(curMin);
curMax = curMax * bucketFactor;
curMin = curMin * bucketFactor;
}
if (denseMaxs.length >= MAX_DENSIFIED_BUCKETS) {
break;
}
}
}
@@ -238,7 +244,10 @@ export function getHistogramFields(frame: DataFrame): HistogramFields | undefine
for (let i = 0; i < yMaxField.values.length; i++) {
let max = yMaxField.values[i];
countsByMax.set(max, countsByMax.get(max) + countField.values[i]);
let currentCount = countsByMax.get(max);
if (currentCount !== undefined) {
countsByMax.set(max, currentCount + countField.values[i]);
}
}
let fields = {
@@ -643,6 +643,7 @@ export interface MetricFindValue {
value?: string | number;
group?: string;
expandable?: boolean;
properties?: Record<string, string>;
}
export interface DataSourceGetDrilldownsApplicabilityOptions<TQuery extends DataQuery = DataQuery> {
+8
View File
@@ -361,6 +361,10 @@ export interface FeatureToggles {
*/
dashboardNewLayouts?: boolean;
/**
* Use the v2 kubernetes API in the frontend for dashboards
*/
kubernetesDashboardsV2?: boolean;
/**
* Enables undo/redo in dynamic dashboards
*/
dashboardUndoRedo?: boolean;
@@ -1189,4 +1193,8 @@ export interface FeatureToggles {
* @default false
*/
rudderstackUpgrade?: boolean;
/**
* Adds support for Kubernetes alerting historian APIs
*/
kubernetesAlertingHistorian?: boolean;
}
+4 -1
View File
@@ -1,4 +1,7 @@
{
"exclude": ["dist", "node_modules", "test", "**/*.test.ts*"],
"extends": "./tsconfig.json"
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "./src"
}
}
@@ -1,4 +1,7 @@
{
"exclude": ["dist", "node_modules", "**/*.test.ts*"],
"extends": "./tsconfig.json"
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "./src"
}
}
@@ -2,7 +2,8 @@
"compilerOptions": {
"paths": {
"@emotion/core": ["./src/types/emotion-core-stub.d.ts"]
}
},
"rootDir": "./src"
},
"exclude": ["**/*.test.ts*"],
"extends": "./tsconfig.json"
+4 -1
View File
@@ -1,4 +1,7 @@
{
"exclude": ["dist", "node_modules", "test", "**/*.test.ts*"],
"extends": "./tsconfig.json"
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "./src"
}
}
@@ -8,7 +8,8 @@
"emitDeclarationOnly": true,
"isolatedModules": true,
"allowJs": true,
"rootDirs": ["."]
"rootDirs": ["."],
"rootDir": "../.."
},
"exclude": ["dist/**/*"],
"include": [
@@ -8,5 +8,8 @@
"src/querybuilder/testUtils.ts",
"../../public/test/setupTests.ts"
],
"extends": "./tsconfig.json"
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "./src"
}
}
+4 -1
View File
@@ -1,4 +1,7 @@
{
"exclude": ["dist", "node_modules", "**/*.test.ts*", "../../public/test/setupTests.ts"],
"extends": "./tsconfig.json"
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "./src"
}
}
@@ -312,18 +312,7 @@ export const handyTestingSchema: Spec = {
label: 'Custom Variable',
multi: true,
name: 'customVar',
options: [
{
selected: true,
text: 'option1',
value: 'option1',
},
{
selected: false,
text: 'option2',
value: 'option2',
},
],
options: [],
query: 'option1, option2',
skipUrlSync: false,
allowCustomValue: true,
@@ -490,5 +479,18 @@ export const handyTestingSchema: Spec = {
allowCustomValue: true,
},
},
{
kind: 'SwitchVariable',
spec: {
name: 'switchVar',
label: 'Switch Variable',
description: 'A switch variable',
current: 'false',
enabledValue: 'true',
disabledValue: 'false',
hide: 'dontHide',
skipUrlSync: false,
},
},
],
};
+4 -1
View File
@@ -1,4 +1,7 @@
{
"exclude": ["dist/**/*", "**/*.test.ts*"],
"extends": "./tsconfig.json"
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "./src"
}
}
@@ -1,7 +1,7 @@
import { css } from '@emotion/css';
import { useMemo, useState } from 'react';
import { useMeasure } from 'react-use';
import AutoSizer from 'react-virtualized-auto-sizer';
import AutoSizer, { type Size } from 'react-virtualized-auto-sizer';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
@@ -61,7 +61,7 @@ export function RawEditor({ db, query, onChange, onRunQuery, onValidate, queryTo
const renderEditor = (standalone = false) => {
return standalone ? (
<AutoSizer>
{({ width, height }) => {
{({ width, height }: Size) => {
return renderQueryEditor(width, height);
}}
</AutoSizer>
+2 -1
View File
@@ -8,7 +8,8 @@
"emitDeclarationOnly": true,
"isolatedModules": true,
"strict": true,
"rootDirs": ["."]
"rootDirs": ["."],
"rootDir": "./src"
},
"exclude": ["dist/**/*"],
"include": ["src/**/*.ts*", "../../public/app/types/*.d.ts", "../grafana-ui/src/types/*.d.ts"]
+2 -1
View File
@@ -7,7 +7,8 @@
"emitDeclarationOnly": true,
"isolatedModules": true,
"rootDirs": ["."],
"moduleResolution": "bundler"
"moduleResolution": "bundler",
"rootDir": "./src"
},
"ts-node": {
"compilerOptions": {
+2 -1
View File
@@ -3,7 +3,8 @@
"paths": {
"@emotion/core": ["./src/types/emotion-core-stub.d.ts"],
"@grafana/ui": ["."]
}
},
"rootDir": "./src"
},
"exclude": [
"**/*.story.tsx",
+1 -1
View File
@@ -198,7 +198,7 @@ func NewAPIService(ac authlib.AccessClient, features featuremgmt.FeatureToggles,
}
func (b *DashboardsAPIBuilder) GetGroupVersions() []schema.GroupVersion {
if featuremgmt.AnyEnabled(b.features, featuremgmt.FlagDashboardNewLayouts) {
if featuremgmt.AnyEnabled(b.features, featuremgmt.FlagDashboardNewLayouts, featuremgmt.FlagKubernetesDashboardsV2) {
// If dashboards v2 is enabled, we want to use v2beta1 as the default API version.
return []schema.GroupVersion{
dashv2beta1.DashboardResourceInfo.GroupVersion(),
+6
View File
@@ -346,6 +346,12 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge
if err != nil {
return err
}
if enableZanzanaSync {
b.logger.Info("Enabling hooks for RoleBinding to sync to Zanzana")
roleBindingStore.AfterCreate = b.AfterRoleBindingCreate
roleBindingStore.AfterDelete = b.AfterRoleBindingDelete
roleBindingStore.BeginUpdate = b.BeginRoleBindingUpdate
}
storage[iamv0.RoleBindingInfo.StoragePath()] = roleBindingStore
}
//nolint:staticcheck // not yet migrated to OpenFeature
+302
View File
@@ -0,0 +1,302 @@
package iam
import (
"context"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/generic/registry"
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
)
const resourceType = "rolebinding"
// AfterRoleBindingCreate is a post-create hook that writes the role binding to Zanzana (openFGA)
func (b *IdentityAccessManagementAPIBuilder) AfterRoleBindingCreate(obj runtime.Object, _ *metav1.CreateOptions) {
if b.zClient == nil {
return
}
rb, ok := obj.(*iamv0.RoleBinding)
if !ok {
b.logger.Error("failed to convert object to RoleBinding type", "object", obj)
return
}
operation := "create"
// Grab a ticket to write to Zanzana
// This limits the amount of concurrent connections to Zanzana
wait := time.Now()
b.zTickets <- true
hooksWaitHistogram.WithLabelValues(resourceType, operation).Observe(time.Since(wait).Seconds())
go func(rb *iamv0.RoleBinding) {
start := time.Now()
status := "success"
defer func() {
// Release the ticket after write is done
<-b.zTickets
// Record operation duration and count
hooksDurationHistogram.WithLabelValues(resourceType, operation, status).Observe(time.Since(start).Seconds())
}()
b.logger.Debug("writing role binding to zanzana",
"namespace", rb.Namespace,
"name", rb.Name,
"subject", rb.Spec.Subject.Name,
"roleRefs", rb.Spec.RoleRefs,
)
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
defer cancel()
operations := make([]*v1.MutateOperation, 0, len(rb.Spec.RoleRefs))
for _, roleRef := range rb.Spec.RoleRefs {
operations = append(operations, &v1.MutateOperation{
Operation: &v1.MutateOperation_CreateRoleBinding{
CreateRoleBinding: &v1.CreateRoleBindingOperation{
SubjectKind: string(rb.Spec.Subject.Kind),
SubjectName: rb.Spec.Subject.Name,
RoleKind: string(roleRef.Kind),
RoleName: roleRef.Name,
},
},
})
}
if len(operations) == 0 {
return
}
err := b.zClient.Mutate(ctx, &v1.MutateRequest{
Namespace: rb.Namespace,
Operations: operations,
})
if err != nil {
status = "failure"
b.logger.Error("failed to write role binding to zanzana",
"err", err,
"namespace", rb.Namespace,
"name", rb.Name,
"subject", rb.Spec.Subject.Name,
"roleRefs", rb.Spec.RoleRefs,
)
}
}(rb.DeepCopy()) // Pass a copy of the object
}
// AfterRoleBindingDelete is a post-delete hook that removes the role binding from Zanzana (openFGA)
func (b *IdentityAccessManagementAPIBuilder) AfterRoleBindingDelete(obj runtime.Object, _ *metav1.DeleteOptions) {
if b.zClient == nil {
return
}
rb, ok := obj.(*iamv0.RoleBinding)
if !ok {
b.logger.Error("failed to convert object to RoleBinding type", "object", obj)
return
}
operation := "delete"
// Grab a ticket to write to Zanzana
// This limits the amount of concurrent connections to Zanzana
wait := time.Now()
b.zTickets <- true
hooksWaitHistogram.WithLabelValues(resourceType, operation).Observe(time.Since(wait).Seconds())
go func(rb *iamv0.RoleBinding) {
start := time.Now()
status := "success"
defer func() {
// Release the ticket after write is done
<-b.zTickets
// Record operation duration and count
hooksDurationHistogram.WithLabelValues(resourceType, operation, status).Observe(time.Since(start).Seconds())
}()
b.logger.Debug("deleting role binding from zanzana",
"namespace", rb.Namespace,
"name", rb.Name,
"subject", rb.Spec.Subject.Name,
"roleRefs", rb.Spec.RoleRefs,
)
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
defer cancel()
operations := make([]*v1.MutateOperation, 0, len(rb.Spec.RoleRefs))
for _, roleRef := range rb.Spec.RoleRefs {
operations = append(operations, &v1.MutateOperation{
Operation: &v1.MutateOperation_DeleteRoleBinding{
DeleteRoleBinding: &v1.DeleteRoleBindingOperation{
SubjectKind: string(rb.Spec.Subject.Kind),
SubjectName: rb.Spec.Subject.Name,
RoleKind: string(roleRef.Kind),
RoleName: roleRef.Name,
},
},
})
}
if len(operations) == 0 {
return
}
err := b.zClient.Mutate(ctx, &v1.MutateRequest{
Namespace: rb.Namespace,
Operations: operations,
})
if err != nil {
status = "failure"
b.logger.Error("failed to delete role binding from zanzana",
"err", err,
"namespace", rb.Namespace,
"name", rb.Name,
"subject", rb.Spec.Subject.Name,
"roleRefs", rb.Spec.RoleRefs,
)
}
}(rb.DeepCopy()) // Pass a copy of the object
}
// BeginRoleBindingUpdate is a pre-update hook that prepares zanzana updates.
// It performs the zanzana write after K8s update succeeds.
func (b *IdentityAccessManagementAPIBuilder) BeginRoleBindingUpdate(ctx context.Context, obj, oldObj runtime.Object, options *metav1.UpdateOptions) (registry.FinishFunc, error) {
if b.zClient == nil {
return nil, nil
}
// Extract role bindings from both old and new objects
oldRB, ok := oldObj.(*iamv0.RoleBinding)
if !ok {
return nil, nil
}
newRB, ok := obj.(*iamv0.RoleBinding)
if !ok {
return nil, nil
}
if oldRB.Spec.Subject.Name == newRB.Spec.Subject.Name && roleRefsEqual(oldRB.Spec.RoleRefs, newRB.Spec.RoleRefs) {
return nil, nil // No changes to the role binding
}
if newRB.Spec.Subject.Name == "" {
b.logger.Error("invalid role binding",
"namespace", newRB.Namespace,
"name", newRB.Name,
"subject", newRB.Spec.Subject.Name,
"roleRefs", newRB.Spec.RoleRefs,
)
return nil, nil
}
// Return a finish function that performs the zanzana write only on success
return func(ctx context.Context, success bool) {
if !success {
return
}
wait := time.Now()
b.zTickets <- true
hooksWaitHistogram.WithLabelValues(resourceType, "update").Observe(time.Since(wait).Seconds())
go func() {
start := time.Now()
status := "success"
defer func() {
<-b.zTickets
// Record operation duration and count
hooksDurationHistogram.WithLabelValues(resourceType, "update", status).Observe(time.Since(start).Seconds())
}()
b.logger.Debug("updating role binding in zanzana",
"namespace", newRB.Namespace,
"name", newRB.Name,
"oldSubject", oldRB.Spec.Subject.Name,
"newSubject", newRB.Spec.Subject.Name,
"oldRoleRefs", oldRB.Spec.RoleRefs,
"newRoleRefs", newRB.Spec.RoleRefs,
)
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
defer cancel()
operations := make([]*v1.MutateOperation, 0, len(oldRB.Spec.RoleRefs))
for _, roleRef := range oldRB.Spec.RoleRefs {
operations = append(operations, &v1.MutateOperation{
Operation: &v1.MutateOperation_DeleteRoleBinding{
DeleteRoleBinding: &v1.DeleteRoleBindingOperation{
SubjectKind: string(oldRB.Spec.Subject.Kind),
SubjectName: oldRB.Spec.Subject.Name,
RoleKind: string(roleRef.Kind),
RoleName: roleRef.Name,
},
},
})
}
for _, roleRef := range newRB.Spec.RoleRefs {
operations = append(operations, &v1.MutateOperation{
Operation: &v1.MutateOperation_CreateRoleBinding{
CreateRoleBinding: &v1.CreateRoleBindingOperation{
SubjectKind: string(newRB.Spec.Subject.Kind),
SubjectName: newRB.Spec.Subject.Name,
RoleKind: string(roleRef.Kind),
RoleName: roleRef.Name,
},
},
})
}
// Only make the request if there are deletes or writes
if len(operations) == 0 {
b.logger.Debug("no role bindings to update in zanzana", "namespace", newRB.Namespace, "name", newRB.Name)
return
}
err := b.zClient.Mutate(ctx, &v1.MutateRequest{
Namespace: newRB.Namespace,
Operations: operations,
})
if err != nil {
status = "failure"
b.logger.Error("failed to update role binding in zanzana",
"err", err,
"namespace", newRB.Namespace,
"name", newRB.Name,
)
}
}()
}, nil
}
func roleRefsEqual(oldRoleRefs, newRoleRefs []iamv0.RoleBindingspecRoleRef) bool {
if len(oldRoleRefs) != len(newRoleRefs) {
return false
}
oldRoleRefsMap := make(map[string]string)
for _, roleRef := range oldRoleRefs {
oldRoleRefsMap[roleRef.Name] = string(roleRef.Kind)
}
for _, roleRef := range newRoleRefs {
refKind, ok := oldRoleRefsMap[roleRef.Name]
if !ok {
return false
}
if refKind != string(roleRef.Kind) {
return false
}
}
return true
}
@@ -0,0 +1,448 @@
package iam
import (
"context"
"slices"
"sync"
"testing"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/stretchr/testify/require"
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/infra/log"
v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
)
func TestAfterRoleBindingCreate(t *testing.T) {
var wg sync.WaitGroup
b := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
}
t.Run("should create zanzana entry for role binding", func(t *testing.T) {
wg.Add(1)
roleBinding := iamv0.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-1",
Namespace: "org-1",
},
Spec: iamv0.RoleBindingSpec{
Subject: iamv0.RoleBindingspecSubject{
Kind: "user",
Name: "user-1",
},
RoleRefs: []iamv0.RoleBindingspecRoleRef{
{
Kind: "role",
Name: "role-1",
},
},
},
}
testRoleBinding := func(ctx context.Context, req *v1.MutateRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.NotNil(t, req.Operations)
require.Len(t, req.Operations, 1)
require.Equal(t, "org-1", req.Namespace)
expectedOperation := &v1.MutateOperation{
Operation: &v1.MutateOperation_CreateRoleBinding{
CreateRoleBinding: &v1.CreateRoleBindingOperation{
SubjectKind: "user",
SubjectName: "user-1",
RoleKind: "role",
RoleName: "role-1",
},
},
}
actualCreate := req.Operations[0].Operation.(*v1.MutateOperation_CreateRoleBinding).CreateRoleBinding
expectedCreate := expectedOperation.Operation.(*v1.MutateOperation_CreateRoleBinding).CreateRoleBinding
require.Equal(t, expectedCreate.SubjectKind, actualCreate.SubjectKind)
require.Equal(t, expectedCreate.SubjectName, actualCreate.SubjectName)
require.Equal(t, expectedCreate.RoleKind, actualCreate.RoleKind)
require.Equal(t, expectedCreate.RoleName, actualCreate.RoleName)
return nil
}
b.zClient = &FakeZanzanaClient{mutateCallback: testRoleBinding}
b.AfterRoleBindingCreate(&roleBinding, nil)
wg.Wait()
})
t.Run("should not write to zanzana when zClient is nil", func(t *testing.T) {
builder := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
zClient: nil,
}
roleBinding := iamv0.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-3",
Namespace: "org-3",
},
Spec: iamv0.RoleBindingSpec{
Subject: iamv0.RoleBindingspecSubject{
Kind: "user",
Name: "user-3",
},
RoleRefs: []iamv0.RoleBindingspecRoleRef{
{
Kind: "role",
Name: "role-3",
},
},
},
}
// Should not panic or error when zClient is nil
builder.AfterRoleBindingCreate(&roleBinding, nil)
})
}
func TestBeginRoleBindingUpdate(t *testing.T) {
var wg sync.WaitGroup
b := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
}
t.Run("should update zanzana entry when role binding changed", func(t *testing.T) {
wg.Add(1)
oldBinding := iamv0.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-1",
Namespace: "org-1",
},
Spec: iamv0.RoleBindingSpec{
Subject: iamv0.RoleBindingspecSubject{
Kind: "user",
Name: "user-1",
},
RoleRefs: []iamv0.RoleBindingspecRoleRef{
{
Kind: "role",
Name: "role-foo",
},
{
Kind: "role",
Name: "role-2",
},
},
},
}
newBinding := iamv0.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-1",
Namespace: "org-1",
},
Spec: iamv0.RoleBindingSpec{
Subject: iamv0.RoleBindingspecSubject{
Kind: "user",
Name: "user-1",
},
RoleRefs: []iamv0.RoleBindingspecRoleRef{
{
Kind: "role",
Name: "role-bar",
},
},
},
}
testRoleBindingUpdate := func(ctx context.Context, req *v1.MutateRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.Equal(t, "org-1", req.Namespace)
require.NotNil(t, req.Operations)
require.Len(t, req.Operations, 3)
// Should write new binding and delete old one
require.True(t, containsOperation(req.Operations, &v1.MutateOperation{
Operation: &v1.MutateOperation_DeleteRoleBinding{
DeleteRoleBinding: &v1.DeleteRoleBindingOperation{
SubjectKind: "user",
SubjectName: "user-1",
RoleKind: "role",
RoleName: "role-foo",
},
},
}))
require.True(t, containsOperation(req.Operations, &v1.MutateOperation{
Operation: &v1.MutateOperation_CreateRoleBinding{
CreateRoleBinding: &v1.CreateRoleBindingOperation{
SubjectKind: "user",
SubjectName: "user-1",
RoleKind: "role",
RoleName: "role-bar",
},
},
}))
return nil
}
b.zClient = &FakeZanzanaClient{mutateCallback: testRoleBindingUpdate}
finishFunc, err := b.BeginRoleBindingUpdate(context.Background(), &newBinding, &oldBinding, nil)
require.NoError(t, err)
require.NotNil(t, finishFunc)
finishFunc(context.Background(), true)
wg.Wait()
})
t.Run("should return nil finish func when bindings are identical", func(t *testing.T) {
oldBinding := iamv0.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-2",
Namespace: "org-2",
},
Spec: iamv0.RoleBindingSpec{
Subject: iamv0.RoleBindingspecSubject{
Kind: "user",
Name: "user-1",
},
RoleRefs: []iamv0.RoleBindingspecRoleRef{
{
Kind: "role",
Name: "role-1",
},
},
},
}
newBinding := iamv0.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-2",
Namespace: "org-2",
},
Spec: iamv0.RoleBindingSpec{
Subject: iamv0.RoleBindingspecSubject{
Kind: "user",
Name: "user-1",
},
RoleRefs: []iamv0.RoleBindingspecRoleRef{
{
Kind: "role",
Name: "role-1",
},
},
},
}
writeCalled := false
testNoWriteOnNoChange := func(ctx context.Context, req *v1.MutateRequest) error {
writeCalled = true
require.Fail(t, "Write should not be called when bindings are identical")
return nil
}
b.zClient = &FakeZanzanaClient{mutateCallback: testNoWriteOnNoChange}
finishFunc, err := b.BeginRoleBindingUpdate(context.Background(), &newBinding, &oldBinding, nil)
require.NoError(t, err)
require.Nil(t, finishFunc) // Should return nil when bindings are identical
// Verify write was never called
time.Sleep(100 * time.Millisecond)
require.False(t, writeCalled, "Write callback should not be called when bindings are identical")
})
t.Run("should return nil finish func when new binding has empty subject name", func(t *testing.T) {
oldBinding := iamv0.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-8",
Namespace: "org-8",
},
Spec: iamv0.RoleBindingSpec{
Subject: iamv0.RoleBindingspecSubject{
Kind: "user",
Name: "user-1",
},
RoleRefs: []iamv0.RoleBindingspecRoleRef{
{
Kind: "role",
Name: "role-1",
},
},
},
}
newBinding := iamv0.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-8",
Namespace: "org-8",
},
Spec: iamv0.RoleBindingSpec{
Subject: iamv0.RoleBindingspecSubject{
Kind: "",
Name: "", // Empty name - should cause early return
},
RoleRefs: []iamv0.RoleBindingspecRoleRef{
{
Kind: "role",
Name: "role-1",
},
},
},
}
writeCalled := false
testNoWriteOnInvalidBinding := func(ctx context.Context, req *v1.MutateRequest) error {
writeCalled = true
require.Fail(t, "Write should not be called when new binding has empty subject name")
return nil
}
b.zClient = &FakeZanzanaClient{mutateCallback: testNoWriteOnInvalidBinding}
finishFunc, err := b.BeginRoleBindingUpdate(context.Background(), &newBinding, &oldBinding, nil)
require.NoError(t, err)
require.Nil(t, finishFunc) // Should return nil when new binding has empty subject name
// Verify write was never called
time.Sleep(100 * time.Millisecond)
require.False(t, writeCalled, "Write callback should not be called when new binding has empty subject name")
})
}
func TestAfterRoleBindingDelete(t *testing.T) {
var wg sync.WaitGroup
b := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
}
t.Run("should delete zanzana entry for team binding with member permission", func(t *testing.T) {
wg.Add(1)
roleBinding := iamv0.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-1",
Namespace: "org-1",
},
Spec: iamv0.RoleBindingSpec{
Subject: iamv0.RoleBindingspecSubject{
Kind: "user",
Name: "user-1",
},
RoleRefs: []iamv0.RoleBindingspecRoleRef{
{
Kind: "role",
Name: "role-1",
},
{
Kind: "role",
Name: "role-2",
},
},
},
}
testRoleBindingDelete := func(ctx context.Context, req *v1.MutateRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.Equal(t, "org-1", req.Namespace)
// Should have deletes but no writes
require.NotNil(t, req.Operations)
require.Len(t, req.Operations, 2)
require.True(t, containsOperation(req.Operations, &v1.MutateOperation{
Operation: &v1.MutateOperation_DeleteRoleBinding{
DeleteRoleBinding: &v1.DeleteRoleBindingOperation{
SubjectKind: "user",
SubjectName: "user-1",
RoleKind: "role",
RoleName: "role-1",
},
},
}))
require.True(t, containsOperation(req.Operations, &v1.MutateOperation{
Operation: &v1.MutateOperation_DeleteRoleBinding{
DeleteRoleBinding: &v1.DeleteRoleBindingOperation{
SubjectKind: "user",
SubjectName: "user-1",
RoleKind: "role",
RoleName: "role-2",
},
},
}))
return nil
}
b.zClient = &FakeZanzanaClient{mutateCallback: testRoleBindingDelete}
b.AfterRoleBindingDelete(&roleBinding, nil)
wg.Wait()
})
t.Run("should not delete from zanzana when zClient is nil", func(t *testing.T) {
builder := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
zClient: nil,
}
roleBinding := iamv0.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-3",
Namespace: "org-3",
},
Spec: iamv0.RoleBindingSpec{
Subject: iamv0.RoleBindingspecSubject{
Kind: "user",
Name: "user-3",
},
RoleRefs: []iamv0.RoleBindingspecRoleRef{
{
Kind: "role",
Name: "role-3",
},
},
},
}
// Should not panic or error when zClient is nil
builder.AfterRoleBindingDelete(&roleBinding, nil)
})
}
func containsOperation(operations []*v1.MutateOperation, operation *v1.MutateOperation) bool {
return slices.ContainsFunc(operations, func(o *v1.MutateOperation) bool {
switch operation.Operation.(type) {
case *v1.MutateOperation_DeleteRoleBinding:
deleteOperation := operation.Operation.(*v1.MutateOperation_DeleteRoleBinding)
deleteO, ok := o.Operation.(*v1.MutateOperation_DeleteRoleBinding)
if !ok {
return false
}
return deleteO.DeleteRoleBinding.SubjectKind == deleteOperation.DeleteRoleBinding.SubjectKind &&
deleteO.DeleteRoleBinding.SubjectName == deleteOperation.DeleteRoleBinding.SubjectName &&
deleteO.DeleteRoleBinding.RoleKind == deleteOperation.DeleteRoleBinding.RoleKind &&
deleteO.DeleteRoleBinding.RoleName == deleteOperation.DeleteRoleBinding.RoleName
case *v1.MutateOperation_CreateRoleBinding:
createOperation := operation.Operation.(*v1.MutateOperation_CreateRoleBinding)
createO, ok := o.Operation.(*v1.MutateOperation_CreateRoleBinding)
if !ok {
return false
}
return createO.CreateRoleBinding.SubjectKind == createOperation.CreateRoleBinding.SubjectKind &&
createO.CreateRoleBinding.SubjectName == createOperation.CreateRoleBinding.SubjectName &&
createO.CreateRoleBinding.RoleKind == createOperation.CreateRoleBinding.RoleKind &&
createO.CreateRoleBinding.RoleName == createOperation.CreateRoleBinding.RoleName
}
return false
})
}
+4
View File
@@ -172,6 +172,8 @@ func (b *APIBuilder) handleSettings(w http.ResponseWriter, r *http.Request) {
for i, val := range all {
branch := val.Branch()
url := val.URL()
path := val.Path()
settings.Items[i] = provisioning.RepositoryView{
Name: val.Name,
@@ -179,6 +181,8 @@ func (b *APIBuilder) handleSettings(w http.ResponseWriter, r *http.Request) {
Type: val.Spec.Type,
Target: val.Spec.Sync.Target,
Branch: branch,
URL: url,
Path: path,
Workflows: val.Spec.Workflows,
}
}
@@ -15,6 +15,9 @@ type FakeDataSourceService struct {
lastID int64
DataSources []*datasources.DataSource
SimulatePluginFailure bool
// UID -> Headers
DataSourceHeaders map[string]http.Header
}
var _ datasources.DataSourceService = &FakeDataSourceService{}
@@ -152,5 +155,5 @@ func (s *FakeDataSourceService) DecryptedPassword(ctx context.Context, ds *datas
}
func (s *FakeDataSourceService) CustomHeaders(ctx context.Context, ds *datasources.DataSource) (http.Header, error) {
return nil, nil
return s.DataSourceHeaders[ds.UID], nil
}
+15 -1
View File
@@ -579,6 +579,13 @@ var (
FrontendOnly: false, // The restore backend feature changes behavior based on this flag
Owner: grafanaDashboardsSquad,
},
{
Name: "kubernetesDashboardsV2",
Description: "Use the v2 kubernetes API in the frontend for dashboards",
Stage: FeatureStageExperimental,
FrontendOnly: false,
Owner: grafanaDashboardsSquad,
},
{
Name: "dashboardUndoRedo",
Description: "Enables undo/redo in dynamic dashboards",
@@ -1315,7 +1322,7 @@ var (
Name: "elasticsearchImprovedParsing",
Description: "Enables less memory intensive Elasticsearch result parsing",
Stage: FeatureStageExperimental,
Owner: awsDatasourcesSquad,
Owner: grafanaPartnerPluginsSquad,
},
{
Name: "datasourceConnectionsTab",
@@ -1963,6 +1970,13 @@ var (
RequiresRestart: false,
HideFromDocs: false,
},
{
Name: "kubernetesAlertingHistorian",
Description: "Adds support for Kubernetes alerting historian APIs",
Stage: FeatureStageExperimental,
Owner: grafanaAlertingSquad,
RequiresRestart: true,
},
}
)
+3 -1
View File
@@ -80,6 +80,7 @@ dashboardSceneForViewers,GA,@grafana/dashboards-squad,false,false,true
dashboardSceneSolo,GA,@grafana/dashboards-squad,false,false,true
dashboardScene,GA,@grafana/dashboards-squad,false,false,true
dashboardNewLayouts,experimental,@grafana/dashboards-squad,false,false,false
kubernetesDashboardsV2,experimental,@grafana/dashboards-squad,false,false,false
dashboardUndoRedo,experimental,@grafana/dashboards-squad,false,false,true
unlimitedLayoutsNesting,experimental,@grafana/dashboards-squad,false,false,true
perPanelNonApplicableDrilldowns,experimental,@grafana/dashboards-squad,false,false,true
@@ -181,7 +182,7 @@ k8SFolderMove,experimental,@grafana/search-and-storage,false,false,false
improvedExternalSessionHandlingSAML,GA,@grafana/identity-access-team,false,false,false
teamHttpHeadersTempo,experimental,@grafana/identity-access-team,false,false,false
grafanaAdvisor,privatePreview,@grafana/plugins-platform-backend,false,false,false
elasticsearchImprovedParsing,experimental,@grafana/aws-datasources,false,false,false
elasticsearchImprovedParsing,experimental,@grafana/partner-datasources,false,false,false
datasourceConnectionsTab,privatePreview,@grafana/plugins-platform-backend,false,false,true
fetchRulesUsingPost,experimental,@grafana/alerting-squad,false,false,false
newLogsPanel,GA,@grafana/observability-logs,false,false,true
@@ -266,3 +267,4 @@ transformationsEmptyPlaceholder,preview,@grafana/datapro,false,false,true
ttlPluginInstanceManager,experimental,@grafana/plugins-platform-backend,false,false,true
lokiQueryLimitsContext,experimental,@grafana/observability-logs,false,false,true
rudderstackUpgrade,experimental,@grafana/grafana-frontend-platform,false,false,true
kubernetesAlertingHistorian,experimental,@grafana/alerting-squad,false,true,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
80 dashboardSceneSolo GA @grafana/dashboards-squad false false true
81 dashboardScene GA @grafana/dashboards-squad false false true
82 dashboardNewLayouts experimental @grafana/dashboards-squad false false false
83 kubernetesDashboardsV2 experimental @grafana/dashboards-squad false false false
84 dashboardUndoRedo experimental @grafana/dashboards-squad false false true
85 unlimitedLayoutsNesting experimental @grafana/dashboards-squad false false true
86 perPanelNonApplicableDrilldowns experimental @grafana/dashboards-squad false false true
182 improvedExternalSessionHandlingSAML GA @grafana/identity-access-team false false false
183 teamHttpHeadersTempo experimental @grafana/identity-access-team false false false
184 grafanaAdvisor privatePreview @grafana/plugins-platform-backend false false false
185 elasticsearchImprovedParsing experimental @grafana/aws-datasources @grafana/partner-datasources false false false
186 datasourceConnectionsTab privatePreview @grafana/plugins-platform-backend false false true
187 fetchRulesUsingPost experimental @grafana/alerting-squad false false false
188 newLogsPanel GA @grafana/observability-logs false false true
267 ttlPluginInstanceManager experimental @grafana/plugins-platform-backend false false true
268 lokiQueryLimitsContext experimental @grafana/observability-logs false false true
269 rudderstackUpgrade experimental @grafana/grafana-frontend-platform false false true
270 kubernetesAlertingHistorian experimental @grafana/alerting-squad false true false
+8
View File
@@ -259,6 +259,10 @@ const (
// Enables experimental new dashboard layouts
FlagDashboardNewLayouts = "dashboardNewLayouts"
// FlagKubernetesDashboardsV2
// Use the v2 kubernetes API in the frontend for dashboards
FlagKubernetesDashboardsV2 = "kubernetesDashboardsV2"
// FlagPdfTables
// Enables generating table data as PDF in reporting
FlagPdfTables = "pdfTables"
@@ -757,4 +761,8 @@ const (
// FlagAwsDatasourcesHttpProxy
// Enables http proxy settings for aws datasources
FlagAwsDatasourcesHttpProxy = "awsDatasourcesHttpProxy"
// FlagKubernetesAlertingHistorian
// Adds support for Kubernetes alerting historian APIs
FlagKubernetesAlertingHistorian = "kubernetesAlertingHistorian"
)
+47 -3
View File
@@ -1214,13 +1214,16 @@
{
"metadata": {
"name": "elasticsearchImprovedParsing",
"resourceVersion": "1763734583253",
"creationTimestamp": "2025-01-15T17:05:54Z"
"resourceVersion": "1764260048941",
"creationTimestamp": "2025-01-15T17:05:54Z",
"annotations": {
"grafana.app/updatedTimestamp": "2025-11-27 16:14:08.941633 +0000 UTC"
}
},
"spec": {
"description": "Enables less memory intensive Elasticsearch result parsing",
"stage": "experimental",
"codeowner": "@grafana/aws-datasources"
"codeowner": "@grafana/partner-datasources"
}
},
{
@@ -1790,6 +1793,19 @@
"requiresRestart": true
}
},
{
"metadata": {
"name": "kubernetesAlertingHistorian",
"resourceVersion": "1764257713773",
"creationTimestamp": "2025-11-27T15:35:13Z"
},
"spec": {
"description": "Adds support for Kubernetes alerting historian APIs",
"stage": "experimental",
"codeowner": "@grafana/alerting-squad",
"requiresRestart": true
}
},
{
"metadata": {
"name": "kubernetesAlertingRules",
@@ -1911,6 +1927,18 @@
"expression": "true"
}
},
{
"metadata": {
"name": "kubernetesDashboardsV2",
"resourceVersion": "1764236054307",
"creationTimestamp": "2025-11-27T09:34:14Z"
},
"spec": {
"description": "Use the v2 kubernetes API in the frontend for dashboards",
"stage": "experimental",
"codeowner": "@grafana/dashboards-squad"
}
},
{
"metadata": {
"name": "kubernetesExternalGroupMapping",
@@ -3547,6 +3575,22 @@
"expression": "true"
}
},
{
"metadata": {
"name": "v2DashboardAPIVersion",
"resourceVersion": "1762457740470",
"creationTimestamp": "2025-11-06T19:22:05Z",
"deletionTimestamp": "2025-11-27T09:34:14Z",
"annotations": {
"grafana.app/updatedTimestamp": "2025-11-06 19:35:40.470587 +0000 UTC"
}
},
"spec": {
"description": "Enables the v2 dashboard API version",
"stage": "experimental",
"codeowner": "@grafana/dashboards-squad"
}
},
{
"metadata": {
"name": "vizActionsAuth",
+37
View File
@@ -239,6 +239,30 @@
const CHECK_INTERVAL = 1 * 1000;
function getCookie(name) {
const cookies = document.cookie.split(";").map(c => c.trim());
for (const cookie of cookies) {
if (cookie.startsWith(name + "=")) {
return cookie.substring(name.length + 1);
}
}
return null;
}
function getSessionExpiration() {
const value = getCookie("grafana_session_expiry") || "0";
const realExpiresSeconds = parseInt(value, 10);
const expiresSeconds = Math.max(realExpiresSeconds - 10, 0); // Rotate 10s before the real expiration
const expiration = new Date(expiresSeconds * 1000);
return expiration;
}
async function rotateSession() {
await fetch('/api/user/auth-tokens/rotate', { method: 'POST' });
}
/**
* Fetches boot data from the server. If it returns undefined, it should be retried later.
* Will return a rejected promise on unrecoverable errors.
@@ -295,6 +319,19 @@
function loadBootData() {
return new Promise((resolve, reject) => {
const attemptFetch = async () => {
try {
const sessionExpiration = getSessionExpiration();
const now = new Date();
// If the session has expired, don't continue trying to fetch boot data
if (now >= sessionExpiration) {
await rotateSession();
}
} catch (error) {
// Just ignore any errors in session rotation. The user can just log in again.
console.warn("Failed to rotate session", error);
}
try {
const bootData = await fetchBootData();
+55 -43
View File
@@ -4,11 +4,14 @@ import (
"context"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/infra/log"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/ngalert/eval"
@@ -24,55 +27,64 @@ type HistorySrv struct {
hist Historian
}
const labelQueryPrefix = "labels_"
func (srv *HistorySrv) RouteQueryStateHistory(c *contextmodel.ReqContext) response.Response {
from := c.QueryInt64("from")
to := c.QueryInt64("to")
limit := c.QueryInt("limit")
ruleUID := c.Query("ruleUID")
dashUID := c.Query("dashboardUID")
panelID := c.QueryInt64("panelID")
previous := c.Query("previous")
if previous != "" {
_, err := eval.ParseStateString(previous)
if err != nil {
return ErrResp(http.StatusBadRequest, fmt.Errorf("invalid previous state filter: %w", err), "")
}
query, err := ParseHistoryQuery(c.OrgID, c.SignedInUser, c.Req.URL.Query())
if err != nil {
return ErrResp(http.StatusBadRequest, err, "")
}
current := c.Query("current")
if current != "" {
_, err := eval.ParseStateString(current)
if err != nil {
return ErrResp(http.StatusBadRequest, fmt.Errorf("invalid current state filter: %w", err), "")
}
}
labels := make(map[string]string)
for k, v := range c.Req.URL.Query() {
if strings.HasPrefix(k, labelQueryPrefix) {
labels[k[len(labelQueryPrefix):]] = v[0]
}
}
query := models.HistoryQuery{
RuleUID: ruleUID,
OrgID: c.GetOrgID(),
DashboardUID: dashUID,
PanelID: panelID,
Previous: previous,
Current: current,
SignedInUser: c.SignedInUser,
From: time.Unix(from, 0),
To: time.Unix(to, 0),
Limit: limit,
Labels: labels,
}
frame, err := srv.hist.Query(c.Req.Context(), query)
if err != nil {
return ErrResp(http.StatusInternalServerError, err, "")
}
return response.JSON(http.StatusOK, frame)
}
const labelQueryPrefix = "labels_"
// ParseHistoryQuery parses a HistoryQuery from request parameters.
func ParseHistoryQuery(orgID int64, user identity.Requester, query url.Values) (models.HistoryQuery, error) {
from, _ := strconv.ParseInt(query.Get("from"), 10, 64)
to, _ := strconv.ParseInt(query.Get("to"), 10, 64)
limit, _ := strconv.Atoi(query.Get("limit"))
ruleUID := query.Get("ruleUID")
dashUID := query.Get("dashboardUID")
panelID, _ := strconv.ParseInt(query.Get("panelID"), 10, 64)
previous := query.Get("previous")
if previous != "" {
_, err := eval.ParseStateString(previous)
if err != nil {
return models.HistoryQuery{}, fmt.Errorf("invalid previous state filter: %w", err)
}
}
current := query.Get("current")
if current != "" {
_, err := eval.ParseStateString(current)
if err != nil {
return models.HistoryQuery{}, fmt.Errorf("invalid current state filter: %w", err)
}
}
labels := make(map[string]string)
for k, v := range query {
if strings.HasPrefix(k, labelQueryPrefix) {
labels[k[len(labelQueryPrefix):]] = v[0]
}
}
return models.HistoryQuery{
RuleUID: ruleUID,
OrgID: orgID,
DashboardUID: dashUID,
PanelID: panelID,
Previous: previous,
Current: current,
SignedInUser: user,
From: time.Unix(from, 0),
To: time.Unix(to, 0),
Limit: limit,
Labels: labels,
}, nil
}
@@ -205,11 +205,23 @@ func (w *DatasourceWriter) makeWriter(ctx context.Context, orgID int64, dsUID st
return nil, err
}
// We need to add the writer headers (valid for any data source) and any data-source-specific headers.
headers := make(http.Header)
for k, v := range w.cfg.CustomHeaders {
headers.Add(k, v)
}
dsHeaders, err := w.datasources.CustomHeaders(ctx, ds)
if err != nil {
return nil, fmt.Errorf("failed to get headers for data source: %w", err)
}
for k, values := range dsHeaders {
for _, v := range values {
headers.Add(k, v)
}
}
var backend backendType
if dsUID == string(grafanaCloudPromType) {
backend = grafanaCloudPromType
@@ -56,13 +56,14 @@ func (m *mockHTTPClientProvider) New(options ...sdkhttpclient.Options) (*http.Cl
type testDataSources struct {
dsfakes.FakeDataSourceService
prom1, prom2, prom3 *TestRemoteWriteTarget
prom1, prom2, prom3, prom4 *TestRemoteWriteTarget
}
func (t *testDataSources) Reset() {
t.prom1.Reset()
t.prom2.Reset()
t.prom3.Reset()
t.prom4.Reset()
}
func setupDataSources(t *testing.T) *testDataSources {
@@ -70,7 +71,9 @@ func setupDataSources(t *testing.T) *testDataSources {
prom1: NewTestRemoteWriteTarget(t),
prom2: NewTestRemoteWriteTarget(t),
prom3: NewTestRemoteWriteTarget(t),
prom4: NewTestRemoteWriteTarget(t),
}
res.DataSourceHeaders = make(map[string]http.Header)
t.Cleanup(func() {
res.prom1.Close()
@@ -81,6 +84,9 @@ func setupDataSources(t *testing.T) *testDataSources {
t.Cleanup(func() {
res.prom3.Close()
})
t.Cleanup(func() {
res.prom4.Close()
})
p1, _ := res.AddDataSource(context.Background(), &datasources.AddDataSourceCommand{
Name: "prom-1",
@@ -107,7 +113,7 @@ func setupDataSources(t *testing.T) *testDataSources {
Type: datasources.DS_LOKI,
})
// Add a third Prometheus datasource that uses PDC
// Add a third Prometheus datasource that uses PDC.
p3, _ := res.AddDataSource(context.Background(), &datasources.AddDataSourceCommand{
Name: "prom-3",
UID: "prom-3",
@@ -123,6 +129,21 @@ func setupDataSources(t *testing.T) *testDataSources {
require.True(t, p3.IsSecureSocksDSProxyEnabled())
// Add a fourth Prometheus datasource with headers in the JSON config.
p4, _ := res.AddDataSource(context.Background(), &datasources.AddDataSourceCommand{
Name: "prom-4",
UID: "prom-4",
Type: datasources.DS_PROMETHEUS,
JsonData: simplejson.MustJson([]byte(`{"prometheusType":"Prometheus"}`)),
})
p4.URL = res.prom4.srv.URL
res.prom4.ExpectedPath = "/api/v1/write"
res.DataSourceHeaders["prom-4"] = http.Header{
"X-Scope-OrgID": []string{"test-user"},
"X-Test-Header": []string{"test-value"},
"X-Double-Header": []string{"one", "two", "three"},
}
return res
}
@@ -204,6 +225,45 @@ func TestDatasourceWriter(t *testing.T) {
assert.Equal(t, headers[header2], testDS.prom1.LastHeaders.Get(header2))
})
t.Run("when data source headers are configured, they are passed to the request", func(t *testing.T) {
testDS.Reset()
overwrittenHeader := "X-Test-Header"
cHeaders := map[string]string{
"X-Custom-Header": "test-value",
"X-Another-Header": "another-value",
overwrittenHeader: "overwritten", // Data source headers should be overwritten by custom headers.
}
cfg = DatasourceWriterConfig{
Timeout: time.Second * 5,
DefaultDatasourceUID: "prom-1",
CustomHeaders: cHeaders,
}
writer = NewDatasourceWriter(cfg, testDS, httpclient.NewProvider(), pluginContextProvider, clock.New(), log.New("test"), met)
uid := "prom-4"
err := writer.WriteDatasource(context.Background(), uid, "metric", time.Now(), frames, 1, map[string]string{})
require.NoError(t, err)
dsHeaders := testDS.DataSourceHeaders[uid]
require.Len(t, dsHeaders, 3)
// We're confirming we have a data source header with the same name but different value.
// This one should not be sent in the request.
require.NotEmpty(t, dsHeaders[overwrittenHeader])
require.NotEqual(t, dsHeaders[overwrittenHeader], cHeaders[overwrittenHeader])
// All headers (except for the one that was overwritten) should have been used.
for k, vv := range dsHeaders {
if k != overwrittenHeader {
assert.Equal(t, vv, testDS.prom4.LastHeaders.Values(k))
}
}
for k, v := range cHeaders {
assert.Equal(t, v, testDS.prom4.LastHeaders.Get(k))
}
})
t.Run("when PDC is enabled proxy options are passed to HTTP client provider", func(t *testing.T) {
testDS.Reset()
+2 -1
View File
@@ -1,6 +1,7 @@
package writer
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
@@ -37,7 +38,7 @@ func NewTestRemoteWriteTarget(t *testing.T) *TestRemoteWriteTarget {
handler := func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != target.ExpectedPath {
require.Fail(t, "Received unexpected request for endpoint %s", r.URL.Path)
require.Fail(t, fmt.Sprintf("Received unexpected request for endpoint %s", r.URL.Path))
}
target.mtx.Lock()
+9 -3
View File
@@ -581,10 +581,16 @@ func TestMain(m *testing.M) {
// nolint:staticcheck
testSQLStore.cfg.IsFeatureToggleEnabled = features.IsEnabledGlobally
if err := testSQLStore.dialect.TruncateDBTables(testSQLStore.GetEngine()); err != nil {
return nil, err
skipTruncate := false
if skip, present := os.LookupEnv("SKIP_DB_TRUNCATE"); present {
skipTruncate = strings.ToLower(skip) == "true"
}
if !skipTruncate {
if err := testSQLStore.dialect.TruncateDBTables(testSQLStore.GetEngine()); err != nil {
return nil, err
}
testSQLStore.engine.ResetSequenceGenerator()
}
testSQLStore.engine.ResetSequenceGenerator()
if err := testSQLStore.Reset(); err != nil {
return nil, err
+2
View File
@@ -615,6 +615,8 @@ type Cfg struct {
HttpsSkipVerify bool
ResourceServerJoinRingTimeout time.Duration
EnableSearch bool
OverridesFilePath string
OverridesReloadInterval time.Duration
// Secrets Management
SecretsManagement SecretsManagerSettings
+4
View File
@@ -94,6 +94,10 @@ func (cfg *Cfg) setUnifiedStorageConfig() {
cfg.HttpsSkipVerify = section.Key("https_skip_verify").MustBool(false)
cfg.ResourceServerJoinRingTimeout = section.Key("resource_server_join_ring_timeout").MustDuration(10 * time.Second)
// quotas/limits config
cfg.OverridesFilePath = section.Key("overrides_path").String()
cfg.OverridesReloadInterval = section.Key("overrides_reload_period").MustDuration(30 * time.Second)
cfg.MaxFileIndexAge = section.Key("max_file_index_age").MustDuration(0)
cfg.MinFileIndexBuildVersion = section.Key("min_file_index_build_version").MustString("")
}
@@ -54,6 +54,9 @@ func NewRESTOptionsGetterMemory(originalStorageConfig storagebackend.Config, sec
// Create BadgerDB with in-memory mode
db, err := badger.Open(badger.DefaultOptions("").
WithInMemory(true).
WithMemTableSize(256 << 10). // 256KB memtable size
WithValueThreshold(16 << 10). // 16KB threshold for storing values in LSM vs value log
WithNumMemtables(2). // Keep only 2 memtables in memory
WithLogger(nil))
if err != nil {
return nil, err
+13
View File
@@ -211,6 +211,19 @@ func newClient(opts options.StorageOptions,
serverOptions.QOSQueue = queue
}
// only enable if an overrides file path is provided
if cfg.OverridesFilePath != "" {
overridesSvc, err := resource.NewOverridesService(ctx, cfg.Logger, reg, tracer, resource.ReloadOptions{
FilePath: cfg.OverridesFilePath,
ReloadPeriod: cfg.OverridesReloadInterval,
})
if err != nil {
return nil, err
}
serverOptions.OverridesService = overridesSvc
}
server, err := sql.NewResourceServer(serverOptions)
if err != nil {
return nil, err
@@ -0,0 +1,208 @@
package migrations_test
import (
"fmt"
"net/http"
"testing"
authlib "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/tests/apis"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/runtime/schema"
)
// foldersAndDashboardsTestCase tests the "folders-dashboards" ResourceMigration
type foldersAndDashboardsTestCase struct {
parentFolderUID string
childFolderUID string
dashboardUID string
libPanelUID string
}
// newFoldersAndDashboardsTestCase creates a test case for the compound folders+dashboards migrator
func newFoldersAndDashboardsTestCase() resourceMigratorTestCase {
return &foldersAndDashboardsTestCase{
parentFolderUID: "parent-folder-uid",
childFolderUID: "child-folder-uid",
dashboardUID: "", // Will be generated during setup
libPanelUID: "", // Will be generated during setup
}
}
func (tc *foldersAndDashboardsTestCase) name() string {
return "folders-dashboards"
}
func (tc *foldersAndDashboardsTestCase) resources() []schema.GroupVersionResource {
return []schema.GroupVersionResource{
{
Group: "folder.grafana.app",
Version: "v1beta1",
Resource: "folders",
},
{
Group: "dashboard.grafana.app",
Version: "v1beta1",
Resource: "dashboards",
},
}
}
func (tc *foldersAndDashboardsTestCase) setup(t *testing.T, helper *apis.K8sTestHelper) {
t.Helper()
// Create parent folder
parent := createTestFolder(t, helper, tc.parentFolderUID, "parent-folder", "")
// Create child folder (nested under parent)
child := createTestFolder(t, helper, tc.childFolderUID, "child-folder", parent.UID)
// Create library panel in child folder
tc.libPanelUID = createTestLibraryPanel(t, helper, "Test Library Panel", child.UID)
// Create dashboard with library panel in child folder
tc.dashboardUID = createTestDashboardWithLibraryPanel(t, helper, "dashboard-with-library-panel",
tc.libPanelUID, "Test LP in dashboard", child.UID)
}
func (tc *foldersAndDashboardsTestCase) verify(t *testing.T, helper *apis.K8sTestHelper, shouldExist bool) {
t.Helper()
// Build maps of UIDs by resource type
folderUIDs := []string{tc.parentFolderUID, tc.childFolderUID}
dashboardUIDs := []string{tc.dashboardUID}
expectedFolderCount := 0
if shouldExist {
expectedFolderCount = len(folderUIDs)
}
orgID := helper.Org1.OrgID
namespace := authlib.OrgNamespaceFormatter(orgID)
// Verify folders
folderCli := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
Namespace: namespace,
GVR: schema.GroupVersionResource{
Group: "folder.grafana.app",
Version: "v1beta1",
Resource: "folders",
},
})
verifyResourceCount(t, folderCli, expectedFolderCount)
for _, uid := range folderUIDs {
verifyResource(t, folderCli, uid, shouldExist)
}
// Verify dashboards
expectedDashboardCount := 0
if shouldExist {
expectedDashboardCount = len(dashboardUIDs)
}
dashboardCli := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
Namespace: namespace,
GVR: schema.GroupVersionResource{
Group: "dashboard.grafana.app",
Version: "v1beta1",
Resource: "dashboards",
},
})
verifyResourceCount(t, dashboardCli, expectedDashboardCount)
for _, uid := range dashboardUIDs {
verifyResource(t, dashboardCli, uid, shouldExist)
}
}
// createTestFolder creates a folder with specified UID and optional parent
func createTestFolder(t *testing.T, helper *apis.K8sTestHelper, uid, title, parentUID string) *folder.Folder {
t.Helper()
payload := fmt.Sprintf(`{
"title": "%s",
"uid": "%s"`, title, uid)
if parentUID != "" {
payload += fmt.Sprintf(`,
"parentUid": "%s"`, parentUID)
}
payload += "}"
folderCreate := apis.DoRequest(helper, apis.RequestParams{
User: helper.Org1.Admin,
Method: http.MethodPost,
Path: "/api/folders",
Body: []byte(payload),
}, &folder.Folder{})
require.NotNil(t, folderCreate.Result)
require.Equal(t, uid, folderCreate.Result.UID)
return folderCreate.Result
}
// createTestLibraryPanel creates a library panel in a folder
func createTestLibraryPanel(t *testing.T, helper *apis.K8sTestHelper, name, folderUID string) string {
t.Helper()
libPanelPayload := fmt.Sprintf(`{
"kind": 1,
"name": "%s",
"folderUid": "%s",
"model": {
"type": "text",
"title": "%s"
}
}`, name, folderUID, name)
libCreate := apis.DoRequest(helper, apis.RequestParams{
User: helper.Org1.Admin,
Method: http.MethodPost,
Path: "/api/library-elements",
Body: []byte(libPanelPayload),
}, &map[string]interface{}{})
require.NotNil(t, libCreate.Response)
require.Equal(t, http.StatusOK, libCreate.Response.StatusCode)
libPanelUID := (*libCreate.Result)["result"].(map[string]interface{})["uid"].(string)
require.NotEmpty(t, libPanelUID)
return libPanelUID
}
// createTestDashboardWithLibraryPanel creates a dashboard that uses a library panel
func createTestDashboardWithLibraryPanel(t *testing.T, helper *apis.K8sTestHelper, dashTitle, libPanelUID, libPanelName, folderUID string) string {
t.Helper()
dashPayload := fmt.Sprintf(`{
"dashboard": {
"title": "%s",
"panels": [{
"id": 1,
"libraryPanel": {
"uid": "%s",
"name": "%s"
}
}]
},
"folderUid": "%s",
"overwrite": false
}`, dashTitle, libPanelUID, libPanelName, folderUID)
dashCreate := apis.DoRequest(helper, apis.RequestParams{
User: helper.Org1.Admin,
Method: http.MethodPost,
Path: "/api/dashboards/db",
Body: []byte(dashPayload),
}, &map[string]interface{}{})
require.NotNil(t, dashCreate.Response)
require.Equal(t, http.StatusOK, dashCreate.Response.StatusCode)
dashUID := (*dashCreate.Result)["uid"].(string)
require.NotEmpty(t, dashUID)
return dashUID
}
@@ -0,0 +1,210 @@
package migrations_test
import (
"context"
"fmt"
"os"
"testing"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tests/apis"
"github.com/grafana/grafana/pkg/tests/testinfra"
"github.com/grafana/grafana/pkg/tests/testsuite"
"github.com/grafana/grafana/pkg/util/testutil"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
)
func TestMain(m *testing.M) {
testsuite.Run(m)
}
// resourceMigratorTestCase defines the interface for testing a resource migrator.
type resourceMigratorTestCase interface {
// name returns the test case name
name() string
// resources returns the GVRs that this migrator handles
resources() []schema.GroupVersionResource
// setup creates test resources in legacy storage (Mode0)
setup(t *testing.T, helper *apis.K8sTestHelper)
// verify checks that resources exist (or don't exist) in unified storage
verify(t *testing.T, helper *apis.K8sTestHelper, shouldExist bool)
}
// TestIntegrationMigrations verifies that legacy storage data is correctly migrated to unified storage.
// The test follows a three-step process:
// Step 1: inserts legacy data (migration disabled at startup)
// Step 2: verifies that the data is not in unified storage
// Step 3: migration runs at startup, and the test verifies that the data is in unified storage
func TestIntegrationMigrations(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
migrationTestCases := []resourceMigratorTestCase{
newFoldersAndDashboardsTestCase(),
}
runMigrationTestSuite(t, migrationTestCases)
}
// runMigrationTestSuite executes the migration test suite for the given test cases
func runMigrationTestSuite(t *testing.T, testCases []resourceMigratorTestCase) {
if db.IsTestDbSQLite() {
// Share the same SQLite DB file between steps
tmpDir := t.TempDir()
dbPath := tmpDir + "/shared-migration-test-suite.db"
oldVal := os.Getenv("SQLITE_TEST_DB")
require.NoError(t, os.Setenv("SQLITE_TEST_DB", dbPath))
t.Cleanup(func() {
if oldVal == "" {
_ = os.Unsetenv("SQLITE_TEST_DB")
} else {
_ = os.Setenv("SQLITE_TEST_DB", oldVal)
}
})
t.Logf("Using shared database path: %s", dbPath)
}
// Store UIDs created by each test case
type testCaseState struct {
tc resourceMigratorTestCase
}
testStates := make([]testCaseState, len(testCases))
for i, tc := range testCases {
testStates[i].tc = tc
}
// reuse org users throughout the tests
var org1 *apis.OrgUsers
var orgB *apis.OrgUsers
t.Run("Step 1: Create data in legacy", func(t *testing.T) {
// Enforce Mode0 for all migrated resources
unifiedConfig := make(map[string]setting.UnifiedStorageConfig)
for _, tc := range testCases {
for _, gvr := range tc.resources() {
resourceKey := fmt.Sprintf("%s.%s", gvr.Resource, gvr.Group)
unifiedConfig[resourceKey] = setting.UnifiedStorageConfig{
DualWriterMode: grafanarest.Mode0,
}
}
}
// Set up test environment with Mode0 (writes only to legacy)
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
DisableDataMigrations: true,
DisableDBCleanup: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: unifiedConfig,
})
t.Cleanup(helper.Shutdown)
org1 = &helper.Org1
orgB = &helper.OrgB
for i := range testStates {
state := &testStates[i]
t.Run(state.tc.name(), func(t *testing.T) {
state.tc.setup(t, helper)
// Verify resources were created in legacy storage
state.tc.verify(t, helper, true)
})
}
})
// Set SKIP_DB_TRUNCATE to not truncate the data created in Step 1
oldSkipTruncate := os.Getenv("SKIP_DB_TRUNCATE")
require.NoError(t, os.Setenv("SKIP_DB_TRUNCATE", "true"))
t.Cleanup(func() {
if oldSkipTruncate == "" {
_ = os.Unsetenv("SKIP_DB_TRUNCATE")
} else {
_ = os.Setenv("SKIP_DB_TRUNCATE", oldSkipTruncate)
}
})
t.Run("Step 2: Verify data is NOT in unified storage before the migration", func(t *testing.T) {
// Build unified storage config for Mode5
unifiedConfig := make(map[string]setting.UnifiedStorageConfig)
for _, tc := range testCases {
for _, gvr := range tc.resources() {
resourceKey := fmt.Sprintf("%s.%s", gvr.Resource, gvr.Group)
unifiedConfig[resourceKey] = setting.UnifiedStorageConfig{
DualWriterMode: grafanarest.Mode5,
}
}
}
helper := apis.NewK8sTestHelperWithOpts(t, apis.K8sTestHelperOpts{
GrafanaOpts: testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
DisableDataMigrations: true,
DisableDBCleanup: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: unifiedConfig,
},
Org1Users: org1,
OrgBUsers: orgB,
})
t.Cleanup(helper.Shutdown)
for _, state := range testStates {
t.Run(state.tc.name(), func(t *testing.T) {
// Verify resources don't exist in unified storage yet
state.tc.verify(t, helper, false)
})
}
})
t.Run("Step 3: verify data is migrated to unified storage", func(t *testing.T) {
// Migrations will run automatically at startup and mode 5 is enforced by the config
helper := apis.NewK8sTestHelperWithOpts(t, apis.K8sTestHelperOpts{
GrafanaOpts: testinfra.GrafanaOpts{
// EnableLog: true,
AppModeProduction: true,
DisableAnonymous: true,
DisableDataMigrations: false, // Run migrations at startup
APIServerStorageType: "unified",
},
Org1Users: org1,
OrgBUsers: orgB,
})
t.Cleanup(helper.Shutdown)
for _, state := range testStates {
t.Run(state.tc.name(), func(t *testing.T) {
// Verify resources now exist in unified storage after migration
state.tc.verify(t, helper, true)
})
}
})
}
// verifyResourceCount verifies that the expected number of resources exist in K8s storage
func verifyResourceCount(t *testing.T, client *apis.K8sResourceClient, expectedCount int) {
t.Helper()
l, err := client.Resource.List(context.Background(), metav1.ListOptions{})
require.NoError(t, err)
resources, err := meta.ExtractList(l)
require.NoError(t, err)
require.Equal(t, expectedCount, len(resources))
}
// verifyResource verifies that a resource with the given UID exists in K8s storage
func verifyResource(t *testing.T, client *apis.K8sResourceClient, uid string, shouldExist bool) {
t.Helper()
_, err := client.Resource.Get(context.Background(), uid, metav1.GetOptions{})
if shouldExist {
require.NoError(t, err)
} else {
require.Error(t, err)
}
}
+33 -7
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"github.com/grafana/dskit/backoff"
"github.com/grafana/grafana-app-sdk/logging"
gocache "github.com/patrickmn/go-cache"
@@ -13,8 +14,8 @@ import (
const (
defaultLookbackPeriod = 30 * time.Second
defaultPollInterval = 100 * time.Millisecond
defaultEventCacheSize = 10000
defaultMinBackoff = 100 * time.Millisecond
defaultMaxBackoff = 5 * time.Second
defaultBufferSize = 10000
)
@@ -29,15 +30,17 @@ type notifierOptions struct {
type watchOptions struct {
LookbackPeriod time.Duration // How far back to look for events
PollInterval time.Duration // How often to poll for new events
BufferSize int // How many events to buffer
MinBackoff time.Duration // Minimum interval between polling requests
MaxBackoff time.Duration // Maximum interval between polling requests
}
func defaultWatchOptions() watchOptions {
return watchOptions{
LookbackPeriod: defaultLookbackPeriod,
PollInterval: defaultPollInterval,
BufferSize: defaultBufferSize,
MinBackoff: defaultMinBackoff,
MaxBackoff: defaultMaxBackoff,
}
}
@@ -62,9 +65,13 @@ func (n *notifier) cacheKey(evt Event) string {
}
func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event {
if opts.PollInterval <= 0 {
opts.PollInterval = defaultPollInterval
if opts.MinBackoff <= 0 {
opts.MinBackoff = defaultMinBackoff
}
if opts.MaxBackoff <= 0 || opts.MaxBackoff <= opts.MinBackoff {
opts.MaxBackoff = defaultMaxBackoff
}
cacheTTL := opts.LookbackPeriod
cacheCleanupInterval := 2 * opts.LookbackPeriod
@@ -81,11 +88,21 @@ func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event {
go func() {
defer close(events)
// Initialize backoff with minimum backoff interval
currentInterval := opts.MinBackoff
backoffConfig := backoff.Config{
MinBackoff: opts.MinBackoff,
MaxBackoff: opts.MaxBackoff,
MaxRetries: 0, // infinite retries
}
bo := backoff.New(ctx, backoffConfig)
for {
select {
case <-ctx.Done():
return
case <-time.After(opts.PollInterval):
case <-time.After(currentInterval):
foundEvents := false
for evt, err := range n.eventStore.ListSince(ctx, subtractDurationFromSnowflake(lastRV, opts.LookbackPeriod)) {
if err != nil {
n.log.Error("Failed to list events since", "error", err)
@@ -102,6 +119,7 @@ func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event {
continue
}
foundEvents = true
if evt.ResourceVersion > lastRV {
lastRV = evt.ResourceVersion + 1
}
@@ -113,6 +131,14 @@ func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event {
return
}
}
// Apply backoff logic: reset to min when events are found, increase when no events
if foundEvents {
bo.Reset()
currentInterval = opts.MinBackoff
} else {
currentInterval = bo.NextDelay()
}
}
}
}()
+10 -6
View File
@@ -32,7 +32,6 @@ func TestDefaultWatchOptions(t *testing.T) {
opts := defaultWatchOptions()
assert.Equal(t, defaultLookbackPeriod, opts.LookbackPeriod)
assert.Equal(t, defaultPollInterval, opts.PollInterval)
assert.Equal(t, defaultBufferSize, opts.BufferSize)
}
@@ -158,8 +157,9 @@ func TestNotifier_Watch_NoEvents(t *testing.T) {
opts := watchOptions{
LookbackPeriod: 100 * time.Millisecond,
PollInterval: 50 * time.Millisecond,
BufferSize: 10,
MinBackoff: 50 * time.Millisecond,
MaxBackoff: 500 * time.Millisecond,
}
events := notifier.Watch(ctx, opts)
@@ -210,8 +210,9 @@ func TestNotifier_Watch_WithExistingEvents(t *testing.T) {
opts := watchOptions{
LookbackPeriod: 100 * time.Millisecond,
PollInterval: 50 * time.Millisecond,
BufferSize: 10,
MinBackoff: 50 * time.Millisecond,
MaxBackoff: 500 * time.Millisecond,
}
// Start watching
@@ -265,8 +266,9 @@ func TestNotifier_Watch_EventDeduplication(t *testing.T) {
opts := watchOptions{
LookbackPeriod: time.Second,
PollInterval: 20 * time.Millisecond,
BufferSize: 10,
MinBackoff: 20 * time.Millisecond,
MaxBackoff: 200 * time.Millisecond,
}
// Start watching
@@ -326,8 +328,9 @@ func TestNotifier_Watch_ContextCancellation(t *testing.T) {
opts := watchOptions{
LookbackPeriod: 100 * time.Millisecond,
PollInterval: 20 * time.Millisecond,
BufferSize: 10,
MinBackoff: 20 * time.Millisecond,
MaxBackoff: 200 * time.Millisecond,
}
events := notifier.Watch(ctx, opts)
@@ -369,8 +372,9 @@ func TestNotifier_Watch_MultipleEvents(t *testing.T) {
opts := watchOptions{
LookbackPeriod: time.Second,
PollInterval: 20 * time.Millisecond,
BufferSize: 10,
MinBackoff: 20 * time.Millisecond,
MaxBackoff: 200 * time.Millisecond,
}
// Start watching
+127
View File
@@ -0,0 +1,127 @@
package resource
import (
"context"
"fmt"
"io"
"os"
"strings"
"time"
"github.com/grafana/dskit/runtimeconfig"
"github.com/grafana/dskit/services"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/prometheus/client_golang/prometheus"
"go.opentelemetry.io/otel/trace"
"go.yaml.in/yaml/v3"
)
const DEFAULT_RESOURCE_LIMIT = 1000
type OverridesService struct {
manager *runtimeconfig.Manager
logger log.Logger
tracer trace.Tracer
}
type ReloadOptions struct {
FilePath string
ReloadPeriod time.Duration
}
// ResourceQuota represents quota limits for a specific resource
type ResourceQuota struct {
Limit int `yaml:"limit"`
}
// NamespaceOverrides represents all overrides for a tenant
type NamespaceOverrides struct {
Quotas map[string]ResourceQuota `yaml:"quotas"`
}
// Overrides represents the entire overrides configuration file
type Overrides struct {
Namespaces map[string]NamespaceOverrides
}
/*
This service loads overrides (currently just quotas) from a YAML file with the following yaml structure:
"123":
quotas:
grafana.dashboard.app/dashboards:
limit: 1500
grafana.folder.app/folders:
limit: 1500
*/
func NewOverridesService(_ context.Context, logger log.Logger, reg prometheus.Registerer, tracer trace.Tracer, opts ReloadOptions) (*OverridesService, error) {
// shouldn't be empty since we use file path existence to determine if we should enable the service
if opts.FilePath == "" {
return nil, fmt.Errorf("overrides file path is required")
}
if opts.ReloadPeriod == 0 {
opts.ReloadPeriod = time.Second * 30
}
// Check if file exists
if _, err := os.Stat(opts.FilePath); err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("overrides file does not exist: %s", opts.FilePath)
}
return nil, fmt.Errorf("failed to stat overrides file: %w", err)
}
config := runtimeconfig.Config{
ReloadPeriod: opts.ReloadPeriod,
LoadPath: []string{opts.FilePath},
Loader: func(r io.Reader) (interface{}, error) {
var tenants map[string]NamespaceOverrides
decoder := yaml.NewDecoder(r)
if err := decoder.Decode(&tenants); err != nil {
return nil, err
}
return &Overrides{Namespaces: tenants}, nil
},
}
manager, err := runtimeconfig.New(config, "tenant-overrides", reg, logger)
if err != nil {
return nil, err
}
return &OverridesService{
manager: manager,
logger: logger,
tracer: tracer,
}, nil
}
func (q *OverridesService) init(ctx context.Context) error {
return services.StartAndAwaitRunning(ctx, q.manager)
}
func (q *OverridesService) stop(ctx context.Context) error {
return services.StopAndAwaitTerminated(ctx, q.manager)
}
func (q *OverridesService) GetQuota(_ context.Context, nsr NamespacedResource) (ResourceQuota, error) {
if nsr.Namespace == "" || nsr.Resource == "" || nsr.Group == "" {
return ResourceQuota{}, fmt.Errorf("invalid namespaced resource: %+v", nsr)
}
overrides, ok := q.manager.GetConfig().(*Overrides)
if !ok {
return ResourceQuota{}, fmt.Errorf("failed to get quota overrides from config manager")
}
tenantId := strings.TrimPrefix(nsr.Namespace, "stacks-")
groupResource := nsr.Group + "/" + nsr.Resource
if tenantOverrides, ok := overrides.Namespaces[tenantId]; ok {
if resourceQuota, ok := tenantOverrides.Quotas[groupResource]; ok {
return resourceQuota, nil
}
}
return ResourceQuota{Limit: DEFAULT_RESOURCE_LIMIT}, nil
}
+427
View File
@@ -0,0 +1,427 @@
package resource
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewQuotaService(t *testing.T) {
tests := []struct {
name string
opts ReloadOptions
setupFile func(t *testing.T) string
expectError bool
errorMsg string
}{
{
name: "success with valid file",
opts: ReloadOptions{},
setupFile: func(t *testing.T) string {
tmpFile := filepath.Join(t.TempDir(), "overrides.yaml")
content := `"123":
quotas:
grafana.dashboard.app/dashboards:
limit: 1500
`
require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644))
return tmpFile
},
expectError: false,
},
{
name: "success with custom reload period",
opts: ReloadOptions{
ReloadPeriod: time.Minute,
},
setupFile: func(t *testing.T) string {
tmpFile := filepath.Join(t.TempDir(), "overrides.yaml")
require.NoError(t, os.WriteFile(tmpFile, []byte{}, 0644))
return tmpFile
},
expectError: false,
},
{
name: "error when file path is empty",
opts: ReloadOptions{
FilePath: "",
},
setupFile: func(t *testing.T) string { return "" },
expectError: true,
errorMsg: "overrides file path is required",
},
{
name: "error when file does not exist",
opts: ReloadOptions{
FilePath: "/nonexistent/path/overrides.yaml",
},
setupFile: func(t *testing.T) string { return "/nonexistent/path/overrides.yaml" },
expectError: true,
errorMsg: "overrides file does not exist",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
logger := log.NewNopLogger()
reg := prometheus.NewRegistry()
tcr := tracing.NewNoopTracerService()
filePath := tt.setupFile(t)
if filePath != "" && tt.opts.FilePath == "" {
tt.opts.FilePath = filePath
}
service, err := NewOverridesService(ctx, logger, reg, tcr, tt.opts)
if tt.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.errorMsg)
assert.Nil(t, service)
} else {
require.NoError(t, err)
assert.NotNil(t, service)
assert.NotNil(t, service.manager)
assert.NotNil(t, service.logger)
}
})
}
}
func TestQuotaService_ConfigReload(t *testing.T) {
ctx := context.Background()
logger := log.NewNopLogger()
reg := prometheus.NewRegistry()
tcr := tracing.NewNoopTracerService()
// Create a temporary config file
tmpFile := filepath.Join(t.TempDir(), "overrides.yaml")
initialConfig := `"123":
quotas:
grafana.dashboard.app/dashboards:
limit: 1500
`
require.NoError(t, os.WriteFile(tmpFile, []byte(initialConfig), 0644))
// Create service with a very short reload period
service, err := NewOverridesService(ctx, logger, reg, tcr, ReloadOptions{
FilePath: tmpFile,
ReloadPeriod: 100 * time.Millisecond, // Very short reload period for testing
})
require.NoError(t, err)
require.NotNil(t, service)
// Initialize the service
err = service.init(ctx)
require.NoError(t, err)
defer func(service *OverridesService, ctx context.Context) {
err := service.stop(ctx)
require.NoError(t, err)
}(service, ctx)
// Verify initial config
nsr := NamespacedResource{
Namespace: "stacks-123",
Group: "grafana.dashboard.app",
Resource: "dashboards",
}
quota, err := service.GetQuota(ctx, nsr)
require.NoError(t, err)
assert.Equal(t, 1500, quota.Limit, "initial quota should be 1500")
// Update the config file with new values
updatedConfig := `"123":
quotas:
grafana.dashboard.app/dashboards:
limit: 2500
"456":
quotas:
grafana.folder.app/folders:
limit: 3000
`
require.NoError(t, os.WriteFile(tmpFile, []byte(updatedConfig), 0644))
// Wait for the config to be reloaded (wait longer than reload period)
time.Sleep(500 * time.Millisecond)
// Verify the config was updated for existing tenant
quota, err = service.GetQuota(ctx, nsr)
require.NoError(t, err)
assert.Equal(t, 2500, quota.Limit, "quota should be updated to 2500")
// Verify new tenant config is also loaded
nsr2 := NamespacedResource{
Namespace: "stacks-456",
Group: "grafana.folder.app",
Resource: "folders",
}
quota2, err := service.GetQuota(ctx, nsr2)
require.NoError(t, err)
assert.Equal(t, 3000, quota2.Limit, "new tenant quota should be 3000")
}
func TestQuotaService_GetQuota(t *testing.T) {
tests := []struct {
name string
setupFile func(t *testing.T) string
nsr NamespacedResource
expectedLimit int
expectError bool
errorMsg string
description string
}{
{
name: "returns custom quota for matching tenant and resource",
setupFile: func(t *testing.T) string {
tmpFile := filepath.Join(t.TempDir(), "overrides.yaml")
content := `"123":
quotas:
grafana.dashboard.app/dashboards:
limit: 1500
`
require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644))
return tmpFile
},
nsr: NamespacedResource{
Namespace: "stacks-123",
Group: "grafana.dashboard.app",
Resource: "dashboards",
},
expectedLimit: 1500,
expectError: false,
description: "should return custom limit for matching tenant",
},
{
name: "returns default quota when tenant not found",
setupFile: func(t *testing.T) string {
tmpFile := filepath.Join(t.TempDir(), "overrides.yaml")
content := `"123":
quotas:
grafana.dashboard.app/dashboards:
limit: 1500
`
require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644))
return tmpFile
},
nsr: NamespacedResource{
Namespace: "stacks-456",
Group: "grafana.dashboard.app",
Resource: "dashboards",
},
expectedLimit: DEFAULT_RESOURCE_LIMIT,
expectError: false,
description: "should return default limit when tenant not found",
},
{
name: "returns default quota when resource not found",
setupFile: func(t *testing.T) string {
tmpFile := filepath.Join(t.TempDir(), "overrides.yaml")
content := `"123":
quotas:
grafana.dashboard.app/dashboards:
limit: 1500
`
require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644))
return tmpFile
},
nsr: NamespacedResource{
Namespace: "stacks-123",
Group: "grafana.folder.app",
Resource: "folders",
},
expectedLimit: DEFAULT_RESOURCE_LIMIT,
expectError: false,
description: "should return default limit when resource not found",
},
{
name: "handles namespace without stacks- prefix",
setupFile: func(t *testing.T) string {
tmpFile := filepath.Join(t.TempDir(), "overrides.yaml")
content := `"123":
quotas:
grafana.dashboard.app/dashboards:
limit: 1500
`
require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644))
return tmpFile
},
nsr: NamespacedResource{
Namespace: "123",
Group: "grafana.dashboard.app",
Resource: "dashboards",
},
expectedLimit: 1500,
expectError: false,
description: "should handle namespace without stacks- prefix",
},
{
name: "returns default quota when config is empty",
setupFile: func(t *testing.T) string {
tmpFile := filepath.Join(t.TempDir(), "overrides.yaml")
content := ""
require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644))
return tmpFile
},
nsr: NamespacedResource{
Namespace: "stacks-123",
Group: "grafana.dashboard.app",
Resource: "dashboards",
},
expectedLimit: DEFAULT_RESOURCE_LIMIT,
expectError: false,
description: "should return default limit when config is empty",
},
{
name: "handles multiple resources for same tenant",
setupFile: func(t *testing.T) string {
tmpFile := filepath.Join(t.TempDir(), "overrides.yaml")
content := `"123":
quotas:
grafana.dashboard.app/dashboards:
limit: 1500
grafana.folder.app/folders:
limit: 2500
`
require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644))
return tmpFile
},
nsr: NamespacedResource{
Namespace: "stacks-123",
Group: "grafana.folder.app",
Resource: "folders",
},
expectedLimit: 2500,
expectError: false,
description: "should return correct limit for specific resource",
},
{
name: "returns error when namespace is empty",
setupFile: func(t *testing.T) string {
tmpFile := filepath.Join(t.TempDir(), "overrides.yaml")
content := `"123":
quotas:
grafana.dashboard.app/dashboards:
limit: 1500
grafana.folder.app/folders:
limit: 2500
`
require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644))
return tmpFile
},
nsr: NamespacedResource{
Namespace: "",
Group: "grafana.dashboard.app",
Resource: "dashboards",
},
expectError: true,
errorMsg: "invalid namespaced resource",
description: "should return error when namespace is empty",
},
{
name: "returns error when group is empty",
setupFile: func(t *testing.T) string {
tmpFile := filepath.Join(t.TempDir(), "overrides.yaml")
content := `"123":
quotas:
grafana.dashboard.app/dashboards:
limit: 1500
grafana.folder.app/folders:
limit: 2500
`
require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644))
return tmpFile
},
nsr: NamespacedResource{
Namespace: "stacks-123",
Group: "",
Resource: "dashboards",
},
expectError: true,
errorMsg: "invalid namespaced resource",
description: "should return error when group is empty",
},
{
name: "returns error when resource is empty",
setupFile: func(t *testing.T) string {
tmpFile := filepath.Join(t.TempDir(), "overrides.yaml")
content := `"123":
quotas:
grafana.dashboard.app/dashboards:
limit: 1500
grafana.folder.app/folders:
limit: 2500
`
require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644))
return tmpFile
},
nsr: NamespacedResource{
Namespace: "stacks-123",
Group: "grafana.dashboard.app",
Resource: "",
},
expectError: true,
errorMsg: "invalid namespaced resource",
description: "should return error when resource is empty",
},
{
name: "returns error when all fields are empty",
setupFile: func(t *testing.T) string {
tmpFile := filepath.Join(t.TempDir(), "overrides.yaml")
content := `"123":
quotas:
grafana.dashboard.app/dashboards:
limit: 1500
grafana.folder.app/folders:
limit: 2500
`
require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644))
return tmpFile
},
nsr: NamespacedResource{
Namespace: "",
Group: "",
Resource: "",
},
expectError: true,
errorMsg: "invalid namespaced resource",
description: "should return error when all fields are empty",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
logger := log.NewNopLogger()
reg := prometheus.NewRegistry()
tcr := tracing.NewNoopTracerService()
opts := ReloadOptions{
FilePath: tt.setupFile(t),
}
service, err := NewOverridesService(ctx, logger, reg, tcr, opts)
require.NoError(t, err, "failed to create quota service")
err = service.init(ctx)
require.NoError(t, err, "failed to initialize quota service")
quota, err := service.GetQuota(ctx, tt.nsr)
if tt.expectError {
require.Error(t, err, tt.description)
assert.Contains(t, err.Error(), tt.errorMsg, tt.description)
assert.Equal(t, ResourceQuota{}, quota, "should return empty quota on error")
} else {
require.NoError(t, err, tt.description)
assert.Equal(t, tt.expectedLimit, quota.Limit, tt.description)
}
})
}
}
+67 -13
View File
@@ -14,6 +14,8 @@ import (
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
@@ -220,6 +222,9 @@ type ResourceServerOptions struct {
// Search options
Search SearchOptions
// Quota service
OverridesService *OverridesService
// Diagnostics
Diagnostics resourcepb.DiagnosticsServer
@@ -342,6 +347,7 @@ func NewResourceServer(opts ResourceServerOptions) (*server, error) {
reg: opts.Reg,
queue: opts.QOSQueue,
queueConfig: opts.QOSConfig,
overridesService: opts.OverridesService,
artificialSuccessfulWriteDelay: opts.Search.IndexMinUpdateInterval,
}
@@ -366,19 +372,20 @@ func NewResourceServer(opts ResourceServerOptions) (*server, error) {
var _ ResourceServer = &server{}
type server struct {
log log.Logger
backend StorageBackend
blob BlobSupport
secure secrets.InlineSecureValueSupport
search *searchSupport
diagnostics resourcepb.DiagnosticsServer
access claims.AccessClient
writeHooks WriteAccessHooks
lifecycle LifecycleHooks
now func() int64
mostRecentRV atomic.Int64 // The most recent resource version seen by the server
storageMetrics *StorageMetrics
indexMetrics *BleveIndexMetrics
log log.Logger
backend StorageBackend
blob BlobSupport
secure secrets.InlineSecureValueSupport
search *searchSupport
diagnostics resourcepb.DiagnosticsServer
access claims.AccessClient
writeHooks WriteAccessHooks
lifecycle LifecycleHooks
now func() int64
mostRecentRV atomic.Int64 // The most recent resource version seen by the server
storageMetrics *StorageMetrics
indexMetrics *BleveIndexMetrics
overridesService *OverridesService
// Background watch task -- this has permissions for everything
ctx context.Context
@@ -411,6 +418,11 @@ func (s *server) Init(ctx context.Context) error {
}
}
// initialize tenant overrides service
if s.initErr == nil && s.overridesService != nil {
s.initErr = s.overridesService.init(ctx)
}
// initialize the search index
if s.initErr == nil && s.search != nil {
s.initErr = s.search.init(ctx)
@@ -444,6 +456,13 @@ func (s *server) Stop(ctx context.Context) error {
s.search.stop()
}
if s.overridesService != nil {
if err := s.overridesService.stop(ctx); err != nil {
stopFailed = true
s.initErr = fmt.Errorf("service stopeed with error: %w", err)
}
}
// Stops the streaming
s.cancel()
@@ -647,6 +666,13 @@ func (s *server) Create(ctx context.Context, req *resourcepb.CreateRequest) (*re
ctx, span := tracer.Start(ctx, "resource.server.Create")
defer span.End()
// check quotas and log for now
s.checkQuota(ctx, NamespacedResource{
Namespace: req.Key.Namespace,
Group: req.Key.Group,
Resource: req.Key.Resource,
})
if r := verifyRequestKey(req.Key); r != nil {
return nil, fmt.Errorf("invalid request key: %s", r.Message)
}
@@ -1549,3 +1575,31 @@ func (s *server) RebuildIndexes(ctx context.Context, req *resourcepb.RebuildInde
return s.search.RebuildIndexes(ctx, req)
}
func (s *server) checkQuota(ctx context.Context, nsr NamespacedResource) {
span := trace.SpanFromContext(ctx)
span.AddEvent("checkQuota", trace.WithAttributes(
attribute.String("namespace", nsr.Namespace),
attribute.String("group", nsr.Group),
attribute.String("resource", nsr.Resource),
))
if s.overridesService == nil {
return
}
quota, err := s.overridesService.GetQuota(ctx, nsr)
if err != nil {
s.log.FromContext(ctx).Error("failed to get quota for resource", "namespace", nsr.Namespace, "group", nsr.Group, "resource", nsr.Resource, "error", err)
return
}
stats, err := s.backend.GetResourceStats(ctx, nsr, 0)
if err != nil {
s.log.FromContext(ctx).Error("failed to get resource stats for quota checking", "namespace", nsr.Namespace, "group", nsr.Group, "resource", nsr.Resource, "error", err)
return
}
if len(stats) > 0 && stats[0].Count >= int64(quota.Limit) {
s.log.FromContext(ctx).Info("Quota exceeded on create", "namespace", nsr.Namespace, "group", nsr.Group, "resource", nsr.Resource, "quota", quota.Limit, "count", stats[0].Count, "stats_resource", stats[0].Resource)
}
}
+6 -1
View File
@@ -14,6 +14,7 @@ import (
"github.com/jackc/pgx/v5/pgconn"
"github.com/lib/pq"
"github.com/prometheus/client_golang/prometheus"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"go.opentelemetry.io/otel/trace/noop"
"go.uber.org/atomic"
@@ -263,7 +264,11 @@ func (b *backend) Stop(_ context.Context) error {
// GetResourceStats implements Backend.
func (b *backend) GetResourceStats(ctx context.Context, nsr resource.NamespacedResource, minCount int) ([]resource.ResourceStats, error) {
ctx, span := b.tracer.Start(ctx, tracePrefix+"GetResourceStats")
ctx, span := b.tracer.Start(ctx, tracePrefix+"GetResourceStats", trace.WithAttributes(
attribute.String("namespace", nsr.Namespace),
attribute.String("group", nsr.Group),
attribute.String("resource", nsr.Resource),
))
defer span.End()
req := &sqlStatsRequest{
+15 -13
View File
@@ -30,19 +30,20 @@ type QOSEnqueueDequeuer interface {
// ServerOptions contains the options for creating a new ResourceServer
type ServerOptions struct {
Backend resource.StorageBackend
DB infraDB.DB
Cfg *setting.Cfg
Tracer trace.Tracer
Reg prometheus.Registerer
AccessClient types.AccessClient
SearchOptions resource.SearchOptions
StorageMetrics *resource.StorageMetrics
IndexMetrics *resource.BleveIndexMetrics
Features featuremgmt.FeatureToggles
QOSQueue QOSEnqueueDequeuer
SecureValues secrets.InlineSecureValueSupport
OwnsIndexFn func(key resource.NamespacedResource) (bool, error)
Backend resource.StorageBackend
OverridesService *resource.OverridesService
DB infraDB.DB
Cfg *setting.Cfg
Tracer trace.Tracer
Reg prometheus.Registerer
AccessClient types.AccessClient
SearchOptions resource.SearchOptions
StorageMetrics *resource.StorageMetrics
IndexMetrics *resource.BleveIndexMetrics
Features featuremgmt.FeatureToggles
QOSQueue QOSEnqueueDequeuer
SecureValues secrets.InlineSecureValueSupport
OwnsIndexFn func(key resource.NamespacedResource) (bool, error)
}
func NewResourceServer(opts ServerOptions) (resource.ResourceServer, error) {
@@ -119,6 +120,7 @@ func NewResourceServer(opts ServerOptions) (resource.ResourceServer, error) {
serverOptions.IndexMetrics = opts.IndexMetrics
serverOptions.QOSQueue = opts.QOSQueue
serverOptions.OwnsIndexFn = opts.OwnsIndexFn
serverOptions.OverridesService = opts.OverridesService
return resource.NewResourceServer(serverOptions)
}
+12
View File
@@ -279,6 +279,18 @@ func (s *service) starting(ctx context.Context) error {
QOSQueue: s.queue,
OwnsIndexFn: s.OwnsIndex,
}
if s.cfg.OverridesFilePath != "" {
overridesSvc, err := resource.NewOverridesService(context.Background(), s.log, s.reg, s.tracing, resource.ReloadOptions{
FilePath: s.cfg.OverridesFilePath,
ReloadPeriod: s.cfg.OverridesReloadInterval,
})
if err != nil {
return err
}
serverOptions.OverridesService = overridesSvc
}
server, err := NewResourceServer(serverOptions)
if err != nil {
return err
+34 -4
View File
@@ -93,7 +93,18 @@ type K8sTestHelper struct {
userSvc user.Service
}
type K8sTestHelperOpts struct {
testinfra.GrafanaOpts
// If provided, these users will be used instead of creating new ones
Org1Users *OrgUsers
OrgBUsers *OrgUsers
}
func NewK8sTestHelper(t *testing.T, opts testinfra.GrafanaOpts) *K8sTestHelper {
return NewK8sTestHelperWithOpts(t, K8sTestHelperOpts{GrafanaOpts: opts})
}
func NewK8sTestHelperWithOpts(t *testing.T, opts K8sTestHelperOpts) *K8sTestHelper {
t.Helper()
// Use GRPC server when not configured
@@ -111,9 +122,12 @@ func NewK8sTestHelper(t *testing.T, opts testinfra.GrafanaOpts) *K8sTestHelper {
path = opts.DirPath
)
if opts.Dir == "" && opts.DirPath == "" {
dir, path = testinfra.CreateGrafDir(t, opts)
dir, path = testinfra.CreateGrafDir(t, opts.GrafanaOpts)
}
listenerAddress, env, testDB := testinfra.StartGrafanaEnvWithDB(t, dir, path)
if !opts.DisableDBCleanup {
t.Cleanup(testDB.Cleanup)
}
listenerAddress, env := testinfra.StartGrafanaEnv(t, dir, path)
c := &K8sTestHelper{
env: *env,
@@ -143,8 +157,24 @@ func NewK8sTestHelper(t *testing.T, opts testinfra.GrafanaOpts) *K8sTestHelper {
_ = c.CreateOrg(Org1)
_ = c.CreateOrg(Org2)
c.Org1 = c.createTestUsers(Org1)
c.OrgB = c.createTestUsers(Org2)
if opts.Org1Users != nil {
c.Org1 = *opts.Org1Users
c.Org1.Admin.baseURL = listenerAddress
c.Org1.Editor.baseURL = listenerAddress
c.Org1.Viewer.baseURL = listenerAddress
c.Org1.None.baseURL = listenerAddress
} else {
c.Org1 = c.createTestUsers(Org1)
}
if opts.OrgBUsers != nil {
c.OrgB = *opts.OrgBUsers
c.OrgB.Admin.baseURL = listenerAddress
c.OrgB.Editor.baseURL = listenerAddress
c.OrgB.Viewer.baseURL = listenerAddress
c.OrgB.None.baseURL = listenerAddress
} else {
c.OrgB = c.createTestUsers(Org2)
}
c.loadAPIGroups()
@@ -4309,6 +4309,10 @@
"type": "string",
"default": ""
},
"path": {
"description": "For git, this is the target path",
"type": "string"
},
"target": {
"description": "When syncing, where values are saved\n\nPossible enum values:\n - `\"folder\"` Resources will be saved into a folder managed by this repository It will contain a copy of everything from the remote The folder k8s name will be the same as the repository k8s name\n - `\"instance\"` Resources are saved in the global context Only one repository may specify the `instance` target When this exists, the UI will promote writing to the instance repo rather than the grafana database (where possible)",
"type": "string",
@@ -4335,6 +4339,10 @@
"local"
]
},
"url": {
"description": "For git, this is the target URL",
"type": "string"
},
"workflows": {
"description": "The supported workflows",
"type": "array",
@@ -136,6 +136,19 @@ func TestIntegrationProvisioning_CreatingAndGetting(t *testing.T) {
return
}
for _, i := range settings.Items {
switch i.Type {
case provisioning.LocalRepositoryType:
assert.Equal(collect, i.Path, helper.ProvisioningPath)
case provisioning.GitHubRepositoryType:
assert.Equal(collect, i.URL, "https://github.com/grafana/grafana-git-sync-demo")
assert.Equal(collect, i.Path, "grafana/")
default:
assert.NotEmpty(collect, i.Path)
assert.NotEmpty(collect, i.URL)
}
}
assert.ElementsMatch(collect, []provisioning.RepositoryType{
provisioning.LocalRepositoryType,
provisioning.GitHubRepositoryType,
+15 -2
View File
@@ -49,6 +49,12 @@ func StartGrafana(t *testing.T, grafDir, cfgPath string) (string, db.DB) {
}
func StartGrafanaEnv(t *testing.T, grafDir, cfgPath string) (string, *server.TestEnv) {
addr, env, testDB := StartGrafanaEnvWithDB(t, grafDir, cfgPath)
t.Cleanup(testDB.Cleanup)
return addr, env
}
func StartGrafanaEnvWithDB(t *testing.T, grafDir, cfgPath string) (string, *server.TestEnv, *sqlutil.TestDB) {
t.Helper()
ctx := context.Background()
@@ -93,7 +99,6 @@ func StartGrafanaEnv(t *testing.T, grafDir, cfgPath string) (string, *server.Tes
// Use proper database type based on the environment variable GRAFANA_TEST_DB in tests
testDB, err := sqlutil.GetTestDB(sqlutil.GetTestDBType())
require.NoError(t, err)
t.Cleanup(testDB.Cleanup)
dbCfg := cfg.Raw.Section("database")
dbCfg.Key("type").SetValue(testDB.DriverName)
@@ -169,7 +174,7 @@ func StartGrafanaEnv(t *testing.T, grafDir, cfgPath string) (string, *server.Tes
t.Logf("Grafana is listening on %s", addr)
return addr, env
return addr, env, testDB
}
// CreateGrafDir creates the Grafana directory.
@@ -538,6 +543,12 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) {
_, err = section.NewKey("max_page_size_bytes", fmt.Sprintf("%d", opts.UnifiedStorageMaxPageSizeBytes))
require.NoError(t, err)
}
if opts.DisableDataMigrations {
section, err := getOrCreateSection("unified_storage")
require.NoError(t, err)
_, err = section.NewKey("disable_data_migrations", "true")
require.NoError(t, err)
}
if opts.PermittedProvisioningPaths != "" {
_, err = pathsSect.NewKey("permitted_provisioning_paths", opts.PermittedProvisioningPaths)
require.NoError(t, err)
@@ -637,6 +648,8 @@ type GrafanaOpts struct {
EnableSCIM bool
APIServerRuntimeConfig string
DisableControllers bool
DisableDBCleanup bool
DisableDataMigrations bool
SecretsManagerEnableDBMigrations bool
// Allow creating grafana dir beforehand

Some files were not shown because too many files have changed in this diff Show More