Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 45e60f849b | |||
| d43f4b576a | |||
| f79f7ac33d | |||
| f03ee8d19c | |||
| eb37860388 | |||
| da7b70336c | |||
| 17817bdda7 | |||
| 5bed426fd8 | |||
| 8bc405d5ed | |||
| b8c9ee987e | |||
| 49e4d6760b | |||
| 665a54f02f | |||
| ba6a783997 | |||
| f704b8aa79 | |||
| c1a46fdcb5 | |||
| 7143324229 | |||
| 48625d67e5 | |||
| 8bad33de4c | |||
| 040854c8af | |||
| 987c1fc6b6 | |||
| 170ac31c5a | |||
| 0d1e0bc21c | |||
| afd84f0335 | |||
| d680537ea1 | |||
| 78d507d285 | |||
| 9d1d0e72c2 | |||
| fd955f90ac | |||
| ccb032f376 | |||
| cf452c167b | |||
| bd0140b6f0 | |||
| 215d25ef69 | |||
| d3beed7dd2 | |||
| e2f2011d9e | |||
| 6db51cbdb9 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+4
-22
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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=
|
||||||
|
|||||||
@@ -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
@@ -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:*",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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,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
@@ -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,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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {};
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
@@ -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,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';
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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 = '';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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 => ({
|
||||||
|
});
|
||||||
|
|
||||||
+1
-1
@@ -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({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Generated
+3
-1
@@ -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
|
||||||
|
|||||||
|
Generated
+9
-1
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{}
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user