Compare commits

...

34 Commits

Author SHA1 Message Date
Aleksandar Petrov 45e60f849b Improve naming consistency 2026-01-14 16:45:24 -04:00
Aleksandar Petrov d43f4b576a Fix issue with collapsed nodes found with E2E test 2026-01-14 16:44:12 -04:00
Aleksandar Petrov f79f7ac33d Move extra header elements to the right 2026-01-14 09:31:41 -04:00
Aleksandar Petrov f03ee8d19c Improve UI state handling in split view 2026-01-14 09:31:41 -04:00
Aleksandar Petrov eb37860388 Improve layout 2026-01-14 09:31:41 -04:00
Aleksandar Petrov da7b70336c Improve click handling in split view 2026-01-14 09:31:40 -04:00
Aleksandar Petrov 17817bdda7 Add call tree to flame graph container 2026-01-14 09:31:40 -04:00
Aleksandar Petrov 5bed426fd8 Add search support 2026-01-14 09:31:40 -04:00
Aleksandar Petrov 8bc405d5ed Add support to show callers in call tree 2026-01-14 09:31:40 -04:00
Aleksandar Petrov b8c9ee987e Simplify things a bit 2026-01-14 09:31:39 -04:00
Aleksandar Petrov 49e4d6760b Add action column, improve UX 2026-01-14 09:31:39 -04:00
Aleksandar Petrov 665a54f02f First iteration of call tree profile visualization 2026-01-14 09:31:39 -04: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
241 changed files with 86557 additions and 3707 deletions
+2
View File
@@ -543,6 +543,7 @@ i18next.config.ts @grafana/grafana-frontend-platform
/packages/grafana-data/tsconfig.json @grafana/grafana-frontend-platform /packages/grafana-data/tsconfig.json @grafana/grafana-frontend-platform
/packages/grafana-data/test/ @grafana/grafana-frontend-platform /packages/grafana-data/test/ @grafana/grafana-frontend-platform
/packages/grafana-data/typings/ @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/**/*logs* @grafana/observability-logs
/packages/grafana-data/src/context/plugins/ @grafana/plugins-platform-frontend /packages/grafana-data/src/context/plugins/ @grafana/plugins-platform-frontend
@@ -658,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/LocationService.tsx @grafana/grafana-search-navigate-organise
/packages/grafana-runtime/src/services/LocationSrv.ts @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/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/chromeHeaderHeight.ts @grafana/grafana-search-navigate-organise
/packages/grafana-runtime/src/utils/DataSourceWithBackend* @grafana/grafana-datasources-core-services /packages/grafana-runtime/src/utils/DataSourceWithBackend* @grafana/grafana-datasources-core-services
/packages/grafana-runtime/src/utils/licensing.ts @grafana/grafana-operator-experience-squad /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/zipkin/**/*'
- '**/pkg/tsdb/jaeger/*' - '**/pkg/tsdb/jaeger/*'
- '**/pkg/tsdb/jaeger/**/*' - '**/pkg/tsdb/jaeger/**/*'
- '**/pkg/tsdb/elasticsearch/*'
- '**/pkg/tsdb/elasticsearch/**/*'
deny: deny:
- pkg: github.com/grafana/grafana/pkg/api - pkg: github.com/grafana/grafana/pkg/api
desc: Core plugins are not allowed to depend on Grafana core packages desc: Core plugins are not allowed to depend on Grafana core packages
@@ -28,7 +28,7 @@ type check struct {
PluginStore pluginstore.Store PluginStore pluginstore.Store
PluginContextProvider PluginContextProvider PluginContextProvider PluginContextProvider
PluginClient plugins.Client PluginClient plugins.Client
PluginRepo repo.Service PluginRepo checks.PluginInfoGetter
GrafanaVersion string GrafanaVersion string
pluginCanBeInstalledCache map[string]bool pluginCanBeInstalledCache map[string]bool
pluginExistsCacheMu sync.RWMutex pluginExistsCacheMu sync.RWMutex
@@ -39,7 +39,7 @@ func New(
pluginStore pluginstore.Store, pluginStore pluginstore.Store,
pluginContextProvider PluginContextProvider, pluginContextProvider PluginContextProvider,
pluginClient plugins.Client, pluginClient plugins.Client,
pluginRepo repo.Service, pluginRepo checks.PluginInfoGetter,
grafanaVersion string, grafanaVersion string,
) checks.Check { ) checks.Check {
return &check{ return &check{
@@ -15,7 +15,7 @@ import (
type missingPluginStep struct { type missingPluginStep struct {
PluginStore pluginstore.Store PluginStore pluginstore.Store
PluginRepo repo.Service PluginRepo checks.PluginInfoGetter
GrafanaVersion string GrafanaVersion string
} }
+8
View File
@@ -5,6 +5,7 @@ import (
"github.com/grafana/grafana-app-sdk/logging" "github.com/grafana/grafana-app-sdk/logging"
advisorv0alpha1 "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1" 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 // 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 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) 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( func New(
pluginStore pluginstore.Store, pluginStore pluginstore.Store,
pluginRepo repo.Service, pluginRepo checks.PluginInfoGetter,
updateChecker pluginchecker.PluginUpdateChecker, updateChecker pluginchecker.PluginUpdateChecker,
pluginErrorResolver plugins.ErrorResolver, pluginErrorResolver plugins.ErrorResolver,
grafanaVersion string, grafanaVersion string,
@@ -33,7 +33,7 @@ func New(
type check struct { type check struct {
PluginStore pluginstore.Store PluginStore pluginstore.Store
PluginRepo repo.Service PluginRepo checks.PluginInfoGetter
updateChecker pluginchecker.PluginUpdateChecker updateChecker pluginchecker.PluginUpdateChecker
pluginErrorResolver plugins.ErrorResolver pluginErrorResolver plugins.ErrorResolver
GrafanaVersion string GrafanaVersion string
@@ -290,7 +290,7 @@
], ],
"legend": { "legend": {
"displayMode": "table", "displayMode": "table",
"placement": "right", "placement": "bottom",
"showLegend": true, "showLegend": true,
"values": [ "values": [
"percent" "percent"
@@ -304,7 +304,7 @@
"fields": "", "fields": "",
"values": false "values": false
}, },
"showLegend": true, "showLegend": false,
"strokeWidth": 1, "strokeWidth": 1,
"text": {} "text": {}
}, },
@@ -323,15 +323,6 @@
} }
], ],
"title": "Percent", "title": "Percent",
"transformations": [
{
"id": "renameByRegex",
"options": {
"regex": "^Backend-(.*)$",
"renamePattern": "b-$1"
}
}
],
"type": "piechart" "type": "piechart"
}, },
{ {
@@ -375,7 +366,7 @@
], ],
"legend": { "legend": {
"displayMode": "table", "displayMode": "table",
"placement": "right", "placement": "bottom",
"showLegend": true, "showLegend": true,
"values": [ "values": [
"value" "value"
@@ -389,7 +380,7 @@
"fields": "", "fields": "",
"values": false "values": false
}, },
"showLegend": true, "showLegend": false,
"strokeWidth": 1, "strokeWidth": 1,
"text": {} "text": {}
}, },
@@ -408,15 +399,6 @@
} }
], ],
"title": "Value", "title": "Value",
"transformations": [
{
"id": "renameByRegex",
"options": {
"regex": "(.*)",
"renamePattern": "$1-how-much-wood-could-a-woodchuck-chuck-if-a-woodchuck-could-chuck-wood"
}
}
],
"type": "piechart" "type": "piechart"
}, },
{ {
+10 -3
View File
@@ -1,9 +1,16 @@
include ../sdk.mk include ../sdk.mk
.PHONY: generate # Run Grafana App SDK code generation .PHONY: internal-generate # Run Grafana App SDK code generation
generate: install-app-sdk update-app-sdk internal-generate: install-app-sdk update-app-sdk
@$(APP_SDK_BIN) generate \ @$(APP_SDK_BIN) generate \
--source=./kinds/ \ --source=./kinds/ \
--gogenpath=./pkg/apis \ --gogenpath=./pkg/apis \
--grouping=group \ --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 ## Codegen
- Go: `make generate` - Go and TypeScript: `make generate`
- Frontend: Follow instructions in this [README](../..//packages/grafana-api-clients/README.md)
## Plugin sync ## Plugin sync
+1 -1
View File
@@ -11,7 +11,7 @@ manifest: {
v0alpha1Version: { v0alpha1Version: {
served: true served: true
codegen: { codegen: {
ts: {enabled: false} ts: {enabled: true}
go: {enabled: true} go: {enabled: true}
} }
kinds: [ kinds: [
@@ -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": { "legend": {
"values": ["percent"], "values": ["percent"],
"displayMode": "table", "displayMode": "table",
"placement": "right" "placement": "bottom"
}, },
"pieType": "pie", "pieType": "pie",
"reduceOptions": { "reduceOptions": {
@@ -256,7 +256,7 @@
"fields": "", "fields": "",
"values": false "values": false
}, },
"showLegend": true, "showLegend": false,
"strokeWidth": 1, "strokeWidth": 1,
"text": {} "text": {}
}, },
@@ -272,15 +272,6 @@
"timeFrom": null, "timeFrom": null,
"timeShift": null, "timeShift": null,
"title": "Percent", "title": "Percent",
"transformations": [
{
"id": "renameByRegex",
"options": {
"regex": "^Backend-(.*)$",
"renamePattern": "b-$1"
}
}
],
"type": "piechart" "type": "piechart"
}, },
{ {
@@ -320,7 +311,7 @@
"legend": { "legend": {
"values": ["value"], "values": ["value"],
"displayMode": "table", "displayMode": "table",
"placement": "right" "placement": "bottom"
}, },
"pieType": "pie", "pieType": "pie",
"reduceOptions": { "reduceOptions": {
@@ -328,7 +319,7 @@
"fields": "", "fields": "",
"values": false "values": false
}, },
"showLegend": true, "showLegend": false,
"strokeWidth": 1, "strokeWidth": 1,
"text": {} "text": {}
}, },
@@ -344,15 +335,6 @@
"timeFrom": null, "timeFrom": null,
"timeShift": null, "timeShift": null,
"title": "Value", "title": "Value",
"transformations": [
{
"id": "renameByRegex",
"options": {
"regex": "(.*)",
"renamePattern": "$1-how-much-wood-could-a-woodchuck-chuck-if-a-woodchuck-could-chuck-wood"
}
}
],
"type": "piechart" "type": "piechart"
}, },
{ {
@@ -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: 1. Set the data source's basic configuration options:
| Name | Description | | Name | Description |
| ----------- | ------------------------------------------------------------------------ | | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Name** | Sets the name you use to refer to the data source in panels and queries. | | **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. | | **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 ### Provision the data source
@@ -129,6 +130,7 @@ datasources:
clientEmail: stackdriver@myproject.iam.gserviceaccount.com clientEmail: stackdriver@myproject.iam.gserviceaccount.com
authenticationType: jwt authenticationType: jwt
defaultProject: my-project-name defaultProject: my-project-name
universeDomain: googleapis.com
secureJsonData: secureJsonData:
privateKey: | privateKey: |
-----BEGIN PRIVATE KEY----- -----BEGIN PRIVATE KEY-----
@@ -152,6 +154,7 @@ datasources:
clientEmail: stackdriver@myproject.iam.gserviceaccount.com clientEmail: stackdriver@myproject.iam.gserviceaccount.com
authenticationType: jwt authenticationType: jwt
defaultProject: my-project-name defaultProject: my-project-name
universeDomain: googleapis.com
privateKeyPath: /etc/secrets/gce.pem privateKeyPath: /etc/secrets/gce.pem
``` ```
@@ -166,6 +169,7 @@ datasources:
access: proxy access: proxy
jsonData: jsonData:
authenticationType: gce authenticationType: gce
universeDomain: googleapis.com
``` ```
## Import pre-configured dashboards ## 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) - [CockroachDB](/grafana/plugins/grafana-cockroachdb-datasource)
- [Databricks](/grafana/plugins/grafana-databricks-datasource) - [Databricks](/grafana/plugins/grafana-databricks-datasource)
- [DataDog](/grafana/plugins/grafana-datadog-datasource) - [DataDog](/grafana/plugins/grafana-datadog-datasource)
- [IBM Db2](/grafana/plugins/grafana-ibmdb2-datasource)
- [Drone](/grafana/plugins/grafana-drone-datasource) - [Drone](/grafana/plugins/grafana-drone-datasource)
- [DynamoDB](/grafana/plugins/grafana-dynamodb-datasource/) - [DynamoDB](/grafana/plugins/grafana-dynamodb-datasource/)
- [Dynatrace](/grafana/plugins/grafana-dynatrace-datasource) - [Dynatrace](/grafana/plugins/grafana-dynatrace-datasource)
@@ -2030,6 +2030,44 @@ For example: `disabled_labels=grafana_folder`
<hr> <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]` ### `[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) 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 | | `reportingRetries` | Enables rendering retries for the reporting feature |
| `externalServiceAccounts` | Automatic service account and token setup for plugins | | `externalServiceAccounts` | Automatic service account and token setup for plugins |
| `cloudWatchBatchQueries` | Runs CloudWatch metrics queries as separate batches | | `cloudWatchBatchQueries` | Runs CloudWatch metrics queries as separate batches |
| `dashboardNewLayouts` | Enables new dashboard layouts |
| `pdfTables` | Enables generating table data as PDF in reporting | | `pdfTables` | Enables generating table data as PDF in reporting |
| `canvasPanelPanZoom` | Allow pan and zoom in canvas panel | | `canvasPanelPanZoom` | Allow pan and zoom in canvas panel |
| `alertingSaveStateCompressed` | Enables the compressed protobuf-based alert state storage. Default is enabled. | | `alertingSaveStateCompressed` | Enables the compressed protobuf-based alert state storage. Default is enabled. |
@@ -30,7 +30,9 @@ refs:
# Datagrid # 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 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. inside a dashboard.
+128 -25
View File
@@ -1337,6 +1337,11 @@
"count": 2 "count": 2
} }
}, },
"public/app/features/alerting/unified/api/onCallApi.test.ts": {
"no-restricted-syntax": {
"count": 2
}
},
"public/app/features/alerting/unified/components/AnnotationDetailsField.tsx": { "public/app/features/alerting/unified/components/AnnotationDetailsField.tsx": {
"@typescript-eslint/consistent-type-assertions": { "@typescript-eslint/consistent-type-assertions": {
"count": 1 "count": 1
@@ -1377,6 +1382,11 @@
"count": 1 "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": { "public/app/features/alerting/unified/components/import-to-gma/NamespaceAndGroupFilter.tsx": {
"no-restricted-syntax": { "no-restricted-syntax": {
"count": 2 "count": 2
@@ -1617,11 +1627,31 @@
"count": 1 "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": { "public/app/features/alerting/unified/rule-editor/formDefaults.ts": {
"no-restricted-syntax": { "no-restricted-syntax": {
"count": 6 "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": { "public/app/features/alerting/unified/types/alerting.ts": {
"@typescript-eslint/no-explicit-any": { "@typescript-eslint/no-explicit-any": {
"count": 5 "count": 5
@@ -1632,6 +1662,16 @@
"count": 1 "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": { "public/app/features/alerting/unified/utils/datasource.ts": {
"no-restricted-syntax": { "no-restricted-syntax": {
"count": 2 "count": 2
@@ -1663,12 +1703,20 @@
"count": 1 "count": 1
} }
}, },
"public/app/features/alerting/unified/utils/rules.test.ts": {
"no-restricted-syntax": {
"count": 1
}
},
"public/app/features/alerting/unified/utils/rules.ts": { "public/app/features/alerting/unified/utils/rules.ts": {
"@typescript-eslint/consistent-type-assertions": { "@typescript-eslint/consistent-type-assertions": {
"count": 3 "count": 3
}, },
"@typescript-eslint/no-explicit-any": { "@typescript-eslint/no-explicit-any": {
"count": 1 "count": 1
},
"no-restricted-syntax": {
"count": 1
} }
}, },
"public/app/features/annotations/components/StandardAnnotationQueryEditor.tsx": { "public/app/features/annotations/components/StandardAnnotationQueryEditor.tsx": {
@@ -1724,6 +1772,16 @@
"count": 1 "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": { "public/app/features/connections/tabs/ConnectData/ConnectData.tsx": {
"@typescript-eslint/consistent-type-assertions": { "@typescript-eslint/consistent-type-assertions": {
"count": 1 "count": 1
@@ -2063,6 +2121,11 @@
"count": 1 "count": 1
} }
}, },
"public/app/features/dashboard/components/GenAI/utils.ts": {
"no-restricted-syntax": {
"count": 1
}
},
"public/app/features/dashboard/components/HelpWizard/HelpWizard.tsx": { "public/app/features/dashboard/components/HelpWizard/HelpWizard.tsx": {
"no-restricted-syntax": { "no-restricted-syntax": {
"count": 3 "count": 3
@@ -2889,6 +2952,71 @@
"count": 1 "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": { "public/app/features/plugins/sandbox/distortions.ts": {
"@typescript-eslint/consistent-type-assertions": { "@typescript-eslint/consistent-type-assertions": {
"count": 1 "count": 1
@@ -3615,46 +3743,21 @@
"count": 1 "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": { "public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/aggregations.ts": {
"@typescript-eslint/consistent-type-assertions": { "@typescript-eslint/consistent-type-assertions": {
"count": 1 "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": { "public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/MetricEditor.tsx": {
"@typescript-eslint/consistent-type-assertions": { "@typescript-eslint/consistent-type-assertions": {
"count": 1 "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": { "public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/aggregations.ts": {
"@typescript-eslint/consistent-type-assertions": { "@typescript-eslint/consistent-type-assertions": {
"count": 1 "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": { "public/app/plugins/datasource/elasticsearch/configuration/DataLinks.tsx": {
"no-restricted-syntax": { "no-restricted-syntax": {
"count": 1 "count": 1
+38
View File
@@ -117,6 +117,8 @@ module.exports = [
'scripts/grafana-server/tmp', 'scripts/grafana-server/tmp',
'packages/grafana-ui/src/graveyard', // deprecated UI components slated for removal 'packages/grafana-ui/src/graveyard', // deprecated UI components slated for removal
'public/build-swagger', // swagger build output 'public/build-swagger', // swagger build output
'apps/plugins/plugin/src/generated/meta/v0alpha1',
'apps/plugins/plugin/src/generated/plugin/v0alpha1',
], ],
}, },
...grafanaConfig, ...grafanaConfig,
@@ -575,6 +577,42 @@ module.exports = [
"Property[key.name='a11y'][value.type='ObjectExpression'] Property[key.name='test'][value.value='off']", "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.', 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/beevik/etree v1.4.1 // @grafana/grafana-backend-group
github.com/benbjohnson/clock v1.3.5 // @grafana/alerting-backend 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/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/v2 v2.5.7 // @grafana/grafana-search-and-storage
github.com/blevesearch/bleve_index_api v1.2.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 v0.2.2 // @grafana/grafana-backend-group
github.com/blugelabs/bluge_segment_api v0.2.0 // @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 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/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.22.0 // indirect github.com/bits-and-blooms/bitset v1.22.0 // indirect
github.com/blang/semver v3.5.1+incompatible // indirect github.com/blang/semver v3.5.1+incompatible // indirect
github.com/blevesearch/geo v0.1.20 // indirect github.com/blevesearch/geo v0.2.4 // indirect
github.com/blevesearch/go-faiss v1.0.25 // indirect github.com/blevesearch/go-faiss v1.0.26 // indirect
github.com/blevesearch/go-porterstemmer v1.0.3 // indirect github.com/blevesearch/go-porterstemmer v1.0.3 // indirect
github.com/blevesearch/gtreap v0.1.1 // indirect github.com/blevesearch/gtreap v0.1.1 // indirect
github.com/blevesearch/mmap-go v1.0.4 // 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/segment v0.9.1 // indirect
github.com/blevesearch/snowballstem v0.9.0 // indirect github.com/blevesearch/snowballstem v0.9.0 // indirect
github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect
github.com/blevesearch/vellum v1.1.0 // indirect github.com/blevesearch/vellum v1.1.0 // indirect
github.com/blevesearch/zapx/v11 v11.4.1 // indirect github.com/blevesearch/zapx/v11 v11.4.2 // indirect
github.com/blevesearch/zapx/v12 v12.4.1 // indirect github.com/blevesearch/zapx/v12 v12.4.2 // indirect
github.com/blevesearch/zapx/v13 v13.4.1 // indirect github.com/blevesearch/zapx/v13 v13.4.2 // indirect
github.com/blevesearch/zapx/v14 v14.4.1 // indirect github.com/blevesearch/zapx/v14 v14.4.2 // indirect
github.com/blevesearch/zapx/v15 v15.4.1 // indirect github.com/blevesearch/zapx/v15 v15.4.2 // indirect
github.com/blevesearch/zapx/v16 v16.2.2 // indirect github.com/blevesearch/zapx/v16 v16.2.8 // indirect
github.com/bluele/gcache v0.0.2 // indirect github.com/bluele/gcache v0.0.2 // indirect
github.com/blugelabs/ice v1.0.0 // indirect github.com/blugelabs/ice v1.0.0 // indirect
github.com/blugelabs/ice/v2 v2.0.1 // 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-jwt/jwt/v5 v5.3.0 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // 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/gomodule/redigo v1.8.9 // indirect
github.com/google/btree v1.1.3 // indirect github.com/google/btree v1.1.3 // indirect
github.com/google/cel-go v0.26.1 // 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 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 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= 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.7 h1:2d9YrL5zrX5EBBW++GOaEKjE+NPWeZGaX77IM26m1Z8=
github.com/blevesearch/bleve/v2 v2.5.0/go.mod h1:PcJzTPnEynO15dCf9isxOga7YFRa/cMSsbnRwnszXUk= github.com/blevesearch/bleve/v2 v2.5.7/go.mod h1:yj0NlS7ocGC4VOSAedqDDMktdh2935v2CSWOCDMHdSA=
github.com/blevesearch/bleve_index_api v1.2.7 h1:c8r9vmbaYQroAMSGag7zq5gEVPiuXrUQDqfnj7uYZSY= github.com/blevesearch/bleve_index_api v1.3.0 h1:DsMpWVjFNlBw9/6pyWf59XoqcAkhHj3H0UWiQsavb6E=
github.com/blevesearch/bleve_index_api v1.2.7/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0= github.com/blevesearch/bleve_index_api v1.3.0/go.mod h1:xvd48t5XMeeioWQ5/jZvgLrV98flT2rdvEJ3l/ki4Ko=
github.com/blevesearch/geo v0.1.20 h1:paaSpu2Ewh/tn5DKn/FB5SzvH0EWupxHEIwbCk/QPqM= github.com/blevesearch/geo v0.2.4 h1:ECIGQhw+QALCZaDcogRTNSJYQXRtC8/m8IKiA706cqk=
github.com/blevesearch/geo v0.1.20/go.mod h1:DVG2QjwHNMFmjo+ZgzrIq2sfCh6rIHzy9d9d0B59I6w= github.com/blevesearch/geo v0.2.4/go.mod h1:K56Q33AzXt2YExVHGObtmRSFYZKYGv0JEN5mdacJJR8=
github.com/blevesearch/go-faiss v1.0.25 h1:lel1rkOUGbT1CJ0YgzKwC7k+XH0XVBHnCVWahdCXk4U= github.com/blevesearch/go-faiss v1.0.26 h1:4dRLolFgjPyjkaXwff4NfbZFdE/dfywbzDqporeQvXI=
github.com/blevesearch/go-faiss v1.0.25/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk= 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 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo=
github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M= github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M=
github.com/blevesearch/gtreap v0.1.1 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y= 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.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 h1:OVhDhT5B/M1HNPpYPBKIEJaD0F3Si+CrEKULGCDPWmc=
github.com/blevesearch/mmap-go v1.0.4/go.mod h1:EWmEAOmdAS9z/pi/+Toxu99DnsbhG1TIxUoRmJw/pSs= 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.13 h1:ZPjv/4VwWvHJZKeMSgScCapOy8+DdmsmRyLmSB88UoY=
github.com/blevesearch/scorch_segment_api/v2 v2.3.9/go.mod h1:IrzspZlVjhf4X29oJiEhBxEteTqOY9RlYlk1lCmYHr4= 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.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 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU=
github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw= 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.0.7/go.mod h1:doBZpmRhwTsASB4QdUZANlJvqVAUdUyX0ZK7QJCTeBE=
github.com/blevesearch/vellum v1.1.0 h1:CinkGyIsgVlYf8Y2LUQHvdelgXr6PYuvoDIajq6yR9w= 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/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.2 h1:l46SV+b0gFN+Rw3wUI1YdMWdSAVhskYuvxlcgpQFljs=
github.com/blevesearch/zapx/v11 v11.4.1/go.mod h1:qNOGxIqdPC1MXauJCD9HBG487PxviTUUbmChFOAosGs= github.com/blevesearch/zapx/v11 v11.4.2/go.mod h1:4gdeyy9oGa/lLa6D34R9daXNUvfMPZqUYjPwiLmekwc=
github.com/blevesearch/zapx/v12 v12.4.1 h1:K77bhypII60a4v8mwvav7r4IxWA8qxhNjgF9xGdb9eQ= github.com/blevesearch/zapx/v12 v12.4.2 h1:fzRbhllQmEMUuAQ7zBuMvKRlcPA5ESTgWlDEoB9uQNE=
github.com/blevesearch/zapx/v12 v12.4.1/go.mod h1:QRPrlPOzAxBNMI0MkgdD+xsTqx65zbuPr3Ko4Re49II= github.com/blevesearch/zapx/v12 v12.4.2/go.mod h1:TdFmr7afSz1hFh/SIBCCZvcLfzYvievIH6aEISCte58=
github.com/blevesearch/zapx/v13 v13.4.1 h1:EnkEMZFUK0lsW/jOJJF2xOcp+W8TjEsyeN5BeAZEYYE= github.com/blevesearch/zapx/v13 v13.4.2 h1:46PIZCO/ZuKZYgxI8Y7lOJqX3Irkc3N8W82QTK3MVks=
github.com/blevesearch/zapx/v13 v13.4.1/go.mod h1:e6duBMlCvgbH9rkzNMnUa9hRI9F7ri2BRcHfphcmGn8= github.com/blevesearch/zapx/v13 v13.4.2/go.mod h1:knK8z2NdQHlb5ot/uj8wuvOq5PhDGjNYQQy0QDnopZk=
github.com/blevesearch/zapx/v14 v14.4.1 h1:G47kGCshknBZzZAtjcnIAMn3oNx8XBLxp8DMq18ogyE= github.com/blevesearch/zapx/v14 v14.4.2 h1:2SGHakVKd+TrtEqpfeq8X+So5PShQ5nW6GNxT7fWYz0=
github.com/blevesearch/zapx/v14 v14.4.1/go.mod h1:O7sDxiaL2r2PnCXbhh1Bvm7b4sP+jp4unE9DDPWGoms= github.com/blevesearch/zapx/v14 v14.4.2/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXmppH9Hi7a877D8=
github.com/blevesearch/zapx/v15 v15.4.1 h1:B5IoTMUCEzFdc9FSQbhVOxAY+BO17c05866fNruiI7g= github.com/blevesearch/zapx/v15 v15.4.2 h1:sWxpDE0QQOTjyxYbAVjt3+0ieu8NCE0fDRaFxEsp31k=
github.com/blevesearch/zapx/v15 v15.4.1/go.mod h1:b/MreHjYeQoLjyY2+UaM0hGZZUajEbE0xhnr1A2/Q6Y= github.com/blevesearch/zapx/v15 v15.4.2/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw=
github.com/blevesearch/zapx/v16 v16.2.2 h1:MifKJVRTEhMTgSlle2bDRTb39BGc9jXFRLPZc6r0Rzk= github.com/blevesearch/zapx/v16 v16.2.8 h1:SlnzF0YGtSlrsOE3oE7EgEX6BIepGpeqxs1IjMbHLQI=
github.com/blevesearch/zapx/v16 v16.2.2/go.mod h1:B9Pk4G1CqtErgQV9DyCSA9Lb7WZe4olYfGw7fVDZ4sk= 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 h1:WcbfdXICg7G/DGBh1PFfcirkWOQV+v077yF1pSy3DGw=
github.com/bluele/gcache v0.0.2/go.mod h1:m15KV+ECjptwSPxKhOhQoAFQVtUFjTVkc3H8o0t/fp0= github.com/bluele/gcache v0.0.2/go.mod h1:m15KV+ECjptwSPxKhOhQoAFQVtUFjTVkc3H8o0t/fp0=
github.com/blugelabs/bluge v0.2.2 h1:gat8CqE6P6tOgeX30XGLOVNTC26cpM2RWVcreXWtYcM= 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 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= 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/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 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.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4=
github.com/golang/glog v1.1.0/go.mod h1:pfYeQZ3JWZoXTV5sFc986z3HTpwQs9At6P4ImfuP3NQ= 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/benbjohnson/immutable v0.4.0/go.mod h1:iAr8OjJGLnLmVUr9MZ/rz4PWUy6Ouc2JLYuMArmvAJM=
github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY= 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/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 h1:kDy+zgJFJJoJYBvdfBSiZYBbdsUL0XcjHYWezpQBGPA=
github.com/blevesearch/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:9eJDeqxJ3E7WnLebQUlPD7ZjSce7AnDb9vjGmMCbD0A= 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 h1:iAtV2Cu5s0GD1lwUiekkFHe2gTMCCNVj2foPclDLIFI=
github.com/blevesearch/goleveldb v1.0.1/go.mod h1:WrU8ltZbIp0wAoig/MHbrPCXSOLpe79nz5lv5nqfYrQ= 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 h1:cDYjn/NCH+wwt2UdehaLpr2e4BwLIjN4V/TdLsL+B5A=
github.com/blevesearch/snowball v0.6.1/go.mod h1:ZF0IBg5vgpeoUhnMza2v0A/z8m1cWPlwhke08LpNusg= 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 h1:CYzVPaScODMvgE9o+kf6D4RJ/VRomyi9uHF+PtB+Afc=
github.com/blevesearch/stempel v0.2.0/go.mod h1:wjeTHqQv+nQdbPuJ/YcvOjTInA2EIc6Ks1FoSUzSLvc= 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 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0=
github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= 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= 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.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 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.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-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/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= 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 h1:sfe532Ipn7GX0V6mHdynBk393rDmqgI0QmjLK7ct7TU=
github.com/joncrlsn/dque v0.0.0-20211108142734-c2ef48c5192a/go.mod h1:dNKs71rs2VJGBAmttu7fouEsRQlRjxy0p1Sx+T5wbpY= 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/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/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 h1:z1n1AOHVVydOOVuyphbOKyR4NICDQFiJMn1IK5hVQ5Y=
github.com/jsternberg/zap-logfmt v1.3.0/go.mod h1:N3DENp9WNmCZxvkBD/eReWwz1149BK6jEN9cQ4fNwZE= 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. // Decoupled plugins run their own tests so ignoring them here.
'<rootDir>/public/app/plugins/datasource/azuremonitor', '<rootDir>/public/app/plugins/datasource/azuremonitor',
'<rootDir>/public/app/plugins/datasource/cloud-monitoring', '<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-postgresql-datasource',
'<rootDir>/public/app/plugins/datasource/grafana-pyroscope-datasource', '<rootDir>/public/app/plugins/datasource/grafana-pyroscope-datasource',
'<rootDir>/public/app/plugins/datasource/grafana-testdata-datasource', '<rootDir>/public/app/plugins/datasource/grafana-testdata-datasource',
+2 -2
View File
@@ -293,8 +293,8 @@
"@grafana/plugin-ui": "^0.11.1", "@grafana/plugin-ui": "^0.11.1",
"@grafana/prometheus": "workspace:*", "@grafana/prometheus": "workspace:*",
"@grafana/runtime": "workspace:*", "@grafana/runtime": "workspace:*",
"@grafana/scenes": "v6.52.1", "@grafana/scenes": "6.52.2",
"@grafana/scenes-react": "v6.52.1", "@grafana/scenes-react": "6.52.2",
"@grafana/schema": "workspace:*", "@grafana/schema": "workspace:*",
"@grafana/sql": "workspace:*", "@grafana/sql": "workspace:*",
"@grafana/ui": "workspace:*", "@grafana/ui": "workspace:*",
+10 -1
View File
@@ -35,6 +35,14 @@
}, },
"./test": { "./test": {
"@grafana-app/source": "./test/index.ts" "@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": { "publishConfig": {
@@ -52,7 +60,7 @@
"typecheck": "tsc --emitDeclarationOnly false --noEmit", "typecheck": "tsc --emitDeclarationOnly false --noEmit",
"prepack": "cp package.json package.json.bak && node ../../scripts/prepare-npm-package.js", "prepack": "cp package.json package.json.bak && node ../../scripts/prepare-npm-package.js",
"postpack": "mv package.json.bak package.json", "postpack": "mv package.json.bak package.json",
"themes-schema": "tsx ./src/themes/scripts/generateSchema.ts" "themes-schema": "tsx ./scripts/generateSchema.ts"
}, },
"dependencies": { "dependencies": {
"@braintree/sanitize-url": "7.0.1", "@braintree/sanitize-url": "7.0.1",
@@ -102,6 +110,7 @@
"react-dom": "18.3.1", "react-dom": "18.3.1",
"rimraf": "6.0.1", "rimraf": "6.0.1",
"rollup": "^4.22.4", "rollup": "^4.22.4",
"rollup-plugin-copy": "3.5.0",
"rollup-plugin-esbuild": "6.2.1", "rollup-plugin-esbuild": "6.2.1",
"rollup-plugin-node-externals": "^8.0.0", "rollup-plugin-node-externals": "^8.0.0",
"tsx": "^4.21.0", "tsx": "^4.21.0",
+21 -2
View File
@@ -1,21 +1,40 @@
import json from '@rollup/plugin-json'; import json from '@rollup/plugin-json';
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
import copy from 'rollup-plugin-copy';
import { entryPoint, plugins, esmOutput, cjsOutput } from '../rollup.config.parts'; import { entryPoint, plugins, esmOutput, cjsOutput } from '../rollup.config.parts';
const rq = createRequire(import.meta.url); const rq = createRequire(import.meta.url);
const pkg = rq('./package.json'); 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 [ export default [
{ {
input: entryPoint, input: entryPoint,
plugins: [...plugins, json()], plugins: grafanaDataPlugins,
output: [cjsOutput(pkg, 'grafana-data'), esmOutput(pkg, 'grafana-data')], output: [cjsOutput(pkg, 'grafana-data'), esmOutput(pkg, 'grafana-data')],
treeshake: false, treeshake: false,
}, },
{ {
input: 'src/unstable.ts', input: 'src/unstable.ts',
plugins: [...plugins, json()], plugins: grafanaDataPlugins,
output: [cjsOutput(pkg, 'grafana-data'), esmOutput(pkg, 'grafana-data')], output: [cjsOutput(pkg, 'grafana-data'), esmOutput(pkg, 'grafana-data')],
treeshake: false, 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, DataLinkConfigOrigin,
SupportedTransformationType, SupportedTransformationType,
type InternalDataLink, type InternalDataLink,
type LinkTarget,
type LinkModel, type LinkModel,
type LinkModelSupplier, type LinkModelSupplier,
VariableOrigin, VariableOrigin,
@@ -852,6 +851,7 @@ export {
VariableSuggestionsScope, VariableSuggestionsScope,
OneClickMode, OneClickMode,
} from './types/dataLink'; } from './types/dataLink';
export { type LinkTarget } from './types/linkTarget';
export { export {
type Action, type Action,
type ActionModel, type ActionModel,
@@ -93,7 +93,6 @@ export { DataTransformerID } from '../transformations/transformers/ids';
export { mergeTransformer } from '../transformations/transformers/merge'; export { mergeTransformer } from '../transformations/transformers/merge';
export { getThemeById } from '../themes/registry'; export { getThemeById } from '../themes/registry';
export * as experimentalThemeDefinitions from '../themes/themeDefinitions';
export { GrafanaEdition } from '../types/config'; export { GrafanaEdition } from '../types/config';
export { SIPrefix } from '../valueFormats/symbolFormatters'; export { SIPrefix } from '../valueFormats/symbolFormatters';
+27 -1
View File
@@ -1,7 +1,18 @@
import { Registry, RegistryItem } from '../utils/Registry'; import { Registry, RegistryItem } from '../utils/Registry';
import { createTheme, NewThemeOptionsSchema } from './createTheme'; 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'; import { GrafanaTheme2 } from './types';
export interface ThemeRegistryItem extends RegistryItem { export interface ThemeRegistryItem extends RegistryItem {
@@ -9,6 +20,21 @@ export interface ThemeRegistryItem extends RegistryItem {
build: () => GrafanaTheme2; build: () => GrafanaTheme2;
} }
const extraThemes: { [key: string]: unknown } = {
aubergine,
debug,
desertbloom,
gildedgrove,
gloom,
mars,
matrix,
sapphiredusk,
synthwave,
tron,
victorian,
zen,
};
/** /**
* @internal * @internal
* Only for internal use, never use this from a plugin * 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; path: string;
version: string; version: string;
preload: boolean; preload: boolean;
/** @deprecated it will be removed in a future release */
angular: AngularMeta; angular: AngularMeta;
loadingStrategy: PluginLoadingStrategy; loadingStrategy: PluginLoadingStrategy;
dependencies: PluginDependencies; dependencies: PluginDependencies;
@@ -219,6 +220,7 @@ export interface GrafanaConfig {
snapshotEnabled: boolean; snapshotEnabled: boolean;
datasources: { [str: string]: DataSourceInstanceSettings }; datasources: { [str: string]: DataSourceInstanceSettings };
panels: { [key: string]: PanelPluginMeta }; panels: { [key: string]: PanelPluginMeta };
/** @deprecated it will be removed in a future release */
apps: Record<string, AppPluginConfig>; apps: Record<string, AppPluginConfig>;
auth: AuthSettings; auth: AuthSettings;
minRefreshInterval: string; minRefreshInterval: string;
+1 -2
View File
@@ -1,5 +1,6 @@
import { ScopedVars } from './ScopedVars'; import { ScopedVars } from './ScopedVars';
import { ExploreCorrelationHelperData, ExplorePanelsState } from './explore'; import { ExploreCorrelationHelperData, ExplorePanelsState } from './explore';
import { LinkTarget } from './linkTarget';
import { InterpolateFunction } from './panel'; import { InterpolateFunction } from './panel';
import { DataQuery } from './query'; import { DataQuery } from './query';
import { TimeRange } from './time'; import { TimeRange } from './time';
@@ -88,8 +89,6 @@ export interface InternalDataLink<T extends DataQuery = any> {
range?: TimeRange; range?: TimeRange;
} }
export type LinkTarget = '_blank' | '_self' | undefined;
/** /**
* Processed Link Model. The values are ready to use * Processed Link Model. The values are ready to use
*/ */
+9 -1
View File
@@ -356,7 +356,7 @@ export interface FeatureToggles {
*/ */
dashboardScene?: boolean; dashboardScene?: boolean;
/** /**
* Enables experimental new dashboard layouts * Enables new dashboard layouts
*/ */
dashboardNewLayouts?: boolean; dashboardNewLayouts?: boolean;
/** /**
@@ -531,6 +531,10 @@ export interface FeatureToggles {
*/ */
alertingListViewV2?: boolean; alertingListViewV2?: boolean;
/** /**
* Enables the new Alerting navigation structure with improved menu grouping
*/
alertingNavigationV2?: boolean;
/**
* Enables saved searches for alert rules list * Enables saved searches for alert rules list
*/ */
alertingSavedSearches?: boolean; alertingSavedSearches?: boolean;
@@ -1251,4 +1255,8 @@ export interface FeatureToggles {
* Enables profiles exemplars support in profiles drilldown * Enables profiles exemplars support in profiles drilldown
*/ */
profilesExemplars?: boolean; 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 { ComponentType } from 'react';
import { LinkTarget } from './dataLink';
import { IconName } from './icon'; import { IconName } from './icon';
import { LinkTarget } from './linkTarget';
export interface NavLinkDTO { export interface NavLinkDTO {
id?: string; id?: string;
+2
View File
@@ -11,6 +11,7 @@ import { DataFrame } from './dataFrame';
import { DataQueryError, DataQueryRequest, DataQueryTimings } from './datasource'; import { DataQueryError, DataQueryRequest, DataQueryTimings } from './datasource';
import { FieldConfigSource } from './fieldOverrides'; import { FieldConfigSource } from './fieldOverrides';
import { IconName } from './icon'; import { IconName } from './icon';
import { LinkTarget } from './linkTarget';
import { OptionEditorConfig } from './options'; import { OptionEditorConfig } from './options';
import { PluginMeta } from './plugin'; import { PluginMeta } from './plugin';
import { AbsoluteTimeRange, TimeRange, TimeZone } from './time'; import { AbsoluteTimeRange, TimeRange, TimeZone } from './time';
@@ -191,6 +192,7 @@ export interface PanelMenuItem {
onClick?: (event: React.MouseEvent) => void; onClick?: (event: React.MouseEvent) => void;
shortcut?: string; shortcut?: string;
href?: string; href?: string;
target?: LinkTarget;
subMenu?: PanelMenuItem[]; subMenu?: PanelMenuItem[];
} }
@@ -53,6 +53,7 @@ export interface PluginError {
pluginType?: PluginType; pluginType?: PluginType;
} }
/** @deprecated it will be removed in a future release */
export interface AngularMeta { export interface AngularMeta {
detected: boolean; detected: boolean;
hideDeprecation: boolean; hideDeprecation: boolean;
+1 -1
View File
@@ -9,4 +9,4 @@
* and be subject to the standard policies * 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, "emitDeclarationOnly": true,
"isolatedModules": true, "isolatedModules": true,
"rootDirs": ["."], "rootDirs": ["."],
"moduleResolution": "bundler" "moduleResolution": "bundler",
"resolveJsonModule": true
}, },
"exclude": ["dist/**/*"], "exclude": ["dist/**/*"],
"include": [ "include": [
+2
View File
@@ -58,6 +58,7 @@
"d3": "^7.8.5", "d3": "^7.8.5",
"lodash": "4.17.21", "lodash": "4.17.21",
"react": "18.3.1", "react": "18.3.1",
"react-table": "^7.8.0",
"react-use": "17.6.0", "react-use": "17.6.0",
"react-virtualized-auto-sizer": "1.0.26", "react-virtualized-auto-sizer": "1.0.26",
"tinycolor2": "1.6.0", "tinycolor2": "1.6.0",
@@ -81,6 +82,7 @@
"@types/lodash": "4.17.20", "@types/lodash": "4.17.20",
"@types/node": "24.10.1", "@types/node": "24.10.1",
"@types/react": "18.3.18", "@types/react": "18.3.18",
"@types/react-table": "^7.7.20",
"@types/react-virtualized-auto-sizer": "1.0.8", "@types/react-virtualized-auto-sizer": "1.0.8",
"@types/tinycolor2": "1.4.6", "@types/tinycolor2": "1.4.6",
"babel-jest": "29.7.0", "babel-jest": "29.7.0",
@@ -0,0 +1,49 @@
import { Meta, StoryObj } from '@storybook/react';
import { createDataFrame } from '@grafana/data';
import { FlameGraphDataContainer } from '../FlameGraph/dataTransform';
import { data } from '../FlameGraph/testData/dataNestedSet';
import { ColorScheme } from '../types';
import FlameGraphCallTreeContainer from './FlameGraphCallTreeContainer';
const meta: Meta<typeof FlameGraphCallTreeContainer> = {
title: 'CallTree',
component: FlameGraphCallTreeContainer,
args: {
colorScheme: ColorScheme.PackageBased,
search: '',
},
decorators: [
(Story) => (
<div style={{ width: '100%', height: '1000px' }}>
<Story />
</div>
),
],
};
export default meta;
export const Basic: StoryObj<typeof meta> = {
render: (args) => {
const dataContainer = new FlameGraphDataContainer(createDataFrame(data), { collapsing: true });
return (
<FlameGraphCallTreeContainer
{...args}
data={dataContainer}
onSymbolClick={(symbol) => {
console.log('Symbol clicked:', symbol);
}}
onSandwich={(item) => {
console.log('Sandwich:', item);
}}
onSearch={(symbol) => {
console.log('Search:', symbol);
}}
/>
);
},
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,580 @@
import { FlameGraphDataContainer, LevelItem } from '../FlameGraph/dataTransform';
export interface CallTreeNode {
id: string; // Path-based ID (e.g., "0.2.1")
label: string; // Function name
self: number; // Self value
total: number; // Total value
selfPercent: number; // Self as % of root
totalPercent: number; // Total as % of root
depth: number; // Indentation level
parentId?: string; // Parent node ID
hasChildren: boolean; // Has expandable children
childCount: number; // Number of direct children
subtreeSize: number; // Total number of nodes in subtree (excluding self)
levelItem: LevelItem; // Reference to original data
subRows?: CallTreeNode[]; // Child nodes for react-table useExpanded
isLastChild: boolean; // Whether this is the last child of its parent
// For diff profiles
selfRight?: number;
totalRight?: number;
selfPercentRight?: number;
totalPercentRight?: number;
diffPercent?: number;
}
/**
* Build hierarchical call tree node from the LevelItem structure.
* Each node gets a unique ID based on its path in the tree.
* Children are stored in the subRows property for react-table useExpanded.
*/
export function buildCallTreeNode(
data: FlameGraphDataContainer,
rootItem: LevelItem,
rootTotal: number,
parentId?: string,
parentDepth: number = -1,
childIndex: number = 0
): CallTreeNode {
const nodeId = parentId ? `${parentId}.${childIndex}` : `${childIndex}`;
const depth = parentDepth + 1;
// Get values for current item
const itemIndex = rootItem.itemIndexes[0];
const label = data.getLabel(itemIndex);
const self = data.getSelf(itemIndex);
const total = data.getValue(itemIndex);
const selfPercent = rootTotal > 0 ? (self / rootTotal) * 100 : 0;
const totalPercent = rootTotal > 0 ? (total / rootTotal) * 100 : 0;
// For diff profiles
let selfRight: number | undefined;
let totalRight: number | undefined;
let selfPercentRight: number | undefined;
let totalPercentRight: number | undefined;
let diffPercent: number | undefined;
if (data.isDiffFlamegraph()) {
selfRight = data.getSelfRight(itemIndex);
totalRight = data.getValueRight(itemIndex);
selfPercentRight = rootTotal > 0 ? (selfRight / rootTotal) * 100 : 0;
totalPercentRight = rootTotal > 0 ? (totalRight / rootTotal) * 100 : 0;
// Calculate diff percentage (change from baseline to comparison)
if (self > 0) {
diffPercent = ((selfRight - self) / self) * 100;
} else if (selfRight > 0) {
diffPercent = Infinity; // New in comparison
} else {
diffPercent = 0;
}
}
// Recursively build children
const subRows =
rootItem.children.length > 0
? rootItem.children.map((child, index) => {
const childNode = buildCallTreeNode(data, child, rootTotal, nodeId, depth, index);
// Mark if this is the last child
childNode.isLastChild = index === rootItem.children.length - 1;
return childNode;
})
: undefined;
// Calculate child count and subtree size
const childCount = rootItem.children.length;
const subtreeSize = subRows ? subRows.reduce((sum, child) => sum + child.subtreeSize + 1, 0) : 0;
const node: CallTreeNode = {
id: nodeId,
label,
self,
total,
selfPercent,
totalPercent,
depth,
parentId,
hasChildren: rootItem.children.length > 0,
childCount,
subtreeSize,
levelItem: rootItem,
subRows,
isLastChild: false, // Will be set by parent
selfRight,
totalRight,
selfPercentRight,
totalPercentRight,
diffPercent,
};
return node;
}
/**
* Build all call tree nodes from the root level items.
* Returns an array of root nodes, each with their children in subRows.
* This handles cases where there might be multiple root items.
*/
export function buildAllCallTreeNodes(data: FlameGraphDataContainer): CallTreeNode[] {
const levels = data.getLevels();
const rootTotal = levels.length > 0 ? levels[0][0].value : 0;
// Build hierarchical structure for each root item
const rootNodes = levels[0].map((rootItem, index) => buildCallTreeNode(data, rootItem, rootTotal, undefined, -1, index));
return rootNodes;
}
/**
* Build call tree nodes from an array of levels (from mergeParentSubtrees).
* This is used for the callers view where we get LevelItem[][] from getSandwichLevels.
* Unlike buildCallTreeNode which recursively processes children, this function
* processes pre-organized levels and builds the hierarchy from them.
*/
export function buildCallTreeFromLevels(
levels: LevelItem[][],
data: FlameGraphDataContainer,
rootTotal: number
): CallTreeNode[] {
if (levels.length === 0 || levels[0].length === 0) {
return [];
}
// Map to track LevelItem -> CallTreeNode for building relationships
const levelItemToNode = new Map<LevelItem, CallTreeNode>();
// Process each level and build nodes
levels.forEach((level, levelIndex) => {
level.forEach((levelItem, itemIndex) => {
// Get values from data
const itemDataIndex = levelItem.itemIndexes[0];
const label = data.getLabel(itemDataIndex);
const self = data.getSelf(itemDataIndex);
const total = data.getValue(itemDataIndex);
const selfPercent = rootTotal > 0 ? (self / rootTotal) * 100 : 0;
const totalPercent = rootTotal > 0 ? (total / rootTotal) * 100 : 0;
// For diff profiles
let selfRight: number | undefined;
let totalRight: number | undefined;
let selfPercentRight: number | undefined;
let totalPercentRight: number | undefined;
let diffPercent: number | undefined;
if (data.isDiffFlamegraph()) {
selfRight = data.getSelfRight(itemDataIndex);
totalRight = data.getValueRight(itemDataIndex);
selfPercentRight = rootTotal > 0 ? (selfRight / rootTotal) * 100 : 0;
totalPercentRight = rootTotal > 0 ? (totalRight / rootTotal) * 100 : 0;
// Calculate diff percentage
if (self > 0) {
diffPercent = ((selfRight - self) / self) * 100;
} else if (selfRight > 0) {
diffPercent = Infinity;
} else {
diffPercent = 0;
}
}
// Determine parent (if exists)
let parentId: string | undefined;
let depth = levelIndex;
if (levelItem.parents && levelItem.parents.length > 0) {
const parentNode = levelItemToNode.get(levelItem.parents[0]);
if (parentNode) {
parentId = parentNode.id;
depth = parentNode.depth + 1;
}
}
// Generate path-based ID
// For root nodes, use index at level 0
// For child nodes, append index to parent ID
let nodeId: string;
if (!parentId) {
nodeId = `${itemIndex}`;
} else {
// Find index among siblings
const parent = levelItemToNode.get(levelItem.parents![0]);
const siblingIndex = parent?.subRows?.length || 0;
nodeId = `${parentId}.${siblingIndex}`;
}
// Create the node (without children initially)
const node: CallTreeNode = {
id: nodeId,
label,
self,
total,
selfPercent,
totalPercent,
depth,
parentId,
hasChildren: levelItem.children.length > 0,
childCount: levelItem.children.length,
subtreeSize: 0, // Will be calculated later
levelItem,
subRows: undefined,
isLastChild: false,
selfRight,
totalRight,
selfPercentRight,
totalPercentRight,
diffPercent,
};
// Add to map
levelItemToNode.set(levelItem, node);
// Add as child to parent
if (levelItem.parents && levelItem.parents.length > 0) {
const parentNode = levelItemToNode.get(levelItem.parents[0]);
if (parentNode) {
if (!parentNode.subRows) {
parentNode.subRows = [];
}
parentNode.subRows.push(node);
// Mark if this is the last child
const isLastChild = parentNode.subRows.length === parentNode.childCount;
node.isLastChild = isLastChild;
}
}
});
});
// Calculate subtreeSize for all nodes (bottom-up)
const calculateSubtreeSize = (node: CallTreeNode): number => {
if (!node.subRows || node.subRows.length === 0) {
node.subtreeSize = 0;
return 0;
}
const size = node.subRows.reduce((sum, child) => {
return sum + calculateSubtreeSize(child) + 1;
}, 0);
node.subtreeSize = size;
return size;
};
// Collect root nodes (level 0)
const rootNodes: CallTreeNode[] = [];
levels[0].forEach((levelItem) => {
const node = levelItemToNode.get(levelItem);
if (node) {
calculateSubtreeSize(node);
rootNodes.push(node);
}
});
return rootNodes;
}
/**
* Recursively collect expanded state for nodes up to a certain depth.
*/
function collectExpandedByDepth(
node: CallTreeNode,
levelsToExpand: number,
expanded: Record<string, boolean>
): void {
if (node.depth < levelsToExpand && node.hasChildren) {
expanded[node.id] = true;
}
if (node.subRows) {
node.subRows.forEach((child) => collectExpandedByDepth(child, levelsToExpand, expanded));
}
}
/**
* Get initial expanded state for the tree.
* Auto-expands first N levels.
*/
export function getInitialExpandedState(nodes: CallTreeNode[], levelsToExpand: number = 2): Record<string, boolean> {
const expanded: Record<string, boolean> = {};
nodes.forEach((node) => {
collectExpandedByDepth(node, levelsToExpand, expanded);
});
return expanded;
}
/**
* Restructure the callers tree to show a specific target node at the root.
* In the callers view, we want to show the target function with its callers as children.
* This function finds the target node and collects all paths that lead to it,
* then restructures them so the target is at the root.
*/
export function restructureCallersTree(
nodes: CallTreeNode[],
targetLabel: string
): { restructuredTree: CallTreeNode[]; targetNode: CallTreeNode | undefined } {
// First, find all paths from root to target node
const findPathsToTarget = (
nodes: CallTreeNode[],
targetLabel: string,
currentPath: CallTreeNode[] = []
): CallTreeNode[][] => {
const paths: CallTreeNode[][] = [];
for (const node of nodes) {
const newPath = [...currentPath, node];
if (node.label === targetLabel) {
// Found a path to the target
paths.push(newPath);
}
if (node.subRows && node.subRows.length > 0) {
// Continue searching in children
const childPaths = findPathsToTarget(node.subRows, targetLabel, newPath);
paths.push(...childPaths);
}
}
return paths;
};
const paths = findPathsToTarget(nodes, targetLabel);
if (paths.length === 0) {
// Target not found, return original tree
return { restructuredTree: nodes, targetNode: undefined };
}
// Get the target node from the first path (they should all have the same target node)
const targetNode = paths[0][paths[0].length - 1];
// Now restructure: create a new tree with target at root
// Each path to the target becomes a branch under the target
// For example, if we have: root -> A -> B -> target
// We want: target -> B -> A -> root (inverted)
const buildInvertedChildren = (paths: CallTreeNode[][]): CallTreeNode[] => {
// Group paths by their immediate caller (the node right before target)
const callerGroups = new Map<string, CallTreeNode[][]>();
for (const path of paths) {
if (path.length <= 1) {
// Path is just the target node itself, no callers
continue;
}
// The immediate caller is the node right before the target
const immediateCaller = path[path.length - 2];
const callerKey = immediateCaller.label;
if (!callerGroups.has(callerKey)) {
callerGroups.set(callerKey, []);
}
callerGroups.get(callerKey)!.push(path);
}
// Build nodes for each immediate caller
const callerNodes: CallTreeNode[] = [];
let callerIndex = 0;
for (const [, callerPaths] of callerGroups.entries()) {
// Get the immediate caller node from one of the paths
const immediateCallerNode = callerPaths[0][callerPaths[0].length - 2];
// For this caller, recursively build its callers (from the remaining path)
const remainingPaths = callerPaths.map((path) => path.slice(0, -1)); // Remove target from paths
const grandCallers = buildInvertedChildren(remainingPaths);
// Create a new node for this caller as a child of the target
const newCallerId = `0.${callerIndex}`;
const callerNode: CallTreeNode = {
...immediateCallerNode,
id: newCallerId,
depth: 1,
parentId: '0',
subRows: grandCallers.length > 0 ? grandCallers : undefined,
hasChildren: grandCallers.length > 0,
childCount: grandCallers.length,
isLastChild: callerIndex === callerGroups.size - 1,
};
// Update IDs of grandCallers
if (grandCallers.length > 0) {
grandCallers.forEach((grandCaller, idx) => {
updateNodeIds(grandCaller, newCallerId, idx);
});
}
callerNodes.push(callerNode);
callerIndex++;
}
return callerNodes;
};
// Helper to recursively update node IDs
const updateNodeIds = (node: CallTreeNode, parentId: string, index: number) => {
node.id = `${parentId}.${index}`;
node.parentId = parentId;
node.depth = parentId.split('.').length;
if (node.subRows) {
node.subRows.forEach((child, idx) => {
updateNodeIds(child, node.id, idx);
});
}
};
// Build the inverted children for the target
const invertedChildren = buildInvertedChildren(paths);
// Create the restructured target node as root
const restructuredTarget: CallTreeNode = {
...targetNode,
id: '0',
depth: 0,
parentId: undefined,
subRows: invertedChildren.length > 0 ? invertedChildren : undefined,
hasChildren: invertedChildren.length > 0,
childCount: invertedChildren.length,
subtreeSize: invertedChildren.reduce((sum, child) => sum + child.subtreeSize + 1, 0),
isLastChild: false,
};
return { restructuredTree: [restructuredTarget], targetNode: restructuredTarget };
}
/**
* Build a callers tree directly from sandwich levels data.
* This creates an inverted tree where the target function is at the root
* and its callers are shown as children.
*/
export function buildCallersTreeFromLevels(
levels: LevelItem[][],
targetLabel: string,
data: FlameGraphDataContainer,
rootTotal: number
): { tree: CallTreeNode[]; targetNode: CallTreeNode | undefined } {
if (levels.length === 0) {
return { tree: [], targetNode: undefined };
}
// Find the target node in the levels
let targetLevelIndex = -1;
let targetItem: LevelItem | undefined;
for (let i = 0; i < levels.length; i++) {
for (const item of levels[i]) {
const label = data.getLabel(item.itemIndexes[0]);
if (label === targetLabel) {
targetLevelIndex = i;
targetItem = item;
break;
}
}
if (targetItem) break;
}
if (!targetItem || targetLevelIndex === -1) {
// Target not found
return { tree: [], targetNode: undefined };
}
// Create a map from LevelItem to all items that reference it as a parent
const childrenMap = new Map<LevelItem, LevelItem[]>();
for (const level of levels) {
for (const item of level) {
if (item.parents) {
for (const parent of item.parents) {
if (!childrenMap.has(parent)) {
childrenMap.set(parent, []);
}
childrenMap.get(parent)!.push(item);
}
}
}
}
// Build the inverted tree recursively
// For callers view: the target is root, and parents become children
const buildInvertedNode = (
item: LevelItem,
nodeId: string,
depth: number,
parentId: string | undefined
): CallTreeNode => {
const itemIdx = item.itemIndexes[0];
const label = data.getLabel(itemIdx);
const self = data.getSelf(itemIdx);
const total = data.getValue(itemIdx);
const selfPercent = rootTotal > 0 ? (self / rootTotal) * 100 : 0;
const totalPercent = rootTotal > 0 ? (total / rootTotal) * 100 : 0;
// For diff profiles
let selfRight: number | undefined;
let totalRight: number | undefined;
let selfPercentRight: number | undefined;
let totalPercentRight: number | undefined;
let diffPercent: number | undefined;
if (data.isDiffFlamegraph()) {
selfRight = data.getSelfRight(itemIdx);
totalRight = data.getValueRight(itemIdx);
selfPercentRight = rootTotal > 0 ? (selfRight / rootTotal) * 100 : 0;
totalPercentRight = rootTotal > 0 ? (totalRight / rootTotal) * 100 : 0;
if (self > 0) {
diffPercent = ((selfRight - self) / self) * 100;
} else if (selfRight > 0) {
diffPercent = Infinity;
} else {
diffPercent = 0;
}
}
// In the inverted tree, parents become children (callers)
const callers = item.parents || [];
const subRows =
callers.length > 0
? callers.map((caller, idx) => {
const callerId = `${nodeId}.${idx}`;
const callerNode = buildInvertedNode(caller, callerId, depth + 1, nodeId);
callerNode.isLastChild = idx === callers.length - 1;
return callerNode;
})
: undefined;
const childCount = callers.length;
const subtreeSize = subRows ? subRows.reduce((sum, child) => sum + child.subtreeSize + 1, 0) : 0;
return {
id: nodeId,
label,
self,
total,
selfPercent,
totalPercent,
depth,
parentId,
hasChildren: callers.length > 0,
childCount,
subtreeSize,
levelItem: item,
subRows,
isLastChild: false,
selfRight,
totalRight,
selfPercentRight,
totalPercentRight,
diffPercent,
};
};
// Build tree with target as root
const targetNode = buildInvertedNode(targetItem, '0', 0, undefined);
return { tree: [targetNode], targetNode };
}
@@ -16,7 +16,7 @@ const meta: Meta<typeof FlameGraph> = {
rangeMax: 1, rangeMax: 1,
textAlign: 'left', textAlign: 'left',
colorScheme: ColorScheme.PackageBased, colorScheme: ColorScheme.PackageBased,
selectedView: SelectedView.Both, selectedView: SelectedView.Multi,
search: '', search: '',
}, },
}; };
@@ -43,10 +43,13 @@ describe('FlameGraph', () => {
setRangeMax={setRangeMax} setRangeMax={setRangeMax}
onItemFocused={onItemFocused} onItemFocused={onItemFocused}
textAlign={'left'} textAlign={'left'}
onTextAlignChange={jest.fn()}
onSandwich={onSandwich} onSandwich={onSandwich}
onFocusPillClick={onFocusPillClick} onFocusPillClick={onFocusPillClick}
onSandwichPillClick={onSandwichPillClick} onSandwichPillClick={onSandwichPillClick}
colorScheme={ColorScheme.ValueBased} colorScheme={ColorScheme.ValueBased}
onColorSchemeChange={jest.fn()}
isDiffMode={false}
selectedView={SelectedView.FlameGraph} selectedView={SelectedView.FlameGraph}
search={''} search={''}
collapsedMap={container.getCollapsedMap()} collapsedMap={container.getCollapsedMap()}
@@ -19,8 +19,10 @@
import { css, cx } from '@emotion/css'; import { css, cx } from '@emotion/css';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Icon } from '@grafana/ui'; import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Button, ButtonGroup, Dropdown, Icon, Menu, RadioButtonGroup, useStyles2 } from '@grafana/ui';
import { byPackageGradient, byValueGradient, diffColorBlindGradient, diffDefaultGradient } from './colors';
import { PIXELS_PER_LEVEL } from '../constants'; import { PIXELS_PER_LEVEL } from '../constants';
import { ClickedItemData, ColorScheme, ColorSchemeDiff, SelectedView, TextAlign } from '../types'; import { ClickedItemData, ColorScheme, ColorSchemeDiff, SelectedView, TextAlign } from '../types';
@@ -39,11 +41,14 @@ type Props = {
onItemFocused: (data: ClickedItemData) => void; onItemFocused: (data: ClickedItemData) => void;
focusedItemData?: ClickedItemData; focusedItemData?: ClickedItemData;
textAlign: TextAlign; textAlign: TextAlign;
onTextAlignChange: (align: TextAlign) => void;
sandwichItem?: string; sandwichItem?: string;
onSandwich: (label: string) => void; onSandwich: (label: string) => void;
onFocusPillClick: () => void; onFocusPillClick: () => void;
onSandwichPillClick: () => void; onSandwichPillClick: () => void;
colorScheme: ColorScheme | ColorSchemeDiff; colorScheme: ColorScheme | ColorSchemeDiff;
onColorSchemeChange: (colorScheme: ColorScheme | ColorSchemeDiff) => void;
isDiffMode: boolean;
showFlameGraphOnly?: boolean; showFlameGraphOnly?: boolean;
getExtraContextMenuButtons?: GetExtraContextMenuButtonsFunction; getExtraContextMenuButtons?: GetExtraContextMenuButtonsFunction;
collapsing?: boolean; collapsing?: boolean;
@@ -63,11 +68,14 @@ const FlameGraph = ({
onItemFocused, onItemFocused,
focusedItemData, focusedItemData,
textAlign, textAlign,
onTextAlignChange,
onSandwich, onSandwich,
sandwichItem, sandwichItem,
onFocusPillClick, onFocusPillClick,
onSandwichPillClick, onSandwichPillClick,
colorScheme, colorScheme,
onColorSchemeChange,
isDiffMode,
showFlameGraphOnly, showFlameGraphOnly,
getExtraContextMenuButtons, getExtraContextMenuButtons,
collapsing, collapsing,
@@ -76,7 +84,7 @@ const FlameGraph = ({
collapsedMap, collapsedMap,
setCollapsedMap, setCollapsedMap,
}: Props) => { }: Props) => {
const styles = getStyles(); const styles = useStyles2(getStyles);
const [levels, setLevels] = useState<LevelItem[][]>(); const [levels, setLevels] = useState<LevelItem[][]>();
const [levelsCallers, setLevelsCallers] = useState<LevelItem[][]>(); const [levelsCallers, setLevelsCallers] = useState<LevelItem[][]>();
@@ -175,28 +183,183 @@ const FlameGraph = ({
); );
} }
const alignOptions: Array<SelectableValue<TextAlign>> = [
{ value: 'left', description: 'Align text left', icon: 'align-left' },
{ value: 'right', description: 'Align text right', icon: 'align-right' },
];
return ( return (
<div className={styles.graph}> <div className={styles.graph}>
<FlameGraphMetadata <div className={styles.toolbar}>
data={data} <FlameGraphMetadata
focusedItem={focusedItemData} data={data}
sandwichedLabel={sandwichItem} focusedItem={focusedItemData}
totalTicks={totalViewTicks} sandwichedLabel={sandwichItem}
onFocusPillClick={onFocusPillClick} totalTicks={totalViewTicks}
onSandwichPillClick={onSandwichPillClick} onFocusPillClick={onFocusPillClick}
/> onSandwichPillClick={onSandwichPillClick}
/>
<div className={styles.controls}>
<ColorSchemeButton value={colorScheme} onChange={onColorSchemeChange} isDiffMode={isDiffMode} />
<ButtonGroup className={styles.buttonSpacing}>
<Button
variant={'secondary'}
fill={'outline'}
size={'sm'}
tooltip={'Expand all groups'}
onClick={() => {
setCollapsedMap(collapsedMap.setAllCollapsedStatus(false));
}}
aria-label={'Expand all groups'}
icon={'angle-double-down'}
/>
<Button
variant={'secondary'}
fill={'outline'}
size={'sm'}
tooltip={'Collapse all groups'}
onClick={() => {
setCollapsedMap(collapsedMap.setAllCollapsedStatus(true));
}}
aria-label={'Collapse all groups'}
icon={'angle-double-up'}
/>
</ButtonGroup>
<RadioButtonGroup<TextAlign>
size="sm"
options={alignOptions}
value={textAlign}
onChange={onTextAlignChange}
/>
</div>
</div>
{canvas} {canvas}
</div> </div>
); );
}; };
const getStyles = () => ({ type ColorSchemeButtonProps = {
value: ColorScheme | ColorSchemeDiff;
onChange: (colorScheme: ColorScheme | ColorSchemeDiff) => void;
isDiffMode: boolean;
};
function ColorSchemeButton(props: ColorSchemeButtonProps) {
const styles = useStyles2(getStyles);
let menu = (
<Menu>
<Menu.Item label="By package name" onClick={() => props.onChange(ColorScheme.PackageBased)} />
<Menu.Item label="By value" onClick={() => props.onChange(ColorScheme.ValueBased)} />
</Menu>
);
// Show a bit different gradient as a way to indicate selected value
const colorDotStyle =
{
[ColorScheme.ValueBased]: styles.colorDotByValue,
[ColorScheme.PackageBased]: styles.colorDotByPackage,
[ColorSchemeDiff.DiffColorBlind]: styles.colorDotDiffColorBlind,
[ColorSchemeDiff.Default]: styles.colorDotDiffDefault,
}[props.value] || styles.colorDotByValue;
let contents = <span className={cx(styles.colorDot, colorDotStyle)} />;
if (props.isDiffMode) {
menu = (
<Menu>
<Menu.Item label="Default (green to red)" onClick={() => props.onChange(ColorSchemeDiff.Default)} />
<Menu.Item label="Color blind (blue to red)" onClick={() => props.onChange(ColorSchemeDiff.DiffColorBlind)} />
</Menu>
);
contents = (
<div className={cx(styles.colorDotDiff, colorDotStyle)}>
<div>-100% (removed)</div>
<div>0%</div>
<div>+100% (added)</div>
</div>
);
}
return (
<Dropdown overlay={menu}>
<Button
variant={'secondary'}
fill={'outline'}
size={'sm'}
tooltip={'Change color scheme'}
onClick={() => {}}
className={styles.buttonSpacing}
aria-label={'Change color scheme'}
>
{contents}
</Button>
</Dropdown>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
graph: css({ graph: css({
label: 'graph', label: 'graph',
overflow: 'auto', overflow: 'auto',
flexGrow: 1, flexGrow: 1,
flexBasis: '50%', flexBasis: '50%',
}), }),
toolbar: css({
label: 'toolbar',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: theme.spacing(1),
}),
controls: css({
label: 'controls',
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
}),
buttonSpacing: css({
label: 'buttonSpacing',
marginRight: theme.spacing(1),
}),
colorDot: css({
label: 'colorDot',
display: 'inline-block',
width: '10px',
height: '10px',
borderRadius: theme.shape.radius.circle,
}),
colorDotDiff: css({
label: 'colorDotDiff',
display: 'flex',
width: '200px',
height: '12px',
color: 'white',
fontSize: 9,
lineHeight: 1.3,
fontWeight: 300,
justifyContent: 'space-between',
padding: '0 2px',
// We have a specific sizing for this so probably makes sense to use hardcoded value here
// eslint-disable-next-line @grafana/no-border-radius-literal
borderRadius: '2px',
}),
colorDotByValue: css({
label: 'colorDotByValue',
background: byValueGradient,
}),
colorDotByPackage: css({
label: 'colorDotByPackage',
background: byPackageGradient,
}),
colorDotDiffDefault: css({
label: 'colorDotDiffDefault',
background: diffDefaultGradient,
}),
colorDotDiffColorBlind: css({
label: 'colorDotDiffColorBlind',
background: diffColorBlindGradient,
}),
sandwichCanvasWrapper: css({ sandwichCanvasWrapper: css({
label: 'sandwichCanvasWrapper', label: 'sandwichCanvasWrapper',
display: 'flex', display: 'flex',
File diff suppressed because it is too large Load Diff
@@ -1,19 +1,18 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import uFuzzy from '@leeoniya/ufuzzy'; import uFuzzy from '@leeoniya/ufuzzy';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import * as React from 'react'; import * as React from 'react';
import { useMeasure } from 'react-use'; import { useMeasure } from 'react-use';
import { DataFrame, GrafanaTheme2, escapeStringForRegex } from '@grafana/data'; import { DataFrame, GrafanaTheme2 } from '@grafana/data';
import { ThemeContext } from '@grafana/ui'; import { ThemeContext } from '@grafana/ui';
import FlameGraph from './FlameGraph/FlameGraph'; import { FlameGraphDataContainer } from './FlameGraph/dataTransform';
import { GetExtraContextMenuButtonsFunction } from './FlameGraph/FlameGraphContextMenu'; import { GetExtraContextMenuButtonsFunction } from './FlameGraph/FlameGraphContextMenu';
import { CollapsedMap, FlameGraphDataContainer } from './FlameGraph/dataTransform';
import FlameGraphHeader from './FlameGraphHeader'; import FlameGraphHeader from './FlameGraphHeader';
import FlameGraphTopTableContainer from './TopTable/FlameGraphTopTableContainer'; import FlameGraphPane from './FlameGraphPane';
import { MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH } from './constants'; import { MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH } from './constants';
import { ClickedItemData, ColorScheme, ColorSchemeDiff, SelectedView, TextAlign } from './types'; import { PaneView, SelectedView, ViewMode } from './types';
import { getAssistantContextFromDataFrame } from './utils'; import { getAssistantContextFromDataFrame } from './utils';
const ufuzzy = new uFuzzy(); const ufuzzy = new uFuzzy();
@@ -104,17 +103,18 @@ const FlameGraphContainer = ({
getExtraContextMenuButtons, getExtraContextMenuButtons,
showAnalyzeWithAssistant = true, showAnalyzeWithAssistant = true,
}: Props) => { }: Props) => {
const [focusedItemData, setFocusedItemData] = useState<ClickedItemData>(); // Shared state across all views
const [rangeMin, setRangeMin] = useState(0);
const [rangeMax, setRangeMax] = useState(1);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [selectedView, setSelectedView] = useState(SelectedView.Both); const [selectedView, setSelectedView] = useState(SelectedView.Multi);
const [viewMode, setViewMode] = useState<ViewMode>(ViewMode.Split);
const [leftPaneView, setLeftPaneView] = useState<PaneView>(PaneView.TopTable);
const [rightPaneView, setRightPaneView] = useState<PaneView>(PaneView.FlameGraph);
const [singleView, setSingleView] = useState<PaneView>(PaneView.FlameGraph);
const [sizeRef, { width: containerWidth }] = useMeasure<HTMLDivElement>(); const [sizeRef, { width: containerWidth }] = useMeasure<HTMLDivElement>();
const [textAlign, setTextAlign] = useState<TextAlign>('left'); // Used to trigger reset of pane-specific state (focus, sandwich) when parent reset button is clicked
// This is a label of the item because in sandwich view we group all items by label and present a merged graph const [resetKey, setResetKey] = useState(0);
const [sandwichItem, setSandwichItem] = useState<string>(); // Track if we temporarily switched away from Both view due to narrow width
const [collapsedMap, setCollapsedMap] = useState(new CollapsedMap()); const [viewBeforeNarrow, setViewBeforeNarrow] = useState<SelectedView | null>(null);
const theme = useMemo(() => getTheme(), [getTheme]); const theme = useMemo(() => getTheme(), [getTheme]);
const dataContainer = useMemo((): FlameGraphDataContainer | undefined => { const dataContainer = useMemo((): FlameGraphDataContainer | undefined => {
@@ -122,157 +122,220 @@ const FlameGraphContainer = ({
return; return;
} }
const container = new FlameGraphDataContainer(data, { collapsing: !disableCollapsing }, theme); return new FlameGraphDataContainer(data, { collapsing: !disableCollapsing }, theme);
setCollapsedMap(container.getCollapsedMap());
return container;
}, [data, theme, disableCollapsing]); }, [data, theme, disableCollapsing]);
const [colorScheme, setColorScheme] = useColorScheme(dataContainer);
const styles = getStyles(theme); const styles = getStyles(theme);
const matchedLabels = useLabelSearch(search, dataContainer); const matchedLabels = useLabelSearch(search, dataContainer);
// If user resizes window with both as the selected view // Handle responsive layout: switch away from Both view when narrow, restore when wide again
useEffect(() => { useEffect(() => {
if ( if (containerWidth === 0) {
containerWidth > 0 &&
containerWidth < MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH &&
selectedView === SelectedView.Both &&
!vertical
) {
setSelectedView(SelectedView.FlameGraph);
}
}, [selectedView, setSelectedView, containerWidth, vertical]);
const resetFocus = useCallback(() => {
setFocusedItemData(undefined);
setRangeMin(0);
setRangeMax(1);
}, [setFocusedItemData, setRangeMax, setRangeMin]);
const resetSandwich = useCallback(() => {
setSandwichItem(undefined);
}, [setSandwichItem]);
useEffect(() => {
if (!keepFocusOnDataChange) {
resetFocus();
resetSandwich();
return; return;
} }
if (dataContainer && focusedItemData) { const isNarrow = containerWidth < MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH && !vertical;
const item = dataContainer.getNodesWithLabel(focusedItemData.label)?.[0];
if (item) { if (isNarrow && selectedView === SelectedView.Multi) {
setFocusedItemData({ ...focusedItemData, item }); // Going narrow: save current view and switch to FlameGraph
setViewBeforeNarrow(SelectedView.Multi);
const levels = dataContainer.getLevels(); setSelectedView(SelectedView.FlameGraph);
const totalViewTicks = levels.length ? levels[0][0].value : 0; } else if (!isNarrow && viewBeforeNarrow !== null) {
setRangeMin(item.start / totalViewTicks); // Going wide again: restore the previous view
setRangeMax((item.start + item.value) / totalViewTicks); setSelectedView(viewBeforeNarrow);
} else { setViewBeforeNarrow(null);
setFocusedItemData({
...focusedItemData,
item: {
start: 0,
value: 0,
itemIndexes: [],
children: [],
level: 0,
},
});
setRangeMin(0);
setRangeMax(1);
}
} }
}, [dataContainer, keepFocusOnDataChange]); // eslint-disable-line react-hooks/exhaustive-deps }, [containerWidth, vertical, selectedView, viewBeforeNarrow]);
const onSymbolClick = useCallback(
(symbol: string) => {
const anchored = `^${escapeStringForRegex(symbol)}$`;
if (search === anchored) {
setSearch('');
} else {
onTableSymbolClick?.(symbol);
setSearch(anchored);
resetFocus();
}
},
[setSearch, resetFocus, onTableSymbolClick, search]
);
if (!dataContainer) { if (!dataContainer) {
return null; return null;
} }
const flameGraph = (
<FlameGraph
data={dataContainer}
rangeMin={rangeMin}
rangeMax={rangeMax}
matchedLabels={matchedLabels}
setRangeMin={setRangeMin}
setRangeMax={setRangeMax}
onItemFocused={(data) => setFocusedItemData(data)}
focusedItemData={focusedItemData}
textAlign={textAlign}
sandwichItem={sandwichItem}
onSandwich={(label: string) => {
resetFocus();
setSandwichItem(label);
}}
onFocusPillClick={resetFocus}
onSandwichPillClick={resetSandwich}
colorScheme={colorScheme}
showFlameGraphOnly={showFlameGraphOnly}
collapsing={!disableCollapsing}
getExtraContextMenuButtons={getExtraContextMenuButtons}
selectedView={selectedView}
search={search}
collapsedMap={collapsedMap}
setCollapsedMap={setCollapsedMap}
/>
);
const table = (
<FlameGraphTopTableContainer
data={dataContainer}
onSymbolClick={onSymbolClick}
search={search}
matchedLabels={matchedLabels}
sandwichItem={sandwichItem}
onSandwich={setSandwichItem}
onSearch={(str) => {
if (!str) {
setSearch('');
return;
}
setSearch(`^${escapeStringForRegex(str)}$`);
}}
onTableSort={onTableSort}
colorScheme={colorScheme}
/>
);
let body; let body;
if (showFlameGraphOnly || selectedView === SelectedView.FlameGraph) { if (showFlameGraphOnly || selectedView === SelectedView.FlameGraph) {
body = flameGraph; body = (
<FlameGraphPane
paneView={PaneView.FlameGraph}
dataContainer={dataContainer}
search={search}
matchedLabels={matchedLabels}
onTableSymbolClick={onTableSymbolClick}
onTextAlignSelected={onTextAlignSelected}
onTableSort={onTableSort}
showFlameGraphOnly={showFlameGraphOnly}
disableCollapsing={disableCollapsing}
getExtraContextMenuButtons={getExtraContextMenuButtons}
selectedView={selectedView}
viewMode={viewMode}
theme={theme}
setSearch={setSearch}
resetKey={resetKey}
keepFocusOnDataChange={keepFocusOnDataChange}
/>
);
} else if (selectedView === SelectedView.TopTable) { } else if (selectedView === SelectedView.TopTable) {
body = <div className={styles.tableContainer}>{table}</div>; body = (
} else if (selectedView === SelectedView.Both) { <FlameGraphPane
if (vertical) { paneView={PaneView.TopTable}
body = ( dataContainer={dataContainer}
<div> search={search}
<div className={styles.verticalGraphContainer}>{flameGraph}</div> matchedLabels={matchedLabels}
<div className={styles.verticalTableContainer}>{table}</div> onTableSymbolClick={onTableSymbolClick}
</div> onTextAlignSelected={onTextAlignSelected}
); onTableSort={onTableSort}
showFlameGraphOnly={showFlameGraphOnly}
disableCollapsing={disableCollapsing}
getExtraContextMenuButtons={getExtraContextMenuButtons}
selectedView={selectedView}
viewMode={viewMode}
theme={theme}
setSearch={setSearch}
resetKey={resetKey}
keepFocusOnDataChange={keepFocusOnDataChange}
/>
);
} else if (selectedView === SelectedView.CallTree) {
body = (
<FlameGraphPane
paneView={PaneView.CallTree}
dataContainer={dataContainer}
search={search}
matchedLabels={matchedLabels}
onTableSymbolClick={onTableSymbolClick}
onTextAlignSelected={onTextAlignSelected}
onTableSort={onTableSort}
showFlameGraphOnly={showFlameGraphOnly}
disableCollapsing={disableCollapsing}
getExtraContextMenuButtons={getExtraContextMenuButtons}
selectedView={selectedView}
viewMode={viewMode}
theme={theme}
setSearch={setSearch}
resetKey={resetKey}
keepFocusOnDataChange={keepFocusOnDataChange}
/>
);
} else if (selectedView === SelectedView.Multi) {
// New view model: support split view with independent pane selections
if (viewMode === ViewMode.Split) {
if (vertical) {
body = (
<div>
<div className={styles.verticalPaneContainer}>
<FlameGraphPane
key="left-pane"
paneView={leftPaneView}
dataContainer={dataContainer}
search={search}
matchedLabels={matchedLabels}
onTableSymbolClick={onTableSymbolClick}
onTextAlignSelected={onTextAlignSelected}
onTableSort={onTableSort}
showFlameGraphOnly={showFlameGraphOnly}
disableCollapsing={disableCollapsing}
getExtraContextMenuButtons={getExtraContextMenuButtons}
selectedView={selectedView}
viewMode={viewMode}
theme={theme}
setSearch={setSearch}
resetKey={resetKey}
keepFocusOnDataChange={keepFocusOnDataChange}
/>
</div>
<div className={styles.verticalPaneContainer}>
<FlameGraphPane
key="right-pane"
paneView={rightPaneView}
dataContainer={dataContainer}
search={search}
matchedLabels={matchedLabels}
onTableSymbolClick={onTableSymbolClick}
onTextAlignSelected={onTextAlignSelected}
onTableSort={onTableSort}
showFlameGraphOnly={showFlameGraphOnly}
disableCollapsing={disableCollapsing}
getExtraContextMenuButtons={getExtraContextMenuButtons}
selectedView={selectedView}
viewMode={viewMode}
theme={theme}
setSearch={setSearch}
resetKey={resetKey}
keepFocusOnDataChange={keepFocusOnDataChange}
/>
</div>
</div>
);
} else {
body = (
<div className={styles.horizontalContainer}>
<div className={styles.horizontalPaneContainer}>
<FlameGraphPane
key="left-pane"
paneView={leftPaneView}
dataContainer={dataContainer}
search={search}
matchedLabels={matchedLabels}
onTableSymbolClick={onTableSymbolClick}
onTextAlignSelected={onTextAlignSelected}
onTableSort={onTableSort}
showFlameGraphOnly={showFlameGraphOnly}
disableCollapsing={disableCollapsing}
getExtraContextMenuButtons={getExtraContextMenuButtons}
selectedView={selectedView}
viewMode={viewMode}
theme={theme}
setSearch={setSearch}
resetKey={resetKey}
keepFocusOnDataChange={keepFocusOnDataChange}
/>
</div>
<div className={styles.horizontalPaneContainer}>
<FlameGraphPane
key="right-pane"
paneView={rightPaneView}
dataContainer={dataContainer}
search={search}
matchedLabels={matchedLabels}
onTableSymbolClick={onTableSymbolClick}
onTextAlignSelected={onTextAlignSelected}
onTableSort={onTableSort}
showFlameGraphOnly={showFlameGraphOnly}
disableCollapsing={disableCollapsing}
getExtraContextMenuButtons={getExtraContextMenuButtons}
selectedView={selectedView}
viewMode={viewMode}
theme={theme}
setSearch={setSearch}
resetKey={resetKey}
keepFocusOnDataChange={keepFocusOnDataChange}
/>
</div>
</div>
);
}
} else { } else {
// Single view mode
body = ( body = (
<div className={styles.horizontalContainer}> <div className={styles.singlePaneContainer}>
<div className={styles.horizontalTableContainer}>{table}</div> <FlameGraphPane
<div className={styles.horizontalGraphContainer}>{flameGraph}</div> key={`single-${singleView}`}
paneView={singleView}
dataContainer={dataContainer}
search={search}
matchedLabels={matchedLabels}
onTableSymbolClick={onTableSymbolClick}
onTextAlignSelected={onTextAlignSelected}
onTableSort={onTableSort}
showFlameGraphOnly={showFlameGraphOnly}
disableCollapsing={disableCollapsing}
getExtraContextMenuButtons={getExtraContextMenuButtons}
selectedView={selectedView}
viewMode={viewMode}
theme={theme}
setSearch={setSearch}
resetKey={resetKey}
keepFocusOnDataChange={keepFocusOnDataChange}
/>
</div> </div>
); );
} }
@@ -292,25 +355,24 @@ const FlameGraphContainer = ({
setSelectedView(view); setSelectedView(view);
onViewSelected?.(view); onViewSelected?.(view);
}} }}
viewMode={viewMode}
setViewMode={setViewMode}
leftPaneView={leftPaneView}
setLeftPaneView={setLeftPaneView}
rightPaneView={rightPaneView}
setRightPaneView={setRightPaneView}
singleView={singleView}
setSingleView={setSingleView}
containerWidth={containerWidth} containerWidth={containerWidth}
onReset={() => { onReset={() => {
resetFocus(); // Reset search and pane states when user clicks reset button
resetSandwich(); setSearch('');
setResetKey((k) => k + 1);
}} }}
textAlign={textAlign} showResetButton={Boolean(search)}
onTextAlignChange={(align) => {
setTextAlign(align);
onTextAlignSelected?.(align);
}}
showResetButton={Boolean(focusedItemData || sandwichItem)}
colorScheme={colorScheme}
onColorSchemeChange={setColorScheme}
stickyHeader={Boolean(stickyHeader)} stickyHeader={Boolean(stickyHeader)}
extraHeaderElements={extraHeaderElements} extraHeaderElements={extraHeaderElements}
vertical={vertical} vertical={vertical}
isDiffMode={dataContainer.isDiffFlamegraph()}
setCollapsedMap={setCollapsedMap}
collapsedMap={collapsedMap}
assistantContext={data && showAnalyzeWithAssistant ? getAssistantContextFromDataFrame(data) : undefined} assistantContext={data && showAnalyzeWithAssistant ? getAssistantContextFromDataFrame(data) : undefined}
/> />
)} )}
@@ -321,18 +383,6 @@ const FlameGraphContainer = ({
); );
}; };
function useColorScheme(dataContainer: FlameGraphDataContainer | undefined) {
const defaultColorScheme = dataContainer?.isDiffFlamegraph() ? ColorSchemeDiff.Default : ColorScheme.PackageBased;
const [colorScheme, setColorScheme] = useState<ColorScheme | ColorSchemeDiff>(defaultColorScheme);
// This makes sure that if we change the data to/from diff profile we reset the color scheme.
useEffect(() => {
setColorScheme(defaultColorScheme);
}, [defaultColorScheme]);
return [colorScheme, setColorScheme] as const;
}
/** /**
* Based on the search string it does a fuzzy search over all the unique labels, so we can highlight them later. * Based on the search string it does a fuzzy search over all the unique labels, so we can highlight them later.
*/ */
@@ -420,12 +470,6 @@ function getStyles(theme: GrafanaTheme2) {
flexGrow: 1, flexGrow: 1,
}), }),
tableContainer: css({
// This is not ideal for dashboard panel where it creates a double scroll. In a panel it should be 100% but then
// in explore we need a specific height.
height: 800,
}),
horizontalContainer: css({ horizontalContainer: css({
label: 'horizontalContainer', label: 'horizontalContainer',
display: 'flex', display: 'flex',
@@ -435,20 +479,20 @@ function getStyles(theme: GrafanaTheme2) {
width: '100%', width: '100%',
}), }),
horizontalGraphContainer: css({ horizontalPaneContainer: css({
flexBasis: '50%', label: 'horizontalPaneContainer',
}),
horizontalTableContainer: css({
flexBasis: '50%', flexBasis: '50%',
maxHeight: 800, maxHeight: 800,
}), }),
verticalGraphContainer: css({ verticalPaneContainer: css({
label: 'verticalPaneContainer',
marginBottom: theme.spacing(1), marginBottom: theme.spacing(1),
height: 800,
}), }),
verticalTableContainer: css({ singlePaneContainer: css({
label: 'singlePaneContainer',
height: 800, height: 800,
}), }),
}; };
@@ -3,9 +3,8 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import * as React from 'react'; import * as React from 'react';
import { CollapsedMap } from './FlameGraph/dataTransform';
import FlameGraphHeader from './FlameGraphHeader'; import FlameGraphHeader from './FlameGraphHeader';
import { ColorScheme, SelectedView } from './types'; import { PaneView, SelectedView, ViewMode } from './types';
jest.mock('@grafana/assistant', () => ({ jest.mock('@grafana/assistant', () => ({
useAssistant: jest.fn().mockReturnValue({ useAssistant: jest.fn().mockReturnValue({
@@ -20,26 +19,30 @@ describe('FlameGraphHeader', () => {
function setup(props: Partial<React.ComponentProps<typeof FlameGraphHeader>> = {}) { function setup(props: Partial<React.ComponentProps<typeof FlameGraphHeader>> = {}) {
const setSearch = jest.fn(); const setSearch = jest.fn();
const setSelectedView = jest.fn(); const setSelectedView = jest.fn();
const setViewMode = jest.fn();
const setLeftPaneView = jest.fn();
const setRightPaneView = jest.fn();
const setSingleView = jest.fn();
const onReset = jest.fn(); const onReset = jest.fn();
const onSchemeChange = jest.fn();
const renderResult = render( const renderResult = render(
<FlameGraphHeader <FlameGraphHeader
search={''} search={''}
setSearch={setSearch} setSearch={setSearch}
selectedView={SelectedView.Both} selectedView={SelectedView.Multi}
setSelectedView={setSelectedView} setSelectedView={setSelectedView}
viewMode={ViewMode.Split}
setViewMode={setViewMode}
leftPaneView={PaneView.TopTable}
setLeftPaneView={setLeftPaneView}
rightPaneView={PaneView.FlameGraph}
setRightPaneView={setRightPaneView}
singleView={PaneView.FlameGraph}
setSingleView={setSingleView}
containerWidth={1600} containerWidth={1600}
onReset={onReset} onReset={onReset}
onTextAlignChange={jest.fn()}
textAlign={'left'}
showResetButton={true} showResetButton={true}
colorScheme={ColorScheme.ValueBased}
onColorSchemeChange={onSchemeChange}
stickyHeader={false} stickyHeader={false}
isDiffMode={false}
setCollapsedMap={() => {}}
collapsedMap={new CollapsedMap()}
{...props} {...props}
/> />
); );
@@ -50,7 +53,6 @@ describe('FlameGraphHeader', () => {
setSearch, setSearch,
setSelectedView, setSelectedView,
onReset, onReset,
onSchemeChange,
}, },
}; };
} }
@@ -70,27 +72,4 @@ describe('FlameGraphHeader', () => {
await userEvent.click(resetButton); await userEvent.click(resetButton);
expect(handlers.onReset).toHaveBeenCalledTimes(1); expect(handlers.onReset).toHaveBeenCalledTimes(1);
}); });
it('calls on color scheme change when clicked', async () => {
const { handlers } = setup();
const changeButton = screen.getByLabelText(/Change color scheme/);
expect(changeButton).toBeInTheDocument();
await userEvent.click(changeButton);
const byPackageButton = screen.getByText(/By package name/);
expect(byPackageButton).toBeInTheDocument();
await userEvent.click(byPackageButton);
expect(handlers.onSchemeChange).toHaveBeenCalledTimes(1);
});
it('shows diff color scheme switch when diff', async () => {
setup({ isDiffMode: true });
const changeButton = screen.getByLabelText(/Change color scheme/);
expect(changeButton).toBeInTheDocument();
await userEvent.click(changeButton);
expect(screen.getByText(/Default/)).toBeInTheDocument();
expect(screen.getByText(/Color blind/)).toBeInTheDocument();
});
}); });
@@ -5,30 +5,29 @@ import { useDebounce, usePrevious } from 'react-use';
import { ChatContextItem, OpenAssistantButton } from '@grafana/assistant'; import { ChatContextItem, OpenAssistantButton } from '@grafana/assistant';
import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Button, ButtonGroup, Dropdown, Input, Menu, RadioButtonGroup, useStyles2 } from '@grafana/ui'; import { Button, Input, RadioButtonGroup, useStyles2 } from '@grafana/ui';
import { byPackageGradient, byValueGradient, diffColorBlindGradient, diffDefaultGradient } from './FlameGraph/colors';
import { CollapsedMap } from './FlameGraph/dataTransform';
import { MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH } from './constants'; import { MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH } from './constants';
import { ColorScheme, ColorSchemeDiff, SelectedView, TextAlign } from './types'; import { PaneView, SelectedView, ViewMode } from './types';
type Props = { type Props = {
search: string; search: string;
setSearch: (search: string) => void; setSearch: (search: string) => void;
selectedView: SelectedView; selectedView: SelectedView;
setSelectedView: (view: SelectedView) => void; setSelectedView: (view: SelectedView) => void;
viewMode: ViewMode;
setViewMode: (mode: ViewMode) => void;
leftPaneView: PaneView;
setLeftPaneView: (view: PaneView) => void;
rightPaneView: PaneView;
setRightPaneView: (view: PaneView) => void;
singleView: PaneView;
setSingleView: (view: PaneView) => void;
containerWidth: number; containerWidth: number;
onReset: () => void; onReset: () => void;
textAlign: TextAlign;
onTextAlignChange: (align: TextAlign) => void;
showResetButton: boolean; showResetButton: boolean;
colorScheme: ColorScheme | ColorSchemeDiff;
onColorSchemeChange: (colorScheme: ColorScheme | ColorSchemeDiff) => void;
stickyHeader: boolean; stickyHeader: boolean;
vertical?: boolean; vertical?: boolean;
isDiffMode: boolean;
setCollapsedMap: (collapsedMap: CollapsedMap) => void;
collapsedMap: CollapsedMap;
extraHeaderElements?: React.ReactNode; extraHeaderElements?: React.ReactNode;
@@ -40,19 +39,20 @@ const FlameGraphHeader = ({
setSearch, setSearch,
selectedView, selectedView,
setSelectedView, setSelectedView,
viewMode,
setViewMode,
leftPaneView,
setLeftPaneView,
rightPaneView,
setRightPaneView,
singleView,
setSingleView,
containerWidth, containerWidth,
onReset, onReset,
textAlign,
onTextAlignChange,
showResetButton, showResetButton,
colorScheme,
onColorSchemeChange,
stickyHeader, stickyHeader,
extraHeaderElements, extraHeaderElements,
vertical, vertical,
isDiffMode,
setCollapsedMap,
collapsedMap,
assistantContext, assistantContext,
}: Props) => { }: Props) => {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
@@ -87,6 +87,25 @@ const FlameGraphHeader = ({
/> />
</div> </div>
{selectedView === SelectedView.Multi && viewMode === ViewMode.Split && (
<div className={styles.middleContainer}>
<RadioButtonGroup<PaneView>
size="sm"
options={paneViewOptions}
value={leftPaneView}
onChange={setLeftPaneView}
className={styles.buttonSpacing}
/>
<RadioButtonGroup<PaneView>
size="sm"
options={paneViewOptions}
value={rightPaneView}
onChange={setRightPaneView}
className={styles.buttonSpacing}
/>
</div>
)}
<div className={styles.rightContainer}> <div className={styles.rightContainer}>
{!!assistantContext?.length && ( {!!assistantContext?.length && (
<div className={styles.buttonSpacing}> <div className={styles.buttonSpacing}>
@@ -111,129 +130,63 @@ const FlameGraphHeader = ({
aria-label={'Reset focus and sandwich state'} aria-label={'Reset focus and sandwich state'}
/> />
)} )}
<ColorSchemeButton value={colorScheme} onChange={onColorSchemeChange} isDiffMode={isDiffMode} /> {selectedView === SelectedView.Multi ? (
<ButtonGroup className={styles.buttonSpacing}> <>
<Button {viewMode === ViewMode.Single && (
variant={'secondary'} <RadioButtonGroup<PaneView>
fill={'outline'} size="sm"
size={'sm'} options={paneViewOptions}
tooltip={'Expand all groups'} value={singleView}
onClick={() => { onChange={setSingleView}
setCollapsedMap(collapsedMap.setAllCollapsedStatus(false)); className={styles.buttonSpacing}
}} />
aria-label={'Expand all groups'} )}
icon={'angle-double-down'} <RadioButtonGroup<ViewMode>
disabled={selectedView === SelectedView.TopTable} size="sm"
options={viewModeOptions}
value={viewMode}
onChange={setViewMode}
className={styles.buttonSpacing}
/>
</>
) : (
<RadioButtonGroup<SelectedView>
size="sm"
options={getViewOptions(containerWidth, vertical)}
value={selectedView}
onChange={setSelectedView}
className={styles.buttonSpacing}
/> />
<Button )}
variant={'secondary'}
fill={'outline'}
size={'sm'}
tooltip={'Collapse all groups'}
onClick={() => {
setCollapsedMap(collapsedMap.setAllCollapsedStatus(true));
}}
aria-label={'Collapse all groups'}
icon={'angle-double-up'}
disabled={selectedView === SelectedView.TopTable}
/>
</ButtonGroup>
<RadioButtonGroup<TextAlign>
size="sm"
disabled={selectedView === SelectedView.TopTable}
options={alignOptions}
value={textAlign}
onChange={onTextAlignChange}
className={styles.buttonSpacing}
/>
<RadioButtonGroup<SelectedView>
size="sm"
options={getViewOptions(containerWidth, vertical)}
value={selectedView}
onChange={setSelectedView}
/>
{extraHeaderElements && <div className={styles.extraElements}>{extraHeaderElements}</div>} {extraHeaderElements && <div className={styles.extraElements}>{extraHeaderElements}</div>}
</div> </div>
</div> </div>
); );
}; };
type ColorSchemeButtonProps = { const viewModeOptions: Array<SelectableValue<ViewMode>> = [
value: ColorScheme | ColorSchemeDiff; { value: ViewMode.Single, label: 'Single', description: 'Single view' },
onChange: (colorScheme: ColorScheme | ColorSchemeDiff) => void; { value: ViewMode.Split, label: 'Split', description: 'Split view' },
isDiffMode: boolean; ];
};
function ColorSchemeButton(props: ColorSchemeButtonProps) {
// TODO: probably create separate getStyles
const styles = useStyles2(getStyles);
let menu = (
<Menu>
<Menu.Item label="By package name" onClick={() => props.onChange(ColorScheme.PackageBased)} />
<Menu.Item label="By value" onClick={() => props.onChange(ColorScheme.ValueBased)} />
</Menu>
);
// Show a bit different gradient as a way to indicate selected value const paneViewOptions: Array<SelectableValue<PaneView>> = [
const colorDotStyle = { value: PaneView.TopTable, label: 'Top Table' },
{ { value: PaneView.FlameGraph, label: 'Flame Graph' },
[ColorScheme.ValueBased]: styles.colorDotByValue, { value: PaneView.CallTree, label: 'Call Tree' },
[ColorScheme.PackageBased]: styles.colorDotByPackage,
[ColorSchemeDiff.DiffColorBlind]: styles.colorDotDiffColorBlind,
[ColorSchemeDiff.Default]: styles.colorDotDiffDefault,
}[props.value] || styles.colorDotByValue;
let contents = <span className={cx(styles.colorDot, colorDotStyle)} />;
if (props.isDiffMode) {
menu = (
<Menu>
<Menu.Item label="Default (green to red)" onClick={() => props.onChange(ColorSchemeDiff.Default)} />
<Menu.Item label="Color blind (blue to red)" onClick={() => props.onChange(ColorSchemeDiff.DiffColorBlind)} />
</Menu>
);
contents = (
<div className={cx(styles.colorDotDiff, colorDotStyle)}>
<div>-100% (removed)</div>
<div>0%</div>
<div>+100% (added)</div>
</div>
);
}
return (
<Dropdown overlay={menu}>
<Button
variant={'secondary'}
fill={'outline'}
size={'sm'}
tooltip={'Change color scheme'}
onClick={() => {}}
className={styles.buttonSpacing}
aria-label={'Change color scheme'}
>
{contents}
</Button>
</Dropdown>
);
}
const alignOptions: Array<SelectableValue<TextAlign>> = [
{ value: 'left', description: 'Align text left', icon: 'align-left' },
{ value: 'right', description: 'Align text right', icon: 'align-right' },
]; ];
function getViewOptions(width: number, vertical?: boolean): Array<SelectableValue<SelectedView>> { function getViewOptions(width: number, vertical?: boolean): Array<SelectableValue<SelectedView>> {
let viewOptions: Array<{ value: SelectedView; label: string; description: string }> = [ let viewOptions: Array<{ value: SelectedView; label: string; description: string }> = [
{ value: SelectedView.TopTable, label: 'Top Table', description: 'Only show top table' }, { value: SelectedView.TopTable, label: 'Top Table', description: 'Only show top table' },
{ value: SelectedView.FlameGraph, label: 'Flame Graph', description: 'Only show flame graph' }, { value: SelectedView.FlameGraph, label: 'Flame Graph', description: 'Only show flame graph' },
{ value: SelectedView.CallTree, label: 'Call Tree', description: 'Only show call tree' },
]; ];
if (width >= MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH || vertical) { if (width >= MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH || vertical) {
viewOptions.push({ viewOptions.push({
value: SelectedView.Both, value: SelectedView.Multi,
label: 'Both', label: 'Multi',
description: 'Show both the top table and flame graph', description: 'Show split or single view with multiple visualizations',
}); });
} }
@@ -273,10 +226,12 @@ const getStyles = (theme: GrafanaTheme2) => ({
display: 'flex', display: 'flex',
flexWrap: 'wrap', flexWrap: 'wrap',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'flex-start',
width: '100%', width: '100%',
top: 0, top: 0,
gap: theme.spacing(1), gap: theme.spacing(1),
marginTop: theme.spacing(1), marginTop: theme.spacing(1),
position: 'relative',
}), }),
stickyHeader: css({ stickyHeader: css({
zIndex: theme.zIndex.navbarFixed, zIndex: theme.zIndex.navbarFixed,
@@ -285,10 +240,20 @@ const getStyles = (theme: GrafanaTheme2) => ({
}), }),
inputContainer: css({ inputContainer: css({
label: 'inputContainer', label: 'inputContainer',
flexGrow: 1, flexGrow: 0,
minWidth: '150px', minWidth: '150px',
maxWidth: '350px', maxWidth: '350px',
}), }),
middleContainer: css({
label: 'middleContainer',
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
gap: theme.spacing(1),
position: 'absolute',
left: '50%',
transform: 'translateX(-50%)',
}),
rightContainer: css({ rightContainer: css({
label: 'rightContainer', label: 'rightContainer',
display: 'flex', display: 'flex',
@@ -309,44 +274,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
padding: '0 5px', padding: '0 5px',
color: theme.colors.text.disabled, color: theme.colors.text.disabled,
}), }),
colorDot: css({
label: 'colorDot',
display: 'inline-block',
width: '10px',
height: '10px',
borderRadius: theme.shape.radius.circle,
}),
colorDotDiff: css({
label: 'colorDotDiff',
display: 'flex',
width: '200px',
height: '12px',
color: 'white',
fontSize: 9,
lineHeight: 1.3,
fontWeight: 300,
justifyContent: 'space-between',
padding: '0 2px',
// We have a specific sizing for this so probably makes sense to use hardcoded value here
// eslint-disable-next-line @grafana/no-border-radius-literal
borderRadius: '2px',
}),
colorDotByValue: css({
label: 'colorDotByValue',
background: byValueGradient,
}),
colorDotByPackage: css({
label: 'colorDotByPackage',
background: byPackageGradient,
}),
colorDotDiffDefault: css({
label: 'colorDotDiffDefault',
background: diffDefaultGradient,
}),
colorDotDiffColorBlind: css({
label: 'colorDotDiffColorBlind',
background: diffColorBlindGradient,
}),
extraElements: css({ extraElements: css({
label: 'extraElements', label: 'extraElements',
marginLeft: theme.spacing(1), marginLeft: theme.spacing(1),
@@ -0,0 +1,269 @@
import { css } from '@emotion/css';
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react';
import { GrafanaTheme2, escapeStringForRegex } from '@grafana/data';
import FlameGraphCallTreeContainer from './CallTree/FlameGraphCallTreeContainer';
import FlameGraph from './FlameGraph/FlameGraph';
import { GetExtraContextMenuButtonsFunction } from './FlameGraph/FlameGraphContextMenu';
import { FlameGraphDataContainer } from './FlameGraph/dataTransform';
import FlameGraphTopTableContainer from './TopTable/FlameGraphTopTableContainer';
import { ClickedItemData, ColorScheme, ColorSchemeDiff, PaneView, SelectedView, TextAlign, ViewMode } from './types';
export type FlameGraphPaneProps = {
paneView: PaneView;
dataContainer: FlameGraphDataContainer;
search: string;
matchedLabels: Set<string> | undefined;
onTableSymbolClick?: (symbol: string) => void;
onTextAlignSelected?: (align: string) => void;
onTableSort?: (sort: string) => void;
showFlameGraphOnly?: boolean;
disableCollapsing?: boolean;
getExtraContextMenuButtons?: GetExtraContextMenuButtonsFunction;
selectedView: SelectedView;
viewMode: ViewMode;
theme: GrafanaTheme2;
setSearch: (search: string) => void;
/** When this key changes, the pane's internal state (focus, sandwich, etc.) will be reset */
resetKey?: number;
/** Whether to preserve focus when the data changes */
keepFocusOnDataChange?: boolean;
};
const FlameGraphPane = ({
paneView,
dataContainer,
search,
matchedLabels,
onTableSymbolClick,
onTextAlignSelected,
onTableSort,
showFlameGraphOnly,
disableCollapsing,
getExtraContextMenuButtons,
selectedView,
viewMode,
theme,
setSearch,
resetKey,
keepFocusOnDataChange,
}: FlameGraphPaneProps) => {
// Pane-specific state - each instance maintains its own
const [focusedItemData, setFocusedItemData] = useState<ClickedItemData>();
const [rangeMin, setRangeMin] = useState(0);
const [rangeMax, setRangeMax] = useState(1);
const [textAlign, setTextAlign] = useState<TextAlign>('left');
const [sandwichItem, setSandwichItem] = useState<string>();
// Initialize collapsedMap from dataContainer to ensure collapsed groups are shown correctly on first render
const [collapsedMap, setCollapsedMap] = useState(() => dataContainer.getCollapsedMap());
const [colorScheme, setColorScheme] = useColorScheme(dataContainer);
const styles = useMemo(() => getStyles(theme), [theme]);
// Re-initialize collapsed map when dataContainer changes (e.g., new data loaded)
// Using useLayoutEffect to ensure collapsed state is applied before browser paint
useLayoutEffect(() => {
setCollapsedMap(dataContainer.getCollapsedMap());
}, [dataContainer]);
// Reset internal state when resetKey changes (triggered by parent's reset button)
useEffect(() => {
if (resetKey !== undefined && resetKey > 0) {
setFocusedItemData(undefined);
setRangeMin(0);
setRangeMax(1);
setSandwichItem(undefined);
}
}, [resetKey]);
// Handle focus preservation or reset when data changes
useEffect(() => {
if (!keepFocusOnDataChange) {
setFocusedItemData(undefined);
setRangeMin(0);
setRangeMax(1);
setSandwichItem(undefined);
return;
}
if (dataContainer && focusedItemData) {
const item = dataContainer.getNodesWithLabel(focusedItemData.label)?.[0];
if (item) {
setFocusedItemData({ ...focusedItemData, item });
const levels = dataContainer.getLevels();
const totalViewTicks = levels.length ? levels[0][0].value : 0;
setRangeMin(item.start / totalViewTicks);
setRangeMax((item.start + item.value) / totalViewTicks);
} else {
setFocusedItemData({
...focusedItemData,
item: {
start: 0,
value: 0,
itemIndexes: [],
children: [],
level: 0,
},
});
setRangeMin(0);
setRangeMax(1);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataContainer, keepFocusOnDataChange]);
const resetFocus = useCallback(() => {
setFocusedItemData(undefined);
setRangeMin(0);
setRangeMax(1);
}, []);
const resetSandwich = useCallback(() => {
setSandwichItem(undefined);
}, []);
const onSymbolClick = useCallback(
(symbol: string) => {
const anchored = `^${escapeStringForRegex(symbol)}$`;
if (search === anchored) {
setSearch('');
} else {
onTableSymbolClick?.(symbol);
setSearch(anchored);
resetFocus();
}
},
[search, setSearch, resetFocus, onTableSymbolClick]
);
// Separate callback for CallTree that doesn't trigger search
const onCallTreeSymbolClick = useCallback(
(symbol: string) => {
onTableSymbolClick?.(symbol);
},
[onTableSymbolClick]
);
// Search callback for CallTree search button
const onCallTreeSearch = useCallback(
(symbol: string) => {
const anchored = `^${escapeStringForRegex(symbol)}$`;
if (search === anchored) {
setSearch('');
} else {
onTableSymbolClick?.(symbol);
setSearch(anchored);
resetFocus();
}
},
[search, setSearch, resetFocus, onTableSymbolClick]
);
const isInSplitView = selectedView === SelectedView.Multi && viewMode === ViewMode.Split;
const isCallTreeInSplitView = isInSplitView && paneView === PaneView.CallTree;
switch (paneView) {
case PaneView.TopTable:
return (
<div className={styles.tableContainer}>
<FlameGraphTopTableContainer
data={dataContainer}
onSymbolClick={onSymbolClick}
search={search}
matchedLabels={matchedLabels}
sandwichItem={sandwichItem}
onSandwich={setSandwichItem}
onSearch={(str) => {
if (!str) {
setSearch('');
return;
}
setSearch(`^${escapeStringForRegex(str)}$`);
}}
onTableSort={onTableSort}
colorScheme={colorScheme}
/>
</div>
);
case PaneView.FlameGraph:
default:
return (
<FlameGraph
data={dataContainer}
rangeMin={rangeMin}
rangeMax={rangeMax}
matchedLabels={matchedLabels}
setRangeMin={setRangeMin}
setRangeMax={setRangeMax}
onItemFocused={(data) => setFocusedItemData(data)}
focusedItemData={focusedItemData}
textAlign={textAlign}
onTextAlignChange={(align) => {
setTextAlign(align);
onTextAlignSelected?.(align);
}}
sandwichItem={sandwichItem}
onSandwich={(label: string) => {
resetFocus();
setSandwichItem(label);
}}
onFocusPillClick={resetFocus}
onSandwichPillClick={resetSandwich}
colorScheme={colorScheme}
onColorSchemeChange={setColorScheme}
isDiffMode={dataContainer.isDiffFlamegraph()}
showFlameGraphOnly={showFlameGraphOnly}
collapsing={!disableCollapsing}
getExtraContextMenuButtons={getExtraContextMenuButtons}
selectedView={selectedView}
search={search}
collapsedMap={collapsedMap}
setCollapsedMap={setCollapsedMap}
/>
);
case PaneView.CallTree:
return (
<div className={styles.tableContainer}>
<FlameGraphCallTreeContainer
data={dataContainer}
onSymbolClick={onCallTreeSymbolClick}
sandwichItem={sandwichItem}
onSandwich={setSandwichItem}
onTableSort={onTableSort}
colorScheme={colorScheme}
search={search}
compact={isCallTreeInSplitView}
onSearch={onCallTreeSearch}
/>
</div>
);
}
};
function useColorScheme(dataContainer: FlameGraphDataContainer | undefined) {
const defaultColorScheme = dataContainer?.isDiffFlamegraph() ? ColorSchemeDiff.Default : ColorScheme.PackageBased;
const [colorScheme, setColorScheme] = useState<ColorScheme | ColorSchemeDiff>(defaultColorScheme);
// This makes sure that if we change the data to/from diff profile we reset the color scheme.
useEffect(() => {
setColorScheme(defaultColorScheme);
}, [defaultColorScheme]);
return [colorScheme, setColorScheme] as const;
}
function getStyles(theme: GrafanaTheme2) {
return {
tableContainer: css({
// This is not ideal for dashboard panel where it creates a double scroll. In a panel it should be 100% but then
// in explore we need a specific height.
height: 800,
}),
};
}
export default FlameGraphPane;
+1
View File
@@ -1,3 +1,4 @@
export { default as FlameGraph, type Props } from './FlameGraphContainer'; export { default as FlameGraph, type Props } from './FlameGraphContainer';
export { default as FlameGraphCallTreeContainer } from './CallTree/FlameGraphCallTreeContainer';
export { checkFields, getMessageCheckFieldsResult } from './FlameGraph/dataTransform'; export { checkFields, getMessageCheckFieldsResult } from './FlameGraph/dataTransform';
export { data } from './FlameGraph/testData/dataNestedSet'; export { data } from './FlameGraph/testData/dataNestedSet';
+13 -1
View File
@@ -20,7 +20,19 @@ export enum SampleUnit {
export enum SelectedView { export enum SelectedView {
TopTable = 'topTable', TopTable = 'topTable',
FlameGraph = 'flameGraph', FlameGraph = 'flameGraph',
Both = 'both', Multi = 'multi',
CallTree = 'callTree',
}
export enum ViewMode {
Single = 'single',
Split = 'split',
}
export enum PaneView {
TopTable = 'topTable',
FlameGraph = 'flameGraph',
CallTree = 'callTree',
} }
export interface TableData { export interface TableData {
@@ -1,14 +1,40 @@
import { Decorator } from '@storybook/react'; import { Decorator } from '@storybook/react';
import { useEffect } from 'react';
import * as React from 'react'; import * as React from 'react';
import { getThemeById, ThemeContext } from '@grafana/data'; import { createTheme, getThemeById, ThemeContext } from '@grafana/data';
import { GlobalStyles } from '@grafana/ui'; import { GlobalStyles, PortalContainer } from '@grafana/ui';
interface ThemeableStoryProps { interface ThemeableStoryProps {
themeId: string; themeId?: string;
} }
const ThemeableStory = ({ children, themeId }: React.PropsWithChildren<ThemeableStoryProps>) => { const ThemeableStory = ({ children, themeId }: React.PropsWithChildren<ThemeableStoryProps>) => {
const theme = getThemeById(themeId); // Always ensure we have a valid theme
const theme = React.useMemo(() => {
const id = themeId || 'dark';
let resolvedTheme = getThemeById(id);
// If getThemeById returns undefined, create a default theme
if (!resolvedTheme) {
console.warn(`Theme '${id}' not found, using default theme`);
resolvedTheme = createTheme({ colors: { mode: id === 'light' ? 'light' : 'dark' } });
}
console.log('withTheme: resolved theme', { id, hasTheme: !!resolvedTheme, hasSpacing: !!resolvedTheme?.spacing });
return resolvedTheme;
}, [themeId]);
// Apply theme to document root for Portals
useEffect(() => {
if (!theme) return;
document.body.style.setProperty('--theme-background', theme.colors.background.primary);
}, [theme]);
if (!theme) {
console.error('withTheme: No theme available!');
return null;
}
const css = ` const css = `
#storybook-root { #storybook-root {
@@ -23,6 +49,7 @@ const ThemeableStory = ({ children, themeId }: React.PropsWithChildren<Themeable
return ( return (
<ThemeContext.Provider value={theme}> <ThemeContext.Provider value={theme}>
<GlobalStyles /> <GlobalStyles />
<PortalContainer />
<style>{css}</style> <style>{css}</style>
{children} {children}
@@ -33,4 +60,4 @@ const ThemeableStory = ({ children, themeId }: React.PropsWithChildren<Themeable
export const withTheme = export const withTheme =
(): Decorator => (): Decorator =>
// eslint-disable-next-line react/display-name // eslint-disable-next-line react/display-name
(story, context) => <ThemeableStory themeId={context.globals.theme}>{story()}</ThemeableStory>; (story, context) => <ThemeableStory themeId={context.globals?.theme}>{story()}</ThemeableStory>;
+1
View File
@@ -86,6 +86,7 @@ export class GrafanaBootConfig {
snapshotEnabled = true; snapshotEnabled = true;
datasources: { [str: string]: DataSourceInstanceSettings } = {}; datasources: { [str: string]: DataSourceInstanceSettings } = {};
panels: { [key: string]: PanelPluginMeta } = {}; panels: { [key: string]: PanelPluginMeta } = {};
/** @deprecated it will be removed in a future release, use isAppPluginInstalled or getAppPluginVersion instead */
apps: Record<string, AppPluginConfigGrafanaData> = {}; apps: Record<string, AppPluginConfigGrafanaData> = {};
auth: AuthSettings = {}; auth: AuthSettings = {};
minRefreshInterval = ''; minRefreshInterval = '';
+2
View File
@@ -77,3 +77,5 @@ export {
getCorrelationsService, getCorrelationsService,
setCorrelationsService, setCorrelationsService,
} from './services/CorrelationsService'; } 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 { UserStorage } from '../utils/userStorage';
export { initOpenFeature, evaluateBooleanFlag } from './openFeature'; export { initOpenFeature, evaluateBooleanFlag } from './openFeature';
export { getAppPluginMeta, getAppPluginMetas, setAppPluginMetas } from '../services/pluginMeta/apps';
export { useAppPluginMeta, useAppPluginMetas } from '../services/pluginMeta/hooks';
@@ -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'; 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); 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 ( return (
<tr className={cx(styles.row, className)}> <tr className={cx(styles.row, className)}>
<td className={styles.labelCell}> <td>
<span className={styles.itemWrapper}> <span className={styles.itemWrapper}>
<VizLegendSeriesIcon <VizLegendSeriesIcon
color={item.color} color={item.color}
@@ -77,26 +77,24 @@ export const LegendTableItem = ({
readonly={readonly} readonly={readonly}
lineStyle={item.lineStyle} lineStyle={item.lineStyle}
/> />
<div className={styles.labelCellInner}> <button
<button disabled={readonly}
disabled={readonly} type="button"
type="button" title={item.label}
title={item.label} onBlur={onMouseOut}
onBlur={onMouseOut} onFocus={onMouseOver}
onFocus={onMouseOver} onMouseOver={onMouseOver}
onMouseOver={onMouseOver} onMouseOut={onMouseOut}
onMouseOut={onMouseOut} onClick={!readonly ? onClick : undefined}
onClick={!readonly ? onClick : undefined} className={cx(styles.label, item.disabled && styles.labelDisabled)}
className={cx(styles.label, item.disabled && styles.labelDisabled)} >
> {item.label}{' '}
{item.label}{' '} {item.yAxis === 2 && (
{item.yAxis === 2 && ( <span className={styles.yAxisLabel}>
<span className={styles.yAxisLabel}> <Trans i18nKey="grafana-ui.viz-legend.right-axis-indicator">(right y-axis)</Trans>
<Trans i18nKey="grafana-ui.viz-legend.right-axis-indicator">(right y-axis)</Trans> </span>
</span> )}
)} </button>
</button>
</div>
</span> </span>
</td> </td>
{item.getDisplayValues && {item.getDisplayValues &&
@@ -130,28 +128,6 @@ const getStyles = (theme: GrafanaTheme2) => {
background: rowHoverBg, 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: css({
label: 'LegendLabel', label: 'LegendLabel',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
@@ -159,6 +135,9 @@ const getStyles = (theme: GrafanaTheme2) => {
border: 'none', border: 'none',
fontSize: 'inherit', fontSize: 'inherit',
padding: 0, padding: 0,
maxWidth: '600px',
textOverflow: 'ellipsis',
overflow: 'hidden',
userSelect: 'text', userSelect: 'text',
}), }),
labelDisabled: css({ labelDisabled: css({
+19 -3
View File
@@ -574,8 +574,8 @@ var (
}, },
{ {
Name: "dashboardNewLayouts", Name: "dashboardNewLayouts",
Description: "Enables experimental new dashboard layouts", Description: "Enables new dashboard layouts",
Stage: FeatureStageExperimental, Stage: FeatureStagePublicPreview,
FrontendOnly: false, // The restore backend feature changes behavior based on this flag FrontendOnly: false, // The restore backend feature changes behavior based on this flag
Owner: grafanaDashboardsSquad, Owner: grafanaDashboardsSquad,
}, },
@@ -879,6 +879,13 @@ var (
Owner: grafanaAlertingSquad, Owner: grafanaAlertingSquad,
FrontendOnly: true, FrontendOnly: true,
}, },
{
Name: "alertingNavigationV2",
Description: "Enables the new Alerting navigation structure with improved menu grouping",
Stage: FeatureStageExperimental,
Owner: grafanaAlertingSquad,
FrontendOnly: false,
},
{ {
Name: "alertingSavedSearches", Name: "alertingSavedSearches",
Description: "Enables saved searches for alert rules list", Description: "Enables saved searches for alert rules list",
@@ -981,7 +988,8 @@ var (
Stage: FeatureStageDeprecated, Stage: FeatureStageDeprecated,
Owner: grafanaPartnerPluginsSquad, Owner: grafanaPartnerPluginsSquad,
Expression: "true", // Enabled by default for now Expression: "true", // Enabled by default for now
}, { },
{
Name: "alertingFilterV2", Name: "alertingFilterV2",
Description: "Enable the new alerting search experience", Description: "Enable the new alerting search experience",
Stage: FeatureStageExperimental, Stage: FeatureStageExperimental,
@@ -2069,6 +2077,14 @@ var (
Owner: grafanaObservabilityTracesAndProfilingSquad, Owner: grafanaObservabilityTracesAndProfilingSquad,
FrontendOnly: false, FrontendOnly: false,
}, },
{
Name: "alertingSyncDispatchTimer",
Description: "Use synchronized dispatch timer to minimize duplicate notifications across alertmanager HA pods",
Stage: FeatureStageExperimental,
Owner: grafanaAlertingSquad,
RequiresRestart: true,
HideFromDocs: true,
},
} }
) )
+3 -1
View File
@@ -79,7 +79,7 @@ annotationPermissionUpdate,GA,@grafana/identity-access-team,false,false,false
dashboardSceneForViewers,GA,@grafana/dashboards-squad,false,false,true dashboardSceneForViewers,GA,@grafana/dashboards-squad,false,false,true
dashboardSceneSolo,GA,@grafana/dashboards-squad,false,false,true dashboardSceneSolo,GA,@grafana/dashboards-squad,false,false,true
dashboardScene,GA,@grafana/dashboards-squad,false,false,true dashboardScene,GA,@grafana/dashboards-squad,false,false,true
dashboardNewLayouts,experimental,@grafana/dashboards-squad,false,false,false dashboardNewLayouts,preview,@grafana/dashboards-squad,false,false,false
dashboardUndoRedo,experimental,@grafana/dashboards-squad,false,false,true dashboardUndoRedo,experimental,@grafana/dashboards-squad,false,false,true
unlimitedLayoutsNesting,experimental,@grafana/dashboards-squad,false,false,true unlimitedLayoutsNesting,experimental,@grafana/dashboards-squad,false,false,true
drilldownRecommendations,experimental,@grafana/dashboards-squad,false,false,true drilldownRecommendations,experimental,@grafana/dashboards-squad,false,false,true
@@ -121,6 +121,7 @@ dashboardLibrary,experimental,@grafana/sharing-squad,false,false,false
suggestedDashboards,experimental,@grafana/sharing-squad,false,false,false suggestedDashboards,experimental,@grafana/sharing-squad,false,false,false
dashboardTemplates,preview,@grafana/sharing-squad,false,false,false dashboardTemplates,preview,@grafana/sharing-squad,false,false,false
alertingListViewV2,privatePreview,@grafana/alerting-squad,false,false,true alertingListViewV2,privatePreview,@grafana/alerting-squad,false,false,true
alertingNavigationV2,experimental,@grafana/alerting-squad,false,false,false
alertingSavedSearches,experimental,@grafana/alerting-squad,false,false,true alertingSavedSearches,experimental,@grafana/alerting-squad,false,false,true
alertingDisableSendAlertsExternal,experimental,@grafana/alerting-squad,false,false,false alertingDisableSendAlertsExternal,experimental,@grafana/alerting-squad,false,false,false
preserveDashboardStateWhenNavigating,experimental,@grafana/dashboards-squad,false,false,false preserveDashboardStateWhenNavigating,experimental,@grafana/dashboards-squad,false,false,false
@@ -280,3 +281,4 @@ multiPropsVariables,experimental,@grafana/dashboards-squad,false,false,true
smoothingTransformation,experimental,@grafana/datapro,false,false,true smoothingTransformation,experimental,@grafana/datapro,false,false,true
secretsManagementAppPlatformAwsKeeper,experimental,@grafana/grafana-operator-experience-squad,false,false,false secretsManagementAppPlatformAwsKeeper,experimental,@grafana/grafana-operator-experience-squad,false,false,false
profilesExemplars,experimental,@grafana/observability-traces-and-profiling,false,false,false profilesExemplars,experimental,@grafana/observability-traces-and-profiling,false,false,false
alertingSyncDispatchTimer,experimental,@grafana/alerting-squad,false,true,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
79 dashboardSceneForViewers GA @grafana/dashboards-squad false false true
80 dashboardSceneSolo GA @grafana/dashboards-squad false false true
81 dashboardScene GA @grafana/dashboards-squad false false true
82 dashboardNewLayouts experimental preview @grafana/dashboards-squad false false false
83 dashboardUndoRedo experimental @grafana/dashboards-squad false false true
84 unlimitedLayoutsNesting experimental @grafana/dashboards-squad false false true
85 drilldownRecommendations experimental @grafana/dashboards-squad false false true
121 suggestedDashboards experimental @grafana/sharing-squad false false false
122 dashboardTemplates preview @grafana/sharing-squad false false false
123 alertingListViewV2 privatePreview @grafana/alerting-squad false false true
124 alertingNavigationV2 experimental @grafana/alerting-squad false false false
125 alertingSavedSearches experimental @grafana/alerting-squad false false true
126 alertingDisableSendAlertsExternal experimental @grafana/alerting-squad false false false
127 preserveDashboardStateWhenNavigating experimental @grafana/dashboards-squad false false false
281 smoothingTransformation experimental @grafana/datapro false false true
282 secretsManagementAppPlatformAwsKeeper experimental @grafana/grafana-operator-experience-squad false false false
283 profilesExemplars experimental @grafana/observability-traces-and-profiling false false false
284 alertingSyncDispatchTimer experimental @grafana/alerting-squad false true false
+9 -1
View File
@@ -260,7 +260,7 @@ const (
FlagAnnotationPermissionUpdate = "annotationPermissionUpdate" FlagAnnotationPermissionUpdate = "annotationPermissionUpdate"
// FlagDashboardNewLayouts // FlagDashboardNewLayouts
// Enables experimental new dashboard layouts // Enables new dashboard layouts
FlagDashboardNewLayouts = "dashboardNewLayouts" FlagDashboardNewLayouts = "dashboardNewLayouts"
// FlagPdfTables // FlagPdfTables
@@ -371,6 +371,10 @@ const (
// Enables a flow to get started with a new dashboard from a template // Enables a flow to get started with a new dashboard from a template
FlagDashboardTemplates = "dashboardTemplates" FlagDashboardTemplates = "dashboardTemplates"
// FlagAlertingNavigationV2
// Enables the new Alerting navigation structure with improved menu grouping
FlagAlertingNavigationV2 = "alertingNavigationV2"
// FlagAlertingDisableSendAlertsExternal // FlagAlertingDisableSendAlertsExternal
// Disables the ability to send alerts to an external Alertmanager datasource. // Disables the ability to send alerts to an external Alertmanager datasource.
FlagAlertingDisableSendAlertsExternal = "alertingDisableSendAlertsExternal" FlagAlertingDisableSendAlertsExternal = "alertingDisableSendAlertsExternal"
@@ -789,4 +793,8 @@ const (
// FlagProfilesExemplars // FlagProfilesExemplars
// Enables profiles exemplars support in profiles drilldown // Enables profiles exemplars support in profiles drilldown
FlagProfilesExemplars = "profilesExemplars" FlagProfilesExemplars = "profilesExemplars"
// FlagAlertingSyncDispatchTimer
// Use synchronized dispatch timer to minimize duplicate notifications across alertmanager HA pods
FlagAlertingSyncDispatchTimer = "alertingSyncDispatchTimer"
) )
+35 -5
View File
@@ -348,6 +348,18 @@
"expression": "true" "expression": "true"
} }
}, },
{
"metadata": {
"name": "alertingNavigationV2",
"resourceVersion": "1768320918269",
"creationTimestamp": "2026-01-13T16:15:18Z"
},
"spec": {
"description": "Enables the new Alerting navigation structure with improved menu grouping",
"stage": "experimental",
"codeowner": "@grafana/alerting-squad"
}
},
{ {
"metadata": { "metadata": {
"name": "alertingNotificationHistory", "name": "alertingNotificationHistory",
@@ -511,6 +523,20 @@
"frontend": true "frontend": true
} }
}, },
{
"metadata": {
"name": "alertingSyncDispatchTimer",
"resourceVersion": "1766161788928",
"creationTimestamp": "2025-12-19T16:29:48Z"
},
"spec": {
"description": "Use synchronized dispatch timer to minimize duplicate notifications across alertmanager HA pods",
"stage": "experimental",
"codeowner": "@grafana/alerting-squad",
"requiresRestart": true,
"hideFromDocs": true
}
},
{ {
"metadata": { "metadata": {
"name": "alertingTriage", "name": "alertingTriage",
@@ -662,7 +688,8 @@
"metadata": { "metadata": {
"name": "auditLoggingAppPlatform", "name": "auditLoggingAppPlatform",
"resourceVersion": "1767013056996", "resourceVersion": "1767013056996",
"creationTimestamp": "2025-12-29T12:57:36Z" "creationTimestamp": "2025-12-29T12:57:36Z",
"deletionTimestamp": "2026-01-06T09:18:36Z"
}, },
"spec": { "spec": {
"description": "Enable audit logging with Kubernetes under app platform", "description": "Enable audit logging with Kubernetes under app platform",
@@ -1015,12 +1042,15 @@
{ {
"metadata": { "metadata": {
"name": "dashboardNewLayouts", "name": "dashboardNewLayouts",
"resourceVersion": "1764664939750", "resourceVersion": "1768382835527",
"creationTimestamp": "2024-10-23T08:55:45Z" "creationTimestamp": "2024-10-23T08:55:45Z",
"annotations": {
"grafana.app/updatedTimestamp": "2026-01-14 09:27:15.527103 +0000 UTC"
}
}, },
"spec": { "spec": {
"description": "Enables experimental new dashboard layouts", "description": "Enables new dashboard layouts",
"stage": "experimental", "stage": "preview",
"codeowner": "@grafana/dashboards-squad" "codeowner": "@grafana/dashboards-squad"
} }
}, },
+1 -2
View File
@@ -54,8 +54,7 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink
} }
//nolint:staticcheck // not yet migrated to OpenFeature //nolint:staticcheck // not yet migrated to OpenFeature
if c.HasRole(identity.RoleAdmin) && if c.HasRole(identity.RoleAdmin) &&
(s.cfg.StackID == "" || // show OnPrem even when provisioning is disabled s.features.IsEnabledGlobally(featuremgmt.FlagProvisioning) {
s.features.IsEnabledGlobally(featuremgmt.FlagProvisioning)) {
generalNodeLinks = append(generalNodeLinks, &navtree.NavLink{ generalNodeLinks = append(generalNodeLinks, &navtree.NavLink{
Text: "Provisioning", Text: "Provisioning",
Id: "provisioning", Id: "provisioning",
+4
View File
@@ -213,6 +213,9 @@ func (ng *AlertNG) init() error {
SkipVerify: ng.Cfg.Smtp.SkipVerify, SkipVerify: ng.Cfg.Smtp.SkipVerify,
StaticHeaders: ng.Cfg.Smtp.StaticHeaders, StaticHeaders: ng.Cfg.Smtp.StaticHeaders,
} }
runtimeConfig := remoteClient.RuntimeConfig{
DispatchTimer: notifier.GetDispatchTimer(ng.FeatureToggles).String(),
}
cfg := remote.AlertmanagerConfig{ cfg := remote.AlertmanagerConfig{
BasicAuthPassword: ng.Cfg.UnifiedAlerting.RemoteAlertmanager.Password, BasicAuthPassword: ng.Cfg.UnifiedAlerting.RemoteAlertmanager.Password,
@@ -222,6 +225,7 @@ func (ng *AlertNG) init() error {
ExternalURL: ng.Cfg.AppURL, ExternalURL: ng.Cfg.AppURL,
SmtpConfig: smtpCfg, SmtpConfig: smtpCfg,
Timeout: ng.Cfg.UnifiedAlerting.RemoteAlertmanager.Timeout, Timeout: ng.Cfg.UnifiedAlerting.RemoteAlertmanager.Timeout,
RuntimeConfig: runtimeConfig,
} }
autogenFn := func(ctx context.Context, logger log.Logger, orgID int64, cfg *definitions.PostableApiAlertingConfig, invalidReceiverAction notifier.InvalidReceiversAction) error { autogenFn := func(ctx context.Context, logger log.Logger, orgID int64, cfg *definitions.PostableApiAlertingConfig, invalidReceiverAction notifier.InvalidReceiversAction) error {
return notifier.AddAutogenConfig(ctx, logger, ng.store, orgID, cfg, invalidReceiverAction, ng.FeatureToggles) return notifier.AddAutogenConfig(ctx, logger, ng.store, orgID, cfg, invalidReceiverAction, ng.FeatureToggles)
@@ -33,6 +33,9 @@ const (
// How long we keep silences in the kvstore after they've expired. // How long we keep silences in the kvstore after they've expired.
silenceRetention = 5 * 24 * time.Hour silenceRetention = 5 * 24 * time.Hour
// How long we keep flushes in the kvstore after they've expired.
flushRetention = 5 * 24 * time.Hour
) )
type AlertingStore interface { type AlertingStore interface {
@@ -44,8 +47,10 @@ type AlertingStore interface {
type stateStore interface { type stateStore interface {
SaveSilences(ctx context.Context, st alertingNotify.State) (int64, error) SaveSilences(ctx context.Context, st alertingNotify.State) (int64, error)
SaveNotificationLog(ctx context.Context, st alertingNotify.State) (int64, error) SaveNotificationLog(ctx context.Context, st alertingNotify.State) (int64, error)
SaveFlushLog(ctx context.Context, st alertingNotify.State) (int64, error)
GetSilences(ctx context.Context) (string, error) GetSilences(ctx context.Context) (string, error)
GetNotificationLog(ctx context.Context) (string, error) GetNotificationLog(ctx context.Context) (string, error)
GetFlushLog(ctx context.Context) (string, error)
} }
type alertmanager struct { type alertmanager struct {
@@ -101,6 +106,10 @@ func NewAlertmanager(ctx context.Context, orgID int64, cfg *setting.Cfg, store A
if err != nil { if err != nil {
return nil, err return nil, err
} }
flushLog, err := stateStore.GetFlushLog(ctx)
if err != nil {
return nil, err
}
silencesOptions := maintenanceOptions{ silencesOptions := maintenanceOptions{
initialState: silences, initialState: silences,
@@ -123,12 +132,29 @@ func NewAlertmanager(ctx context.Context, orgID int64, cfg *setting.Cfg, store A
} }
l := log.New("ngalert.notifier") l := log.New("ngalert.notifier")
dispatchTimer := GetDispatchTimer(featureToggles)
var flushLogOptions *maintenanceOptions
if dispatchTimer == alertingNotify.DispatchTimerSync {
flushLogOptions = &maintenanceOptions{
initialState: flushLog,
retention: flushRetention,
maintenanceFrequency: maintenanceInterval,
maintenanceFunc: func(state alertingNotify.State) (int64, error) {
// Detached context here is to make sure that when the service is shut down the persist operation is executed.
return stateStore.SaveFlushLog(context.Background(), state)
},
}
}
opts := alertingNotify.GrafanaAlertmanagerOpts{ opts := alertingNotify.GrafanaAlertmanagerOpts{
ExternalURL: cfg.AppURL, ExternalURL: cfg.AppURL,
AlertStoreCallback: nil, AlertStoreCallback: nil,
PeerTimeout: cfg.UnifiedAlerting.HAPeerTimeout, PeerTimeout: cfg.UnifiedAlerting.HAPeerTimeout,
Silences: silencesOptions, Silences: silencesOptions,
Nflog: nflogOptions, Nflog: nflogOptions,
FlushLog: flushLogOptions,
DispatchTimer: dispatchTimer,
Limits: alertingNotify.Limits{ Limits: alertingNotify.Limits{
MaxSilences: cfg.UnifiedAlerting.AlertmanagerMaxSilencesCount, MaxSilences: cfg.UnifiedAlerting.AlertmanagerMaxSilencesCount,
MaxSilenceSizeBytes: cfg.UnifiedAlerting.AlertmanagerMaxSilenceSizeBytes, MaxSilenceSizeBytes: cfg.UnifiedAlerting.AlertmanagerMaxSilenceSizeBytes,
@@ -0,0 +1,16 @@
package notifier
import (
alertingNotify "github.com/grafana/alerting/notify"
"github.com/grafana/grafana/pkg/services/featuremgmt"
)
// GetDispatchTimer returns the appropriate dispatch timer based on feature toggles.
func GetDispatchTimer(features featuremgmt.FeatureToggles) (dt alertingNotify.DispatchTimer) {
//nolint:staticcheck // not yet migrated to OpenFeature
enabled := features.IsEnabledGlobally(featuremgmt.FlagAlertingSyncDispatchTimer)
if enabled {
dt = alertingNotify.DispatchTimerSync
}
return
}
@@ -0,0 +1,36 @@
package notifier
import (
"testing"
alertingNotify "github.com/grafana/alerting/notify"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/stretchr/testify/require"
)
func TestGetDispatchTimer(t *testing.T) {
tests := []struct {
name string
featureFlagValue bool
expected alertingNotify.DispatchTimer
}{
{
name: "feature flag enabled returns sync timer",
featureFlagValue: true,
expected: alertingNotify.DispatchTimerSync,
},
{
name: "feature flag disabled returns default timer",
featureFlagValue: false,
expected: alertingNotify.DispatchTimerDefault,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
features := featuremgmt.WithFeatures(featuremgmt.FlagAlertingSyncDispatchTimer, tt.featureFlagValue)
result := GetDispatchTimer(features)
require.Equal(t, tt.expected, result)
})
}
}
@@ -15,6 +15,7 @@ const (
KVNamespace = "alertmanager" KVNamespace = "alertmanager"
NotificationLogFilename = "notifications" NotificationLogFilename = "notifications"
SilencesFilename = "silences" SilencesFilename = "silences"
FlushLogFilename = "flushes"
) )
// FileStore is in charge of persisting the alertmanager files to the database. // FileStore is in charge of persisting the alertmanager files to the database.
@@ -42,6 +43,10 @@ func (fileStore *FileStore) GetNotificationLog(ctx context.Context) (string, err
return fileStore.contentFor(ctx, NotificationLogFilename) return fileStore.contentFor(ctx, NotificationLogFilename)
} }
func (fileStore *FileStore) GetFlushLog(ctx context.Context) (string, error) {
return fileStore.contentFor(ctx, FlushLogFilename)
}
// contentFor returns the content for the given Alertmanager kvstore key. // contentFor returns the content for the given Alertmanager kvstore key.
func (fileStore *FileStore) contentFor(ctx context.Context, filename string) (string, error) { func (fileStore *FileStore) contentFor(ctx context.Context, filename string) (string, error) {
// Then, let's attempt to read it from the database. // Then, let's attempt to read it from the database.
@@ -74,6 +79,11 @@ func (fileStore *FileStore) SaveNotificationLog(ctx context.Context, st alerting
return fileStore.persist(ctx, NotificationLogFilename, st) return fileStore.persist(ctx, NotificationLogFilename, st)
} }
// SaveFlushLog saves the flush log to the database and returns the size of the unencoded state.
func (fileStore *FileStore) SaveFlushLog(ctx context.Context, st alertingNotify.State) (int64, error) {
return fileStore.persist(ctx, FlushLogFilename, st)
}
// persist takes care of persisting the binary representation of internal state to the database as a base64 encoded string. // persist takes care of persisting the binary representation of internal state to the database as a base64 encoded string.
func (fileStore *FileStore) persist(ctx context.Context, filename string, st alertingNotify.State) (int64, error) { func (fileStore *FileStore) persist(ctx context.Context, filename string, st alertingNotify.State) (int64, error) {
var size int64 var size int64
@@ -106,3 +106,48 @@ func TestFileStore_NotificationLog(t *testing.T) {
t.Errorf("Unexpected Diff: %v", cmp.Diff(newState, decoded)) t.Errorf("Unexpected Diff: %v", cmp.Diff(newState, decoded))
} }
} }
func TestFileStore_FlushLog(t *testing.T) {
store := fakes.NewFakeKVStore(t)
ctx := context.Background()
var orgId int64 = 1
// Initialize kvstore with empty flush log state.
initialState := flushLogState{} // FlushLog uses the same structure as nflog
decodedState, err := initialState.MarshalBinary()
require.NoError(t, err)
encodedState := base64.StdEncoding.EncodeToString(decodedState)
err = store.Set(ctx, orgId, KVNamespace, FlushLogFilename, encodedState)
require.NoError(t, err)
fs := NewFileStore(orgId, store)
// Load initial (empty).
flushLog, err := fs.GetFlushLog(ctx)
require.NoError(t, err)
decoded, err := decodeFlushLogState(strings.NewReader(flushLog))
require.NoError(t, err)
if !cmp.Equal(initialState, decoded) {
t.Errorf("Unexpected Diff: %v", cmp.Diff(initialState, decoded))
}
// Save new flush log state.
now := time.Now()
oneHour := now.Add(time.Hour)
v1 := createFlushLog(1, now, oneHour)
v2 := createFlushLog(2, now, oneHour)
newState := flushLogState{1: v1, 2: v2}
size, err := fs.SaveFlushLog(ctx, newState)
require.NoError(t, err)
require.Greater(t, size, int64(0))
// Load new.
flushLog, err = fs.GetFlushLog(ctx)
require.NoError(t, err)
decoded, err = decodeFlushLogState(strings.NewReader(flushLog))
require.NoError(t, err)
if !cmp.Equal(newState, decoded) {
t.Errorf("Unexpected Diff: %v", cmp.Diff(newState, decoded))
}
}
@@ -82,6 +82,7 @@ type Alertmanager interface {
type ExternalState struct { type ExternalState struct {
Silences []byte Silences []byte
Nflog []byte Nflog []byte
FlushLog []byte
} }
// StateMerger describes a type that is able to merge external state (nflog, silences) with its own. // StateMerger describes a type that is able to merge external state (nflog, silences) with its own.
@@ -378,7 +379,7 @@ func (moa *MultiOrgAlertmanager) SyncAlertmanagersForOrgs(ctx context.Context, o
func (moa *MultiOrgAlertmanager) cleanupOrphanLocalOrgState(ctx context.Context, func (moa *MultiOrgAlertmanager) cleanupOrphanLocalOrgState(ctx context.Context,
activeOrganizations map[int64]struct{}, activeOrganizations map[int64]struct{},
) { ) {
storedFiles := []string{NotificationLogFilename, SilencesFilename} storedFiles := []string{NotificationLogFilename, SilencesFilename, FlushLogFilename}
for _, fileName := range storedFiles { for _, fileName := range storedFiles {
keys, err := moa.kvStore.Keys(ctx, kvstore.AllOrganizations, KVNamespace, fileName) keys, err := moa.kvStore.Keys(ctx, kvstore.AllOrganizations, KVNamespace, fileName)
if err != nil { if err != nil {
+4 -1
View File
@@ -5,5 +5,8 @@ func (am *alertmanager) MergeState(state ExternalState) error {
if err := am.Base.MergeNflog(state.Nflog); err != nil { if err := am.Base.MergeNflog(state.Nflog); err != nil {
return err return err
} }
return am.Base.MergeSilences(state.Silences) if err := am.Base.MergeSilences(state.Silences); err != nil {
return err
}
return am.Base.MergeFlushLog(state.FlushLog)
} }
+48 -4
View File
@@ -11,6 +11,7 @@ import (
"time" "time"
"github.com/matttproud/golang_protobuf_extensions/pbutil" "github.com/matttproud/golang_protobuf_extensions/pbutil"
"github.com/prometheus/alertmanager/flushlog/flushlogpb"
"github.com/prometheus/alertmanager/nflog/nflogpb" "github.com/prometheus/alertmanager/nflog/nflogpb"
"github.com/prometheus/alertmanager/silence/silencepb" "github.com/prometheus/alertmanager/silence/silencepb"
"github.com/prometheus/common/model" "github.com/prometheus/common/model"
@@ -228,15 +229,13 @@ func (f *FakeOrgStore) FetchOrgIds(_ context.Context) ([]int64, error) {
return f.orgs, nil return f.orgs, nil
} }
type NoValidation struct { type NoValidation struct{}
}
func (n NoValidation) Validate(_ models.NotificationSettings) error { func (n NoValidation) Validate(_ models.NotificationSettings) error {
return nil return nil
} }
type RejectingValidation struct { type RejectingValidation struct{}
}
func (n RejectingValidation) Validate(s models.NotificationSettings) error { func (n RejectingValidation) Validate(s models.NotificationSettings) error {
return ErrorReceiverDoesNotExist{ErrorReferenceInvalid: ErrorReferenceInvalid{Reference: s.Receiver}} return ErrorReceiverDoesNotExist{ErrorReferenceInvalid: ErrorReferenceInvalid{Reference: s.Receiver}}
@@ -365,6 +364,51 @@ func createNotificationLog(groupKey string, receiverName string, sentAt, expires
} }
} }
// https://github.com/grafana/prometheus-alertmanager/blob/main/flushlog/flushlog.go#L136-L136
type flushLogState map[uint64]*flushlogpb.MeshFlushLog
func (s flushLogState) MarshalBinary() ([]byte, error) {
var buf bytes.Buffer
for _, e := range s {
if _, err := pbutil.WriteDelimited(&buf, e); err != nil {
return nil, err
}
}
return buf.Bytes(), nil
}
func createFlushLog(groupFingerprint uint64, ts, expiresAt time.Time) *flushlogpb.MeshFlushLog {
return &flushlogpb.MeshFlushLog{
FlushLog: &flushlogpb.FlushLog{
GroupFingerprint: groupFingerprint,
Timestamp: ts,
},
ExpiresAt: expiresAt,
}
}
// decodeFlushLogState copied from decodeState in prometheus-alertmanager/flushlog/flushlog.go
func decodeFlushLogState(r io.Reader) (flushLogState, error) {
st := flushLogState{}
for {
var e flushlogpb.MeshFlushLog
_, err := pbutil.ReadDelimited(r, &e)
if err == nil {
if e.FlushLog == nil || e.FlushLog.GroupFingerprint == 0 || e.FlushLog.Timestamp.IsZero() {
return nil, errInvalidState
}
st[e.FlushLog.GroupFingerprint] = &e
continue
}
if errors.Is(err, io.EOF) {
break
}
return nil, err
}
return st, nil
}
type call struct { type call struct {
Method string Method string
Args []interface{} Args []interface{}
+20 -4
View File
@@ -47,6 +47,7 @@ import (
type stateStore interface { type stateStore interface {
GetSilences(ctx context.Context) (string, error) GetSilences(ctx context.Context) (string, error)
GetNotificationLog(ctx context.Context) (string, error) GetNotificationLog(ctx context.Context) (string, error)
GetFlushLog(ctx context.Context) (string, error)
} }
// AutogenFn is a function that adds auto-generated routes to a configuration. // AutogenFn is a function that adds auto-generated routes to a configuration.
@@ -86,6 +87,8 @@ type Alertmanager struct {
promoteConfig bool promoteConfig bool
externalURL string externalURL string
runtimeConfig remoteClient.RuntimeConfig
} }
type AlertmanagerConfig struct { type AlertmanagerConfig struct {
@@ -111,6 +114,9 @@ type AlertmanagerConfig struct {
// Timeout for the HTTP client. // Timeout for the HTTP client.
Timeout time.Duration Timeout time.Duration
// RuntimeConfig specifies runtime behavior settings for the remote Alertmanager.
RuntimeConfig remoteClient.RuntimeConfig
} }
func (cfg *AlertmanagerConfig) Validate() error { func (cfg *AlertmanagerConfig) Validate() error {
@@ -203,6 +209,7 @@ func NewAlertmanager(ctx context.Context, cfg AlertmanagerConfig, store stateSto
externalURL: cfg.ExternalURL, externalURL: cfg.ExternalURL,
promoteConfig: cfg.PromoteConfig, promoteConfig: cfg.PromoteConfig,
smtp: cfg.SmtpConfig, smtp: cfg.SmtpConfig,
runtimeConfig: cfg.RuntimeConfig,
} }
// Parse the default configuration once and remember its hash so we can compare it later. // Parse the default configuration once and remember its hash so we can compare it later.
@@ -331,10 +338,11 @@ func (am *Alertmanager) buildConfiguration(ctx context.Context, raw []byte, crea
AlertmanagerConfig: mergeResult.Config, AlertmanagerConfig: mergeResult.Config,
Templates: templates, Templates: templates,
}, },
CreatedAt: createdAtEpoch, CreatedAt: createdAtEpoch,
Promoted: am.promoteConfig, Promoted: am.promoteConfig,
ExternalURL: am.externalURL, ExternalURL: am.externalURL,
SmtpConfig: am.smtp, SmtpConfig: am.smtp,
RuntimeConfig: am.runtimeConfig,
} }
cfgHash, err := calculateUserGrafanaConfigHash(payload) cfgHash, err := calculateUserGrafanaConfigHash(payload)
@@ -388,6 +396,8 @@ func (am *Alertmanager) GetRemoteState(ctx context.Context) (notifier.ExternalSt
rs.Silences = p.Data rs.Silences = p.Data
case "nfl": case "nfl":
rs.Nflog = p.Data rs.Nflog = p.Data
case "fls":
rs.FlushLog = p.Data
default: default:
return rs, fmt.Errorf("unknown part key %q", p.Key) return rs, fmt.Errorf("unknown part key %q", p.Key)
} }
@@ -677,6 +687,12 @@ func (am *Alertmanager) getFullState(ctx context.Context) (string, error) {
} }
parts = append(parts, alertingClusterPB.Part{Key: notifier.NotificationLogFilename, Data: []byte(notificationLog)}) parts = append(parts, alertingClusterPB.Part{Key: notifier.NotificationLogFilename, Data: []byte(notificationLog)})
flushLog, err := am.state.GetFlushLog(ctx)
if err != nil {
return "", fmt.Errorf("error getting flush log: %w", err)
}
parts = append(parts, alertingClusterPB.Part{Key: notifier.FlushLogFilename, Data: []byte(flushLog)})
fs := alertingClusterPB.FullState{ fs := alertingClusterPB.FullState{
Parts: parts, Parts: parts,
} }

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