Compare commits

..

32 Commits

Author SHA1 Message Date
Tania B. 0f3a5cd3a5 Use subpath for ofrep api url 2026-01-14 14:38:25 +01:00
Fabrizio ba6a783997 Add Db2 plugin (#116190)
* Add new Db2 plugin

* Fix ID

* Fix ID

* Run `i18n-extract`

* Linting

* Fix ID

* Linting

* Rename plugin

* Fix i18n entry

* Run `yarn i18n-extract`
2026-01-14 14:20:24 +01:00
Andreas Christou f704b8aa79 Cloud Monitoring: Add support for Google Cloud universe_domain (#115931)
* feat(cloud-monitoring): add support for Google Cloud universe_domain (#110083)

This change introduces support for Google Cloud's `universe_domain`, enabling connections to sovereign cloud environments with custom API endpoints.

- Adds an optional "Universe Domain" field in the Google Cloud Monitoring data source configuration (frontend and backend).
- Allows specifying a custom API domain (e.g., `s3nsapis.fr`) for use in sovereign environments.
- Defaults to `googleapis.com` to ensure backward compatibility for existing configurations.

Signed-off-by: Andreas Christou <andreas.christou@grafana.com>

* Minor docs update

* Doc updates

* Update editor

* Update docs/sources/datasources/google-cloud-monitoring/_index.md

Co-authored-by: Larissa Wandzura <126723338+lwandz13@users.noreply.github.com>

* Lint

* Review

---------

Signed-off-by: Andreas Christou <andreas.christou@grafana.com>
Co-authored-by: Larissa Wandzura <126723338+lwandz13@users.noreply.github.com>
2026-01-14 13:16:09 +00:00
Andreas Christou c1a46fdcb5 Elasticsearch: Decoupling from core (#115900)
* Complete decoupling of backend

- Replace usage of featuremgmt
- Copy simplejson
- Add standalone logic

* Complete frontend decoupling

- Fix imports
- Copy store and reducer logic

* Add required files for full decoupling

* Regen cue

* Prettier

* Remove unneeded script

* Jest fix

* Add jest config

* Lint

* Lit

* Prune suppresions
2026-01-14 12:54:21 +00:00
Cauê Marcondes 7143324229 Elasticsearch: Add support for serverless connections (#114855)
* serverless connecction

* Adding api key

* fix

* addressing pr comments

* fixing tests

* refactoring

* changing to value semantic

* addressing pr comments

* minor changes

---------

Co-authored-by: Lucas Francisco Lopez <lucas.lopez@elastic.co>
2026-01-14 12:51:42 +00:00
Ryan McKinley 48625d67e5 Chore: update blevesearch dependencies (#116251) 2026-01-14 12:15:19 +00:00
Jack Westbrook 8bad33de4c Grafana/data: Fix theme types schema resolution (#116240)
* fix(grafana-data): copy theme schema json to types so declaration resolves

* refactor(grafana-data): move node scripts out of source code

* feat(grafana-data): generate types for theme schema

* chore(codeowners): update for grafana-data/scripts file move

* feat(grafana-data): put back copy plugin for theme json files

* revert(grafana-data): remove definition output

* feat(grafana-data): make builds great again

* minor tidy up

---------

Co-authored-by: Ashley Harrison <ashley.harrison@grafana.com>
2026-01-14 12:05:23 +00:00
Ryan McKinley 040854c8af Search: Allow query field selection (#116238) 2026-01-14 11:55:05 +00:00
Rafael Bortolon Paulovic 987c1fc6b6 feat(unified): add index scoring model config (#116210)
* feat(unified): add bm25 index scoring model

We want try BM25 scoring model since they have global scoring which we can probably re-use for fan-in/fan-out logic

https://github.com/blevesearch/bleve/blob/32d98823c4b7482c62cc6c847508ed7659c23c37/docs/scoring.md#global-scoring

* fix(plugins): update plugin test data
2026-01-14 12:07:53 +01:00
Alejandro Fraenkel 170ac31c5a Alerting: Add alertingNavigationV2 feature toggle (#116215)
feat(alerting): add alertingNavigationV2 feature toggle

Introduces a new feature toggle to enable the improved Alerting navigation
structure with grouped menu items. This toggle will allow:
- Safe incremental rollout of navigation changes
- Quick rollback if issues arise
- Handling BE/FE deployment timing differences

Toggle details:
- Name: alertingNavigationV2
- Stage: Experimental
- Owner: @grafana/alerting-squad
- Default: false (disabled)
- Affects: Both backend (navtree) and frontend (navigation hooks)
2026-01-14 11:58:11 +01:00
Dominik Prokop 0d1e0bc21c PanelMenu: use openInNewTab links extensions API correctly (#116200)
* Extensons: Make links use openInNewTab API

* Use openInNewTab api correctly in the UI

* Bump scenes

* Fx circular dep

* test

* Revert "test"

This reverts commit 8784a7992c.
2026-01-14 11:29:43 +01:00
Natalia Bernarte Oses afd84f0335 Datagrid: Deprecate panel (#116071)
* deprecate datagrid

* Update docs/sources/visualizations/panels-visualizations/visualizations/datagrid/index.md

Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com>

---------

Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com>
2026-01-14 11:10:51 +01:00
Andres Martinez Gotor d680537ea1 Advisor: Simplify interface used (#116191) 2026-01-14 11:05:16 +01:00
Bogdan Matei 78d507d285 Dynamic Dashboards: Change the stage of the feature toggle (#116189) 2026-01-14 09:50:37 +00:00
Tito Lins 9d1d0e72c2 Alerting: add sync timer support (#114602)
- add new feature flag to support enabling the dispatcher sync timer on the alertmanager
- this attempts to synchronize the flushes across HA nodes to decrease amount of duplicate notifications

---------

Co-authored-by: Yuri Tseretyan <yuriy.tseretyan@grafana.com>
2026-01-14 10:04:29 +01:00
Konrad Lalik fd955f90ac Alerting: Enable server-side folder search for GMA rules (#116201)
* Alerting: Support backend filtering for folder search

Updates the Grafana managed rules API and filter logic to support
server-side filtering by folder (namespace).

Changes:
- Add `searchFolder` parameter to `getGrafanaGroups` API endpoint
- Map filter state `namespace` to `searchFolder` in backend filter
- Disable client-side namespace filtering when backend filtering is enabled
- Update tests to verify correct behavior for folder search with backend filters

* Add missing property in filter options

* Update tests
2026-01-14 09:48:07 +01:00
Sonia Aguilar ccb032f376 Alerting: Single alertmanager contact points versions (#116076)
* POC ssingle AM

* wip

* add query param ?version=2

* wip2

* wip3

* Update logic

* update badges and tests

* remove unsused import

* fix: update NewReceiverView snapshots to include version field

* update translations

* fix: delegate version determination to backend for new integrations

- Remove hardcoded version: 'v1' from defaultChannelValues
- Reset version to undefined when integration type changes
- Backend uses GetCurrentVersion() when no version is provided
- Update snapshots to reflect version handling changes
- Remove unused getDefaultVersionForNotifier function

* update snapshot

* fix(alerting): fix contact point form issues

- Fix empty info alert showing when notifier.dto.info is undefined
- Fix options not loading for new contact points by using default creatable version

* fix(alerting): only show version badge for legacy integrations

* update tests for version badge and getOptionsForVersion changes

* docs: add comment explaining currentVersion field in NotifierDTO

* Show user-friendly 'Legacy' label for legacy integrations

- Replace technical version strings (v0mimir1, v0mimir2) with user-friendly labels
- v0mimir1 -> 'Legacy', v0mimir2 -> 'Legacy v2', etc.
- Technical version is still shown in tooltip for reference
- Add getLegacyVersionLabel() utility function
- Update tests for badge display and utility function

* Add v0mimir2 to test mock for Legacy v2 badge test

* hasLegacyIntegrations now uses isLegacyVersion

- Accept notifiers array to properly check canCreate: false
- No longer relies on version string comparison (v1 check)
- Uses isLegacyVersion for consistent legacy detection
- Update tests to pass notifiers and test correct behavior

* update translations
2026-01-14 08:31:13 +01:00
Alex Khomenko cf452c167b Provisioning: Do not show the page when the toggle is off (#116206) 2026-01-14 07:41:10 +02:00
Hugo Häggmark bd0140b6f0 GrafanaBootData: Deprecate config.apps (#115610)
* GrafanaBootData: decouple `config.apps` from boot data IV

* chore: changed to openfeature flags eval

* chore: updates after PR feedback

* chore: updates after PR feedback

* chore: copy types to runtime package

* chore: add code ownership

* chore: deprecate in interface too

* chore: add important notice to comments

* chore: deprecate the whole interface
2026-01-14 06:30:05 +01:00
grafana-pr-automation[bot] 215d25ef69 I18n: Download translations from Crowdin (#116232)
New Crowdin translations by GitHub Action

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-14 00:43:07 +00:00
sabithamuppuri d3beed7dd2 Docs: add unified_alerting.state_history configuration section (fixes #114670) (#115607)
Co-authored-by: Pepe Cano <825430+ppcano@users.noreply.github.com>
Co-authored-by: Johnny Kartheiser <140559259+JohnnyK-Grafana@users.noreply.github.com>
2026-01-13 21:37:09 +00:00
Anton Chimrov e2f2011d9e Restore Canvas element key simplification to prevent blinking icons (#113693)
* Simplify Canvas element key to prevent blinking icons

* Fix formatting with prettier
2026-01-13 13:01:22 -08:00
Paul Marbach 6db51cbdb9 Legends: Revert scrolled truncated legend for now (#116217)
* Revert "PieChart: Fix right-oriented legends (#116084)"

This reverts commit 0c8c886930.

* Revert "TimeSeries: Fix truncated label text in legend table mode (#115647)"

This reverts commit f91efcfe2c.
2026-01-13 19:54:42 +00:00
Haris Rozajac 82d8d44977 Dashboard Conversion: Remove duplicated data loss function (#116214)
remove duplicated dataloss function
2026-01-13 11:44:36 -07:00
Ida Štambuk 60abd9a159 Dynamic dashboards: Add tests for custom grid repeats (#114545) 2026-01-13 19:42:47 +01:00
Will Browne 6186aac5d4 Revert "Plugins: Add module hash field to plugin model" (#116211)
* Revert "Plugins: Add module hash field to plugin model (#116119)"

This reverts commit aa9b587cc1.

* trigger

* trigger
2026-01-13 18:34:39 +00:00
Galen Kistler a28076ef5e Logs: Feature flag clean up (#116205)
* chore: reassign flags to big tent
2026-01-13 11:41:35 -06:00
Motte b687ca6b6d Chore: Improve packaging/docker/run.sh (#114012)
* Chore: set -e line in packaging/docker/run.sh

* Chore: fix ShellCheck SC2188 in packaging/docker/run.sh

* Chore: fix ShellCheck SC2166 in packaging/docker/run.sh
2026-01-13 15:48:50 +00:00
Alyssa Joyner 1d3f09d519 [InfluxDB]: Remove banner (#116141) 2026-01-13 08:32:09 -07:00
Alex Khomenko ec1ace398e Recent dashboards: Add experimental toggle (#116121)
* Add experimentRecentlyViewedDashboards toggle

* Emit dashboards_browse_list_viewed event

* Move feature toggle to parent

* merge
2026-01-13 17:22:20 +02:00
Yunwen Zheng fe5aa3e281 RecentlyViewedDashboards: UI tweaks (#116171) 2026-01-13 10:20:17 -05:00
Jo a01777eafa docs: improve RBAC and role creation documentation (#116188)
* docs: improve RBAC and role creation documentation

- Clarify that file-based RBAC provisioning is for self-managed instances only
- Distinguish between Grafana Admin (Server Admin) and Org Admin
- Remove incorrect UI instructions for custom role creation
- Add Terraform example for creating custom roles and assignments

* Apply suggestions from code review

Co-authored-by: Anna Urbiztondo <anna.urbiztondo@grafana.com>

---------

Co-authored-by: Anna Urbiztondo <anna.urbiztondo@grafana.com>
2026-01-13 15:11:15 +00:00
294 changed files with 14352 additions and 2149 deletions
+3
View File
@@ -440,6 +440,7 @@ i18next.config.ts @grafana/grafana-frontend-platform
/e2e-playwright/dashboards/TestDashboard.json @grafana/dashboards-squad @grafana/grafana-search-navigate-organise
/e2e-playwright/dashboards/TestV2Dashboard.json @grafana/dashboards-squad
/e2e-playwright/dashboards/V2DashWithRepeats.json @grafana/dashboards-squad
/e2e-playwright/dashboards/V2DashWithRowRepeats.json @grafana/dashboards-squad
/e2e-playwright/dashboards/V2DashWithTabRepeats.json @grafana/dashboards-squad
/e2e-playwright/dashboards-suite/adhoc-filter-from-panel.spec.ts @grafana/datapro
/e2e-playwright/dashboards-suite/dashboard-browse-nested.spec.ts @grafana/grafana-search-navigate-organise
@@ -542,6 +543,7 @@ i18next.config.ts @grafana/grafana-frontend-platform
/packages/grafana-data/tsconfig.json @grafana/grafana-frontend-platform
/packages/grafana-data/test/ @grafana/grafana-frontend-platform
/packages/grafana-data/typings/ @grafana/grafana-frontend-platform
/packages/grafana-data/scripts/ @grafana/grafana-frontend-platform
/packages/grafana-data/src/**/*logs* @grafana/observability-logs
/packages/grafana-data/src/context/plugins/ @grafana/plugins-platform-frontend
@@ -657,6 +659,7 @@ i18next.config.ts @grafana/grafana-frontend-platform
/packages/grafana-runtime/src/services/LocationService.tsx @grafana/grafana-search-navigate-organise
/packages/grafana-runtime/src/services/LocationSrv.ts @grafana/grafana-search-navigate-organise
/packages/grafana-runtime/src/services/live.ts @grafana/dashboards-squad
/packages/grafana-runtime/src/services/pluginMeta @grafana/plugins-platform-frontend
/packages/grafana-runtime/src/utils/chromeHeaderHeight.ts @grafana/grafana-search-navigate-organise
/packages/grafana-runtime/src/utils/DataSourceWithBackend* @grafana/grafana-datasources-core-services
/packages/grafana-runtime/src/utils/licensing.ts @grafana/grafana-operator-experience-squad
+2
View File
@@ -121,6 +121,8 @@ linters:
- '**/pkg/tsdb/zipkin/**/*'
- '**/pkg/tsdb/jaeger/*'
- '**/pkg/tsdb/jaeger/**/*'
- '**/pkg/tsdb/elasticsearch/*'
- '**/pkg/tsdb/elasticsearch/**/*'
deny:
- pkg: github.com/grafana/grafana/pkg/api
desc: Core plugins are not allowed to depend on Grafana core packages
@@ -28,7 +28,7 @@ type check struct {
PluginStore pluginstore.Store
PluginContextProvider PluginContextProvider
PluginClient plugins.Client
PluginRepo repo.Service
PluginRepo checks.PluginInfoGetter
GrafanaVersion string
pluginCanBeInstalledCache map[string]bool
pluginExistsCacheMu sync.RWMutex
@@ -39,7 +39,7 @@ func New(
pluginStore pluginstore.Store,
pluginContextProvider PluginContextProvider,
pluginClient plugins.Client,
pluginRepo repo.Service,
pluginRepo checks.PluginInfoGetter,
grafanaVersion string,
) checks.Check {
return &check{
@@ -15,7 +15,7 @@ import (
type missingPluginStep struct {
PluginStore pluginstore.Store
PluginRepo repo.Service
PluginRepo checks.PluginInfoGetter
GrafanaVersion string
}
+8
View File
@@ -5,6 +5,7 @@ import (
"github.com/grafana/grafana-app-sdk/logging"
advisorv0alpha1 "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1"
"github.com/grafana/grafana/pkg/plugins/repo"
)
// Check returns metadata about the check being executed and the list of Steps
@@ -37,3 +38,10 @@ type Step interface {
// Run executes the step for an item and returns a report
Run(ctx context.Context, log logging.Logger, obj *advisorv0alpha1.CheckSpec, item any) ([]advisorv0alpha1.CheckReportFailure, error)
}
// PluginInfoGetter is a minimal interface for retrieving plugin information from a repository.
// It contains only the GetPluginsInfo method used by plugincheck and datasourcecheck.
type PluginInfoGetter interface {
// GetPluginsInfo will return a list of plugins from grafana.com/api/plugins.
GetPluginsInfo(ctx context.Context, options repo.GetPluginsInfoOptions, compatOpts repo.CompatOpts) ([]repo.PluginInfo, error)
}
@@ -17,7 +17,7 @@ const (
func New(
pluginStore pluginstore.Store,
pluginRepo repo.Service,
pluginRepo checks.PluginInfoGetter,
updateChecker pluginchecker.PluginUpdateChecker,
pluginErrorResolver plugins.ErrorResolver,
grafanaVersion string,
@@ -33,7 +33,7 @@ func New(
type check struct {
PluginStore pluginstore.Store
PluginRepo repo.Service
PluginRepo checks.PluginInfoGetter
updateChecker pluginchecker.PluginUpdateChecker
pluginErrorResolver plugins.ErrorResolver
GrafanaVersion string
@@ -71,11 +71,6 @@ func convertDashboardSpec_V2alpha1_to_V1beta1(in *dashv2alpha1.DashboardSpec) (m
if err != nil {
return nil, fmt.Errorf("failed to convert panels: %w", err)
}
// Count total panels including those in collapsed rows
totalPanelsConverted := countTotalPanels(panels)
if totalPanelsConverted < len(in.Elements) {
return nil, fmt.Errorf("some panels were not converted from v2alpha1 to v1beta1")
}
if len(panels) > 0 {
dashboard["panels"] = panels
@@ -198,29 +193,6 @@ func convertLinksToV1(links []dashv2alpha1.DashboardDashboardLink) []map[string]
return result
}
// countTotalPanels counts all panels including those nested in collapsed row panels.
func countTotalPanels(panels []interface{}) int {
count := 0
for _, p := range panels {
panel, ok := p.(map[string]interface{})
if !ok {
count++
continue
}
// Check if this is a row panel with nested panels
if panelType, ok := panel["type"].(string); ok && panelType == "row" {
if nestedPanels, ok := panel["panels"].([]interface{}); ok {
count += len(nestedPanels)
}
// Don't count the row itself as a panel element
} else {
count++
}
}
return count
}
// convertPanelsFromElementsAndLayout converts V2 layout structures to V1 panel arrays.
// V1 only supports a flat array of panels with row panels for grouping.
// This function dispatches to the appropriate converter based on layout type:
@@ -290,7 +290,7 @@
],
"legend": {
"displayMode": "table",
"placement": "right",
"placement": "bottom",
"showLegend": true,
"values": [
"percent"
@@ -304,7 +304,7 @@
"fields": "",
"values": false
},
"showLegend": true,
"showLegend": false,
"strokeWidth": 1,
"text": {}
},
@@ -323,15 +323,6 @@
}
],
"title": "Percent",
"transformations": [
{
"id": "renameByRegex",
"options": {
"regex": "^Backend-(.*)$",
"renamePattern": "b-$1"
}
}
],
"type": "piechart"
},
{
@@ -375,7 +366,7 @@
],
"legend": {
"displayMode": "table",
"placement": "right",
"placement": "bottom",
"showLegend": true,
"values": [
"value"
@@ -389,7 +380,7 @@
"fields": "",
"values": false
},
"showLegend": true,
"showLegend": false,
"strokeWidth": 1,
"text": {}
},
@@ -408,15 +399,6 @@
}
],
"title": "Value",
"transformations": [
{
"id": "renameByRegex",
"options": {
"regex": "(.*)",
"renamePattern": "$1-how-much-wood-could-a-woodchuck-chuck-if-a-woodchuck-could-chuck-wood"
}
}
],
"type": "piechart"
},
{
+10 -3
View File
@@ -1,9 +1,16 @@
include ../sdk.mk
.PHONY: generate # Run Grafana App SDK code generation
generate: install-app-sdk update-app-sdk
.PHONY: internal-generate # Run Grafana App SDK code generation
internal-generate: install-app-sdk update-app-sdk
@$(APP_SDK_BIN) generate \
--source=./kinds/ \
--gogenpath=./pkg/apis \
--grouping=group \
--defencoding=none
--defencoding=none
.PHONY: generate
generate: internal-generate # copy files to packages/grafana-runtime/src/services/pluginMeta/types
rm -f ./packages/grafana-runtime/src/services/pluginMeta/types/*.ts
cp plugin/src/generated/meta/v0alpha1/meta_object_gen.ts ../../packages/grafana-runtime/src/services/pluginMeta/types/meta_object_gen.ts
cp plugin/src/generated/meta/v0alpha1/types.spec.gen.ts ../../packages/grafana-runtime/src/services/pluginMeta/types/types.spec.gen.ts
cp plugin/src/generated/meta/v0alpha1/types.status.gen.ts ../../packages/grafana-runtime/src/services/pluginMeta/types/types.status.gen.ts
+1 -2
View File
@@ -4,8 +4,7 @@ API documentation is available at http://localhost:3000/swagger?api=plugins.graf
## Codegen
- Go: `make generate`
- Frontend: Follow instructions in this [README](../..//packages/grafana-api-clients/README.md)
- Go and TypeScript: `make generate`
## Plugin sync
-1
View File
@@ -30,7 +30,6 @@ require (
require (
cel.dev/expr v0.25.1 // indirect
github.com/Machiel/slugify v1.0.1 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/NYTimes/gziphandler v1.1.1 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
-2
View File
@@ -9,8 +9,6 @@ github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
github.com/Machiel/slugify v1.0.1 h1:EfWSlRWstMadsgzmiV7d0yVd2IFlagWH68Q+DcYCm4E=
github.com/Machiel/slugify v1.0.1/go.mod h1:fTFGn5uWEynW4CUMG7sWkYXOf1UgDxyTM3DbR6Qfg3k=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I=
github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
+1 -1
View File
@@ -11,7 +11,7 @@ manifest: {
v0alpha1Version: {
served: true
codegen: {
ts: {enabled: false}
ts: {enabled: true}
go: {enabled: true}
}
kinds: [
+18 -4
View File
@@ -4,6 +4,7 @@ import (
"context"
"time"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
)
@@ -11,16 +12,26 @@ const (
defaultLocalTTL = 1 * time.Hour
)
// PluginAssetsCalculator is an interface for calculating plugin asset information.
// LocalProvider requires this to calculate loading strategy and module hash.
type PluginAssetsCalculator interface {
LoadingStrategy(ctx context.Context, p pluginstore.Plugin) plugins.LoadingStrategy
ModuleHash(ctx context.Context, p pluginstore.Plugin) string
}
// LocalProvider retrieves plugin metadata for locally installed plugins.
// It uses the plugin store to access plugins that have already been loaded.
type LocalProvider struct {
store pluginstore.Store
store pluginstore.Store
pluginAssets PluginAssetsCalculator
}
// NewLocalProvider creates a new LocalProvider for locally installed plugins.
func NewLocalProvider(pluginStore pluginstore.Store) *LocalProvider {
// pluginAssets is required for calculating loading strategy and module hash.
func NewLocalProvider(pluginStore pluginstore.Store, pluginAssets PluginAssetsCalculator) *LocalProvider {
return &LocalProvider{
store: pluginStore,
store: pluginStore,
pluginAssets: pluginAssets,
}
}
@@ -31,7 +42,10 @@ func (p *LocalProvider) GetMeta(ctx context.Context, pluginID, version string) (
return nil, ErrMetaNotFound
}
spec := pluginStorePluginToMeta(plugin, plugin.LoadingStrategy, plugin.ModuleHash)
loadingStrategy := p.pluginAssets.LoadingStrategy(ctx, plugin)
moduleHash := p.pluginAssets.ModuleHash(ctx, plugin)
spec := pluginStorePluginToMeta(plugin, loadingStrategy, moduleHash)
return &Result{
Meta: spec,
TTL: defaultLocalTTL,
@@ -0,0 +1,49 @@
/*
* This file was generated by grafana-app-sdk. DO NOT EDIT.
*/
import { Spec } from './types.spec.gen';
import { Status } from './types.status.gen';
export interface Metadata {
name: string;
namespace: string;
generateName?: string;
selfLink?: string;
uid?: string;
resourceVersion?: string;
generation?: number;
creationTimestamp?: string;
deletionTimestamp?: string;
deletionGracePeriodSeconds?: number;
labels?: Record<string, string>;
annotations?: Record<string, string>;
ownerReferences?: OwnerReference[];
finalizers?: string[];
managedFields?: ManagedFieldsEntry[];
}
export interface OwnerReference {
apiVersion: string;
kind: string;
name: string;
uid: string;
controller?: boolean;
blockOwnerDeletion?: boolean;
}
export interface ManagedFieldsEntry {
manager?: string;
operation?: string;
apiVersion?: string;
time?: string;
fieldsType?: string;
subresource?: string;
}
export interface Meta {
kind: string;
apiVersion: string;
metadata: Metadata;
spec: Spec;
status: Status;
}
@@ -0,0 +1,30 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
// metadata contains embedded CommonMetadata and can be extended with custom string fields
// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here
// without external reference as using the CommonMetadata reference breaks thema codegen.
export interface Metadata {
updateTimestamp: string;
createdBy: string;
uid: string;
creationTimestamp: string;
deletionTimestamp?: string;
finalizers: string[];
resourceVersion: string;
generation: number;
updatedBy: string;
labels: Record<string, string>;
}
export const defaultMetadata = (): Metadata => ({
updateTimestamp: "",
createdBy: "",
uid: "",
creationTimestamp: "",
finalizers: [],
resourceVersion: "",
generation: 0,
updatedBy: "",
labels: {},
});
@@ -0,0 +1,278 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
// JSON configuration schema for Grafana plugins
// Converted from: https://github.com/grafana/grafana/blob/main/docs/sources/developers/plugins/plugin.schema.json
export interface JSONData {
// Unique name of the plugin
id: string;
// Plugin type
type: "app" | "datasource" | "panel" | "renderer";
// Human-readable name of the plugin
name: string;
// Metadata for the plugin
info: Info;
// Dependency information
dependencies: Dependencies;
// Optional fields
alerting?: boolean;
annotations?: boolean;
autoEnabled?: boolean;
backend?: boolean;
buildMode?: string;
builtIn?: boolean;
category?: "tsdb" | "logging" | "cloud" | "tracing" | "profiling" | "sql" | "enterprise" | "iot" | "other";
enterpriseFeatures?: EnterpriseFeatures;
executable?: string;
hideFromList?: boolean;
// +listType=atomic
includes?: Include[];
logs?: boolean;
metrics?: boolean;
multiValueFilterOperators?: boolean;
pascalName?: string;
preload?: boolean;
queryOptions?: QueryOptions;
// +listType=atomic
routes?: Route[];
skipDataQuery?: boolean;
state?: "alpha" | "beta";
streaming?: boolean;
suggestions?: boolean;
tracing?: boolean;
iam?: IAM;
// +listType=atomic
roles?: Role[];
extensions?: Extensions;
}
export const defaultJSONData = (): JSONData => ({
id: "",
type: "app",
name: "",
info: defaultInfo(),
dependencies: defaultDependencies(),
});
export interface Info {
// Required fields
// +listType=set
keywords: string[];
logos: {
small: string;
large: string;
};
updated: string;
version: string;
// Optional fields
author?: {
name?: string;
email?: string;
url?: string;
};
description?: string;
// +listType=atomic
links?: {
name?: string;
url?: string;
}[];
// +listType=atomic
screenshots?: {
name?: string;
path?: string;
}[];
}
export const defaultInfo = (): Info => ({
keywords: [],
logos: {
small: "",
large: "",
},
updated: "",
version: "",
});
export interface Dependencies {
// Required field
grafanaDependency: string;
// Optional fields
grafanaVersion?: string;
// +listType=set
// +listMapKey=id
plugins?: {
id: string;
type: "app" | "datasource" | "panel";
name: string;
}[];
extensions?: {
// +listType=set
exposedComponents?: string[];
};
}
export const defaultDependencies = (): Dependencies => ({
grafanaDependency: "",
});
export interface EnterpriseFeatures {
// Allow additional properties
healthDiagnosticsErrors?: boolean;
}
export const defaultEnterpriseFeatures = (): EnterpriseFeatures => ({
healthDiagnosticsErrors: false,
});
export interface Include {
uid?: string;
type?: "dashboard" | "page" | "panel" | "datasource";
name?: string;
component?: string;
role?: "Admin" | "Editor" | "Viewer" | "None";
action?: string;
path?: string;
addToNav?: boolean;
defaultNav?: boolean;
icon?: string;
}
export const defaultInclude = (): Include => ({
});
export interface QueryOptions {
maxDataPoints?: boolean;
minInterval?: boolean;
cacheTimeout?: boolean;
}
export const defaultQueryOptions = (): QueryOptions => ({
});
export interface Route {
path?: string;
method?: string;
url?: string;
reqSignedIn?: boolean;
reqRole?: string;
reqAction?: string;
// +listType=atomic
headers?: string[];
body?: Record<string, any>;
tokenAuth?: {
url?: string;
// +listType=set
scopes?: string[];
params?: Record<string, any>;
};
jwtTokenAuth?: {
url?: string;
// +listType=set
scopes?: string[];
params?: Record<string, any>;
};
// +listType=atomic
urlParams?: {
name?: string;
content?: string;
}[];
}
export const defaultRoute = (): Route => ({
});
export interface IAM {
// +listType=atomic
permissions?: {
action?: string;
scope?: string;
}[];
}
export const defaultIAM = (): IAM => ({
});
export interface Role {
role?: {
name?: string;
description?: string;
// +listType=atomic
permissions?: {
action?: string;
scope?: string;
}[];
};
// +listType=set
grants?: string[];
}
export const defaultRole = (): Role => ({
});
export interface Extensions {
// +listType=atomic
addedComponents?: {
// +listType=set
targets: string[];
title: string;
description?: string;
}[];
// +listType=atomic
addedLinks?: {
// +listType=set
targets: string[];
title: string;
description?: string;
}[];
// +listType=atomic
addedFunctions?: {
// +listType=set
targets: string[];
title: string;
description?: string;
}[];
// +listType=set
// +listMapKey=id
exposedComponents?: {
id: string;
title?: string;
description?: string;
}[];
// +listType=set
// +listMapKey=id
extensionPoints?: {
id: string;
title?: string;
description?: string;
}[];
}
export const defaultExtensions = (): Extensions => ({
});
export interface Spec {
pluginJson: JSONData;
class: "core" | "external";
module?: {
path: string;
hash?: string;
loadingStrategy?: "fetch" | "script";
};
baseURL?: string;
signature?: {
status: "internal" | "valid" | "invalid" | "modified" | "unsigned";
type?: "grafana" | "commercial" | "community" | "private" | "private-glob";
org?: string;
};
angular?: {
detected: boolean;
};
translations?: Record<string, string>;
// +listType=atomic
children?: string[];
}
export const defaultSpec = (): Spec => ({
pluginJson: defaultJSONData(),
class: "core",
});
@@ -0,0 +1,30 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
export interface OperatorState {
// lastEvaluation is the ResourceVersion last evaluated
lastEvaluation: string;
// state describes the state of the lastEvaluation.
// It is limited to three possible states for machine evaluation.
state: "success" | "in_progress" | "failed";
// descriptiveState is an optional more descriptive state field which has no requirements on format
descriptiveState?: string;
// details contains any extra information that is operator-specific
details?: Record<string, any>;
}
export const defaultOperatorState = (): OperatorState => ({
lastEvaluation: "",
state: "success",
});
export interface Status {
// operatorStates is a map of operator ID to operator state evaluations.
// Any operator which consumes this kind SHOULD add its state evaluation information to this field.
operatorStates?: Record<string, OperatorState>;
// additionalFields is reserved for future use
additionalFields?: Record<string, any>;
}
export const defaultStatus = (): Status => ({
});
@@ -0,0 +1,49 @@
/*
* This file was generated by grafana-app-sdk. DO NOT EDIT.
*/
import { Spec } from './types.spec.gen';
import { Status } from './types.status.gen';
export interface Metadata {
name: string;
namespace: string;
generateName?: string;
selfLink?: string;
uid?: string;
resourceVersion?: string;
generation?: number;
creationTimestamp?: string;
deletionTimestamp?: string;
deletionGracePeriodSeconds?: number;
labels?: Record<string, string>;
annotations?: Record<string, string>;
ownerReferences?: OwnerReference[];
finalizers?: string[];
managedFields?: ManagedFieldsEntry[];
}
export interface OwnerReference {
apiVersion: string;
kind: string;
name: string;
uid: string;
controller?: boolean;
blockOwnerDeletion?: boolean;
}
export interface ManagedFieldsEntry {
manager?: string;
operation?: string;
apiVersion?: string;
time?: string;
fieldsType?: string;
subresource?: string;
}
export interface Plugin {
kind: string;
apiVersion: string;
metadata: Metadata;
spec: Spec;
status: Status;
}
@@ -0,0 +1,30 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
// metadata contains embedded CommonMetadata and can be extended with custom string fields
// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here
// without external reference as using the CommonMetadata reference breaks thema codegen.
export interface Metadata {
updateTimestamp: string;
createdBy: string;
uid: string;
creationTimestamp: string;
deletionTimestamp?: string;
finalizers: string[];
resourceVersion: string;
generation: number;
updatedBy: string;
labels: Record<string, string>;
}
export const defaultMetadata = (): Metadata => ({
updateTimestamp: "",
createdBy: "",
uid: "",
creationTimestamp: "",
finalizers: [],
resourceVersion: "",
generation: 0,
updatedBy: "",
labels: {},
});
@@ -0,0 +1,13 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
export interface Spec {
id: string;
version: string;
url?: string;
}
export const defaultSpec = (): Spec => ({
id: "",
version: "",
});
@@ -0,0 +1,30 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
export interface OperatorState {
// lastEvaluation is the ResourceVersion last evaluated
lastEvaluation: string;
// state describes the state of the lastEvaluation.
// It is limited to three possible states for machine evaluation.
state: "success" | "in_progress" | "failed";
// descriptiveState is an optional more descriptive state field which has no requirements on format
descriptiveState?: string;
// details contains any extra information that is operator-specific
details?: Record<string, any>;
}
export const defaultOperatorState = (): OperatorState => ({
lastEvaluation: "",
state: "success",
});
export interface Status {
// operatorStates is a map of operator ID to operator state evaluations.
// Any operator which consumes this kind SHOULD add its state evaluation information to this field.
operatorStates?: Record<string, OperatorState>;
// additionalFields is reserved for future use
additionalFields?: Record<string, any>;
}
export const defaultStatus = (): Status => ({
});
@@ -248,7 +248,7 @@
"legend": {
"values": ["percent"],
"displayMode": "table",
"placement": "right"
"placement": "bottom"
},
"pieType": "pie",
"reduceOptions": {
@@ -256,7 +256,7 @@
"fields": "",
"values": false
},
"showLegend": true,
"showLegend": false,
"strokeWidth": 1,
"text": {}
},
@@ -272,15 +272,6 @@
"timeFrom": null,
"timeShift": null,
"title": "Percent",
"transformations": [
{
"id": "renameByRegex",
"options": {
"regex": "^Backend-(.*)$",
"renamePattern": "b-$1"
}
}
],
"type": "piechart"
},
{
@@ -320,7 +311,7 @@
"legend": {
"values": ["value"],
"displayMode": "table",
"placement": "right"
"placement": "bottom"
},
"pieType": "pie",
"reduceOptions": {
@@ -328,7 +319,7 @@
"fields": "",
"values": false
},
"showLegend": true,
"showLegend": false,
"strokeWidth": 1,
"text": {}
},
@@ -344,15 +335,6 @@
"timeFrom": null,
"timeShift": null,
"title": "Value",
"transformations": [
{
"id": "renameByRegex",
"options": {
"regex": "(.*)",
"renamePattern": "$1-how-much-wood-could-a-woodchuck-chuck-if-a-woodchuck-could-chuck-wood"
}
}
],
"type": "piechart"
},
{
@@ -35,10 +35,10 @@ For Grafana Cloud users, Grafana Support is not authorised to make org role chan
## Grafana server administrators
A Grafana server administrator manages server-wide settings and access to resources such as organizations, users, and licenses. Grafana includes a default server administrator that you can use to manage all of Grafana, or you can divide that responsibility among other server administrators that you create.
A Grafana server administrator (sometimes referred to as a **Grafana Admin**) manages server-wide settings and access to resources such as organizations, users, and licenses. Grafana includes a default server administrator that you can use to manage all of Grafana, or you can divide that responsibility among other server administrators that you create.
{{< admonition type="note" >}}
The server administrator role does not mean that the user is also a Grafana [organization administrator](#organization-roles).
{{< admonition type="caution" >}}
The server administrator role is distinct from the [organization administrator](#organization-roles) role.
{{< /admonition >}}
A server administrator can perform the following tasks:
@@ -50,7 +50,7 @@ A server administrator can perform the following tasks:
- Upgrade the server to Grafana Enterprise.
{{< admonition type="note" >}}
The server administrator role does not exist in Grafana Cloud.
The server administrator (Grafana Admin) role does not exist in Grafana Cloud.
{{< /admonition >}}
To assign or remove server administrator privileges, see [Server user management](../user-management/server-user-management/assign-remove-server-admin-privileges/).
@@ -53,6 +53,11 @@ refs:
destination: /docs/grafana/<GRAFANA_VERSION>/administration/roles-and-permissions/access-control/custom-role-actions-scopes/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/account-management/authentication-and-permissions/access-control/custom-role-actions-scopes/
rbac-terraform-provisioning:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/administration/roles-and-permissions/access-control/rbac-terraform-provisioning/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/account-management/authentication-and-permissions/access-control/rbac-terraform-provisioning/
rbac-grafana-provisioning:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/administration/roles-and-permissions/access-control/rbac-grafana-provisioning/
@@ -145,7 +150,13 @@ Refer to the [RBAC HTTP API](ref:api-rbac-get-a-role) for more details.
## Create custom roles
This section shows you how to create a custom RBAC role using Grafana provisioning and the HTTP API.
This section shows you how to create a custom RBAC role using Grafana provisioning or the HTTP API.
Creating and editing custom roles is not currently possible in the Grafana UI. To manage custom roles, use one of the following methods:
- [Provisioning](ref:rbac-grafana-provisioning) (for self-managed instances)
- [HTTP API](ref:api-rbac-create-a-new-custom-role)
- [Terraform](ref:rbac-terraform-provisioning)
Create a custom role when basic roles and fixed roles do not meet your permissions requirements.
@@ -153,14 +164,101 @@ Create a custom role when basic roles and fixed roles do not meet your permissio
- [Plan your RBAC rollout strategy](ref:plan-rbac-rollout-strategy).
- Determine which permissions you want to add to the custom role. To see a list of actions and scope, refer to [RBAC permissions, actions, and scopes](ref:custom-role-actions-scopes).
- [Enable role provisioning](ref:rbac-grafana-provisioning).
- Ensure that you have permissions to create a custom role.
- By default, the Grafana Admin role has permission to create custom roles.
- A Grafana Admin can delegate the custom role privilege to another user by creating a custom role with the relevant permissions and adding the `permissions:type:delegate` scope.
### Create custom roles using provisioning
### Create custom roles using the HTTP API
[File-based provisioning](ref:rbac-grafana-provisioning) is one method you can use to create custom roles.
The following examples show you how to create a custom role using the Grafana HTTP API. For more information about the HTTP API, refer to [Create a new custom role](ref:api-rbac-create-a-new-custom-role).
{{< admonition type="note" >}}
When you create a custom role you can only give it the same permissions you already have. For example, if you only have `users:create` permissions, then you can't create a role that includes other permissions.
{{< /admonition >}}
The following example creates a `custom:users:admin` role and assigns the `users:create` action to it.
**Example request**
```
curl --location --request POST '<grafana_url>/api/access-control/roles/' \
--header 'Authorization: Basic YWRtaW46cGFzc3dvcmQ=' \
--header 'Content-Type: application/json' \
--data-raw '{
"version": 1,
"uid": "jZrmlLCkGksdka",
"name": "custom:users:admin",
"displayName": "custom users admin",
"description": "My custom role which gives users permissions to create users",
"global": true,
"permissions": [
{
"action": "users:create"
}
]
}'
```
**Example response**
```
{
"version": 1,
"uid": "jZrmlLCkGksdka",
"name": "custom:users:admin",
"displayName": "custom users admin",
"description": "My custom role which gives users permissions to create users",
"global": true,
"permissions": [
{
"action": "users:create"
"updated": "2021-05-17T22:07:31.569936+02:00",
"created": "2021-05-17T22:07:31.569935+02:00"
}
],
"updated": "2021-05-17T22:07:31.564403+02:00",
"created": "2021-05-17T22:07:31.564403+02:00"
}
```
Refer to the [RBAC HTTP API](ref:api-rbac-create-a-new-custom-role) for more details.
### Create custom roles using Terraform
You can use the [Grafana Terraform provider](https://registry.terraform.io/providers/grafana/grafana/latest/docs) to manage custom roles and their assignments. This is the recommended method for Grafana Cloud users who want to manage RBAC as code. For more information, refer to [Provisioning RBAC with Terraform](ref:rbac-terraform-provisioning).
The following example creates a custom role and assigns it to a team:
```terraform
resource "grafana_role" "custom_folder_manager" {
name = "custom:folders:manager"
description = "Custom role for reading and creating folders"
uid = "custom-folders-manager"
version = 1
global = true
permissions {
action = "folders:read"
scope = "folders:*"
}
permissions {
action = "folders:create"
scope = "folders:uid:general" # Allows creating folders at the root level
}
}
resource "grafana_role_assignment" "custom_folder_manager_assignment" {
role_uid = grafana_role.custom_folder_manager.uid
teams = ["<TEAM_UID>"]
}
```
For more information, refer to the [`grafana_role`](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/role) and [`grafana_role_assignment`](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/role_assignment) documentation in the Terraform Registry.
### Create custom roles using file-based provisioning
You can use [file-based provisioning](ref:rbac-grafana-provisioning) to create custom roles for self-managed instances.
1. Open the YAML configuration file and locate the `roles` section.
@@ -251,61 +349,6 @@ roles:
state: 'absent'
```
### Create custom roles using the HTTP API
The following examples show you how to create a custom role using the Grafana HTTP API. For more information about the HTTP API, refer to [Create a new custom role](ref:api-rbac-create-a-new-custom-role).
{{< admonition type="note" >}}
You cannot create a custom role with permissions that you do not have. For example, if you only have `users:create` permissions, then you cannot create a role that includes other permissions.
{{< /admonition >}}
The following example creates a `custom:users:admin` role and assigns the `users:create` action to it.
**Example request**
```
curl --location --request POST '<grafana_url>/api/access-control/roles/' \
--header 'Authorization: Basic YWRtaW46cGFzc3dvcmQ=' \
--header 'Content-Type: application/json' \
--data-raw '{
"version": 1,
"uid": "jZrmlLCkGksdka",
"name": "custom:users:admin",
"displayName": "custom users admin",
"description": "My custom role which gives users permissions to create users",
"global": true,
"permissions": [
{
"action": "users:create"
}
]
}'
```
**Example response**
```
{
"version": 1,
"uid": "jZrmlLCkGksdka",
"name": "custom:users:admin",
"displayName": "custom users admin",
"description": "My custom role which gives users permissions to create users",
"global": true,
"permissions": [
{
"action": "users:create"
"updated": "2021-05-17T22:07:31.569936+02:00",
"created": "2021-05-17T22:07:31.569935+02:00"
}
],
"updated": "2021-05-17T22:07:31.564403+02:00",
"created": "2021-05-17T22:07:31.564403+02:00"
}
```
Refer to the [RBAC HTTP API](ref:api-rbac-create-a-new-custom-role) for more details.
## Update basic role permissions
If the default basic role definitions do not meet your requirements, you can change their permissions.
@@ -6,7 +6,6 @@ description: Learn about RBAC Grafana provisioning and view an example YAML prov
file that configures Grafana role assignments.
labels:
products:
- cloud
- enterprise
menuTitle: Provisioning RBAC with Grafana
title: Provisioning RBAC with Grafana
@@ -52,11 +51,13 @@ refs:
# Provisioning RBAC with Grafana
{{< admonition type="note" >}}
Available in [Grafana Enterprise](/docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise/) and [Grafana Cloud](/docs/grafana-cloud).
Available in [Grafana Enterprise](/docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise/) for self-managed instances. This feature is not available in Grafana Cloud.
{{< /admonition >}}
You can create, change or remove [Custom roles](ref:manage-rbac-roles-create-custom-roles-using-provisioning) and create or remove [basic role assignments](ref:assign-rbac-roles-assign-a-fixed-role-to-a-basic-role-using-provisioning), by adding one or more YAML configuration files in the `provisioning/access-control/` directory.
Because this method requires access to the file system where Grafana is running, it's only available for self-managed Grafana instances. To provision RBAC in Grafana Cloud, use [Terraform](ref:rbac-terraform-provisioning) or the [HTTP API](ref:api-rbac-create-and-manage-custom-roles).
Grafana performs provisioning during startup. After you make a change to the configuration file, you can reload it during runtime. You do not need to restart the Grafana server for your changes to take effect.
**Before you begin:**
@@ -103,10 +103,11 @@ To configure basic settings for the data source, complete the following steps:
1. Set the data source's basic configuration options:
| Name | Description |
| ----------- | ------------------------------------------------------------------------ |
| **Name** | Sets the name you use to refer to the data source in panels and queries. |
| **Default** | Sets whether the data source is pre-selected for new panels. |
| Name | Description |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Name** | Sets the name you use to refer to the data source in panels and queries. |
| **Default** | Sets whether the data source is pre-selected for new panels. |
| **Universe Domain** | The universe domain to connect to. For more information, refer to [Documentation on universe domains](https://docs.cloud.google.com/python/docs/reference/monitoring/latest/google.cloud.monitoring_v3.services.service_monitoring_service.ServiceMonitoringServiceAsyncClient#google_cloud_monitoring_v3_services_service_monitoring_service_ServiceMonitoringServiceAsyncClient_universe_domain). Defaults to `googleapis.com`. |
### Provision the data source
@@ -129,6 +130,7 @@ datasources:
clientEmail: stackdriver@myproject.iam.gserviceaccount.com
authenticationType: jwt
defaultProject: my-project-name
universeDomain: googleapis.com
secureJsonData:
privateKey: |
-----BEGIN PRIVATE KEY-----
@@ -152,6 +154,7 @@ datasources:
clientEmail: stackdriver@myproject.iam.gserviceaccount.com
authenticationType: jwt
defaultProject: my-project-name
universeDomain: googleapis.com
privateKeyPath: /etc/secrets/gce.pem
```
@@ -166,6 +169,7 @@ datasources:
access: proxy
jsonData:
authenticationType: gce
universeDomain: googleapis.com
```
## Import pre-configured dashboards
@@ -87,6 +87,7 @@ With a Grafana Enterprise license, you also get access to premium data sources,
- [CockroachDB](/grafana/plugins/grafana-cockroachdb-datasource)
- [Databricks](/grafana/plugins/grafana-databricks-datasource)
- [DataDog](/grafana/plugins/grafana-datadog-datasource)
- [IBM Db2](/grafana/plugins/grafana-ibmdb2-datasource)
- [Drone](/grafana/plugins/grafana-drone-datasource)
- [DynamoDB](/grafana/plugins/grafana-dynamodb-datasource/)
- [Dynatrace](/grafana/plugins/grafana-dynatrace-datasource)
@@ -2030,6 +2030,44 @@ For example: `disabled_labels=grafana_folder`
<hr>
### `[unified_alerting.state_history]`
This section configures where Grafana Alerting writes alert state history. Refer to [Configure alert state history](/docs/grafana/<GRAFANA_VERSION>/alerting/set-up/configure-alert-state-history/) for end-to-end setup and examples.
#### `enabled `
Enables recording alert state history. Default is `false`.
#### `backend `
Select the backend used to store alert state history. Supported values: `loki`, `prometheus`, `multiple`.
#### `loki_remote_url `
The URL of the Loki server used when `backend = loki` (or when `backend = multiple` and Loki is a primary/secondary).
#### `prometheus_target_datasource_uid `
Target Prometheus data source UID used for writing alert state changes when `backend = prometheus` (or when `backend = multiple` and Prometheus is a secondary).
#### `prometheus_metric_name `
Optional. Metric name for the alert state metric. Default is `GRAFANA_ALERTS`.
#### `prometheus_write_timeout `
Optional. Timeout for writing alert state data to the target data source. Default is `10s`.
#### `primary `
Used only when `backend = multiple`. Selects the primary backend (for example `loki`).
#### `secondaries `
Used only when `backend = multiple`. Comma-separated list of secondary backends (for example `prometheus`).
<hr>
### `[unified_alerting.state_history.annotations]`
This section controls retention of annotations automatically created while evaluating alert rules when alerting state history backend is configured to be annotations (see setting [unified_alerting.state_history].backend)
@@ -83,6 +83,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
| `reportingRetries` | Enables rendering retries for the reporting feature |
| `externalServiceAccounts` | Automatic service account and token setup for plugins |
| `cloudWatchBatchQueries` | Runs CloudWatch metrics queries as separate batches |
| `dashboardNewLayouts` | Enables new dashboard layouts |
| `pdfTables` | Enables generating table data as PDF in reporting |
| `canvasPanelPanZoom` | Allow pan and zoom in canvas panel |
| `alertingSaveStateCompressed` | Enables the compressed protobuf-based alert state storage. Default is enabled. |
@@ -30,7 +30,9 @@ refs:
# Datagrid
{{< docs/experimental product="The datagrid visualization" featureFlag="`enableDatagridEditing`" >}}
{{< admonition type="caution" >}}
Starting with Grafana 12.4, Datagrid is deprecated. It will be removed in version 13.0.
{{< /admonition >}}
Datagrids offer you the ability to create, edit, and fine-tune data within Grafana. As such, this panel can act as a data source for other panels
inside a dashboard.
@@ -1,6 +1,7 @@
import { test, expect } from '@grafana/plugin-e2e';
import testV2DashWithRepeats from '../dashboards/V2DashWithRepeats.json';
import testV2DashWithRowRepeats from '../dashboards/V2DashWithRowRepeats.json';
import {
checkRepeatedPanelTitles,
@@ -10,11 +11,14 @@ import {
saveDashboard,
importTestDashboard,
goToEmbeddedPanel,
goToPanelSnapshot,
} from './utils';
const repeatTitleBase = 'repeat - ';
const newTitleBase = 'edited rep - ';
const repeatOptions = [1, 2, 3, 4];
const getTitleInRepeatRow = (rowIndex: number, panelIndex: number) =>
`repeated-row-${rowIndex}-repeated-panel-${panelIndex}`;
test.use({
featureToggles: {
@@ -165,9 +169,7 @@ test.describe(
)
).toBeVisible();
await dashboardPage
.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.backToDashboardButton)
.click();
await page.keyboard.press('Escape');
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.DashboardEditPaneSplitter.primaryBody)
@@ -217,9 +219,7 @@ test.describe(
)
).toBeVisible();
await dashboardPage
.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.backToDashboardButton)
.click();
await page.keyboard.press('Escape');
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.DashboardEditPaneSplitter.primaryBody)
@@ -405,5 +405,143 @@ test.describe(
await dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.headerContainer).all()
).toHaveLength(3);
});
test('can view repeated panel in a repeated row', async ({ dashboardPage, selectors, page }) => {
await importTestDashboard(
page,
selectors,
'Custom grid repeats - view repeated panel in a repeated row',
JSON.stringify(testV2DashWithRowRepeats)
);
// make sure the repeated panel is present in multiple rows
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1)))
).toBeVisible();
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(2, 2)))
).toBeVisible();
await dashboardPage
.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1)))
.hover();
await page.keyboard.press('v');
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(2, 2)))
).not.toBeVisible();
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1)))
).toBeVisible();
const repeatedPanelUrl = page.url();
await page.keyboard.press('Escape');
// load view panel directly
await page.goto(repeatedPanelUrl);
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1)))
).toBeVisible();
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(2, 2)))
).not.toBeVisible();
});
test('can view embedded panel in a repeated row', async ({ dashboardPage, selectors, page }) => {
const embedPanelTitle = 'embedded-panel';
await importTestDashboard(
page,
selectors,
'Custom grid repeats - view embedded repeated panel in a repeated row',
JSON.stringify(testV2DashWithRowRepeats)
);
await dashboardPage
.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1)))
.hover();
await page.keyboard.press('p+e');
await goToEmbeddedPanel(page);
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1)))
).toBeVisible();
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(2, 2)))
).not.toBeVisible();
});
// there is a bug in the Snapshot feature that prevents the next two tests from passing
// tracking issue: https://github.com/grafana/grafana/issues/114509
test.skip('can view repeated panel inside snapshot', async ({ dashboardPage, selectors, page }) => {
await importTestDashboard(
page,
selectors,
'Custom grid repeats - view repeated panel inside snapshot',
JSON.stringify(testV2DashWithRowRepeats)
);
await dashboardPage
.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1)))
.hover();
await page.keyboard.press('p+s');
// click "Publish snapshot"
await dashboardPage
.getByGrafanaSelector(selectors.pages.ShareDashboardDrawer.ShareSnapshot.publishSnapshot)
.click();
// click "Copy link" button in the snapshot drawer
await dashboardPage
.getByGrafanaSelector(selectors.pages.ShareDashboardDrawer.ShareSnapshot.copyUrlButton)
.click();
await goToPanelSnapshot(page);
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1)))
).toBeVisible();
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(2, 2)))
).not.toBeVisible();
});
test.skip('can view single panel in a repeated row inside snapshot', async ({ dashboardPage, selectors, page }) => {
await importTestDashboard(
page,
selectors,
'Custom grid repeats - view single panel inside snapshot',
JSON.stringify(testV2DashWithRowRepeats)
);
await dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('single panel row 1')).hover();
// open panel snapshot
await page.keyboard.press('p+s');
// click "Publish snapshot"
await dashboardPage
.getByGrafanaSelector(selectors.pages.ShareDashboardDrawer.ShareSnapshot.publishSnapshot)
.click();
// click "Copy link" button
await dashboardPage
.getByGrafanaSelector(selectors.pages.ShareDashboardDrawer.ShareSnapshot.copyUrlButton)
.click();
await goToPanelSnapshot(page);
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('single panel row 1'))
).toBeVisible();
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1)))
).toBeHidden();
});
}
);
@@ -218,6 +218,15 @@ export async function goToEmbeddedPanel(page: Page) {
await page.goto(soloPanelUrl!);
}
export async function goToPanelSnapshot(page: Page) {
// extracting snapshot url from clipboard
const snapshotUrl = await page.evaluate(() => navigator.clipboard.readText());
expect(snapshotUrl).toBeDefined();
await page.goto(snapshotUrl);
}
export async function moveTab(
dashboardPage: DashboardPage,
page: Page,
@@ -0,0 +1,486 @@
{
"apiVersion": "dashboard.grafana.app/v2beta1",
"kind": "Dashboard",
"metadata": {
"name": "ad8l8fz",
"namespace": "default",
"uid": "fLb2na54K8NZHvn8LfWGL1jhZh03Hy0xpV1KzMYgAXEX",
"resourceVersion": "1",
"generation": 2,
"creationTimestamp": "2025-11-25T15:52:42Z",
"labels": {
"grafana.app/deprecatedInternalID": "20"
},
"annotations": {
"grafana.app/createdBy": "user:aerwo725ot62od",
"grafana.app/updatedBy": "user:aerwo725ot62od",
"grafana.app/updatedTimestamp": "2025-11-25T15:52:42Z",
"grafana.app/folder": ""
}
},
"spec": {
"annotations": [
{
"kind": "AnnotationQuery",
"spec": {
"builtIn": true,
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"query": {
"datasource": {
"name": "-- Grafana --"
},
"group": "grafana",
"kind": "DataQuery",
"spec": {},
"version": "v0"
}
}
}
],
"cursorSync": "Off",
"description": "",
"editable": true,
"elements": {
"panel-1": {
"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": 4,
"links": [],
"title": "repeated-row-$c4-repeated-panel-$c3",
"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.4.0-pre"
}
}
},
"panel-2": {
"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": 2,
"links": [],
"title": "single panel row $c4",
"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.4.0-pre"
}
}
}
},
"layout": {
"kind": "RowsLayout",
"spec": {
"rows": [
{
"kind": "RowsLayoutRow",
"spec": {
"collapse": false,
"layout": {
"kind": "GridLayout",
"spec": {
"items": [
{
"kind": "GridLayoutItem",
"spec": {
"element": {
"kind": "ElementReference",
"name": "panel-1"
},
"height": 10,
"repeat": {
"direction": "h",
"mode": "variable",
"value": "c3"
},
"width": 24,
"x": 0,
"y": 0
}
},
{
"kind": "GridLayoutItem",
"spec": {
"element": {
"kind": "ElementReference",
"name": "panel-2"
},
"height": 8,
"width": 12,
"x": 0,
"y": 10
}
}
]
}
},
"repeat": {
"mode": "variable",
"value": "c4"
},
"title": "Repeated row $c4"
}
}
]
}
},
"links": [],
"liveNow": false,
"preload": false,
"tags": [],
"timeSettings": {
"autoRefresh": "",
"autoRefreshIntervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
"fiscalYearStartMonth": 0,
"from": "now-6h",
"hideTimepicker": false,
"timezone": "browser",
"to": "now"
},
"title": "test-e2e-repeats",
"variables": [
{
"kind": "CustomVariable",
"spec": {
"allowCustomValue": true,
"current": {
"text": ["1", "2", "3", "4"],
"value": ["1", "2", "3", "4"]
},
"hide": "dontHide",
"includeAll": true,
"multi": true,
"name": "c1",
"options": [
{
"selected": true,
"text": "1",
"value": "1"
},
{
"selected": true,
"text": "2",
"value": "2"
},
{
"selected": true,
"text": "3",
"value": "3"
},
{
"selected": true,
"text": "4",
"value": "4"
}
],
"query": "1,2,3,4",
"skipUrlSync": false
}
},
{
"kind": "CustomVariable",
"spec": {
"allowCustomValue": true,
"current": {
"text": ["A", "B", "C", "D"],
"value": ["A", "B", "C", "D"]
},
"hide": "dontHide",
"includeAll": true,
"multi": true,
"name": "c2",
"options": [
{
"selected": true,
"text": "A",
"value": "A"
},
{
"selected": true,
"text": "B",
"value": "B"
},
{
"selected": true,
"text": "C",
"value": "C"
},
{
"selected": true,
"text": "D",
"value": "D"
}
],
"query": "A,B,C,D",
"skipUrlSync": false
}
},
{
"kind": "CustomVariable",
"spec": {
"allowCustomValue": true,
"current": {
"text": ["1", "2", "3", "4"],
"value": ["1", "2", "3", "4"]
},
"hide": "dontHide",
"includeAll": false,
"multi": true,
"name": "c3",
"options": [
{
"selected": true,
"text": "1",
"value": "1"
},
{
"selected": true,
"text": "2",
"value": "2"
},
{
"selected": true,
"text": "3",
"value": "3"
},
{
"selected": true,
"text": "4",
"value": "4"
}
],
"query": "1, 2, 3, 4",
"skipUrlSync": false
}
},
{
"kind": "CustomVariable",
"spec": {
"allowCustomValue": true,
"current": {
"text": ["1", "2", "3", "4"],
"value": ["1", "2", "3", "4"]
},
"hide": "dontHide",
"includeAll": false,
"multi": true,
"name": "c4",
"options": [
{
"selected": true,
"text": "1",
"value": "1"
},
{
"selected": true,
"text": "2",
"value": "2"
},
{
"selected": true,
"text": "3",
"value": "3"
},
{
"selected": true,
"text": "4",
"value": "4"
}
],
"query": "1, 2, 3, 4",
"skipUrlSync": false
}
}
]
},
"status": {}
}
+128 -25
View File
@@ -1337,6 +1337,11 @@
"count": 2
}
},
"public/app/features/alerting/unified/api/onCallApi.test.ts": {
"no-restricted-syntax": {
"count": 2
}
},
"public/app/features/alerting/unified/components/AnnotationDetailsField.tsx": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
@@ -1377,6 +1382,11 @@
"count": 1
}
},
"public/app/features/alerting/unified/components/import-to-gma/ConfirmConvertModal.test.tsx": {
"no-restricted-syntax": {
"count": 1
}
},
"public/app/features/alerting/unified/components/import-to-gma/NamespaceAndGroupFilter.tsx": {
"no-restricted-syntax": {
"count": 2
@@ -1617,11 +1627,31 @@
"count": 1
}
},
"public/app/features/alerting/unified/mocks/server/configure.ts": {
"no-restricted-syntax": {
"count": 1
}
},
"public/app/features/alerting/unified/mocks/server/handlers/plugins.ts": {
"no-restricted-syntax": {
"count": 1
}
},
"public/app/features/alerting/unified/rule-editor/clone.utils.test.tsx": {
"no-restricted-syntax": {
"count": 2
}
},
"public/app/features/alerting/unified/rule-editor/formDefaults.ts": {
"no-restricted-syntax": {
"count": 6
}
},
"public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts": {
"no-restricted-syntax": {
"count": 1
}
},
"public/app/features/alerting/unified/types/alerting.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 5
@@ -1632,6 +1662,16 @@
"count": 1
}
},
"public/app/features/alerting/unified/utils/config.test.ts": {
"no-restricted-syntax": {
"count": 6
}
},
"public/app/features/alerting/unified/utils/config.ts": {
"no-restricted-syntax": {
"count": 1
}
},
"public/app/features/alerting/unified/utils/datasource.ts": {
"no-restricted-syntax": {
"count": 2
@@ -1663,12 +1703,20 @@
"count": 1
}
},
"public/app/features/alerting/unified/utils/rules.test.ts": {
"no-restricted-syntax": {
"count": 1
}
},
"public/app/features/alerting/unified/utils/rules.ts": {
"@typescript-eslint/consistent-type-assertions": {
"count": 3
},
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"no-restricted-syntax": {
"count": 1
}
},
"public/app/features/annotations/components/StandardAnnotationQueryEditor.tsx": {
@@ -1724,6 +1772,16 @@
"count": 1
}
},
"public/app/features/connections/components/AdvisorRedirectNotice/AdvisorRedirectNotice.test.tsx": {
"no-restricted-syntax": {
"count": 2
}
},
"public/app/features/connections/components/AdvisorRedirectNotice/AdvisorRedirectNotice.tsx": {
"no-restricted-syntax": {
"count": 1
}
},
"public/app/features/connections/tabs/ConnectData/ConnectData.tsx": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
@@ -2063,6 +2121,11 @@
"count": 1
}
},
"public/app/features/dashboard/components/GenAI/utils.ts": {
"no-restricted-syntax": {
"count": 1
}
},
"public/app/features/dashboard/components/HelpWizard/HelpWizard.tsx": {
"no-restricted-syntax": {
"count": 3
@@ -2889,6 +2952,71 @@
"count": 1
}
},
"public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts": {
"no-restricted-syntax": {
"count": 6
}
},
"public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.test.ts": {
"no-restricted-syntax": {
"count": 6
}
},
"public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts": {
"no-restricted-syntax": {
"count": 6
}
},
"public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.test.ts": {
"no-restricted-syntax": {
"count": 6
}
},
"public/app/features/plugins/extensions/usePluginComponent.test.tsx": {
"no-restricted-syntax": {
"count": 3
}
},
"public/app/features/plugins/extensions/usePluginComponents.test.tsx": {
"no-restricted-syntax": {
"count": 2
}
},
"public/app/features/plugins/extensions/usePluginFunctions.test.tsx": {
"no-restricted-syntax": {
"count": 2
}
},
"public/app/features/plugins/extensions/usePluginLinks.test.tsx": {
"no-restricted-syntax": {
"count": 2
}
},
"public/app/features/plugins/extensions/utils.test.tsx": {
"no-restricted-syntax": {
"count": 27
}
},
"public/app/features/plugins/extensions/utils.tsx": {
"no-restricted-syntax": {
"count": 7
}
},
"public/app/features/plugins/extensions/validators.test.tsx": {
"no-restricted-syntax": {
"count": 30
}
},
"public/app/features/plugins/extensions/validators.ts": {
"no-restricted-syntax": {
"count": 4
}
},
"public/app/features/plugins/sandbox/codeLoader.ts": {
"no-restricted-syntax": {
"count": 1
}
},
"public/app/features/plugins/sandbox/distortions.ts": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
@@ -3615,46 +3743,21 @@
"count": 1
}
},
"public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/DateHistogramSettingsEditor.tsx": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
}
},
"public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/TermsSettingsEditor.tsx": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
}
},
"public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/aggregations.ts": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
}
},
"public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.ts": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
}
},
"public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/MetricEditor.tsx": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
}
},
"public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/SettingField.tsx": {
"@typescript-eslint/consistent-type-assertions": {
"count": 2
}
},
"public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/aggregations.ts": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
}
},
"public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.ts": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
}
},
"public/app/plugins/datasource/elasticsearch/configuration/DataLinks.tsx": {
"no-restricted-syntax": {
"count": 1
+38
View File
@@ -117,6 +117,8 @@ module.exports = [
'scripts/grafana-server/tmp',
'packages/grafana-ui/src/graveyard', // deprecated UI components slated for removal
'public/build-swagger', // swagger build output
'apps/plugins/plugin/src/generated/meta/v0alpha1',
'apps/plugins/plugin/src/generated/plugin/v0alpha1',
],
},
...grafanaConfig,
@@ -575,6 +577,42 @@ module.exports = [
"Property[key.name='a11y'][value.type='ObjectExpression'] Property[key.name='test'][value.value='off']",
message: 'Skipping a11y tests is not allowed. Please fix the component or story instead.',
},
{
selector: 'MemberExpression[object.name="config"][property.name="apps"]',
message:
'Usage of config.apps is not allowed. Use the function getAppPluginMetas or useAppPluginMetas from @grafana/runtime instead',
},
],
},
},
{
files: [...commonTestIgnores],
ignores: [
// FIXME: Remove once all enterprise issues are fixed -
// we don't have a suppressions file/approach for enterprise code yet
...enterpriseIgnores,
],
rules: {
'no-restricted-syntax': [
'error',
{
selector: 'MemberExpression[object.name="config"][property.name="apps"]',
message:
'Usage of config.apps is not allowed. Use the function getAppPluginMetas or useAppPluginMetas from @grafana/runtime instead',
},
],
},
},
{
files: [...enterpriseIgnores],
rules: {
'no-restricted-syntax': [
'error',
{
selector: 'MemberExpression[object.name="config"][property.name="apps"]',
message:
'Usage of config.apps is not allowed. Use the function getAppPluginMetas or useAppPluginMetas from @grafana/runtime instead',
},
],
},
},
+11 -12
View File
@@ -44,8 +44,8 @@ require (
github.com/beevik/etree v1.4.1 // @grafana/grafana-backend-group
github.com/benbjohnson/clock v1.3.5 // @grafana/alerting-backend
github.com/blang/semver/v4 v4.0.0 // indirect; @grafana/grafana-developer-enablement-squad
github.com/blevesearch/bleve/v2 v2.5.0 // @grafana/grafana-search-and-storage
github.com/blevesearch/bleve_index_api v1.2.7 // @grafana/grafana-search-and-storage
github.com/blevesearch/bleve/v2 v2.5.7 // @grafana/grafana-search-and-storage
github.com/blevesearch/bleve_index_api v1.3.0 // @grafana/grafana-search-and-storage
github.com/blugelabs/bluge v0.2.2 // @grafana/grafana-backend-group
github.com/blugelabs/bluge_segment_api v0.2.0 // @grafana/grafana-backend-group
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf // @grafana/grafana-backend-group
@@ -365,22 +365,22 @@ require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.22.0 // indirect
github.com/blang/semver v3.5.1+incompatible // indirect
github.com/blevesearch/geo v0.1.20 // indirect
github.com/blevesearch/go-faiss v1.0.25 // indirect
github.com/blevesearch/geo v0.2.4 // indirect
github.com/blevesearch/go-faiss v1.0.26 // indirect
github.com/blevesearch/go-porterstemmer v1.0.3 // indirect
github.com/blevesearch/gtreap v0.1.1 // indirect
github.com/blevesearch/mmap-go v1.0.4 // indirect
github.com/blevesearch/scorch_segment_api/v2 v2.3.9 // indirect
github.com/blevesearch/scorch_segment_api/v2 v2.3.13 // indirect
github.com/blevesearch/segment v0.9.1 // indirect
github.com/blevesearch/snowballstem v0.9.0 // indirect
github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect
github.com/blevesearch/vellum v1.1.0 // indirect
github.com/blevesearch/zapx/v11 v11.4.1 // indirect
github.com/blevesearch/zapx/v12 v12.4.1 // indirect
github.com/blevesearch/zapx/v13 v13.4.1 // indirect
github.com/blevesearch/zapx/v14 v14.4.1 // indirect
github.com/blevesearch/zapx/v15 v15.4.1 // indirect
github.com/blevesearch/zapx/v16 v16.2.2 // indirect
github.com/blevesearch/zapx/v11 v11.4.2 // indirect
github.com/blevesearch/zapx/v12 v12.4.2 // indirect
github.com/blevesearch/zapx/v13 v13.4.2 // indirect
github.com/blevesearch/zapx/v14 v14.4.2 // indirect
github.com/blevesearch/zapx/v15 v15.4.2 // indirect
github.com/blevesearch/zapx/v16 v16.2.8 // indirect
github.com/bluele/gcache v0.0.2 // indirect
github.com/blugelabs/ice v1.0.0 // indirect
github.com/blugelabs/ice/v2 v2.0.1 // indirect
@@ -443,7 +443,6 @@ require (
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 // indirect
github.com/gomodule/redigo v1.8.9 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/cel-go v0.26.1 // indirect
+22 -24
View File
@@ -931,14 +931,14 @@ github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdn
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
github.com/blevesearch/bleve/v2 v2.5.0 h1:HzYqBy/5/M9Ul9ESEmXzN/3Jl7YpmWBdHM/+zzv/3k4=
github.com/blevesearch/bleve/v2 v2.5.0/go.mod h1:PcJzTPnEynO15dCf9isxOga7YFRa/cMSsbnRwnszXUk=
github.com/blevesearch/bleve_index_api v1.2.7 h1:c8r9vmbaYQroAMSGag7zq5gEVPiuXrUQDqfnj7uYZSY=
github.com/blevesearch/bleve_index_api v1.2.7/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0=
github.com/blevesearch/geo v0.1.20 h1:paaSpu2Ewh/tn5DKn/FB5SzvH0EWupxHEIwbCk/QPqM=
github.com/blevesearch/geo v0.1.20/go.mod h1:DVG2QjwHNMFmjo+ZgzrIq2sfCh6rIHzy9d9d0B59I6w=
github.com/blevesearch/go-faiss v1.0.25 h1:lel1rkOUGbT1CJ0YgzKwC7k+XH0XVBHnCVWahdCXk4U=
github.com/blevesearch/go-faiss v1.0.25/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk=
github.com/blevesearch/bleve/v2 v2.5.7 h1:2d9YrL5zrX5EBBW++GOaEKjE+NPWeZGaX77IM26m1Z8=
github.com/blevesearch/bleve/v2 v2.5.7/go.mod h1:yj0NlS7ocGC4VOSAedqDDMktdh2935v2CSWOCDMHdSA=
github.com/blevesearch/bleve_index_api v1.3.0 h1:DsMpWVjFNlBw9/6pyWf59XoqcAkhHj3H0UWiQsavb6E=
github.com/blevesearch/bleve_index_api v1.3.0/go.mod h1:xvd48t5XMeeioWQ5/jZvgLrV98flT2rdvEJ3l/ki4Ko=
github.com/blevesearch/geo v0.2.4 h1:ECIGQhw+QALCZaDcogRTNSJYQXRtC8/m8IKiA706cqk=
github.com/blevesearch/geo v0.2.4/go.mod h1:K56Q33AzXt2YExVHGObtmRSFYZKYGv0JEN5mdacJJR8=
github.com/blevesearch/go-faiss v1.0.26 h1:4dRLolFgjPyjkaXwff4NfbZFdE/dfywbzDqporeQvXI=
github.com/blevesearch/go-faiss v1.0.26/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk=
github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo=
github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M=
github.com/blevesearch/gtreap v0.1.1 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y=
@@ -947,8 +947,8 @@ github.com/blevesearch/mmap-go v1.0.2/go.mod h1:ol2qBqYaOUsGdm7aRMRrYGgPvnwLe6Y+
github.com/blevesearch/mmap-go v1.0.3/go.mod h1:pYvKl/grLQrBxuaRYgoTssa4rVujYYeenDp++2E+yvs=
github.com/blevesearch/mmap-go v1.0.4 h1:OVhDhT5B/M1HNPpYPBKIEJaD0F3Si+CrEKULGCDPWmc=
github.com/blevesearch/mmap-go v1.0.4/go.mod h1:EWmEAOmdAS9z/pi/+Toxu99DnsbhG1TIxUoRmJw/pSs=
github.com/blevesearch/scorch_segment_api/v2 v2.3.9 h1:X6nJXnNHl7nasXW+U6y2Ns2Aw8F9STszkYkyBfQ+p0o=
github.com/blevesearch/scorch_segment_api/v2 v2.3.9/go.mod h1:IrzspZlVjhf4X29oJiEhBxEteTqOY9RlYlk1lCmYHr4=
github.com/blevesearch/scorch_segment_api/v2 v2.3.13 h1:ZPjv/4VwWvHJZKeMSgScCapOy8+DdmsmRyLmSB88UoY=
github.com/blevesearch/scorch_segment_api/v2 v2.3.13/go.mod h1:ENk2LClTehOuMS8XzN3UxBEErYmtwkE7MAArFTXs9Vc=
github.com/blevesearch/segment v0.9.0/go.mod h1:9PfHYUdQCgHktBgvtUOF4x+pc4/l8rdH0u5spnW85UQ=
github.com/blevesearch/segment v0.9.1 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU=
github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw=
@@ -960,18 +960,18 @@ github.com/blevesearch/vellum v1.0.5/go.mod h1:atE0EH3fvk43zzS7t1YNdNC7DbmcC3uz+
github.com/blevesearch/vellum v1.0.7/go.mod h1:doBZpmRhwTsASB4QdUZANlJvqVAUdUyX0ZK7QJCTeBE=
github.com/blevesearch/vellum v1.1.0 h1:CinkGyIsgVlYf8Y2LUQHvdelgXr6PYuvoDIajq6yR9w=
github.com/blevesearch/vellum v1.1.0/go.mod h1:QgwWryE8ThtNPxtgWJof5ndPfx0/YMBh+W2weHKPw8Y=
github.com/blevesearch/zapx/v11 v11.4.1 h1:qFCPlFbsEdwbbckJkysptSQOsHn4s6ZOHL5GMAIAVHA=
github.com/blevesearch/zapx/v11 v11.4.1/go.mod h1:qNOGxIqdPC1MXauJCD9HBG487PxviTUUbmChFOAosGs=
github.com/blevesearch/zapx/v12 v12.4.1 h1:K77bhypII60a4v8mwvav7r4IxWA8qxhNjgF9xGdb9eQ=
github.com/blevesearch/zapx/v12 v12.4.1/go.mod h1:QRPrlPOzAxBNMI0MkgdD+xsTqx65zbuPr3Ko4Re49II=
github.com/blevesearch/zapx/v13 v13.4.1 h1:EnkEMZFUK0lsW/jOJJF2xOcp+W8TjEsyeN5BeAZEYYE=
github.com/blevesearch/zapx/v13 v13.4.1/go.mod h1:e6duBMlCvgbH9rkzNMnUa9hRI9F7ri2BRcHfphcmGn8=
github.com/blevesearch/zapx/v14 v14.4.1 h1:G47kGCshknBZzZAtjcnIAMn3oNx8XBLxp8DMq18ogyE=
github.com/blevesearch/zapx/v14 v14.4.1/go.mod h1:O7sDxiaL2r2PnCXbhh1Bvm7b4sP+jp4unE9DDPWGoms=
github.com/blevesearch/zapx/v15 v15.4.1 h1:B5IoTMUCEzFdc9FSQbhVOxAY+BO17c05866fNruiI7g=
github.com/blevesearch/zapx/v15 v15.4.1/go.mod h1:b/MreHjYeQoLjyY2+UaM0hGZZUajEbE0xhnr1A2/Q6Y=
github.com/blevesearch/zapx/v16 v16.2.2 h1:MifKJVRTEhMTgSlle2bDRTb39BGc9jXFRLPZc6r0Rzk=
github.com/blevesearch/zapx/v16 v16.2.2/go.mod h1:B9Pk4G1CqtErgQV9DyCSA9Lb7WZe4olYfGw7fVDZ4sk=
github.com/blevesearch/zapx/v11 v11.4.2 h1:l46SV+b0gFN+Rw3wUI1YdMWdSAVhskYuvxlcgpQFljs=
github.com/blevesearch/zapx/v11 v11.4.2/go.mod h1:4gdeyy9oGa/lLa6D34R9daXNUvfMPZqUYjPwiLmekwc=
github.com/blevesearch/zapx/v12 v12.4.2 h1:fzRbhllQmEMUuAQ7zBuMvKRlcPA5ESTgWlDEoB9uQNE=
github.com/blevesearch/zapx/v12 v12.4.2/go.mod h1:TdFmr7afSz1hFh/SIBCCZvcLfzYvievIH6aEISCte58=
github.com/blevesearch/zapx/v13 v13.4.2 h1:46PIZCO/ZuKZYgxI8Y7lOJqX3Irkc3N8W82QTK3MVks=
github.com/blevesearch/zapx/v13 v13.4.2/go.mod h1:knK8z2NdQHlb5ot/uj8wuvOq5PhDGjNYQQy0QDnopZk=
github.com/blevesearch/zapx/v14 v14.4.2 h1:2SGHakVKd+TrtEqpfeq8X+So5PShQ5nW6GNxT7fWYz0=
github.com/blevesearch/zapx/v14 v14.4.2/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXmppH9Hi7a877D8=
github.com/blevesearch/zapx/v15 v15.4.2 h1:sWxpDE0QQOTjyxYbAVjt3+0ieu8NCE0fDRaFxEsp31k=
github.com/blevesearch/zapx/v15 v15.4.2/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw=
github.com/blevesearch/zapx/v16 v16.2.8 h1:SlnzF0YGtSlrsOE3oE7EgEX6BIepGpeqxs1IjMbHLQI=
github.com/blevesearch/zapx/v16 v16.2.8/go.mod h1:murSoCJPCk25MqURrcJaBQ1RekuqSCSfMjXH4rHyA14=
github.com/bluele/gcache v0.0.2 h1:WcbfdXICg7G/DGBh1PFfcirkWOQV+v077yF1pSy3DGw=
github.com/bluele/gcache v0.0.2/go.mod h1:m15KV+ECjptwSPxKhOhQoAFQVtUFjTVkc3H8o0t/fp0=
github.com/blugelabs/bluge v0.2.2 h1:gat8CqE6P6tOgeX30XGLOVNTC26cpM2RWVcreXWtYcM=
@@ -1442,8 +1442,6 @@ github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2V
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 h1:gtexQ/VGyN+VVFRXSFiguSNcXmS6rkKT+X7FdIrTtfo=
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4=
github.com/golang/glog v1.1.0/go.mod h1:pfYeQZ3JWZoXTV5sFc986z3HTpwQs9At6P4ImfuP3NQ=
+27 -2
View File
@@ -520,14 +520,40 @@ github.com/benbjohnson/immutable v0.4.0 h1:CTqXbEerYso8YzVPxmWxh2gnoRQbbB9X1quUC
github.com/benbjohnson/immutable v0.4.0/go.mod h1:iAr8OjJGLnLmVUr9MZ/rz4PWUy6Ouc2JLYuMArmvAJM=
github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY=
github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932 h1:mXoPYz/Ul5HYEDvkta6I8/rnYM5gSdSV2tJ6XbZuEtY=
github.com/blevesearch/bleve/v2 v2.5.7 h1:2d9YrL5zrX5EBBW++GOaEKjE+NPWeZGaX77IM26m1Z8=
github.com/blevesearch/bleve/v2 v2.5.7/go.mod h1:yj0NlS7ocGC4VOSAedqDDMktdh2935v2CSWOCDMHdSA=
github.com/blevesearch/bleve_index_api v1.2.8/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0=
github.com/blevesearch/bleve_index_api v1.2.11 h1:bXQ54kVuwP8hdrXUSOnvTQfgK0KI1+f9A0ITJT8tX1s=
github.com/blevesearch/bleve_index_api v1.2.11/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0=
github.com/blevesearch/bleve_index_api v1.3.0 h1:DsMpWVjFNlBw9/6pyWf59XoqcAkhHj3H0UWiQsavb6E=
github.com/blevesearch/bleve_index_api v1.3.0/go.mod h1:xvd48t5XMeeioWQ5/jZvgLrV98flT2rdvEJ3l/ki4Ko=
github.com/blevesearch/geo v0.2.4 h1:ECIGQhw+QALCZaDcogRTNSJYQXRtC8/m8IKiA706cqk=
github.com/blevesearch/geo v0.2.4/go.mod h1:K56Q33AzXt2YExVHGObtmRSFYZKYGv0JEN5mdacJJR8=
github.com/blevesearch/go-faiss v1.0.26/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk=
github.com/blevesearch/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:kDy+zgJFJJoJYBvdfBSiZYBbdsUL0XcjHYWezpQBGPA=
github.com/blevesearch/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:9eJDeqxJ3E7WnLebQUlPD7ZjSce7AnDb9vjGmMCbD0A=
github.com/blevesearch/goleveldb v1.0.1 h1:iAtV2Cu5s0GD1lwUiekkFHe2gTMCCNVj2foPclDLIFI=
github.com/blevesearch/goleveldb v1.0.1/go.mod h1:WrU8ltZbIp0wAoig/MHbrPCXSOLpe79nz5lv5nqfYrQ=
github.com/blevesearch/scorch_segment_api/v2 v2.3.10/go.mod h1:Z3e6ChN3qyN35yaQpl00MfI5s8AxUJbpTR/DL8QOQ+8=
github.com/blevesearch/scorch_segment_api/v2 v2.3.13 h1:ZPjv/4VwWvHJZKeMSgScCapOy8+DdmsmRyLmSB88UoY=
github.com/blevesearch/scorch_segment_api/v2 v2.3.13/go.mod h1:ENk2LClTehOuMS8XzN3UxBEErYmtwkE7MAArFTXs9Vc=
github.com/blevesearch/snowball v0.6.1 h1:cDYjn/NCH+wwt2UdehaLpr2e4BwLIjN4V/TdLsL+B5A=
github.com/blevesearch/snowball v0.6.1/go.mod h1:ZF0IBg5vgpeoUhnMza2v0A/z8m1cWPlwhke08LpNusg=
github.com/blevesearch/stempel v0.2.0 h1:CYzVPaScODMvgE9o+kf6D4RJ/VRomyi9uHF+PtB+Afc=
github.com/blevesearch/stempel v0.2.0/go.mod h1:wjeTHqQv+nQdbPuJ/YcvOjTInA2EIc6Ks1FoSUzSLvc=
github.com/blevesearch/vellum v1.0.10/go.mod h1:ul1oT0FhSMDIExNjIxHqJoGpVrBpKCdgDQNxfqgJt7k=
github.com/blevesearch/zapx/v11 v11.4.2 h1:l46SV+b0gFN+Rw3wUI1YdMWdSAVhskYuvxlcgpQFljs=
github.com/blevesearch/zapx/v11 v11.4.2/go.mod h1:4gdeyy9oGa/lLa6D34R9daXNUvfMPZqUYjPwiLmekwc=
github.com/blevesearch/zapx/v12 v12.4.2 h1:fzRbhllQmEMUuAQ7zBuMvKRlcPA5ESTgWlDEoB9uQNE=
github.com/blevesearch/zapx/v12 v12.4.2/go.mod h1:TdFmr7afSz1hFh/SIBCCZvcLfzYvievIH6aEISCte58=
github.com/blevesearch/zapx/v13 v13.4.2 h1:46PIZCO/ZuKZYgxI8Y7lOJqX3Irkc3N8W82QTK3MVks=
github.com/blevesearch/zapx/v13 v13.4.2/go.mod h1:knK8z2NdQHlb5ot/uj8wuvOq5PhDGjNYQQy0QDnopZk=
github.com/blevesearch/zapx/v14 v14.4.2 h1:2SGHakVKd+TrtEqpfeq8X+So5PShQ5nW6GNxT7fWYz0=
github.com/blevesearch/zapx/v14 v14.4.2/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXmppH9Hi7a877D8=
github.com/blevesearch/zapx/v15 v15.4.2 h1:sWxpDE0QQOTjyxYbAVjt3+0ieu8NCE0fDRaFxEsp31k=
github.com/blevesearch/zapx/v15 v15.4.2/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw=
github.com/blevesearch/zapx/v16 v16.2.8 h1:SlnzF0YGtSlrsOE3oE7EgEX6BIepGpeqxs1IjMbHLQI=
github.com/blevesearch/zapx/v16 v16.2.8/go.mod h1:murSoCJPCk25MqURrcJaBQ1RekuqSCSfMjXH4rHyA14=
github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0=
github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE=
github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I=
@@ -998,8 +1024,6 @@ github.com/grafana/prometheus-alertmanager v0.25.1-0.20250331083058-4563aec7a975
github.com/grafana/prometheus-alertmanager v0.25.1-0.20250331083058-4563aec7a975/go.mod h1:FGdGvhI40Dq+CTQaSzK9evuve774cgOUdGfVO04OXkw=
github.com/grafana/prometheus-alertmanager v0.25.1-0.20250604130045-92c8f6389b36 h1:AjZ58JRw1ZieFH/SdsddF5BXtsDKt5kSrKNPWrzYz3Y=
github.com/grafana/prometheus-alertmanager v0.25.1-0.20250604130045-92c8f6389b36/go.mod h1:O/QP1BCm0HHIzbKvgMzqb5sSyH88rzkFk84F4TfJjBU=
github.com/grafana/prometheus-alertmanager v0.25.1-0.20260112162805-d29cc9cf7f0f h1:9tRhudagkQO2s61SLFLSziIdCm7XlkfypVKDxpcHokg=
github.com/grafana/prometheus-alertmanager v0.25.1-0.20260112162805-d29cc9cf7f0f/go.mod h1:AsVdCBeDFN9QbgpJg+8voDAcgsW0RmNvBd70ecMMdC0=
github.com/grafana/pyroscope-go/godeltaprof v0.1.8/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU=
github.com/grafana/pyroscope/api v1.2.1-0.20250415190842-3ff7247547ae/go.mod h1:6CJ1uXmLZ13ufpO9xE4pST+DyaBt0uszzrV0YnoaVLQ=
github.com/grafana/sqlds/v4 v4.2.4/go.mod h1:BQRjUG8rOqrBI4NAaeoWrIMuoNgfi8bdhCJ+5cgEfLU=
@@ -1092,6 +1116,7 @@ github.com/jon-whit/go-grpc-prometheus v1.4.0/go.mod h1:iTPm+Iuhh3IIqR0iGZ91JJEg
github.com/joncrlsn/dque v0.0.0-20211108142734-c2ef48c5192a h1:sfe532Ipn7GX0V6mHdynBk393rDmqgI0QmjLK7ct7TU=
github.com/joncrlsn/dque v0.0.0-20211108142734-c2ef48c5192a/go.mod h1:dNKs71rs2VJGBAmttu7fouEsRQlRjxy0p1Sx+T5wbpY=
github.com/josephspurrier/goversioninfo v1.4.0/go.mod h1:JWzv5rKQr+MmW+LvM412ToT/IkYDZjaclF2pKDss8IY=
github.com/json-iterator/go v0.0.0-20171115153421-f7279a603ede/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/jstemmer/go-junit-report v0.9.1 h1:6QPYqodiu3GuPL+7mfx+NwDdp2eTkp9IfEUpgAwUN0o=
github.com/jsternberg/zap-logfmt v1.3.0 h1:z1n1AOHVVydOOVuyphbOKyR4NICDQFiJMn1IK5hVQ5Y=
github.com/jsternberg/zap-logfmt v1.3.0/go.mod h1:N3DENp9WNmCZxvkBD/eReWwz1149BK6jEN9cQ4fNwZE=
+1
View File
@@ -82,6 +82,7 @@ module.exports = {
// Decoupled plugins run their own tests so ignoring them here.
'<rootDir>/public/app/plugins/datasource/azuremonitor',
'<rootDir>/public/app/plugins/datasource/cloud-monitoring',
'<rootDir>/public/app/plugins/datasource/elasticsearch',
'<rootDir>/public/app/plugins/datasource/grafana-postgresql-datasource',
'<rootDir>/public/app/plugins/datasource/grafana-pyroscope-datasource',
'<rootDir>/public/app/plugins/datasource/grafana-testdata-datasource',
+2 -2
View File
@@ -293,8 +293,8 @@
"@grafana/plugin-ui": "^0.11.1",
"@grafana/prometheus": "workspace:*",
"@grafana/runtime": "workspace:*",
"@grafana/scenes": "v6.52.1",
"@grafana/scenes-react": "v6.52.1",
"@grafana/scenes": "6.52.2",
"@grafana/scenes-react": "6.52.2",
"@grafana/schema": "workspace:*",
"@grafana/sql": "workspace:*",
"@grafana/ui": "workspace:*",
+10 -1
View File
@@ -35,6 +35,14 @@
},
"./test": {
"@grafana-app/source": "./test/index.ts"
},
"./themes/schema.generated.json": {
"@grafana-app/source": "./src/themes/schema.generated.json",
"default": "./dist/esm/themes/schema.generated.json"
},
"./themes/definitions/*.json": {
"@grafana-app/source": "./src/themes/themeDefinitions/*.json",
"default": "./dist/esm/themes/themeDefinitions/*.json"
}
},
"publishConfig": {
@@ -52,7 +60,7 @@
"typecheck": "tsc --emitDeclarationOnly false --noEmit",
"prepack": "cp package.json package.json.bak && node ../../scripts/prepare-npm-package.js",
"postpack": "mv package.json.bak package.json",
"themes-schema": "tsx ./src/themes/scripts/generateSchema.ts"
"themes-schema": "tsx ./scripts/generateSchema.ts"
},
"dependencies": {
"@braintree/sanitize-url": "7.0.1",
@@ -102,6 +110,7 @@
"react-dom": "18.3.1",
"rimraf": "6.0.1",
"rollup": "^4.22.4",
"rollup-plugin-copy": "3.5.0",
"rollup-plugin-esbuild": "6.2.1",
"rollup-plugin-node-externals": "^8.0.0",
"tsx": "^4.21.0",
+21 -2
View File
@@ -1,21 +1,40 @@
import json from '@rollup/plugin-json';
import { createRequire } from 'node:module';
import copy from 'rollup-plugin-copy';
import { entryPoint, plugins, esmOutput, cjsOutput } from '../rollup.config.parts';
const rq = createRequire(import.meta.url);
const pkg = rq('./package.json');
const grafanaDataPlugins = [
...plugins,
copy({
targets: [
{
src: 'src/themes/schema.generated.json',
dest: 'dist/esm/',
},
{
src: 'src/themes/themeDefinitions/*.json',
dest: 'dist/esm/',
},
],
flatten: false,
}),
json(),
];
export default [
{
input: entryPoint,
plugins: [...plugins, json()],
plugins: grafanaDataPlugins,
output: [cjsOutput(pkg, 'grafana-data'), esmOutput(pkg, 'grafana-data')],
treeshake: false,
},
{
input: 'src/unstable.ts',
plugins: [...plugins, json()],
plugins: grafanaDataPlugins,
output: [cjsOutput(pkg, 'grafana-data'), esmOutput(pkg, 'grafana-data')],
treeshake: false,
},
@@ -0,0 +1,22 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { NewThemeOptionsSchema } from '../src/themes/createTheme';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const jsonOut = path.join(__dirname, '..', 'src', 'themes', 'schema.generated.json');
fs.writeFileSync(
jsonOut,
JSON.stringify(
NewThemeOptionsSchema.toJSONSchema({
target: 'draft-07',
}),
undefined,
2
)
);
console.log('Successfully generated theme schema');
+1 -1
View File
@@ -844,7 +844,6 @@ export {
DataLinkConfigOrigin,
SupportedTransformationType,
type InternalDataLink,
type LinkTarget,
type LinkModel,
type LinkModelSupplier,
VariableOrigin,
@@ -852,6 +851,7 @@ export {
VariableSuggestionsScope,
OneClickMode,
} from './types/dataLink';
export { type LinkTarget } from './types/linkTarget';
export {
type Action,
type ActionModel,
@@ -93,7 +93,6 @@ export { DataTransformerID } from '../transformations/transformers/ids';
export { mergeTransformer } from '../transformations/transformers/merge';
export { getThemeById } from '../themes/registry';
export * as experimentalThemeDefinitions from '../themes/themeDefinitions';
export { GrafanaEdition } from '../types/config';
export { SIPrefix } from '../valueFormats/symbolFormatters';
+27 -1
View File
@@ -1,7 +1,18 @@
import { Registry, RegistryItem } from '../utils/Registry';
import { createTheme, NewThemeOptionsSchema } from './createTheme';
import * as extraThemes from './themeDefinitions';
import aubergine from './themeDefinitions/aubergine.json';
import debug from './themeDefinitions/debug.json';
import desertbloom from './themeDefinitions/desertbloom.json';
import gildedgrove from './themeDefinitions/gildedgrove.json';
import gloom from './themeDefinitions/gloom.json';
import mars from './themeDefinitions/mars.json';
import matrix from './themeDefinitions/matrix.json';
import sapphiredusk from './themeDefinitions/sapphiredusk.json';
import synthwave from './themeDefinitions/synthwave.json';
import tron from './themeDefinitions/tron.json';
import victorian from './themeDefinitions/victorian.json';
import zen from './themeDefinitions/zen.json';
import { GrafanaTheme2 } from './types';
export interface ThemeRegistryItem extends RegistryItem {
@@ -9,6 +20,21 @@ export interface ThemeRegistryItem extends RegistryItem {
build: () => GrafanaTheme2;
}
const extraThemes: { [key: string]: unknown } = {
aubergine,
debug,
desertbloom,
gildedgrove,
gloom,
mars,
matrix,
sapphiredusk,
synthwave,
tron,
victorian,
zen,
};
/**
* @internal
* Only for internal use, never use this from a plugin
@@ -1,19 +0,0 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { NewThemeOptionsSchema } from '../createTheme';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
fs.writeFileSync(
path.join(__dirname, '../schema.generated.json'),
JSON.stringify(
NewThemeOptionsSchema.toJSONSchema({
target: 'draft-07',
}),
undefined,
2
)
);
@@ -1,12 +0,0 @@
export { default as aubergine } from './aubergine.json';
export { default as debug } from './debug.json';
export { default as desertbloom } from './desertbloom.json';
export { default as gildedgrove } from './gildedgrove.json';
export { default as mars } from './mars.json';
export { default as matrix } from './matrix.json';
export { default as sapphiredusk } from './sapphiredusk.json';
export { default as synthwave } from './synthwave.json';
export { default as tron } from './tron.json';
export { default as victorian } from './victorian.json';
export { default as zen } from './zen.json';
export { default as gloom } from './gloom.json';
@@ -32,6 +32,7 @@ export type AppPluginConfig = {
path: string;
version: string;
preload: boolean;
/** @deprecated it will be removed in a future release */
angular: AngularMeta;
loadingStrategy: PluginLoadingStrategy;
dependencies: PluginDependencies;
@@ -219,6 +220,7 @@ export interface GrafanaConfig {
snapshotEnabled: boolean;
datasources: { [str: string]: DataSourceInstanceSettings };
panels: { [key: string]: PanelPluginMeta };
/** @deprecated it will be removed in a future release */
apps: Record<string, AppPluginConfig>;
auth: AuthSettings;
minRefreshInterval: string;
+1 -2
View File
@@ -1,5 +1,6 @@
import { ScopedVars } from './ScopedVars';
import { ExploreCorrelationHelperData, ExplorePanelsState } from './explore';
import { LinkTarget } from './linkTarget';
import { InterpolateFunction } from './panel';
import { DataQuery } from './query';
import { TimeRange } from './time';
@@ -88,8 +89,6 @@ export interface InternalDataLink<T extends DataQuery = any> {
range?: TimeRange;
}
export type LinkTarget = '_blank' | '_self' | undefined;
/**
* Processed Link Model. The values are ready to use
*/
+14 -1
View File
@@ -356,7 +356,7 @@ export interface FeatureToggles {
*/
dashboardScene?: boolean;
/**
* Enables experimental new dashboard layouts
* Enables new dashboard layouts
*/
dashboardNewLayouts?: boolean;
/**
@@ -531,6 +531,10 @@ export interface FeatureToggles {
*/
alertingListViewV2?: boolean;
/**
* Enables the new Alerting navigation structure with improved menu grouping
*/
alertingNavigationV2?: boolean;
/**
* Enables saved searches for alert rules list
*/
alertingSavedSearches?: boolean;
@@ -984,6 +988,11 @@ export interface FeatureToggles {
*/
recentlyViewedDashboards?: boolean;
/**
* A/A test for recently viewed dashboards feature
* @default false
*/
experimentRecentlyViewedDashboards?: boolean;
/**
* Enable configuration of alert enrichments in Grafana Cloud.
* @default false
*/
@@ -1246,4 +1255,8 @@ export interface FeatureToggles {
* Enables profiles exemplars support in profiles drilldown
*/
profilesExemplars?: boolean;
/**
* Use synchronized dispatch timer to minimize duplicate notifications across alertmanager HA pods
*/
alertingSyncDispatchTimer?: boolean;
}
@@ -0,0 +1,4 @@
/**
* Target for links - controls whether link opens in new tab or same tab
*/
export type LinkTarget = '_blank' | '_self' | undefined;
+1 -1
View File
@@ -1,7 +1,7 @@
import { ComponentType } from 'react';
import { LinkTarget } from './dataLink';
import { IconName } from './icon';
import { LinkTarget } from './linkTarget';
export interface NavLinkDTO {
id?: string;
+2
View File
@@ -11,6 +11,7 @@ import { DataFrame } from './dataFrame';
import { DataQueryError, DataQueryRequest, DataQueryTimings } from './datasource';
import { FieldConfigSource } from './fieldOverrides';
import { IconName } from './icon';
import { LinkTarget } from './linkTarget';
import { OptionEditorConfig } from './options';
import { PluginMeta } from './plugin';
import { AbsoluteTimeRange, TimeRange, TimeZone } from './time';
@@ -191,6 +192,7 @@ export interface PanelMenuItem {
onClick?: (event: React.MouseEvent) => void;
shortcut?: string;
href?: string;
target?: LinkTarget;
subMenu?: PanelMenuItem[];
}
@@ -53,6 +53,7 @@ export interface PluginError {
pluginType?: PluginType;
}
/** @deprecated it will be removed in a future release */
export interface AngularMeta {
detected: boolean;
hideDeprecation: boolean;
+1 -1
View File
@@ -9,4 +9,4 @@
* and be subject to the standard policies
*/
export { default as themeJsonSchema } from './themes/schema.generated.json';
export {};
+2 -1
View File
@@ -8,7 +8,8 @@
"emitDeclarationOnly": true,
"isolatedModules": true,
"rootDirs": ["."],
"moduleResolution": "bundler"
"moduleResolution": "bundler",
"resolveJsonModule": true
},
"exclude": ["dist/**/*"],
"include": [
+1
View File
@@ -86,6 +86,7 @@ export class GrafanaBootConfig {
snapshotEnabled = true;
datasources: { [str: string]: DataSourceInstanceSettings } = {};
panels: { [key: string]: PanelPluginMeta } = {};
/** @deprecated it will be removed in a future release, use isAppPluginInstalled or getAppPluginVersion instead */
apps: Record<string, AppPluginConfigGrafanaData> = {};
auth: AuthSettings = {};
minRefreshInterval = '';
+2
View File
@@ -77,3 +77,5 @@ export {
getCorrelationsService,
setCorrelationsService,
} from './services/CorrelationsService';
export { getAppPluginVersion, isAppPluginInstalled } from './services/pluginMeta/apps';
export { useAppPluginInstalled, useAppPluginVersion } from './services/pluginMeta/hooks';
@@ -29,3 +29,5 @@ export {
export { UserStorage } from '../utils/userStorage';
export { initOpenFeature, evaluateBooleanFlag } from './openFeature';
export { getAppPluginMeta, getAppPluginMetas, setAppPluginMetas } from '../services/pluginMeta/apps';
export { useAppPluginMeta, useAppPluginMetas } from '../services/pluginMeta/hooks';
@@ -16,8 +16,11 @@ export async function initOpenFeature() {
* to allow for overrides https://github.com/open-feature/js-sdk-contrib/tree/main/libs/providers/multi-provider
*/
const subPath = config.appSubUrl || '';
const baseUrl = `${subPath}/apis/features.grafana.app/v0alpha1/namespaces/${config.namespace}`;
const ofProvider = new OFREPWebProvider({
baseUrl: '/apis/features.grafana.app/v0alpha1/namespaces/' + config.namespace,
baseUrl: baseUrl,
pollInterval: -1, // disable polling
timeoutMs: 5_000,
});
@@ -0,0 +1,258 @@
import { evaluateBooleanFlag } from '../../internal/openFeature';
import {
getAppPluginMeta,
getAppPluginMetas,
getAppPluginVersion,
isAppPluginInstalled,
setAppPluginMetas,
} from './apps';
import { initPluginMetas } from './plugins';
import { app } from './test-fixtures/config.apps';
jest.mock('./plugins', () => ({ ...jest.requireActual('./plugins'), initPluginMetas: jest.fn() }));
jest.mock('../../internal/openFeature', () => ({
...jest.requireActual('../../internal/openFeature'),
evaluateBooleanFlag: jest.fn(),
}));
const initPluginMetasMock = jest.mocked(initPluginMetas);
const evaluateBooleanFlagMock = jest.mocked(evaluateBooleanFlag);
describe('when useMTPlugins flag is enabled and apps is not initialized', () => {
beforeEach(() => {
setAppPluginMetas({});
jest.resetAllMocks();
initPluginMetasMock.mockResolvedValue({ items: [] });
evaluateBooleanFlagMock.mockReturnValue(true);
});
it('getAppPluginMetas should call initPluginMetas and return correct result', async () => {
const apps = await getAppPluginMetas();
expect(apps).toEqual([]);
expect(initPluginMetasMock).toHaveBeenCalledTimes(1);
});
it('getAppPluginMeta should call initPluginMetas and return correct result', async () => {
const result = await getAppPluginMeta('myorg-someplugin-app');
expect(result).toEqual(null);
expect(initPluginMetasMock).toHaveBeenCalledTimes(1);
});
it('isAppPluginInstalled should call initPluginMetas and return false', async () => {
const installed = await isAppPluginInstalled('myorg-someplugin-app');
expect(installed).toEqual(false);
expect(initPluginMetasMock).toHaveBeenCalledTimes(1);
});
it('getAppPluginVersion should call initPluginMetas and return null', async () => {
const result = await getAppPluginVersion('myorg-someplugin-app');
expect(result).toEqual(null);
expect(initPluginMetasMock).toHaveBeenCalledTimes(1);
});
});
describe('when useMTPlugins flag is enabled and apps is initialized', () => {
beforeEach(() => {
setAppPluginMetas({ 'myorg-someplugin-app': app });
jest.resetAllMocks();
evaluateBooleanFlagMock.mockReturnValue(true);
});
it('getAppPluginMetas should not call initPluginMetas and return correct result', async () => {
const apps = await getAppPluginMetas();
expect(apps).toEqual([app]);
expect(initPluginMetasMock).not.toHaveBeenCalled();
});
it('getAppPluginMeta should not call initPluginMetas and return correct result', async () => {
const result = await getAppPluginMeta('myorg-someplugin-app');
expect(result).toEqual(app);
expect(initPluginMetasMock).not.toHaveBeenCalled();
});
it('getAppPluginMeta should return null if the pluginId is not found', async () => {
const result = await getAppPluginMeta('otherorg-otherplugin-app');
expect(result).toEqual(null);
});
it('isAppPluginInstalled should not call initPluginMetas and return true', async () => {
const installed = await isAppPluginInstalled('myorg-someplugin-app');
expect(installed).toEqual(true);
expect(initPluginMetasMock).not.toHaveBeenCalled();
});
it('isAppPluginInstalled should return false if the pluginId is not found', async () => {
const result = await isAppPluginInstalled('otherorg-otherplugin-app');
expect(result).toEqual(false);
});
it('getAppPluginVersion should not call initPluginMetas and return correct result', async () => {
const result = await getAppPluginVersion('myorg-someplugin-app');
expect(result).toEqual('1.0.0');
expect(initPluginMetasMock).not.toHaveBeenCalled();
});
it('getAppPluginVersion should return null if the pluginId is not found', async () => {
const result = await getAppPluginVersion('otherorg-otherplugin-app');
expect(result).toEqual(null);
});
});
describe('when useMTPlugins flag is disabled and apps is not initialized', () => {
beforeEach(() => {
setAppPluginMetas({});
jest.resetAllMocks();
evaluateBooleanFlagMock.mockReturnValue(false);
});
it('getAppPluginMetas should not call initPluginMetas and return correct result', async () => {
const apps = await getAppPluginMetas();
expect(apps).toEqual([]);
expect(initPluginMetasMock).not.toHaveBeenCalled();
});
it('getAppPluginMeta should not call initPluginMetas and return correct result', async () => {
const result = await getAppPluginMeta('myorg-someplugin-app');
expect(result).toEqual(null);
expect(initPluginMetasMock).not.toHaveBeenCalled();
});
it('isAppPluginInstalled should not call initPluginMetas and return false', async () => {
const result = await isAppPluginInstalled('myorg-someplugin-app');
expect(result).toEqual(false);
expect(initPluginMetasMock).not.toHaveBeenCalled();
});
it('getAppPluginVersion should not call initPluginMetas and return correct result', async () => {
const result = await getAppPluginVersion('myorg-someplugin-app');
expect(result).toEqual(null);
expect(initPluginMetasMock).not.toHaveBeenCalled();
});
});
describe('when useMTPlugins flag is disabled and apps is initialized', () => {
beforeEach(() => {
setAppPluginMetas({ 'myorg-someplugin-app': app });
jest.resetAllMocks();
evaluateBooleanFlagMock.mockReturnValue(false);
});
it('getAppPluginMetas should not call initPluginMetas and return correct result', async () => {
const apps = await getAppPluginMetas();
expect(apps).toEqual([app]);
expect(initPluginMetasMock).not.toHaveBeenCalled();
});
it('getAppPluginMeta should not call initPluginMetas and return correct result', async () => {
const result = await getAppPluginMeta('myorg-someplugin-app');
expect(result).toEqual(app);
expect(initPluginMetasMock).not.toHaveBeenCalled();
});
it('getAppPluginMeta should return null if the pluginId is not found', async () => {
const result = await getAppPluginMeta('otherorg-otherplugin-app');
expect(result).toEqual(null);
});
it('isAppPluginInstalled should not call initPluginMetas and return true', async () => {
const result = await isAppPluginInstalled('myorg-someplugin-app');
expect(result).toEqual(true);
expect(initPluginMetasMock).not.toHaveBeenCalled();
});
it('isAppPluginInstalled should return false if the pluginId is not found', async () => {
const result = await isAppPluginInstalled('otherorg-otherplugin-app');
expect(result).toEqual(false);
});
it('getAppPluginVersion should not call initPluginMetas and return correct result', async () => {
const result = await getAppPluginVersion('myorg-someplugin-app');
expect(result).toEqual('1.0.0');
expect(initPluginMetasMock).not.toHaveBeenCalled();
});
it('getAppPluginVersion should return null if the pluginId is not found', async () => {
const result = await getAppPluginVersion('otherorg-otherplugin-app');
expect(result).toEqual(null);
});
});
describe('immutability', () => {
beforeEach(() => {
setAppPluginMetas({ 'myorg-someplugin-app': app });
jest.resetAllMocks();
evaluateBooleanFlagMock.mockReturnValue(false);
});
it('getAppPluginMetas should return a deep clone', async () => {
const mutatedApps = await getAppPluginMetas();
// assert we have correct props
expect(mutatedApps).toHaveLength(1);
expect(mutatedApps[0].dependencies.grafanaDependency).toEqual('>=10.4.0');
expect(mutatedApps[0].extensions.addedLinks).toHaveLength(0);
// mutate deep props
mutatedApps[0].dependencies.grafanaDependency = '';
mutatedApps[0].extensions.addedLinks.push({ targets: [], title: '', description: '' });
// assert we have mutated props
expect(mutatedApps[0].dependencies.grafanaDependency).toEqual('');
expect(mutatedApps[0].extensions.addedLinks).toHaveLength(1);
expect(mutatedApps[0].extensions.addedLinks[0]).toEqual({ targets: [], title: '', description: '' });
const apps = await getAppPluginMetas();
// assert that we have not mutated the source
expect(apps[0].dependencies.grafanaDependency).toEqual('>=10.4.0');
expect(apps[0].extensions.addedLinks).toHaveLength(0);
});
it('getAppPluginMeta should return a deep clone', async () => {
const mutatedApp = await getAppPluginMeta('myorg-someplugin-app');
// assert we have correct props
expect(mutatedApp).toBeDefined();
expect(mutatedApp!.dependencies.grafanaDependency).toEqual('>=10.4.0');
expect(mutatedApp!.extensions.addedLinks).toHaveLength(0);
// mutate deep props
mutatedApp!.dependencies.grafanaDependency = '';
mutatedApp!.extensions.addedLinks.push({ targets: [], title: '', description: '' });
// assert we have mutated props
expect(mutatedApp!.dependencies.grafanaDependency).toEqual('');
expect(mutatedApp!.extensions.addedLinks).toHaveLength(1);
expect(mutatedApp!.extensions.addedLinks[0]).toEqual({ targets: [], title: '', description: '' });
const result = await getAppPluginMeta('myorg-someplugin-app');
// assert that we have not mutated the source
expect(result).toBeDefined();
expect(result!.dependencies.grafanaDependency).toEqual('>=10.4.0');
expect(result!.extensions.addedLinks).toHaveLength(0);
});
});
@@ -0,0 +1,71 @@
import type { AppPluginConfig } from '@grafana/data';
import { config } from '../../config';
import { evaluateBooleanFlag } from '../../internal/openFeature';
import { getAppPluginMapper } from './mappers/mappers';
import { initPluginMetas } from './plugins';
import type { AppPluginMetas } from './types';
let apps: AppPluginMetas = {};
function initialized(): boolean {
return Boolean(Object.keys(apps).length);
}
async function initAppPluginMetas(): Promise<void> {
if (!evaluateBooleanFlag('useMTPlugins', false)) {
// eslint-disable-next-line no-restricted-syntax
apps = config.apps;
return;
}
const metas = await initPluginMetas();
const mapper = getAppPluginMapper();
apps = mapper(metas);
}
export async function getAppPluginMetas(): Promise<AppPluginConfig[]> {
if (!initialized()) {
await initAppPluginMetas();
}
return Object.values(structuredClone(apps));
}
export async function getAppPluginMeta(pluginId: string): Promise<AppPluginConfig | null> {
if (!initialized()) {
await initAppPluginMetas();
}
const app = apps[pluginId];
return app ? structuredClone(app) : null;
}
/**
* Check if an app plugin is installed. The function does not check if the app plugin is enabled.
* @param pluginId - The id of the app plugin.
* @returns True if the app plugin is installed, false otherwise.
*/
export async function isAppPluginInstalled(pluginId: string): Promise<boolean> {
const app = await getAppPluginMeta(pluginId);
return Boolean(app);
}
/**
* Get the version of an app plugin.
* @param pluginId - The id of the app plugin.
* @returns The version of the app plugin, or null if the plugin is not installed.
*/
export async function getAppPluginVersion(pluginId: string): Promise<string | null> {
const app = await getAppPluginMeta(pluginId);
return app?.version ?? null;
}
export function setAppPluginMetas(override: AppPluginMetas): void {
if (process.env.NODE_ENV !== 'test') {
throw new Error('setAppPluginMetas() function can only be called from tests.');
}
apps = structuredClone(override);
}
@@ -0,0 +1,214 @@
import { renderHook, waitFor } from '@testing-library/react';
import {
getAppPluginMeta,
getAppPluginMetas,
getAppPluginVersion,
isAppPluginInstalled,
setAppPluginMetas,
} from './apps';
import { useAppPluginMeta, useAppPluginMetas, useAppPluginInstalled, useAppPluginVersion } from './hooks';
import { apps } from './test-fixtures/config.apps';
const actualApps = jest.requireActual<typeof import('./apps')>('./apps');
jest.mock('./apps', () => ({
...jest.requireActual('./apps'),
getAppPluginMetas: jest.fn(),
getAppPluginMeta: jest.fn(),
isAppPluginInstalled: jest.fn(),
getAppPluginVersion: jest.fn(),
}));
const getAppPluginMetaMock = jest.mocked(getAppPluginMeta);
const getAppPluginMetasMock = jest.mocked(getAppPluginMetas);
const isAppPluginInstalledMock = jest.mocked(isAppPluginInstalled);
const getAppPluginVersionMock = jest.mocked(getAppPluginVersion);
describe('useAppPluginMeta', () => {
beforeEach(() => {
setAppPluginMetas(apps);
jest.resetAllMocks();
getAppPluginMetaMock.mockImplementation(actualApps.getAppPluginMeta);
});
it('should return correct default values', async () => {
const { result } = renderHook(() => useAppPluginMeta('grafana-exploretraces-app'));
expect(result.current.loading).toEqual(true);
expect(result.current.error).toBeUndefined();
expect(result.current.value).toBeUndefined();
await waitFor(() => expect(result.current.loading).toEqual(true));
});
it('should return correct values after loading', async () => {
const { result } = renderHook(() => useAppPluginMeta('grafana-exploretraces-app'));
await waitFor(() => expect(result.current.loading).toEqual(false));
expect(result.current.loading).toEqual(false);
expect(result.current.error).toBeUndefined();
expect(result.current.value).toEqual(apps['grafana-exploretraces-app']);
});
it('should return correct values if the pluginId does not exist', async () => {
const { result } = renderHook(() => useAppPluginMeta('otherorg-otherplugin-app'));
await waitFor(() => expect(result.current.loading).toEqual(false));
expect(result.current.loading).toEqual(false);
expect(result.current.error).toBeUndefined();
expect(result.current.value).toEqual(null);
});
it('should return correct values if useAppPluginMeta throws', async () => {
getAppPluginMetaMock.mockRejectedValue(new Error('Some error'));
const { result } = renderHook(() => useAppPluginMeta('otherorg-otherplugin-app'));
await waitFor(() => expect(result.current.loading).toEqual(false));
expect(result.current.loading).toEqual(false);
expect(result.current.error).toEqual(new Error('Some error'));
expect(result.current.value).toBeUndefined();
});
});
describe('useAppPluginMetas', () => {
beforeEach(() => {
setAppPluginMetas(apps);
jest.resetAllMocks();
getAppPluginMetasMock.mockImplementation(actualApps.getAppPluginMetas);
});
it('should return correct default values', async () => {
const { result } = renderHook(() => useAppPluginMetas());
expect(result.current.loading).toEqual(true);
expect(result.current.error).toBeUndefined();
expect(result.current.value).toBeUndefined();
await waitFor(() => expect(result.current.loading).toEqual(true));
});
it('should return correct values after loading', async () => {
const { result } = renderHook(() => useAppPluginMetas());
await waitFor(() => expect(result.current.loading).toEqual(false));
expect(result.current.loading).toEqual(false);
expect(result.current.error).toBeUndefined();
expect(result.current.value).toEqual(Object.values(apps));
});
it('should return correct values if useAppPluginMetas throws', async () => {
getAppPluginMetasMock.mockRejectedValue(new Error('Some error'));
const { result } = renderHook(() => useAppPluginMetas());
await waitFor(() => expect(result.current.loading).toEqual(false));
expect(result.current.loading).toEqual(false);
expect(result.current.error).toEqual(new Error('Some error'));
expect(result.current.value).toBeUndefined();
});
});
describe('useAppPluginInstalled', () => {
beforeEach(() => {
setAppPluginMetas(apps);
jest.resetAllMocks();
isAppPluginInstalledMock.mockImplementation(actualApps.isAppPluginInstalled);
});
it('should return correct default values', async () => {
const { result } = renderHook(() => useAppPluginInstalled('grafana-exploretraces-app'));
expect(result.current.loading).toEqual(true);
expect(result.current.error).toBeUndefined();
expect(result.current.value).toBeUndefined();
await waitFor(() => expect(result.current.loading).toEqual(true));
});
it('should return correct values after loading', async () => {
const { result } = renderHook(() => useAppPluginInstalled('grafana-exploretraces-app'));
await waitFor(() => expect(result.current.loading).toEqual(false));
expect(result.current.loading).toEqual(false);
expect(result.current.error).toBeUndefined();
expect(result.current.value).toEqual(true);
});
it('should return correct values if the pluginId does not exist', async () => {
const { result } = renderHook(() => useAppPluginInstalled('otherorg-otherplugin-app'));
await waitFor(() => expect(result.current.loading).toEqual(false));
expect(result.current.loading).toEqual(false);
expect(result.current.error).toBeUndefined();
expect(result.current.value).toEqual(false);
});
it('should return correct values if isAppPluginInstalled throws', async () => {
isAppPluginInstalledMock.mockRejectedValue(new Error('Some error'));
const { result } = renderHook(() => useAppPluginInstalled('otherorg-otherplugin-app'));
await waitFor(() => expect(result.current.loading).toEqual(false));
expect(result.current.loading).toEqual(false);
expect(result.current.error).toEqual(new Error('Some error'));
expect(result.current.value).toBeUndefined();
});
});
describe('useAppPluginVersion', () => {
beforeEach(() => {
setAppPluginMetas(apps);
jest.resetAllMocks();
getAppPluginVersionMock.mockImplementation(actualApps.getAppPluginVersion);
});
it('should return correct default values', async () => {
const { result } = renderHook(() => useAppPluginVersion('grafana-exploretraces-app'));
expect(result.current.loading).toEqual(true);
expect(result.current.error).toBeUndefined();
expect(result.current.value).toBeUndefined();
await waitFor(() => expect(result.current.loading).toEqual(true));
});
it('should return correct values after loading', async () => {
const { result } = renderHook(() => useAppPluginVersion('grafana-exploretraces-app'));
await waitFor(() => expect(result.current.loading).toEqual(false));
expect(result.current.loading).toEqual(false);
expect(result.current.error).toBeUndefined();
expect(result.current.value).toEqual('1.2.2');
});
it('should return correct values if the pluginId does not exist', async () => {
const { result } = renderHook(() => useAppPluginVersion('otherorg-otherplugin-app'));
await waitFor(() => expect(result.current.loading).toEqual(false));
expect(result.current.loading).toEqual(false);
expect(result.current.error).toBeUndefined();
expect(result.current.value).toEqual(null);
});
it('should return correct values if getAppPluginVersion throws', async () => {
getAppPluginVersionMock.mockRejectedValue(new Error('Some error'));
const { result } = renderHook(() => useAppPluginVersion('otherorg-otherplugin-app'));
await waitFor(() => expect(result.current.loading).toEqual(false));
expect(result.current.loading).toEqual(false);
expect(result.current.error).toEqual(new Error('Some error'));
expect(result.current.value).toBeUndefined();
});
});
@@ -0,0 +1,35 @@
import { useAsync } from 'react-use';
import { getAppPluginMeta, getAppPluginMetas, getAppPluginVersion, isAppPluginInstalled } from './apps';
export function useAppPluginMetas() {
const { loading, error, value } = useAsync(async () => getAppPluginMetas());
return { loading, error, value };
}
export function useAppPluginMeta(pluginId: string) {
const { loading, error, value } = useAsync(async () => getAppPluginMeta(pluginId));
return { loading, error, value };
}
/**
* Hook that checks if an app plugin is installed. The hook does not check if the app plugin is enabled.
* @param pluginId - The ID of the app plugin.
* @returns loading, error, value of the app plugin installed status.
* The value is true if the app plugin is installed, false otherwise.
*/
export function useAppPluginInstalled(pluginId: string) {
const { loading, error, value } = useAsync(async () => isAppPluginInstalled(pluginId));
return { loading, error, value };
}
/**
* Hook that gets the version of an app plugin.
* @param pluginId - The ID of the app plugin.
* @returns loading, error, value of the app plugin version.
* The value is the version of the app plugin, or null if the plugin is not installed.
*/
export function useAppPluginVersion(pluginId: string) {
const { loading, error, value } = useAsync(async () => getAppPluginVersion(pluginId));
return { loading, error, value };
}
@@ -0,0 +1,7 @@
import { AppPluginMetasMapper, PluginMetasResponse } from '../types';
import { v0alpha1AppMapper } from './v0alpha1AppMapper';
export function getAppPluginMapper(): AppPluginMetasMapper<PluginMetasResponse> {
return v0alpha1AppMapper;
}
@@ -0,0 +1,84 @@
import { apps } from '../test-fixtures/config.apps';
import { v0alpha1Response } from '../test-fixtures/v0alpha1Response';
import { v0alpha1AppMapper } from './v0alpha1AppMapper';
const PLUGIN_IDS = v0alpha1Response.items
.filter((i) => i.spec.pluginJson.type === 'app')
.map((i) => ({ pluginId: i.spec.pluginJson.id }));
describe('v0alpha1AppMapper', () => {
describe.each(PLUGIN_IDS)('when called for pluginId:$pluginId', ({ pluginId }) => {
it('should map id property correctly', () => {
const result = v0alpha1AppMapper(v0alpha1Response);
expect(result[pluginId].id).toEqual(apps[pluginId].id);
});
it('should map path property correctly', () => {
const result = v0alpha1AppMapper(v0alpha1Response);
expect(result[pluginId].path).toEqual(apps[pluginId].path);
});
it('should map version property correctly', () => {
const result = v0alpha1AppMapper(v0alpha1Response);
expect(result[pluginId].version).toEqual(apps[pluginId].version);
});
it('should map preload property correctly', () => {
const result = v0alpha1AppMapper(v0alpha1Response);
expect(result[pluginId].preload).toEqual(apps[pluginId].preload);
});
it('should map angular property correctly', () => {
const result = v0alpha1AppMapper(v0alpha1Response);
expect(result[pluginId].angular).toEqual({});
});
it('should map loadingStrategy property correctly', () => {
const result = v0alpha1AppMapper(v0alpha1Response);
expect(result[pluginId].loadingStrategy).toEqual(apps[pluginId].loadingStrategy);
});
it('should map dependencies property correctly', () => {
const result = v0alpha1AppMapper(v0alpha1Response);
expect(result[pluginId].dependencies).toEqual(apps[pluginId].dependencies);
});
it('should map extensions property correctly', () => {
const result = v0alpha1AppMapper(v0alpha1Response);
expect(result[pluginId].extensions.addedComponents).toEqual(apps[pluginId].extensions.addedComponents);
expect(result[pluginId].extensions.addedFunctions).toEqual(apps[pluginId].extensions.addedFunctions);
expect(result[pluginId].extensions.addedLinks).toEqual(apps[pluginId].extensions.addedLinks);
expect(result[pluginId].extensions.exposedComponents).toEqual(apps[pluginId].extensions.exposedComponents);
expect(result[pluginId].extensions.extensionPoints).toEqual(apps[pluginId].extensions.extensionPoints);
});
it('should map moduleHash property correctly', () => {
const result = v0alpha1AppMapper(v0alpha1Response);
expect(result[pluginId].moduleHash).toEqual(apps[pluginId].moduleHash);
});
it('should map buildMode property correctly', () => {
const result = v0alpha1AppMapper(v0alpha1Response);
expect(result[pluginId].buildMode).toEqual(apps[pluginId].buildMode);
});
});
it('should only map specs with type app', () => {
const result = v0alpha1AppMapper(v0alpha1Response);
expect(v0alpha1Response.items).toHaveLength(58);
expect(Object.keys(result)).toHaveLength(5);
expect(Object.keys(result)).toEqual(Object.keys(apps));
});
});
@@ -0,0 +1,111 @@
import {
type AngularMeta,
type AppPluginConfig,
type PluginDependencies,
type PluginExtensions,
PluginLoadingStrategy,
type PluginType,
} from '@grafana/data';
import type { AppPluginMetas, AppPluginMetasMapper, PluginMetasResponse } from '../types';
import type { Spec as v0alpha1Spec } from '../types/types.spec.gen';
function angularyMapper(spec: v0alpha1Spec): AngularMeta {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return {} as AngularMeta;
}
function dependenciesMapper(spec: v0alpha1Spec): PluginDependencies {
const plugins = (spec.pluginJson.dependencies?.plugins ?? []).map((v) => ({
...v,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
type: v.type as PluginType,
version: '',
}));
const dependencies: PluginDependencies = {
...spec.pluginJson.dependencies,
extensions: {
exposedComponents: spec.pluginJson.dependencies.extensions?.exposedComponents ?? [],
},
grafanaDependency: spec.pluginJson.dependencies.grafanaDependency,
grafanaVersion: spec.pluginJson.dependencies.grafanaVersion ?? '',
plugins,
};
return dependencies;
}
function extensionsMapper(spec: v0alpha1Spec): PluginExtensions {
const addedComponents = spec.pluginJson.extensions?.addedComponents ?? [];
const addedFunctions = spec.pluginJson.extensions?.addedFunctions ?? [];
const addedLinks = spec.pluginJson.extensions?.addedLinks ?? [];
const exposedComponents = (spec.pluginJson.extensions?.exposedComponents ?? []).map((v) => ({
...v,
description: v.description ?? '',
title: v.title ?? '',
}));
const extensionPoints = (spec.pluginJson.extensions?.extensionPoints ?? []).map((v) => ({
...v,
description: v.description ?? '',
title: v.title ?? '',
}));
const extensions: PluginExtensions = {
addedComponents,
addedFunctions,
addedLinks,
exposedComponents,
extensionPoints,
};
return extensions;
}
function loadingStrategyMapper(spec: v0alpha1Spec): PluginLoadingStrategy {
const loadingStrategy = spec.module?.loadingStrategy ?? PluginLoadingStrategy.fetch;
if (loadingStrategy === PluginLoadingStrategy.script) {
return PluginLoadingStrategy.script;
}
return PluginLoadingStrategy.fetch;
}
function specMapper(spec: v0alpha1Spec): AppPluginConfig {
const { id, info, preload = false } = spec.pluginJson;
const angular = angularyMapper(spec);
const dependencies = dependenciesMapper(spec);
const extensions = extensionsMapper(spec);
const loadingStrategy = loadingStrategyMapper(spec);
const path = spec.module?.path ?? '';
const version = info.version;
const buildMode = spec.pluginJson.buildMode ?? 'production';
const moduleHash = spec.module?.hash;
return {
id,
angular,
dependencies,
extensions,
loadingStrategy,
path,
preload,
version,
buildMode,
moduleHash,
};
}
export const v0alpha1AppMapper: AppPluginMetasMapper<PluginMetasResponse> = (response) => {
const result: AppPluginMetas = {};
return response.items.reduce((acc, curr) => {
if (curr.spec.pluginJson.type !== 'app') {
return acc;
}
const config = specMapper(curr.spec);
acc[config.id] = config;
return acc;
}, result);
};
@@ -0,0 +1,153 @@
import { evaluateBooleanFlag } from '../../internal/openFeature';
import { clearCache, initPluginMetas } from './plugins';
import { v0alpha1Meta } from './test-fixtures/v0alpha1Response';
jest.mock('../../internal/openFeature', () => ({
...jest.requireActual('../../internal/openFeature'),
evaluateBooleanFlag: jest.fn(),
}));
const evaluateBooleanFlagMock = jest.mocked(evaluateBooleanFlag);
describe('when useMTPlugins toggle is enabled and cache is not initialized', () => {
const originalFetch = global.fetch;
beforeEach(() => {
jest.resetAllMocks();
clearCache();
evaluateBooleanFlagMock.mockReturnValue(true);
});
afterEach(() => {
global.fetch = originalFetch;
});
it('initPluginMetas should call loadPluginMetas and return correct result if response is ok', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ items: [v0alpha1Meta] }),
});
const response = await initPluginMetas();
expect(response.items).toHaveLength(1);
expect(response.items[0]).toEqual(v0alpha1Meta);
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(global.fetch).toHaveBeenCalledWith('/apis/plugins.grafana.app/v0alpha1/namespaces/default/metas');
});
it('initPluginMetas should call loadPluginMetas and return correct result if response is not ok', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: false,
status: 404,
statusText: 'Not found',
});
await expect(initPluginMetas()).rejects.toThrow(new Error(`Failed to load plugin metas 404:Not found`));
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(global.fetch).toHaveBeenCalledWith('/apis/plugins.grafana.app/v0alpha1/namespaces/default/metas');
});
});
describe('when useMTPlugins toggle is enabled and cache is initialized', () => {
const originalFetch = global.fetch;
beforeEach(() => {
jest.resetAllMocks();
clearCache();
evaluateBooleanFlagMock.mockReturnValue(true);
});
afterEach(() => {
global.fetch = originalFetch;
});
it('initPluginMetas should return cache', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ items: [v0alpha1Meta] }),
});
const original = await initPluginMetas();
const cached = await initPluginMetas();
expect(original).toEqual(cached);
expect(global.fetch).toHaveBeenCalledTimes(1);
});
it('initPluginMetas should return inflight promise', async () => {
jest.useFakeTimers();
global.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ items: [v0alpha1Meta] }),
});
const original = initPluginMetas();
const cached = initPluginMetas();
await jest.runAllTimersAsync();
expect(original).toEqual(cached);
expect(global.fetch).toHaveBeenCalledTimes(1);
});
});
describe('when useMTPlugins toggle is disabled and cache is not initialized', () => {
const originalFetch = global.fetch;
beforeEach(() => {
jest.resetAllMocks();
clearCache();
global.fetch = jest.fn();
evaluateBooleanFlagMock.mockReturnValue(false);
});
afterEach(() => {
global.fetch = originalFetch;
});
it('initPluginMetas should call loadPluginMetas and return correct result if response is ok', async () => {
const response = await initPluginMetas();
expect(response.items).toHaveLength(0);
expect(global.fetch).not.toHaveBeenCalled();
});
});
describe('when useMTPlugins toggle is disabled and cache is initialized', () => {
const originalFetch = global.fetch;
beforeEach(() => {
jest.resetAllMocks();
clearCache();
global.fetch = jest.fn();
evaluateBooleanFlagMock.mockReturnValue(false);
});
afterEach(() => {
global.fetch = originalFetch;
});
it('initPluginMetas should return cache', async () => {
const original = await initPluginMetas();
const cached = await initPluginMetas();
expect(original).toEqual(cached);
expect(global.fetch).not.toHaveBeenCalled();
});
it('initPluginMetas should return inflight promise', async () => {
jest.useFakeTimers();
const original = initPluginMetas();
const cached = initPluginMetas();
await jest.runAllTimersAsync();
expect(original).toEqual(cached);
expect(global.fetch).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,41 @@
import { config } from '../../config';
import { evaluateBooleanFlag } from '../../internal/openFeature';
import type { PluginMetasResponse } from './types';
let initPromise: Promise<PluginMetasResponse> | null = null;
function getApiVersion(): string {
return 'v0alpha1';
}
async function loadPluginMetas(): Promise<PluginMetasResponse> {
if (!evaluateBooleanFlag('useMTPlugins', false)) {
const result = { items: [] };
return result;
}
const metas = await fetch(`/apis/plugins.grafana.app/${getApiVersion()}/namespaces/${config.namespace}/metas`);
if (!metas.ok) {
throw new Error(`Failed to load plugin metas ${metas.status}:${metas.statusText}`);
}
const result = await metas.json();
return result;
}
export function initPluginMetas(): Promise<PluginMetasResponse> {
if (!initPromise) {
initPromise = loadPluginMetas();
}
return initPromise;
}
export function clearCache() {
if (process.env.NODE_ENV !== 'test') {
throw new Error('clearCache() function can only be called from tests.');
}
initPromise = null;
}
@@ -0,0 +1,303 @@
import { cloneDeep } from 'lodash';
import { AngularMeta, AppPluginConfig, PluginLoadingStrategy } from '@grafana/data';
import { AppPluginMetas } from '../types';
export const app: AppPluginConfig = cloneDeep({
id: 'myorg-someplugin-app',
path: 'public/plugins/myorg-someplugin-app/module.js',
version: '1.0.0',
preload: false,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
angular: { detected: false } as AngularMeta,
loadingStrategy: PluginLoadingStrategy.script,
extensions: {
addedLinks: [],
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
addedFunctions: [],
},
dependencies: {
grafanaDependency: '>=10.4.0',
grafanaVersion: '*',
plugins: [],
extensions: {
exposedComponents: [],
},
},
buildMode: 'production',
});
export const apps: AppPluginMetas = cloneDeep({
'grafana-exploretraces-app': {
id: 'grafana-exploretraces-app',
path: 'public/plugins/grafana-exploretraces-app/module.js',
version: '1.2.2',
preload: true,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
angular: { detected: false } as AngularMeta,
loadingStrategy: PluginLoadingStrategy.script,
extensions: {
addedLinks: [
{
targets: ['grafana/dashboard/panel/menu'],
title: 'Open in Traces Drilldown',
description: 'Open current query in the Traces Drilldown app',
},
{
targets: ['grafana/explore/toolbar/action'],
title: 'Open in Grafana Traces Drilldown',
description: 'Try our new queryless experience for traces',
},
],
addedComponents: [
{
targets: ['grafana-asserts-app/entity-assertions-widget/v1'],
title: 'Asserts widget',
description: 'A block with assertions for a given service',
},
{
targets: ['grafana-asserts-app/insights-timeline-widget/v1'],
title: 'Insights Timeline Widget',
description: 'Widget for displaying insights timeline in other apps',
},
],
exposedComponents: [
{
id: 'grafana-exploretraces-app/open-in-explore-traces-button/v1',
title: 'Open in Traces Drilldown button',
description: 'A button that opens a traces view in the Traces Drilldown app.',
},
{
id: 'grafana-exploretraces-app/embedded-trace-exploration/v1',
title: 'Embedded Trace Exploration',
description:
'A component that renders a trace exploration view that can be embedded in other parts of Grafana.',
},
],
extensionPoints: [
{
id: 'grafana-exploretraces-app/investigation/v1',
title: '',
description: '',
},
{
id: 'grafana-exploretraces-app/get-logs-drilldown-link/v1',
title: '',
description: '',
},
],
addedFunctions: [],
},
dependencies: {
grafanaDependency: '>=11.5.0',
grafanaVersion: '*',
plugins: [],
extensions: {
exposedComponents: [
'grafana-asserts-app/entity-assertions-widget/v1',
'grafana-asserts-app/insights-timeline-widget/v1',
],
},
},
buildMode: 'production',
},
'grafana-lokiexplore-app': {
id: 'grafana-lokiexplore-app',
path: 'public/plugins/grafana-lokiexplore-app/module.js',
version: '1.0.32',
preload: true,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
angular: { detected: false } as AngularMeta,
loadingStrategy: PluginLoadingStrategy.script,
extensions: {
addedLinks: [
{
targets: [
'grafana/dashboard/panel/menu',
'grafana/explore/toolbar/action',
'grafana-metricsdrilldown-app/open-in-logs-drilldown/v1',
'grafana-assistant-app/navigateToDrilldown/v1',
],
title: 'Open in Grafana Logs Drilldown',
description: 'Open current query in the Grafana Logs Drilldown view',
},
],
addedComponents: [
{
targets: ['grafana-asserts-app/insights-timeline-widget/v1'],
title: 'Insights Timeline Widget',
description: 'Widget for displaying insights timeline in other apps',
},
],
exposedComponents: [
{
id: 'grafana-lokiexplore-app/open-in-explore-logs-button/v1',
title: 'Open in Logs Drilldown button',
description: 'A button that opens a logs view in the Logs Drilldown app.',
},
{
id: 'grafana-lokiexplore-app/embedded-logs-exploration/v1',
title: 'Embedded Logs Exploration',
description:
'A component that renders a logs exploration view that can be embedded in other parts of Grafana.',
},
],
extensionPoints: [
{
id: 'grafana-lokiexplore-app/investigation/v1',
title: '',
description: '',
},
],
addedFunctions: [
{
targets: ['grafana-exploretraces-app/get-logs-drilldown-link/v1'],
title: 'Open Logs Drilldown',
description: 'Returns url to logs drilldown app',
},
],
},
dependencies: {
grafanaDependency: '>=11.6.0',
grafanaVersion: '*',
plugins: [],
extensions: {
exposedComponents: [
'grafana-adaptivelogs-app/temporary-exemptions/v1',
'grafana-lokiexplore-app/embedded-logs-exploration/v1',
'grafana-asserts-app/insights-timeline-widget/v1',
'grafana/add-to-dashboard-form/v1',
],
},
},
buildMode: 'production',
},
'grafana-metricsdrilldown-app': {
id: 'grafana-metricsdrilldown-app',
path: 'public/plugins/grafana-metricsdrilldown-app/module.js',
version: '1.0.26',
preload: true,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
angular: { detected: false } as AngularMeta,
loadingStrategy: PluginLoadingStrategy.script,
extensions: {
addedLinks: [
{
targets: [
'grafana/dashboard/panel/menu',
'grafana/explore/toolbar/action',
'grafana-assistant-app/navigateToDrilldown/v1',
'grafana/alerting/alertingrule/queryeditor',
],
title: 'Open in Grafana Metrics Drilldown',
description: 'Open current query in the Grafana Metrics Drilldown view',
},
{
targets: ['grafana-metricsdrilldown-app/grafana-assistant-app/navigateToDrilldown/v0-alpha'],
title: 'Navigate to metrics drilldown',
description: 'Build a url path to the metrics drilldown',
},
{
targets: ['grafana/datasources/config/actions', 'grafana/datasources/config/status'],
title: 'Open in Metrics Drilldown',
description: 'Browse metrics in Grafana Metrics Drilldown',
},
],
addedComponents: [],
exposedComponents: [
{
id: 'grafana-metricsdrilldown-app/label-breakdown-component/v1',
title: 'Label Breakdown',
description: 'A metrics label breakdown view from the Metrics Drilldown app.',
},
{
id: 'grafana-metricsdrilldown-app/knowledge-graph-insight-metrics/v1',
title: 'Knowledge Graph Source Metrics',
description: 'Explore the underlying metrics related to a Knowledge Graph insight',
},
],
extensionPoints: [
{
id: 'grafana-exploremetrics-app/investigation/v1',
title: '',
description: '',
},
{
id: 'grafana-metricsdrilldown-app/open-in-logs-drilldown/v1',
title: '',
description: '',
},
],
addedFunctions: [],
},
dependencies: {
grafanaDependency: '>=11.6.0',
grafanaVersion: '*',
plugins: [],
extensions: {
exposedComponents: ['grafana/add-to-dashboard-form/v1'],
},
},
buildMode: 'production',
},
'grafana-pyroscope-app': {
id: 'grafana-pyroscope-app',
path: 'public/plugins/grafana-pyroscope-app/module.js',
version: '1.14.2',
preload: true,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
angular: { detected: false } as AngularMeta,
loadingStrategy: PluginLoadingStrategy.script,
extensions: {
addedLinks: [
{
targets: [
'grafana/explore/toolbar/action',
'grafana/traceview/details',
'grafana-assistant-app/navigateToDrilldown/v1',
],
title: 'Open in Grafana Profiles Drilldown',
description: 'Try our new queryless experience for profiles',
},
],
addedComponents: [],
exposedComponents: [
{
id: 'grafana-pyroscope-app/embedded-profiles-exploration/v1',
title: 'Embedded Profiles Exploration',
description:
'A component that renders a profiles exploration view that can be embedded in other parts of Grafana.',
},
],
extensionPoints: [
{
id: 'grafana-pyroscope-app/investigation/v1',
title: '',
description: '',
},
{
id: 'grafana-pyroscope-app/settings/v1',
title: '',
description: '',
},
],
addedFunctions: [],
},
dependencies: {
grafanaDependency: '>=11.5.0',
grafanaVersion: '*',
plugins: [],
extensions: {
exposedComponents: [
'grafana-o11yinsights-app/insights-launcher/v1',
'grafana-adaptiveprofiles-app/resolution-boost/v1',
],
},
},
buildMode: 'production',
},
[app.id]: app,
});
@@ -0,0 +1,10 @@
import type { AppPluginConfig } from '@grafana/data';
import type { Meta } from './types/meta_object_gen';
export type AppPluginMetas = Record<string, AppPluginConfig>;
export type AppPluginMetasMapper<T> = (response: T) => AppPluginMetas;
export interface PluginMetasResponse {
items: Meta[];
}
@@ -0,0 +1,49 @@
/*
* This file was generated by grafana-app-sdk. DO NOT EDIT.
*/
import { Spec } from './types.spec.gen';
import { Status } from './types.status.gen';
export interface Metadata {
name: string;
namespace: string;
generateName?: string;
selfLink?: string;
uid?: string;
resourceVersion?: string;
generation?: number;
creationTimestamp?: string;
deletionTimestamp?: string;
deletionGracePeriodSeconds?: number;
labels?: Record<string, string>;
annotations?: Record<string, string>;
ownerReferences?: OwnerReference[];
finalizers?: string[];
managedFields?: ManagedFieldsEntry[];
}
export interface OwnerReference {
apiVersion: string;
kind: string;
name: string;
uid: string;
controller?: boolean;
blockOwnerDeletion?: boolean;
}
export interface ManagedFieldsEntry {
manager?: string;
operation?: string;
apiVersion?: string;
time?: string;
fieldsType?: string;
subresource?: string;
}
export interface Meta {
kind: string;
apiVersion: string;
metadata: Metadata;
spec: Spec;
status: Status;
}
@@ -0,0 +1,278 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
// JSON configuration schema for Grafana plugins
// Converted from: https://github.com/grafana/grafana/blob/main/docs/sources/developers/plugins/plugin.schema.json
export interface JSONData {
// Unique name of the plugin
id: string;
// Plugin type
type: "app" | "datasource" | "panel" | "renderer";
// Human-readable name of the plugin
name: string;
// Metadata for the plugin
info: Info;
// Dependency information
dependencies: Dependencies;
// Optional fields
alerting?: boolean;
annotations?: boolean;
autoEnabled?: boolean;
backend?: boolean;
buildMode?: string;
builtIn?: boolean;
category?: "tsdb" | "logging" | "cloud" | "tracing" | "profiling" | "sql" | "enterprise" | "iot" | "other";
enterpriseFeatures?: EnterpriseFeatures;
executable?: string;
hideFromList?: boolean;
// +listType=atomic
includes?: Include[];
logs?: boolean;
metrics?: boolean;
multiValueFilterOperators?: boolean;
pascalName?: string;
preload?: boolean;
queryOptions?: QueryOptions;
// +listType=atomic
routes?: Route[];
skipDataQuery?: boolean;
state?: "alpha" | "beta";
streaming?: boolean;
suggestions?: boolean;
tracing?: boolean;
iam?: IAM;
// +listType=atomic
roles?: Role[];
extensions?: Extensions;
}
export const defaultJSONData = (): JSONData => ({
id: "",
type: "app",
name: "",
info: defaultInfo(),
dependencies: defaultDependencies(),
});
export interface Info {
// Required fields
// +listType=set
keywords: string[];
logos: {
small: string;
large: string;
};
updated: string;
version: string;
// Optional fields
author?: {
name?: string;
email?: string;
url?: string;
};
description?: string;
// +listType=atomic
links?: {
name?: string;
url?: string;
}[];
// +listType=atomic
screenshots?: {
name?: string;
path?: string;
}[];
}
export const defaultInfo = (): Info => ({
keywords: [],
logos: {
small: "",
large: "",
},
updated: "",
version: "",
});
export interface Dependencies {
// Required field
grafanaDependency: string;
// Optional fields
grafanaVersion?: string;
// +listType=set
// +listMapKey=id
plugins?: {
id: string;
type: "app" | "datasource" | "panel";
name: string;
}[];
extensions?: {
// +listType=set
exposedComponents?: string[];
};
}
export const defaultDependencies = (): Dependencies => ({
grafanaDependency: "",
});
export interface EnterpriseFeatures {
// Allow additional properties
healthDiagnosticsErrors?: boolean;
}
export const defaultEnterpriseFeatures = (): EnterpriseFeatures => ({
healthDiagnosticsErrors: false,
});
export interface Include {
uid?: string;
type?: "dashboard" | "page" | "panel" | "datasource";
name?: string;
component?: string;
role?: "Admin" | "Editor" | "Viewer" | "None";
action?: string;
path?: string;
addToNav?: boolean;
defaultNav?: boolean;
icon?: string;
}
export const defaultInclude = (): Include => ({
});
export interface QueryOptions {
maxDataPoints?: boolean;
minInterval?: boolean;
cacheTimeout?: boolean;
}
export const defaultQueryOptions = (): QueryOptions => ({
});
export interface Route {
path?: string;
method?: string;
url?: string;
reqSignedIn?: boolean;
reqRole?: string;
reqAction?: string;
// +listType=atomic
headers?: string[];
body?: Record<string, any>;
tokenAuth?: {
url?: string;
// +listType=set
scopes?: string[];
params?: Record<string, any>;
};
jwtTokenAuth?: {
url?: string;
// +listType=set
scopes?: string[];
params?: Record<string, any>;
};
// +listType=atomic
urlParams?: {
name?: string;
content?: string;
}[];
}
export const defaultRoute = (): Route => ({
});
export interface IAM {
// +listType=atomic
permissions?: {
action?: string;
scope?: string;
}[];
}
export const defaultIAM = (): IAM => ({
});
export interface Role {
role?: {
name?: string;
description?: string;
// +listType=atomic
permissions?: {
action?: string;
scope?: string;
}[];
};
// +listType=set
grants?: string[];
}
export const defaultRole = (): Role => ({
});
export interface Extensions {
// +listType=atomic
addedComponents?: {
// +listType=set
targets: string[];
title: string;
description?: string;
}[];
// +listType=atomic
addedLinks?: {
// +listType=set
targets: string[];
title: string;
description?: string;
}[];
// +listType=atomic
addedFunctions?: {
// +listType=set
targets: string[];
title: string;
description?: string;
}[];
// +listType=set
// +listMapKey=id
exposedComponents?: {
id: string;
title?: string;
description?: string;
}[];
// +listType=set
// +listMapKey=id
extensionPoints?: {
id: string;
title?: string;
description?: string;
}[];
}
export const defaultExtensions = (): Extensions => ({
});
export interface Spec {
pluginJson: JSONData;
class: "core" | "external";
module?: {
path: string;
hash?: string;
loadingStrategy?: "fetch" | "script";
};
baseURL?: string;
signature?: {
status: "internal" | "valid" | "invalid" | "modified" | "unsigned";
type?: "grafana" | "commercial" | "community" | "private" | "private-glob";
org?: string;
};
angular?: {
detected: boolean;
};
translations?: Record<string, string>;
// +listType=atomic
children?: string[];
}
export const defaultSpec = (): Spec => ({
pluginJson: defaultJSONData(),
class: "core",
});
@@ -0,0 +1,30 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
export interface OperatorState {
// lastEvaluation is the ResourceVersion last evaluated
lastEvaluation: string;
// state describes the state of the lastEvaluation.
// It is limited to three possible states for machine evaluation.
state: "success" | "in_progress" | "failed";
// descriptiveState is an optional more descriptive state field which has no requirements on format
descriptiveState?: string;
// details contains any extra information that is operator-specific
details?: Record<string, any>;
}
export const defaultOperatorState = (): OperatorState => ({
lastEvaluation: "",
state: "success",
});
export interface Status {
// operatorStates is a map of operator ID to operator state evaluations.
// Any operator which consumes this kind SHOULD add its state evaluation information to this field.
operatorStates?: Record<string, OperatorState>;
// additionalFields is reserved for future use
additionalFields?: Record<string, any>;
}
export const defaultStatus = (): Status => ({
});
@@ -10,7 +10,7 @@
import * as common from '@grafana/schema';
export const pluginVersion = "12.4.0-pre";
export const pluginVersion = "%VERSION%";
export type BucketAggregation = (DateHistogram | Histogram | Terms | Filters | GeoHashGrid | Nested);
@@ -1,78 +0,0 @@
import { render, screen } from '@testing-library/react';
import { VizLegendTable } from './VizLegendTable';
import { VizLegendItem } from './types';
describe('VizLegendTable', () => {
const mockItems: VizLegendItem[] = [
{ label: 'Series 1', color: 'red', yAxis: 1 },
{ label: 'Series 2', color: 'blue', yAxis: 1 },
{ label: 'Series 3', color: 'green', yAxis: 1 },
];
it('renders without crashing', () => {
const { container } = render(<VizLegendTable items={mockItems} placement="bottom" />);
expect(container.querySelector('table')).toBeInTheDocument();
});
it('renders all items', () => {
render(<VizLegendTable items={mockItems} placement="bottom" />);
expect(screen.getByText('Series 1')).toBeInTheDocument();
expect(screen.getByText('Series 2')).toBeInTheDocument();
expect(screen.getByText('Series 3')).toBeInTheDocument();
});
it('renders table headers when items have display values', () => {
const itemsWithStats: VizLegendItem[] = [
{
label: 'Series 1',
color: 'red',
yAxis: 1,
getDisplayValues: () => [
{ numeric: 100, text: '100', title: 'Max' },
{ numeric: 50, text: '50', title: 'Min' },
],
},
];
render(<VizLegendTable items={itemsWithStats} placement="bottom" />);
expect(screen.getByText('Max')).toBeInTheDocument();
expect(screen.getByText('Min')).toBeInTheDocument();
});
it('renders sort icon when sorted', () => {
const { container } = render(
<VizLegendTable items={mockItems} placement="bottom" sortBy="Name" sortDesc={false} />
);
expect(container.querySelector('svg')).toBeInTheDocument();
});
it('calls onToggleSort when header is clicked', () => {
const onToggleSort = jest.fn();
render(<VizLegendTable items={mockItems} placement="bottom" onToggleSort={onToggleSort} isSortable={true} />);
const header = screen.getByText('Name');
header.click();
expect(onToggleSort).toHaveBeenCalledWith('Name');
});
it('does not call onToggleSort when not sortable', () => {
const onToggleSort = jest.fn();
render(<VizLegendTable items={mockItems} placement="bottom" onToggleSort={onToggleSort} isSortable={false} />);
const header = screen.getByText('Name');
header.click();
expect(onToggleSort).not.toHaveBeenCalled();
});
it('renders with long labels', () => {
const itemsWithLongLabels: VizLegendItem[] = [
{
label: 'This is a very long series name that should be scrollable within its table cell',
color: 'red',
yAxis: 1,
},
];
render(<VizLegendTable items={itemsWithLongLabels} placement="bottom" />);
expect(
screen.getByText('This is a very long series name that should be scrollable within its table cell')
).toBeInTheDocument();
});
});
@@ -1,112 +0,0 @@
import { render, screen } from '@testing-library/react';
import { LegendTableItem } from './VizLegendTableItem';
import { VizLegendItem } from './types';
describe('LegendTableItem', () => {
const mockItem: VizLegendItem = {
label: 'Series 1',
color: 'red',
yAxis: 1,
};
it('renders without crashing', () => {
const { container } = render(
<table>
<tbody>
<LegendTableItem item={mockItem} />
</tbody>
</table>
);
expect(container.querySelector('tr')).toBeInTheDocument();
});
it('renders label text', () => {
render(
<table>
<tbody>
<LegendTableItem item={mockItem} />
</tbody>
</table>
);
expect(screen.getByText('Series 1')).toBeInTheDocument();
});
it('renders with long label text', () => {
const longLabelItem: VizLegendItem = {
...mockItem,
label: 'This is a very long series name that should be scrollable in the table cell',
};
render(
<table>
<tbody>
<LegendTableItem item={longLabelItem} />
</tbody>
</table>
);
expect(
screen.getByText('This is a very long series name that should be scrollable in the table cell')
).toBeInTheDocument();
});
it('renders stat values when provided', () => {
const itemWithStats: VizLegendItem = {
...mockItem,
getDisplayValues: () => [
{ numeric: 100, text: '100', title: 'Max' },
{ numeric: 50, text: '50', title: 'Min' },
],
};
render(
<table>
<tbody>
<LegendTableItem item={itemWithStats} />
</tbody>
</table>
);
expect(screen.getByText('100')).toBeInTheDocument();
expect(screen.getByText('50')).toBeInTheDocument();
});
it('renders right y-axis indicator when yAxis is 2', () => {
const rightAxisItem: VizLegendItem = {
...mockItem,
yAxis: 2,
};
render(
<table>
<tbody>
<LegendTableItem item={rightAxisItem} />
</tbody>
</table>
);
expect(screen.getByText('(right y-axis)')).toBeInTheDocument();
});
it('calls onLabelClick when label is clicked', () => {
const onLabelClick = jest.fn();
render(
<table>
<tbody>
<LegendTableItem item={mockItem} onLabelClick={onLabelClick} />
</tbody>
</table>
);
const button = screen.getByRole('button');
button.click();
expect(onLabelClick).toHaveBeenCalledWith(mockItem, expect.any(Object));
});
it('does not call onClick when readonly', () => {
const onLabelClick = jest.fn();
render(
<table>
<tbody>
<LegendTableItem item={mockItem} onLabelClick={onLabelClick} readonly={true} />
</tbody>
</table>
);
const button = screen.getByRole('button');
expect(button).toBeDisabled();
});
});
@@ -69,7 +69,7 @@ export const LegendTableItem = ({
return (
<tr className={cx(styles.row, className)}>
<td className={styles.labelCell}>
<td>
<span className={styles.itemWrapper}>
<VizLegendSeriesIcon
color={item.color}
@@ -77,26 +77,24 @@ export const LegendTableItem = ({
readonly={readonly}
lineStyle={item.lineStyle}
/>
<div className={styles.labelCellInner}>
<button
disabled={readonly}
type="button"
title={item.label}
onBlur={onMouseOut}
onFocus={onMouseOver}
onMouseOver={onMouseOver}
onMouseOut={onMouseOut}
onClick={!readonly ? onClick : undefined}
className={cx(styles.label, item.disabled && styles.labelDisabled)}
>
{item.label}{' '}
{item.yAxis === 2 && (
<span className={styles.yAxisLabel}>
<Trans i18nKey="grafana-ui.viz-legend.right-axis-indicator">(right y-axis)</Trans>
</span>
)}
</button>
</div>
<button
disabled={readonly}
type="button"
title={item.label}
onBlur={onMouseOut}
onFocus={onMouseOver}
onMouseOver={onMouseOver}
onMouseOut={onMouseOut}
onClick={!readonly ? onClick : undefined}
className={cx(styles.label, item.disabled && styles.labelDisabled)}
>
{item.label}{' '}
{item.yAxis === 2 && (
<span className={styles.yAxisLabel}>
<Trans i18nKey="grafana-ui.viz-legend.right-axis-indicator">(right y-axis)</Trans>
</span>
)}
</button>
</span>
</td>
{item.getDisplayValues &&
@@ -130,28 +128,6 @@ const getStyles = (theme: GrafanaTheme2) => {
background: rowHoverBg,
},
}),
labelCell: css({
label: 'LegendLabelCell',
maxWidth: 0,
width: '100%',
minWidth: theme.spacing(16),
}),
labelCellInner: css({
label: 'LegendLabelCellInner',
display: 'block',
flex: 1,
minWidth: 0,
overflowX: 'auto',
overflowY: 'hidden',
paddingRight: theme.spacing(3),
scrollbarWidth: 'none',
msOverflowStyle: 'none',
maskImage: `linear-gradient(to right, black calc(100% - ${theme.spacing(3)}), transparent 100%)`,
WebkitMaskImage: `linear-gradient(to right, black calc(100% - ${theme.spacing(3)}), transparent 100%)`,
'&::-webkit-scrollbar': {
display: 'none',
},
}),
label: css({
label: 'LegendLabel',
whiteSpace: 'nowrap',
@@ -159,6 +135,9 @@ const getStyles = (theme: GrafanaTheme2) => {
border: 'none',
fontSize: 'inherit',
padding: 0,
maxWidth: '600px',
textOverflow: 'ellipsis',
overflow: 'hidden',
userSelect: 'text',
}),
labelDisabled: css({
+4 -3
View File
@@ -1,4 +1,5 @@
#!/bin/bash -e
#!/bin/bash
set -e
PERMISSIONS_OK=0
@@ -26,14 +27,14 @@ if [ ! -d "$GF_PATHS_PLUGINS" ]; then
fi
if [ ! -z ${GF_AWS_PROFILES+x} ]; then
> "$GF_PATHS_HOME/.aws/credentials"
:> "$GF_PATHS_HOME/.aws/credentials"
for profile in ${GF_AWS_PROFILES}; do
access_key_varname="GF_AWS_${profile}_ACCESS_KEY_ID"
secret_key_varname="GF_AWS_${profile}_SECRET_ACCESS_KEY"
region_varname="GF_AWS_${profile}_REGION"
if [ ! -z "${!access_key_varname}" -a ! -z "${!secret_key_varname}" ]; then
if [ ! -z "${!access_key_varname}" ] && [ ! -z "${!secret_key_varname}" ]; then
echo "[${profile}]" >> "$GF_PATHS_HOME/.aws/credentials"
echo "aws_access_key_id = ${!access_key_varname}" >> "$GF_PATHS_HOME/.aws/credentials"
echo "aws_secret_access_key = ${!secret_key_varname}" >> "$GF_PATHS_HOME/.aws/credentials"
+6 -6
View File
@@ -161,7 +161,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
AliasIDs: panel.AliasIDs,
Info: panel.Info,
Module: panel.Module,
ModuleHash: panel.ModuleHash,
ModuleHash: hs.pluginAssets.ModuleHash(c.Req.Context(), panel),
BaseURL: panel.BaseURL,
SkipDataQuery: panel.SkipDataQuery,
Suggestions: panel.Suggestions,
@@ -170,7 +170,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
Signature: string(panel.Signature),
Sort: getPanelSort(panel.ID),
Angular: panel.Angular,
LoadingStrategy: panel.LoadingStrategy,
LoadingStrategy: hs.pluginAssets.LoadingStrategy(c.Req.Context(), panel),
Translations: panel.Translations,
}
}
@@ -527,11 +527,11 @@ func (hs *HTTPServer) getFSDataSources(c *contextmodel.ReqContext, availablePlug
JSONData: plugin.JSONData,
Signature: plugin.Signature,
Module: plugin.Module,
ModuleHash: plugin.ModuleHash,
ModuleHash: hs.pluginAssets.ModuleHash(c.Req.Context(), plugin),
BaseURL: plugin.BaseURL,
Angular: plugin.Angular,
MultiValueFilterOperators: plugin.MultiValueFilterOperators,
LoadingStrategy: plugin.LoadingStrategy,
LoadingStrategy: hs.pluginAssets.LoadingStrategy(c.Req.Context(), plugin),
Translations: plugin.Translations,
}
@@ -638,10 +638,10 @@ func (hs *HTTPServer) newAppDTO(ctx context.Context, plugin pluginstore.Plugin,
Path: plugin.Module,
Preload: false,
Angular: plugin.Angular,
LoadingStrategy: plugin.LoadingStrategy,
LoadingStrategy: hs.pluginAssets.LoadingStrategy(ctx, plugin),
Extensions: plugin.Extensions,
Dependencies: plugin.Dependencies,
ModuleHash: plugin.ModuleHash,
ModuleHash: hs.pluginAssets.ModuleHash(ctx, plugin),
Translations: plugin.Translations,
BuildMode: plugin.BuildMode,
}
+44 -20
View File
@@ -20,6 +20,8 @@ import (
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/manager/pluginfakes"
"github.com/grafana/grafana/pkg/plugins/manager/signature"
"github.com/grafana/grafana/pkg/plugins/manager/signature/statickey"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
@@ -31,6 +33,7 @@ import (
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/licensing"
"github.com/grafana/grafana/pkg/services/pluginsintegration/managedplugins"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginassets"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
"github.com/grafana/grafana/pkg/services/rendering"
@@ -43,7 +46,7 @@ import (
"github.com/grafana/grafana/pkg/web"
)
func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features featuremgmt.FeatureToggles, pstore pluginstore.Store, psettings pluginsettings.Service) (*web.Mux, *HTTPServer) {
func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features featuremgmt.FeatureToggles, pstore pluginstore.Store, psettings pluginsettings.Service, passets *pluginassets.Service) (*web.Mux, *HTTPServer) {
t.Helper()
db.InitTestDB(t)
// nolint:staticcheck
@@ -74,6 +77,12 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features featuremgmt.F
pluginsSettings = &pluginsettings.FakePluginSettings{}
}
var pluginsAssets = passets
if pluginsAssets == nil {
sig := signature.ProvideService(pluginsCfg, statickey.New())
pluginsAssets = pluginassets.ProvideService(pluginsCfg, pluginsCDN, sig, pluginStore)
}
hs := &HTTPServer{
authnService: &authntest.FakeService{},
Cfg: cfg,
@@ -90,6 +99,7 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features featuremgmt.F
AccessControl: accesscontrolmock.New(),
PluginSettings: pluginsSettings,
pluginsCDNService: pluginsCDN,
pluginAssets: pluginsAssets,
namespacer: request.GetNamespaceMapper(cfg),
SocialService: socialimpl.ProvideService(cfg, features, &usagestats.UsageStatsMock{}, supportbundlestest.NewFakeBundleService(), remotecache.NewFakeCacheStorage(), nil, ssosettingstests.NewFakeService()),
managedPluginsService: managedplugins.NewNoop(),
@@ -122,7 +132,7 @@ func TestIntegrationHTTPServer_GetFrontendSettings_hideVersionAnonymous(t *testi
cfg.BuildVersion = "7.8.9"
cfg.BuildCommit = "01234567"
m, hs := setupTestEnvironment(t, cfg, featuremgmt.WithFeatures(), nil, nil)
m, hs := setupTestEnvironment(t, cfg, featuremgmt.WithFeatures(), nil, nil, nil)
req := httptest.NewRequest(http.MethodGet, "/api/frontend/settings", nil)
@@ -214,7 +224,7 @@ func TestIntegrationHTTPServer_GetFrontendSettings_pluginsCDNBaseURL(t *testing.
if test.mutateCfg != nil {
test.mutateCfg(cfg)
}
m, _ := setupTestEnvironment(t, cfg, featuremgmt.WithFeatures(), nil, nil)
m, _ := setupTestEnvironment(t, cfg, featuremgmt.WithFeatures(), nil, nil, nil)
req := httptest.NewRequest(http.MethodGet, "/api/frontend/settings", nil)
recorder := httptest.NewRecorder()
@@ -239,6 +249,7 @@ func TestIntegrationHTTPServer_GetFrontendSettings_apps(t *testing.T) {
desc string
pluginStore func() pluginstore.Store
pluginSettings func() pluginsettings.Service
pluginAssets func() *pluginassets.Service
expected settings
}{
{
@@ -255,8 +266,7 @@ func TestIntegrationHTTPServer_GetFrontendSettings_apps(t *testing.T) {
Type: plugins.TypeApp,
Preload: true,
},
FS: &pluginfakes.FakePluginFS{},
LoadingStrategy: plugins.LoadingStrategyScript,
FS: &pluginfakes.FakePluginFS{},
},
},
}
@@ -266,6 +276,7 @@ func TestIntegrationHTTPServer_GetFrontendSettings_apps(t *testing.T) {
Plugins: newAppSettings("test-app", false),
}
},
pluginAssets: newPluginAssets(),
expected: settings{
Apps: map[string]*plugins.AppDTO{
"test-app": {
@@ -293,8 +304,7 @@ func TestIntegrationHTTPServer_GetFrontendSettings_apps(t *testing.T) {
Type: plugins.TypeApp,
Preload: true,
},
FS: &pluginfakes.FakePluginFS{},
LoadingStrategy: plugins.LoadingStrategyScript,
FS: &pluginfakes.FakePluginFS{},
},
},
}
@@ -304,6 +314,7 @@ func TestIntegrationHTTPServer_GetFrontendSettings_apps(t *testing.T) {
Plugins: newAppSettings("test-app", true),
}
},
pluginAssets: newPluginAssets(),
expected: settings{
Apps: map[string]*plugins.AppDTO{
"test-app": {
@@ -330,9 +341,8 @@ func TestIntegrationHTTPServer_GetFrontendSettings_apps(t *testing.T) {
Type: plugins.TypeApp,
Preload: true,
},
Angular: plugins.AngularMeta{Detected: true},
FS: &pluginfakes.FakePluginFS{},
LoadingStrategy: plugins.LoadingStrategyFetch,
Angular: plugins.AngularMeta{Detected: true},
FS: &pluginfakes.FakePluginFS{},
},
},
}
@@ -342,6 +352,7 @@ func TestIntegrationHTTPServer_GetFrontendSettings_apps(t *testing.T) {
Plugins: newAppSettings("test-app", true),
}
},
pluginAssets: newPluginAssets(),
expected: settings{
Apps: map[string]*plugins.AppDTO{
"test-app": {
@@ -368,7 +379,6 @@ func TestIntegrationHTTPServer_GetFrontendSettings_apps(t *testing.T) {
Type: plugins.TypeApp,
Preload: true,
},
LoadingStrategy: plugins.LoadingStrategyScript,
},
},
}
@@ -378,6 +388,13 @@ func TestIntegrationHTTPServer_GetFrontendSettings_apps(t *testing.T) {
Plugins: newAppSettings("test-app", true),
}
},
pluginAssets: newPluginAssetsWithConfig(&config.PluginManagementCfg{
PluginSettings: map[string]map[string]string{
"test-app": {
pluginassets.CreatePluginVersionCfgKey: pluginassets.CreatePluginVersionScriptSupportEnabled,
},
},
}),
expected: settings{
Apps: map[string]*plugins.AppDTO{
"test-app": {
@@ -407,7 +424,6 @@ func TestIntegrationHTTPServer_GetFrontendSettings_apps(t *testing.T) {
FS: &pluginfakes.FakePluginFS{TypeFunc: func() plugins.FSType {
return plugins.FSTypeCDN
}},
LoadingStrategy: plugins.LoadingStrategyFetch,
},
},
}
@@ -417,6 +433,7 @@ func TestIntegrationHTTPServer_GetFrontendSettings_apps(t *testing.T) {
Plugins: newAppSettings("test-app", true),
}
},
pluginAssets: newPluginAssets(),
expected: settings{
Apps: map[string]*plugins.AppDTO{
"test-app": {
@@ -434,7 +451,7 @@ func TestIntegrationHTTPServer_GetFrontendSettings_apps(t *testing.T) {
for _, test := range tests {
t.Run(test.desc, func(t *testing.T) {
cfg := setting.NewCfg()
m, _ := setupTestEnvironment(t, cfg, featuremgmt.WithFeatures(), test.pluginStore(), test.pluginSettings())
m, _ := setupTestEnvironment(t, cfg, featuremgmt.WithFeatures(), test.pluginStore(), test.pluginSettings(), test.pluginAssets())
req := httptest.NewRequest(http.MethodGet, "/api/frontend/settings", nil)
recorder := httptest.NewRecorder()
@@ -535,8 +552,7 @@ func TestIntegrationHTTPServer_GetFrontendSettings_translations(t *testing.T) {
"en-US": "public/plugins/test-app/locales/en-US/test-app.json",
"pt-BR": "public/plugins/test-app/locales/pt-BR/test-app.json",
},
FS: &pluginfakes.FakePluginFS{},
LoadingStrategy: plugins.LoadingStrategyScript,
FS: &pluginfakes.FakePluginFS{},
},
},
}
@@ -586,8 +602,7 @@ func TestIntegrationHTTPServer_GetFrontendSettings_translations(t *testing.T) {
"en-US": "public/plugins/test-app/locales/en-US/test-app.json",
"pt-BR": "public/plugins/test-app/locales/pt-BR/test-app.json",
},
FS: &pluginfakes.FakePluginFS{},
LoadingStrategy: plugins.LoadingStrategyScript,
FS: &pluginfakes.FakePluginFS{},
},
},
}
@@ -627,8 +642,7 @@ func TestIntegrationHTTPServer_GetFrontendSettings_translations(t *testing.T) {
"en-US": "public/plugins/test-app/locales/en-US/test-app.json",
"pt-BR": "public/plugins/test-app/locales/pt-BR/test-app.json",
},
FS: &pluginfakes.FakePluginFS{},
LoadingStrategy: plugins.LoadingStrategyScript,
FS: &pluginfakes.FakePluginFS{},
},
},
}
@@ -656,7 +670,7 @@ func TestIntegrationHTTPServer_GetFrontendSettings_translations(t *testing.T) {
for _, test := range tests {
t.Run(test.desc, func(t *testing.T) {
cfg := setting.NewCfg()
m, hs := setupTestEnvironment(t, cfg, featuremgmt.WithFeatures(), test.pluginStore(), nil)
m, hs := setupTestEnvironment(t, cfg, featuremgmt.WithFeatures(), test.pluginStore(), nil, nil)
// Create a request with the appropriate context
req := httptest.NewRequest(http.MethodGet, "/api/frontend/settings", nil)
@@ -693,3 +707,13 @@ func TestIntegrationHTTPServer_GetFrontendSettings_translations(t *testing.T) {
})
}
}
func newPluginAssets() func() *pluginassets.Service {
return newPluginAssetsWithConfig(&config.PluginManagementCfg{})
}
func newPluginAssetsWithConfig(pCfg *config.PluginManagementCfg) func() *pluginassets.Service {
return func() *pluginassets.Service {
return pluginassets.ProvideService(pCfg, pluginscdn.ProvideService(pCfg), signature.ProvideService(pCfg, statickey.New()), &pluginstore.FakePluginStore{})
}
}
+4 -1
View File
@@ -82,6 +82,7 @@ import (
"github.com/grafana/grafana/pkg/services/playlist"
"github.com/grafana/grafana/pkg/services/plugindashboards"
"github.com/grafana/grafana/pkg/services/pluginsintegration/managedplugins"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginassets"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginchecker"
"github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext"
pluginSettings "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings"
@@ -150,6 +151,7 @@ type HTTPServer struct {
pluginDashboardService plugindashboards.Service
pluginStaticRouteResolver plugins.StaticRouteResolver
pluginErrorResolver plugins.ErrorResolver
pluginAssets *pluginassets.Service
pluginPreinstall pluginchecker.Preinstall
SearchService search.Service
ShortURLService shorturls.Service
@@ -253,7 +255,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
encryptionService encryption.Internal, grafanaUpdateChecker *updatemanager.GrafanaService,
pluginsUpdateChecker *updatemanager.PluginsService, searchUsersService searchusers.Service,
dataSourcesService datasources.DataSourceService, queryDataService query.Service, pluginFileStore plugins.FileStore,
serviceaccountsService serviceaccounts.Service,
serviceaccountsService serviceaccounts.Service, pluginAssets *pluginassets.Service,
authInfoService login.AuthInfoService, storageService store.StorageService,
notificationService notifications.Service, dashboardService dashboards.DashboardService,
dashboardProvisioningService dashboards.DashboardProvisioningService, folderService folder.Service,
@@ -292,6 +294,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
pluginStore: pluginStore,
pluginStaticRouteResolver: pluginStaticRouteResolver,
pluginDashboardService: pluginDashboardService,
pluginAssets: pluginAssets,
pluginErrorResolver: pluginErrorResolver,
pluginFileStore: pluginFileStore,
grafanaUpdateChecker: grafanaUpdateChecker,
+2 -2
View File
@@ -201,7 +201,7 @@ func (hs *HTTPServer) GetPluginSettingByID(c *contextmodel.ReqContext) response.
Includes: plugin.Includes,
BaseUrl: plugin.BaseURL,
Module: plugin.Module,
ModuleHash: plugin.ModuleHash,
ModuleHash: hs.pluginAssets.ModuleHash(c.Req.Context(), plugin),
DefaultNavUrl: path.Join(hs.Cfg.AppSubURL, plugin.DefaultNavURL),
State: plugin.State,
Signature: plugin.Signature,
@@ -209,7 +209,7 @@ func (hs *HTTPServer) GetPluginSettingByID(c *contextmodel.ReqContext) response.
SignatureOrg: plugin.SignatureOrg,
SecureJsonFields: map[string]bool{},
AngularDetected: plugin.Angular.Detected,
LoadingStrategy: plugin.LoadingStrategy,
LoadingStrategy: hs.pluginAssets.LoadingStrategy(c.Req.Context(), plugin),
Extensions: plugin.Extensions,
Translations: plugin.Translations,
}
+10 -4
View File
@@ -28,6 +28,8 @@ import (
"github.com/grafana/grafana/pkg/plugins/manager/filestore"
"github.com/grafana/grafana/pkg/plugins/manager/pluginfakes"
"github.com/grafana/grafana/pkg/plugins/manager/registry"
"github.com/grafana/grafana/pkg/plugins/manager/signature"
"github.com/grafana/grafana/pkg/plugins/manager/signature/statickey"
"github.com/grafana/grafana/pkg/plugins/pluginerrs"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
@@ -41,6 +43,7 @@ import (
"github.com/grafana/grafana/pkg/services/org/orgtest"
"github.com/grafana/grafana/pkg/services/pluginsintegration/managedplugins"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginassets"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginchecker"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
@@ -674,10 +677,9 @@ func Test_PluginsList_AccessControl(t *testing.T) {
func createPlugin(jd plugins.JSONData, class plugins.Class, files plugins.FS) *plugins.Plugin {
return &plugins.Plugin{
JSONData: jd,
Class: class,
FS: files,
LoadingStrategy: plugins.LoadingStrategyScript,
JSONData: jd,
Class: class,
FS: files,
}
}
@@ -844,6 +846,10 @@ func Test_PluginsSettings(t *testing.T) {
ErrorCode: tc.errCode,
})
}
pCfg := &config.PluginManagementCfg{}
pluginCDN := pluginscdn.ProvideService(pCfg)
sig := signature.ProvideService(pCfg, statickey.New())
hs.pluginAssets = pluginassets.ProvideService(pCfg, pluginCDN, sig, hs.pluginStore)
hs.pluginErrorResolver = pluginerrs.ProvideStore(errTracker)
hs.pluginsUpdateChecker, err = updatemanager.ProvidePluginsService(
hs.Cfg,
-1
View File
@@ -4,7 +4,6 @@ go 1.25.5
require (
github.com/Machiel/slugify v1.0.1
github.com/Masterminds/semver/v3 v3.4.0
github.com/ProtonMail/go-crypto v1.3.0
github.com/gobwas/glob v0.2.3
github.com/google/go-cmp v0.7.0
-2
View File
@@ -2,8 +2,6 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEK
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Machiel/slugify v1.0.1 h1:EfWSlRWstMadsgzmiV7d0yVd2IFlagWH68Q+DcYCm4E=
github.com/Machiel/slugify v1.0.1/go.mod h1:fTFGn5uWEynW4CUMG7sWkYXOf1UgDxyTM3DbR6Qfg3k=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
+1 -3
View File
@@ -140,9 +140,7 @@ type Licensing interface {
}
type SignatureCalculator interface {
// Calculate calculates the signature and returns both the signature and the manifest.
// The manifest may be nil if the plugin is unsigned or if an error occurred.
Calculate(ctx context.Context, src PluginSource, plugin FoundPlugin) (Signature, *PluginManifest, error)
Calculate(ctx context.Context, src PluginSource, plugin FoundPlugin) (Signature, error)
}
type KeyStore interface {
+4 -26
View File
@@ -129,7 +129,6 @@ func TestLoader_Load(t *testing.T) {
Class: plugins.ClassCore,
SkipHostEnvVars: true,
Translations: map[string]string{},
LoadingStrategy: plugins.LoadingStrategyScript,
},
},
},
@@ -217,33 +216,15 @@ func TestLoader_Load(t *testing.T) {
ExtensionPoints: []plugins.ExtensionPoint{},
},
},
Class: plugins.ClassExternal,
Module: "public/plugins/test-app/module.js",
BaseURL: "public/plugins/test-app",
FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/includes-symlinks")),
Manifest: &plugins.PluginManifest{
Plugin: "test-app",
Version: "1.0.0",
KeyID: "7e4d0c6a708866e7",
Time: 1622547655175,
Files: map[string]string{
"dashboards/connections.json": "bea86da4be970b98dc4681802ab55cdef3441dc3eb3c654cb207948d17b25303",
"dashboards/extra/memory.json": "7c042464941084caa91d0a9a2f188b05315a9796308a652ccdee31ca4fbcbfee",
"plugin.json": "c59a51bf6d7ecd7a99608ccb99353390c8b973672a938a0247164324005c0caf",
"symlink_to_txt": "9f32c171bf78a85d5cb77a48ab44f85578ee2942a1fc9f9ec4fde194ae4ff048",
"text.txt": "9f32c171bf78a85d5cb77a48ab44f85578ee2942a1fc9f9ec4fde194ae4ff048",
},
ManifestVersion: "2.0.0",
SignatureType: plugins.SignatureTypeGrafana,
SignedByOrg: "grafana",
SignedByOrgName: "Grafana Labs",
},
Class: plugins.ClassExternal,
Module: "public/plugins/test-app/module.js",
BaseURL: "public/plugins/test-app",
FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/includes-symlinks")),
Signature: "valid",
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "Grafana Labs",
SkipHostEnvVars: true,
Translations: map[string]string{},
LoadingStrategy: plugins.LoadingStrategyScript,
},
},
},
@@ -296,7 +277,6 @@ func TestLoader_Load(t *testing.T) {
Signature: "unsigned",
SkipHostEnvVars: true,
Translations: map[string]string{},
LoadingStrategy: plugins.LoadingStrategyScript,
},
},
},
@@ -356,7 +336,6 @@ func TestLoader_Load(t *testing.T) {
Signature: plugins.SignatureStatusUnsigned,
SkipHostEnvVars: true,
Translations: map[string]string{},
LoadingStrategy: plugins.LoadingStrategyScript,
},
},
},
@@ -455,7 +434,6 @@ func TestLoader_Load(t *testing.T) {
BaseURL: "public/plugins/test-app",
SkipHostEnvVars: true,
Translations: map[string]string{},
LoadingStrategy: plugins.LoadingStrategyScript,
},
},
},
@@ -11,7 +11,6 @@ import (
"github.com/grafana/grafana/pkg/plugins/log"
"github.com/grafana/grafana/pkg/plugins/manager/signature"
"github.com/grafana/grafana/pkg/plugins/pluginassets"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
"github.com/grafana/grafana/pkg/plugins/tracing"
"github.com/grafana/grafana/pkg/semconv"
)
@@ -55,7 +54,7 @@ func New(cfg *config.PluginManagementCfg, opts Opts) *Bootstrap {
}
if opts.DecorateFuncs == nil {
opts.DecorateFuncs = DefaultDecorateFuncs(cfg, pluginscdn.ProvideService(cfg))
opts.DecorateFuncs = DefaultDecorateFuncs(cfg)
}
return &Bootstrap{
@@ -11,7 +11,6 @@ import (
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/log"
"github.com/grafana/grafana/pkg/plugins/pluginassets"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
)
// DefaultConstructor implements the default ConstructFunc used for the Construct step of the Bootstrap stage.
@@ -29,14 +28,12 @@ func DefaultConstructFunc(cfg *config.PluginManagementCfg, signatureCalculator p
}
// DefaultDecorateFuncs are the default DecorateFuncs used for the Decorate step of the Bootstrap stage.
func DefaultDecorateFuncs(cfg *config.PluginManagementCfg, cdn *pluginscdn.Service) []DecorateFunc {
func DefaultDecorateFuncs(cfg *config.PluginManagementCfg) []DecorateFunc {
return []DecorateFunc{
AppDefaultNavURLDecorateFunc,
TemplateDecorateFunc,
AppChildDecorateFunc(),
SkipHostEnvVarsDecorateFunc(cfg),
ModuleHashDecorateFunc(cfg, cdn),
LoadingStrategyDecorateFunc(cfg, cdn),
}
}
@@ -51,30 +48,19 @@ func NewDefaultConstructor(cfg *config.PluginManagementCfg, signatureCalculator
// Construct will calculate the plugin's signature state and create the plugin using the pluginFactoryFunc.
func (c *DefaultConstructor) Construct(ctx context.Context, src plugins.PluginSource, bundle *plugins.FoundBundle) ([]*plugins.Plugin, error) {
// Calculate signature and cache manifest
sig, manifest, err := c.signatureCalculator.Calculate(ctx, src, bundle.Primary)
sig, err := c.signatureCalculator.Calculate(ctx, src, bundle.Primary)
if err != nil {
c.log.Warn("Could not calculate plugin signature state", "pluginId", bundle.Primary.JSONData.ID, "error", err)
return nil, err
}
plugin, err := c.pluginFactoryFunc(bundle, src.PluginClass(ctx), sig)
if err != nil {
c.log.Error("Could not create primary plugin base", "pluginId", bundle.Primary.JSONData.ID, "error", err)
return nil, err
}
plugin.Manifest = manifest
res := make([]*plugins.Plugin, 0, len(plugin.Children)+1)
res = append(res, plugin)
for _, child := range plugin.Children {
// Child plugins use the parent's manifest
if child.Parent != nil && child.Parent.Manifest != nil {
child.Manifest = child.Parent.Manifest
}
res = append(res, child)
}
res = append(res, plugin.Children...)
return res, nil
}
@@ -159,19 +145,3 @@ func SkipHostEnvVarsDecorateFunc(cfg *config.PluginManagementCfg) DecorateFunc {
return p, nil
}
}
// ModuleHashDecorateFunc returns a DecorateFunc that calculates and sets the module hash for the plugin.
func ModuleHashDecorateFunc(cfg *config.PluginManagementCfg, cdn *pluginscdn.Service) DecorateFunc {
return func(_ context.Context, p *plugins.Plugin) (*plugins.Plugin, error) {
p.ModuleHash = pluginassets.CalculateModuleHash(p, cfg, cdn)
return p, nil
}
}
// LoadingStrategyDecorateFunc returns a DecorateFunc that calculates and sets the loading strategy for the plugin.
func LoadingStrategyDecorateFunc(cfg *config.PluginManagementCfg, cdn *pluginscdn.Service) DecorateFunc {
return func(_ context.Context, p *plugins.Plugin) (*plugins.Plugin, error) {
p.LoadingStrategy = pluginassets.CalculateLoadingStrategy(p, cfg, cdn)
return p, nil
}
}
+38 -17
View File
@@ -14,6 +14,7 @@ import (
"path"
"path/filepath"
"runtime"
"strings"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/clearsign"
@@ -36,6 +37,26 @@ var (
fromSlash = filepath.FromSlash
)
// PluginManifest holds details for the file manifest
type PluginManifest struct {
Plugin string `json:"plugin"`
Version string `json:"version"`
KeyID string `json:"keyId"`
Time int64 `json:"time"`
Files map[string]string `json:"files"`
// V2 supported fields
ManifestVersion string `json:"manifestVersion"`
SignatureType plugins.SignatureType `json:"signatureType"`
SignedByOrg string `json:"signedByOrg"`
SignedByOrgName string `json:"signedByOrgName"`
RootURLs []string `json:"rootUrls"`
}
func (m *PluginManifest) IsV2() bool {
return strings.HasPrefix(m.ManifestVersion, "2.")
}
type Signature struct {
kr plugins.KeyRetriever
cfg *config.PluginManagementCfg
@@ -66,14 +87,14 @@ func DefaultCalculator(cfg *config.PluginManagementCfg) *Signature {
// readPluginManifest attempts to read and verify the plugin manifest
// if any error occurs or the manifest is not valid, this will return an error
func (s *Signature) readPluginManifest(ctx context.Context, body []byte) (*plugins.PluginManifest, error) {
func (s *Signature) readPluginManifest(ctx context.Context, body []byte) (*PluginManifest, error) {
block, _ := clearsign.Decode(body)
if block == nil {
return nil, errors.New("unable to decode manifest")
}
// Convert to a well typed object
var manifest plugins.PluginManifest
var manifest PluginManifest
err := json.Unmarshal(block.Plaintext, &manifest)
if err != nil {
return nil, fmt.Errorf("%v: %w", "Error parsing manifest JSON", err)
@@ -90,7 +111,7 @@ var ErrSignatureTypeUnsigned = errors.New("plugin is unsigned")
// ReadPluginManifestFromFS reads the plugin manifest from the provided plugins.FS.
// If the manifest is not found, it will return an error wrapping ErrSignatureTypeUnsigned.
func (s *Signature) ReadPluginManifestFromFS(ctx context.Context, pfs plugins.FS) (*plugins.PluginManifest, error) {
func (s *Signature) ReadPluginManifestFromFS(ctx context.Context, pfs plugins.FS) (*PluginManifest, error) {
f, err := pfs.Open("MANIFEST.txt")
if err != nil {
if errors.Is(err, plugins.ErrFileNotExist) {
@@ -119,9 +140,9 @@ func (s *Signature) ReadPluginManifestFromFS(ctx context.Context, pfs plugins.FS
return manifest, nil
}
func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plugin plugins.FoundPlugin) (plugins.Signature, *plugins.PluginManifest, error) {
func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plugin plugins.FoundPlugin) (plugins.Signature, error) {
if defaultSignature, exists := src.DefaultSignature(ctx, plugin.JSONData.ID); exists {
return defaultSignature, nil, nil
return defaultSignature, nil
}
manifest, err := s.ReadPluginManifestFromFS(ctx, plugin.FS)
@@ -130,29 +151,29 @@ func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plu
s.log.Warn("Plugin is unsigned", "id", plugin.JSONData.ID, "err", err)
return plugins.Signature{
Status: plugins.SignatureStatusUnsigned,
}, nil, nil
}, nil
case err != nil:
s.log.Warn("Plugin signature is invalid", "id", plugin.JSONData.ID, "err", err)
return plugins.Signature{
Status: plugins.SignatureStatusInvalid,
}, nil, nil
}, nil
}
if !manifest.IsV2() {
return plugins.Signature{
Status: plugins.SignatureStatusInvalid,
}, nil, nil
}, nil
}
fsFiles, err := plugin.FS.Files()
if err != nil {
return plugins.Signature{}, nil, fmt.Errorf("files: %w", err)
return plugins.Signature{}, fmt.Errorf("files: %w", err)
}
if len(fsFiles) == 0 {
s.log.Warn("No plugin file information in directory", "pluginId", plugin.JSONData.ID)
return plugins.Signature{
Status: plugins.SignatureStatusInvalid,
}, nil, nil
}, nil
}
// Make sure the versions all match
@@ -160,20 +181,20 @@ func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plu
s.log.Debug("Plugin signature invalid because ID or Version mismatch", "pluginId", plugin.JSONData.ID, "manifestPluginId", manifest.Plugin, "pluginVersion", plugin.JSONData.Info.Version, "manifestPluginVersion", manifest.Version)
return plugins.Signature{
Status: plugins.SignatureStatusModified,
}, nil, nil
}, nil
}
// Validate that plugin is running within defined root URLs
if len(manifest.RootURLs) > 0 {
if match, err := urlMatch(manifest.RootURLs, s.cfg.GrafanaAppURL, manifest.SignatureType); err != nil {
s.log.Warn("Could not verify if root URLs match", "plugin", plugin.JSONData.ID, "rootUrls", manifest.RootURLs)
return plugins.Signature{}, nil, err
return plugins.Signature{}, err
} else if !match {
s.log.Warn("Could not find root URL that matches running application URL", "plugin", plugin.JSONData.ID,
"appUrl", s.cfg.GrafanaAppURL, "rootUrls", manifest.RootURLs)
return plugins.Signature{
Status: plugins.SignatureStatusInvalid,
}, nil, nil
}, nil
}
}
@@ -186,7 +207,7 @@ func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plu
s.log.Debug("Plugin signature invalid", "pluginId", plugin.JSONData.ID, "error", err)
return plugins.Signature{
Status: plugins.SignatureStatusModified,
}, nil, nil
}, nil
}
manifestFiles[p] = struct{}{}
@@ -215,7 +236,7 @@ func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plu
s.log.Warn("The following files were not included in the signature", "plugin", plugin.JSONData.ID, "files", unsignedFiles)
return plugins.Signature{
Status: plugins.SignatureStatusModified,
}, nil, nil
}, nil
}
s.log.Debug("Plugin signature valid", "id", plugin.JSONData.ID)
@@ -223,7 +244,7 @@ func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plu
Status: plugins.SignatureStatusValid,
Type: manifest.SignatureType,
SigningOrg: manifest.SignedByOrgName,
}, manifest, nil
}, nil
}
func verifyHash(mlog log.Logger, plugin plugins.FoundPlugin, path, hash string) error {
@@ -300,7 +321,7 @@ func (r invalidFieldErr) Error() string {
return fmt.Sprintf("valid manifest field %s is required", r.field)
}
func (s *Signature) validateManifest(ctx context.Context, m plugins.PluginManifest, block *clearsign.Block) error {
func (s *Signature) validateManifest(ctx context.Context, m PluginManifest, block *clearsign.Block) error {
if len(m.Plugin) == 0 {
return invalidFieldErr{field: "plugin"}
}
+16 -16
View File
@@ -164,7 +164,7 @@ func TestCalculate(t *testing.T) {
for _, tc := range tcs {
basePath := filepath.Join(parentDir, "testdata/non-pvt-with-root-url/plugin")
s := provideTestServiceWithConfig(&config.PluginManagementCfg{GrafanaAppURL: tc.appURL})
sig, _, err := s.Calculate(context.Background(), &pluginfakes.FakePluginSource{
sig, err := s.Calculate(context.Background(), &pluginfakes.FakePluginSource{
PluginClassFunc: func(ctx context.Context) plugins.Class {
return plugins.ClassExternal
},
@@ -192,7 +192,7 @@ func TestCalculate(t *testing.T) {
runningWindows = true
s := provideDefaultTestService()
sig, _, err := s.Calculate(context.Background(), &pluginfakes.FakePluginSource{
sig, err := s.Calculate(context.Background(), &pluginfakes.FakePluginSource{
PluginClassFunc: func(ctx context.Context) plugins.Class {
return plugins.ClassExternal
},
@@ -260,7 +260,7 @@ func TestCalculate(t *testing.T) {
require.NoError(t, err)
pfs, err = newPathSeparatorOverrideFS(string(tc.platform.separator), pfs)
require.NoError(t, err)
sig, _, err := s.Calculate(context.Background(), &pluginfakes.FakePluginSource{
sig, err := s.Calculate(context.Background(), &pluginfakes.FakePluginSource{
PluginClassFunc: func(ctx context.Context) plugins.Class {
return plugins.ClassExternal
},
@@ -396,7 +396,7 @@ func TestFSPathSeparatorFiles(t *testing.T) {
}
}
func fileList(manifest *plugins.PluginManifest) []string {
func fileList(manifest *PluginManifest) []string {
keys := make([]string, 0, len(manifest.Files))
for k := range manifest.Files {
keys = append(keys, k)
@@ -682,52 +682,52 @@ func Test_urlMatch_private(t *testing.T) {
func Test_validateManifest(t *testing.T) {
tcs := []struct {
name string
manifest *plugins.PluginManifest
manifest *PluginManifest
expectedErr string
}{
{
name: "Empty plugin field",
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.Plugin = "" }),
manifest: createV2Manifest(t, func(m *PluginManifest) { m.Plugin = "" }),
expectedErr: "valid manifest field plugin is required",
},
{
name: "Empty keyId field",
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.KeyID = "" }),
manifest: createV2Manifest(t, func(m *PluginManifest) { m.KeyID = "" }),
expectedErr: "valid manifest field keyId is required",
},
{
name: "Empty signedByOrg field",
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.SignedByOrg = "" }),
manifest: createV2Manifest(t, func(m *PluginManifest) { m.SignedByOrg = "" }),
expectedErr: "valid manifest field signedByOrg is required",
},
{
name: "Empty signedByOrgName field",
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.SignedByOrgName = "" }),
manifest: createV2Manifest(t, func(m *PluginManifest) { m.SignedByOrgName = "" }),
expectedErr: "valid manifest field SignedByOrgName is required",
},
{
name: "Empty signatureType field",
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.SignatureType = "" }),
manifest: createV2Manifest(t, func(m *PluginManifest) { m.SignatureType = "" }),
expectedErr: "valid manifest field signatureType is required",
},
{
name: "Invalid signatureType field",
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.SignatureType = "invalidSignatureType" }),
manifest: createV2Manifest(t, func(m *PluginManifest) { m.SignatureType = "invalidSignatureType" }),
expectedErr: "valid manifest field signatureType is required",
},
{
name: "Empty files field",
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.Files = map[string]string{} }),
manifest: createV2Manifest(t, func(m *PluginManifest) { m.Files = map[string]string{} }),
expectedErr: "valid manifest field files is required",
},
{
name: "Empty time field",
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.Time = 0 }),
manifest: createV2Manifest(t, func(m *PluginManifest) { m.Time = 0 }),
expectedErr: "valid manifest field time is required",
},
{
name: "Empty version field",
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.Version = "" }),
manifest: createV2Manifest(t, func(m *PluginManifest) { m.Version = "" }),
expectedErr: "valid manifest field version is required",
},
}
@@ -740,10 +740,10 @@ func Test_validateManifest(t *testing.T) {
}
}
func createV2Manifest(t *testing.T, cbs ...func(*plugins.PluginManifest)) *plugins.PluginManifest {
func createV2Manifest(t *testing.T, cbs ...func(*PluginManifest)) *PluginManifest {
t.Helper()
m := &plugins.PluginManifest{
m := &PluginManifest{
Plugin: "grafana-test-app",
Version: "2.5.3",
KeyID: "7e4d0c6a708866e7",
@@ -1,67 +0,0 @@
package pluginassets
import (
"github.com/Masterminds/semver/v3"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
)
const (
CreatePluginVersionCfgKey = "create_plugin_version"
CreatePluginVersionScriptSupportEnabled = "4.15.0"
)
var (
scriptLoadingMinSupportedVersion = semver.MustParse(CreatePluginVersionScriptSupportEnabled)
)
// CalculateLoadingStrategy calculates the loading strategy for a plugin.
// If a plugin has plugin setting `create_plugin_version` >= 4.15.0, set loadingStrategy to "script".
// If a plugin is not loaded via the CDN and is not Angular, set loadingStrategy to "script".
// Otherwise, set loadingStrategy to "fetch".
func CalculateLoadingStrategy(p *plugins.Plugin, cfg *config.PluginManagementCfg, cdn *pluginscdn.Service) plugins.LoadingStrategy {
if cfg != nil && cfg.PluginSettings != nil {
if pCfg, ok := cfg.PluginSettings[p.ID]; ok {
if compatibleCreatePluginVersion(pCfg) {
return plugins.LoadingStrategyScript
}
}
// If the plugin has a parent
if p.Parent != nil {
// Check the parent's create_plugin_version setting
if pCfg, ok := cfg.PluginSettings[p.Parent.ID]; ok {
if compatibleCreatePluginVersion(pCfg) {
return plugins.LoadingStrategyScript
}
}
// Since the parent plugin is not explicitly configured as script loading compatible,
// If the plugin is either loaded from the CDN (via its parent) or contains Angular, we should use fetch
if cdnEnabled(p.Parent, cdn) || p.Angular.Detected {
return plugins.LoadingStrategyFetch
}
}
}
if !cdnEnabled(p, cdn) && !p.Angular.Detected {
return plugins.LoadingStrategyScript
}
return plugins.LoadingStrategyFetch
}
// compatibleCreatePluginVersion checks if the create_plugin_version setting is >= 4.15.0
func compatibleCreatePluginVersion(ps map[string]string) bool {
if cpv, ok := ps[CreatePluginVersionCfgKey]; ok {
createPluginVer, err := semver.NewVersion(cpv)
if err != nil {
// Invalid semver, treat as incompatible
return false
}
return !createPluginVer.LessThan(scriptLoadingMinSupportedVersion)
}
return false
}
@@ -1,216 +0,0 @@
package pluginassets
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
)
// cdnFS is a simple mock FS that returns CDN type
type cdnFS struct {
plugins.FS
}
func (f *cdnFS) Type() plugins.FSType {
return plugins.FSTypeCDN
}
func TestCalculateLoadingStrategy(t *testing.T) {
const pluginID = "grafana-test-datasource"
const (
incompatVersion = "4.14.0"
compatVersion = CreatePluginVersionScriptSupportEnabled
futureVersion = "5.0.0"
)
tcs := []struct {
name string
pluginSettings config.PluginSettings
plugin *plugins.Plugin
expected plugins.LoadingStrategy
}{
{
name: "Expected LoadingStrategyScript when create-plugin version is compatible and plugin is not angular",
pluginSettings: newPluginSettings(pluginID, map[string]string{
CreatePluginVersionCfgKey: compatVersion,
}),
plugin: newPluginForLoadingStrategy(pluginID, withAngularForLoadingStrategy(false)),
expected: plugins.LoadingStrategyScript,
},
{
name: "Expected LoadingStrategyScript when parent create-plugin version is compatible and plugin is not angular",
pluginSettings: newPluginSettings("parent-datasource", map[string]string{
CreatePluginVersionCfgKey: compatVersion,
}),
plugin: newPluginForLoadingStrategy(pluginID, withAngularForLoadingStrategy(false), func(p *plugins.Plugin) {
p.Parent = &plugins.Plugin{
JSONData: plugins.JSONData{ID: "parent-datasource"},
FS: plugins.NewFakeFS(),
}
}),
expected: plugins.LoadingStrategyScript,
},
{
name: "Expected LoadingStrategyScript when create-plugin version is future compatible and plugin is not angular",
pluginSettings: newPluginSettings(pluginID, map[string]string{
CreatePluginVersionCfgKey: futureVersion,
}),
plugin: newPluginForLoadingStrategy(pluginID, withAngularForLoadingStrategy(false), withFSForLoadingStrategy(plugins.NewFakeFS())),
expected: plugins.LoadingStrategyScript,
},
{
name: "Expected LoadingStrategyScript when create-plugin version is not provided, plugin is not angular and is not configured as CDN enabled",
pluginSettings: newPluginSettings(pluginID, map[string]string{}),
plugin: newPluginForLoadingStrategy(pluginID, withAngularForLoadingStrategy(false), withFSForLoadingStrategy(plugins.NewFakeFS())),
expected: plugins.LoadingStrategyScript,
},
{
name: "Expected LoadingStrategyScript when create-plugin version is not compatible, plugin is not angular, is not configured as CDN enabled and does not have a CDN fs",
pluginSettings: newPluginSettings(pluginID, map[string]string{
CreatePluginVersionCfgKey: incompatVersion,
}),
plugin: newPluginForLoadingStrategy(pluginID, withAngularForLoadingStrategy(false), withClassForLoadingStrategy(plugins.ClassExternal), withFSForLoadingStrategy(plugins.NewFakeFS())),
expected: plugins.LoadingStrategyScript,
},
{
name: "Expected LoadingStrategyFetch when parent create-plugin version is not set, is configured as CDN enabled and plugin is not angular",
pluginSettings: config.PluginSettings{
"parent-datasource": {
"cdn": "true",
},
},
plugin: newPluginForLoadingStrategy(pluginID, withAngularForLoadingStrategy(false), func(p *plugins.Plugin) {
p.Parent = &plugins.Plugin{
JSONData: plugins.JSONData{ID: "parent-datasource"},
FS: plugins.NewFakeFS(),
}
}),
expected: plugins.LoadingStrategyFetch,
},
{
name: "Expected LoadingStrategyFetch when parent create-plugin version is not set, is configured as CDN enabled and plugin is angular",
pluginSettings: config.PluginSettings{
"parent-datasource": {
"cdn": "true",
},
},
plugin: newPluginForLoadingStrategy(pluginID, withAngularForLoadingStrategy(true), func(p *plugins.Plugin) {
p.Parent = &plugins.Plugin{
JSONData: plugins.JSONData{ID: "parent-datasource"},
FS: plugins.NewFakeFS(),
}
}),
expected: plugins.LoadingStrategyFetch,
},
{
name: "Expected LoadingStrategyFetch when parent create-plugin version is not set, is not configured as CDN enabled and plugin is angular",
pluginSettings: config.PluginSettings{},
plugin: newPluginForLoadingStrategy(pluginID, withAngularForLoadingStrategy(true), withFSForLoadingStrategy(plugins.NewFakeFS()), func(p *plugins.Plugin) {
p.Parent = &plugins.Plugin{
JSONData: plugins.JSONData{ID: "parent-datasource"},
FS: plugins.NewFakeFS(),
}
}),
expected: plugins.LoadingStrategyFetch,
},
{
name: "Expected LoadingStrategyFetch when create-plugin version is not compatible, plugin is not angular, and plugin is configured as CDN enabled",
pluginSettings: newPluginSettings(pluginID, map[string]string{
"cdn": "true",
CreatePluginVersionCfgKey: incompatVersion,
}),
plugin: newPluginForLoadingStrategy(pluginID, withAngularForLoadingStrategy(false), withClassForLoadingStrategy(plugins.ClassExternal), withFSForLoadingStrategy(plugins.NewFakeFS())),
expected: plugins.LoadingStrategyFetch,
},
{
name: "Expected LoadingStrategyFetch when create-plugin version is not compatible and plugin is angular",
pluginSettings: newPluginSettings(pluginID, map[string]string{
CreatePluginVersionCfgKey: incompatVersion,
}),
plugin: newPluginForLoadingStrategy(pluginID, withAngularForLoadingStrategy(true), withFSForLoadingStrategy(plugins.NewFakeFS())),
expected: plugins.LoadingStrategyFetch,
},
{
name: "Expected LoadingStrategyFetch when create-plugin version is not compatible, plugin is not angular and plugin is configured as CDN enabled",
pluginSettings: newPluginSettings(pluginID, map[string]string{
"cdn": "true",
CreatePluginVersionCfgKey: incompatVersion,
}),
plugin: newPluginForLoadingStrategy(pluginID, withAngularForLoadingStrategy(false), withFSForLoadingStrategy(plugins.NewFakeFS())),
expected: plugins.LoadingStrategyFetch,
},
{
name: "Expected LoadingStrategyFetch when create-plugin version is not compatible, plugin is not angular and has a CDN fs",
pluginSettings: newPluginSettings(pluginID, map[string]string{
CreatePluginVersionCfgKey: incompatVersion,
}),
plugin: newPluginForLoadingStrategy(pluginID, withAngularForLoadingStrategy(false), withFSForLoadingStrategy(
&cdnFS{FS: plugins.NewFakeFS()},
)),
expected: plugins.LoadingStrategyFetch,
},
{
name: "Expected LoadingStrategyScript when plugin setting create-plugin version is badly formatted, plugin is not configured as CDN enabled and does not have a CDN fs",
pluginSettings: newPluginSettings(pluginID, map[string]string{
CreatePluginVersionCfgKey: "invalidSemver",
}),
plugin: newPluginForLoadingStrategy(pluginID, withAngularForLoadingStrategy(false), withFSForLoadingStrategy(plugins.NewFakeFS())),
expected: plugins.LoadingStrategyScript,
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
cfg := &config.PluginManagementCfg{
PluginSettings: tc.pluginSettings,
}
cdn := pluginscdn.ProvideService(&config.PluginManagementCfg{
PluginsCDNURLTemplate: "http://cdn.example.com", // required for cdn.PluginSupported check
PluginSettings: tc.pluginSettings,
})
got := CalculateLoadingStrategy(tc.plugin, cfg, cdn)
assert.Equal(t, tc.expected, got, "unexpected loading strategy")
})
}
}
func newPluginForLoadingStrategy(pluginID string, cbs ...func(*plugins.Plugin)) *plugins.Plugin {
p := &plugins.Plugin{
JSONData: plugins.JSONData{
ID: pluginID,
},
}
for _, cb := range cbs {
cb(p)
}
return p
}
func withAngularForLoadingStrategy(angular bool) func(*plugins.Plugin) {
return func(p *plugins.Plugin) {
p.Angular = plugins.AngularMeta{Detected: angular}
}
}
func withFSForLoadingStrategy(fs plugins.FS) func(*plugins.Plugin) {
return func(p *plugins.Plugin) {
p.FS = fs
}
}
func withClassForLoadingStrategy(class plugins.Class) func(*plugins.Plugin) {
return func(p *plugins.Plugin) {
p.Class = class
}
}
func newPluginSettings(pluginID string, kv map[string]string) config.PluginSettings {
return config.PluginSettings{
pluginID: kv,
}
}
-90
View File
@@ -1,90 +0,0 @@
package pluginassets
import (
"encoding/base64"
"encoding/hex"
"path"
"path/filepath"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
)
// CalculateModuleHash calculates the module.js SHA256 hash for a plugin in the format expected by the browser for SRI checks.
// The module hash is read from the plugin's cached manifest.
// For nested plugins, the module hash is read from the root parent plugin's manifest.
// If the plugin is unsigned or not a CDN plugin, an empty string is returned.
func CalculateModuleHash(p *plugins.Plugin, cfg *config.PluginManagementCfg, cdn *pluginscdn.Service) string {
if cfg == nil || !cfg.Features.SriChecksEnabled {
return ""
}
if !p.Signature.IsValid() {
return ""
}
rootParent := findRootParent(p)
if rootParent.Manifest == nil {
return ""
}
if !rootParent.Manifest.IsV2() {
return ""
}
if !cdnEnabled(rootParent, cdn) {
return ""
}
modulePath := getModulePathInManifest(p, rootParent)
moduleHash, ok := rootParent.Manifest.Files[modulePath]
if !ok {
return ""
}
return convertHashForSRI(moduleHash)
}
// findRootParent returns the root parent plugin (the one that contains the manifest).
// For non-nested plugins, it returns the plugin itself.
func findRootParent(p *plugins.Plugin) *plugins.Plugin {
root := p
for root.Parent != nil {
root = root.Parent
}
return root
}
// getModulePathInManifest returns the path to module.js as it appears in the manifest.
// For nested plugins, this is the relative path from the root parent to the plugin's module.js.
// For non-nested plugins, this is simply "module.js".
func getModulePathInManifest(p *plugins.Plugin, rootParent *plugins.Plugin) string {
if p == rootParent {
return "module.js"
}
// Calculate the relative path from root parent to this plugin
relPath, err := rootParent.FS.Rel(p.FS.Base())
if err != nil {
return ""
}
// MANIFEST.txt uses forward slashes as path separators
pluginRootPath := filepath.ToSlash(relPath)
return path.Join(pluginRootPath, "module.js")
}
// convertHashForSRI takes a SHA256 hash string and returns it as expected by the browser for SRI checks.
func convertHashForSRI(h string) string {
hb, err := hex.DecodeString(h)
if err != nil {
return ""
}
return "sha256-" + base64.StdEncoding.EncodeToString(hb)
}
// cdnEnabled checks if a plugin is loaded via CDN
func cdnEnabled(p *plugins.Plugin, cdn *pluginscdn.Service) bool {
return p.FS.Type().CDN() || cdn.PluginSupported(p.ID)
}
-356
View File
@@ -1,356 +0,0 @@
package pluginassets
import (
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
)
func TestConvertHashForSRI(t *testing.T) {
for _, tc := range []struct {
hash string
expHash string
expErr bool
}{
{
hash: "ddfcb449445064e6c39f0c20b15be3cb6a55837cf4781df23d02de005f436811",
expHash: "sha256-3fy0SURQZObDnwwgsVvjy2pVg3z0eB3yPQLeAF9DaBE=",
},
{
hash: "not-a-valid-hash",
expErr: true,
},
} {
t.Run(tc.hash, func(t *testing.T) {
r := convertHashForSRI(tc.hash)
if tc.expErr {
// convertHashForSRI returns empty string on error
require.Empty(t, r)
} else {
require.Equal(t, tc.expHash, r)
}
})
}
}
func TestCalculateModuleHash(t *testing.T) {
const (
pluginID = "grafana-test-datasource"
parentPluginID = "grafana-test-app"
)
// Helper to create a plugin with manifest
createPluginWithManifest := func(id string, manifest *plugins.PluginManifest, parent *plugins.Plugin) *plugins.Plugin {
p := &plugins.Plugin{
JSONData: plugins.JSONData{
ID: id,
},
Signature: plugins.SignatureStatusValid,
Manifest: manifest,
}
if parent != nil {
p.Parent = parent
}
return p
}
// Helper to create a v2 manifest
createV2Manifest := func(files map[string]string) *plugins.PluginManifest {
return &plugins.PluginManifest{
ManifestVersion: "2.0.0",
Files: files,
}
}
for _, tc := range []struct {
name string
plugin *plugins.Plugin
cfg *config.PluginManagementCfg
cdn *pluginscdn.Service
expModuleHash string
}{
{
name: "should return empty string when cfg is nil",
plugin: createPluginWithManifest(pluginID, createV2Manifest(map[string]string{
"module.js": "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03",
}), nil),
cfg: nil,
cdn: nil,
expModuleHash: "",
},
{
name: "should return empty string when SRI checks are disabled",
plugin: createPluginWithManifest(pluginID, createV2Manifest(map[string]string{
"module.js": "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03",
}), nil),
cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: false}},
cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}),
expModuleHash: "",
},
{
name: "should return empty string for unsigned plugin",
plugin: &plugins.Plugin{
JSONData: plugins.JSONData{ID: pluginID},
Signature: plugins.SignatureStatusUnsigned,
Manifest: createV2Manifest(map[string]string{"module.js": "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"}),
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid")),
},
cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}},
cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}),
expModuleHash: "",
},
{
name: "should return module hash for valid plugin",
plugin: &plugins.Plugin{
JSONData: plugins.JSONData{ID: pluginID},
Signature: plugins.SignatureStatusValid,
Manifest: createV2Manifest(map[string]string{"module.js": "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"}),
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid")),
},
cfg: &config.PluginManagementCfg{
PluginsCDNURLTemplate: "https://cdn.example.com",
Features: config.Features{SriChecksEnabled: true},
PluginSettings: config.PluginSettings{
pluginID: {"cdn": "true"},
},
},
cdn: func() *pluginscdn.Service {
cfg := &config.PluginManagementCfg{
PluginsCDNURLTemplate: "https://cdn.example.com",
PluginSettings: config.PluginSettings{
pluginID: {"cdn": "true"},
},
}
return pluginscdn.ProvideService(cfg)
}(),
expModuleHash: "sha256-WJG1tSLV3whtD/CxEPvZ0hu0/HFjrzTQgoai6Eb2vgM=",
},
{
name: "should return empty string when manifest is nil",
plugin: &plugins.Plugin{
JSONData: plugins.JSONData{ID: pluginID},
Signature: plugins.SignatureStatusValid,
Manifest: nil,
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid")),
},
cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}},
cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}),
expModuleHash: "",
},
{
name: "should return empty string for v1 manifest",
plugin: &plugins.Plugin{
JSONData: plugins.JSONData{ID: pluginID},
Signature: plugins.SignatureStatusValid,
Manifest: &plugins.PluginManifest{
ManifestVersion: "1.0.0",
Files: map[string]string{"module.js": "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"},
},
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid")),
},
cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}},
cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}),
expModuleHash: "",
},
{
name: "should return empty string when module.js is not in manifest",
plugin: &plugins.Plugin{
JSONData: plugins.JSONData{ID: pluginID},
Signature: plugins.SignatureStatusValid,
Manifest: createV2Manifest(map[string]string{"plugin.json": "129fab4e0584d18c778ebdfa5fe1a68edf2e5c5aeb8290b2c68182c857cb59f8"}),
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid")),
},
cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}},
cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}),
expModuleHash: "",
},
{
name: "missing module.js entry from MANIFEST.txt should not return module hash",
plugin: &plugins.Plugin{
JSONData: plugins.JSONData{ID: pluginID},
Signature: plugins.SignatureStatusValid,
Manifest: createV2Manifest(map[string]string{"plugin.json": "129fab4e0584d18c778ebdfa5fe1a68edf2e5c5aeb8290b2c68182c857cb59f8"}),
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-no-module-js")),
},
cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}},
cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}),
expModuleHash: "",
},
{
name: "signed status but missing MANIFEST.txt should not return module hash",
plugin: &plugins.Plugin{
JSONData: plugins.JSONData{ID: pluginID},
Signature: plugins.SignatureStatusValid,
Manifest: nil,
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-no-manifest-txt")),
},
cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}},
cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}),
expModuleHash: "",
},
{
// parentPluginID (/)
// └── pluginID (/datasource)
name: "nested plugin should return module hash from parent MANIFEST.txt",
plugin: func() *plugins.Plugin {
parent := &plugins.Plugin{
JSONData: plugins.JSONData{ID: parentPluginID},
Signature: plugins.SignatureStatusValid,
Manifest: createV2Manifest(map[string]string{
"module.js": "266c19bc148b22ddef2a288fc5f8f40855bda22ccf60be53340b4931e469ae2a",
"datasource/module.js": "04d70db091d96c4775fb32ba5a8f84cc22893eb43afdb649726661d4425c6711",
}),
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested")),
}
return &plugins.Plugin{
JSONData: plugins.JSONData{ID: pluginID},
Signature: plugins.SignatureStatusValid,
Parent: parent,
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "datasource")),
}
}(),
cfg: &config.PluginManagementCfg{
PluginsCDNURLTemplate: "https://cdn.example.com",
Features: config.Features{SriChecksEnabled: true},
PluginSettings: config.PluginSettings{
pluginID: {"cdn": "true"},
parentPluginID: {"cdn": "true"},
},
},
cdn: func() *pluginscdn.Service {
cfg := &config.PluginManagementCfg{
PluginsCDNURLTemplate: "https://cdn.example.com",
PluginSettings: config.PluginSettings{
pluginID: {"cdn": "true"},
parentPluginID: {"cdn": "true"},
},
}
return pluginscdn.ProvideService(cfg)
}(),
expModuleHash: "sha256-BNcNsJHZbEd1+zK6Wo+EzCKJPrQ6/bZJcmZh1EJcZxE=",
},
{
// parentPluginID (/)
// └── pluginID (/panels/one)
name: "nested plugin deeper than one subfolder should return module hash from parent MANIFEST.txt",
plugin: func() *plugins.Plugin {
parent := &plugins.Plugin{
JSONData: plugins.JSONData{ID: parentPluginID},
Signature: plugins.SignatureStatusValid,
Manifest: createV2Manifest(map[string]string{
"module.js": "266c19bc148b22ddef2a288fc5f8f40855bda22ccf60be53340b4931e469ae2a",
"panels/one/module.js": "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f",
"datasource/module.js": "04d70db091d96c4775fb32ba5a8f84cc22893eb43afdb649726661d4425c6711",
}),
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested")),
}
return &plugins.Plugin{
JSONData: plugins.JSONData{ID: pluginID},
Signature: plugins.SignatureStatusValid,
Parent: parent,
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "panels", "one")),
}
}(),
cfg: &config.PluginManagementCfg{
PluginsCDNURLTemplate: "https://cdn.example.com",
Features: config.Features{SriChecksEnabled: true},
PluginSettings: config.PluginSettings{
pluginID: {"cdn": "true"},
parentPluginID: {"cdn": "true"},
},
},
cdn: func() *pluginscdn.Service {
cfg := &config.PluginManagementCfg{
PluginsCDNURLTemplate: "https://cdn.example.com",
PluginSettings: config.PluginSettings{
pluginID: {"cdn": "true"},
parentPluginID: {"cdn": "true"},
},
}
return pluginscdn.ProvideService(cfg)
}(),
expModuleHash: "sha256-y9GsIoRkWg4emocipyn1vN0rgxIicocJxjYL7s3WFD8=",
},
{
// grand-parent-app (/)
// ├── parent-datasource (/datasource)
// │ └── child-panel (/datasource/panels/one)
name: "nested plugin of a nested plugin should return module hash from grandparent MANIFEST.txt",
plugin: func() *plugins.Plugin {
grandparent := &plugins.Plugin{
JSONData: plugins.JSONData{ID: "grand-parent-app"},
Signature: plugins.SignatureStatusValid,
Manifest: createV2Manifest(map[string]string{
"module.js": "266c19bc148b22ddef2a288fc5f8f40855bda22ccf60be53340b4931e469ae2a",
"datasource/module.js": "04d70db091d96c4775fb32ba5a8f84cc22893eb43afdb649726661d4425c6711",
"datasource/panels/one/module.js": "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f",
}),
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested")),
}
parent := &plugins.Plugin{
JSONData: plugins.JSONData{ID: "parent-datasource"},
Signature: plugins.SignatureStatusValid,
Parent: grandparent,
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested", "datasource")),
}
return &plugins.Plugin{
JSONData: plugins.JSONData{ID: "child-panel"},
Signature: plugins.SignatureStatusValid,
Parent: parent,
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested", "datasource", "panels", "one")),
}
}(),
cfg: &config.PluginManagementCfg{
PluginsCDNURLTemplate: "https://cdn.example.com",
Features: config.Features{SriChecksEnabled: true},
PluginSettings: config.PluginSettings{
"child-panel": {"cdn": "true"},
"parent-datasource": {"cdn": "true"},
"grand-parent-app": {"cdn": "true"},
},
},
cdn: func() *pluginscdn.Service {
cfg := &config.PluginManagementCfg{
PluginsCDNURLTemplate: "https://cdn.example.com",
PluginSettings: config.PluginSettings{
"child-panel": {"cdn": "true"},
"parent-datasource": {"cdn": "true"},
"grand-parent-app": {"cdn": "true"},
},
}
return pluginscdn.ProvideService(cfg)
}(),
expModuleHash: "sha256-y9GsIoRkWg4emocipyn1vN0rgxIicocJxjYL7s3WFD8=",
},
{
name: "nested plugin should not return module hash when parent manifest is nil",
plugin: func() *plugins.Plugin {
parent := &plugins.Plugin{
JSONData: plugins.JSONData{ID: parentPluginID},
Signature: plugins.SignatureStatusValid,
Manifest: nil, // Parent has no manifest
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested")),
}
return &plugins.Plugin{
JSONData: plugins.JSONData{ID: pluginID},
Signature: plugins.SignatureStatusValid,
Parent: parent,
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "panels", "one")),
}
}(),
cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}},
cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}),
expModuleHash: "",
},
} {
t.Run(tc.name, func(t *testing.T) {
result := CalculateModuleHash(tc.plugin, tc.cfg, tc.cdn)
require.Equal(t, tc.expModuleHash, result)
})
}
}
+2 -26
View File
@@ -40,7 +40,6 @@ type Plugin struct {
Pinned bool
// Signature fields
Manifest *PluginManifest
Signature SignatureStatus
SignatureType SignatureType
SignatureOrg string
@@ -49,10 +48,8 @@ type Plugin struct {
Error *Error
// SystemJS fields
Module string
ModuleHash string
LoadingStrategy LoadingStrategy
BaseURL string
Module string
BaseURL string
Angular AngularMeta
@@ -535,24 +532,3 @@ func (pt Type) IsValid() bool {
}
return false
}
// PluginManifest holds details for the file manifest
type PluginManifest struct {
Plugin string `json:"plugin"`
Version string `json:"version"`
KeyID string `json:"keyId"`
Time int64 `json:"time"`
Files map[string]string `json:"files"`
// V2 supported fields
ManifestVersion string `json:"manifestVersion"`
SignatureType SignatureType `json:"signatureType"`
SignedByOrg string `json:"signedByOrg"`
SignedByOrgName string `json:"signedByOrgName"`
RootURLs []string `json:"rootUrls"`
}
// IsV2 returns true if the manifest is version 2.x
func (m *PluginManifest) IsV2() bool {
return strings.HasPrefix(m.ManifestVersion, "2.")
}

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