Compare commits
31 Commits
wb/loading
...
dual-write
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5df03e531 | ||
|
|
2308b5458e | ||
|
|
bd0140b6f0 | ||
|
|
215d25ef69 | ||
|
|
d3beed7dd2 | ||
|
|
e2f2011d9e | ||
|
|
6db51cbdb9 | ||
|
|
82d8d44977 | ||
|
|
60abd9a159 | ||
|
|
6186aac5d4 | ||
|
|
a28076ef5e | ||
|
|
b687ca6b6d | ||
|
|
1d3f09d519 | ||
|
|
ec1ace398e | ||
|
|
fe5aa3e281 | ||
|
|
a01777eafa | ||
|
|
1a2c3bdbc9 | ||
|
|
8af89b1210 | ||
|
|
4b24e63e0b | ||
|
|
68e7d66e54 | ||
|
|
ef0601b85e | ||
|
|
e25d09ff3e | ||
|
|
b7269073b2 | ||
|
|
29dfdafad5 | ||
|
|
b7da61c260 | ||
|
|
0e6ad2e7c8 | ||
|
|
aae69c1e75 | ||
|
|
89dd3870b3 | ||
|
|
3b577e2c42 | ||
|
|
7ff004f775 | ||
|
|
4d26d0cd5c |
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -440,6 +440,7 @@ i18next.config.ts @grafana/grafana-frontend-platform
|
||||
/e2e-playwright/dashboards/TestDashboard.json @grafana/dashboards-squad @grafana/grafana-search-navigate-organise
|
||||
/e2e-playwright/dashboards/TestV2Dashboard.json @grafana/dashboards-squad
|
||||
/e2e-playwright/dashboards/V2DashWithRepeats.json @grafana/dashboards-squad
|
||||
/e2e-playwright/dashboards/V2DashWithRowRepeats.json @grafana/dashboards-squad
|
||||
/e2e-playwright/dashboards/V2DashWithTabRepeats.json @grafana/dashboards-squad
|
||||
/e2e-playwright/dashboards-suite/adhoc-filter-from-panel.spec.ts @grafana/datapro
|
||||
/e2e-playwright/dashboards-suite/dashboard-browse-nested.spec.ts @grafana/grafana-search-navigate-organise
|
||||
@@ -657,6 +658,7 @@ i18next.config.ts @grafana/grafana-frontend-platform
|
||||
/packages/grafana-runtime/src/services/LocationService.tsx @grafana/grafana-search-navigate-organise
|
||||
/packages/grafana-runtime/src/services/LocationSrv.ts @grafana/grafana-search-navigate-organise
|
||||
/packages/grafana-runtime/src/services/live.ts @grafana/dashboards-squad
|
||||
/packages/grafana-runtime/src/services/pluginMeta @grafana/plugins-platform-frontend
|
||||
/packages/grafana-runtime/src/utils/chromeHeaderHeight.ts @grafana/grafana-search-navigate-organise
|
||||
/packages/grafana-runtime/src/utils/DataSourceWithBackend* @grafana/grafana-datasources-core-services
|
||||
/packages/grafana-runtime/src/utils/licensing.ts @grafana/grafana-operator-experience-squad
|
||||
|
||||
@@ -71,11 +71,6 @@ func convertDashboardSpec_V2alpha1_to_V1beta1(in *dashv2alpha1.DashboardSpec) (m
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert panels: %w", err)
|
||||
}
|
||||
// Count total panels including those in collapsed rows
|
||||
totalPanelsConverted := countTotalPanels(panels)
|
||||
if totalPanelsConverted < len(in.Elements) {
|
||||
return nil, fmt.Errorf("some panels were not converted from v2alpha1 to v1beta1")
|
||||
}
|
||||
|
||||
if len(panels) > 0 {
|
||||
dashboard["panels"] = panels
|
||||
@@ -198,29 +193,6 @@ func convertLinksToV1(links []dashv2alpha1.DashboardDashboardLink) []map[string]
|
||||
return result
|
||||
}
|
||||
|
||||
// countTotalPanels counts all panels including those nested in collapsed row panels.
|
||||
func countTotalPanels(panels []interface{}) int {
|
||||
count := 0
|
||||
for _, p := range panels {
|
||||
panel, ok := p.(map[string]interface{})
|
||||
if !ok {
|
||||
count++
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this is a row panel with nested panels
|
||||
if panelType, ok := panel["type"].(string); ok && panelType == "row" {
|
||||
if nestedPanels, ok := panel["panels"].([]interface{}); ok {
|
||||
count += len(nestedPanels)
|
||||
}
|
||||
// Don't count the row itself as a panel element
|
||||
} else {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// convertPanelsFromElementsAndLayout converts V2 layout structures to V1 panel arrays.
|
||||
// V1 only supports a flat array of panels with row panels for grouping.
|
||||
// This function dispatches to the appropriate converter based on layout type:
|
||||
|
||||
@@ -290,7 +290,7 @@
|
||||
],
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "right",
|
||||
"placement": "bottom",
|
||||
"showLegend": true,
|
||||
"values": [
|
||||
"percent"
|
||||
@@ -304,7 +304,7 @@
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showLegend": true,
|
||||
"showLegend": false,
|
||||
"strokeWidth": 1,
|
||||
"text": {}
|
||||
},
|
||||
@@ -323,15 +323,6 @@
|
||||
}
|
||||
],
|
||||
"title": "Percent",
|
||||
"transformations": [
|
||||
{
|
||||
"id": "renameByRegex",
|
||||
"options": {
|
||||
"regex": "^Backend-(.*)$",
|
||||
"renamePattern": "b-$1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"type": "piechart"
|
||||
},
|
||||
{
|
||||
@@ -375,7 +366,7 @@
|
||||
],
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "right",
|
||||
"placement": "bottom",
|
||||
"showLegend": true,
|
||||
"values": [
|
||||
"value"
|
||||
@@ -389,7 +380,7 @@
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showLegend": true,
|
||||
"showLegend": false,
|
||||
"strokeWidth": 1,
|
||||
"text": {}
|
||||
},
|
||||
@@ -408,15 +399,6 @@
|
||||
}
|
||||
],
|
||||
"title": "Value",
|
||||
"transformations": [
|
||||
{
|
||||
"id": "renameByRegex",
|
||||
"options": {
|
||||
"regex": "(.*)",
|
||||
"renamePattern": "$1-how-much-wood-could-a-woodchuck-chuck-if-a-woodchuck-could-chuck-wood"
|
||||
}
|
||||
}
|
||||
],
|
||||
"type": "piechart"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
include ../sdk.mk
|
||||
|
||||
.PHONY: generate # Run Grafana App SDK code generation
|
||||
generate: install-app-sdk update-app-sdk
|
||||
.PHONY: internal-generate # Run Grafana App SDK code generation
|
||||
internal-generate: install-app-sdk update-app-sdk
|
||||
@$(APP_SDK_BIN) generate \
|
||||
--source=./kinds/ \
|
||||
--gogenpath=./pkg/apis \
|
||||
--grouping=group \
|
||||
--defencoding=none
|
||||
--defencoding=none
|
||||
|
||||
.PHONY: generate
|
||||
generate: internal-generate # copy files to packages/grafana-runtime/src/services/pluginMeta/types
|
||||
rm -f ./packages/grafana-runtime/src/services/pluginMeta/types/*.ts
|
||||
cp plugin/src/generated/meta/v0alpha1/meta_object_gen.ts ../../packages/grafana-runtime/src/services/pluginMeta/types/meta_object_gen.ts
|
||||
cp plugin/src/generated/meta/v0alpha1/types.spec.gen.ts ../../packages/grafana-runtime/src/services/pluginMeta/types/types.spec.gen.ts
|
||||
cp plugin/src/generated/meta/v0alpha1/types.status.gen.ts ../../packages/grafana-runtime/src/services/pluginMeta/types/types.status.gen.ts
|
||||
@@ -4,8 +4,7 @@ API documentation is available at http://localhost:3000/swagger?api=plugins.graf
|
||||
|
||||
## Codegen
|
||||
|
||||
- Go: `make generate`
|
||||
- Frontend: Follow instructions in this [README](../..//packages/grafana-api-clients/README.md)
|
||||
- Go and TypeScript: `make generate`
|
||||
|
||||
## Plugin sync
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ manifest: {
|
||||
v0alpha1Version: {
|
||||
served: true
|
||||
codegen: {
|
||||
ts: {enabled: false}
|
||||
ts: {enabled: true}
|
||||
go: {enabled: true}
|
||||
}
|
||||
kinds: [
|
||||
|
||||
@@ -13,9 +13,10 @@ const (
|
||||
)
|
||||
|
||||
// PluginAssetsCalculator is an interface for calculating plugin asset information.
|
||||
// LocalProvider requires this to calculate loading strategy.
|
||||
// LocalProvider requires this to calculate loading strategy and module hash.
|
||||
type PluginAssetsCalculator interface {
|
||||
LoadingStrategy(ctx context.Context, p pluginstore.Plugin) plugins.LoadingStrategy
|
||||
ModuleHash(ctx context.Context, p pluginstore.Plugin) string
|
||||
}
|
||||
|
||||
// LocalProvider retrieves plugin metadata for locally installed plugins.
|
||||
@@ -26,7 +27,7 @@ type LocalProvider struct {
|
||||
}
|
||||
|
||||
// NewLocalProvider creates a new LocalProvider for locally installed plugins.
|
||||
// pluginAssets is required for calculating loading strategy.
|
||||
// pluginAssets is required for calculating loading strategy and module hash.
|
||||
func NewLocalProvider(pluginStore pluginstore.Store, pluginAssets PluginAssetsCalculator) *LocalProvider {
|
||||
return &LocalProvider{
|
||||
store: pluginStore,
|
||||
@@ -42,7 +43,7 @@ func (p *LocalProvider) GetMeta(ctx context.Context, pluginID, version string) (
|
||||
}
|
||||
|
||||
loadingStrategy := p.pluginAssets.LoadingStrategy(ctx, plugin)
|
||||
moduleHash := plugin.ModuleHash
|
||||
moduleHash := p.pluginAssets.ModuleHash(ctx, plugin)
|
||||
|
||||
spec := pluginStorePluginToMeta(plugin, loadingStrategy, moduleHash)
|
||||
return &Result{
|
||||
|
||||
49
apps/plugins/plugin/src/generated/meta/v0alpha1/meta_object_gen.ts
generated
Normal file
49
apps/plugins/plugin/src/generated/meta/v0alpha1/meta_object_gen.ts
generated
Normal file
@@ -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;
|
||||
}
|
||||
30
apps/plugins/plugin/src/generated/meta/v0alpha1/types.metadata.gen.ts
generated
Normal file
30
apps/plugins/plugin/src/generated/meta/v0alpha1/types.metadata.gen.ts
generated
Normal file
@@ -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: {},
|
||||
});
|
||||
|
||||
278
apps/plugins/plugin/src/generated/meta/v0alpha1/types.spec.gen.ts
generated
Normal file
278
apps/plugins/plugin/src/generated/meta/v0alpha1/types.spec.gen.ts
generated
Normal file
@@ -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",
|
||||
});
|
||||
|
||||
30
apps/plugins/plugin/src/generated/meta/v0alpha1/types.status.gen.ts
generated
Normal file
30
apps/plugins/plugin/src/generated/meta/v0alpha1/types.status.gen.ts
generated
Normal file
@@ -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 => ({
|
||||
});
|
||||
|
||||
49
apps/plugins/plugin/src/generated/plugin/v0alpha1/plugin_object_gen.ts
generated
Normal file
49
apps/plugins/plugin/src/generated/plugin/v0alpha1/plugin_object_gen.ts
generated
Normal file
@@ -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;
|
||||
}
|
||||
30
apps/plugins/plugin/src/generated/plugin/v0alpha1/types.metadata.gen.ts
generated
Normal file
30
apps/plugins/plugin/src/generated/plugin/v0alpha1/types.metadata.gen.ts
generated
Normal file
@@ -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: {},
|
||||
});
|
||||
|
||||
13
apps/plugins/plugin/src/generated/plugin/v0alpha1/types.spec.gen.ts
generated
Normal file
13
apps/plugins/plugin/src/generated/plugin/v0alpha1/types.spec.gen.ts
generated
Normal file
@@ -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: "",
|
||||
});
|
||||
|
||||
30
apps/plugins/plugin/src/generated/plugin/v0alpha1/types.status.gen.ts
generated
Normal file
30
apps/plugins/plugin/src/generated/plugin/v0alpha1/types.status.gen.ts
generated
Normal file
@@ -0,0 +1,30 @@
|
||||
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
|
||||
|
||||
export interface OperatorState {
|
||||
// lastEvaluation is the ResourceVersion last evaluated
|
||||
lastEvaluation: string;
|
||||
// state describes the state of the lastEvaluation.
|
||||
// It is limited to three possible states for machine evaluation.
|
||||
state: "success" | "in_progress" | "failed";
|
||||
// descriptiveState is an optional more descriptive state field which has no requirements on format
|
||||
descriptiveState?: string;
|
||||
// details contains any extra information that is operator-specific
|
||||
details?: Record<string, any>;
|
||||
}
|
||||
|
||||
export const defaultOperatorState = (): OperatorState => ({
|
||||
lastEvaluation: "",
|
||||
state: "success",
|
||||
});
|
||||
|
||||
export interface Status {
|
||||
// operatorStates is a map of operator ID to operator state evaluations.
|
||||
// Any operator which consumes this kind SHOULD add its state evaluation information to this field.
|
||||
operatorStates?: Record<string, OperatorState>;
|
||||
// additionalFields is reserved for future use
|
||||
additionalFields?: Record<string, any>;
|
||||
}
|
||||
|
||||
export const defaultStatus = (): Status => ({
|
||||
});
|
||||
|
||||
@@ -248,7 +248,7 @@
|
||||
"legend": {
|
||||
"values": ["percent"],
|
||||
"displayMode": "table",
|
||||
"placement": "right"
|
||||
"placement": "bottom"
|
||||
},
|
||||
"pieType": "pie",
|
||||
"reduceOptions": {
|
||||
@@ -256,7 +256,7 @@
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showLegend": true,
|
||||
"showLegend": false,
|
||||
"strokeWidth": 1,
|
||||
"text": {}
|
||||
},
|
||||
@@ -272,15 +272,6 @@
|
||||
"timeFrom": null,
|
||||
"timeShift": null,
|
||||
"title": "Percent",
|
||||
"transformations": [
|
||||
{
|
||||
"id": "renameByRegex",
|
||||
"options": {
|
||||
"regex": "^Backend-(.*)$",
|
||||
"renamePattern": "b-$1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"type": "piechart"
|
||||
},
|
||||
{
|
||||
@@ -320,7 +311,7 @@
|
||||
"legend": {
|
||||
"values": ["value"],
|
||||
"displayMode": "table",
|
||||
"placement": "right"
|
||||
"placement": "bottom"
|
||||
},
|
||||
"pieType": "pie",
|
||||
"reduceOptions": {
|
||||
@@ -328,7 +319,7 @@
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showLegend": true,
|
||||
"showLegend": false,
|
||||
"strokeWidth": 1,
|
||||
"text": {}
|
||||
},
|
||||
@@ -344,15 +335,6 @@
|
||||
"timeFrom": null,
|
||||
"timeShift": null,
|
||||
"title": "Value",
|
||||
"transformations": [
|
||||
{
|
||||
"id": "renameByRegex",
|
||||
"options": {
|
||||
"regex": "(.*)",
|
||||
"renamePattern": "$1-how-much-wood-could-a-woodchuck-chuck-if-a-woodchuck-could-chuck-wood"
|
||||
}
|
||||
}
|
||||
],
|
||||
"type": "piechart"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -35,10 +35,10 @@ For Grafana Cloud users, Grafana Support is not authorised to make org role chan
|
||||
|
||||
## Grafana server administrators
|
||||
|
||||
A Grafana server administrator manages server-wide settings and access to resources such as organizations, users, and licenses. Grafana includes a default server administrator that you can use to manage all of Grafana, or you can divide that responsibility among other server administrators that you create.
|
||||
A Grafana server administrator (sometimes referred to as a **Grafana Admin**) manages server-wide settings and access to resources such as organizations, users, and licenses. Grafana includes a default server administrator that you can use to manage all of Grafana, or you can divide that responsibility among other server administrators that you create.
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
The server administrator role does not mean that the user is also a Grafana [organization administrator](#organization-roles).
|
||||
{{< admonition type="caution" >}}
|
||||
The server administrator role is distinct from the [organization administrator](#organization-roles) role.
|
||||
{{< /admonition >}}
|
||||
|
||||
A server administrator can perform the following tasks:
|
||||
@@ -50,7 +50,7 @@ A server administrator can perform the following tasks:
|
||||
- Upgrade the server to Grafana Enterprise.
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
The server administrator role does not exist in Grafana Cloud.
|
||||
The server administrator (Grafana Admin) role does not exist in Grafana Cloud.
|
||||
{{< /admonition >}}
|
||||
|
||||
To assign or remove server administrator privileges, see [Server user management](../user-management/server-user-management/assign-remove-server-admin-privileges/).
|
||||
|
||||
@@ -53,6 +53,11 @@ refs:
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/roles-and-permissions/access-control/custom-role-actions-scopes/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/account-management/authentication-and-permissions/access-control/custom-role-actions-scopes/
|
||||
rbac-terraform-provisioning:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/roles-and-permissions/access-control/rbac-terraform-provisioning/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/account-management/authentication-and-permissions/access-control/rbac-terraform-provisioning/
|
||||
rbac-grafana-provisioning:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/roles-and-permissions/access-control/rbac-grafana-provisioning/
|
||||
@@ -145,7 +150,13 @@ Refer to the [RBAC HTTP API](ref:api-rbac-get-a-role) for more details.
|
||||
|
||||
## Create custom roles
|
||||
|
||||
This section shows you how to create a custom RBAC role using Grafana provisioning and the HTTP API.
|
||||
This section shows you how to create a custom RBAC role using Grafana provisioning or the HTTP API.
|
||||
|
||||
Creating and editing custom roles is not currently possible in the Grafana UI. To manage custom roles, use one of the following methods:
|
||||
|
||||
- [Provisioning](ref:rbac-grafana-provisioning) (for self-managed instances)
|
||||
- [HTTP API](ref:api-rbac-create-a-new-custom-role)
|
||||
- [Terraform](ref:rbac-terraform-provisioning)
|
||||
|
||||
Create a custom role when basic roles and fixed roles do not meet your permissions requirements.
|
||||
|
||||
@@ -153,14 +164,101 @@ Create a custom role when basic roles and fixed roles do not meet your permissio
|
||||
|
||||
- [Plan your RBAC rollout strategy](ref:plan-rbac-rollout-strategy).
|
||||
- Determine which permissions you want to add to the custom role. To see a list of actions and scope, refer to [RBAC permissions, actions, and scopes](ref:custom-role-actions-scopes).
|
||||
- [Enable role provisioning](ref:rbac-grafana-provisioning).
|
||||
- Ensure that you have permissions to create a custom role.
|
||||
- By default, the Grafana Admin role has permission to create custom roles.
|
||||
- A Grafana Admin can delegate the custom role privilege to another user by creating a custom role with the relevant permissions and adding the `permissions:type:delegate` scope.
|
||||
|
||||
### Create custom roles using provisioning
|
||||
### Create custom roles using the HTTP API
|
||||
|
||||
[File-based provisioning](ref:rbac-grafana-provisioning) is one method you can use to create custom roles.
|
||||
The following examples show you how to create a custom role using the Grafana HTTP API. For more information about the HTTP API, refer to [Create a new custom role](ref:api-rbac-create-a-new-custom-role).
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
When you create a custom role you can only give it the same permissions you already have. For example, if you only have `users:create` permissions, then you can't create a role that includes other permissions.
|
||||
{{< /admonition >}}
|
||||
|
||||
The following example creates a `custom:users:admin` role and assigns the `users:create` action to it.
|
||||
|
||||
**Example request**
|
||||
|
||||
```
|
||||
curl --location --request POST '<grafana_url>/api/access-control/roles/' \
|
||||
--header 'Authorization: Basic YWRtaW46cGFzc3dvcmQ=' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data-raw '{
|
||||
"version": 1,
|
||||
"uid": "jZrmlLCkGksdka",
|
||||
"name": "custom:users:admin",
|
||||
"displayName": "custom users admin",
|
||||
"description": "My custom role which gives users permissions to create users",
|
||||
"global": true,
|
||||
"permissions": [
|
||||
{
|
||||
"action": "users:create"
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
**Example response**
|
||||
|
||||
```
|
||||
{
|
||||
"version": 1,
|
||||
"uid": "jZrmlLCkGksdka",
|
||||
"name": "custom:users:admin",
|
||||
"displayName": "custom users admin",
|
||||
"description": "My custom role which gives users permissions to create users",
|
||||
"global": true,
|
||||
"permissions": [
|
||||
{
|
||||
"action": "users:create"
|
||||
"updated": "2021-05-17T22:07:31.569936+02:00",
|
||||
"created": "2021-05-17T22:07:31.569935+02:00"
|
||||
}
|
||||
],
|
||||
"updated": "2021-05-17T22:07:31.564403+02:00",
|
||||
"created": "2021-05-17T22:07:31.564403+02:00"
|
||||
}
|
||||
```
|
||||
|
||||
Refer to the [RBAC HTTP API](ref:api-rbac-create-a-new-custom-role) for more details.
|
||||
|
||||
### Create custom roles using Terraform
|
||||
|
||||
You can use the [Grafana Terraform provider](https://registry.terraform.io/providers/grafana/grafana/latest/docs) to manage custom roles and their assignments. This is the recommended method for Grafana Cloud users who want to manage RBAC as code. For more information, refer to [Provisioning RBAC with Terraform](ref:rbac-terraform-provisioning).
|
||||
|
||||
The following example creates a custom role and assigns it to a team:
|
||||
|
||||
```terraform
|
||||
resource "grafana_role" "custom_folder_manager" {
|
||||
name = "custom:folders:manager"
|
||||
description = "Custom role for reading and creating folders"
|
||||
uid = "custom-folders-manager"
|
||||
version = 1
|
||||
global = true
|
||||
|
||||
permissions {
|
||||
action = "folders:read"
|
||||
scope = "folders:*"
|
||||
}
|
||||
|
||||
permissions {
|
||||
action = "folders:create"
|
||||
scope = "folders:uid:general" # Allows creating folders at the root level
|
||||
}
|
||||
}
|
||||
|
||||
resource "grafana_role_assignment" "custom_folder_manager_assignment" {
|
||||
role_uid = grafana_role.custom_folder_manager.uid
|
||||
teams = ["<TEAM_UID>"]
|
||||
}
|
||||
```
|
||||
|
||||
For more information, refer to the [`grafana_role`](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/role) and [`grafana_role_assignment`](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/role_assignment) documentation in the Terraform Registry.
|
||||
|
||||
### Create custom roles using file-based provisioning
|
||||
|
||||
You can use [file-based provisioning](ref:rbac-grafana-provisioning) to create custom roles for self-managed instances.
|
||||
|
||||
1. Open the YAML configuration file and locate the `roles` section.
|
||||
|
||||
@@ -251,61 +349,6 @@ roles:
|
||||
state: 'absent'
|
||||
```
|
||||
|
||||
### Create custom roles using the HTTP API
|
||||
|
||||
The following examples show you how to create a custom role using the Grafana HTTP API. For more information about the HTTP API, refer to [Create a new custom role](ref:api-rbac-create-a-new-custom-role).
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
You cannot create a custom role with permissions that you do not have. For example, if you only have `users:create` permissions, then you cannot create a role that includes other permissions.
|
||||
{{< /admonition >}}
|
||||
|
||||
The following example creates a `custom:users:admin` role and assigns the `users:create` action to it.
|
||||
|
||||
**Example request**
|
||||
|
||||
```
|
||||
curl --location --request POST '<grafana_url>/api/access-control/roles/' \
|
||||
--header 'Authorization: Basic YWRtaW46cGFzc3dvcmQ=' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data-raw '{
|
||||
"version": 1,
|
||||
"uid": "jZrmlLCkGksdka",
|
||||
"name": "custom:users:admin",
|
||||
"displayName": "custom users admin",
|
||||
"description": "My custom role which gives users permissions to create users",
|
||||
"global": true,
|
||||
"permissions": [
|
||||
{
|
||||
"action": "users:create"
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
**Example response**
|
||||
|
||||
```
|
||||
{
|
||||
"version": 1,
|
||||
"uid": "jZrmlLCkGksdka",
|
||||
"name": "custom:users:admin",
|
||||
"displayName": "custom users admin",
|
||||
"description": "My custom role which gives users permissions to create users",
|
||||
"global": true,
|
||||
"permissions": [
|
||||
{
|
||||
"action": "users:create"
|
||||
"updated": "2021-05-17T22:07:31.569936+02:00",
|
||||
"created": "2021-05-17T22:07:31.569935+02:00"
|
||||
}
|
||||
],
|
||||
"updated": "2021-05-17T22:07:31.564403+02:00",
|
||||
"created": "2021-05-17T22:07:31.564403+02:00"
|
||||
}
|
||||
```
|
||||
|
||||
Refer to the [RBAC HTTP API](ref:api-rbac-create-a-new-custom-role) for more details.
|
||||
|
||||
## Update basic role permissions
|
||||
|
||||
If the default basic role definitions do not meet your requirements, you can change their permissions.
|
||||
|
||||
@@ -6,7 +6,6 @@ description: Learn about RBAC Grafana provisioning and view an example YAML prov
|
||||
file that configures Grafana role assignments.
|
||||
labels:
|
||||
products:
|
||||
- cloud
|
||||
- enterprise
|
||||
menuTitle: Provisioning RBAC with Grafana
|
||||
title: Provisioning RBAC with Grafana
|
||||
@@ -52,11 +51,13 @@ refs:
|
||||
# Provisioning RBAC with Grafana
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
Available in [Grafana Enterprise](/docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise/) and [Grafana Cloud](/docs/grafana-cloud).
|
||||
Available in [Grafana Enterprise](/docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise/) for self-managed instances. This feature is not available in Grafana Cloud.
|
||||
{{< /admonition >}}
|
||||
|
||||
You can create, change or remove [Custom roles](ref:manage-rbac-roles-create-custom-roles-using-provisioning) and create or remove [basic role assignments](ref:assign-rbac-roles-assign-a-fixed-role-to-a-basic-role-using-provisioning), by adding one or more YAML configuration files in the `provisioning/access-control/` directory.
|
||||
|
||||
Because this method requires access to the file system where Grafana is running, it's only available for self-managed Grafana instances. To provision RBAC in Grafana Cloud, use [Terraform](ref:rbac-terraform-provisioning) or the [HTTP API](ref:api-rbac-create-and-manage-custom-roles).
|
||||
|
||||
Grafana performs provisioning during startup. After you make a change to the configuration file, you can reload it during runtime. You do not need to restart the Grafana server for your changes to take effect.
|
||||
|
||||
**Before you begin:**
|
||||
|
||||
@@ -2030,6 +2030,44 @@ For example: `disabled_labels=grafana_folder`
|
||||
|
||||
<hr>
|
||||
|
||||
### `[unified_alerting.state_history]`
|
||||
|
||||
This section configures where Grafana Alerting writes alert state history. Refer to [Configure alert state history](/docs/grafana/<GRAFANA_VERSION>/alerting/set-up/configure-alert-state-history/) for end-to-end setup and examples.
|
||||
|
||||
#### `enabled `
|
||||
|
||||
Enables recording alert state history. Default is `false`.
|
||||
|
||||
#### `backend `
|
||||
|
||||
Select the backend used to store alert state history. Supported values: `loki`, `prometheus`, `multiple`.
|
||||
|
||||
#### `loki_remote_url `
|
||||
|
||||
The URL of the Loki server used when `backend = loki` (or when `backend = multiple` and Loki is a primary/secondary).
|
||||
|
||||
#### `prometheus_target_datasource_uid `
|
||||
|
||||
Target Prometheus data source UID used for writing alert state changes when `backend = prometheus` (or when `backend = multiple` and Prometheus is a secondary).
|
||||
|
||||
#### `prometheus_metric_name `
|
||||
|
||||
Optional. Metric name for the alert state metric. Default is `GRAFANA_ALERTS`.
|
||||
|
||||
#### `prometheus_write_timeout `
|
||||
|
||||
Optional. Timeout for writing alert state data to the target data source. Default is `10s`.
|
||||
|
||||
#### `primary `
|
||||
|
||||
Used only when `backend = multiple`. Selects the primary backend (for example `loki`).
|
||||
|
||||
#### `secondaries `
|
||||
|
||||
Used only when `backend = multiple`. Comma-separated list of secondary backends (for example `prometheus`).
|
||||
|
||||
<hr>
|
||||
|
||||
### `[unified_alerting.state_history.annotations]`
|
||||
|
||||
This section controls retention of annotations automatically created while evaluating alert rules when alerting state history backend is configured to be annotations (see setting [unified_alerting.state_history].backend)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test, expect } from '@grafana/plugin-e2e';
|
||||
|
||||
import testV2DashWithRepeats from '../dashboards/V2DashWithRepeats.json';
|
||||
import testV2DashWithRowRepeats from '../dashboards/V2DashWithRowRepeats.json';
|
||||
|
||||
import {
|
||||
checkRepeatedPanelTitles,
|
||||
@@ -10,11 +11,14 @@ import {
|
||||
saveDashboard,
|
||||
importTestDashboard,
|
||||
goToEmbeddedPanel,
|
||||
goToPanelSnapshot,
|
||||
} from './utils';
|
||||
|
||||
const repeatTitleBase = 'repeat - ';
|
||||
const newTitleBase = 'edited rep - ';
|
||||
const repeatOptions = [1, 2, 3, 4];
|
||||
const getTitleInRepeatRow = (rowIndex: number, panelIndex: number) =>
|
||||
`repeated-row-${rowIndex}-repeated-panel-${panelIndex}`;
|
||||
|
||||
test.use({
|
||||
featureToggles: {
|
||||
@@ -165,9 +169,7 @@ test.describe(
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.backToDashboardButton)
|
||||
.click();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.DashboardEditPaneSplitter.primaryBody)
|
||||
@@ -217,9 +219,7 @@ test.describe(
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.backToDashboardButton)
|
||||
.click();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.DashboardEditPaneSplitter.primaryBody)
|
||||
@@ -405,5 +405,143 @@ test.describe(
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.headerContainer).all()
|
||||
).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('can view repeated panel in a repeated row', async ({ dashboardPage, selectors, page }) => {
|
||||
await importTestDashboard(
|
||||
page,
|
||||
selectors,
|
||||
'Custom grid repeats - view repeated panel in a repeated row',
|
||||
JSON.stringify(testV2DashWithRowRepeats)
|
||||
);
|
||||
|
||||
// make sure the repeated panel is present in multiple rows
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1)))
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(2, 2)))
|
||||
).toBeVisible();
|
||||
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1)))
|
||||
.hover();
|
||||
|
||||
await page.keyboard.press('v');
|
||||
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(2, 2)))
|
||||
).not.toBeVisible();
|
||||
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1)))
|
||||
).toBeVisible();
|
||||
|
||||
const repeatedPanelUrl = page.url();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// load view panel directly
|
||||
await page.goto(repeatedPanelUrl);
|
||||
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1)))
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(2, 2)))
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('can view embedded panel in a repeated row', async ({ dashboardPage, selectors, page }) => {
|
||||
const embedPanelTitle = 'embedded-panel';
|
||||
await importTestDashboard(
|
||||
page,
|
||||
selectors,
|
||||
'Custom grid repeats - view embedded repeated panel in a repeated row',
|
||||
JSON.stringify(testV2DashWithRowRepeats)
|
||||
);
|
||||
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1)))
|
||||
.hover();
|
||||
await page.keyboard.press('p+e');
|
||||
|
||||
await goToEmbeddedPanel(page);
|
||||
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1)))
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(2, 2)))
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
// there is a bug in the Snapshot feature that prevents the next two tests from passing
|
||||
// tracking issue: https://github.com/grafana/grafana/issues/114509
|
||||
test.skip('can view repeated panel inside snapshot', async ({ dashboardPage, selectors, page }) => {
|
||||
await importTestDashboard(
|
||||
page,
|
||||
selectors,
|
||||
'Custom grid repeats - view repeated panel inside snapshot',
|
||||
JSON.stringify(testV2DashWithRowRepeats)
|
||||
);
|
||||
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1)))
|
||||
.hover();
|
||||
await page.keyboard.press('p+s');
|
||||
|
||||
// click "Publish snapshot"
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.pages.ShareDashboardDrawer.ShareSnapshot.publishSnapshot)
|
||||
.click();
|
||||
|
||||
// click "Copy link" button in the snapshot drawer
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.pages.ShareDashboardDrawer.ShareSnapshot.copyUrlButton)
|
||||
.click();
|
||||
|
||||
await goToPanelSnapshot(page);
|
||||
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1)))
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(2, 2)))
|
||||
).not.toBeVisible();
|
||||
});
|
||||
test.skip('can view single panel in a repeated row inside snapshot', async ({ dashboardPage, selectors, page }) => {
|
||||
await importTestDashboard(
|
||||
page,
|
||||
selectors,
|
||||
'Custom grid repeats - view single panel inside snapshot',
|
||||
JSON.stringify(testV2DashWithRowRepeats)
|
||||
);
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('single panel row 1')).hover();
|
||||
// open panel snapshot
|
||||
await page.keyboard.press('p+s');
|
||||
|
||||
// click "Publish snapshot"
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.pages.ShareDashboardDrawer.ShareSnapshot.publishSnapshot)
|
||||
.click();
|
||||
|
||||
// click "Copy link" button
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.pages.ShareDashboardDrawer.ShareSnapshot.copyUrlButton)
|
||||
.click();
|
||||
|
||||
await goToPanelSnapshot(page);
|
||||
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('single panel row 1'))
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1)))
|
||||
).toBeHidden();
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -218,6 +218,15 @@ export async function goToEmbeddedPanel(page: Page) {
|
||||
await page.goto(soloPanelUrl!);
|
||||
}
|
||||
|
||||
export async function goToPanelSnapshot(page: Page) {
|
||||
// extracting snapshot url from clipboard
|
||||
const snapshotUrl = await page.evaluate(() => navigator.clipboard.readText());
|
||||
|
||||
expect(snapshotUrl).toBeDefined();
|
||||
|
||||
await page.goto(snapshotUrl);
|
||||
}
|
||||
|
||||
export async function moveTab(
|
||||
dashboardPage: DashboardPage,
|
||||
page: Page,
|
||||
|
||||
486
e2e-playwright/dashboards/V2DashWithRowRepeats.json
Normal file
486
e2e-playwright/dashboards/V2DashWithRowRepeats.json
Normal file
@@ -0,0 +1,486 @@
|
||||
{
|
||||
"apiVersion": "dashboard.grafana.app/v2beta1",
|
||||
"kind": "Dashboard",
|
||||
"metadata": {
|
||||
"name": "ad8l8fz",
|
||||
"namespace": "default",
|
||||
"uid": "fLb2na54K8NZHvn8LfWGL1jhZh03Hy0xpV1KzMYgAXEX",
|
||||
"resourceVersion": "1",
|
||||
"generation": 2,
|
||||
"creationTimestamp": "2025-11-25T15:52:42Z",
|
||||
"labels": {
|
||||
"grafana.app/deprecatedInternalID": "20"
|
||||
},
|
||||
"annotations": {
|
||||
"grafana.app/createdBy": "user:aerwo725ot62od",
|
||||
"grafana.app/updatedBy": "user:aerwo725ot62od",
|
||||
"grafana.app/updatedTimestamp": "2025-11-25T15:52:42Z",
|
||||
"grafana.app/folder": ""
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"annotations": [
|
||||
{
|
||||
"kind": "AnnotationQuery",
|
||||
"spec": {
|
||||
"builtIn": true,
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"query": {
|
||||
"datasource": {
|
||||
"name": "-- Grafana --"
|
||||
},
|
||||
"group": "grafana",
|
||||
"kind": "DataQuery",
|
||||
"spec": {},
|
||||
"version": "v0"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"cursorSync": "Off",
|
||||
"description": "",
|
||||
"editable": true,
|
||||
"elements": {
|
||||
"panel-1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"data": {
|
||||
"kind": "QueryGroup",
|
||||
"spec": {
|
||||
"queries": [
|
||||
{
|
||||
"kind": "PanelQuery",
|
||||
"spec": {
|
||||
"hidden": false,
|
||||
"query": {
|
||||
"group": "",
|
||||
"kind": "DataQuery",
|
||||
"spec": {},
|
||||
"version": "v0"
|
||||
},
|
||||
"refId": "A"
|
||||
}
|
||||
}
|
||||
],
|
||||
"queryOptions": {},
|
||||
"transformations": []
|
||||
}
|
||||
},
|
||||
"description": "",
|
||||
"id": 4,
|
||||
"links": [],
|
||||
"title": "repeated-row-$c4-repeated-panel-$c3",
|
||||
"vizConfig": {
|
||||
"group": "timeseries",
|
||||
"kind": "VizConfig",
|
||||
"spec": {
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"barWidthFactor": 0.6,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "auto",
|
||||
"showValues": false,
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": 0
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"hideZeros": false,
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
}
|
||||
},
|
||||
"version": "12.4.0-pre"
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel-2": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"data": {
|
||||
"kind": "QueryGroup",
|
||||
"spec": {
|
||||
"queries": [
|
||||
{
|
||||
"kind": "PanelQuery",
|
||||
"spec": {
|
||||
"hidden": false,
|
||||
"query": {
|
||||
"group": "",
|
||||
"kind": "DataQuery",
|
||||
"spec": {},
|
||||
"version": "v0"
|
||||
},
|
||||
"refId": "A"
|
||||
}
|
||||
}
|
||||
],
|
||||
"queryOptions": {},
|
||||
"transformations": []
|
||||
}
|
||||
},
|
||||
"description": "",
|
||||
"id": 2,
|
||||
"links": [],
|
||||
"title": "single panel row $c4",
|
||||
"vizConfig": {
|
||||
"group": "timeseries",
|
||||
"kind": "VizConfig",
|
||||
"spec": {
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"barWidthFactor": 0.6,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "auto",
|
||||
"showValues": false,
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": 0
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"hideZeros": false,
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
}
|
||||
},
|
||||
"version": "12.4.0-pre"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"layout": {
|
||||
"kind": "RowsLayout",
|
||||
"spec": {
|
||||
"rows": [
|
||||
{
|
||||
"kind": "RowsLayoutRow",
|
||||
"spec": {
|
||||
"collapse": false,
|
||||
"layout": {
|
||||
"kind": "GridLayout",
|
||||
"spec": {
|
||||
"items": [
|
||||
{
|
||||
"kind": "GridLayoutItem",
|
||||
"spec": {
|
||||
"element": {
|
||||
"kind": "ElementReference",
|
||||
"name": "panel-1"
|
||||
},
|
||||
"height": 10,
|
||||
"repeat": {
|
||||
"direction": "h",
|
||||
"mode": "variable",
|
||||
"value": "c3"
|
||||
},
|
||||
"width": 24,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "GridLayoutItem",
|
||||
"spec": {
|
||||
"element": {
|
||||
"kind": "ElementReference",
|
||||
"name": "panel-2"
|
||||
},
|
||||
"height": 8,
|
||||
"width": 12,
|
||||
"x": 0,
|
||||
"y": 10
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"repeat": {
|
||||
"mode": "variable",
|
||||
"value": "c4"
|
||||
},
|
||||
"title": "Repeated row $c4"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"liveNow": false,
|
||||
"preload": false,
|
||||
"tags": [],
|
||||
"timeSettings": {
|
||||
"autoRefresh": "",
|
||||
"autoRefreshIntervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
|
||||
"fiscalYearStartMonth": 0,
|
||||
"from": "now-6h",
|
||||
"hideTimepicker": false,
|
||||
"timezone": "browser",
|
||||
"to": "now"
|
||||
},
|
||||
"title": "test-e2e-repeats",
|
||||
"variables": [
|
||||
{
|
||||
"kind": "CustomVariable",
|
||||
"spec": {
|
||||
"allowCustomValue": true,
|
||||
"current": {
|
||||
"text": ["1", "2", "3", "4"],
|
||||
"value": ["1", "2", "3", "4"]
|
||||
},
|
||||
"hide": "dontHide",
|
||||
"includeAll": true,
|
||||
"multi": true,
|
||||
"name": "c1",
|
||||
"options": [
|
||||
{
|
||||
"selected": true,
|
||||
"text": "1",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"selected": true,
|
||||
"text": "2",
|
||||
"value": "2"
|
||||
},
|
||||
{
|
||||
"selected": true,
|
||||
"text": "3",
|
||||
"value": "3"
|
||||
},
|
||||
{
|
||||
"selected": true,
|
||||
"text": "4",
|
||||
"value": "4"
|
||||
}
|
||||
],
|
||||
"query": "1,2,3,4",
|
||||
"skipUrlSync": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "CustomVariable",
|
||||
"spec": {
|
||||
"allowCustomValue": true,
|
||||
"current": {
|
||||
"text": ["A", "B", "C", "D"],
|
||||
"value": ["A", "B", "C", "D"]
|
||||
},
|
||||
"hide": "dontHide",
|
||||
"includeAll": true,
|
||||
"multi": true,
|
||||
"name": "c2",
|
||||
"options": [
|
||||
{
|
||||
"selected": true,
|
||||
"text": "A",
|
||||
"value": "A"
|
||||
},
|
||||
{
|
||||
"selected": true,
|
||||
"text": "B",
|
||||
"value": "B"
|
||||
},
|
||||
{
|
||||
"selected": true,
|
||||
"text": "C",
|
||||
"value": "C"
|
||||
},
|
||||
{
|
||||
"selected": true,
|
||||
"text": "D",
|
||||
"value": "D"
|
||||
}
|
||||
],
|
||||
"query": "A,B,C,D",
|
||||
"skipUrlSync": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "CustomVariable",
|
||||
"spec": {
|
||||
"allowCustomValue": true,
|
||||
"current": {
|
||||
"text": ["1", "2", "3", "4"],
|
||||
"value": ["1", "2", "3", "4"]
|
||||
},
|
||||
"hide": "dontHide",
|
||||
"includeAll": false,
|
||||
"multi": true,
|
||||
"name": "c3",
|
||||
"options": [
|
||||
{
|
||||
"selected": true,
|
||||
"text": "1",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"selected": true,
|
||||
"text": "2",
|
||||
"value": "2"
|
||||
},
|
||||
{
|
||||
"selected": true,
|
||||
"text": "3",
|
||||
"value": "3"
|
||||
},
|
||||
{
|
||||
"selected": true,
|
||||
"text": "4",
|
||||
"value": "4"
|
||||
}
|
||||
],
|
||||
"query": "1, 2, 3, 4",
|
||||
"skipUrlSync": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "CustomVariable",
|
||||
"spec": {
|
||||
"allowCustomValue": true,
|
||||
"current": {
|
||||
"text": ["1", "2", "3", "4"],
|
||||
"value": ["1", "2", "3", "4"]
|
||||
},
|
||||
"hide": "dontHide",
|
||||
"includeAll": false,
|
||||
"multi": true,
|
||||
"name": "c4",
|
||||
"options": [
|
||||
{
|
||||
"selected": true,
|
||||
"text": "1",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"selected": true,
|
||||
"text": "2",
|
||||
"value": "2"
|
||||
},
|
||||
{
|
||||
"selected": true,
|
||||
"text": "3",
|
||||
"value": "3"
|
||||
},
|
||||
{
|
||||
"selected": true,
|
||||
"text": "4",
|
||||
"value": "4"
|
||||
}
|
||||
],
|
||||
"query": "1, 2, 3, 4",
|
||||
"skipUrlSync": false
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {}
|
||||
}
|
||||
@@ -1337,6 +1337,11 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"public/app/features/alerting/unified/api/onCallApi.test.ts": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"public/app/features/alerting/unified/components/AnnotationDetailsField.tsx": {
|
||||
"@typescript-eslint/consistent-type-assertions": {
|
||||
"count": 1
|
||||
@@ -1377,6 +1382,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/alerting/unified/components/import-to-gma/ConfirmConvertModal.test.tsx": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/alerting/unified/components/import-to-gma/NamespaceAndGroupFilter.tsx": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 2
|
||||
@@ -1617,11 +1627,31 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/alerting/unified/mocks/server/configure.ts": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/alerting/unified/mocks/server/handlers/plugins.ts": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/alerting/unified/rule-editor/clone.utils.test.tsx": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"public/app/features/alerting/unified/rule-editor/formDefaults.ts": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 6
|
||||
}
|
||||
},
|
||||
"public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/alerting/unified/types/alerting.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 5
|
||||
@@ -1632,6 +1662,16 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/alerting/unified/utils/config.test.ts": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 6
|
||||
}
|
||||
},
|
||||
"public/app/features/alerting/unified/utils/config.ts": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/alerting/unified/utils/datasource.ts": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 2
|
||||
@@ -1663,12 +1703,20 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/alerting/unified/utils/rules.test.ts": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/alerting/unified/utils/rules.ts": {
|
||||
"@typescript-eslint/consistent-type-assertions": {
|
||||
"count": 3
|
||||
},
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-syntax": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/annotations/components/StandardAnnotationQueryEditor.tsx": {
|
||||
@@ -1724,6 +1772,16 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/connections/components/AdvisorRedirectNotice/AdvisorRedirectNotice.test.tsx": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"public/app/features/connections/components/AdvisorRedirectNotice/AdvisorRedirectNotice.tsx": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/connections/tabs/ConnectData/ConnectData.tsx": {
|
||||
"@typescript-eslint/consistent-type-assertions": {
|
||||
"count": 1
|
||||
@@ -2063,6 +2121,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/dashboard/components/GenAI/utils.ts": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/dashboard/components/HelpWizard/HelpWizard.tsx": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 3
|
||||
@@ -2889,6 +2952,71 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 6
|
||||
}
|
||||
},
|
||||
"public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.test.ts": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 6
|
||||
}
|
||||
},
|
||||
"public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 6
|
||||
}
|
||||
},
|
||||
"public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.test.ts": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 6
|
||||
}
|
||||
},
|
||||
"public/app/features/plugins/extensions/usePluginComponent.test.tsx": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"public/app/features/plugins/extensions/usePluginComponents.test.tsx": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"public/app/features/plugins/extensions/usePluginFunctions.test.tsx": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"public/app/features/plugins/extensions/usePluginLinks.test.tsx": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"public/app/features/plugins/extensions/utils.test.tsx": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 27
|
||||
}
|
||||
},
|
||||
"public/app/features/plugins/extensions/utils.tsx": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 7
|
||||
}
|
||||
},
|
||||
"public/app/features/plugins/extensions/validators.test.tsx": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 30
|
||||
}
|
||||
},
|
||||
"public/app/features/plugins/extensions/validators.ts": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"public/app/features/plugins/sandbox/codeLoader.ts": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/plugins/sandbox/distortions.ts": {
|
||||
"@typescript-eslint/consistent-type-assertions": {
|
||||
"count": 1
|
||||
|
||||
@@ -117,6 +117,8 @@ module.exports = [
|
||||
'scripts/grafana-server/tmp',
|
||||
'packages/grafana-ui/src/graveyard', // deprecated UI components slated for removal
|
||||
'public/build-swagger', // swagger build output
|
||||
'apps/plugins/plugin/src/generated/meta/v0alpha1',
|
||||
'apps/plugins/plugin/src/generated/plugin/v0alpha1',
|
||||
],
|
||||
},
|
||||
...grafanaConfig,
|
||||
@@ -575,6 +577,42 @@ module.exports = [
|
||||
"Property[key.name='a11y'][value.type='ObjectExpression'] Property[key.name='test'][value.value='off']",
|
||||
message: 'Skipping a11y tests is not allowed. Please fix the component or story instead.',
|
||||
},
|
||||
{
|
||||
selector: 'MemberExpression[object.name="config"][property.name="apps"]',
|
||||
message:
|
||||
'Usage of config.apps is not allowed. Use the function getAppPluginMetas or useAppPluginMetas from @grafana/runtime instead',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [...commonTestIgnores],
|
||||
ignores: [
|
||||
// FIXME: Remove once all enterprise issues are fixed -
|
||||
// we don't have a suppressions file/approach for enterprise code yet
|
||||
...enterpriseIgnores,
|
||||
],
|
||||
rules: {
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
{
|
||||
selector: 'MemberExpression[object.name="config"][property.name="apps"]',
|
||||
message:
|
||||
'Usage of config.apps is not allowed. Use the function getAppPluginMetas or useAppPluginMetas from @grafana/runtime instead',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [...enterpriseIgnores],
|
||||
rules: {
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
{
|
||||
selector: 'MemberExpression[object.name="config"][property.name="apps"]',
|
||||
message:
|
||||
'Usage of config.apps is not allowed. Use the function getAppPluginMetas or useAppPluginMetas from @grafana/runtime instead',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -32,6 +32,7 @@ export type AppPluginConfig = {
|
||||
path: string;
|
||||
version: string;
|
||||
preload: boolean;
|
||||
/** @deprecated it will be removed in a future release */
|
||||
angular: AngularMeta;
|
||||
loadingStrategy: PluginLoadingStrategy;
|
||||
dependencies: PluginDependencies;
|
||||
@@ -219,6 +220,7 @@ export interface GrafanaConfig {
|
||||
snapshotEnabled: boolean;
|
||||
datasources: { [str: string]: DataSourceInstanceSettings };
|
||||
panels: { [key: string]: PanelPluginMeta };
|
||||
/** @deprecated it will be removed in a future release */
|
||||
apps: Record<string, AppPluginConfig>;
|
||||
auth: AuthSettings;
|
||||
minRefreshInterval: string;
|
||||
|
||||
@@ -984,6 +984,11 @@ export interface FeatureToggles {
|
||||
*/
|
||||
recentlyViewedDashboards?: boolean;
|
||||
/**
|
||||
* A/A test for recently viewed dashboards feature
|
||||
* @default false
|
||||
*/
|
||||
experimentRecentlyViewedDashboards?: boolean;
|
||||
/**
|
||||
* Enable configuration of alert enrichments in Grafana Cloud.
|
||||
* @default false
|
||||
*/
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface PluginError {
|
||||
pluginType?: PluginType;
|
||||
}
|
||||
|
||||
/** @deprecated it will be removed in a future release */
|
||||
export interface AngularMeta {
|
||||
detected: boolean;
|
||||
hideDeprecation: boolean;
|
||||
|
||||
@@ -86,6 +86,7 @@ export class GrafanaBootConfig {
|
||||
snapshotEnabled = true;
|
||||
datasources: { [str: string]: DataSourceInstanceSettings } = {};
|
||||
panels: { [key: string]: PanelPluginMeta } = {};
|
||||
/** @deprecated it will be removed in a future release, use isAppPluginInstalled or getAppPluginVersion instead */
|
||||
apps: Record<string, AppPluginConfigGrafanaData> = {};
|
||||
auth: AuthSettings = {};
|
||||
minRefreshInterval = '';
|
||||
|
||||
@@ -77,3 +77,5 @@ export {
|
||||
getCorrelationsService,
|
||||
setCorrelationsService,
|
||||
} from './services/CorrelationsService';
|
||||
export { getAppPluginVersion, isAppPluginInstalled } from './services/pluginMeta/apps';
|
||||
export { useAppPluginInstalled, useAppPluginVersion } from './services/pluginMeta/hooks';
|
||||
|
||||
@@ -29,3 +29,5 @@ export {
|
||||
export { UserStorage } from '../utils/userStorage';
|
||||
|
||||
export { initOpenFeature, evaluateBooleanFlag } from './openFeature';
|
||||
export { getAppPluginMeta, getAppPluginMetas, setAppPluginMetas } from '../services/pluginMeta/apps';
|
||||
export { useAppPluginMeta, useAppPluginMetas } from '../services/pluginMeta/hooks';
|
||||
|
||||
258
packages/grafana-runtime/src/services/pluginMeta/apps.test.ts
Normal file
258
packages/grafana-runtime/src/services/pluginMeta/apps.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
71
packages/grafana-runtime/src/services/pluginMeta/apps.ts
Normal file
71
packages/grafana-runtime/src/services/pluginMeta/apps.ts
Normal file
@@ -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);
|
||||
}
|
||||
214
packages/grafana-runtime/src/services/pluginMeta/hooks.test.tsx
Normal file
214
packages/grafana-runtime/src/services/pluginMeta/hooks.test.tsx
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
35
packages/grafana-runtime/src/services/pluginMeta/hooks.tsx
Normal file
35
packages/grafana-runtime/src/services/pluginMeta/hooks.tsx
Normal file
@@ -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);
|
||||
};
|
||||
153
packages/grafana-runtime/src/services/pluginMeta/plugins.test.ts
Normal file
153
packages/grafana-runtime/src/services/pluginMeta/plugins.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
41
packages/grafana-runtime/src/services/pluginMeta/plugins.ts
Normal file
41
packages/grafana-runtime/src/services/pluginMeta/plugins.ts
Normal file
@@ -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
10
packages/grafana-runtime/src/services/pluginMeta/types.ts
Normal file
10
packages/grafana-runtime/src/services/pluginMeta/types.ts
Normal file
@@ -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[];
|
||||
}
|
||||
49
packages/grafana-runtime/src/services/pluginMeta/types/meta_object_gen.ts
generated
Normal file
49
packages/grafana-runtime/src/services/pluginMeta/types/meta_object_gen.ts
generated
Normal file
@@ -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;
|
||||
}
|
||||
278
packages/grafana-runtime/src/services/pluginMeta/types/types.spec.gen.ts
generated
Normal file
278
packages/grafana-runtime/src/services/pluginMeta/types/types.spec.gen.ts
generated
Normal file
@@ -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",
|
||||
});
|
||||
|
||||
30
packages/grafana-runtime/src/services/pluginMeta/types/types.status.gen.ts
generated
Normal file
30
packages/grafana-runtime/src/services/pluginMeta/types/types.status.gen.ts
generated
Normal file
@@ -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,78 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { VizLegendTable } from './VizLegendTable';
|
||||
import { VizLegendItem } from './types';
|
||||
|
||||
describe('VizLegendTable', () => {
|
||||
const mockItems: VizLegendItem[] = [
|
||||
{ label: 'Series 1', color: 'red', yAxis: 1 },
|
||||
{ label: 'Series 2', color: 'blue', yAxis: 1 },
|
||||
{ label: 'Series 3', color: 'green', yAxis: 1 },
|
||||
];
|
||||
|
||||
it('renders without crashing', () => {
|
||||
const { container } = render(<VizLegendTable items={mockItems} placement="bottom" />);
|
||||
expect(container.querySelector('table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders all items', () => {
|
||||
render(<VizLegendTable items={mockItems} placement="bottom" />);
|
||||
expect(screen.getByText('Series 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Series 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Series 3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders table headers when items have display values', () => {
|
||||
const itemsWithStats: VizLegendItem[] = [
|
||||
{
|
||||
label: 'Series 1',
|
||||
color: 'red',
|
||||
yAxis: 1,
|
||||
getDisplayValues: () => [
|
||||
{ numeric: 100, text: '100', title: 'Max' },
|
||||
{ numeric: 50, text: '50', title: 'Min' },
|
||||
],
|
||||
},
|
||||
];
|
||||
render(<VizLegendTable items={itemsWithStats} placement="bottom" />);
|
||||
expect(screen.getByText('Max')).toBeInTheDocument();
|
||||
expect(screen.getByText('Min')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders sort icon when sorted', () => {
|
||||
const { container } = render(
|
||||
<VizLegendTable items={mockItems} placement="bottom" sortBy="Name" sortDesc={false} />
|
||||
);
|
||||
expect(container.querySelector('svg')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onToggleSort when header is clicked', () => {
|
||||
const onToggleSort = jest.fn();
|
||||
render(<VizLegendTable items={mockItems} placement="bottom" onToggleSort={onToggleSort} isSortable={true} />);
|
||||
const header = screen.getByText('Name');
|
||||
header.click();
|
||||
expect(onToggleSort).toHaveBeenCalledWith('Name');
|
||||
});
|
||||
|
||||
it('does not call onToggleSort when not sortable', () => {
|
||||
const onToggleSort = jest.fn();
|
||||
render(<VizLegendTable items={mockItems} placement="bottom" onToggleSort={onToggleSort} isSortable={false} />);
|
||||
const header = screen.getByText('Name');
|
||||
header.click();
|
||||
expect(onToggleSort).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders with long labels', () => {
|
||||
const itemsWithLongLabels: VizLegendItem[] = [
|
||||
{
|
||||
label: 'This is a very long series name that should be scrollable within its table cell',
|
||||
color: 'red',
|
||||
yAxis: 1,
|
||||
},
|
||||
];
|
||||
render(<VizLegendTable items={itemsWithLongLabels} placement="bottom" />);
|
||||
expect(
|
||||
screen.getByText('This is a very long series name that should be scrollable within its table cell')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,112 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { LegendTableItem } from './VizLegendTableItem';
|
||||
import { VizLegendItem } from './types';
|
||||
|
||||
describe('LegendTableItem', () => {
|
||||
const mockItem: VizLegendItem = {
|
||||
label: 'Series 1',
|
||||
color: 'red',
|
||||
yAxis: 1,
|
||||
};
|
||||
|
||||
it('renders without crashing', () => {
|
||||
const { container } = render(
|
||||
<table>
|
||||
<tbody>
|
||||
<LegendTableItem item={mockItem} />
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(container.querySelector('tr')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders label text', () => {
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<LegendTableItem item={mockItem} />
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(screen.getByText('Series 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with long label text', () => {
|
||||
const longLabelItem: VizLegendItem = {
|
||||
...mockItem,
|
||||
label: 'This is a very long series name that should be scrollable in the table cell',
|
||||
};
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<LegendTableItem item={longLabelItem} />
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(
|
||||
screen.getByText('This is a very long series name that should be scrollable in the table cell')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders stat values when provided', () => {
|
||||
const itemWithStats: VizLegendItem = {
|
||||
...mockItem,
|
||||
getDisplayValues: () => [
|
||||
{ numeric: 100, text: '100', title: 'Max' },
|
||||
{ numeric: 50, text: '50', title: 'Min' },
|
||||
],
|
||||
};
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<LegendTableItem item={itemWithStats} />
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(screen.getByText('100')).toBeInTheDocument();
|
||||
expect(screen.getByText('50')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders right y-axis indicator when yAxis is 2', () => {
|
||||
const rightAxisItem: VizLegendItem = {
|
||||
...mockItem,
|
||||
yAxis: 2,
|
||||
};
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<LegendTableItem item={rightAxisItem} />
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(screen.getByText('(right y-axis)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onLabelClick when label is clicked', () => {
|
||||
const onLabelClick = jest.fn();
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<LegendTableItem item={mockItem} onLabelClick={onLabelClick} />
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
const button = screen.getByRole('button');
|
||||
button.click();
|
||||
expect(onLabelClick).toHaveBeenCalledWith(mockItem, expect.any(Object));
|
||||
});
|
||||
|
||||
it('does not call onClick when readonly', () => {
|
||||
const onLabelClick = jest.fn();
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<LegendTableItem item={mockItem} onLabelClick={onLabelClick} readonly={true} />
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -69,7 +69,7 @@ export const LegendTableItem = ({
|
||||
|
||||
return (
|
||||
<tr className={cx(styles.row, className)}>
|
||||
<td className={styles.labelCell}>
|
||||
<td>
|
||||
<span className={styles.itemWrapper}>
|
||||
<VizLegendSeriesIcon
|
||||
color={item.color}
|
||||
@@ -77,26 +77,24 @@ export const LegendTableItem = ({
|
||||
readonly={readonly}
|
||||
lineStyle={item.lineStyle}
|
||||
/>
|
||||
<div className={styles.labelCellInner}>
|
||||
<button
|
||||
disabled={readonly}
|
||||
type="button"
|
||||
title={item.label}
|
||||
onBlur={onMouseOut}
|
||||
onFocus={onMouseOver}
|
||||
onMouseOver={onMouseOver}
|
||||
onMouseOut={onMouseOut}
|
||||
onClick={!readonly ? onClick : undefined}
|
||||
className={cx(styles.label, item.disabled && styles.labelDisabled)}
|
||||
>
|
||||
{item.label}{' '}
|
||||
{item.yAxis === 2 && (
|
||||
<span className={styles.yAxisLabel}>
|
||||
<Trans i18nKey="grafana-ui.viz-legend.right-axis-indicator">(right y-axis)</Trans>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
disabled={readonly}
|
||||
type="button"
|
||||
title={item.label}
|
||||
onBlur={onMouseOut}
|
||||
onFocus={onMouseOver}
|
||||
onMouseOver={onMouseOver}
|
||||
onMouseOut={onMouseOut}
|
||||
onClick={!readonly ? onClick : undefined}
|
||||
className={cx(styles.label, item.disabled && styles.labelDisabled)}
|
||||
>
|
||||
{item.label}{' '}
|
||||
{item.yAxis === 2 && (
|
||||
<span className={styles.yAxisLabel}>
|
||||
<Trans i18nKey="grafana-ui.viz-legend.right-axis-indicator">(right y-axis)</Trans>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</span>
|
||||
</td>
|
||||
{item.getDisplayValues &&
|
||||
@@ -130,28 +128,6 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
background: rowHoverBg,
|
||||
},
|
||||
}),
|
||||
labelCell: css({
|
||||
label: 'LegendLabelCell',
|
||||
maxWidth: 0,
|
||||
width: '100%',
|
||||
minWidth: theme.spacing(16),
|
||||
}),
|
||||
labelCellInner: css({
|
||||
label: 'LegendLabelCellInner',
|
||||
display: 'block',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflowX: 'auto',
|
||||
overflowY: 'hidden',
|
||||
paddingRight: theme.spacing(3),
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none',
|
||||
maskImage: `linear-gradient(to right, black calc(100% - ${theme.spacing(3)}), transparent 100%)`,
|
||||
WebkitMaskImage: `linear-gradient(to right, black calc(100% - ${theme.spacing(3)}), transparent 100%)`,
|
||||
'&::-webkit-scrollbar': {
|
||||
display: 'none',
|
||||
},
|
||||
}),
|
||||
label: css({
|
||||
label: 'LegendLabel',
|
||||
whiteSpace: 'nowrap',
|
||||
@@ -159,6 +135,9 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
border: 'none',
|
||||
fontSize: 'inherit',
|
||||
padding: 0,
|
||||
maxWidth: '600px',
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
userSelect: 'text',
|
||||
}),
|
||||
labelDisabled: css({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#!/bin/bash -e
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
PERMISSIONS_OK=0
|
||||
|
||||
@@ -26,14 +27,14 @@ if [ ! -d "$GF_PATHS_PLUGINS" ]; then
|
||||
fi
|
||||
|
||||
if [ ! -z ${GF_AWS_PROFILES+x} ]; then
|
||||
> "$GF_PATHS_HOME/.aws/credentials"
|
||||
:> "$GF_PATHS_HOME/.aws/credentials"
|
||||
|
||||
for profile in ${GF_AWS_PROFILES}; do
|
||||
access_key_varname="GF_AWS_${profile}_ACCESS_KEY_ID"
|
||||
secret_key_varname="GF_AWS_${profile}_SECRET_ACCESS_KEY"
|
||||
region_varname="GF_AWS_${profile}_REGION"
|
||||
|
||||
if [ ! -z "${!access_key_varname}" -a ! -z "${!secret_key_varname}" ]; then
|
||||
if [ ! -z "${!access_key_varname}" ] && [ ! -z "${!secret_key_varname}" ]; then
|
||||
echo "[${profile}]" >> "$GF_PATHS_HOME/.aws/credentials"
|
||||
echo "aws_access_key_id = ${!access_key_varname}" >> "$GF_PATHS_HOME/.aws/credentials"
|
||||
echo "aws_secret_access_key = ${!secret_key_varname}" >> "$GF_PATHS_HOME/.aws/credentials"
|
||||
|
||||
@@ -161,7 +161,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
|
||||
AliasIDs: panel.AliasIDs,
|
||||
Info: panel.Info,
|
||||
Module: panel.Module,
|
||||
ModuleHash: panel.ModuleHash,
|
||||
ModuleHash: hs.pluginAssets.ModuleHash(c.Req.Context(), panel),
|
||||
BaseURL: panel.BaseURL,
|
||||
SkipDataQuery: panel.SkipDataQuery,
|
||||
Suggestions: panel.Suggestions,
|
||||
@@ -527,7 +527,7 @@ func (hs *HTTPServer) getFSDataSources(c *contextmodel.ReqContext, availablePlug
|
||||
JSONData: plugin.JSONData,
|
||||
Signature: plugin.Signature,
|
||||
Module: plugin.Module,
|
||||
ModuleHash: plugin.ModuleHash,
|
||||
ModuleHash: hs.pluginAssets.ModuleHash(c.Req.Context(), plugin),
|
||||
BaseURL: plugin.BaseURL,
|
||||
Angular: plugin.Angular,
|
||||
MultiValueFilterOperators: plugin.MultiValueFilterOperators,
|
||||
@@ -641,7 +641,7 @@ func (hs *HTTPServer) newAppDTO(ctx context.Context, plugin pluginstore.Plugin,
|
||||
LoadingStrategy: hs.pluginAssets.LoadingStrategy(ctx, plugin),
|
||||
Extensions: plugin.Extensions,
|
||||
Dependencies: plugin.Dependencies,
|
||||
ModuleHash: plugin.ModuleHash,
|
||||
ModuleHash: hs.pluginAssets.ModuleHash(ctx, plugin),
|
||||
Translations: plugin.Translations,
|
||||
BuildMode: plugin.BuildMode,
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ import (
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/plugins/config"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/pluginfakes"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/signature"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/signature/statickey"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
|
||||
accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
@@ -77,7 +79,8 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features featuremgmt.F
|
||||
|
||||
var pluginsAssets = passets
|
||||
if pluginsAssets == nil {
|
||||
pluginsAssets = pluginassets.ProvideService(pluginsCfg, pluginsCDN, pluginStore)
|
||||
sig := signature.ProvideService(pluginsCfg, statickey.New())
|
||||
pluginsAssets = pluginassets.ProvideService(pluginsCfg, pluginsCDN, sig, pluginStore)
|
||||
}
|
||||
|
||||
hs := &HTTPServer{
|
||||
@@ -711,6 +714,6 @@ func newPluginAssets() func() *pluginassets.Service {
|
||||
|
||||
func newPluginAssetsWithConfig(pCfg *config.PluginManagementCfg) func() *pluginassets.Service {
|
||||
return func() *pluginassets.Service {
|
||||
return pluginassets.ProvideService(pCfg, pluginscdn.ProvideService(pCfg), &pluginstore.FakePluginStore{})
|
||||
return pluginassets.ProvideService(pCfg, pluginscdn.ProvideService(pCfg), signature.ProvideService(pCfg, statickey.New()), &pluginstore.FakePluginStore{})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,7 +201,7 @@ func (hs *HTTPServer) GetPluginSettingByID(c *contextmodel.ReqContext) response.
|
||||
Includes: plugin.Includes,
|
||||
BaseUrl: plugin.BaseURL,
|
||||
Module: plugin.Module,
|
||||
ModuleHash: plugin.ModuleHash,
|
||||
ModuleHash: hs.pluginAssets.ModuleHash(c.Req.Context(), plugin),
|
||||
DefaultNavUrl: path.Join(hs.Cfg.AppSubURL, plugin.DefaultNavURL),
|
||||
State: plugin.State,
|
||||
Signature: plugin.Signature,
|
||||
|
||||
@@ -28,6 +28,8 @@ import (
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/filestore"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/pluginfakes"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/registry"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/signature"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/signature/statickey"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginerrs"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
|
||||
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
@@ -846,7 +848,8 @@ func Test_PluginsSettings(t *testing.T) {
|
||||
}
|
||||
pCfg := &config.PluginManagementCfg{}
|
||||
pluginCDN := pluginscdn.ProvideService(pCfg)
|
||||
hs.pluginAssets = pluginassets.ProvideService(pCfg, pluginCDN, hs.pluginStore)
|
||||
sig := signature.ProvideService(pCfg, statickey.New())
|
||||
hs.pluginAssets = pluginassets.ProvideService(pCfg, pluginCDN, sig, hs.pluginStore)
|
||||
hs.pluginErrorResolver = pluginerrs.ProvideStore(errTracker)
|
||||
hs.pluginsUpdateChecker, err = updatemanager.ProvidePluginsService(
|
||||
hs.Cfg,
|
||||
|
||||
@@ -140,9 +140,7 @@ type Licensing interface {
|
||||
}
|
||||
|
||||
type SignatureCalculator interface {
|
||||
// Calculate calculates the signature and returns both the signature and the manifest.
|
||||
// The manifest may be nil if the plugin is unsigned or if an error occurred.
|
||||
Calculate(ctx context.Context, src PluginSource, plugin FoundPlugin) (Signature, *PluginManifest, error)
|
||||
Calculate(ctx context.Context, src PluginSource, plugin FoundPlugin) (Signature, error)
|
||||
}
|
||||
|
||||
type KeyStore interface {
|
||||
|
||||
@@ -216,27 +216,10 @@ func TestLoader_Load(t *testing.T) {
|
||||
ExtensionPoints: []plugins.ExtensionPoint{},
|
||||
},
|
||||
},
|
||||
Class: plugins.ClassExternal,
|
||||
Module: "public/plugins/test-app/module.js",
|
||||
BaseURL: "public/plugins/test-app",
|
||||
FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/includes-symlinks")),
|
||||
Manifest: &plugins.PluginManifest{
|
||||
Plugin: "test-app",
|
||||
Version: "1.0.0",
|
||||
KeyID: "7e4d0c6a708866e7",
|
||||
Time: 1622547655175,
|
||||
Files: map[string]string{
|
||||
"dashboards/connections.json": "bea86da4be970b98dc4681802ab55cdef3441dc3eb3c654cb207948d17b25303",
|
||||
"dashboards/extra/memory.json": "7c042464941084caa91d0a9a2f188b05315a9796308a652ccdee31ca4fbcbfee",
|
||||
"plugin.json": "c59a51bf6d7ecd7a99608ccb99353390c8b973672a938a0247164324005c0caf",
|
||||
"symlink_to_txt": "9f32c171bf78a85d5cb77a48ab44f85578ee2942a1fc9f9ec4fde194ae4ff048",
|
||||
"text.txt": "9f32c171bf78a85d5cb77a48ab44f85578ee2942a1fc9f9ec4fde194ae4ff048",
|
||||
},
|
||||
ManifestVersion: "2.0.0",
|
||||
SignatureType: plugins.SignatureTypeGrafana,
|
||||
SignedByOrg: "grafana",
|
||||
SignedByOrgName: "Grafana Labs",
|
||||
},
|
||||
Class: plugins.ClassExternal,
|
||||
Module: "public/plugins/test-app/module.js",
|
||||
BaseURL: "public/plugins/test-app",
|
||||
FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/includes-symlinks")),
|
||||
Signature: "valid",
|
||||
SignatureType: plugins.SignatureTypeGrafana,
|
||||
SignatureOrg: "Grafana Labs",
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/plugins/log"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/signature"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginassets"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
|
||||
"github.com/grafana/grafana/pkg/plugins/tracing"
|
||||
"github.com/grafana/grafana/pkg/semconv"
|
||||
)
|
||||
@@ -55,7 +54,7 @@ func New(cfg *config.PluginManagementCfg, opts Opts) *Bootstrap {
|
||||
}
|
||||
|
||||
if opts.DecorateFuncs == nil {
|
||||
opts.DecorateFuncs = DefaultDecorateFuncs(cfg, pluginscdn.ProvideService(cfg))
|
||||
opts.DecorateFuncs = DefaultDecorateFuncs(cfg)
|
||||
}
|
||||
|
||||
return &Bootstrap{
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/plugins/config"
|
||||
"github.com/grafana/grafana/pkg/plugins/log"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginassets"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
|
||||
)
|
||||
|
||||
// DefaultConstructor implements the default ConstructFunc used for the Construct step of the Bootstrap stage.
|
||||
@@ -29,13 +28,12 @@ func DefaultConstructFunc(cfg *config.PluginManagementCfg, signatureCalculator p
|
||||
}
|
||||
|
||||
// DefaultDecorateFuncs are the default DecorateFuncs used for the Decorate step of the Bootstrap stage.
|
||||
func DefaultDecorateFuncs(cfg *config.PluginManagementCfg, cdn *pluginscdn.Service) []DecorateFunc {
|
||||
func DefaultDecorateFuncs(cfg *config.PluginManagementCfg) []DecorateFunc {
|
||||
return []DecorateFunc{
|
||||
AppDefaultNavURLDecorateFunc,
|
||||
TemplateDecorateFunc,
|
||||
AppChildDecorateFunc(),
|
||||
SkipHostEnvVarsDecorateFunc(cfg),
|
||||
ModuleHashDecorateFunc(cfg, cdn),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,30 +48,19 @@ func NewDefaultConstructor(cfg *config.PluginManagementCfg, signatureCalculator
|
||||
|
||||
// Construct will calculate the plugin's signature state and create the plugin using the pluginFactoryFunc.
|
||||
func (c *DefaultConstructor) Construct(ctx context.Context, src plugins.PluginSource, bundle *plugins.FoundBundle) ([]*plugins.Plugin, error) {
|
||||
// Calculate signature and cache manifest
|
||||
sig, manifest, err := c.signatureCalculator.Calculate(ctx, src, bundle.Primary)
|
||||
sig, err := c.signatureCalculator.Calculate(ctx, src, bundle.Primary)
|
||||
if err != nil {
|
||||
c.log.Warn("Could not calculate plugin signature state", "pluginId", bundle.Primary.JSONData.ID, "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
plugin, err := c.pluginFactoryFunc(bundle, src.PluginClass(ctx), sig)
|
||||
if err != nil {
|
||||
c.log.Error("Could not create primary plugin base", "pluginId", bundle.Primary.JSONData.ID, "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
plugin.Manifest = manifest
|
||||
|
||||
res := make([]*plugins.Plugin, 0, len(plugin.Children)+1)
|
||||
res = append(res, plugin)
|
||||
for _, child := range plugin.Children {
|
||||
// Child plugins use the parent's manifest
|
||||
if child.Parent != nil && child.Parent.Manifest != nil {
|
||||
child.Manifest = child.Parent.Manifest
|
||||
}
|
||||
res = append(res, child)
|
||||
}
|
||||
res = append(res, plugin.Children...)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
@@ -158,11 +145,3 @@ func SkipHostEnvVarsDecorateFunc(cfg *config.PluginManagementCfg) DecorateFunc {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
|
||||
// ModuleHashDecorateFunc returns a DecorateFunc that calculates and sets the module hash for the plugin.
|
||||
func ModuleHashDecorateFunc(cfg *config.PluginManagementCfg, cdn *pluginscdn.Service) DecorateFunc {
|
||||
return func(_ context.Context, p *plugins.Plugin) (*plugins.Plugin, error) {
|
||||
p.ModuleHash = pluginassets.CalculateModuleHash(p, cfg, cdn)
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/ProtonMail/go-crypto/openpgp"
|
||||
"github.com/ProtonMail/go-crypto/openpgp/clearsign"
|
||||
@@ -36,6 +37,26 @@ var (
|
||||
fromSlash = filepath.FromSlash
|
||||
)
|
||||
|
||||
// PluginManifest holds details for the file manifest
|
||||
type PluginManifest struct {
|
||||
Plugin string `json:"plugin"`
|
||||
Version string `json:"version"`
|
||||
KeyID string `json:"keyId"`
|
||||
Time int64 `json:"time"`
|
||||
Files map[string]string `json:"files"`
|
||||
|
||||
// V2 supported fields
|
||||
ManifestVersion string `json:"manifestVersion"`
|
||||
SignatureType plugins.SignatureType `json:"signatureType"`
|
||||
SignedByOrg string `json:"signedByOrg"`
|
||||
SignedByOrgName string `json:"signedByOrgName"`
|
||||
RootURLs []string `json:"rootUrls"`
|
||||
}
|
||||
|
||||
func (m *PluginManifest) IsV2() bool {
|
||||
return strings.HasPrefix(m.ManifestVersion, "2.")
|
||||
}
|
||||
|
||||
type Signature struct {
|
||||
kr plugins.KeyRetriever
|
||||
cfg *config.PluginManagementCfg
|
||||
@@ -66,14 +87,14 @@ func DefaultCalculator(cfg *config.PluginManagementCfg) *Signature {
|
||||
|
||||
// readPluginManifest attempts to read and verify the plugin manifest
|
||||
// if any error occurs or the manifest is not valid, this will return an error
|
||||
func (s *Signature) readPluginManifest(ctx context.Context, body []byte) (*plugins.PluginManifest, error) {
|
||||
func (s *Signature) readPluginManifest(ctx context.Context, body []byte) (*PluginManifest, error) {
|
||||
block, _ := clearsign.Decode(body)
|
||||
if block == nil {
|
||||
return nil, errors.New("unable to decode manifest")
|
||||
}
|
||||
|
||||
// Convert to a well typed object
|
||||
var manifest plugins.PluginManifest
|
||||
var manifest PluginManifest
|
||||
err := json.Unmarshal(block.Plaintext, &manifest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%v: %w", "Error parsing manifest JSON", err)
|
||||
@@ -90,7 +111,7 @@ var ErrSignatureTypeUnsigned = errors.New("plugin is unsigned")
|
||||
|
||||
// ReadPluginManifestFromFS reads the plugin manifest from the provided plugins.FS.
|
||||
// If the manifest is not found, it will return an error wrapping ErrSignatureTypeUnsigned.
|
||||
func (s *Signature) ReadPluginManifestFromFS(ctx context.Context, pfs plugins.FS) (*plugins.PluginManifest, error) {
|
||||
func (s *Signature) ReadPluginManifestFromFS(ctx context.Context, pfs plugins.FS) (*PluginManifest, error) {
|
||||
f, err := pfs.Open("MANIFEST.txt")
|
||||
if err != nil {
|
||||
if errors.Is(err, plugins.ErrFileNotExist) {
|
||||
@@ -119,9 +140,9 @@ func (s *Signature) ReadPluginManifestFromFS(ctx context.Context, pfs plugins.FS
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plugin plugins.FoundPlugin) (plugins.Signature, *plugins.PluginManifest, error) {
|
||||
func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plugin plugins.FoundPlugin) (plugins.Signature, error) {
|
||||
if defaultSignature, exists := src.DefaultSignature(ctx, plugin.JSONData.ID); exists {
|
||||
return defaultSignature, nil, nil
|
||||
return defaultSignature, nil
|
||||
}
|
||||
|
||||
manifest, err := s.ReadPluginManifestFromFS(ctx, plugin.FS)
|
||||
@@ -130,29 +151,29 @@ func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plu
|
||||
s.log.Warn("Plugin is unsigned", "id", plugin.JSONData.ID, "err", err)
|
||||
return plugins.Signature{
|
||||
Status: plugins.SignatureStatusUnsigned,
|
||||
}, nil, nil
|
||||
}, nil
|
||||
case err != nil:
|
||||
s.log.Warn("Plugin signature is invalid", "id", plugin.JSONData.ID, "err", err)
|
||||
return plugins.Signature{
|
||||
Status: plugins.SignatureStatusInvalid,
|
||||
}, nil, nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
if !manifest.IsV2() {
|
||||
return plugins.Signature{
|
||||
Status: plugins.SignatureStatusInvalid,
|
||||
}, nil, nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
fsFiles, err := plugin.FS.Files()
|
||||
if err != nil {
|
||||
return plugins.Signature{}, nil, fmt.Errorf("files: %w", err)
|
||||
return plugins.Signature{}, fmt.Errorf("files: %w", err)
|
||||
}
|
||||
if len(fsFiles) == 0 {
|
||||
s.log.Warn("No plugin file information in directory", "pluginId", plugin.JSONData.ID)
|
||||
return plugins.Signature{
|
||||
Status: plugins.SignatureStatusInvalid,
|
||||
}, nil, nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Make sure the versions all match
|
||||
@@ -160,20 +181,20 @@ func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plu
|
||||
s.log.Debug("Plugin signature invalid because ID or Version mismatch", "pluginId", plugin.JSONData.ID, "manifestPluginId", manifest.Plugin, "pluginVersion", plugin.JSONData.Info.Version, "manifestPluginVersion", manifest.Version)
|
||||
return plugins.Signature{
|
||||
Status: plugins.SignatureStatusModified,
|
||||
}, nil, nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Validate that plugin is running within defined root URLs
|
||||
if len(manifest.RootURLs) > 0 {
|
||||
if match, err := urlMatch(manifest.RootURLs, s.cfg.GrafanaAppURL, manifest.SignatureType); err != nil {
|
||||
s.log.Warn("Could not verify if root URLs match", "plugin", plugin.JSONData.ID, "rootUrls", manifest.RootURLs)
|
||||
return plugins.Signature{}, nil, err
|
||||
return plugins.Signature{}, err
|
||||
} else if !match {
|
||||
s.log.Warn("Could not find root URL that matches running application URL", "plugin", plugin.JSONData.ID,
|
||||
"appUrl", s.cfg.GrafanaAppURL, "rootUrls", manifest.RootURLs)
|
||||
return plugins.Signature{
|
||||
Status: plugins.SignatureStatusInvalid,
|
||||
}, nil, nil
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,7 +207,7 @@ func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plu
|
||||
s.log.Debug("Plugin signature invalid", "pluginId", plugin.JSONData.ID, "error", err)
|
||||
return plugins.Signature{
|
||||
Status: plugins.SignatureStatusModified,
|
||||
}, nil, nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
manifestFiles[p] = struct{}{}
|
||||
@@ -215,7 +236,7 @@ func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plu
|
||||
s.log.Warn("The following files were not included in the signature", "plugin", plugin.JSONData.ID, "files", unsignedFiles)
|
||||
return plugins.Signature{
|
||||
Status: plugins.SignatureStatusModified,
|
||||
}, nil, nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
s.log.Debug("Plugin signature valid", "id", plugin.JSONData.ID)
|
||||
@@ -223,7 +244,7 @@ func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plu
|
||||
Status: plugins.SignatureStatusValid,
|
||||
Type: manifest.SignatureType,
|
||||
SigningOrg: manifest.SignedByOrgName,
|
||||
}, manifest, nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
func verifyHash(mlog log.Logger, plugin plugins.FoundPlugin, path, hash string) error {
|
||||
@@ -300,7 +321,7 @@ func (r invalidFieldErr) Error() string {
|
||||
return fmt.Sprintf("valid manifest field %s is required", r.field)
|
||||
}
|
||||
|
||||
func (s *Signature) validateManifest(ctx context.Context, m plugins.PluginManifest, block *clearsign.Block) error {
|
||||
func (s *Signature) validateManifest(ctx context.Context, m PluginManifest, block *clearsign.Block) error {
|
||||
if len(m.Plugin) == 0 {
|
||||
return invalidFieldErr{field: "plugin"}
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ func TestCalculate(t *testing.T) {
|
||||
for _, tc := range tcs {
|
||||
basePath := filepath.Join(parentDir, "testdata/non-pvt-with-root-url/plugin")
|
||||
s := provideTestServiceWithConfig(&config.PluginManagementCfg{GrafanaAppURL: tc.appURL})
|
||||
sig, _, err := s.Calculate(context.Background(), &pluginfakes.FakePluginSource{
|
||||
sig, err := s.Calculate(context.Background(), &pluginfakes.FakePluginSource{
|
||||
PluginClassFunc: func(ctx context.Context) plugins.Class {
|
||||
return plugins.ClassExternal
|
||||
},
|
||||
@@ -192,7 +192,7 @@ func TestCalculate(t *testing.T) {
|
||||
|
||||
runningWindows = true
|
||||
s := provideDefaultTestService()
|
||||
sig, _, err := s.Calculate(context.Background(), &pluginfakes.FakePluginSource{
|
||||
sig, err := s.Calculate(context.Background(), &pluginfakes.FakePluginSource{
|
||||
PluginClassFunc: func(ctx context.Context) plugins.Class {
|
||||
return plugins.ClassExternal
|
||||
},
|
||||
@@ -260,7 +260,7 @@ func TestCalculate(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
pfs, err = newPathSeparatorOverrideFS(string(tc.platform.separator), pfs)
|
||||
require.NoError(t, err)
|
||||
sig, _, err := s.Calculate(context.Background(), &pluginfakes.FakePluginSource{
|
||||
sig, err := s.Calculate(context.Background(), &pluginfakes.FakePluginSource{
|
||||
PluginClassFunc: func(ctx context.Context) plugins.Class {
|
||||
return plugins.ClassExternal
|
||||
},
|
||||
@@ -396,7 +396,7 @@ func TestFSPathSeparatorFiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func fileList(manifest *plugins.PluginManifest) []string {
|
||||
func fileList(manifest *PluginManifest) []string {
|
||||
keys := make([]string, 0, len(manifest.Files))
|
||||
for k := range manifest.Files {
|
||||
keys = append(keys, k)
|
||||
@@ -682,52 +682,52 @@ func Test_urlMatch_private(t *testing.T) {
|
||||
func Test_validateManifest(t *testing.T) {
|
||||
tcs := []struct {
|
||||
name string
|
||||
manifest *plugins.PluginManifest
|
||||
manifest *PluginManifest
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
name: "Empty plugin field",
|
||||
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.Plugin = "" }),
|
||||
manifest: createV2Manifest(t, func(m *PluginManifest) { m.Plugin = "" }),
|
||||
expectedErr: "valid manifest field plugin is required",
|
||||
},
|
||||
{
|
||||
name: "Empty keyId field",
|
||||
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.KeyID = "" }),
|
||||
manifest: createV2Manifest(t, func(m *PluginManifest) { m.KeyID = "" }),
|
||||
expectedErr: "valid manifest field keyId is required",
|
||||
},
|
||||
{
|
||||
name: "Empty signedByOrg field",
|
||||
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.SignedByOrg = "" }),
|
||||
manifest: createV2Manifest(t, func(m *PluginManifest) { m.SignedByOrg = "" }),
|
||||
expectedErr: "valid manifest field signedByOrg is required",
|
||||
},
|
||||
{
|
||||
name: "Empty signedByOrgName field",
|
||||
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.SignedByOrgName = "" }),
|
||||
manifest: createV2Manifest(t, func(m *PluginManifest) { m.SignedByOrgName = "" }),
|
||||
expectedErr: "valid manifest field SignedByOrgName is required",
|
||||
},
|
||||
{
|
||||
name: "Empty signatureType field",
|
||||
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.SignatureType = "" }),
|
||||
manifest: createV2Manifest(t, func(m *PluginManifest) { m.SignatureType = "" }),
|
||||
expectedErr: "valid manifest field signatureType is required",
|
||||
},
|
||||
{
|
||||
name: "Invalid signatureType field",
|
||||
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.SignatureType = "invalidSignatureType" }),
|
||||
manifest: createV2Manifest(t, func(m *PluginManifest) { m.SignatureType = "invalidSignatureType" }),
|
||||
expectedErr: "valid manifest field signatureType is required",
|
||||
},
|
||||
{
|
||||
name: "Empty files field",
|
||||
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.Files = map[string]string{} }),
|
||||
manifest: createV2Manifest(t, func(m *PluginManifest) { m.Files = map[string]string{} }),
|
||||
expectedErr: "valid manifest field files is required",
|
||||
},
|
||||
{
|
||||
name: "Empty time field",
|
||||
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.Time = 0 }),
|
||||
manifest: createV2Manifest(t, func(m *PluginManifest) { m.Time = 0 }),
|
||||
expectedErr: "valid manifest field time is required",
|
||||
},
|
||||
{
|
||||
name: "Empty version field",
|
||||
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.Version = "" }),
|
||||
manifest: createV2Manifest(t, func(m *PluginManifest) { m.Version = "" }),
|
||||
expectedErr: "valid manifest field version is required",
|
||||
},
|
||||
}
|
||||
@@ -740,10 +740,10 @@ func Test_validateManifest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func createV2Manifest(t *testing.T, cbs ...func(*plugins.PluginManifest)) *plugins.PluginManifest {
|
||||
func createV2Manifest(t *testing.T, cbs ...func(*PluginManifest)) *PluginManifest {
|
||||
t.Helper()
|
||||
|
||||
m := &plugins.PluginManifest{
|
||||
m := &PluginManifest{
|
||||
Plugin: "grafana-test-app",
|
||||
Version: "2.5.3",
|
||||
KeyID: "7e4d0c6a708866e7",
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
package pluginassets
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"path"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/plugins/config"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
|
||||
)
|
||||
|
||||
// CalculateModuleHash calculates the module.js SHA256 hash for a plugin in the format expected by the browser for SRI checks.
|
||||
// The module hash is read from the plugin's cached manifest.
|
||||
// For nested plugins, the module hash is read from the root parent plugin's manifest.
|
||||
// If the plugin is unsigned or not a CDN plugin, an empty string is returned.
|
||||
func CalculateModuleHash(p *plugins.Plugin, cfg *config.PluginManagementCfg, cdn *pluginscdn.Service) string {
|
||||
if cfg == nil || !cfg.Features.SriChecksEnabled {
|
||||
return ""
|
||||
}
|
||||
|
||||
if !p.Signature.IsValid() {
|
||||
return ""
|
||||
}
|
||||
|
||||
rootParent := findRootParent(p)
|
||||
if rootParent.Manifest == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if !rootParent.Manifest.IsV2() {
|
||||
return ""
|
||||
}
|
||||
|
||||
if !cdnEnabled(rootParent, cdn) {
|
||||
return ""
|
||||
}
|
||||
|
||||
modulePath := getModulePathInManifest(p, rootParent)
|
||||
moduleHash, ok := rootParent.Manifest.Files[modulePath]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
return convertHashForSRI(moduleHash)
|
||||
}
|
||||
|
||||
// findRootParent returns the root parent plugin (the one that contains the manifest).
|
||||
// For non-nested plugins, it returns the plugin itself.
|
||||
func findRootParent(p *plugins.Plugin) *plugins.Plugin {
|
||||
root := p
|
||||
for root.Parent != nil {
|
||||
root = root.Parent
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
// getModulePathInManifest returns the path to module.js as it appears in the manifest.
|
||||
// For nested plugins, this is the relative path from the root parent to the plugin's module.js.
|
||||
// For non-nested plugins, this is simply "module.js".
|
||||
func getModulePathInManifest(p *plugins.Plugin, rootParent *plugins.Plugin) string {
|
||||
if p == rootParent {
|
||||
return "module.js"
|
||||
}
|
||||
|
||||
// Calculate the relative path from root parent to this plugin
|
||||
relPath, err := rootParent.FS.Rel(p.FS.Base())
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// MANIFEST.txt uses forward slashes as path separators
|
||||
pluginRootPath := filepath.ToSlash(relPath)
|
||||
return path.Join(pluginRootPath, "module.js")
|
||||
}
|
||||
|
||||
// convertHashForSRI takes a SHA256 hash string and returns it as expected by the browser for SRI checks.
|
||||
func convertHashForSRI(h string) string {
|
||||
hb, err := hex.DecodeString(h)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return "sha256-" + base64.StdEncoding.EncodeToString(hb)
|
||||
}
|
||||
|
||||
// cdnEnabled checks if a plugin is loaded via CDN
|
||||
func cdnEnabled(p *plugins.Plugin, cdn *pluginscdn.Service) bool {
|
||||
return p.FS.Type().CDN() || cdn.PluginSupported(p.ID)
|
||||
}
|
||||
@@ -1,356 +0,0 @@
|
||||
package pluginassets
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/plugins/config"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
|
||||
)
|
||||
|
||||
func TestConvertHashForSRI(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
hash string
|
||||
expHash string
|
||||
expErr bool
|
||||
}{
|
||||
{
|
||||
hash: "ddfcb449445064e6c39f0c20b15be3cb6a55837cf4781df23d02de005f436811",
|
||||
expHash: "sha256-3fy0SURQZObDnwwgsVvjy2pVg3z0eB3yPQLeAF9DaBE=",
|
||||
},
|
||||
{
|
||||
hash: "not-a-valid-hash",
|
||||
expErr: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.hash, func(t *testing.T) {
|
||||
r := convertHashForSRI(tc.hash)
|
||||
if tc.expErr {
|
||||
// convertHashForSRI returns empty string on error
|
||||
require.Empty(t, r)
|
||||
} else {
|
||||
require.Equal(t, tc.expHash, r)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateModuleHash(t *testing.T) {
|
||||
const (
|
||||
pluginID = "grafana-test-datasource"
|
||||
parentPluginID = "grafana-test-app"
|
||||
)
|
||||
|
||||
// Helper to create a plugin with manifest
|
||||
createPluginWithManifest := func(id string, manifest *plugins.PluginManifest, parent *plugins.Plugin) *plugins.Plugin {
|
||||
p := &plugins.Plugin{
|
||||
JSONData: plugins.JSONData{
|
||||
ID: id,
|
||||
},
|
||||
Signature: plugins.SignatureStatusValid,
|
||||
Manifest: manifest,
|
||||
}
|
||||
if parent != nil {
|
||||
p.Parent = parent
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// Helper to create a v2 manifest
|
||||
createV2Manifest := func(files map[string]string) *plugins.PluginManifest {
|
||||
return &plugins.PluginManifest{
|
||||
ManifestVersion: "2.0.0",
|
||||
Files: files,
|
||||
}
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
plugin *plugins.Plugin
|
||||
cfg *config.PluginManagementCfg
|
||||
cdn *pluginscdn.Service
|
||||
expModuleHash string
|
||||
}{
|
||||
{
|
||||
name: "should return empty string when cfg is nil",
|
||||
plugin: createPluginWithManifest(pluginID, createV2Manifest(map[string]string{
|
||||
"module.js": "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03",
|
||||
}), nil),
|
||||
cfg: nil,
|
||||
cdn: nil,
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
name: "should return empty string when SRI checks are disabled",
|
||||
plugin: createPluginWithManifest(pluginID, createV2Manifest(map[string]string{
|
||||
"module.js": "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03",
|
||||
}), nil),
|
||||
cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: false}},
|
||||
cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}),
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
name: "should return empty string for unsigned plugin",
|
||||
plugin: &plugins.Plugin{
|
||||
JSONData: plugins.JSONData{ID: pluginID},
|
||||
Signature: plugins.SignatureStatusUnsigned,
|
||||
Manifest: createV2Manifest(map[string]string{"module.js": "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"}),
|
||||
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid")),
|
||||
},
|
||||
cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}},
|
||||
cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}),
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
name: "should return module hash for valid plugin",
|
||||
plugin: &plugins.Plugin{
|
||||
JSONData: plugins.JSONData{ID: pluginID},
|
||||
Signature: plugins.SignatureStatusValid,
|
||||
Manifest: createV2Manifest(map[string]string{"module.js": "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"}),
|
||||
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid")),
|
||||
},
|
||||
cfg: &config.PluginManagementCfg{
|
||||
PluginsCDNURLTemplate: "https://cdn.example.com",
|
||||
Features: config.Features{SriChecksEnabled: true},
|
||||
PluginSettings: config.PluginSettings{
|
||||
pluginID: {"cdn": "true"},
|
||||
},
|
||||
},
|
||||
cdn: func() *pluginscdn.Service {
|
||||
cfg := &config.PluginManagementCfg{
|
||||
PluginsCDNURLTemplate: "https://cdn.example.com",
|
||||
PluginSettings: config.PluginSettings{
|
||||
pluginID: {"cdn": "true"},
|
||||
},
|
||||
}
|
||||
return pluginscdn.ProvideService(cfg)
|
||||
}(),
|
||||
expModuleHash: "sha256-WJG1tSLV3whtD/CxEPvZ0hu0/HFjrzTQgoai6Eb2vgM=",
|
||||
},
|
||||
{
|
||||
name: "should return empty string when manifest is nil",
|
||||
plugin: &plugins.Plugin{
|
||||
JSONData: plugins.JSONData{ID: pluginID},
|
||||
Signature: plugins.SignatureStatusValid,
|
||||
Manifest: nil,
|
||||
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid")),
|
||||
},
|
||||
cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}},
|
||||
cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}),
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
name: "should return empty string for v1 manifest",
|
||||
plugin: &plugins.Plugin{
|
||||
JSONData: plugins.JSONData{ID: pluginID},
|
||||
Signature: plugins.SignatureStatusValid,
|
||||
Manifest: &plugins.PluginManifest{
|
||||
ManifestVersion: "1.0.0",
|
||||
Files: map[string]string{"module.js": "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"},
|
||||
},
|
||||
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid")),
|
||||
},
|
||||
cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}},
|
||||
cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}),
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
name: "should return empty string when module.js is not in manifest",
|
||||
plugin: &plugins.Plugin{
|
||||
JSONData: plugins.JSONData{ID: pluginID},
|
||||
Signature: plugins.SignatureStatusValid,
|
||||
Manifest: createV2Manifest(map[string]string{"plugin.json": "129fab4e0584d18c778ebdfa5fe1a68edf2e5c5aeb8290b2c68182c857cb59f8"}),
|
||||
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid")),
|
||||
},
|
||||
cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}},
|
||||
cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}),
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
name: "missing module.js entry from MANIFEST.txt should not return module hash",
|
||||
plugin: &plugins.Plugin{
|
||||
JSONData: plugins.JSONData{ID: pluginID},
|
||||
Signature: plugins.SignatureStatusValid,
|
||||
Manifest: createV2Manifest(map[string]string{"plugin.json": "129fab4e0584d18c778ebdfa5fe1a68edf2e5c5aeb8290b2c68182c857cb59f8"}),
|
||||
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-no-module-js")),
|
||||
},
|
||||
cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}},
|
||||
cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}),
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
name: "signed status but missing MANIFEST.txt should not return module hash",
|
||||
plugin: &plugins.Plugin{
|
||||
JSONData: plugins.JSONData{ID: pluginID},
|
||||
Signature: plugins.SignatureStatusValid,
|
||||
Manifest: nil,
|
||||
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-no-manifest-txt")),
|
||||
},
|
||||
cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}},
|
||||
cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}),
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
// parentPluginID (/)
|
||||
// └── pluginID (/datasource)
|
||||
name: "nested plugin should return module hash from parent MANIFEST.txt",
|
||||
plugin: func() *plugins.Plugin {
|
||||
parent := &plugins.Plugin{
|
||||
JSONData: plugins.JSONData{ID: parentPluginID},
|
||||
Signature: plugins.SignatureStatusValid,
|
||||
Manifest: createV2Manifest(map[string]string{
|
||||
"module.js": "266c19bc148b22ddef2a288fc5f8f40855bda22ccf60be53340b4931e469ae2a",
|
||||
"datasource/module.js": "04d70db091d96c4775fb32ba5a8f84cc22893eb43afdb649726661d4425c6711",
|
||||
}),
|
||||
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested")),
|
||||
}
|
||||
return &plugins.Plugin{
|
||||
JSONData: plugins.JSONData{ID: pluginID},
|
||||
Signature: plugins.SignatureStatusValid,
|
||||
Parent: parent,
|
||||
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "datasource")),
|
||||
}
|
||||
}(),
|
||||
cfg: &config.PluginManagementCfg{
|
||||
PluginsCDNURLTemplate: "https://cdn.example.com",
|
||||
Features: config.Features{SriChecksEnabled: true},
|
||||
PluginSettings: config.PluginSettings{
|
||||
pluginID: {"cdn": "true"},
|
||||
parentPluginID: {"cdn": "true"},
|
||||
},
|
||||
},
|
||||
cdn: func() *pluginscdn.Service {
|
||||
cfg := &config.PluginManagementCfg{
|
||||
PluginsCDNURLTemplate: "https://cdn.example.com",
|
||||
PluginSettings: config.PluginSettings{
|
||||
pluginID: {"cdn": "true"},
|
||||
parentPluginID: {"cdn": "true"},
|
||||
},
|
||||
}
|
||||
return pluginscdn.ProvideService(cfg)
|
||||
}(),
|
||||
expModuleHash: "sha256-BNcNsJHZbEd1+zK6Wo+EzCKJPrQ6/bZJcmZh1EJcZxE=",
|
||||
},
|
||||
{
|
||||
// parentPluginID (/)
|
||||
// └── pluginID (/panels/one)
|
||||
name: "nested plugin deeper than one subfolder should return module hash from parent MANIFEST.txt",
|
||||
plugin: func() *plugins.Plugin {
|
||||
parent := &plugins.Plugin{
|
||||
JSONData: plugins.JSONData{ID: parentPluginID},
|
||||
Signature: plugins.SignatureStatusValid,
|
||||
Manifest: createV2Manifest(map[string]string{
|
||||
"module.js": "266c19bc148b22ddef2a288fc5f8f40855bda22ccf60be53340b4931e469ae2a",
|
||||
"panels/one/module.js": "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f",
|
||||
"datasource/module.js": "04d70db091d96c4775fb32ba5a8f84cc22893eb43afdb649726661d4425c6711",
|
||||
}),
|
||||
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested")),
|
||||
}
|
||||
return &plugins.Plugin{
|
||||
JSONData: plugins.JSONData{ID: pluginID},
|
||||
Signature: plugins.SignatureStatusValid,
|
||||
Parent: parent,
|
||||
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "panels", "one")),
|
||||
}
|
||||
}(),
|
||||
cfg: &config.PluginManagementCfg{
|
||||
PluginsCDNURLTemplate: "https://cdn.example.com",
|
||||
Features: config.Features{SriChecksEnabled: true},
|
||||
PluginSettings: config.PluginSettings{
|
||||
pluginID: {"cdn": "true"},
|
||||
parentPluginID: {"cdn": "true"},
|
||||
},
|
||||
},
|
||||
cdn: func() *pluginscdn.Service {
|
||||
cfg := &config.PluginManagementCfg{
|
||||
PluginsCDNURLTemplate: "https://cdn.example.com",
|
||||
PluginSettings: config.PluginSettings{
|
||||
pluginID: {"cdn": "true"},
|
||||
parentPluginID: {"cdn": "true"},
|
||||
},
|
||||
}
|
||||
return pluginscdn.ProvideService(cfg)
|
||||
}(),
|
||||
expModuleHash: "sha256-y9GsIoRkWg4emocipyn1vN0rgxIicocJxjYL7s3WFD8=",
|
||||
},
|
||||
{
|
||||
// grand-parent-app (/)
|
||||
// ├── parent-datasource (/datasource)
|
||||
// │ └── child-panel (/datasource/panels/one)
|
||||
name: "nested plugin of a nested plugin should return module hash from grandparent MANIFEST.txt",
|
||||
plugin: func() *plugins.Plugin {
|
||||
grandparent := &plugins.Plugin{
|
||||
JSONData: plugins.JSONData{ID: "grand-parent-app"},
|
||||
Signature: plugins.SignatureStatusValid,
|
||||
Manifest: createV2Manifest(map[string]string{
|
||||
"module.js": "266c19bc148b22ddef2a288fc5f8f40855bda22ccf60be53340b4931e469ae2a",
|
||||
"datasource/module.js": "04d70db091d96c4775fb32ba5a8f84cc22893eb43afdb649726661d4425c6711",
|
||||
"datasource/panels/one/module.js": "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f",
|
||||
}),
|
||||
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested")),
|
||||
}
|
||||
parent := &plugins.Plugin{
|
||||
JSONData: plugins.JSONData{ID: "parent-datasource"},
|
||||
Signature: plugins.SignatureStatusValid,
|
||||
Parent: grandparent,
|
||||
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested", "datasource")),
|
||||
}
|
||||
return &plugins.Plugin{
|
||||
JSONData: plugins.JSONData{ID: "child-panel"},
|
||||
Signature: plugins.SignatureStatusValid,
|
||||
Parent: parent,
|
||||
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested", "datasource", "panels", "one")),
|
||||
}
|
||||
}(),
|
||||
cfg: &config.PluginManagementCfg{
|
||||
PluginsCDNURLTemplate: "https://cdn.example.com",
|
||||
Features: config.Features{SriChecksEnabled: true},
|
||||
PluginSettings: config.PluginSettings{
|
||||
"child-panel": {"cdn": "true"},
|
||||
"parent-datasource": {"cdn": "true"},
|
||||
"grand-parent-app": {"cdn": "true"},
|
||||
},
|
||||
},
|
||||
cdn: func() *pluginscdn.Service {
|
||||
cfg := &config.PluginManagementCfg{
|
||||
PluginsCDNURLTemplate: "https://cdn.example.com",
|
||||
PluginSettings: config.PluginSettings{
|
||||
"child-panel": {"cdn": "true"},
|
||||
"parent-datasource": {"cdn": "true"},
|
||||
"grand-parent-app": {"cdn": "true"},
|
||||
},
|
||||
}
|
||||
return pluginscdn.ProvideService(cfg)
|
||||
}(),
|
||||
expModuleHash: "sha256-y9GsIoRkWg4emocipyn1vN0rgxIicocJxjYL7s3WFD8=",
|
||||
},
|
||||
{
|
||||
name: "nested plugin should not return module hash when parent manifest is nil",
|
||||
plugin: func() *plugins.Plugin {
|
||||
parent := &plugins.Plugin{
|
||||
JSONData: plugins.JSONData{ID: parentPluginID},
|
||||
Signature: plugins.SignatureStatusValid,
|
||||
Manifest: nil, // Parent has no manifest
|
||||
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested")),
|
||||
}
|
||||
return &plugins.Plugin{
|
||||
JSONData: plugins.JSONData{ID: pluginID},
|
||||
Signature: plugins.SignatureStatusValid,
|
||||
Parent: parent,
|
||||
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "panels", "one")),
|
||||
}
|
||||
}(),
|
||||
cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}},
|
||||
cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}),
|
||||
expModuleHash: "",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := CalculateModuleHash(tc.plugin, tc.cfg, tc.cdn)
|
||||
require.Equal(t, tc.expModuleHash, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,6 @@ type Plugin struct {
|
||||
Pinned bool
|
||||
|
||||
// Signature fields
|
||||
Manifest *PluginManifest
|
||||
Signature SignatureStatus
|
||||
SignatureType SignatureType
|
||||
SignatureOrg string
|
||||
@@ -49,9 +48,8 @@ type Plugin struct {
|
||||
Error *Error
|
||||
|
||||
// SystemJS fields
|
||||
Module string
|
||||
ModuleHash string
|
||||
BaseURL string
|
||||
Module string
|
||||
BaseURL string
|
||||
|
||||
Angular AngularMeta
|
||||
|
||||
@@ -534,24 +532,3 @@ func (pt Type) IsValid() bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PluginManifest holds details for the file manifest
|
||||
type PluginManifest struct {
|
||||
Plugin string `json:"plugin"`
|
||||
Version string `json:"version"`
|
||||
KeyID string `json:"keyId"`
|
||||
Time int64 `json:"time"`
|
||||
Files map[string]string `json:"files"`
|
||||
|
||||
// V2 supported fields
|
||||
ManifestVersion string `json:"manifestVersion"`
|
||||
SignatureType SignatureType `json:"signatureType"`
|
||||
SignedByOrg string `json:"signedByOrg"`
|
||||
SignedByOrgName string `json:"signedByOrgName"`
|
||||
RootURLs []string `json:"rootUrls"`
|
||||
}
|
||||
|
||||
// IsV2 returns true if the manifest is version 2.x
|
||||
func (m *PluginManifest) IsV2() bool {
|
||||
return strings.HasPrefix(m.ManifestVersion, "2.")
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func (r *converter) asDataSource(ds *datasources.DataSource) (*datasourceV0.Data
|
||||
Generation: int64(ds.Version),
|
||||
},
|
||||
Spec: datasourceV0.UnstructuredSpec{},
|
||||
Secure: ToInlineSecureValues(ds.Type, ds.UID, maps.Keys(ds.SecureJsonData)),
|
||||
Secure: ToInlineSecureValues("", ds.UID, maps.Keys(ds.SecureJsonData)),
|
||||
}
|
||||
obj.UID = gapiutil.CalculateClusterWideUID(obj)
|
||||
obj.Spec.SetTitle(ds.Name).
|
||||
@@ -82,18 +82,11 @@ func (r *converter) asDataSource(ds *datasources.DataSource) (*datasourceV0.Data
|
||||
|
||||
// ToInlineSecureValues converts secure json into InlineSecureValues with reference names
|
||||
// The names are predictable and can be used while we implement dual writing for secrets
|
||||
func ToInlineSecureValues(dsType string, dsUID string, keys iter.Seq[string]) common.InlineSecureValues {
|
||||
func ToInlineSecureValues(_ string, dsUID string, keys iter.Seq[string]) common.InlineSecureValues {
|
||||
values := make(common.InlineSecureValues)
|
||||
for k := range keys {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(dsType)) // plugin id
|
||||
h.Write([]byte("|"))
|
||||
h.Write([]byte(dsUID)) // unique identifier
|
||||
h.Write([]byte("|"))
|
||||
h.Write([]byte(k)) // property name
|
||||
n := hex.EncodeToString(h.Sum(nil))
|
||||
values[k] = common.InlineSecureValue{
|
||||
Name: "ds-" + n[0:10], // predictable name for dual writing
|
||||
Name: getLegacySecureValueName(dsUID, k),
|
||||
}
|
||||
}
|
||||
if len(values) == 0 {
|
||||
@@ -102,6 +95,15 @@ func ToInlineSecureValues(dsType string, dsUID string, keys iter.Seq[string]) co
|
||||
return values
|
||||
}
|
||||
|
||||
func getLegacySecureValueName(dsUID string, key string) string {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(dsUID)) // unique identifier
|
||||
h.Write([]byte("|"))
|
||||
h.Write([]byte(key)) // property name
|
||||
n := hex.EncodeToString(h.Sum(nil))
|
||||
return "ds-" + n[0:10] // predictable name for dual writing
|
||||
}
|
||||
|
||||
func (r *converter) toAddCommand(ds *datasourceV0.DataSource) (*datasources.AddDataSourceCommand, error) {
|
||||
if r.group != "" && ds.APIVersion != "" && !strings.HasPrefix(ds.APIVersion, r.group) {
|
||||
return nil, fmt.Errorf("expecting APIGroup: %s", r.group)
|
||||
|
||||
@@ -11,9 +11,11 @@ import (
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
|
||||
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/infra/metrics/metricutil"
|
||||
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -90,6 +92,20 @@ func (s *legacyStorage) Create(ctx context.Context, obj runtime.Object, createVa
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected a datasource object")
|
||||
}
|
||||
|
||||
// Verify the secure value commands
|
||||
for _, v := range ds.Secure {
|
||||
if v.Create.IsZero() {
|
||||
return nil, fmt.Errorf("secure values must use create when creating a new datasource")
|
||||
}
|
||||
if v.Remove {
|
||||
return nil, fmt.Errorf("secure values can not use remove when creating a new datasource")
|
||||
}
|
||||
if v.Name != "" {
|
||||
return nil, fmt.Errorf("secure values can not specify a name when creating a new datasource")
|
||||
}
|
||||
}
|
||||
|
||||
return s.datasources.CreateDataSource(ctx, ds)
|
||||
}
|
||||
|
||||
@@ -122,6 +138,26 @@ func (s *legacyStorage) Update(ctx context.Context, name string, objInfo rest.Up
|
||||
return nil, false, fmt.Errorf("expected a datasource object (old)")
|
||||
}
|
||||
|
||||
// Expose any secure value changes to the dual writer
|
||||
var secureChanges common.InlineSecureValues
|
||||
for k, v := range ds.Secure {
|
||||
if v.Remove || v.Create != "" {
|
||||
if secureChanges == nil {
|
||||
secureChanges = make(common.InlineSecureValues)
|
||||
}
|
||||
secureChanges[k] = v
|
||||
dualwrite.SetUpdatedSecureValues(ctx, ds.Secure)
|
||||
continue
|
||||
}
|
||||
|
||||
// The legacy store must use fixed names generated by the internal system
|
||||
// we can not support external shared secrets when using the SQL backing for datasources
|
||||
validName := getLegacySecureValueName(name, k)
|
||||
if v.Name != validName {
|
||||
return nil, false, fmt.Errorf("invalid secure value name %q, expected %q", v.Name, validName)
|
||||
}
|
||||
}
|
||||
|
||||
// Keep all the old secure values
|
||||
if len(oldDS.Secure) > 0 {
|
||||
for k, v := range oldDS.Secure {
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/builder"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/apistore"
|
||||
"github.com/grafana/grafana/pkg/tsdb/grafana-testdata-datasource/kinds"
|
||||
)
|
||||
|
||||
@@ -102,10 +103,10 @@ func RegisterAPIService(
|
||||
datasources.GetDatasourceProvider(pluginJSON),
|
||||
contextProvider,
|
||||
accessControl,
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
DataSourceAPIBuilderConfig{
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
LoadQueryTypes: features.IsEnabledGlobally(featuremgmt.FlagDatasourceQueryTypes),
|
||||
UseDualWriter: false,
|
||||
UseDualWriter: features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
@@ -224,6 +225,12 @@ func (b *DataSourceAPIBuilder) AllowedV0Alpha1Resources() []string {
|
||||
}
|
||||
|
||||
func (b *DataSourceAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error {
|
||||
opts.StorageOptsRegister(b.datasourceResourceInfo.GroupResource(), apistore.StorageOptions{
|
||||
EnableFolderSupport: false,
|
||||
|
||||
Scheme: opts.Scheme, // allows for generic Type applied to multiple groups
|
||||
})
|
||||
|
||||
storage := map[string]rest.Storage{}
|
||||
|
||||
// Register the raw datasource connection
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
},
|
||||
"secure": {
|
||||
"password": {
|
||||
"name": "ds-d5c1b093af"
|
||||
"name": "ds-0d27eff323"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,10 +22,10 @@
|
||||
},
|
||||
"secure": {
|
||||
"extra": {
|
||||
"name": "ds-bb8b5d8b32"
|
||||
"name": "ds-6ed1b76e5d"
|
||||
},
|
||||
"password": {
|
||||
"name": "ds-973a1eb29d"
|
||||
"name": "ds-edc8fde0ac"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ func (s *LocalInlineSecureValueService) CanReference(ctx context.Context, owner
|
||||
}
|
||||
|
||||
if owner.APIGroup == "" || owner.APIVersion == "" || owner.Kind == "" || owner.Name == "" {
|
||||
return fmt.Errorf("owner reference must have a valid API group, API version, kind and name")
|
||||
return fmt.Errorf("owner reference must have a valid API group, API version, kind and name [CanReference]")
|
||||
}
|
||||
|
||||
if len(names) == 0 {
|
||||
@@ -167,7 +167,7 @@ func (s *LocalInlineSecureValueService) verifyOwnerAndAuth(ctx context.Context,
|
||||
}
|
||||
|
||||
if owner.Namespace == "" || owner.APIGroup == "" || owner.APIVersion == "" || owner.Kind == "" || owner.Name == "" {
|
||||
return nil, fmt.Errorf("owner reference must have a valid API group, API version, kind, namespace and name")
|
||||
return nil, fmt.Errorf("owner reference must have a valid API group, API version, kind, namespace and name [verifyOwnerAndAuth:%+v]", owner)
|
||||
}
|
||||
|
||||
return authInfo, nil
|
||||
|
||||
12
pkg/server/wire_gen.go
generated
12
pkg/server/wire_gen.go
generated
@@ -376,8 +376,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
|
||||
keyretrieverService := keyretriever.ProvideService(keyRetriever)
|
||||
signatureSignature := signature.ProvideService(pluginManagementCfg, keyretrieverService)
|
||||
localProvider := pluginassets.NewLocalProvider()
|
||||
pluginscdnService := pluginscdn.ProvideService(pluginManagementCfg)
|
||||
bootstrap := pipeline.ProvideBootstrapStage(pluginManagementCfg, signatureSignature, localProvider, pluginscdnService)
|
||||
bootstrap := pipeline.ProvideBootstrapStage(pluginManagementCfg, signatureSignature, localProvider)
|
||||
unsignedPluginAuthorizer := signature.ProvideOSSAuthorizer(pluginManagementCfg)
|
||||
validation := signature.ProvideValidatorService(unsignedPluginAuthorizer)
|
||||
angularpatternsstoreService := angularpatternsstore.ProvideService(kvStore)
|
||||
@@ -715,7 +714,8 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pluginassetsService := pluginassets2.ProvideService(pluginManagementCfg, pluginscdnService, pluginstoreService)
|
||||
pluginscdnService := pluginscdn.ProvideService(pluginManagementCfg)
|
||||
pluginassetsService := pluginassets2.ProvideService(pluginManagementCfg, pluginscdnService, signatureSignature, pluginstoreService)
|
||||
avatarCacheServer := avatar.ProvideAvatarCacheServer(cfg)
|
||||
prefService := prefimpl.ProvideService(sqlStore, cfg)
|
||||
dashboardPermissionsService, err := ossaccesscontrol.ProvideDashboardPermissions(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, dashboardService, folderimplService, acimplService, teamService, userService, actionSetService, dashboardServiceImpl, eventualRestConfigProvider)
|
||||
@@ -1042,8 +1042,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
|
||||
keyretrieverService := keyretriever.ProvideService(keyRetriever)
|
||||
signatureSignature := signature.ProvideService(pluginManagementCfg, keyretrieverService)
|
||||
localProvider := pluginassets.NewLocalProvider()
|
||||
pluginscdnService := pluginscdn.ProvideService(pluginManagementCfg)
|
||||
bootstrap := pipeline.ProvideBootstrapStage(pluginManagementCfg, signatureSignature, localProvider, pluginscdnService)
|
||||
bootstrap := pipeline.ProvideBootstrapStage(pluginManagementCfg, signatureSignature, localProvider)
|
||||
unsignedPluginAuthorizer := signature.ProvideOSSAuthorizer(pluginManagementCfg)
|
||||
validation := signature.ProvideValidatorService(unsignedPluginAuthorizer)
|
||||
angularpatternsstoreService := angularpatternsstore.ProvideService(kvStore)
|
||||
@@ -1383,7 +1382,8 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pluginassetsService := pluginassets2.ProvideService(pluginManagementCfg, pluginscdnService, pluginstoreService)
|
||||
pluginscdnService := pluginscdn.ProvideService(pluginManagementCfg)
|
||||
pluginassetsService := pluginassets2.ProvideService(pluginManagementCfg, pluginscdnService, signatureSignature, pluginstoreService)
|
||||
avatarCacheServer := avatar.ProvideAvatarCacheServer(cfg)
|
||||
prefService := prefimpl.ProvideService(sqlStore, cfg)
|
||||
dashboardPermissionsService, err := ossaccesscontrol.ProvideDashboardPermissions(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, dashboardService, folderimplService, acimplService, teamService, userService, actionSetService, dashboardServiceImpl, eventualRestConfigProvider)
|
||||
|
||||
@@ -49,7 +49,7 @@ var (
|
||||
Name: "lokiExperimentalStreaming",
|
||||
Description: "Support new streaming approach for loki (prototype, needs special loki build)",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaObservabilityLogsSquad,
|
||||
Owner: grafanaOSSBigTent,
|
||||
},
|
||||
{
|
||||
Name: "featureHighlights",
|
||||
@@ -177,7 +177,7 @@ var (
|
||||
Name: "lokiLogsDataplane",
|
||||
Description: "Changes logs responses from Loki to be compliant with the dataplane specification.",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaObservabilityLogsSquad,
|
||||
Owner: grafanaOSSBigTent,
|
||||
},
|
||||
{
|
||||
Name: "disableSSEDataplane",
|
||||
@@ -340,7 +340,7 @@ var (
|
||||
Description: "Enables running Loki queries in parallel",
|
||||
Stage: FeatureStagePrivatePreview,
|
||||
FrontendOnly: false,
|
||||
Owner: grafanaObservabilityLogsSquad,
|
||||
Owner: grafanaOSSBigTent,
|
||||
},
|
||||
{
|
||||
Name: "externalServiceAccounts",
|
||||
@@ -745,7 +745,7 @@ var (
|
||||
Name: "logQLScope",
|
||||
Description: "In-development feature that will allow injection of labels into loki queries.",
|
||||
Stage: FeatureStagePrivatePreview,
|
||||
Owner: grafanaObservabilityLogsSquad,
|
||||
Owner: grafanaOSSBigTent,
|
||||
Expression: "false",
|
||||
HideFromDocs: true,
|
||||
},
|
||||
@@ -1260,7 +1260,7 @@ var (
|
||||
Name: "lokiLabelNamesQueryApi",
|
||||
Description: "Defaults to using the Loki `/labels` API instead of `/series`",
|
||||
Stage: FeatureStageGeneralAvailability,
|
||||
Owner: grafanaObservabilityLogsSquad,
|
||||
Owner: grafanaOSSBigTent,
|
||||
Expression: "true",
|
||||
},
|
||||
{
|
||||
@@ -1625,6 +1625,15 @@ var (
|
||||
FrontendOnly: true,
|
||||
Expression: "false",
|
||||
},
|
||||
{
|
||||
Name: "experimentRecentlyViewedDashboards",
|
||||
Description: "A/A test for recently viewed dashboards feature",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaFrontendSearchNavOrganise,
|
||||
FrontendOnly: true,
|
||||
HideFromDocs: true,
|
||||
Expression: "false",
|
||||
},
|
||||
{
|
||||
Name: "alertEnrichment",
|
||||
Description: "Enable configuration of alert enrichments in Grafana Cloud.",
|
||||
|
||||
11
pkg/services/featuremgmt/toggles_gen.csv
generated
11
pkg/services/featuremgmt/toggles_gen.csv
generated
@@ -3,7 +3,7 @@ disableEnvelopeEncryption,GA,@grafana/grafana-operator-experience-squad,false,fa
|
||||
panelTitleSearch,preview,@grafana/search-and-storage,false,false,false
|
||||
publicDashboardsEmailSharing,preview,@grafana/grafana-operator-experience-squad,false,false,false
|
||||
publicDashboardsScene,GA,@grafana/grafana-operator-experience-squad,false,false,true
|
||||
lokiExperimentalStreaming,experimental,@grafana/observability-logs,false,false,false
|
||||
lokiExperimentalStreaming,experimental,@grafana/oss-big-tent,false,false,false
|
||||
featureHighlights,GA,@grafana/grafana-operator-experience-squad,false,false,false
|
||||
storage,experimental,@grafana/search-and-storage,false,false,false
|
||||
canvasPanelNesting,experimental,@grafana/dataviz-squad,false,false,true
|
||||
@@ -22,7 +22,7 @@ starsFromAPIServer,experimental,@grafana/grafana-search-navigate-organise,false,
|
||||
kubernetesStars,experimental,@grafana/grafana-app-platform-squad,false,true,false
|
||||
influxqlStreamingParser,experimental,@grafana/partner-datasources,false,false,false
|
||||
influxdbRunQueriesInParallel,privatePreview,@grafana/partner-datasources,false,false,false
|
||||
lokiLogsDataplane,experimental,@grafana/observability-logs,false,false,false
|
||||
lokiLogsDataplane,experimental,@grafana/oss-big-tent,false,false,false
|
||||
disableSSEDataplane,experimental,@grafana/grafana-datasources-core-services,false,false,false
|
||||
renderAuthJWT,preview,@grafana/grafana-operator-experience-squad,false,false,false
|
||||
refactorVariablesTimeRange,preview,@grafana/dashboards-squad,false,false,false
|
||||
@@ -45,7 +45,7 @@ aiGeneratedDashboardChanges,experimental,@grafana/dashboards-squad,false,false,t
|
||||
reportingRetries,preview,@grafana/grafana-operator-experience-squad,false,true,false
|
||||
reportingCsvEncodingOptions,experimental,@grafana/grafana-operator-experience-squad,false,false,false
|
||||
sseGroupByDatasource,experimental,@grafana/grafana-datasources-core-services,false,false,false
|
||||
lokiRunQueriesInParallel,privatePreview,@grafana/observability-logs,false,false,false
|
||||
lokiRunQueriesInParallel,privatePreview,@grafana/oss-big-tent,false,false,false
|
||||
externalServiceAccounts,preview,@grafana/identity-access-team,false,false,false
|
||||
enableNativeHTTPHistogram,experimental,@grafana/grafana-backend-services-squad,false,true,false
|
||||
disableClassicHTTPHistogram,experimental,@grafana/grafana-backend-services-squad,false,true,false
|
||||
@@ -102,7 +102,7 @@ alertingSaveStateCompressed,preview,@grafana/alerting-squad,false,false,false
|
||||
scopeApi,experimental,@grafana/grafana-app-platform-squad,false,false,false
|
||||
useScopeSingleNodeEndpoint,experimental,@grafana/grafana-operator-experience-squad,false,false,true
|
||||
useMultipleScopeNodesEndpoint,experimental,@grafana/grafana-operator-experience-squad,false,false,true
|
||||
logQLScope,privatePreview,@grafana/observability-logs,false,false,false
|
||||
logQLScope,privatePreview,@grafana/oss-big-tent,false,false,false
|
||||
sqlExpressions,preview,@grafana/grafana-datasources-core-services,false,false,false
|
||||
sqlExpressionsColumnAutoComplete,experimental,@grafana/datapro,false,false,true
|
||||
kubernetesAggregator,experimental,@grafana/grafana-app-platform-squad,false,true,false
|
||||
@@ -173,7 +173,7 @@ alertingAIAnalyzeCentralStateHistory,experimental,@grafana/alerting-squad,false,
|
||||
alertingNotificationsStepMode,GA,@grafana/alerting-squad,false,false,true
|
||||
unifiedStorageSearchUI,experimental,@grafana/search-and-storage,false,false,false
|
||||
elasticsearchCrossClusterSearch,GA,@grafana/partner-datasources,false,false,false
|
||||
lokiLabelNamesQueryApi,GA,@grafana/observability-logs,false,false,false
|
||||
lokiLabelNamesQueryApi,GA,@grafana/oss-big-tent,false,false,false
|
||||
k8SFolderCounts,experimental,@grafana/search-and-storage,false,false,false
|
||||
k8SFolderMove,experimental,@grafana/search-and-storage,false,false,false
|
||||
improvedExternalSessionHandlingSAML,GA,@grafana/identity-access-team,false,false,false
|
||||
@@ -223,6 +223,7 @@ kubernetesAuthnMutation,experimental,@grafana/identity-access-team,false,false,f
|
||||
kubernetesExternalGroupMapping,experimental,@grafana/identity-access-team,false,false,false
|
||||
restoreDashboards,experimental,@grafana/grafana-search-navigate-organise,false,false,false
|
||||
recentlyViewedDashboards,experimental,@grafana/grafana-search-navigate-organise,false,false,true
|
||||
experimentRecentlyViewedDashboards,experimental,@grafana/grafana-search-navigate-organise,false,false,true
|
||||
alertEnrichment,experimental,@grafana/alerting-squad,false,false,false
|
||||
alertEnrichmentMultiStep,experimental,@grafana/alerting-squad,false,false,false
|
||||
alertEnrichmentConditional,experimental,@grafana/alerting-squad,false,false,false
|
||||
|
||||
|
60
pkg/services/featuremgmt/toggles_gen.json
generated
60
pkg/services/featuremgmt/toggles_gen.json
generated
@@ -1365,6 +1365,21 @@
|
||||
"hideFromDocs": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "experimentRecentlyViewedDashboards",
|
||||
"resourceVersion": "1768214542023",
|
||||
"creationTimestamp": "2026-01-12T10:42:22Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "A/A test for recently viewed dashboards feature",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/grafana-search-navigate-organise",
|
||||
"frontend": true,
|
||||
"hideFromDocs": true,
|
||||
"expression": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "exploreLogsAggregatedMetrics",
|
||||
@@ -2207,13 +2222,16 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "logQLScope",
|
||||
"resourceVersion": "1764664939750",
|
||||
"creationTimestamp": "2024-11-11T11:53:24Z"
|
||||
"resourceVersion": "1768317398145",
|
||||
"creationTimestamp": "2024-11-11T11:53:24Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2026-01-13 15:16:38.145488 +0000 UTC"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"description": "In-development feature that will allow injection of labels into loki queries.",
|
||||
"stage": "privatePreview",
|
||||
"codeowner": "@grafana/observability-logs",
|
||||
"codeowner": "@grafana/oss-big-tent",
|
||||
"hideFromDocs": true,
|
||||
"expression": "false"
|
||||
}
|
||||
@@ -2289,38 +2307,47 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "lokiExperimentalStreaming",
|
||||
"resourceVersion": "1764664939750",
|
||||
"creationTimestamp": "2023-06-19T10:03:51Z"
|
||||
"resourceVersion": "1768317398145",
|
||||
"creationTimestamp": "2023-06-19T10:03:51Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2026-01-13 15:16:38.145488 +0000 UTC"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"description": "Support new streaming approach for loki (prototype, needs special loki build)",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/observability-logs"
|
||||
"codeowner": "@grafana/oss-big-tent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "lokiLabelNamesQueryApi",
|
||||
"resourceVersion": "1764664939750",
|
||||
"creationTimestamp": "2024-12-13T14:31:41Z"
|
||||
"resourceVersion": "1768317398145",
|
||||
"creationTimestamp": "2024-12-13T14:31:41Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2026-01-13 15:16:38.145488 +0000 UTC"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"description": "Defaults to using the Loki `/labels` API instead of `/series`",
|
||||
"stage": "GA",
|
||||
"codeowner": "@grafana/observability-logs",
|
||||
"codeowner": "@grafana/oss-big-tent",
|
||||
"expression": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "lokiLogsDataplane",
|
||||
"resourceVersion": "1764664939750",
|
||||
"creationTimestamp": "2023-07-13T07:58:00Z"
|
||||
"resourceVersion": "1768317398145",
|
||||
"creationTimestamp": "2023-07-13T07:58:00Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2026-01-13 15:16:38.145488 +0000 UTC"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"description": "Changes logs responses from Loki to be compliant with the dataplane specification.",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/observability-logs"
|
||||
"codeowner": "@grafana/oss-big-tent"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -2353,13 +2380,16 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "lokiRunQueriesInParallel",
|
||||
"resourceVersion": "1764664939750",
|
||||
"creationTimestamp": "2023-09-19T09:34:01Z"
|
||||
"resourceVersion": "1768317398145",
|
||||
"creationTimestamp": "2023-09-19T09:34:01Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2026-01-13 15:16:38.145488 +0000 UTC"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables running Loki queries in parallel",
|
||||
"stage": "privatePreview",
|
||||
"codeowner": "@grafana/observability-logs"
|
||||
"codeowner": "@grafana/oss-big-tent"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -26,7 +26,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/sources"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginassets"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginerrs"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pipeline"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/provisionedplugins"
|
||||
@@ -214,27 +213,10 @@ func TestLoader_Load(t *testing.T) {
|
||||
ExtensionPoints: []plugins.ExtensionPoint{},
|
||||
},
|
||||
},
|
||||
Class: plugins.ClassExternal,
|
||||
Module: "public/plugins/test-app/module.js",
|
||||
BaseURL: "public/plugins/test-app",
|
||||
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "includes-symlinks")),
|
||||
Manifest: &plugins.PluginManifest{
|
||||
Plugin: "test-app",
|
||||
Version: "1.0.0",
|
||||
KeyID: "7e4d0c6a708866e7",
|
||||
Time: 1622547655175,
|
||||
Files: map[string]string{
|
||||
"dashboards/connections.json": "bea86da4be970b98dc4681802ab55cdef3441dc3eb3c654cb207948d17b25303",
|
||||
"dashboards/extra/memory.json": "7c042464941084caa91d0a9a2f188b05315a9796308a652ccdee31ca4fbcbfee",
|
||||
"plugin.json": "c59a51bf6d7ecd7a99608ccb99353390c8b973672a938a0247164324005c0caf",
|
||||
"symlink_to_txt": "9f32c171bf78a85d5cb77a48ab44f85578ee2942a1fc9f9ec4fde194ae4ff048",
|
||||
"text.txt": "9f32c171bf78a85d5cb77a48ab44f85578ee2942a1fc9f9ec4fde194ae4ff048",
|
||||
},
|
||||
ManifestVersion: "2.0.0",
|
||||
SignatureType: plugins.SignatureTypeGrafana,
|
||||
SignedByOrg: "grafana",
|
||||
SignedByOrgName: "Grafana Labs",
|
||||
},
|
||||
Class: plugins.ClassExternal,
|
||||
Module: "public/plugins/test-app/module.js",
|
||||
BaseURL: "public/plugins/test-app",
|
||||
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "includes-symlinks")),
|
||||
Signature: "valid",
|
||||
SignatureType: plugins.SignatureTypeGrafana,
|
||||
SignatureOrg: "Grafana Labs",
|
||||
@@ -665,24 +647,10 @@ func TestLoader_Load_MultiplePlugins(t *testing.T) {
|
||||
Executable: "test",
|
||||
State: plugins.ReleaseStateAlpha,
|
||||
},
|
||||
Class: plugins.ClassExternal,
|
||||
Module: "public/plugins/test-datasource/module.js",
|
||||
BaseURL: "public/plugins/test-datasource",
|
||||
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "valid-v2-pvt-signature/plugin")),
|
||||
Manifest: &plugins.PluginManifest{
|
||||
Plugin: "test-datasource",
|
||||
Version: "1.0.0",
|
||||
KeyID: "7e4d0c6a708866e7",
|
||||
Time: 1661171417046,
|
||||
Files: map[string]string{
|
||||
"plugin.json": "203ef4a613c5693c437a665cd67f95e2756a0f71b336b2ffb265db7c180d0b19",
|
||||
},
|
||||
ManifestVersion: "2.0.0",
|
||||
SignatureType: plugins.SignatureTypePrivate,
|
||||
SignedByOrg: "willbrowne",
|
||||
SignedByOrgName: "Will Browne",
|
||||
RootURLs: []string{"http://localhost:3000/"},
|
||||
},
|
||||
Class: plugins.ClassExternal,
|
||||
Module: "public/plugins/test-datasource/module.js",
|
||||
BaseURL: "public/plugins/test-datasource",
|
||||
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "valid-v2-pvt-signature/plugin")),
|
||||
Signature: "valid",
|
||||
SignatureType: plugins.SignatureTypePrivate,
|
||||
SignatureOrg: "Will Browne",
|
||||
@@ -799,22 +767,8 @@ func TestLoader_Load_RBACReady(t *testing.T) {
|
||||
},
|
||||
Backend: false,
|
||||
},
|
||||
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "test-app-with-roles")),
|
||||
Class: plugins.ClassExternal,
|
||||
Manifest: &plugins.PluginManifest{
|
||||
Plugin: "test-app",
|
||||
Version: "1.0.0",
|
||||
KeyID: "7e4d0c6a708866e7",
|
||||
Time: 1667484928676,
|
||||
Files: map[string]string{
|
||||
"plugin.json": "3348335ec100392b325f3eeb882a07c729e9cbf0f1ae331239f46840bb1a01eb",
|
||||
},
|
||||
ManifestVersion: "2.0.0",
|
||||
SignatureType: plugins.SignatureTypePrivate,
|
||||
SignedByOrg: "gabrielmabille",
|
||||
SignedByOrgName: "gabrielmabille",
|
||||
RootURLs: []string{"http://localhost:3000/"},
|
||||
},
|
||||
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "test-app-with-roles")),
|
||||
Class: plugins.ClassExternal,
|
||||
Signature: plugins.SignatureStatusValid,
|
||||
SignatureType: plugins.SignatureTypePrivate,
|
||||
SignatureOrg: "gabrielmabille",
|
||||
@@ -883,22 +837,8 @@ func TestLoader_Load_Signature_RootURL(t *testing.T) {
|
||||
Backend: true,
|
||||
Executable: "test",
|
||||
},
|
||||
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "valid-v2-pvt-signature-root-url-uri/plugin")),
|
||||
Class: plugins.ClassExternal,
|
||||
Manifest: &plugins.PluginManifest{
|
||||
Plugin: "test-datasource",
|
||||
Version: "1.0.0",
|
||||
KeyID: "7e4d0c6a708866e7",
|
||||
Time: 1661171981629,
|
||||
Files: map[string]string{
|
||||
"plugin.json": "203ef4a613c5693c437a665cd67f95e2756a0f71b336b2ffb265db7c180d0b19",
|
||||
},
|
||||
ManifestVersion: "2.0.0",
|
||||
SignatureType: plugins.SignatureTypePrivate,
|
||||
SignedByOrg: "willbrowne",
|
||||
SignedByOrgName: "Will Browne",
|
||||
RootURLs: []string{"http://localhost:3000/grafana"},
|
||||
},
|
||||
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "valid-v2-pvt-signature-root-url-uri/plugin")),
|
||||
Class: plugins.ClassExternal,
|
||||
Signature: plugins.SignatureStatusValid,
|
||||
SignatureType: plugins.SignatureTypePrivate,
|
||||
SignatureOrg: "Will Browne",
|
||||
@@ -985,24 +925,8 @@ func TestLoader_Load_DuplicatePlugins(t *testing.T) {
|
||||
},
|
||||
Backend: false,
|
||||
},
|
||||
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "test-app")),
|
||||
Class: plugins.ClassExternal,
|
||||
Manifest: &plugins.PluginManifest{
|
||||
Plugin: "test-app",
|
||||
Version: "1.0.0",
|
||||
KeyID: "7e4d0c6a708866e7",
|
||||
Time: 1621356785895,
|
||||
Files: map[string]string{
|
||||
"plugin.json": "c59a51bf6d7ecd7a99608ccb99353390c8b973672a938a0247164324005c0caf",
|
||||
"dashboards/connections.json": "bea86da4be970b98dc4681802ab55cdef3441dc3eb3c654cb207948d17b25303",
|
||||
"dashboards/memory.json": "7c042464941084caa91d0a9a2f188b05315a9796308a652ccdee31ca4fbcbfee",
|
||||
"dashboards/connections_result.json": "124d85c9c2e40214b83273f764574937a79909cfac3f925276fbb72543c224dc",
|
||||
},
|
||||
ManifestVersion: "2.0.0",
|
||||
SignatureType: plugins.SignatureTypeGrafana,
|
||||
SignedByOrg: "grafana",
|
||||
SignedByOrgName: "Grafana Labs",
|
||||
},
|
||||
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "test-app")),
|
||||
Class: plugins.ClassExternal,
|
||||
Signature: plugins.SignatureStatusValid,
|
||||
SignatureType: plugins.SignatureTypeGrafana,
|
||||
SignatureOrg: "Grafana Labs",
|
||||
@@ -1093,24 +1017,8 @@ func TestLoader_Load_SkipUninitializedPlugins(t *testing.T) {
|
||||
},
|
||||
Backend: false,
|
||||
},
|
||||
FS: mustNewStaticFSForTests(t, pluginDir1),
|
||||
Class: plugins.ClassExternal,
|
||||
Manifest: &plugins.PluginManifest{
|
||||
Plugin: "test-app",
|
||||
Version: "1.0.0",
|
||||
KeyID: "7e4d0c6a708866e7",
|
||||
Time: 1621356785895,
|
||||
Files: map[string]string{
|
||||
"plugin.json": "c59a51bf6d7ecd7a99608ccb99353390c8b973672a938a0247164324005c0caf",
|
||||
"dashboards/connections.json": "bea86da4be970b98dc4681802ab55cdef3441dc3eb3c654cb207948d17b25303",
|
||||
"dashboards/memory.json": "7c042464941084caa91d0a9a2f188b05315a9796308a652ccdee31ca4fbcbfee",
|
||||
"dashboards/connections_result.json": "124d85c9c2e40214b83273f764574937a79909cfac3f925276fbb72543c224dc",
|
||||
},
|
||||
ManifestVersion: "2.0.0",
|
||||
SignatureType: plugins.SignatureTypeGrafana,
|
||||
SignedByOrg: "grafana",
|
||||
SignedByOrgName: "Grafana Labs",
|
||||
},
|
||||
FS: mustNewStaticFSForTests(t, pluginDir1),
|
||||
Class: plugins.ClassExternal,
|
||||
Signature: plugins.SignatureStatusValid,
|
||||
SignatureType: plugins.SignatureTypeGrafana,
|
||||
SignatureOrg: "Grafana Labs",
|
||||
@@ -1272,23 +1180,9 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
|
||||
},
|
||||
Backend: true,
|
||||
},
|
||||
Module: "public/plugins/test-datasource/module.js",
|
||||
BaseURL: "public/plugins/test-datasource",
|
||||
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "nested-plugins/parent")),
|
||||
Manifest: &plugins.PluginManifest{
|
||||
Plugin: "test-datasource",
|
||||
Version: "1.0.0",
|
||||
KeyID: "7e4d0c6a708866e7",
|
||||
Time: 1661172777367,
|
||||
Files: map[string]string{
|
||||
"plugin.json": "a029469ace740e9502bfb0d40924d1cccae73d0b18adcd8f1ceb7f17bf36beb8",
|
||||
"nested/plugin.json": "e64abd35cd211e0e4682974ad5cdd1be7a0b7cd24951d302a16d9e2cb6cefea4",
|
||||
},
|
||||
ManifestVersion: "2.0.0",
|
||||
SignatureType: plugins.SignatureTypeGrafana,
|
||||
SignedByOrg: "grafana",
|
||||
SignedByOrgName: "Grafana Labs",
|
||||
},
|
||||
Module: "public/plugins/test-datasource/module.js",
|
||||
BaseURL: "public/plugins/test-datasource",
|
||||
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "nested-plugins/parent")),
|
||||
Signature: plugins.SignatureStatusValid,
|
||||
SignatureType: plugins.SignatureTypeGrafana,
|
||||
SignatureOrg: "Grafana Labs",
|
||||
@@ -1331,23 +1225,9 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
|
||||
ExtensionPoints: []plugins.ExtensionPoint{},
|
||||
},
|
||||
},
|
||||
Module: "public/plugins/test-panel/module.js",
|
||||
BaseURL: "public/plugins/test-panel",
|
||||
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "nested-plugins/parent/nested")),
|
||||
Manifest: &plugins.PluginManifest{
|
||||
Plugin: "test-datasource",
|
||||
Version: "1.0.0",
|
||||
KeyID: "7e4d0c6a708866e7",
|
||||
Time: 1661172777367,
|
||||
Files: map[string]string{
|
||||
"plugin.json": "a029469ace740e9502bfb0d40924d1cccae73d0b18adcd8f1ceb7f17bf36beb8",
|
||||
"nested/plugin.json": "e64abd35cd211e0e4682974ad5cdd1be7a0b7cd24951d302a16d9e2cb6cefea4",
|
||||
},
|
||||
ManifestVersion: "2.0.0",
|
||||
SignatureType: plugins.SignatureTypeGrafana,
|
||||
SignedByOrg: "grafana",
|
||||
SignedByOrgName: "Grafana Labs",
|
||||
},
|
||||
Module: "public/plugins/test-panel/module.js",
|
||||
BaseURL: "public/plugins/test-panel",
|
||||
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "nested-plugins/parent/nested")),
|
||||
Signature: plugins.SignatureStatusValid,
|
||||
SignatureType: plugins.SignatureTypeGrafana,
|
||||
SignatureOrg: "Grafana Labs",
|
||||
@@ -1495,25 +1375,10 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
|
||||
},
|
||||
Backend: false,
|
||||
},
|
||||
Module: "public/plugins/myorgid-simple-app/module.js",
|
||||
BaseURL: "public/plugins/myorgid-simple-app",
|
||||
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "app-with-child/dist")),
|
||||
DefaultNavURL: "/plugins/myorgid-simple-app/page/root-page-react",
|
||||
Manifest: &plugins.PluginManifest{
|
||||
Plugin: "myorgid-simple-app",
|
||||
Version: "%VERSION%",
|
||||
KeyID: "7e4d0c6a708866e7",
|
||||
Time: 1642614241713,
|
||||
Files: map[string]string{
|
||||
"plugin.json": "1abecfd0229814f6c284ff3c8dd744548f8d676ab3250cd7902c99dabf11480e",
|
||||
"child/plugin.json": "66ba0dffaf3b1bfa17eb9a8672918fc66d1001f465b1061f4fc19c2f2c100f51",
|
||||
},
|
||||
ManifestVersion: "2.0.0",
|
||||
SignatureType: plugins.SignatureTypeGrafana,
|
||||
SignedByOrg: "grafana",
|
||||
SignedByOrgName: "Grafana Labs",
|
||||
RootURLs: []string{},
|
||||
},
|
||||
Module: "public/plugins/myorgid-simple-app/module.js",
|
||||
BaseURL: "public/plugins/myorgid-simple-app",
|
||||
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "app-with-child/dist")),
|
||||
DefaultNavURL: "/plugins/myorgid-simple-app/page/root-page-react",
|
||||
Signature: plugins.SignatureStatusValid,
|
||||
SignatureType: plugins.SignatureTypeGrafana,
|
||||
SignatureOrg: "Grafana Labs",
|
||||
@@ -1566,21 +1431,6 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
|
||||
BaseURL: "public/plugins/myorgid-simple-panel",
|
||||
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "app-with-child/dist/child")),
|
||||
IncludedInAppID: parent.ID,
|
||||
Manifest: &plugins.PluginManifest{
|
||||
Plugin: "myorgid-simple-app",
|
||||
Version: "%VERSION%",
|
||||
KeyID: "7e4d0c6a708866e7",
|
||||
Time: 1642614241713,
|
||||
Files: map[string]string{
|
||||
"plugin.json": "1abecfd0229814f6c284ff3c8dd744548f8d676ab3250cd7902c99dabf11480e",
|
||||
"child/plugin.json": "66ba0dffaf3b1bfa17eb9a8672918fc66d1001f465b1061f4fc19c2f2c100f51",
|
||||
},
|
||||
ManifestVersion: "2.0.0",
|
||||
SignatureType: plugins.SignatureTypeGrafana,
|
||||
SignedByOrg: "grafana",
|
||||
SignedByOrgName: "Grafana Labs",
|
||||
RootURLs: []string{},
|
||||
},
|
||||
Signature: plugins.SignatureStatusValid,
|
||||
SignatureType: plugins.SignatureTypeGrafana,
|
||||
SignatureOrg: "Grafana Labs",
|
||||
@@ -1634,7 +1484,7 @@ func newLoader(t *testing.T, cfg *config.PluginManagementCfg, reg registry.Servi
|
||||
require.NoError(t, err)
|
||||
|
||||
return ProvideService(cfg, pipeline.ProvideDiscoveryStage(cfg, reg),
|
||||
pipeline.ProvideBootstrapStage(cfg, signature.DefaultCalculator(cfg), pluginAssetsProvider, pluginscdn.ProvideService(cfg)),
|
||||
pipeline.ProvideBootstrapStage(cfg, signature.DefaultCalculator(cfg), pluginAssetsProvider),
|
||||
pipeline.ProvideValidationStage(cfg, signature.NewValidator(signature.NewUnsignedAuthorizer(cfg)), angularInspector),
|
||||
pipeline.ProvideInitializationStage(cfg, reg, backendFactory, proc, &pluginfakes.FakeAuthService{}, pluginfakes.NewFakeRoleRegistry(), pluginfakes.NewFakeActionSetRegistry(), pluginfakes.NewFakePluginEnvProvider(), tracing.InitializeTracerForTest(), provisionedplugins.NewNoop()),
|
||||
terminate, errTracker)
|
||||
@@ -1664,7 +1514,7 @@ func newLoaderWithOpts(t *testing.T, cfg *config.PluginManagementCfg, opts loade
|
||||
}
|
||||
|
||||
return ProvideService(cfg, pipeline.ProvideDiscoveryStage(cfg, reg),
|
||||
pipeline.ProvideBootstrapStage(cfg, signature.DefaultCalculator(cfg), pluginassets.NewLocalProvider(), pluginscdn.ProvideService(cfg)),
|
||||
pipeline.ProvideBootstrapStage(cfg, signature.DefaultCalculator(cfg), pluginassets.NewLocalProvider()),
|
||||
pipeline.ProvideValidationStage(cfg, signature.NewValidator(signature.NewUnsignedAuthorizer(cfg)), angularInspector),
|
||||
pipeline.ProvideInitializationStage(cfg, reg, backendFactoryProvider, proc, authServiceRegistry, pluginfakes.NewFakeRoleRegistry(), pluginfakes.NewFakeActionSetRegistry(), pluginfakes.NewFakePluginEnvProvider(), tracing.InitializeTracerForTest(), provisionedplugins.NewNoop()),
|
||||
terminate, errTracker)
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/registry"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/signature"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginassets"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/coreplugin"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/provisionedplugins"
|
||||
@@ -43,7 +42,7 @@ func ProvideDiscoveryStage(cfg *config.PluginManagementCfg, pr registry.Service)
|
||||
})
|
||||
}
|
||||
|
||||
func ProvideBootstrapStage(cfg *config.PluginManagementCfg, sc plugins.SignatureCalculator, ap pluginassets.Provider, cdn *pluginscdn.Service) *bootstrap.Bootstrap {
|
||||
func ProvideBootstrapStage(cfg *config.PluginManagementCfg, sc plugins.SignatureCalculator, ap pluginassets.Provider) *bootstrap.Bootstrap {
|
||||
disableAlertingForTempoDecorateFunc := func(ctx context.Context, p *plugins.Plugin) (*plugins.Plugin, error) {
|
||||
if p.ID == coreplugin.Tempo && !cfg.Features.TempoAlertingEnabled {
|
||||
p.Alerting = false
|
||||
@@ -53,7 +52,7 @@ func ProvideBootstrapStage(cfg *config.PluginManagementCfg, sc plugins.Signature
|
||||
|
||||
return bootstrap.New(cfg, bootstrap.Opts{
|
||||
ConstructFunc: bootstrap.DefaultConstructFunc(cfg, sc, ap),
|
||||
DecorateFuncs: append(bootstrap.DefaultDecorateFuncs(cfg, cdn), disableAlertingForTempoDecorateFunc),
|
||||
DecorateFuncs: append(bootstrap.DefaultDecorateFuncs(cfg), disableAlertingForTempoDecorateFunc),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,19 @@ package pluginassets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/Masterminds/semver/v3"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/plugins/config"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/signature"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
|
||||
)
|
||||
@@ -21,20 +28,24 @@ var (
|
||||
scriptLoadingMinSupportedVersion = semver.MustParse(CreatePluginVersionScriptSupportEnabled)
|
||||
)
|
||||
|
||||
func ProvideService(cfg *config.PluginManagementCfg, cdn *pluginscdn.Service, store pluginstore.Store) *Service {
|
||||
func ProvideService(cfg *config.PluginManagementCfg, cdn *pluginscdn.Service, sig *signature.Signature, store pluginstore.Store) *Service {
|
||||
return &Service{
|
||||
cfg: cfg,
|
||||
cdn: cdn,
|
||||
store: store,
|
||||
log: log.New("pluginassets"),
|
||||
cfg: cfg,
|
||||
cdn: cdn,
|
||||
signature: sig,
|
||||
store: store,
|
||||
log: log.New("pluginassets"),
|
||||
}
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
cfg *config.PluginManagementCfg
|
||||
cdn *pluginscdn.Service
|
||||
store pluginstore.Store
|
||||
log log.Logger
|
||||
cfg *config.PluginManagementCfg
|
||||
cdn *pluginscdn.Service
|
||||
signature *signature.Signature
|
||||
store pluginstore.Store
|
||||
log log.Logger
|
||||
|
||||
moduleHashCache sync.Map
|
||||
}
|
||||
|
||||
// LoadingStrategy calculates the loading strategy for a plugin.
|
||||
@@ -71,6 +82,95 @@ func (s *Service) LoadingStrategy(_ context.Context, p pluginstore.Plugin) plugi
|
||||
return plugins.LoadingStrategyFetch
|
||||
}
|
||||
|
||||
// ModuleHash returns the module.js SHA256 hash for a plugin in the format expected by the browser for SRI checks.
|
||||
// The module hash is read from the plugin's MANIFEST.txt file.
|
||||
// The plugin can also be a nested plugin.
|
||||
// If the plugin is unsigned, an empty string is returned.
|
||||
// The results are cached to avoid repeated reads from the MANIFEST.txt file.
|
||||
func (s *Service) ModuleHash(ctx context.Context, p pluginstore.Plugin) string {
|
||||
k := s.moduleHashCacheKey(p)
|
||||
cachedValue, ok := s.moduleHashCache.Load(k)
|
||||
if ok {
|
||||
return cachedValue.(string)
|
||||
}
|
||||
mh, err := s.moduleHash(ctx, p, "")
|
||||
if err != nil {
|
||||
s.log.Error("Failed to calculate module hash", "plugin", p.ID, "error", err)
|
||||
}
|
||||
s.moduleHashCache.Store(k, mh)
|
||||
return mh
|
||||
}
|
||||
|
||||
// moduleHash is the underlying function for ModuleHash. See its documentation for more information.
|
||||
// If the plugin is not a CDN plugin, the function will return an empty string.
|
||||
// It will read the module hash from the MANIFEST.txt in the [[plugins.FS]] of the provided plugin.
|
||||
// If childFSBase is provided, the function will try to get the hash from MANIFEST.txt for the provided children's
|
||||
// module.js file, rather than for the provided plugin.
|
||||
func (s *Service) moduleHash(ctx context.Context, p pluginstore.Plugin, childFSBase string) (r string, err error) {
|
||||
if !s.cfg.Features.SriChecksEnabled {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Ignore unsigned plugins
|
||||
if !p.Signature.IsValid() {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if p.Parent != nil {
|
||||
// Nested plugin
|
||||
parent, ok := s.store.Plugin(ctx, p.Parent.ID)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("parent plugin plugin %q for child plugin %q not found", p.Parent.ID, p.ID)
|
||||
}
|
||||
|
||||
// The module hash is contained within the parent's MANIFEST.txt file.
|
||||
// For example, the parent's MANIFEST.txt will contain an entry similar to this:
|
||||
//
|
||||
// ```
|
||||
// "datasource/module.js": "1234567890abcdef..."
|
||||
// ```
|
||||
//
|
||||
// Recursively call moduleHash with the parent plugin and with the children plugin folder path
|
||||
// to get the correct module hash for the nested plugin.
|
||||
if childFSBase == "" {
|
||||
childFSBase = p.Base()
|
||||
}
|
||||
return s.moduleHash(ctx, parent, childFSBase)
|
||||
}
|
||||
|
||||
// Only CDN plugins are supported for SRI checks.
|
||||
// CDN plugins have the version as part of the URL, which acts as a cache-buster.
|
||||
// Needed due to: https://github.com/grafana/plugin-tools/pull/1426
|
||||
// FS plugins build before this change will have SRI mismatch issues.
|
||||
if !s.cdnEnabled(p.ID, p.FS) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
manifest, err := s.signature.ReadPluginManifestFromFS(ctx, p.FS)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read plugin manifest: %w", err)
|
||||
}
|
||||
if !manifest.IsV2() {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var childPath string
|
||||
if childFSBase != "" {
|
||||
// Calculate the relative path of the child plugin folder from the parent plugin folder.
|
||||
childPath, err = p.FS.Rel(childFSBase)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("rel path: %w", err)
|
||||
}
|
||||
// MANIFETS.txt uses forward slashes as path separators.
|
||||
childPath = filepath.ToSlash(childPath)
|
||||
}
|
||||
moduleHash, ok := manifest.Files[path.Join(childPath, "module.js")]
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
return convertHashForSRI(moduleHash)
|
||||
}
|
||||
|
||||
func (s *Service) compatibleCreatePluginVersion(ps map[string]string) bool {
|
||||
if cpv, ok := ps[CreatePluginVersionCfgKey]; ok {
|
||||
createPluginVer, err := semver.NewVersion(cpv)
|
||||
@@ -88,3 +188,17 @@ func (s *Service) compatibleCreatePluginVersion(ps map[string]string) bool {
|
||||
func (s *Service) cdnEnabled(pluginID string, fs plugins.FS) bool {
|
||||
return s.cdn.PluginSupported(pluginID) || fs.Type().CDN()
|
||||
}
|
||||
|
||||
// convertHashForSRI takes a SHA256 hash string and returns it as expected by the browser for SRI checks.
|
||||
func convertHashForSRI(h string) (string, error) {
|
||||
hb, err := hex.DecodeString(h)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("hex decode string: %w", err)
|
||||
}
|
||||
return "sha256-" + base64.StdEncoding.EncodeToString(hb), nil
|
||||
}
|
||||
|
||||
// moduleHashCacheKey returns a unique key for the module hash cache.
|
||||
func (s *Service) moduleHashCacheKey(p pluginstore.Plugin) string {
|
||||
return p.ID + ":" + p.Info.Version
|
||||
}
|
||||
|
||||
@@ -2,14 +2,19 @@ package pluginassets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/plugins/config"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/pluginfakes"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/signature"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/signature/statickey"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
|
||||
)
|
||||
@@ -174,6 +179,349 @@ func TestService_Calculate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ModuleHash(t *testing.T) {
|
||||
const (
|
||||
pluginID = "grafana-test-datasource"
|
||||
parentPluginID = "grafana-test-app"
|
||||
)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
features *config.Features
|
||||
store []pluginstore.Plugin
|
||||
|
||||
// Can be used to configure plugin's fs
|
||||
// fs cdn type = loaded from CDN with no files on disk
|
||||
// fs local type = files on disk but served from CDN only if cdn=true
|
||||
plugin pluginstore.Plugin
|
||||
|
||||
// When true, set cdn=true in config
|
||||
cdn bool
|
||||
expModuleHash string
|
||||
}{
|
||||
{
|
||||
name: "unsigned should not return module hash",
|
||||
plugin: newPlugin(pluginID, withSignatureStatus(plugins.SignatureStatusUnsigned)),
|
||||
cdn: false,
|
||||
features: &config.Features{SriChecksEnabled: false},
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
plugin: newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
|
||||
withClass(plugins.ClassExternal),
|
||||
),
|
||||
cdn: true,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"),
|
||||
},
|
||||
{
|
||||
plugin: newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
|
||||
withClass(plugins.ClassExternal),
|
||||
),
|
||||
cdn: true,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"),
|
||||
},
|
||||
{
|
||||
plugin: newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
|
||||
),
|
||||
cdn: false,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
plugin: newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
|
||||
),
|
||||
cdn: true,
|
||||
features: &config.Features{SriChecksEnabled: false},
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
plugin: newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
|
||||
),
|
||||
cdn: false,
|
||||
features: &config.Features{SriChecksEnabled: false},
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
// parentPluginID (/)
|
||||
// └── pluginID (/datasource)
|
||||
name: "nested plugin should return module hash from parent MANIFEST.txt",
|
||||
store: []pluginstore.Plugin{
|
||||
newPlugin(
|
||||
parentPluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested"))),
|
||||
),
|
||||
},
|
||||
plugin: newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "datasource"))),
|
||||
withParent(parentPluginID),
|
||||
),
|
||||
cdn: true,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: newSRIHash(t, "04d70db091d96c4775fb32ba5a8f84cc22893eb43afdb649726661d4425c6711"),
|
||||
},
|
||||
{
|
||||
// parentPluginID (/)
|
||||
// └── pluginID (/panels/one)
|
||||
name: "nested plugin deeper than one subfolder should return module hash from parent MANIFEST.txt",
|
||||
store: []pluginstore.Plugin{
|
||||
newPlugin(
|
||||
parentPluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested"))),
|
||||
),
|
||||
},
|
||||
plugin: newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "panels", "one"))),
|
||||
withParent(parentPluginID),
|
||||
),
|
||||
cdn: true,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: newSRIHash(t, "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f"),
|
||||
},
|
||||
{
|
||||
// grand-parent-app (/)
|
||||
// ├── parent-datasource (/datasource)
|
||||
// │ └── child-panel (/datasource/panels/one)
|
||||
name: "nested plugin of a nested plugin should return module hash from parent MANIFEST.txt",
|
||||
store: []pluginstore.Plugin{
|
||||
newPlugin(
|
||||
"grand-parent-app",
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested"))),
|
||||
),
|
||||
newPlugin(
|
||||
"parent-datasource",
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested", "datasource"))),
|
||||
withParent("grand-parent-app"),
|
||||
),
|
||||
},
|
||||
plugin: newPlugin(
|
||||
"child-panel",
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested", "datasource", "panels", "one"))),
|
||||
withParent("parent-datasource"),
|
||||
),
|
||||
cdn: true,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: newSRIHash(t, "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f"),
|
||||
},
|
||||
{
|
||||
name: "nested plugin should not return module hash from parent if it's not registered in the store",
|
||||
store: []pluginstore.Plugin{},
|
||||
plugin: newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "panels", "one"))),
|
||||
withParent(parentPluginID),
|
||||
),
|
||||
cdn: false,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
name: "missing module.js entry from MANIFEST.txt should not return module hash",
|
||||
plugin: newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-no-module-js"))),
|
||||
),
|
||||
cdn: false,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
name: "signed status but missing MANIFEST.txt should not return module hash",
|
||||
plugin: newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-no-manifest-txt"))),
|
||||
),
|
||||
cdn: false,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: "",
|
||||
},
|
||||
} {
|
||||
if tc.name == "" {
|
||||
var expS string
|
||||
if tc.expModuleHash == "" {
|
||||
expS = "should not return module hash"
|
||||
} else {
|
||||
expS = "should return module hash"
|
||||
}
|
||||
tc.name = fmt.Sprintf("feature=%v, cdn_config=%v, class=%v %s", tc.features.SriChecksEnabled, tc.cdn, tc.plugin.Class, expS)
|
||||
}
|
||||
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var pluginSettings config.PluginSettings
|
||||
if tc.cdn {
|
||||
pluginSettings = config.PluginSettings{
|
||||
pluginID: {
|
||||
"cdn": "true",
|
||||
},
|
||||
parentPluginID: map[string]string{
|
||||
"cdn": "true",
|
||||
},
|
||||
"grand-parent-app": map[string]string{
|
||||
"cdn": "true",
|
||||
},
|
||||
}
|
||||
}
|
||||
features := tc.features
|
||||
if features == nil {
|
||||
features = &config.Features{}
|
||||
}
|
||||
pCfg := &config.PluginManagementCfg{
|
||||
PluginsCDNURLTemplate: "http://cdn.example.com",
|
||||
PluginSettings: pluginSettings,
|
||||
Features: *features,
|
||||
}
|
||||
svc := ProvideService(
|
||||
pCfg,
|
||||
pluginscdn.ProvideService(pCfg),
|
||||
signature.ProvideService(pCfg, statickey.New()),
|
||||
pluginstore.NewFakePluginStore(tc.store...),
|
||||
)
|
||||
mh := svc.ModuleHash(context.Background(), tc.plugin)
|
||||
require.Equal(t, tc.expModuleHash, mh)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ModuleHash_Cache(t *testing.T) {
|
||||
pCfg := &config.PluginManagementCfg{
|
||||
PluginSettings: config.PluginSettings{},
|
||||
Features: config.Features{SriChecksEnabled: true},
|
||||
}
|
||||
svc := ProvideService(
|
||||
pCfg,
|
||||
pluginscdn.ProvideService(pCfg),
|
||||
signature.ProvideService(pCfg, statickey.New()),
|
||||
pluginstore.NewFakePluginStore(),
|
||||
)
|
||||
const pluginID = "grafana-test-datasource"
|
||||
|
||||
t.Run("cache key", func(t *testing.T) {
|
||||
t.Run("with version", func(t *testing.T) {
|
||||
const pluginVersion = "1.0.0"
|
||||
p := newPlugin(pluginID, withInfo(plugins.Info{Version: pluginVersion}))
|
||||
k := svc.moduleHashCacheKey(p)
|
||||
require.Equal(t, pluginID+":"+pluginVersion, k, "cache key should be correct")
|
||||
})
|
||||
|
||||
t.Run("without version", func(t *testing.T) {
|
||||
p := newPlugin(pluginID)
|
||||
k := svc.moduleHashCacheKey(p)
|
||||
require.Equal(t, pluginID+":", k, "cache key should be correct")
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("ModuleHash usage", func(t *testing.T) {
|
||||
pV1 := newPlugin(
|
||||
pluginID,
|
||||
withInfo(plugins.Info{Version: "1.0.0"}),
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
|
||||
)
|
||||
|
||||
pCfg = &config.PluginManagementCfg{
|
||||
PluginsCDNURLTemplate: "https://cdn.grafana.com",
|
||||
PluginSettings: config.PluginSettings{
|
||||
pluginID: {
|
||||
"cdn": "true",
|
||||
},
|
||||
},
|
||||
Features: config.Features{SriChecksEnabled: true},
|
||||
}
|
||||
svc = ProvideService(
|
||||
pCfg,
|
||||
pluginscdn.ProvideService(pCfg),
|
||||
signature.ProvideService(pCfg, statickey.New()),
|
||||
pluginstore.NewFakePluginStore(),
|
||||
)
|
||||
|
||||
k := svc.moduleHashCacheKey(pV1)
|
||||
|
||||
_, ok := svc.moduleHashCache.Load(k)
|
||||
require.False(t, ok, "cache should initially be empty")
|
||||
|
||||
mhV1 := svc.ModuleHash(context.Background(), pV1)
|
||||
pV1Exp := newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03")
|
||||
require.Equal(t, pV1Exp, mhV1, "returned value should be correct")
|
||||
|
||||
cachedMh, ok := svc.moduleHashCache.Load(k)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, pV1Exp, cachedMh, "cache should contain the returned value")
|
||||
|
||||
t.Run("different version uses different cache key", func(t *testing.T) {
|
||||
pV2 := newPlugin(
|
||||
pluginID,
|
||||
withInfo(plugins.Info{Version: "2.0.0"}),
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
// different fs for different hash
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested"))),
|
||||
)
|
||||
mhV2 := svc.ModuleHash(context.Background(), pV2)
|
||||
require.NotEqual(t, mhV2, mhV1, "different version should have different hash")
|
||||
require.Equal(t, newSRIHash(t, "266c19bc148b22ddef2a288fc5f8f40855bda22ccf60be53340b4931e469ae2a"), mhV2)
|
||||
})
|
||||
|
||||
t.Run("cache should be used", func(t *testing.T) {
|
||||
// edit cache directly
|
||||
svc.moduleHashCache.Store(k, "hax")
|
||||
require.Equal(t, "hax", svc.ModuleHash(context.Background(), pV1))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestConvertHashFromSRI(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
hash string
|
||||
expHash string
|
||||
expErr bool
|
||||
}{
|
||||
{
|
||||
hash: "ddfcb449445064e6c39f0c20b15be3cb6a55837cf4781df23d02de005f436811",
|
||||
expHash: "sha256-3fy0SURQZObDnwwgsVvjy2pVg3z0eB3yPQLeAF9DaBE=",
|
||||
},
|
||||
{
|
||||
hash: "not-a-valid-hash",
|
||||
expErr: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.hash, func(t *testing.T) {
|
||||
r, err := convertHashForSRI(tc.hash)
|
||||
if tc.expErr {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expHash, r)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newPlugin(pluginID string, cbs ...func(p pluginstore.Plugin) pluginstore.Plugin) pluginstore.Plugin {
|
||||
p := pluginstore.Plugin{
|
||||
JSONData: plugins.JSONData{
|
||||
@@ -186,6 +534,13 @@ func newPlugin(pluginID string, cbs ...func(p pluginstore.Plugin) pluginstore.Pl
|
||||
return p
|
||||
}
|
||||
|
||||
func withInfo(info plugins.Info) func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
return func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
p.Info = info
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
func withFS(fs plugins.FS) func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
return func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
p.FS = fs
|
||||
@@ -193,6 +548,13 @@ func withFS(fs plugins.FS) func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
func withSignatureStatus(status plugins.SignatureStatus) func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
return func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
p.Signature = status
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
func withAngular(angular bool) func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
return func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
p.Angular = plugins.AngularMeta{Detected: angular}
|
||||
@@ -200,6 +562,13 @@ func withAngular(angular bool) func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
func withParent(parentID string) func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
return func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
p.Parent = &pluginstore.ParentPlugin{ID: parentID}
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
func withClass(class plugins.Class) func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
return func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
p.Class = class
|
||||
@@ -218,3 +587,9 @@ func newPluginSettings(pluginID string, kv map[string]string) config.PluginSetti
|
||||
pluginID: kv,
|
||||
}
|
||||
}
|
||||
|
||||
func newSRIHash(t *testing.T, s string) string {
|
||||
r, err := convertHashForSRI(s)
|
||||
require.NoError(t, err)
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -30,9 +30,8 @@ type Plugin struct {
|
||||
Error *plugins.Error
|
||||
|
||||
// SystemJS fields
|
||||
Module string
|
||||
BaseURL string
|
||||
ModuleHash string
|
||||
Module string
|
||||
BaseURL string
|
||||
|
||||
Angular plugins.AngularMeta
|
||||
|
||||
@@ -81,7 +80,6 @@ func ToGrafanaDTO(p *plugins.Plugin) Plugin {
|
||||
ExternalService: p.ExternalService,
|
||||
Angular: p.Angular,
|
||||
Translations: p.Translations,
|
||||
ModuleHash: p.ModuleHash,
|
||||
}
|
||||
|
||||
if p.Parent != nil {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user