Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8595844334 | |||
| fd955f90ac | |||
| ccb032f376 | |||
| cf452c167b | |||
| bd0140b6f0 | |||
| 215d25ef69 | |||
| d3beed7dd2 | |||
| e2f2011d9e | |||
| 6db51cbdb9 | |||
| 82d8d44977 |
@@ -658,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:
|
||||
|
||||
+4
-22
@@ -290,7 +290,7 @@
|
||||
],
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "right",
|
||||
"placement": "bottom",
|
||||
"showLegend": true,
|
||||
"values": [
|
||||
"percent"
|
||||
@@ -304,7 +304,7 @@
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showLegend": true,
|
||||
"showLegend": false,
|
||||
"strokeWidth": 1,
|
||||
"text": {}
|
||||
},
|
||||
@@ -323,15 +323,6 @@
|
||||
}
|
||||
],
|
||||
"title": "Percent",
|
||||
"transformations": [
|
||||
{
|
||||
"id": "renameByRegex",
|
||||
"options": {
|
||||
"regex": "^Backend-(.*)$",
|
||||
"renamePattern": "b-$1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"type": "piechart"
|
||||
},
|
||||
{
|
||||
@@ -375,7 +366,7 @@
|
||||
],
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "right",
|
||||
"placement": "bottom",
|
||||
"showLegend": true,
|
||||
"values": [
|
||||
"value"
|
||||
@@ -389,7 +380,7 @@
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showLegend": true,
|
||||
"showLegend": false,
|
||||
"strokeWidth": 1,
|
||||
"text": {}
|
||||
},
|
||||
@@ -408,15 +399,6 @@
|
||||
}
|
||||
],
|
||||
"title": "Value",
|
||||
"transformations": [
|
||||
{
|
||||
"id": "renameByRegex",
|
||||
"options": {
|
||||
"regex": "(.*)",
|
||||
"renamePattern": "$1-how-much-wood-could-a-woodchuck-chuck-if-a-woodchuck-could-chuck-wood"
|
||||
}
|
||||
}
|
||||
],
|
||||
"type": "piechart"
|
||||
},
|
||||
{
|
||||
|
||||
+10
-3
@@ -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: [
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* This file was generated by grafana-app-sdk. DO NOT EDIT.
|
||||
*/
|
||||
import { Spec } from './types.spec.gen';
|
||||
import { Status } from './types.status.gen';
|
||||
|
||||
export interface Metadata {
|
||||
name: string;
|
||||
namespace: string;
|
||||
generateName?: string;
|
||||
selfLink?: string;
|
||||
uid?: string;
|
||||
resourceVersion?: string;
|
||||
generation?: number;
|
||||
creationTimestamp?: string;
|
||||
deletionTimestamp?: string;
|
||||
deletionGracePeriodSeconds?: number;
|
||||
labels?: Record<string, string>;
|
||||
annotations?: Record<string, string>;
|
||||
ownerReferences?: OwnerReference[];
|
||||
finalizers?: string[];
|
||||
managedFields?: ManagedFieldsEntry[];
|
||||
}
|
||||
|
||||
export interface OwnerReference {
|
||||
apiVersion: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
uid: string;
|
||||
controller?: boolean;
|
||||
blockOwnerDeletion?: boolean;
|
||||
}
|
||||
|
||||
export interface ManagedFieldsEntry {
|
||||
manager?: string;
|
||||
operation?: string;
|
||||
apiVersion?: string;
|
||||
time?: string;
|
||||
fieldsType?: string;
|
||||
subresource?: string;
|
||||
}
|
||||
|
||||
export interface Meta {
|
||||
kind: string;
|
||||
apiVersion: string;
|
||||
metadata: Metadata;
|
||||
spec: Spec;
|
||||
status: Status;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
|
||||
|
||||
// metadata contains embedded CommonMetadata and can be extended with custom string fields
|
||||
// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here
|
||||
// without external reference as using the CommonMetadata reference breaks thema codegen.
|
||||
export interface Metadata {
|
||||
updateTimestamp: string;
|
||||
createdBy: string;
|
||||
uid: string;
|
||||
creationTimestamp: string;
|
||||
deletionTimestamp?: string;
|
||||
finalizers: string[];
|
||||
resourceVersion: string;
|
||||
generation: number;
|
||||
updatedBy: string;
|
||||
labels: Record<string, string>;
|
||||
}
|
||||
|
||||
export const defaultMetadata = (): Metadata => ({
|
||||
updateTimestamp: "",
|
||||
createdBy: "",
|
||||
uid: "",
|
||||
creationTimestamp: "",
|
||||
finalizers: [],
|
||||
resourceVersion: "",
|
||||
generation: 0,
|
||||
updatedBy: "",
|
||||
labels: {},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
|
||||
|
||||
// JSON configuration schema for Grafana plugins
|
||||
// Converted from: https://github.com/grafana/grafana/blob/main/docs/sources/developers/plugins/plugin.schema.json
|
||||
export interface JSONData {
|
||||
// Unique name of the plugin
|
||||
id: string;
|
||||
// Plugin type
|
||||
type: "app" | "datasource" | "panel" | "renderer";
|
||||
// Human-readable name of the plugin
|
||||
name: string;
|
||||
// Metadata for the plugin
|
||||
info: Info;
|
||||
// Dependency information
|
||||
dependencies: Dependencies;
|
||||
// Optional fields
|
||||
alerting?: boolean;
|
||||
annotations?: boolean;
|
||||
autoEnabled?: boolean;
|
||||
backend?: boolean;
|
||||
buildMode?: string;
|
||||
builtIn?: boolean;
|
||||
category?: "tsdb" | "logging" | "cloud" | "tracing" | "profiling" | "sql" | "enterprise" | "iot" | "other";
|
||||
enterpriseFeatures?: EnterpriseFeatures;
|
||||
executable?: string;
|
||||
hideFromList?: boolean;
|
||||
// +listType=atomic
|
||||
includes?: Include[];
|
||||
logs?: boolean;
|
||||
metrics?: boolean;
|
||||
multiValueFilterOperators?: boolean;
|
||||
pascalName?: string;
|
||||
preload?: boolean;
|
||||
queryOptions?: QueryOptions;
|
||||
// +listType=atomic
|
||||
routes?: Route[];
|
||||
skipDataQuery?: boolean;
|
||||
state?: "alpha" | "beta";
|
||||
streaming?: boolean;
|
||||
suggestions?: boolean;
|
||||
tracing?: boolean;
|
||||
iam?: IAM;
|
||||
// +listType=atomic
|
||||
roles?: Role[];
|
||||
extensions?: Extensions;
|
||||
}
|
||||
|
||||
export const defaultJSONData = (): JSONData => ({
|
||||
id: "",
|
||||
type: "app",
|
||||
name: "",
|
||||
info: defaultInfo(),
|
||||
dependencies: defaultDependencies(),
|
||||
});
|
||||
|
||||
export interface Info {
|
||||
// Required fields
|
||||
// +listType=set
|
||||
keywords: string[];
|
||||
logos: {
|
||||
small: string;
|
||||
large: string;
|
||||
};
|
||||
updated: string;
|
||||
version: string;
|
||||
// Optional fields
|
||||
author?: {
|
||||
name?: string;
|
||||
email?: string;
|
||||
url?: string;
|
||||
};
|
||||
description?: string;
|
||||
// +listType=atomic
|
||||
links?: {
|
||||
name?: string;
|
||||
url?: string;
|
||||
}[];
|
||||
// +listType=atomic
|
||||
screenshots?: {
|
||||
name?: string;
|
||||
path?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const defaultInfo = (): Info => ({
|
||||
keywords: [],
|
||||
logos: {
|
||||
small: "",
|
||||
large: "",
|
||||
},
|
||||
updated: "",
|
||||
version: "",
|
||||
});
|
||||
|
||||
export interface Dependencies {
|
||||
// Required field
|
||||
grafanaDependency: string;
|
||||
// Optional fields
|
||||
grafanaVersion?: string;
|
||||
// +listType=set
|
||||
// +listMapKey=id
|
||||
plugins?: {
|
||||
id: string;
|
||||
type: "app" | "datasource" | "panel";
|
||||
name: string;
|
||||
}[];
|
||||
extensions?: {
|
||||
// +listType=set
|
||||
exposedComponents?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export const defaultDependencies = (): Dependencies => ({
|
||||
grafanaDependency: "",
|
||||
});
|
||||
|
||||
export interface EnterpriseFeatures {
|
||||
// Allow additional properties
|
||||
healthDiagnosticsErrors?: boolean;
|
||||
}
|
||||
|
||||
export const defaultEnterpriseFeatures = (): EnterpriseFeatures => ({
|
||||
healthDiagnosticsErrors: false,
|
||||
});
|
||||
|
||||
export interface Include {
|
||||
uid?: string;
|
||||
type?: "dashboard" | "page" | "panel" | "datasource";
|
||||
name?: string;
|
||||
component?: string;
|
||||
role?: "Admin" | "Editor" | "Viewer" | "None";
|
||||
action?: string;
|
||||
path?: string;
|
||||
addToNav?: boolean;
|
||||
defaultNav?: boolean;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export const defaultInclude = (): Include => ({
|
||||
});
|
||||
|
||||
export interface QueryOptions {
|
||||
maxDataPoints?: boolean;
|
||||
minInterval?: boolean;
|
||||
cacheTimeout?: boolean;
|
||||
}
|
||||
|
||||
export const defaultQueryOptions = (): QueryOptions => ({
|
||||
});
|
||||
|
||||
export interface Route {
|
||||
path?: string;
|
||||
method?: string;
|
||||
url?: string;
|
||||
reqSignedIn?: boolean;
|
||||
reqRole?: string;
|
||||
reqAction?: string;
|
||||
// +listType=atomic
|
||||
headers?: string[];
|
||||
body?: Record<string, any>;
|
||||
tokenAuth?: {
|
||||
url?: string;
|
||||
// +listType=set
|
||||
scopes?: string[];
|
||||
params?: Record<string, any>;
|
||||
};
|
||||
jwtTokenAuth?: {
|
||||
url?: string;
|
||||
// +listType=set
|
||||
scopes?: string[];
|
||||
params?: Record<string, any>;
|
||||
};
|
||||
// +listType=atomic
|
||||
urlParams?: {
|
||||
name?: string;
|
||||
content?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const defaultRoute = (): Route => ({
|
||||
});
|
||||
|
||||
export interface IAM {
|
||||
// +listType=atomic
|
||||
permissions?: {
|
||||
action?: string;
|
||||
scope?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const defaultIAM = (): IAM => ({
|
||||
});
|
||||
|
||||
export interface Role {
|
||||
role?: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
// +listType=atomic
|
||||
permissions?: {
|
||||
action?: string;
|
||||
scope?: string;
|
||||
}[];
|
||||
};
|
||||
// +listType=set
|
||||
grants?: string[];
|
||||
}
|
||||
|
||||
export const defaultRole = (): Role => ({
|
||||
});
|
||||
|
||||
export interface Extensions {
|
||||
// +listType=atomic
|
||||
addedComponents?: {
|
||||
// +listType=set
|
||||
targets: string[];
|
||||
title: string;
|
||||
description?: string;
|
||||
}[];
|
||||
// +listType=atomic
|
||||
addedLinks?: {
|
||||
// +listType=set
|
||||
targets: string[];
|
||||
title: string;
|
||||
description?: string;
|
||||
}[];
|
||||
// +listType=atomic
|
||||
addedFunctions?: {
|
||||
// +listType=set
|
||||
targets: string[];
|
||||
title: string;
|
||||
description?: string;
|
||||
}[];
|
||||
// +listType=set
|
||||
// +listMapKey=id
|
||||
exposedComponents?: {
|
||||
id: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}[];
|
||||
// +listType=set
|
||||
// +listMapKey=id
|
||||
extensionPoints?: {
|
||||
id: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const defaultExtensions = (): Extensions => ({
|
||||
});
|
||||
|
||||
export interface Spec {
|
||||
pluginJson: JSONData;
|
||||
class: "core" | "external";
|
||||
module?: {
|
||||
path: string;
|
||||
hash?: string;
|
||||
loadingStrategy?: "fetch" | "script";
|
||||
};
|
||||
baseURL?: string;
|
||||
signature?: {
|
||||
status: "internal" | "valid" | "invalid" | "modified" | "unsigned";
|
||||
type?: "grafana" | "commercial" | "community" | "private" | "private-glob";
|
||||
org?: string;
|
||||
};
|
||||
angular?: {
|
||||
detected: boolean;
|
||||
};
|
||||
translations?: Record<string, string>;
|
||||
// +listType=atomic
|
||||
children?: string[];
|
||||
}
|
||||
|
||||
export const defaultSpec = (): Spec => ({
|
||||
pluginJson: defaultJSONData(),
|
||||
class: "core",
|
||||
});
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
|
||||
|
||||
export interface OperatorState {
|
||||
// lastEvaluation is the ResourceVersion last evaluated
|
||||
lastEvaluation: string;
|
||||
// state describes the state of the lastEvaluation.
|
||||
// It is limited to three possible states for machine evaluation.
|
||||
state: "success" | "in_progress" | "failed";
|
||||
// descriptiveState is an optional more descriptive state field which has no requirements on format
|
||||
descriptiveState?: string;
|
||||
// details contains any extra information that is operator-specific
|
||||
details?: Record<string, any>;
|
||||
}
|
||||
|
||||
export const defaultOperatorState = (): OperatorState => ({
|
||||
lastEvaluation: "",
|
||||
state: "success",
|
||||
});
|
||||
|
||||
export interface Status {
|
||||
// operatorStates is a map of operator ID to operator state evaluations.
|
||||
// Any operator which consumes this kind SHOULD add its state evaluation information to this field.
|
||||
operatorStates?: Record<string, OperatorState>;
|
||||
// additionalFields is reserved for future use
|
||||
additionalFields?: Record<string, any>;
|
||||
}
|
||||
|
||||
export const defaultStatus = (): Status => ({
|
||||
});
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* This file was generated by grafana-app-sdk. DO NOT EDIT.
|
||||
*/
|
||||
import { Spec } from './types.spec.gen';
|
||||
import { Status } from './types.status.gen';
|
||||
|
||||
export interface Metadata {
|
||||
name: string;
|
||||
namespace: string;
|
||||
generateName?: string;
|
||||
selfLink?: string;
|
||||
uid?: string;
|
||||
resourceVersion?: string;
|
||||
generation?: number;
|
||||
creationTimestamp?: string;
|
||||
deletionTimestamp?: string;
|
||||
deletionGracePeriodSeconds?: number;
|
||||
labels?: Record<string, string>;
|
||||
annotations?: Record<string, string>;
|
||||
ownerReferences?: OwnerReference[];
|
||||
finalizers?: string[];
|
||||
managedFields?: ManagedFieldsEntry[];
|
||||
}
|
||||
|
||||
export interface OwnerReference {
|
||||
apiVersion: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
uid: string;
|
||||
controller?: boolean;
|
||||
blockOwnerDeletion?: boolean;
|
||||
}
|
||||
|
||||
export interface ManagedFieldsEntry {
|
||||
manager?: string;
|
||||
operation?: string;
|
||||
apiVersion?: string;
|
||||
time?: string;
|
||||
fieldsType?: string;
|
||||
subresource?: string;
|
||||
}
|
||||
|
||||
export interface Plugin {
|
||||
kind: string;
|
||||
apiVersion: string;
|
||||
metadata: Metadata;
|
||||
spec: Spec;
|
||||
status: Status;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
|
||||
|
||||
// metadata contains embedded CommonMetadata and can be extended with custom string fields
|
||||
// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here
|
||||
// without external reference as using the CommonMetadata reference breaks thema codegen.
|
||||
export interface Metadata {
|
||||
updateTimestamp: string;
|
||||
createdBy: string;
|
||||
uid: string;
|
||||
creationTimestamp: string;
|
||||
deletionTimestamp?: string;
|
||||
finalizers: string[];
|
||||
resourceVersion: string;
|
||||
generation: number;
|
||||
updatedBy: string;
|
||||
labels: Record<string, string>;
|
||||
}
|
||||
|
||||
export const defaultMetadata = (): Metadata => ({
|
||||
updateTimestamp: "",
|
||||
createdBy: "",
|
||||
uid: "",
|
||||
creationTimestamp: "",
|
||||
finalizers: [],
|
||||
resourceVersion: "",
|
||||
generation: 0,
|
||||
updatedBy: "",
|
||||
labels: {},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
|
||||
|
||||
export interface Spec {
|
||||
id: string;
|
||||
version: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export const defaultSpec = (): Spec => ({
|
||||
id: "",
|
||||
version: "",
|
||||
});
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
|
||||
|
||||
export interface OperatorState {
|
||||
// lastEvaluation is the ResourceVersion last evaluated
|
||||
lastEvaluation: string;
|
||||
// state describes the state of the lastEvaluation.
|
||||
// It is limited to three possible states for machine evaluation.
|
||||
state: "success" | "in_progress" | "failed";
|
||||
// descriptiveState is an optional more descriptive state field which has no requirements on format
|
||||
descriptiveState?: string;
|
||||
// details contains any extra information that is operator-specific
|
||||
details?: Record<string, any>;
|
||||
}
|
||||
|
||||
export const defaultOperatorState = (): OperatorState => ({
|
||||
lastEvaluation: "",
|
||||
state: "success",
|
||||
});
|
||||
|
||||
export interface Status {
|
||||
// operatorStates is a map of operator ID to operator state evaluations.
|
||||
// Any operator which consumes this kind SHOULD add its state evaluation information to this field.
|
||||
operatorStates?: Record<string, OperatorState>;
|
||||
// additionalFields is reserved for future use
|
||||
additionalFields?: Record<string, any>;
|
||||
}
|
||||
|
||||
export const defaultStatus = (): Status => ({
|
||||
});
|
||||
|
||||
@@ -248,7 +248,7 @@
|
||||
"legend": {
|
||||
"values": ["percent"],
|
||||
"displayMode": "table",
|
||||
"placement": "right"
|
||||
"placement": "bottom"
|
||||
},
|
||||
"pieType": "pie",
|
||||
"reduceOptions": {
|
||||
@@ -256,7 +256,7 @@
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showLegend": true,
|
||||
"showLegend": false,
|
||||
"strokeWidth": 1,
|
||||
"text": {}
|
||||
},
|
||||
@@ -272,15 +272,6 @@
|
||||
"timeFrom": null,
|
||||
"timeShift": null,
|
||||
"title": "Percent",
|
||||
"transformations": [
|
||||
{
|
||||
"id": "renameByRegex",
|
||||
"options": {
|
||||
"regex": "^Backend-(.*)$",
|
||||
"renamePattern": "b-$1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"type": "piechart"
|
||||
},
|
||||
{
|
||||
@@ -320,7 +311,7 @@
|
||||
"legend": {
|
||||
"values": ["value"],
|
||||
"displayMode": "table",
|
||||
"placement": "right"
|
||||
"placement": "bottom"
|
||||
},
|
||||
"pieType": "pie",
|
||||
"reduceOptions": {
|
||||
@@ -328,7 +319,7 @@
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showLegend": true,
|
||||
"showLegend": false,
|
||||
"strokeWidth": 1,
|
||||
"text": {}
|
||||
},
|
||||
@@ -344,15 +335,6 @@
|
||||
"timeFrom": null,
|
||||
"timeShift": null,
|
||||
"title": "Value",
|
||||
"transformations": [
|
||||
{
|
||||
"id": "renameByRegex",
|
||||
"options": {
|
||||
"regex": "(.*)",
|
||||
"renamePattern": "$1-how-much-wood-could-a-woodchuck-chuck-if-a-woodchuck-could-chuck-wood"
|
||||
}
|
||||
}
|
||||
],
|
||||
"type": "piechart"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1377,6 +1377,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 +1622,26 @@
|
||||
"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
|
||||
@@ -1663,12 +1683,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 +1752,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 +2101,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 +2932,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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { evaluateBooleanFlag } from '../../internal/openFeature';
|
||||
|
||||
import {
|
||||
getAppPluginMeta,
|
||||
getAppPluginMetas,
|
||||
getAppPluginVersion,
|
||||
isAppPluginInstalled,
|
||||
setAppPluginMetas,
|
||||
} from './apps';
|
||||
import { initPluginMetas } from './plugins';
|
||||
import { app } from './test-fixtures/config.apps';
|
||||
|
||||
jest.mock('./plugins', () => ({ ...jest.requireActual('./plugins'), initPluginMetas: jest.fn() }));
|
||||
jest.mock('../../internal/openFeature', () => ({
|
||||
...jest.requireActual('../../internal/openFeature'),
|
||||
evaluateBooleanFlag: jest.fn(),
|
||||
}));
|
||||
|
||||
const initPluginMetasMock = jest.mocked(initPluginMetas);
|
||||
const evaluateBooleanFlagMock = jest.mocked(evaluateBooleanFlag);
|
||||
|
||||
describe('when useMTPlugins flag is enabled and apps is not initialized', () => {
|
||||
beforeEach(() => {
|
||||
setAppPluginMetas({});
|
||||
jest.resetAllMocks();
|
||||
initPluginMetasMock.mockResolvedValue({ items: [] });
|
||||
evaluateBooleanFlagMock.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('getAppPluginMetas should call initPluginMetas and return correct result', async () => {
|
||||
const apps = await getAppPluginMetas();
|
||||
|
||||
expect(apps).toEqual([]);
|
||||
expect(initPluginMetasMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('getAppPluginMeta should call initPluginMetas and return correct result', async () => {
|
||||
const result = await getAppPluginMeta('myorg-someplugin-app');
|
||||
|
||||
expect(result).toEqual(null);
|
||||
expect(initPluginMetasMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('isAppPluginInstalled should call initPluginMetas and return false', async () => {
|
||||
const installed = await isAppPluginInstalled('myorg-someplugin-app');
|
||||
|
||||
expect(installed).toEqual(false);
|
||||
expect(initPluginMetasMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('getAppPluginVersion should call initPluginMetas and return null', async () => {
|
||||
const result = await getAppPluginVersion('myorg-someplugin-app');
|
||||
|
||||
expect(result).toEqual(null);
|
||||
expect(initPluginMetasMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when useMTPlugins flag is enabled and apps is initialized', () => {
|
||||
beforeEach(() => {
|
||||
setAppPluginMetas({ 'myorg-someplugin-app': app });
|
||||
jest.resetAllMocks();
|
||||
evaluateBooleanFlagMock.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('getAppPluginMetas should not call initPluginMetas and return correct result', async () => {
|
||||
const apps = await getAppPluginMetas();
|
||||
|
||||
expect(apps).toEqual([app]);
|
||||
expect(initPluginMetasMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('getAppPluginMeta should not call initPluginMetas and return correct result', async () => {
|
||||
const result = await getAppPluginMeta('myorg-someplugin-app');
|
||||
|
||||
expect(result).toEqual(app);
|
||||
expect(initPluginMetasMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('getAppPluginMeta should return null if the pluginId is not found', async () => {
|
||||
const result = await getAppPluginMeta('otherorg-otherplugin-app');
|
||||
|
||||
expect(result).toEqual(null);
|
||||
});
|
||||
|
||||
it('isAppPluginInstalled should not call initPluginMetas and return true', async () => {
|
||||
const installed = await isAppPluginInstalled('myorg-someplugin-app');
|
||||
|
||||
expect(installed).toEqual(true);
|
||||
expect(initPluginMetasMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('isAppPluginInstalled should return false if the pluginId is not found', async () => {
|
||||
const result = await isAppPluginInstalled('otherorg-otherplugin-app');
|
||||
|
||||
expect(result).toEqual(false);
|
||||
});
|
||||
|
||||
it('getAppPluginVersion should not call initPluginMetas and return correct result', async () => {
|
||||
const result = await getAppPluginVersion('myorg-someplugin-app');
|
||||
|
||||
expect(result).toEqual('1.0.0');
|
||||
expect(initPluginMetasMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('getAppPluginVersion should return null if the pluginId is not found', async () => {
|
||||
const result = await getAppPluginVersion('otherorg-otherplugin-app');
|
||||
|
||||
expect(result).toEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when useMTPlugins flag is disabled and apps is not initialized', () => {
|
||||
beforeEach(() => {
|
||||
setAppPluginMetas({});
|
||||
jest.resetAllMocks();
|
||||
evaluateBooleanFlagMock.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('getAppPluginMetas should not call initPluginMetas and return correct result', async () => {
|
||||
const apps = await getAppPluginMetas();
|
||||
|
||||
expect(apps).toEqual([]);
|
||||
expect(initPluginMetasMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('getAppPluginMeta should not call initPluginMetas and return correct result', async () => {
|
||||
const result = await getAppPluginMeta('myorg-someplugin-app');
|
||||
|
||||
expect(result).toEqual(null);
|
||||
expect(initPluginMetasMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('isAppPluginInstalled should not call initPluginMetas and return false', async () => {
|
||||
const result = await isAppPluginInstalled('myorg-someplugin-app');
|
||||
|
||||
expect(result).toEqual(false);
|
||||
expect(initPluginMetasMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('getAppPluginVersion should not call initPluginMetas and return correct result', async () => {
|
||||
const result = await getAppPluginVersion('myorg-someplugin-app');
|
||||
|
||||
expect(result).toEqual(null);
|
||||
expect(initPluginMetasMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when useMTPlugins flag is disabled and apps is initialized', () => {
|
||||
beforeEach(() => {
|
||||
setAppPluginMetas({ 'myorg-someplugin-app': app });
|
||||
jest.resetAllMocks();
|
||||
evaluateBooleanFlagMock.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('getAppPluginMetas should not call initPluginMetas and return correct result', async () => {
|
||||
const apps = await getAppPluginMetas();
|
||||
|
||||
expect(apps).toEqual([app]);
|
||||
expect(initPluginMetasMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('getAppPluginMeta should not call initPluginMetas and return correct result', async () => {
|
||||
const result = await getAppPluginMeta('myorg-someplugin-app');
|
||||
|
||||
expect(result).toEqual(app);
|
||||
expect(initPluginMetasMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('getAppPluginMeta should return null if the pluginId is not found', async () => {
|
||||
const result = await getAppPluginMeta('otherorg-otherplugin-app');
|
||||
|
||||
expect(result).toEqual(null);
|
||||
});
|
||||
|
||||
it('isAppPluginInstalled should not call initPluginMetas and return true', async () => {
|
||||
const result = await isAppPluginInstalled('myorg-someplugin-app');
|
||||
|
||||
expect(result).toEqual(true);
|
||||
expect(initPluginMetasMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('isAppPluginInstalled should return false if the pluginId is not found', async () => {
|
||||
const result = await isAppPluginInstalled('otherorg-otherplugin-app');
|
||||
|
||||
expect(result).toEqual(false);
|
||||
});
|
||||
|
||||
it('getAppPluginVersion should not call initPluginMetas and return correct result', async () => {
|
||||
const result = await getAppPluginVersion('myorg-someplugin-app');
|
||||
|
||||
expect(result).toEqual('1.0.0');
|
||||
expect(initPluginMetasMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('getAppPluginVersion should return null if the pluginId is not found', async () => {
|
||||
const result = await getAppPluginVersion('otherorg-otherplugin-app');
|
||||
|
||||
expect(result).toEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('immutability', () => {
|
||||
beforeEach(() => {
|
||||
setAppPluginMetas({ 'myorg-someplugin-app': app });
|
||||
jest.resetAllMocks();
|
||||
evaluateBooleanFlagMock.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('getAppPluginMetas should return a deep clone', async () => {
|
||||
const mutatedApps = await getAppPluginMetas();
|
||||
|
||||
// assert we have correct props
|
||||
expect(mutatedApps).toHaveLength(1);
|
||||
expect(mutatedApps[0].dependencies.grafanaDependency).toEqual('>=10.4.0');
|
||||
expect(mutatedApps[0].extensions.addedLinks).toHaveLength(0);
|
||||
|
||||
// mutate deep props
|
||||
mutatedApps[0].dependencies.grafanaDependency = '';
|
||||
mutatedApps[0].extensions.addedLinks.push({ targets: [], title: '', description: '' });
|
||||
|
||||
// assert we have mutated props
|
||||
expect(mutatedApps[0].dependencies.grafanaDependency).toEqual('');
|
||||
expect(mutatedApps[0].extensions.addedLinks).toHaveLength(1);
|
||||
expect(mutatedApps[0].extensions.addedLinks[0]).toEqual({ targets: [], title: '', description: '' });
|
||||
|
||||
const apps = await getAppPluginMetas();
|
||||
|
||||
// assert that we have not mutated the source
|
||||
expect(apps[0].dependencies.grafanaDependency).toEqual('>=10.4.0');
|
||||
expect(apps[0].extensions.addedLinks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('getAppPluginMeta should return a deep clone', async () => {
|
||||
const mutatedApp = await getAppPluginMeta('myorg-someplugin-app');
|
||||
|
||||
// assert we have correct props
|
||||
expect(mutatedApp).toBeDefined();
|
||||
expect(mutatedApp!.dependencies.grafanaDependency).toEqual('>=10.4.0');
|
||||
expect(mutatedApp!.extensions.addedLinks).toHaveLength(0);
|
||||
|
||||
// mutate deep props
|
||||
mutatedApp!.dependencies.grafanaDependency = '';
|
||||
mutatedApp!.extensions.addedLinks.push({ targets: [], title: '', description: '' });
|
||||
|
||||
// assert we have mutated props
|
||||
expect(mutatedApp!.dependencies.grafanaDependency).toEqual('');
|
||||
expect(mutatedApp!.extensions.addedLinks).toHaveLength(1);
|
||||
expect(mutatedApp!.extensions.addedLinks[0]).toEqual({ targets: [], title: '', description: '' });
|
||||
|
||||
const result = await getAppPluginMeta('myorg-someplugin-app');
|
||||
|
||||
// assert that we have not mutated the source
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.dependencies.grafanaDependency).toEqual('>=10.4.0');
|
||||
expect(result!.extensions.addedLinks).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { AppPluginConfig } from '@grafana/data';
|
||||
|
||||
import { config } from '../../config';
|
||||
import { evaluateBooleanFlag } from '../../internal/openFeature';
|
||||
|
||||
import { getAppPluginMapper } from './mappers/mappers';
|
||||
import { initPluginMetas } from './plugins';
|
||||
import type { AppPluginMetas } from './types';
|
||||
|
||||
let apps: AppPluginMetas = {};
|
||||
|
||||
function initialized(): boolean {
|
||||
return Boolean(Object.keys(apps).length);
|
||||
}
|
||||
|
||||
async function initAppPluginMetas(): Promise<void> {
|
||||
if (!evaluateBooleanFlag('useMTPlugins', false)) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
apps = config.apps;
|
||||
return;
|
||||
}
|
||||
|
||||
const metas = await initPluginMetas();
|
||||
const mapper = getAppPluginMapper();
|
||||
apps = mapper(metas);
|
||||
}
|
||||
|
||||
export async function getAppPluginMetas(): Promise<AppPluginConfig[]> {
|
||||
if (!initialized()) {
|
||||
await initAppPluginMetas();
|
||||
}
|
||||
|
||||
return Object.values(structuredClone(apps));
|
||||
}
|
||||
|
||||
export async function getAppPluginMeta(pluginId: string): Promise<AppPluginConfig | null> {
|
||||
if (!initialized()) {
|
||||
await initAppPluginMetas();
|
||||
}
|
||||
|
||||
const app = apps[pluginId];
|
||||
return app ? structuredClone(app) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an app plugin is installed. The function does not check if the app plugin is enabled.
|
||||
* @param pluginId - The id of the app plugin.
|
||||
* @returns True if the app plugin is installed, false otherwise.
|
||||
*/
|
||||
export async function isAppPluginInstalled(pluginId: string): Promise<boolean> {
|
||||
const app = await getAppPluginMeta(pluginId);
|
||||
return Boolean(app);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the version of an app plugin.
|
||||
* @param pluginId - The id of the app plugin.
|
||||
* @returns The version of the app plugin, or null if the plugin is not installed.
|
||||
*/
|
||||
export async function getAppPluginVersion(pluginId: string): Promise<string | null> {
|
||||
const app = await getAppPluginMeta(pluginId);
|
||||
return app?.version ?? null;
|
||||
}
|
||||
|
||||
export function setAppPluginMetas(override: AppPluginMetas): void {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error('setAppPluginMetas() function can only be called from tests.');
|
||||
}
|
||||
|
||||
apps = structuredClone(override);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import {
|
||||
getAppPluginMeta,
|
||||
getAppPluginMetas,
|
||||
getAppPluginVersion,
|
||||
isAppPluginInstalled,
|
||||
setAppPluginMetas,
|
||||
} from './apps';
|
||||
import { useAppPluginMeta, useAppPluginMetas, useAppPluginInstalled, useAppPluginVersion } from './hooks';
|
||||
import { apps } from './test-fixtures/config.apps';
|
||||
|
||||
const actualApps = jest.requireActual<typeof import('./apps')>('./apps');
|
||||
jest.mock('./apps', () => ({
|
||||
...jest.requireActual('./apps'),
|
||||
getAppPluginMetas: jest.fn(),
|
||||
getAppPluginMeta: jest.fn(),
|
||||
isAppPluginInstalled: jest.fn(),
|
||||
getAppPluginVersion: jest.fn(),
|
||||
}));
|
||||
const getAppPluginMetaMock = jest.mocked(getAppPluginMeta);
|
||||
const getAppPluginMetasMock = jest.mocked(getAppPluginMetas);
|
||||
const isAppPluginInstalledMock = jest.mocked(isAppPluginInstalled);
|
||||
const getAppPluginVersionMock = jest.mocked(getAppPluginVersion);
|
||||
|
||||
describe('useAppPluginMeta', () => {
|
||||
beforeEach(() => {
|
||||
setAppPluginMetas(apps);
|
||||
jest.resetAllMocks();
|
||||
getAppPluginMetaMock.mockImplementation(actualApps.getAppPluginMeta);
|
||||
});
|
||||
|
||||
it('should return correct default values', async () => {
|
||||
const { result } = renderHook(() => useAppPluginMeta('grafana-exploretraces-app'));
|
||||
|
||||
expect(result.current.loading).toEqual(true);
|
||||
expect(result.current.error).toBeUndefined();
|
||||
expect(result.current.value).toBeUndefined();
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toEqual(true));
|
||||
});
|
||||
|
||||
it('should return correct values after loading', async () => {
|
||||
const { result } = renderHook(() => useAppPluginMeta('grafana-exploretraces-app'));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toEqual(false));
|
||||
|
||||
expect(result.current.loading).toEqual(false);
|
||||
expect(result.current.error).toBeUndefined();
|
||||
expect(result.current.value).toEqual(apps['grafana-exploretraces-app']);
|
||||
});
|
||||
|
||||
it('should return correct values if the pluginId does not exist', async () => {
|
||||
const { result } = renderHook(() => useAppPluginMeta('otherorg-otherplugin-app'));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toEqual(false));
|
||||
|
||||
expect(result.current.loading).toEqual(false);
|
||||
expect(result.current.error).toBeUndefined();
|
||||
expect(result.current.value).toEqual(null);
|
||||
});
|
||||
|
||||
it('should return correct values if useAppPluginMeta throws', async () => {
|
||||
getAppPluginMetaMock.mockRejectedValue(new Error('Some error'));
|
||||
|
||||
const { result } = renderHook(() => useAppPluginMeta('otherorg-otherplugin-app'));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toEqual(false));
|
||||
|
||||
expect(result.current.loading).toEqual(false);
|
||||
expect(result.current.error).toEqual(new Error('Some error'));
|
||||
expect(result.current.value).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useAppPluginMetas', () => {
|
||||
beforeEach(() => {
|
||||
setAppPluginMetas(apps);
|
||||
jest.resetAllMocks();
|
||||
getAppPluginMetasMock.mockImplementation(actualApps.getAppPluginMetas);
|
||||
});
|
||||
|
||||
it('should return correct default values', async () => {
|
||||
const { result } = renderHook(() => useAppPluginMetas());
|
||||
|
||||
expect(result.current.loading).toEqual(true);
|
||||
expect(result.current.error).toBeUndefined();
|
||||
expect(result.current.value).toBeUndefined();
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toEqual(true));
|
||||
});
|
||||
|
||||
it('should return correct values after loading', async () => {
|
||||
const { result } = renderHook(() => useAppPluginMetas());
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toEqual(false));
|
||||
|
||||
expect(result.current.loading).toEqual(false);
|
||||
expect(result.current.error).toBeUndefined();
|
||||
expect(result.current.value).toEqual(Object.values(apps));
|
||||
});
|
||||
|
||||
it('should return correct values if useAppPluginMetas throws', async () => {
|
||||
getAppPluginMetasMock.mockRejectedValue(new Error('Some error'));
|
||||
|
||||
const { result } = renderHook(() => useAppPluginMetas());
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toEqual(false));
|
||||
|
||||
expect(result.current.loading).toEqual(false);
|
||||
expect(result.current.error).toEqual(new Error('Some error'));
|
||||
expect(result.current.value).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useAppPluginInstalled', () => {
|
||||
beforeEach(() => {
|
||||
setAppPluginMetas(apps);
|
||||
jest.resetAllMocks();
|
||||
isAppPluginInstalledMock.mockImplementation(actualApps.isAppPluginInstalled);
|
||||
});
|
||||
|
||||
it('should return correct default values', async () => {
|
||||
const { result } = renderHook(() => useAppPluginInstalled('grafana-exploretraces-app'));
|
||||
|
||||
expect(result.current.loading).toEqual(true);
|
||||
expect(result.current.error).toBeUndefined();
|
||||
expect(result.current.value).toBeUndefined();
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toEqual(true));
|
||||
});
|
||||
|
||||
it('should return correct values after loading', async () => {
|
||||
const { result } = renderHook(() => useAppPluginInstalled('grafana-exploretraces-app'));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toEqual(false));
|
||||
|
||||
expect(result.current.loading).toEqual(false);
|
||||
expect(result.current.error).toBeUndefined();
|
||||
expect(result.current.value).toEqual(true);
|
||||
});
|
||||
|
||||
it('should return correct values if the pluginId does not exist', async () => {
|
||||
const { result } = renderHook(() => useAppPluginInstalled('otherorg-otherplugin-app'));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toEqual(false));
|
||||
|
||||
expect(result.current.loading).toEqual(false);
|
||||
expect(result.current.error).toBeUndefined();
|
||||
expect(result.current.value).toEqual(false);
|
||||
});
|
||||
|
||||
it('should return correct values if isAppPluginInstalled throws', async () => {
|
||||
isAppPluginInstalledMock.mockRejectedValue(new Error('Some error'));
|
||||
|
||||
const { result } = renderHook(() => useAppPluginInstalled('otherorg-otherplugin-app'));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toEqual(false));
|
||||
|
||||
expect(result.current.loading).toEqual(false);
|
||||
expect(result.current.error).toEqual(new Error('Some error'));
|
||||
expect(result.current.value).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useAppPluginVersion', () => {
|
||||
beforeEach(() => {
|
||||
setAppPluginMetas(apps);
|
||||
jest.resetAllMocks();
|
||||
getAppPluginVersionMock.mockImplementation(actualApps.getAppPluginVersion);
|
||||
});
|
||||
|
||||
it('should return correct default values', async () => {
|
||||
const { result } = renderHook(() => useAppPluginVersion('grafana-exploretraces-app'));
|
||||
|
||||
expect(result.current.loading).toEqual(true);
|
||||
expect(result.current.error).toBeUndefined();
|
||||
expect(result.current.value).toBeUndefined();
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toEqual(true));
|
||||
});
|
||||
|
||||
it('should return correct values after loading', async () => {
|
||||
const { result } = renderHook(() => useAppPluginVersion('grafana-exploretraces-app'));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toEqual(false));
|
||||
|
||||
expect(result.current.loading).toEqual(false);
|
||||
expect(result.current.error).toBeUndefined();
|
||||
expect(result.current.value).toEqual('1.2.2');
|
||||
});
|
||||
|
||||
it('should return correct values if the pluginId does not exist', async () => {
|
||||
const { result } = renderHook(() => useAppPluginVersion('otherorg-otherplugin-app'));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toEqual(false));
|
||||
|
||||
expect(result.current.loading).toEqual(false);
|
||||
expect(result.current.error).toBeUndefined();
|
||||
expect(result.current.value).toEqual(null);
|
||||
});
|
||||
|
||||
it('should return correct values if getAppPluginVersion throws', async () => {
|
||||
getAppPluginVersionMock.mockRejectedValue(new Error('Some error'));
|
||||
|
||||
const { result } = renderHook(() => useAppPluginVersion('otherorg-otherplugin-app'));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toEqual(false));
|
||||
|
||||
expect(result.current.loading).toEqual(false);
|
||||
expect(result.current.error).toEqual(new Error('Some error'));
|
||||
expect(result.current.value).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
import { getAppPluginMeta, getAppPluginMetas, getAppPluginVersion, isAppPluginInstalled } from './apps';
|
||||
|
||||
export function useAppPluginMetas() {
|
||||
const { loading, error, value } = useAsync(async () => getAppPluginMetas());
|
||||
return { loading, error, value };
|
||||
}
|
||||
|
||||
export function useAppPluginMeta(pluginId: string) {
|
||||
const { loading, error, value } = useAsync(async () => getAppPluginMeta(pluginId));
|
||||
return { loading, error, value };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that checks if an app plugin is installed. The hook does not check if the app plugin is enabled.
|
||||
* @param pluginId - The ID of the app plugin.
|
||||
* @returns loading, error, value of the app plugin installed status.
|
||||
* The value is true if the app plugin is installed, false otherwise.
|
||||
*/
|
||||
export function useAppPluginInstalled(pluginId: string) {
|
||||
const { loading, error, value } = useAsync(async () => isAppPluginInstalled(pluginId));
|
||||
return { loading, error, value };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that gets the version of an app plugin.
|
||||
* @param pluginId - The ID of the app plugin.
|
||||
* @returns loading, error, value of the app plugin version.
|
||||
* The value is the version of the app plugin, or null if the plugin is not installed.
|
||||
*/
|
||||
export function useAppPluginVersion(pluginId: string) {
|
||||
const { loading, error, value } = useAsync(async () => getAppPluginVersion(pluginId));
|
||||
return { loading, error, value };
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { AppPluginMetasMapper, PluginMetasResponse } from '../types';
|
||||
|
||||
import { v0alpha1AppMapper } from './v0alpha1AppMapper';
|
||||
|
||||
export function getAppPluginMapper(): AppPluginMetasMapper<PluginMetasResponse> {
|
||||
return v0alpha1AppMapper;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { apps } from '../test-fixtures/config.apps';
|
||||
import { v0alpha1Response } from '../test-fixtures/v0alpha1Response';
|
||||
|
||||
import { v0alpha1AppMapper } from './v0alpha1AppMapper';
|
||||
|
||||
const PLUGIN_IDS = v0alpha1Response.items
|
||||
.filter((i) => i.spec.pluginJson.type === 'app')
|
||||
.map((i) => ({ pluginId: i.spec.pluginJson.id }));
|
||||
|
||||
describe('v0alpha1AppMapper', () => {
|
||||
describe.each(PLUGIN_IDS)('when called for pluginId:$pluginId', ({ pluginId }) => {
|
||||
it('should map id property correctly', () => {
|
||||
const result = v0alpha1AppMapper(v0alpha1Response);
|
||||
|
||||
expect(result[pluginId].id).toEqual(apps[pluginId].id);
|
||||
});
|
||||
|
||||
it('should map path property correctly', () => {
|
||||
const result = v0alpha1AppMapper(v0alpha1Response);
|
||||
|
||||
expect(result[pluginId].path).toEqual(apps[pluginId].path);
|
||||
});
|
||||
|
||||
it('should map version property correctly', () => {
|
||||
const result = v0alpha1AppMapper(v0alpha1Response);
|
||||
|
||||
expect(result[pluginId].version).toEqual(apps[pluginId].version);
|
||||
});
|
||||
|
||||
it('should map preload property correctly', () => {
|
||||
const result = v0alpha1AppMapper(v0alpha1Response);
|
||||
|
||||
expect(result[pluginId].preload).toEqual(apps[pluginId].preload);
|
||||
});
|
||||
|
||||
it('should map angular property correctly', () => {
|
||||
const result = v0alpha1AppMapper(v0alpha1Response);
|
||||
|
||||
expect(result[pluginId].angular).toEqual({});
|
||||
});
|
||||
|
||||
it('should map loadingStrategy property correctly', () => {
|
||||
const result = v0alpha1AppMapper(v0alpha1Response);
|
||||
|
||||
expect(result[pluginId].loadingStrategy).toEqual(apps[pluginId].loadingStrategy);
|
||||
});
|
||||
|
||||
it('should map dependencies property correctly', () => {
|
||||
const result = v0alpha1AppMapper(v0alpha1Response);
|
||||
|
||||
expect(result[pluginId].dependencies).toEqual(apps[pluginId].dependencies);
|
||||
});
|
||||
|
||||
it('should map extensions property correctly', () => {
|
||||
const result = v0alpha1AppMapper(v0alpha1Response);
|
||||
|
||||
expect(result[pluginId].extensions.addedComponents).toEqual(apps[pluginId].extensions.addedComponents);
|
||||
expect(result[pluginId].extensions.addedFunctions).toEqual(apps[pluginId].extensions.addedFunctions);
|
||||
expect(result[pluginId].extensions.addedLinks).toEqual(apps[pluginId].extensions.addedLinks);
|
||||
expect(result[pluginId].extensions.exposedComponents).toEqual(apps[pluginId].extensions.exposedComponents);
|
||||
expect(result[pluginId].extensions.extensionPoints).toEqual(apps[pluginId].extensions.extensionPoints);
|
||||
});
|
||||
|
||||
it('should map moduleHash property correctly', () => {
|
||||
const result = v0alpha1AppMapper(v0alpha1Response);
|
||||
|
||||
expect(result[pluginId].moduleHash).toEqual(apps[pluginId].moduleHash);
|
||||
});
|
||||
|
||||
it('should map buildMode property correctly', () => {
|
||||
const result = v0alpha1AppMapper(v0alpha1Response);
|
||||
|
||||
expect(result[pluginId].buildMode).toEqual(apps[pluginId].buildMode);
|
||||
});
|
||||
});
|
||||
|
||||
it('should only map specs with type app', () => {
|
||||
const result = v0alpha1AppMapper(v0alpha1Response);
|
||||
|
||||
expect(v0alpha1Response.items).toHaveLength(58);
|
||||
expect(Object.keys(result)).toHaveLength(5);
|
||||
expect(Object.keys(result)).toEqual(Object.keys(apps));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
type AngularMeta,
|
||||
type AppPluginConfig,
|
||||
type PluginDependencies,
|
||||
type PluginExtensions,
|
||||
PluginLoadingStrategy,
|
||||
type PluginType,
|
||||
} from '@grafana/data';
|
||||
|
||||
import type { AppPluginMetas, AppPluginMetasMapper, PluginMetasResponse } from '../types';
|
||||
import type { Spec as v0alpha1Spec } from '../types/types.spec.gen';
|
||||
|
||||
function angularyMapper(spec: v0alpha1Spec): AngularMeta {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
return {} as AngularMeta;
|
||||
}
|
||||
|
||||
function dependenciesMapper(spec: v0alpha1Spec): PluginDependencies {
|
||||
const plugins = (spec.pluginJson.dependencies?.plugins ?? []).map((v) => ({
|
||||
...v,
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
type: v.type as PluginType,
|
||||
version: '',
|
||||
}));
|
||||
|
||||
const dependencies: PluginDependencies = {
|
||||
...spec.pluginJson.dependencies,
|
||||
extensions: {
|
||||
exposedComponents: spec.pluginJson.dependencies.extensions?.exposedComponents ?? [],
|
||||
},
|
||||
grafanaDependency: spec.pluginJson.dependencies.grafanaDependency,
|
||||
grafanaVersion: spec.pluginJson.dependencies.grafanaVersion ?? '',
|
||||
plugins,
|
||||
};
|
||||
|
||||
return dependencies;
|
||||
}
|
||||
|
||||
function extensionsMapper(spec: v0alpha1Spec): PluginExtensions {
|
||||
const addedComponents = spec.pluginJson.extensions?.addedComponents ?? [];
|
||||
const addedFunctions = spec.pluginJson.extensions?.addedFunctions ?? [];
|
||||
const addedLinks = spec.pluginJson.extensions?.addedLinks ?? [];
|
||||
const exposedComponents = (spec.pluginJson.extensions?.exposedComponents ?? []).map((v) => ({
|
||||
...v,
|
||||
description: v.description ?? '',
|
||||
title: v.title ?? '',
|
||||
}));
|
||||
const extensionPoints = (spec.pluginJson.extensions?.extensionPoints ?? []).map((v) => ({
|
||||
...v,
|
||||
description: v.description ?? '',
|
||||
title: v.title ?? '',
|
||||
}));
|
||||
|
||||
const extensions: PluginExtensions = {
|
||||
addedComponents,
|
||||
addedFunctions,
|
||||
addedLinks,
|
||||
exposedComponents,
|
||||
extensionPoints,
|
||||
};
|
||||
|
||||
return extensions;
|
||||
}
|
||||
|
||||
function loadingStrategyMapper(spec: v0alpha1Spec): PluginLoadingStrategy {
|
||||
const loadingStrategy = spec.module?.loadingStrategy ?? PluginLoadingStrategy.fetch;
|
||||
if (loadingStrategy === PluginLoadingStrategy.script) {
|
||||
return PluginLoadingStrategy.script;
|
||||
}
|
||||
|
||||
return PluginLoadingStrategy.fetch;
|
||||
}
|
||||
|
||||
function specMapper(spec: v0alpha1Spec): AppPluginConfig {
|
||||
const { id, info, preload = false } = spec.pluginJson;
|
||||
const angular = angularyMapper(spec);
|
||||
const dependencies = dependenciesMapper(spec);
|
||||
const extensions = extensionsMapper(spec);
|
||||
const loadingStrategy = loadingStrategyMapper(spec);
|
||||
const path = spec.module?.path ?? '';
|
||||
const version = info.version;
|
||||
const buildMode = spec.pluginJson.buildMode ?? 'production';
|
||||
const moduleHash = spec.module?.hash;
|
||||
|
||||
return {
|
||||
id,
|
||||
angular,
|
||||
dependencies,
|
||||
extensions,
|
||||
loadingStrategy,
|
||||
path,
|
||||
preload,
|
||||
version,
|
||||
buildMode,
|
||||
moduleHash,
|
||||
};
|
||||
}
|
||||
|
||||
export const v0alpha1AppMapper: AppPluginMetasMapper<PluginMetasResponse> = (response) => {
|
||||
const result: AppPluginMetas = {};
|
||||
|
||||
return response.items.reduce((acc, curr) => {
|
||||
if (curr.spec.pluginJson.type !== 'app') {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const config = specMapper(curr.spec);
|
||||
acc[config.id] = config;
|
||||
return acc;
|
||||
}, result);
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
import { evaluateBooleanFlag } from '../../internal/openFeature';
|
||||
|
||||
import { clearCache, initPluginMetas } from './plugins';
|
||||
import { v0alpha1Meta } from './test-fixtures/v0alpha1Response';
|
||||
|
||||
jest.mock('../../internal/openFeature', () => ({
|
||||
...jest.requireActual('../../internal/openFeature'),
|
||||
evaluateBooleanFlag: jest.fn(),
|
||||
}));
|
||||
|
||||
const evaluateBooleanFlagMock = jest.mocked(evaluateBooleanFlag);
|
||||
|
||||
describe('when useMTPlugins toggle is enabled and cache is not initialized', () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
clearCache();
|
||||
evaluateBooleanFlagMock.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('initPluginMetas should call loadPluginMetas and return correct result if response is ok', async () => {
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ items: [v0alpha1Meta] }),
|
||||
});
|
||||
|
||||
const response = await initPluginMetas();
|
||||
|
||||
expect(response.items).toHaveLength(1);
|
||||
expect(response.items[0]).toEqual(v0alpha1Meta);
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(global.fetch).toHaveBeenCalledWith('/apis/plugins.grafana.app/v0alpha1/namespaces/default/metas');
|
||||
});
|
||||
|
||||
it('initPluginMetas should call loadPluginMetas and return correct result if response is not ok', async () => {
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not found',
|
||||
});
|
||||
|
||||
await expect(initPluginMetas()).rejects.toThrow(new Error(`Failed to load plugin metas 404:Not found`));
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(global.fetch).toHaveBeenCalledWith('/apis/plugins.grafana.app/v0alpha1/namespaces/default/metas');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when useMTPlugins toggle is enabled and cache is initialized', () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
clearCache();
|
||||
evaluateBooleanFlagMock.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('initPluginMetas should return cache', async () => {
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ items: [v0alpha1Meta] }),
|
||||
});
|
||||
|
||||
const original = await initPluginMetas();
|
||||
const cached = await initPluginMetas();
|
||||
|
||||
expect(original).toEqual(cached);
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('initPluginMetas should return inflight promise', async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ items: [v0alpha1Meta] }),
|
||||
});
|
||||
|
||||
const original = initPluginMetas();
|
||||
const cached = initPluginMetas();
|
||||
await jest.runAllTimersAsync();
|
||||
|
||||
expect(original).toEqual(cached);
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when useMTPlugins toggle is disabled and cache is not initialized', () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
clearCache();
|
||||
global.fetch = jest.fn();
|
||||
evaluateBooleanFlagMock.mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('initPluginMetas should call loadPluginMetas and return correct result if response is ok', async () => {
|
||||
const response = await initPluginMetas();
|
||||
|
||||
expect(response.items).toHaveLength(0);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when useMTPlugins toggle is disabled and cache is initialized', () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
clearCache();
|
||||
global.fetch = jest.fn();
|
||||
evaluateBooleanFlagMock.mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('initPluginMetas should return cache', async () => {
|
||||
const original = await initPluginMetas();
|
||||
const cached = await initPluginMetas();
|
||||
|
||||
expect(original).toEqual(cached);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('initPluginMetas should return inflight promise', async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const original = initPluginMetas();
|
||||
const cached = initPluginMetas();
|
||||
await jest.runAllTimersAsync();
|
||||
|
||||
expect(original).toEqual(cached);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { config } from '../../config';
|
||||
import { evaluateBooleanFlag } from '../../internal/openFeature';
|
||||
|
||||
import type { PluginMetasResponse } from './types';
|
||||
|
||||
let initPromise: Promise<PluginMetasResponse> | null = null;
|
||||
|
||||
function getApiVersion(): string {
|
||||
return 'v0alpha1';
|
||||
}
|
||||
|
||||
async function loadPluginMetas(): Promise<PluginMetasResponse> {
|
||||
if (!evaluateBooleanFlag('useMTPlugins', false)) {
|
||||
const result = { items: [] };
|
||||
return result;
|
||||
}
|
||||
|
||||
const metas = await fetch(`/apis/plugins.grafana.app/${getApiVersion()}/namespaces/${config.namespace}/metas`);
|
||||
if (!metas.ok) {
|
||||
throw new Error(`Failed to load plugin metas ${metas.status}:${metas.statusText}`);
|
||||
}
|
||||
|
||||
const result = await metas.json();
|
||||
return result;
|
||||
}
|
||||
|
||||
export function initPluginMetas(): Promise<PluginMetasResponse> {
|
||||
if (!initPromise) {
|
||||
initPromise = loadPluginMetas();
|
||||
}
|
||||
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
export function clearCache() {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error('clearCache() function can only be called from tests.');
|
||||
}
|
||||
|
||||
initPromise = null;
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import { cloneDeep } from 'lodash';
|
||||
|
||||
import { AngularMeta, AppPluginConfig, PluginLoadingStrategy } from '@grafana/data';
|
||||
|
||||
import { AppPluginMetas } from '../types';
|
||||
|
||||
export const app: AppPluginConfig = cloneDeep({
|
||||
id: 'myorg-someplugin-app',
|
||||
path: 'public/plugins/myorg-someplugin-app/module.js',
|
||||
version: '1.0.0',
|
||||
preload: false,
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
angular: { detected: false } as AngularMeta,
|
||||
loadingStrategy: PluginLoadingStrategy.script,
|
||||
extensions: {
|
||||
addedLinks: [],
|
||||
addedComponents: [],
|
||||
exposedComponents: [],
|
||||
extensionPoints: [],
|
||||
addedFunctions: [],
|
||||
},
|
||||
dependencies: {
|
||||
grafanaDependency: '>=10.4.0',
|
||||
grafanaVersion: '*',
|
||||
plugins: [],
|
||||
extensions: {
|
||||
exposedComponents: [],
|
||||
},
|
||||
},
|
||||
buildMode: 'production',
|
||||
});
|
||||
|
||||
export const apps: AppPluginMetas = cloneDeep({
|
||||
'grafana-exploretraces-app': {
|
||||
id: 'grafana-exploretraces-app',
|
||||
path: 'public/plugins/grafana-exploretraces-app/module.js',
|
||||
version: '1.2.2',
|
||||
preload: true,
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
angular: { detected: false } as AngularMeta,
|
||||
loadingStrategy: PluginLoadingStrategy.script,
|
||||
extensions: {
|
||||
addedLinks: [
|
||||
{
|
||||
targets: ['grafana/dashboard/panel/menu'],
|
||||
title: 'Open in Traces Drilldown',
|
||||
description: 'Open current query in the Traces Drilldown app',
|
||||
},
|
||||
{
|
||||
targets: ['grafana/explore/toolbar/action'],
|
||||
title: 'Open in Grafana Traces Drilldown',
|
||||
description: 'Try our new queryless experience for traces',
|
||||
},
|
||||
],
|
||||
addedComponents: [
|
||||
{
|
||||
targets: ['grafana-asserts-app/entity-assertions-widget/v1'],
|
||||
title: 'Asserts widget',
|
||||
description: 'A block with assertions for a given service',
|
||||
},
|
||||
{
|
||||
targets: ['grafana-asserts-app/insights-timeline-widget/v1'],
|
||||
title: 'Insights Timeline Widget',
|
||||
description: 'Widget for displaying insights timeline in other apps',
|
||||
},
|
||||
],
|
||||
exposedComponents: [
|
||||
{
|
||||
id: 'grafana-exploretraces-app/open-in-explore-traces-button/v1',
|
||||
title: 'Open in Traces Drilldown button',
|
||||
description: 'A button that opens a traces view in the Traces Drilldown app.',
|
||||
},
|
||||
{
|
||||
id: 'grafana-exploretraces-app/embedded-trace-exploration/v1',
|
||||
title: 'Embedded Trace Exploration',
|
||||
description:
|
||||
'A component that renders a trace exploration view that can be embedded in other parts of Grafana.',
|
||||
},
|
||||
],
|
||||
extensionPoints: [
|
||||
{
|
||||
id: 'grafana-exploretraces-app/investigation/v1',
|
||||
title: '',
|
||||
description: '',
|
||||
},
|
||||
{
|
||||
id: 'grafana-exploretraces-app/get-logs-drilldown-link/v1',
|
||||
title: '',
|
||||
description: '',
|
||||
},
|
||||
],
|
||||
addedFunctions: [],
|
||||
},
|
||||
dependencies: {
|
||||
grafanaDependency: '>=11.5.0',
|
||||
grafanaVersion: '*',
|
||||
plugins: [],
|
||||
extensions: {
|
||||
exposedComponents: [
|
||||
'grafana-asserts-app/entity-assertions-widget/v1',
|
||||
'grafana-asserts-app/insights-timeline-widget/v1',
|
||||
],
|
||||
},
|
||||
},
|
||||
buildMode: 'production',
|
||||
},
|
||||
'grafana-lokiexplore-app': {
|
||||
id: 'grafana-lokiexplore-app',
|
||||
path: 'public/plugins/grafana-lokiexplore-app/module.js',
|
||||
version: '1.0.32',
|
||||
preload: true,
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
angular: { detected: false } as AngularMeta,
|
||||
loadingStrategy: PluginLoadingStrategy.script,
|
||||
extensions: {
|
||||
addedLinks: [
|
||||
{
|
||||
targets: [
|
||||
'grafana/dashboard/panel/menu',
|
||||
'grafana/explore/toolbar/action',
|
||||
'grafana-metricsdrilldown-app/open-in-logs-drilldown/v1',
|
||||
'grafana-assistant-app/navigateToDrilldown/v1',
|
||||
],
|
||||
title: 'Open in Grafana Logs Drilldown',
|
||||
description: 'Open current query in the Grafana Logs Drilldown view',
|
||||
},
|
||||
],
|
||||
addedComponents: [
|
||||
{
|
||||
targets: ['grafana-asserts-app/insights-timeline-widget/v1'],
|
||||
title: 'Insights Timeline Widget',
|
||||
description: 'Widget for displaying insights timeline in other apps',
|
||||
},
|
||||
],
|
||||
exposedComponents: [
|
||||
{
|
||||
id: 'grafana-lokiexplore-app/open-in-explore-logs-button/v1',
|
||||
title: 'Open in Logs Drilldown button',
|
||||
description: 'A button that opens a logs view in the Logs Drilldown app.',
|
||||
},
|
||||
{
|
||||
id: 'grafana-lokiexplore-app/embedded-logs-exploration/v1',
|
||||
title: 'Embedded Logs Exploration',
|
||||
description:
|
||||
'A component that renders a logs exploration view that can be embedded in other parts of Grafana.',
|
||||
},
|
||||
],
|
||||
extensionPoints: [
|
||||
{
|
||||
id: 'grafana-lokiexplore-app/investigation/v1',
|
||||
title: '',
|
||||
description: '',
|
||||
},
|
||||
],
|
||||
addedFunctions: [
|
||||
{
|
||||
targets: ['grafana-exploretraces-app/get-logs-drilldown-link/v1'],
|
||||
title: 'Open Logs Drilldown',
|
||||
description: 'Returns url to logs drilldown app',
|
||||
},
|
||||
],
|
||||
},
|
||||
dependencies: {
|
||||
grafanaDependency: '>=11.6.0',
|
||||
grafanaVersion: '*',
|
||||
plugins: [],
|
||||
extensions: {
|
||||
exposedComponents: [
|
||||
'grafana-adaptivelogs-app/temporary-exemptions/v1',
|
||||
'grafana-lokiexplore-app/embedded-logs-exploration/v1',
|
||||
'grafana-asserts-app/insights-timeline-widget/v1',
|
||||
'grafana/add-to-dashboard-form/v1',
|
||||
],
|
||||
},
|
||||
},
|
||||
buildMode: 'production',
|
||||
},
|
||||
'grafana-metricsdrilldown-app': {
|
||||
id: 'grafana-metricsdrilldown-app',
|
||||
path: 'public/plugins/grafana-metricsdrilldown-app/module.js',
|
||||
version: '1.0.26',
|
||||
preload: true,
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
angular: { detected: false } as AngularMeta,
|
||||
loadingStrategy: PluginLoadingStrategy.script,
|
||||
extensions: {
|
||||
addedLinks: [
|
||||
{
|
||||
targets: [
|
||||
'grafana/dashboard/panel/menu',
|
||||
'grafana/explore/toolbar/action',
|
||||
'grafana-assistant-app/navigateToDrilldown/v1',
|
||||
'grafana/alerting/alertingrule/queryeditor',
|
||||
],
|
||||
title: 'Open in Grafana Metrics Drilldown',
|
||||
description: 'Open current query in the Grafana Metrics Drilldown view',
|
||||
},
|
||||
{
|
||||
targets: ['grafana-metricsdrilldown-app/grafana-assistant-app/navigateToDrilldown/v0-alpha'],
|
||||
title: 'Navigate to metrics drilldown',
|
||||
description: 'Build a url path to the metrics drilldown',
|
||||
},
|
||||
{
|
||||
targets: ['grafana/datasources/config/actions', 'grafana/datasources/config/status'],
|
||||
title: 'Open in Metrics Drilldown',
|
||||
description: 'Browse metrics in Grafana Metrics Drilldown',
|
||||
},
|
||||
],
|
||||
addedComponents: [],
|
||||
exposedComponents: [
|
||||
{
|
||||
id: 'grafana-metricsdrilldown-app/label-breakdown-component/v1',
|
||||
title: 'Label Breakdown',
|
||||
description: 'A metrics label breakdown view from the Metrics Drilldown app.',
|
||||
},
|
||||
{
|
||||
id: 'grafana-metricsdrilldown-app/knowledge-graph-insight-metrics/v1',
|
||||
title: 'Knowledge Graph Source Metrics',
|
||||
description: 'Explore the underlying metrics related to a Knowledge Graph insight',
|
||||
},
|
||||
],
|
||||
extensionPoints: [
|
||||
{
|
||||
id: 'grafana-exploremetrics-app/investigation/v1',
|
||||
title: '',
|
||||
description: '',
|
||||
},
|
||||
{
|
||||
id: 'grafana-metricsdrilldown-app/open-in-logs-drilldown/v1',
|
||||
title: '',
|
||||
description: '',
|
||||
},
|
||||
],
|
||||
addedFunctions: [],
|
||||
},
|
||||
dependencies: {
|
||||
grafanaDependency: '>=11.6.0',
|
||||
grafanaVersion: '*',
|
||||
plugins: [],
|
||||
extensions: {
|
||||
exposedComponents: ['grafana/add-to-dashboard-form/v1'],
|
||||
},
|
||||
},
|
||||
buildMode: 'production',
|
||||
},
|
||||
'grafana-pyroscope-app': {
|
||||
id: 'grafana-pyroscope-app',
|
||||
path: 'public/plugins/grafana-pyroscope-app/module.js',
|
||||
version: '1.14.2',
|
||||
preload: true,
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
angular: { detected: false } as AngularMeta,
|
||||
loadingStrategy: PluginLoadingStrategy.script,
|
||||
extensions: {
|
||||
addedLinks: [
|
||||
{
|
||||
targets: [
|
||||
'grafana/explore/toolbar/action',
|
||||
'grafana/traceview/details',
|
||||
'grafana-assistant-app/navigateToDrilldown/v1',
|
||||
],
|
||||
title: 'Open in Grafana Profiles Drilldown',
|
||||
description: 'Try our new queryless experience for profiles',
|
||||
},
|
||||
],
|
||||
addedComponents: [],
|
||||
exposedComponents: [
|
||||
{
|
||||
id: 'grafana-pyroscope-app/embedded-profiles-exploration/v1',
|
||||
title: 'Embedded Profiles Exploration',
|
||||
description:
|
||||
'A component that renders a profiles exploration view that can be embedded in other parts of Grafana.',
|
||||
},
|
||||
],
|
||||
extensionPoints: [
|
||||
{
|
||||
id: 'grafana-pyroscope-app/investigation/v1',
|
||||
title: '',
|
||||
description: '',
|
||||
},
|
||||
{
|
||||
id: 'grafana-pyroscope-app/settings/v1',
|
||||
title: '',
|
||||
description: '',
|
||||
},
|
||||
],
|
||||
addedFunctions: [],
|
||||
},
|
||||
dependencies: {
|
||||
grafanaDependency: '>=11.5.0',
|
||||
grafanaVersion: '*',
|
||||
plugins: [],
|
||||
extensions: {
|
||||
exposedComponents: [
|
||||
'grafana-o11yinsights-app/insights-launcher/v1',
|
||||
'grafana-adaptiveprofiles-app/resolution-boost/v1',
|
||||
],
|
||||
},
|
||||
},
|
||||
buildMode: 'production',
|
||||
},
|
||||
[app.id]: app,
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
import type { AppPluginConfig } from '@grafana/data';
|
||||
|
||||
import type { Meta } from './types/meta_object_gen';
|
||||
|
||||
export type AppPluginMetas = Record<string, AppPluginConfig>;
|
||||
|
||||
export type AppPluginMetasMapper<T> = (response: T) => AppPluginMetas;
|
||||
export interface PluginMetasResponse {
|
||||
items: Meta[];
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* This file was generated by grafana-app-sdk. DO NOT EDIT.
|
||||
*/
|
||||
import { Spec } from './types.spec.gen';
|
||||
import { Status } from './types.status.gen';
|
||||
|
||||
export interface Metadata {
|
||||
name: string;
|
||||
namespace: string;
|
||||
generateName?: string;
|
||||
selfLink?: string;
|
||||
uid?: string;
|
||||
resourceVersion?: string;
|
||||
generation?: number;
|
||||
creationTimestamp?: string;
|
||||
deletionTimestamp?: string;
|
||||
deletionGracePeriodSeconds?: number;
|
||||
labels?: Record<string, string>;
|
||||
annotations?: Record<string, string>;
|
||||
ownerReferences?: OwnerReference[];
|
||||
finalizers?: string[];
|
||||
managedFields?: ManagedFieldsEntry[];
|
||||
}
|
||||
|
||||
export interface OwnerReference {
|
||||
apiVersion: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
uid: string;
|
||||
controller?: boolean;
|
||||
blockOwnerDeletion?: boolean;
|
||||
}
|
||||
|
||||
export interface ManagedFieldsEntry {
|
||||
manager?: string;
|
||||
operation?: string;
|
||||
apiVersion?: string;
|
||||
time?: string;
|
||||
fieldsType?: string;
|
||||
subresource?: string;
|
||||
}
|
||||
|
||||
export interface Meta {
|
||||
kind: string;
|
||||
apiVersion: string;
|
||||
metadata: Metadata;
|
||||
spec: Spec;
|
||||
status: Status;
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
|
||||
|
||||
// JSON configuration schema for Grafana plugins
|
||||
// Converted from: https://github.com/grafana/grafana/blob/main/docs/sources/developers/plugins/plugin.schema.json
|
||||
export interface JSONData {
|
||||
// Unique name of the plugin
|
||||
id: string;
|
||||
// Plugin type
|
||||
type: "app" | "datasource" | "panel" | "renderer";
|
||||
// Human-readable name of the plugin
|
||||
name: string;
|
||||
// Metadata for the plugin
|
||||
info: Info;
|
||||
// Dependency information
|
||||
dependencies: Dependencies;
|
||||
// Optional fields
|
||||
alerting?: boolean;
|
||||
annotations?: boolean;
|
||||
autoEnabled?: boolean;
|
||||
backend?: boolean;
|
||||
buildMode?: string;
|
||||
builtIn?: boolean;
|
||||
category?: "tsdb" | "logging" | "cloud" | "tracing" | "profiling" | "sql" | "enterprise" | "iot" | "other";
|
||||
enterpriseFeatures?: EnterpriseFeatures;
|
||||
executable?: string;
|
||||
hideFromList?: boolean;
|
||||
// +listType=atomic
|
||||
includes?: Include[];
|
||||
logs?: boolean;
|
||||
metrics?: boolean;
|
||||
multiValueFilterOperators?: boolean;
|
||||
pascalName?: string;
|
||||
preload?: boolean;
|
||||
queryOptions?: QueryOptions;
|
||||
// +listType=atomic
|
||||
routes?: Route[];
|
||||
skipDataQuery?: boolean;
|
||||
state?: "alpha" | "beta";
|
||||
streaming?: boolean;
|
||||
suggestions?: boolean;
|
||||
tracing?: boolean;
|
||||
iam?: IAM;
|
||||
// +listType=atomic
|
||||
roles?: Role[];
|
||||
extensions?: Extensions;
|
||||
}
|
||||
|
||||
export const defaultJSONData = (): JSONData => ({
|
||||
id: "",
|
||||
type: "app",
|
||||
name: "",
|
||||
info: defaultInfo(),
|
||||
dependencies: defaultDependencies(),
|
||||
});
|
||||
|
||||
export interface Info {
|
||||
// Required fields
|
||||
// +listType=set
|
||||
keywords: string[];
|
||||
logos: {
|
||||
small: string;
|
||||
large: string;
|
||||
};
|
||||
updated: string;
|
||||
version: string;
|
||||
// Optional fields
|
||||
author?: {
|
||||
name?: string;
|
||||
email?: string;
|
||||
url?: string;
|
||||
};
|
||||
description?: string;
|
||||
// +listType=atomic
|
||||
links?: {
|
||||
name?: string;
|
||||
url?: string;
|
||||
}[];
|
||||
// +listType=atomic
|
||||
screenshots?: {
|
||||
name?: string;
|
||||
path?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const defaultInfo = (): Info => ({
|
||||
keywords: [],
|
||||
logos: {
|
||||
small: "",
|
||||
large: "",
|
||||
},
|
||||
updated: "",
|
||||
version: "",
|
||||
});
|
||||
|
||||
export interface Dependencies {
|
||||
// Required field
|
||||
grafanaDependency: string;
|
||||
// Optional fields
|
||||
grafanaVersion?: string;
|
||||
// +listType=set
|
||||
// +listMapKey=id
|
||||
plugins?: {
|
||||
id: string;
|
||||
type: "app" | "datasource" | "panel";
|
||||
name: string;
|
||||
}[];
|
||||
extensions?: {
|
||||
// +listType=set
|
||||
exposedComponents?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export const defaultDependencies = (): Dependencies => ({
|
||||
grafanaDependency: "",
|
||||
});
|
||||
|
||||
export interface EnterpriseFeatures {
|
||||
// Allow additional properties
|
||||
healthDiagnosticsErrors?: boolean;
|
||||
}
|
||||
|
||||
export const defaultEnterpriseFeatures = (): EnterpriseFeatures => ({
|
||||
healthDiagnosticsErrors: false,
|
||||
});
|
||||
|
||||
export interface Include {
|
||||
uid?: string;
|
||||
type?: "dashboard" | "page" | "panel" | "datasource";
|
||||
name?: string;
|
||||
component?: string;
|
||||
role?: "Admin" | "Editor" | "Viewer" | "None";
|
||||
action?: string;
|
||||
path?: string;
|
||||
addToNav?: boolean;
|
||||
defaultNav?: boolean;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export const defaultInclude = (): Include => ({
|
||||
});
|
||||
|
||||
export interface QueryOptions {
|
||||
maxDataPoints?: boolean;
|
||||
minInterval?: boolean;
|
||||
cacheTimeout?: boolean;
|
||||
}
|
||||
|
||||
export const defaultQueryOptions = (): QueryOptions => ({
|
||||
});
|
||||
|
||||
export interface Route {
|
||||
path?: string;
|
||||
method?: string;
|
||||
url?: string;
|
||||
reqSignedIn?: boolean;
|
||||
reqRole?: string;
|
||||
reqAction?: string;
|
||||
// +listType=atomic
|
||||
headers?: string[];
|
||||
body?: Record<string, any>;
|
||||
tokenAuth?: {
|
||||
url?: string;
|
||||
// +listType=set
|
||||
scopes?: string[];
|
||||
params?: Record<string, any>;
|
||||
};
|
||||
jwtTokenAuth?: {
|
||||
url?: string;
|
||||
// +listType=set
|
||||
scopes?: string[];
|
||||
params?: Record<string, any>;
|
||||
};
|
||||
// +listType=atomic
|
||||
urlParams?: {
|
||||
name?: string;
|
||||
content?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const defaultRoute = (): Route => ({
|
||||
});
|
||||
|
||||
export interface IAM {
|
||||
// +listType=atomic
|
||||
permissions?: {
|
||||
action?: string;
|
||||
scope?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const defaultIAM = (): IAM => ({
|
||||
});
|
||||
|
||||
export interface Role {
|
||||
role?: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
// +listType=atomic
|
||||
permissions?: {
|
||||
action?: string;
|
||||
scope?: string;
|
||||
}[];
|
||||
};
|
||||
// +listType=set
|
||||
grants?: string[];
|
||||
}
|
||||
|
||||
export const defaultRole = (): Role => ({
|
||||
});
|
||||
|
||||
export interface Extensions {
|
||||
// +listType=atomic
|
||||
addedComponents?: {
|
||||
// +listType=set
|
||||
targets: string[];
|
||||
title: string;
|
||||
description?: string;
|
||||
}[];
|
||||
// +listType=atomic
|
||||
addedLinks?: {
|
||||
// +listType=set
|
||||
targets: string[];
|
||||
title: string;
|
||||
description?: string;
|
||||
}[];
|
||||
// +listType=atomic
|
||||
addedFunctions?: {
|
||||
// +listType=set
|
||||
targets: string[];
|
||||
title: string;
|
||||
description?: string;
|
||||
}[];
|
||||
// +listType=set
|
||||
// +listMapKey=id
|
||||
exposedComponents?: {
|
||||
id: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}[];
|
||||
// +listType=set
|
||||
// +listMapKey=id
|
||||
extensionPoints?: {
|
||||
id: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const defaultExtensions = (): Extensions => ({
|
||||
});
|
||||
|
||||
export interface Spec {
|
||||
pluginJson: JSONData;
|
||||
class: "core" | "external";
|
||||
module?: {
|
||||
path: string;
|
||||
hash?: string;
|
||||
loadingStrategy?: "fetch" | "script";
|
||||
};
|
||||
baseURL?: string;
|
||||
signature?: {
|
||||
status: "internal" | "valid" | "invalid" | "modified" | "unsigned";
|
||||
type?: "grafana" | "commercial" | "community" | "private" | "private-glob";
|
||||
org?: string;
|
||||
};
|
||||
angular?: {
|
||||
detected: boolean;
|
||||
};
|
||||
translations?: Record<string, string>;
|
||||
// +listType=atomic
|
||||
children?: string[];
|
||||
}
|
||||
|
||||
export const defaultSpec = (): Spec => ({
|
||||
pluginJson: defaultJSONData(),
|
||||
class: "core",
|
||||
});
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
|
||||
|
||||
export interface OperatorState {
|
||||
// lastEvaluation is the ResourceVersion last evaluated
|
||||
lastEvaluation: string;
|
||||
// state describes the state of the lastEvaluation.
|
||||
// It is limited to three possible states for machine evaluation.
|
||||
state: "success" | "in_progress" | "failed";
|
||||
// descriptiveState is an optional more descriptive state field which has no requirements on format
|
||||
descriptiveState?: string;
|
||||
// details contains any extra information that is operator-specific
|
||||
details?: Record<string, any>;
|
||||
}
|
||||
|
||||
export const defaultOperatorState = (): OperatorState => ({
|
||||
lastEvaluation: "",
|
||||
state: "success",
|
||||
});
|
||||
|
||||
export interface Status {
|
||||
// operatorStates is a map of operator ID to operator state evaluations.
|
||||
// Any operator which consumes this kind SHOULD add its state evaluation information to this field.
|
||||
operatorStates?: Record<string, OperatorState>;
|
||||
// additionalFields is reserved for future use
|
||||
additionalFields?: Record<string, any>;
|
||||
}
|
||||
|
||||
export const defaultStatus = (): Status => ({
|
||||
});
|
||||
|
||||
@@ -1,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({
|
||||
|
||||
@@ -54,8 +54,7 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink
|
||||
}
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if c.HasRole(identity.RoleAdmin) &&
|
||||
(s.cfg.StackID == "" || // show OnPrem even when provisioning is disabled
|
||||
s.features.IsEnabledGlobally(featuremgmt.FlagProvisioning)) {
|
||||
s.features.IsEnabledGlobally(featuremgmt.FlagProvisioning) {
|
||||
generalNodeLinks = append(generalNodeLinks, &navtree.NavLink{
|
||||
Text: "Provisioning",
|
||||
Id: "provisioning",
|
||||
|
||||
@@ -108,7 +108,9 @@ export const alertmanagerApi = alertingApi.injectEndpoints({
|
||||
}),
|
||||
|
||||
grafanaNotifiers: build.query<NotifierDTO[], void>({
|
||||
query: () => ({ url: '/api/alert-notifiers' }),
|
||||
// NOTE: version=2 parameter required for versioned schema (PR #109969)
|
||||
// This parameter will be removed in future when v2 becomes default
|
||||
query: () => ({ url: '/api/alert-notifiers?version=2' }),
|
||||
transformResponse: (response: NotifierDTO[]) => {
|
||||
const populateSecureFieldKey = (
|
||||
option: NotificationChannelOption,
|
||||
@@ -121,11 +123,16 @@ export const alertmanagerApi = alertingApi.injectEndpoints({
|
||||
),
|
||||
});
|
||||
|
||||
// Keep versions array intact for version-specific options lookup
|
||||
// Transform options with secureFieldKey population
|
||||
return response.map((notifier) => ({
|
||||
...notifier,
|
||||
options: notifier.options.map((option) => {
|
||||
return populateSecureFieldKey(option, '');
|
||||
}),
|
||||
options: (notifier.options || []).map((option) => populateSecureFieldKey(option, '')),
|
||||
// Also transform options within each version
|
||||
versions: notifier.versions?.map((version) => ({
|
||||
...version,
|
||||
options: (version.options || []).map((option) => populateSecureFieldKey(option, '')),
|
||||
})),
|
||||
}));
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { getIrmIfPresentOrIncidentPluginId } from '../utils/config';
|
||||
|
||||
import { alertingApi } from './alertingApi';
|
||||
|
||||
interface IncidentsPluginConfigDto {
|
||||
@@ -7,13 +5,13 @@ interface IncidentsPluginConfigDto {
|
||||
isIncidentCreated: boolean;
|
||||
}
|
||||
|
||||
const getProxyApiUrl = (path: string) => `/api/plugins/${getIrmIfPresentOrIncidentPluginId()}/resources${path}`;
|
||||
const getProxyApiUrl = (path: string, pluginId: string) => `/api/plugins/${pluginId}/resources${path}`;
|
||||
|
||||
export const incidentsApi = alertingApi.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
getIncidentsPluginConfig: build.query<IncidentsPluginConfigDto, void>({
|
||||
query: () => ({
|
||||
url: getProxyApiUrl('/api/ConfigurationTrackerService.GetConfigurationTracker'),
|
||||
getIncidentsPluginConfig: build.query<IncidentsPluginConfigDto, { pluginId: string }>({
|
||||
query: ({ pluginId }) => ({
|
||||
url: getProxyApiUrl('/api/ConfigurationTrackerService.GetConfigurationTracker', pluginId),
|
||||
data: {},
|
||||
method: 'POST',
|
||||
showErrorAlert: false,
|
||||
|
||||
@@ -1,26 +1,16 @@
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { pluginMeta, pluginMetaToPluginConfig } from '../testSetup/plugins';
|
||||
import { SupportedPlugin } from '../types/pluginBridges';
|
||||
|
||||
import { getProxyApiUrl } from './onCallApi';
|
||||
|
||||
describe('getProxyApiUrl', () => {
|
||||
it('should return URL with IRM plugin ID when IRM plugin is present', () => {
|
||||
config.apps = { [SupportedPlugin.Irm]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Irm]) };
|
||||
|
||||
expect(getProxyApiUrl('/alert_receive_channels/')).toBe(
|
||||
it('should return URL with IRM plugin ID when IRM plugin ID is passed', () => {
|
||||
expect(getProxyApiUrl('/alert_receive_channels/', SupportedPlugin.Irm)).toBe(
|
||||
'/api/plugins/grafana-irm-app/resources/alert_receive_channels/'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return URL with OnCall plugin ID when IRM plugin is not present', () => {
|
||||
config.apps = {
|
||||
[SupportedPlugin.OnCall]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.OnCall]),
|
||||
[SupportedPlugin.Incident]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Incident]),
|
||||
};
|
||||
|
||||
expect(getProxyApiUrl('/alert_receive_channels/')).toBe(
|
||||
it('should return URL with OnCall plugin ID when OnCall plugin ID is passed', () => {
|
||||
expect(getProxyApiUrl('/alert_receive_channels/', SupportedPlugin.OnCall)).toBe(
|
||||
'/api/plugins/grafana-oncall-app/resources/alert_receive_channels/'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { FetchError, isFetchError } from '@grafana/runtime';
|
||||
|
||||
import { GRAFANA_ONCALL_INTEGRATION_TYPE } from '../components/receivers/grafanaAppReceivers/onCall/onCall';
|
||||
import { getIrmIfPresentOrOnCallPluginId } from '../utils/config';
|
||||
|
||||
import { alertingApi } from './alertingApi';
|
||||
|
||||
@@ -38,15 +37,15 @@ export interface OnCallConfigChecks {
|
||||
is_integration_chatops_connected: boolean;
|
||||
}
|
||||
|
||||
export function getProxyApiUrl(path: string) {
|
||||
return `/api/plugins/${getIrmIfPresentOrOnCallPluginId()}/resources${path}`;
|
||||
export function getProxyApiUrl(path: string, pluginId: string) {
|
||||
return `/api/plugins/${pluginId}/resources${path}`;
|
||||
}
|
||||
|
||||
export const onCallApi = alertingApi.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
grafanaOnCallIntegrations: build.query<OnCallIntegrationDTO[], void>({
|
||||
query: () => ({
|
||||
url: getProxyApiUrl('/alert_receive_channels/'),
|
||||
grafanaOnCallIntegrations: build.query<OnCallIntegrationDTO[], { pluginId: string }>({
|
||||
query: ({ pluginId }) => ({
|
||||
url: getProxyApiUrl('/alert_receive_channels/', pluginId),
|
||||
// legacy_grafana_alerting is necessary for OnCall.
|
||||
// We do NOT need to differentiate between these two on our side
|
||||
params: {
|
||||
@@ -64,31 +63,31 @@ export const onCallApi = alertingApi.injectEndpoints({
|
||||
},
|
||||
providesTags: ['OnCallIntegrations'],
|
||||
}),
|
||||
validateIntegrationName: build.query<boolean, string>({
|
||||
query: (name) => ({
|
||||
url: getProxyApiUrl('/alert_receive_channels/validate_name/'),
|
||||
validateIntegrationName: build.query<boolean, { name: string; pluginId: string }>({
|
||||
query: ({ name, pluginId }) => ({
|
||||
url: getProxyApiUrl('/alert_receive_channels/validate_name/', pluginId),
|
||||
params: { verbal_name: name },
|
||||
showErrorAlert: false,
|
||||
}),
|
||||
}),
|
||||
createIntegration: build.mutation<NewOnCallIntegrationDTO, CreateIntegrationDTO>({
|
||||
query: (integration) => ({
|
||||
url: getProxyApiUrl('/alert_receive_channels/'),
|
||||
createIntegration: build.mutation<NewOnCallIntegrationDTO, CreateIntegrationDTO & { pluginId: string }>({
|
||||
query: ({ pluginId, ...integration }) => ({
|
||||
url: getProxyApiUrl('/alert_receive_channels/', pluginId),
|
||||
data: integration,
|
||||
method: 'POST',
|
||||
showErrorAlert: true,
|
||||
}),
|
||||
invalidatesTags: ['OnCallIntegrations'],
|
||||
}),
|
||||
features: build.query<OnCallFeature[], void>({
|
||||
query: () => ({
|
||||
url: getProxyApiUrl('/features/'),
|
||||
features: build.query<OnCallFeature[], { pluginId: string }>({
|
||||
query: ({ pluginId }) => ({
|
||||
url: getProxyApiUrl('/features/', pluginId),
|
||||
showErrorAlert: false,
|
||||
}),
|
||||
}),
|
||||
onCallConfigChecks: build.query<OnCallConfigChecks, void>({
|
||||
query: () => ({
|
||||
url: getProxyApiUrl('/organization/config-checks/'),
|
||||
onCallConfigChecks: build.query<OnCallConfigChecks, { pluginId: string }>({
|
||||
query: ({ pluginId }) => ({
|
||||
url: getProxyApiUrl('/organization/config-checks/', pluginId),
|
||||
showErrorAlert: false,
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -46,6 +46,7 @@ export type GrafanaPromRulesOptions = Omit<PromRulesOptions, 'ruleSource' | 'nam
|
||||
state?: PromAlertingRuleState[];
|
||||
title?: string;
|
||||
searchGroupName?: string;
|
||||
searchFolder?: string;
|
||||
type?: 'alerting' | 'recording';
|
||||
ruleMatchers?: string[];
|
||||
plugins?: 'hide' | 'only';
|
||||
@@ -103,6 +104,7 @@ export const prometheusApi = alertingApi.injectEndpoints({
|
||||
title,
|
||||
datasources,
|
||||
searchGroupName,
|
||||
searchFolder,
|
||||
dashboardUid,
|
||||
ruleMatchers,
|
||||
plugins,
|
||||
@@ -123,6 +125,7 @@ export const prometheusApi = alertingApi.injectEndpoints({
|
||||
datasource_uid: datasources,
|
||||
'search.rule_name': title,
|
||||
'search.rule_group': searchGroupName,
|
||||
'search.folder': searchFolder,
|
||||
dashboard_uid: dashboardUid,
|
||||
rule_matcher: ruleMatchers,
|
||||
plugins: plugins,
|
||||
|
||||
@@ -36,6 +36,24 @@ export const ProvisioningAlert = ({ resource, ...rest }: ProvisioningAlertProps)
|
||||
);
|
||||
};
|
||||
|
||||
export const ImportedContactPointAlert = (props: ExtraAlertProps) => {
|
||||
return (
|
||||
<Alert
|
||||
title={t(
|
||||
'alerting.provisioning.title-imported',
|
||||
'This contact point was imported and cannot be edited through the UI'
|
||||
)}
|
||||
severity="info"
|
||||
{...props}
|
||||
>
|
||||
<Trans i18nKey="alerting.provisioning.body-imported">
|
||||
This contact point contains integrations that were imported from an external Alertmanager and is currently
|
||||
read-only. The integrations will become editable after the migration process is complete.
|
||||
</Trans>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
export const ProvisioningBadge = ({
|
||||
tooltip,
|
||||
provenance,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { Button, LinkButton, Menu, Tooltip } from '@grafana/ui';
|
||||
|
||||
import { usePluginBridge } from '../../hooks/usePluginBridge';
|
||||
import { getIrmIfPresentOrIncidentPluginId } from '../../utils/config';
|
||||
import { useIrmPlugin } from '../../hooks/usePluginBridge';
|
||||
import { SupportedPlugin } from '../../types/pluginBridges';
|
||||
import { createBridgeURL } from '../PluginBridge';
|
||||
|
||||
interface Props {
|
||||
@@ -11,20 +11,18 @@ interface Props {
|
||||
url?: string;
|
||||
}
|
||||
|
||||
const pluginId = getIrmIfPresentOrIncidentPluginId();
|
||||
|
||||
export const DeclareIncidentButton = ({ title = '', severity = '', url = '' }: Props) => {
|
||||
const { pluginId, loading, installed, settings } = useIrmPlugin(SupportedPlugin.Incident);
|
||||
|
||||
const bridgeURL = createBridgeURL(pluginId, '/incidents/declare', {
|
||||
title,
|
||||
severity,
|
||||
url,
|
||||
});
|
||||
|
||||
const { loading, installed, settings } = usePluginBridge(pluginId);
|
||||
|
||||
return (
|
||||
<>
|
||||
{loading === true && (
|
||||
{loading && (
|
||||
<Button icon="fire" size="sm" type="button" disabled>
|
||||
<Trans i18nKey="alerting.declare-incident-button.declare-incident">Declare Incident</Trans>
|
||||
</Button>
|
||||
@@ -51,17 +49,17 @@ export const DeclareIncidentButton = ({ title = '', severity = '', url = '' }: P
|
||||
};
|
||||
|
||||
export const DeclareIncidentMenuItem = ({ title = '', severity = '', url = '' }: Props) => {
|
||||
const { pluginId, loading, installed, settings } = useIrmPlugin(SupportedPlugin.Incident);
|
||||
|
||||
const bridgeURL = createBridgeURL(pluginId, '/incidents/declare', {
|
||||
title,
|
||||
severity,
|
||||
url,
|
||||
});
|
||||
|
||||
const { loading, installed, settings } = usePluginBridge(pluginId);
|
||||
|
||||
return (
|
||||
<>
|
||||
{loading === true && (
|
||||
{loading && (
|
||||
<Menu.Item
|
||||
label={t('alerting.declare-incident-menu-item.label-declare-incident', 'Declare incident')}
|
||||
icon="fire"
|
||||
|
||||
@@ -18,10 +18,10 @@ import { getAPINamespace } from '../../../../../api/utils';
|
||||
import { alertmanagerApi } from '../../api/alertmanagerApi';
|
||||
import { onCallApi } from '../../api/onCallApi';
|
||||
import { useAsync } from '../../hooks/useAsync';
|
||||
import { usePluginBridge } from '../../hooks/usePluginBridge';
|
||||
import { useIrmPlugin } from '../../hooks/usePluginBridge';
|
||||
import { useProduceNewAlertmanagerConfiguration } from '../../hooks/useProduceNewAlertmanagerConfig';
|
||||
import { addReceiverAction, deleteReceiverAction, updateReceiverAction } from '../../reducers/alertmanager/receivers';
|
||||
import { getIrmIfPresentOrOnCallPluginId } from '../../utils/config';
|
||||
import { SupportedPlugin } from '../../types/pluginBridges';
|
||||
|
||||
import { enhanceContactPointsWithMetadata } from './utils';
|
||||
|
||||
@@ -61,8 +61,8 @@ const defaultOptions = {
|
||||
* Otherwise, returns no data
|
||||
*/
|
||||
const useOnCallIntegrations = ({ skip }: Skippable = {}) => {
|
||||
const { installed, loading } = usePluginBridge(getIrmIfPresentOrOnCallPluginId());
|
||||
const oncallIntegrationsResponse = useGrafanaOnCallIntegrationsQuery(undefined, { skip: skip || !installed });
|
||||
const { pluginId, installed, loading } = useIrmPlugin(SupportedPlugin.OnCall);
|
||||
const oncallIntegrationsResponse = useGrafanaOnCallIntegrationsQuery({ pluginId }, { skip: skip || !installed });
|
||||
|
||||
return useMemo(() => {
|
||||
if (installed) {
|
||||
@@ -126,6 +126,10 @@ export const useGrafanaContactPoints = ({
|
||||
}: GrafanaFetchOptions & Skippable = {}) => {
|
||||
const namespace = getAPINamespace();
|
||||
const potentiallySkip = { skip };
|
||||
|
||||
// Get the IRM/OnCall plugin information
|
||||
const irmOrOnCallPlugin = useIrmPlugin(SupportedPlugin.OnCall);
|
||||
|
||||
const onCallResponse = useOnCallIntegrations(potentiallySkip);
|
||||
const alertNotifiers = useGrafanaNotifiersQuery(undefined, potentiallySkip);
|
||||
const contactPointsListResponse = useK8sContactPoints({ namespace }, potentiallySkip);
|
||||
@@ -158,6 +162,7 @@ export const useGrafanaContactPoints = ({
|
||||
status: contactPointsStatusResponse.data,
|
||||
notifiers: alertNotifiers.data,
|
||||
onCallIntegrations: onCallResponse?.data,
|
||||
onCallPluginId: irmOrOnCallPlugin.pluginId,
|
||||
contactPoints: contactPointsListResponse.data || [],
|
||||
alertmanagerConfiguration: alertmanagerConfigResponse.data,
|
||||
});
|
||||
@@ -172,6 +177,7 @@ export const useGrafanaContactPoints = ({
|
||||
contactPointsListResponse,
|
||||
contactPointsStatusResponse,
|
||||
onCallResponse,
|
||||
irmOrOnCallPlugin.pluginId,
|
||||
]);
|
||||
};
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from 'app/plugins/datasource/alertmanager/types';
|
||||
|
||||
import { OnCallIntegrationDTO } from '../../api/onCallApi';
|
||||
import { SupportedPlugin } from '../../types/pluginBridges';
|
||||
import { extractReceivers } from '../../utils/receivers';
|
||||
import { routeAdapter } from '../../utils/routeAdapter';
|
||||
import { ReceiverTypes } from '../receivers/grafanaAppReceivers/onCall/onCall';
|
||||
@@ -113,7 +114,8 @@ export interface ContactPointWithMetadata extends GrafanaManagedContactPoint {
|
||||
type EnhanceContactPointsArgs = {
|
||||
status?: ReceiversStateDTO[];
|
||||
notifiers?: NotifierDTO[];
|
||||
onCallIntegrations?: OnCallIntegrationDTO[] | undefined | null;
|
||||
onCallIntegrations?: OnCallIntegrationDTO[];
|
||||
onCallPluginId?: SupportedPlugin;
|
||||
contactPoints: Receiver[];
|
||||
alertmanagerConfiguration?: AlertManagerCortexConfig;
|
||||
};
|
||||
@@ -130,6 +132,7 @@ export function enhanceContactPointsWithMetadata({
|
||||
status = [],
|
||||
notifiers = [],
|
||||
onCallIntegrations,
|
||||
onCallPluginId,
|
||||
contactPoints,
|
||||
alertmanagerConfiguration,
|
||||
}: EnhanceContactPointsArgs): ContactPointWithMetadata[] {
|
||||
@@ -162,7 +165,7 @@ export function enhanceContactPointsWithMetadata({
|
||||
[RECEIVER_META_KEY]: getNotifierMetadata(notifiers, receiver),
|
||||
// if OnCall plugin is installed, we'll add it to the receiver's plugin metadata
|
||||
[RECEIVER_PLUGIN_META_KEY]: isOnCallReceiver
|
||||
? getOnCallMetadata(onCallIntegrations, receiver, Boolean(alertmanagerConfiguration))
|
||||
? getOnCallMetadata(onCallIntegrations, receiver, Boolean(alertmanagerConfiguration), onCallPluginId)
|
||||
: undefined,
|
||||
};
|
||||
}),
|
||||
|
||||
+240
-1
@@ -1,11 +1,12 @@
|
||||
import 'core-js/stable/structured-clone';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { clickSelectOption } from 'test/helpers/selectOptionInTest';
|
||||
import { render } from 'test/test-utils';
|
||||
import { render, screen } from 'test/test-utils';
|
||||
import { byRole, byTestId } from 'testing-library-selector';
|
||||
|
||||
import { grafanaAlertNotifiers } from 'app/features/alerting/unified/mockGrafanaNotifiers';
|
||||
import { AlertmanagerProvider } from 'app/features/alerting/unified/state/AlertmanagerContext';
|
||||
import { NotifierDTO } from 'app/features/alerting/unified/types/alerting';
|
||||
|
||||
import { ChannelSubForm } from './ChannelSubForm';
|
||||
import { GrafanaCommonChannelSettings } from './GrafanaCommonChannelSettings';
|
||||
@@ -16,6 +17,7 @@ type TestChannelValues = {
|
||||
type: string;
|
||||
settings: Record<string, unknown>;
|
||||
secureFields: Record<string, boolean>;
|
||||
version?: string;
|
||||
};
|
||||
|
||||
type TestReceiverFormValues = {
|
||||
@@ -246,4 +248,241 @@ describe('ChannelSubForm', () => {
|
||||
expect(slackUrl).toBeEnabled();
|
||||
expect(slackUrl).toHaveValue('');
|
||||
});
|
||||
|
||||
describe('version-specific options display', () => {
|
||||
// Create a mock notifier with different options for v0 and v1
|
||||
const legacyOptions = [
|
||||
{
|
||||
element: 'input' as const,
|
||||
inputType: 'text',
|
||||
label: 'Legacy URL',
|
||||
description: 'The legacy endpoint URL',
|
||||
placeholder: '',
|
||||
propertyName: 'legacyUrl',
|
||||
required: true,
|
||||
secure: false,
|
||||
showWhen: { field: '', is: '' },
|
||||
validationRule: '',
|
||||
dependsOn: '',
|
||||
},
|
||||
];
|
||||
|
||||
const webhookWithVersions: NotifierDTO = {
|
||||
...grafanaAlertNotifiers.webhook,
|
||||
versions: [
|
||||
{
|
||||
version: 'v0mimir1',
|
||||
label: 'Webhook (Legacy)',
|
||||
description: 'Legacy webhook from Mimir',
|
||||
canCreate: false,
|
||||
options: legacyOptions,
|
||||
},
|
||||
{
|
||||
version: 'v0mimir2',
|
||||
label: 'Webhook (Legacy v2)',
|
||||
description: 'Legacy webhook v2 from Mimir',
|
||||
canCreate: false,
|
||||
options: legacyOptions,
|
||||
},
|
||||
{
|
||||
version: 'v1',
|
||||
label: 'Webhook',
|
||||
description: 'Sends HTTP POST request',
|
||||
canCreate: true,
|
||||
options: grafanaAlertNotifiers.webhook.options,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const versionedNotifiers: Notifier[] = [
|
||||
{ dto: webhookWithVersions, meta: { enabled: true, order: 1 } },
|
||||
{ dto: grafanaAlertNotifiers.slack, meta: { enabled: true, order: 2 } },
|
||||
];
|
||||
|
||||
function VersionedTestFormWrapper({
|
||||
defaults,
|
||||
initial,
|
||||
}: {
|
||||
defaults: TestChannelValues;
|
||||
initial?: TestChannelValues;
|
||||
}) {
|
||||
const form = useForm<TestReceiverFormValues>({
|
||||
defaultValues: {
|
||||
name: 'test-contact-point',
|
||||
items: [defaults],
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<AlertmanagerProvider accessType="notification">
|
||||
<FormProvider {...form}>
|
||||
<ChannelSubForm
|
||||
defaultValues={defaults}
|
||||
initialValues={initial}
|
||||
pathPrefix={`items.0.`}
|
||||
integrationIndex={0}
|
||||
notifiers={versionedNotifiers}
|
||||
onDuplicate={jest.fn()}
|
||||
commonSettingsComponent={GrafanaCommonChannelSettings}
|
||||
isEditable={true}
|
||||
isTestable={false}
|
||||
canEditProtectedFields={true}
|
||||
/>
|
||||
</FormProvider>
|
||||
</AlertmanagerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function renderVersionedForm(defaults: TestChannelValues, initial?: TestChannelValues) {
|
||||
return render(<VersionedTestFormWrapper defaults={defaults} initial={initial} />);
|
||||
}
|
||||
|
||||
it('should display v1 options when integration has v1 version', () => {
|
||||
const webhookV1: TestChannelValues = {
|
||||
__id: 'id-0',
|
||||
type: 'webhook',
|
||||
version: 'v1',
|
||||
settings: { url: 'https://example.com' },
|
||||
secureFields: {},
|
||||
};
|
||||
|
||||
renderVersionedForm(webhookV1, webhookV1);
|
||||
|
||||
// Should show v1 URL field (from default options)
|
||||
expect(ui.settings.webhook.url.get()).toBeInTheDocument();
|
||||
// Should NOT show legacy URL field
|
||||
expect(screen.queryByRole('textbox', { name: /Legacy URL/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display v0 options when integration has legacy version', () => {
|
||||
const webhookV0: TestChannelValues = {
|
||||
__id: 'id-0',
|
||||
type: 'webhook',
|
||||
version: 'v0mimir1',
|
||||
settings: { legacyUrl: 'https://legacy.example.com' },
|
||||
secureFields: {},
|
||||
};
|
||||
|
||||
renderVersionedForm(webhookV0, webhookV0);
|
||||
|
||||
// Should show legacy URL field (from v0 options)
|
||||
expect(screen.getByRole('textbox', { name: /Legacy URL/i })).toBeInTheDocument();
|
||||
// Should NOT show v1 URL field
|
||||
expect(ui.settings.webhook.url.query()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display "Legacy" badge for v0mimir1 integration', () => {
|
||||
const webhookV0: TestChannelValues = {
|
||||
__id: 'id-0',
|
||||
type: 'webhook',
|
||||
version: 'v0mimir1',
|
||||
settings: { legacyUrl: 'https://legacy.example.com' },
|
||||
secureFields: {},
|
||||
};
|
||||
|
||||
renderVersionedForm(webhookV0, webhookV0);
|
||||
|
||||
// Should show "Legacy" badge for v0mimir1 integrations
|
||||
expect(screen.getByText('Legacy')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display "Legacy v2" badge for v0mimir2 integration', () => {
|
||||
const webhookV0v2: TestChannelValues = {
|
||||
__id: 'id-0',
|
||||
type: 'webhook',
|
||||
version: 'v0mimir2',
|
||||
settings: { legacyUrl: 'https://legacy.example.com' },
|
||||
secureFields: {},
|
||||
};
|
||||
|
||||
renderVersionedForm(webhookV0v2, webhookV0v2);
|
||||
|
||||
// Should show "Legacy v2" badge for v0mimir2 integrations
|
||||
expect(screen.getByText('Legacy v2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should NOT display version badge for v1 integration', () => {
|
||||
const webhookV1: TestChannelValues = {
|
||||
__id: 'id-0',
|
||||
type: 'webhook',
|
||||
version: 'v1',
|
||||
settings: { url: 'https://example.com' },
|
||||
secureFields: {},
|
||||
};
|
||||
|
||||
renderVersionedForm(webhookV1, webhookV1);
|
||||
|
||||
// Should NOT show version badge for non-legacy v1 integrations
|
||||
expect(screen.queryByText('v1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should filter out notifiers with canCreate: false from dropdown', () => {
|
||||
// Create a notifier that only has v0 versions (cannot be created)
|
||||
const legacyOnlyNotifier: NotifierDTO = {
|
||||
type: 'wechat',
|
||||
name: 'WeChat',
|
||||
heading: 'WeChat settings',
|
||||
description: 'Sends notifications to WeChat',
|
||||
options: [],
|
||||
versions: [
|
||||
{
|
||||
version: 'v0mimir1',
|
||||
label: 'WeChat (Legacy)',
|
||||
description: 'Legacy WeChat',
|
||||
canCreate: false,
|
||||
options: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const notifiersWithLegacyOnly: Notifier[] = [
|
||||
{ dto: webhookWithVersions, meta: { enabled: true, order: 1 } },
|
||||
{ dto: legacyOnlyNotifier, meta: { enabled: true, order: 2 } },
|
||||
];
|
||||
|
||||
function LegacyOnlyTestWrapper({ defaults }: { defaults: TestChannelValues }) {
|
||||
const form = useForm<TestReceiverFormValues>({
|
||||
defaultValues: {
|
||||
name: 'test-contact-point',
|
||||
items: [defaults],
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<AlertmanagerProvider accessType="notification">
|
||||
<FormProvider {...form}>
|
||||
<ChannelSubForm
|
||||
defaultValues={defaults}
|
||||
pathPrefix={`items.0.`}
|
||||
integrationIndex={0}
|
||||
notifiers={notifiersWithLegacyOnly}
|
||||
onDuplicate={jest.fn()}
|
||||
commonSettingsComponent={GrafanaCommonChannelSettings}
|
||||
isEditable={true}
|
||||
isTestable={false}
|
||||
canEditProtectedFields={true}
|
||||
/>
|
||||
</FormProvider>
|
||||
</AlertmanagerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
render(
|
||||
<LegacyOnlyTestWrapper
|
||||
defaults={{
|
||||
__id: 'id-0',
|
||||
type: 'webhook',
|
||||
settings: {},
|
||||
secureFields: {},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
// Webhook should be in dropdown (has v1 with canCreate: true)
|
||||
expect(ui.typeSelector.get()).toHaveTextContent('Webhook');
|
||||
|
||||
// WeChat should NOT be in the options (only has v0 with canCreate: false)
|
||||
// We can't easily check dropdown options without opening it, but the filter should work
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Controller, FieldErrors, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { Alert, Button, Field, Select, Stack, Text, useStyles2 } from '@grafana/ui';
|
||||
import { Alert, Badge, Button, Field, Select, Stack, Text, useStyles2 } from '@grafana/ui';
|
||||
import { NotificationChannelOption } from 'app/features/alerting/unified/types/alerting';
|
||||
|
||||
import {
|
||||
@@ -16,6 +16,12 @@ import {
|
||||
GrafanaChannelValues,
|
||||
ReceiverFormValues,
|
||||
} from '../../../types/receiver-form';
|
||||
import {
|
||||
canCreateNotifier,
|
||||
getLegacyVersionLabel,
|
||||
getOptionsForVersion,
|
||||
isLegacyVersion,
|
||||
} from '../../../utils/notifier-versions';
|
||||
import { OnCallIntegrationType } from '../grafanaAppReceivers/onCall/useOnCallIntegration';
|
||||
|
||||
import { ChannelOptions } from './ChannelOptions';
|
||||
@@ -62,6 +68,7 @@ export function ChannelSubForm<R extends ChannelValues>({
|
||||
|
||||
const channelFieldPath = `items.${integrationIndex}` as const;
|
||||
const typeFieldPath = `${channelFieldPath}.type` as const;
|
||||
const versionFieldPath = `${channelFieldPath}.version` as const;
|
||||
const settingsFieldPath = `${channelFieldPath}.settings` as const;
|
||||
const secureFieldsPath = `${channelFieldPath}.secureFields` as const;
|
||||
|
||||
@@ -104,6 +111,9 @@ export function ChannelSubForm<R extends ChannelValues>({
|
||||
|
||||
setValue(settingsFieldPath, defaultNotifierSettings);
|
||||
setValue(secureFieldsPath, {});
|
||||
|
||||
// Reset version when changing type - backend will use its default
|
||||
setValue(versionFieldPath, undefined);
|
||||
}
|
||||
|
||||
// Restore initial value of an existing oncall integration
|
||||
@@ -123,6 +133,7 @@ export function ChannelSubForm<R extends ChannelValues>({
|
||||
setValue,
|
||||
settingsFieldPath,
|
||||
typeFieldPath,
|
||||
versionFieldPath,
|
||||
secureFieldsPath,
|
||||
getValues,
|
||||
watch,
|
||||
@@ -164,24 +175,30 @@ export function ChannelSubForm<R extends ChannelValues>({
|
||||
setValue(`${settingsFieldPath}.${fieldPath}`, undefined);
|
||||
};
|
||||
|
||||
const typeOptions = useMemo(
|
||||
(): SelectableValue[] =>
|
||||
sortBy(notifiers, ({ dto, meta }) => [meta?.order ?? 0, dto.name]).map<SelectableValue>(
|
||||
({ dto: { name, type }, meta }) => ({
|
||||
// @ts-expect-error ReactNode is supported
|
||||
const typeOptions = useMemo((): SelectableValue[] => {
|
||||
// Filter out notifiers that can't be created (e.g., v0-only integrations like WeChat)
|
||||
// These are legacy integrations that only exist in Mimir and can't be created in Grafana
|
||||
const creatableNotifiers = notifiers.filter(({ dto }) => canCreateNotifier(dto));
|
||||
|
||||
return sortBy(creatableNotifiers, ({ dto, meta }) => [meta?.order ?? 0, dto.name]).map<SelectableValue>(
|
||||
({ dto: { name, type }, meta }) => {
|
||||
return {
|
||||
// ReactNode is supported in Select label, but types don't reflect it
|
||||
/* eslint-disable @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any */
|
||||
label: (
|
||||
<Stack alignItems="center" gap={1}>
|
||||
{name}
|
||||
{meta?.badge}
|
||||
</Stack>
|
||||
),
|
||||
) as any,
|
||||
/* eslint-enable @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any */
|
||||
value: type,
|
||||
description: meta?.description,
|
||||
isDisabled: meta ? !meta.enabled : false,
|
||||
})
|
||||
),
|
||||
[notifiers]
|
||||
);
|
||||
};
|
||||
}
|
||||
);
|
||||
}, [notifiers]);
|
||||
|
||||
const handleTest = async () => {
|
||||
await trigger();
|
||||
@@ -198,10 +215,21 @@ export function ChannelSubForm<R extends ChannelValues>({
|
||||
// Cloud AM takes no value at all
|
||||
const isParseModeNone = parse_mode === 'None' || !parse_mode;
|
||||
const showTelegramWarning = isTelegram && !isParseModeNone;
|
||||
|
||||
// Check if current integration is a legacy version (canCreate: false)
|
||||
// Legacy integrations are read-only and cannot be edited
|
||||
// Read version from existing integration data (stored in receiver config)
|
||||
const integrationVersion = initialValues?.version || defaultValues.version;
|
||||
const isLegacy = notifier ? isLegacyVersion(notifier.dto, integrationVersion) : false;
|
||||
|
||||
// Get the correct options based on the integration's version
|
||||
// This ensures legacy (v0) integrations display the correct schema
|
||||
const versionedOptions = notifier ? getOptionsForVersion(notifier.dto, integrationVersion) : [];
|
||||
|
||||
// if there are mandatory options defined, optional options will be hidden by a collapse
|
||||
// if there aren't mandatory options, all options will be shown without collapse
|
||||
const mandatoryOptions = notifier?.dto.options.filter((o) => o.required) ?? [];
|
||||
const optionalOptions = notifier?.dto.options.filter((o) => !o.required) ?? [];
|
||||
const mandatoryOptions = versionedOptions.filter((o) => o.required);
|
||||
const optionalOptions = versionedOptions.filter((o) => !o.required);
|
||||
|
||||
const contactPointTypeInputId = `contact-point-type-${pathPrefix}`;
|
||||
return (
|
||||
@@ -214,21 +242,35 @@ export function ChannelSubForm<R extends ChannelValues>({
|
||||
data-testid={`${pathPrefix}type`}
|
||||
noMargin
|
||||
>
|
||||
<Controller
|
||||
name={typeFieldPath}
|
||||
control={control}
|
||||
defaultValue={defaultValues.type}
|
||||
render={({ field: { ref, onChange, ...field } }) => (
|
||||
<Select
|
||||
disabled={!isEditable}
|
||||
inputId={contactPointTypeInputId}
|
||||
{...field}
|
||||
width={37}
|
||||
options={typeOptions}
|
||||
onChange={(value) => onChange(value?.value)}
|
||||
<Stack direction="row" alignItems="center" gap={1}>
|
||||
<Controller
|
||||
name={typeFieldPath}
|
||||
control={control}
|
||||
defaultValue={defaultValues.type}
|
||||
render={({ field: { ref, onChange, ...field } }) => (
|
||||
<Select
|
||||
disabled={!isEditable}
|
||||
inputId={contactPointTypeInputId}
|
||||
{...field}
|
||||
width={37}
|
||||
options={typeOptions}
|
||||
onChange={(value) => onChange(value?.value)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{isLegacy && integrationVersion && (
|
||||
<Badge
|
||||
text={getLegacyVersionLabel(integrationVersion)}
|
||||
color="orange"
|
||||
icon="exclamation-triangle"
|
||||
tooltip={t(
|
||||
'alerting.channel-sub-form.tooltip-legacy-version',
|
||||
'This is a legacy integration (version: {{version}}). It cannot be modified.',
|
||||
{ version: integrationVersion }
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</Field>
|
||||
</div>
|
||||
<div className={styles.buttons}>
|
||||
@@ -292,7 +334,7 @@ export function ChannelSubForm<R extends ChannelValues>({
|
||||
name: notifier.dto.name,
|
||||
})}
|
||||
>
|
||||
{notifier.dto.info !== '' && (
|
||||
{notifier.dto.info && (
|
||||
<Alert title="" severity="info">
|
||||
{notifier.dto.info}
|
||||
</Alert>
|
||||
|
||||
+17
-5
@@ -18,12 +18,13 @@ import {
|
||||
|
||||
import { alertmanagerApi } from '../../../api/alertmanagerApi';
|
||||
import { GrafanaChannelValues, ReceiverFormValues } from '../../../types/receiver-form';
|
||||
import { hasLegacyIntegrations } from '../../../utils/notifier-versions';
|
||||
import {
|
||||
formChannelValuesToGrafanaChannelConfig,
|
||||
formValuesToGrafanaReceiver,
|
||||
grafanaReceiverToFormValues,
|
||||
} from '../../../utils/receiver-form';
|
||||
import { ProvisionedResource, ProvisioningAlert } from '../../Provisioning';
|
||||
import { ImportedContactPointAlert, ProvisionedResource, ProvisioningAlert } from '../../Provisioning';
|
||||
import { ReceiverTypes } from '../grafanaAppReceivers/onCall/onCall';
|
||||
import { useOnCallIntegration } from '../grafanaAppReceivers/onCall/useOnCallIntegration';
|
||||
|
||||
@@ -39,6 +40,8 @@ const defaultChannelValues: GrafanaChannelValues = Object.freeze({
|
||||
secureFields: {},
|
||||
disableResolveMessage: false,
|
||||
type: 'email',
|
||||
// version is intentionally not set here - it will be determined by the notifier's currentVersion
|
||||
// when the integration is created/type is changed. The backend will use its default if not provided.
|
||||
});
|
||||
|
||||
interface Props {
|
||||
@@ -67,7 +70,6 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode }
|
||||
} = useOnCallIntegration();
|
||||
|
||||
const { data: grafanaNotifiers = [], isLoading: isLoadingNotifiers } = useGrafanaNotifiersQuery();
|
||||
|
||||
const [testReceivers, setTestReceivers] = useState<Receiver[]>();
|
||||
|
||||
// transform receiver DTO to form values
|
||||
@@ -135,15 +137,20 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode }
|
||||
);
|
||||
}
|
||||
|
||||
// Map notifiers to Notifier[] format for ReceiverForm
|
||||
// The grafanaNotifiers include version-specific options via the versions array from the backend
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions
|
||||
const notifiers: Notifier[] = grafanaNotifiers.map((n) => {
|
||||
if (n.type === ReceiverTypes.OnCall) {
|
||||
return {
|
||||
dto: extendOnCallNotifierFeatures(n),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions
|
||||
dto: extendOnCallNotifierFeatures(n as any) as any,
|
||||
meta: onCallNotifierMeta,
|
||||
};
|
||||
}
|
||||
|
||||
return { dto: n };
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions
|
||||
return { dto: n as any };
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -163,7 +170,12 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode }
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{contactPoint?.provisioned && <ProvisioningAlert resource={ProvisionedResource.ContactPoint} />}
|
||||
{contactPoint?.provisioned && hasLegacyIntegrations(contactPoint, grafanaNotifiers) && (
|
||||
<ImportedContactPointAlert />
|
||||
)}
|
||||
{contactPoint?.provisioned && !hasLegacyIntegrations(contactPoint, grafanaNotifiers) && (
|
||||
<ProvisioningAlert resource={ProvisionedResource.ContactPoint} />
|
||||
)}
|
||||
|
||||
<ReceiverForm<GrafanaChannelValues>
|
||||
contactPointId={contactPoint?.id}
|
||||
|
||||
+19
-10
@@ -6,12 +6,12 @@ import { t } from '@grafana/i18n';
|
||||
import { isFetchError } from '@grafana/runtime';
|
||||
import { Badge } from '@grafana/ui';
|
||||
import { NotifierDTO } from 'app/features/alerting/unified/types/alerting';
|
||||
import { getIrmIfPresentOrOnCallPluginId } from 'app/features/alerting/unified/utils/config';
|
||||
|
||||
import { useAppNotification } from '../../../../../../../core/copy/appNotification';
|
||||
import { Receiver } from '../../../../../../../plugins/datasource/alertmanager/types';
|
||||
import { ONCALL_INTEGRATION_V2_FEATURE, onCallApi } from '../../../../api/onCallApi';
|
||||
import { usePluginBridge } from '../../../../hooks/usePluginBridge';
|
||||
import { useIrmPlugin } from '../../../../hooks/usePluginBridge';
|
||||
import { SupportedPlugin } from '../../../../types/pluginBridges';
|
||||
import { option } from '../../../../utils/notifier-types';
|
||||
|
||||
import { GRAFANA_ONCALL_INTEGRATION_TYPE, ReceiverTypes } from './onCall';
|
||||
@@ -39,16 +39,17 @@ enum OnCallIntegrationStatus {
|
||||
|
||||
function useOnCallPluginStatus() {
|
||||
const {
|
||||
pluginId,
|
||||
installed: isOnCallEnabled,
|
||||
loading: isPluginBridgeLoading,
|
||||
error: pluginError,
|
||||
} = usePluginBridge(getIrmIfPresentOrOnCallPluginId());
|
||||
} = useIrmPlugin(SupportedPlugin.OnCall);
|
||||
|
||||
const {
|
||||
data: onCallFeatures = [],
|
||||
error: onCallFeaturesError,
|
||||
isLoading: isOnCallFeaturesLoading,
|
||||
} = onCallApi.endpoints.features.useQuery(undefined, { skip: !isOnCallEnabled });
|
||||
} = onCallApi.endpoints.features.useQuery({ pluginId }, { skip: !isOnCallEnabled });
|
||||
|
||||
const integrationStatus = useMemo((): OnCallIntegrationStatus => {
|
||||
if (!isOnCallEnabled) {
|
||||
@@ -67,6 +68,7 @@ function useOnCallPluginStatus() {
|
||||
);
|
||||
|
||||
return {
|
||||
pluginId,
|
||||
isOnCallEnabled,
|
||||
integrationStatus,
|
||||
isAlertingV2IntegrationEnabled,
|
||||
@@ -78,8 +80,14 @@ function useOnCallPluginStatus() {
|
||||
export function useOnCallIntegration() {
|
||||
const notifyApp = useAppNotification();
|
||||
|
||||
const { isOnCallEnabled, integrationStatus, isAlertingV2IntegrationEnabled, isOnCallStatusLoading, onCallError } =
|
||||
useOnCallPluginStatus();
|
||||
const {
|
||||
pluginId,
|
||||
isOnCallEnabled,
|
||||
integrationStatus,
|
||||
isAlertingV2IntegrationEnabled,
|
||||
isOnCallStatusLoading,
|
||||
onCallError,
|
||||
} = useOnCallPluginStatus();
|
||||
|
||||
const { useCreateIntegrationMutation, useGrafanaOnCallIntegrationsQuery, useLazyValidateIntegrationNameQuery } =
|
||||
onCallApi;
|
||||
@@ -91,13 +99,13 @@ export function useOnCallIntegration() {
|
||||
data: grafanaOnCallIntegrations = [],
|
||||
isLoading: isLoadingOnCallIntegrations,
|
||||
isError: isIntegrationsQueryError,
|
||||
} = useGrafanaOnCallIntegrationsQuery(undefined, { skip: !isAlertingV2IntegrationEnabled });
|
||||
} = useGrafanaOnCallIntegrationsQuery({ pluginId }, { skip: !isAlertingV2IntegrationEnabled });
|
||||
|
||||
const onCallFormValidators = useMemo(() => {
|
||||
return {
|
||||
integration_name: async (value: string) => {
|
||||
try {
|
||||
await validateIntegrationNameQuery(value).unwrap();
|
||||
await validateIntegrationNameQuery({ name: value, pluginId }).unwrap();
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isFetchError(error) && error.status === 409) {
|
||||
@@ -126,7 +134,7 @@ export function useOnCallIntegration() {
|
||||
: t('alerting.irm-integration.integration-required', 'Selection of existing IRM integration is required');
|
||||
},
|
||||
};
|
||||
}, [grafanaOnCallIntegrations, validateIntegrationNameQuery, isAlertingV2IntegrationEnabled, notifyApp]);
|
||||
}, [grafanaOnCallIntegrations, validateIntegrationNameQuery, isAlertingV2IntegrationEnabled, notifyApp, pluginId]);
|
||||
|
||||
const extendOnCallReceivers = useCallback(
|
||||
(receiver: Receiver): Receiver => {
|
||||
@@ -159,6 +167,7 @@ export function useOnCallIntegration() {
|
||||
|
||||
const createNewOnCallIntegrationJobs = newOnCallIntegrations.map(async (c) => {
|
||||
const newIntegration = await createIntegrationMutation({
|
||||
pluginId,
|
||||
integration: GRAFANA_ONCALL_INTEGRATION_TYPE,
|
||||
verbal_name: c.settings[OnCallIntegrationSetting.IntegrationName],
|
||||
}).unwrap();
|
||||
@@ -180,7 +189,7 @@ export function useOnCallIntegration() {
|
||||
});
|
||||
});
|
||||
},
|
||||
[isAlertingV2IntegrationEnabled, createIntegrationMutation]
|
||||
[isAlertingV2IntegrationEnabled, createIntegrationMutation, pluginId]
|
||||
);
|
||||
|
||||
const extendOnCallNotifierFeatures = useCallback(
|
||||
|
||||
+29
-12
@@ -1,6 +1,9 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { GrafanaManagedReceiverConfig } from '../../../../../../plugins/datasource/alertmanager/types';
|
||||
import { OnCallIntegrationDTO } from '../../../api/onCallApi';
|
||||
import { getIrmIfPresentOrOnCallPluginId, getIsIrmPluginPresent } from '../../../utils/config';
|
||||
import { useIrmPlugin } from '../../../hooks/usePluginBridge';
|
||||
import { SupportedPlugin } from '../../../types/pluginBridges';
|
||||
import { createBridgeURL } from '../../PluginBridge';
|
||||
|
||||
import { GRAFANA_APP_RECEIVERS_SOURCE_IMAGE } from './types';
|
||||
@@ -13,20 +16,34 @@ export interface ReceiverPluginMetadata {
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
const onCallReceiverICon = GRAFANA_APP_RECEIVERS_SOURCE_IMAGE[getIrmIfPresentOrOnCallPluginId()];
|
||||
const onCallReceiverTitle = 'Grafana OnCall';
|
||||
|
||||
export const onCallReceiverMeta: ReceiverPluginMetadata = {
|
||||
title: onCallReceiverTitle,
|
||||
icon: onCallReceiverICon,
|
||||
};
|
||||
|
||||
export function getOnCallMetadata(
|
||||
export function useOnCallMetadata(
|
||||
onCallIntegrations: OnCallIntegrationDTO[] | undefined | null,
|
||||
receiver: GrafanaManagedReceiverConfig,
|
||||
hasAlertManagerConfigData = true
|
||||
): ReceiverPluginMetadata {
|
||||
const pluginName = getIsIrmPluginPresent() ? 'IRM' : 'OnCall';
|
||||
const { pluginId } = useIrmPlugin(SupportedPlugin.OnCall);
|
||||
|
||||
return useMemo(
|
||||
() => getOnCallMetadata(onCallIntegrations, receiver, hasAlertManagerConfigData, pluginId),
|
||||
[onCallIntegrations, receiver, hasAlertManagerConfigData, pluginId]
|
||||
);
|
||||
}
|
||||
|
||||
export function getOnCallMetadata(
|
||||
onCallIntegrations: OnCallIntegrationDTO[] | undefined | null,
|
||||
receiver: GrafanaManagedReceiverConfig,
|
||||
hasAlertManagerConfigData = true,
|
||||
onCallPluginId?: SupportedPlugin
|
||||
): ReceiverPluginMetadata {
|
||||
const pluginId = onCallPluginId || SupportedPlugin.OnCall;
|
||||
const pluginName = pluginId === SupportedPlugin.Irm ? 'IRM' : 'OnCall';
|
||||
const onCallReceiverIcon = GRAFANA_APP_RECEIVERS_SOURCE_IMAGE[pluginId];
|
||||
const onCallReceiverTitle = pluginId === SupportedPlugin.Irm ? 'Grafana IRM' : 'Grafana OnCall';
|
||||
|
||||
const onCallReceiverMeta: ReceiverPluginMetadata = {
|
||||
title: onCallReceiverTitle,
|
||||
icon: onCallReceiverIcon,
|
||||
};
|
||||
|
||||
if (!hasAlertManagerConfigData) {
|
||||
return onCallReceiverMeta;
|
||||
@@ -57,7 +74,7 @@ export function getOnCallMetadata(
|
||||
...onCallReceiverMeta,
|
||||
description: matchingOnCallIntegration?.display_name,
|
||||
externalUrl: matchingOnCallIntegration
|
||||
? createBridgeURL(getIrmIfPresentOrOnCallPluginId(), `/integrations/${matchingOnCallIntegration.value}`)
|
||||
? createBridgeURL(pluginId, `/integrations/${matchingOnCallIntegration.value}`)
|
||||
: undefined,
|
||||
warning: matchingOnCallIntegration ? undefined : `${pluginName} Integration no longer exists`,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import { getPluginSettings } from 'app/features/plugins/pluginSettings';
|
||||
|
||||
import { pluginMeta } from '../testSetup/plugins';
|
||||
import { SupportedPlugin } from '../types/pluginBridges';
|
||||
|
||||
import { useIrmPlugin } from './usePluginBridge';
|
||||
|
||||
jest.mock('app/features/plugins/pluginSettings');
|
||||
|
||||
const mockedGetPluginSettings = jest.mocked(getPluginSettings);
|
||||
|
||||
describe('useIrmPlugin', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return IRM plugin ID when IRM plugin is installed', async () => {
|
||||
mockedGetPluginSettings.mockImplementation((pluginId) => {
|
||||
if (pluginId === SupportedPlugin.Irm) {
|
||||
return Promise.resolve(pluginMeta[SupportedPlugin.Irm]);
|
||||
}
|
||||
if (pluginId === SupportedPlugin.OnCall) {
|
||||
return Promise.resolve({ ...pluginMeta[SupportedPlugin.OnCall], enabled: false });
|
||||
}
|
||||
return Promise.reject(new Error('Plugin not found'));
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useIrmPlugin(SupportedPlugin.OnCall));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.pluginId).toBe(SupportedPlugin.Irm);
|
||||
expect(result.current.installed).toBe(true);
|
||||
expect(result.current.settings).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return OnCall plugin ID when IRM plugin is not installed', async () => {
|
||||
mockedGetPluginSettings.mockImplementation((pluginId) => {
|
||||
if (pluginId === SupportedPlugin.OnCall) {
|
||||
return Promise.resolve(pluginMeta[SupportedPlugin.OnCall]);
|
||||
}
|
||||
return Promise.reject(new Error('Plugin not found'));
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useIrmPlugin(SupportedPlugin.OnCall));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.pluginId).toBe(SupportedPlugin.OnCall);
|
||||
expect(result.current.installed).toBe(true);
|
||||
expect(result.current.settings).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return Incident plugin ID when IRM plugin is not installed', async () => {
|
||||
mockedGetPluginSettings.mockImplementation((pluginId) => {
|
||||
if (pluginId === SupportedPlugin.Incident) {
|
||||
return Promise.resolve(pluginMeta[SupportedPlugin.Incident]);
|
||||
}
|
||||
return Promise.reject(new Error('Plugin not found'));
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useIrmPlugin(SupportedPlugin.Incident));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.pluginId).toBe(SupportedPlugin.Incident);
|
||||
expect(result.current.installed).toBe(true);
|
||||
expect(result.current.settings).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return loading state while fetching plugins', () => {
|
||||
mockedGetPluginSettings.mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(() => resolve(pluginMeta[SupportedPlugin.Irm]), 100))
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useIrmPlugin(SupportedPlugin.OnCall));
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.installed).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return installed undefined when neither plugin is installed', async () => {
|
||||
mockedGetPluginSettings.mockRejectedValue(new Error('Plugin not found'));
|
||||
|
||||
const { result } = renderHook(() => useIrmPlugin(SupportedPlugin.OnCall));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.pluginId).toBe(SupportedPlugin.OnCall);
|
||||
expect(result.current.installed).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return IRM plugin ID when both IRM and OnCall are installed', async () => {
|
||||
mockedGetPluginSettings.mockImplementation((pluginId) => {
|
||||
if (pluginId === SupportedPlugin.Irm) {
|
||||
return Promise.resolve(pluginMeta[SupportedPlugin.Irm]);
|
||||
}
|
||||
if (pluginId === SupportedPlugin.OnCall) {
|
||||
return Promise.resolve(pluginMeta[SupportedPlugin.OnCall]);
|
||||
}
|
||||
return Promise.reject(new Error('Plugin not found'));
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useIrmPlugin(SupportedPlugin.OnCall));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.pluginId).toBe(SupportedPlugin.Irm);
|
||||
expect(result.current.installed).toBe(true);
|
||||
expect(result.current.settings).toEqual(pluginMeta[SupportedPlugin.Irm]);
|
||||
});
|
||||
|
||||
it('should return IRM plugin ID when both IRM and Incident are installed', async () => {
|
||||
mockedGetPluginSettings.mockImplementation((pluginId) => {
|
||||
if (pluginId === SupportedPlugin.Irm) {
|
||||
return Promise.resolve(pluginMeta[SupportedPlugin.Irm]);
|
||||
}
|
||||
if (pluginId === SupportedPlugin.Incident) {
|
||||
return Promise.resolve(pluginMeta[SupportedPlugin.Incident]);
|
||||
}
|
||||
return Promise.reject(new Error('Plugin not found'));
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useIrmPlugin(SupportedPlugin.Incident));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.pluginId).toBe(SupportedPlugin.Irm);
|
||||
expect(result.current.installed).toBe(true);
|
||||
expect(result.current.settings).toEqual(pluginMeta[SupportedPlugin.Irm]);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,8 @@ import { PluginMeta } from '@grafana/data';
|
||||
import { getPluginSettings } from 'app/features/plugins/pluginSettings';
|
||||
|
||||
import { PluginID } from '../components/PluginBridge';
|
||||
import { SupportedPlugin } from '../types/pluginBridges';
|
||||
|
||||
interface PluginBridgeHookResponse {
|
||||
loading: boolean;
|
||||
installed?: boolean;
|
||||
@@ -14,17 +16,54 @@ interface PluginBridgeHookResponse {
|
||||
export function usePluginBridge(plugin: PluginID): PluginBridgeHookResponse {
|
||||
const { loading, error, value } = useAsync(() => getPluginSettings(plugin, { showErrorAlert: false }));
|
||||
|
||||
const installed = value && !error && !loading;
|
||||
const enabled = value?.enabled;
|
||||
const isLoading = loading && !value;
|
||||
|
||||
if (isLoading) {
|
||||
if (loading) {
|
||||
return { loading: true };
|
||||
}
|
||||
|
||||
if (!installed || !enabled) {
|
||||
return { loading: false, installed: false };
|
||||
if (error) {
|
||||
return { loading, error };
|
||||
}
|
||||
|
||||
return { loading, installed: true, settings: value };
|
||||
if (value) {
|
||||
return { loading, installed: value.enabled ?? false, settings: value };
|
||||
}
|
||||
|
||||
return { loading, installed: false };
|
||||
}
|
||||
|
||||
type FallbackPlugin = SupportedPlugin.OnCall | SupportedPlugin.Incident;
|
||||
type IrmWithFallback = SupportedPlugin.Irm | FallbackPlugin;
|
||||
|
||||
export interface PluginBridgeResult {
|
||||
pluginId: IrmWithFallback;
|
||||
loading: boolean;
|
||||
installed?: boolean;
|
||||
error?: Error;
|
||||
settings?: PluginMeta<{}>;
|
||||
}
|
||||
/**
|
||||
* Hook that checks for IRM plugin first, falls back to specified plugin.
|
||||
* IRM replaced both OnCall and Incident - this provides backward compatibility.
|
||||
*
|
||||
* @param fallback - The plugin to use if IRM is not installed (OnCall or Incident)
|
||||
* @returns Bridge result with the active plugin data
|
||||
*
|
||||
* @example
|
||||
* const { pluginId, loading, installed, settings } = useIrmPlugin(SupportedPlugin.OnCall);
|
||||
*/
|
||||
export function useIrmPlugin(fallback: FallbackPlugin): PluginBridgeResult {
|
||||
const irmBridge = usePluginBridge(SupportedPlugin.Irm);
|
||||
const fallbackBridge = usePluginBridge(fallback);
|
||||
|
||||
const loading = irmBridge.loading || fallbackBridge.loading;
|
||||
const pluginId = irmBridge.installed ? SupportedPlugin.Irm : fallback;
|
||||
const activeBridge = irmBridge.installed ? irmBridge : fallbackBridge;
|
||||
|
||||
return {
|
||||
pluginId,
|
||||
loading,
|
||||
installed: activeBridge.installed,
|
||||
error: activeBridge.error,
|
||||
settings: activeBridge.settings,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { type DefaultBodyType, HttpResponse, HttpResponseResolver, PathParams, http } from 'msw';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
import server from '@grafana/test-utils/server';
|
||||
import { mockDataSource, mockFolder } from 'app/features/alerting/unified/mocks';
|
||||
import {
|
||||
@@ -10,10 +9,7 @@ import {
|
||||
} from 'app/features/alerting/unified/mocks/server/handlers/alertmanagers';
|
||||
import { getFolderHandler } from 'app/features/alerting/unified/mocks/server/handlers/folders';
|
||||
import { listNamespacedTimeIntervalHandler } from 'app/features/alerting/unified/mocks/server/handlers/k8s/timeIntervals.k8s';
|
||||
import {
|
||||
getDisabledPluginHandler,
|
||||
getPluginMissingHandler,
|
||||
} from 'app/features/alerting/unified/mocks/server/handlers/plugins';
|
||||
import { getDisabledPluginHandler } from 'app/features/alerting/unified/mocks/server/handlers/plugins';
|
||||
import {
|
||||
ALERTING_API_SERVER_BASE_URL,
|
||||
getK8sResponse,
|
||||
@@ -212,12 +208,6 @@ export function setGrafanaPromRules(groups: GrafanaPromRuleGroupDTO[]) {
|
||||
server.use(http.get(`/api/prometheus/grafana/api/v1/rules`, paginatedHandlerFor(groups)));
|
||||
}
|
||||
|
||||
/** Make a given plugin ID respond with a 404, as if it isn't installed at all */
|
||||
export const removePlugin = (pluginId: string) => {
|
||||
delete config.apps[pluginId];
|
||||
server.use(getPluginMissingHandler(pluginId));
|
||||
};
|
||||
|
||||
/** Make a plugin respond with `enabled: false`, as if its installed but disabled */
|
||||
export const disablePlugin = (pluginId: SupportedPlugin) => {
|
||||
clearPluginSettingsCache(pluginId);
|
||||
|
||||
@@ -455,6 +455,25 @@ describe('grafana-managed rules', () => {
|
||||
expect(frontendFilter.ruleMatches(regularRule)).toBe(true);
|
||||
expect(frontendFilter.ruleMatches(pluginRule)).toBe(true);
|
||||
});
|
||||
|
||||
it('should include searchFolder in backend filter when namespace is provided', () => {
|
||||
const { backendFilter } = getGrafanaFilter(getFilter({ namespace: 'my-folder' }));
|
||||
|
||||
expect(backendFilter.searchFolder).toBe('my-folder');
|
||||
});
|
||||
|
||||
it('should skip namespace filtering on frontend when backend filtering is enabled', () => {
|
||||
const group: PromRuleGroupDTO = {
|
||||
name: 'Test Group',
|
||||
file: 'production/alerts',
|
||||
rules: [],
|
||||
interval: 60,
|
||||
};
|
||||
|
||||
const { frontendFilter } = getGrafanaFilter(getFilter({ namespace: 'staging' }));
|
||||
// Should return true because namespace filter is null (handled by backend)
|
||||
expect(frontendFilter.groupMatches(group)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when alertingUIUseBackendFilters is disabled', () => {
|
||||
@@ -537,6 +556,12 @@ describe('grafana-managed rules', () => {
|
||||
expect(backendFilter.searchGroupName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not include searchFolder in backend filter', () => {
|
||||
const { backendFilter } = getGrafanaFilter(getFilter({ namespace: 'my-folder' }));
|
||||
|
||||
expect(backendFilter.searchFolder).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should perform groupName filtering on frontend', () => {
|
||||
const group: PromRuleGroupDTO = {
|
||||
name: 'CPU Usage Alerts',
|
||||
@@ -706,8 +731,8 @@ describe('grafana-managed rules', () => {
|
||||
expect(frontendFilter.groupMatches(group)).toBe(true);
|
||||
});
|
||||
|
||||
it('should still apply always-frontend filters (namespace)', () => {
|
||||
// Namespace filter should still work
|
||||
it('should skip namespace filtering on frontend', () => {
|
||||
// Namespace filter should be handled by backend
|
||||
const group: PromRuleGroupDTO = {
|
||||
name: 'Test Group',
|
||||
file: 'production/alerts',
|
||||
@@ -719,7 +744,7 @@ describe('grafana-managed rules', () => {
|
||||
expect(nsFilter.groupMatches(group)).toBe(true);
|
||||
|
||||
const { frontendFilter: nsFilter2 } = getGrafanaFilter(getFilter({ namespace: 'staging' }));
|
||||
expect(nsFilter2.groupMatches(group)).toBe(false);
|
||||
expect(nsFilter2.groupMatches(group)).toBe(true);
|
||||
});
|
||||
|
||||
it('should skip dataSourceNames filtering on frontend (handled by backend)', () => {
|
||||
@@ -807,8 +832,8 @@ describe('grafana-managed rules', () => {
|
||||
expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for client-side only filters', () => {
|
||||
expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true);
|
||||
it('should return false for namespace filter (handled by backend)', () => {
|
||||
expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for plugins filter (handled by backend when feature toggle is enabled)', () => {
|
||||
@@ -862,8 +887,8 @@ describe('grafana-managed rules', () => {
|
||||
expect(hasGrafanaClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false);
|
||||
expect(hasGrafanaClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false);
|
||||
|
||||
// Should return true for: always-frontend filters only (namespace)
|
||||
expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true);
|
||||
// Should return false for: namespace (handled by backend)
|
||||
expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(false);
|
||||
|
||||
// plugins is backend-handled when both feature toggles are enabled
|
||||
expect(hasGrafanaClientSideFilters(getFilter({ plugins: 'hide' }))).toBe(false);
|
||||
|
||||
@@ -96,6 +96,7 @@ export function getGrafanaFilter(filterState: Partial<RulesFilter>) {
|
||||
datasources: ruleFilterConfig.dataSourceNames ? undefined : datasourceUids,
|
||||
ruleMatchers: ruleMatchersBackendFilter,
|
||||
plugins: ruleFilterConfig.plugins ? undefined : normalizedFilterState.plugins,
|
||||
searchFolder: groupFilterConfig.namespace ? undefined : normalizedFilterState.namespace,
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -134,7 +135,7 @@ function buildGrafanaFilterConfigs() {
|
||||
};
|
||||
|
||||
const groupFilterConfig: GroupFilterConfig = {
|
||||
namespace: namespaceFilter,
|
||||
namespace: useBackendFilters ? null : namespaceFilter,
|
||||
groupName: useBackendFilters ? null : groupNameFilter,
|
||||
};
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ interface GrafanaPromApiFilter {
|
||||
contactPoint?: string;
|
||||
title?: string;
|
||||
searchGroupName?: string;
|
||||
searchFolder?: string;
|
||||
type?: 'alerting' | 'recording';
|
||||
dashboardUid?: string;
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ describe('paginationLimits', () => {
|
||||
{ contactPoint: 'slack' },
|
||||
{ dataSourceNames: ['prometheus'] },
|
||||
{ labels: ['severity=critical'] },
|
||||
{ namespace: 'production' },
|
||||
])(
|
||||
'should return rule limit for grafana + large limit for datasource when only backend filters are used: %p',
|
||||
(filterState) => {
|
||||
@@ -84,16 +85,6 @@ describe('paginationLimits', () => {
|
||||
expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
|
||||
}
|
||||
);
|
||||
|
||||
it.each<Partial<RulesFilter>>([
|
||||
{ namespace: 'production' },
|
||||
{ ruleState: PromAlertingRuleState.Firing, namespace: 'production' },
|
||||
])('should return large limits for both when frontend filters are used: %p', (filterState) => {
|
||||
const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState));
|
||||
|
||||
expect(grafanaManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
|
||||
expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
|
||||
});
|
||||
});
|
||||
|
||||
describe('when alertingUIUseFullyCompatBackendFilters is enabled', () => {
|
||||
@@ -158,6 +149,7 @@ describe('paginationLimits', () => {
|
||||
{ contactPoint: 'slack' },
|
||||
{ dataSourceNames: ['prometheus'] },
|
||||
{ labels: ['severity=critical'] },
|
||||
{ namespace: 'production' },
|
||||
])(
|
||||
'should return rule limit for grafana + large limit for datasource when only backend filters are used: %p',
|
||||
(filterState) => {
|
||||
@@ -167,16 +159,6 @@ describe('paginationLimits', () => {
|
||||
expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
|
||||
}
|
||||
);
|
||||
|
||||
it.each<Partial<RulesFilter>>([{ namespace: 'production' }])(
|
||||
'should return large limits for both when frontend filters are used: %p',
|
||||
(filterState) => {
|
||||
const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState));
|
||||
|
||||
expect(grafanaManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
|
||||
expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,6 +80,20 @@ export type CloudNotifierType =
|
||||
| 'jira';
|
||||
|
||||
export type NotifierType = GrafanaNotifierType | CloudNotifierType;
|
||||
|
||||
/**
|
||||
* Represents a specific version of a notifier integration
|
||||
* Used for integration versioning during Single Alert Manager migration
|
||||
*/
|
||||
export interface NotifierVersion {
|
||||
version: string;
|
||||
label: string;
|
||||
description: string;
|
||||
options: NotificationChannelOption[];
|
||||
/** Whether this version can be used to create new integrations */
|
||||
canCreate?: boolean;
|
||||
}
|
||||
|
||||
export interface NotifierDTO<T = NotifierType> {
|
||||
name: string;
|
||||
description: string;
|
||||
@@ -88,6 +102,23 @@ export interface NotifierDTO<T = NotifierType> {
|
||||
options: NotificationChannelOption[];
|
||||
info?: string;
|
||||
secure?: boolean;
|
||||
/**
|
||||
* Available versions for this notifier from the backend
|
||||
* Each version contains version-specific options and metadata
|
||||
*/
|
||||
versions?: NotifierVersion[];
|
||||
/**
|
||||
* The default version that the backend will use when creating new integrations.
|
||||
* Returned by the backend from /api/alert-notifiers?version=2
|
||||
*
|
||||
* - "v1" for most notifiers (modern Grafana version)
|
||||
* - "v0mimir1" for legacy-only notifiers (e.g., WeChat)
|
||||
*
|
||||
* Note: Currently not used in the frontend. The backend handles version
|
||||
* selection automatically. Could be used in the future to display
|
||||
* version information or validate notifier capabilities.
|
||||
*/
|
||||
currentVersion?: string;
|
||||
}
|
||||
|
||||
export interface NotificationChannelType {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ControlledField } from '../hooks/useControlledFieldArray';
|
||||
export interface ChannelValues {
|
||||
__id: string; // used to correlate form values to original DTOs
|
||||
type: string;
|
||||
version?: string; // Integration version (e.g. "v0" for Mimir legacy, "v1" for Grafana)
|
||||
settings: Record<string, any>;
|
||||
secureFields: Record<string, boolean | ''>;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { pluginMeta, pluginMetaToPluginConfig } from '../testSetup/plugins';
|
||||
import { SupportedPlugin } from '../types/pluginBridges';
|
||||
|
||||
import {
|
||||
checkEvaluationIntervalGlobalLimit,
|
||||
getIrmIfPresentOrIncidentPluginId,
|
||||
getIrmIfPresentOrOnCallPluginId,
|
||||
getIsIrmPluginPresent,
|
||||
} from './config';
|
||||
import { checkEvaluationIntervalGlobalLimit } from './config';
|
||||
|
||||
describe('checkEvaluationIntervalGlobalLimit', () => {
|
||||
it('should NOT exceed limit if evaluate every is not valid duration', () => {
|
||||
@@ -59,48 +51,3 @@ describe('checkEvaluationIntervalGlobalLimit', () => {
|
||||
expect(exceedsLimit).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIsIrmPluginPresent', () => {
|
||||
it('should return true when IRM plugin is present in config.apps', () => {
|
||||
config.apps = { [SupportedPlugin.Irm]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Irm]) };
|
||||
expect(getIsIrmPluginPresent()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when IRM plugin is not present in config.apps', () => {
|
||||
config.apps = {
|
||||
[SupportedPlugin.OnCall]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.OnCall]),
|
||||
[SupportedPlugin.Incident]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Incident]),
|
||||
};
|
||||
expect(getIsIrmPluginPresent()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIrmIfPresentOrIncidentPluginId', () => {
|
||||
it('should return IRM plugin ID when IRM plugin is present', () => {
|
||||
config.apps = { [SupportedPlugin.Irm]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Irm]) };
|
||||
expect(getIrmIfPresentOrIncidentPluginId()).toBe(SupportedPlugin.Irm);
|
||||
});
|
||||
|
||||
it('should return Incident plugin ID when IRM plugin is not present', () => {
|
||||
config.apps = {
|
||||
[SupportedPlugin.OnCall]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.OnCall]),
|
||||
[SupportedPlugin.Incident]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Incident]),
|
||||
};
|
||||
expect(getIrmIfPresentOrIncidentPluginId()).toBe(SupportedPlugin.Incident);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIrmIfPresentOrOnCallPluginId', () => {
|
||||
it('should return IRM plugin ID when IRM plugin is present', () => {
|
||||
config.apps = { [SupportedPlugin.Irm]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Irm]) };
|
||||
expect(getIrmIfPresentOrOnCallPluginId()).toBe(SupportedPlugin.Irm);
|
||||
});
|
||||
|
||||
it('should return OnCall plugin ID when IRM plugin is not present', () => {
|
||||
config.apps = {
|
||||
[SupportedPlugin.OnCall]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.OnCall]),
|
||||
[SupportedPlugin.Incident]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Incident]),
|
||||
};
|
||||
expect(getIrmIfPresentOrOnCallPluginId()).toBe(SupportedPlugin.OnCall);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { DataSourceInstanceSettings, DataSourceJsonData } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { SupportedPlugin } from '../types/pluginBridges';
|
||||
|
||||
import { isValidPrometheusDuration, safeParsePrometheusDuration } from './time';
|
||||
|
||||
export function getAllDataSources(): Array<DataSourceInstanceSettings<DataSourceJsonData>> {
|
||||
@@ -28,15 +26,3 @@ export function checkEvaluationIntervalGlobalLimit(alertGroupEvaluateEvery?: str
|
||||
|
||||
return { globalLimit: evaluateEveryGlobalLimitMs, exceedsLimit };
|
||||
}
|
||||
|
||||
export function getIsIrmPluginPresent() {
|
||||
return SupportedPlugin.Irm in config.apps;
|
||||
}
|
||||
|
||||
export function getIrmIfPresentOrIncidentPluginId() {
|
||||
return getIsIrmPluginPresent() ? SupportedPlugin.Irm : SupportedPlugin.Incident;
|
||||
}
|
||||
|
||||
export function getIrmIfPresentOrOnCallPluginId() {
|
||||
return getIsIrmPluginPresent() ? SupportedPlugin.Irm : SupportedPlugin.OnCall;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
import { GrafanaManagedContactPoint } from 'app/plugins/datasource/alertmanager/types';
|
||||
|
||||
import { NotificationChannelOption, NotifierDTO, NotifierVersion } from '../types/alerting';
|
||||
|
||||
import {
|
||||
canCreateNotifier,
|
||||
getLegacyVersionLabel,
|
||||
getOptionsForVersion,
|
||||
hasLegacyIntegrations,
|
||||
isLegacyVersion,
|
||||
} from './notifier-versions';
|
||||
|
||||
// Helper to create a minimal NotifierDTO for testing
|
||||
function createNotifier(overrides: Partial<NotifierDTO> = {}): NotifierDTO {
|
||||
return {
|
||||
name: 'Test Notifier',
|
||||
description: 'Test description',
|
||||
type: 'webhook',
|
||||
heading: 'Test heading',
|
||||
options: [
|
||||
{
|
||||
element: 'input',
|
||||
inputType: 'text',
|
||||
label: 'Default Option',
|
||||
description: 'Default option description',
|
||||
placeholder: '',
|
||||
propertyName: 'defaultOption',
|
||||
required: true,
|
||||
secure: false,
|
||||
showWhen: { field: '', is: '' },
|
||||
validationRule: '',
|
||||
dependsOn: '',
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to create a NotifierVersion for testing
|
||||
function createVersion(overrides: Partial<NotifierVersion> = {}): NotifierVersion {
|
||||
return {
|
||||
version: 'v1',
|
||||
label: 'Test Version',
|
||||
description: 'Test version description',
|
||||
options: [
|
||||
{
|
||||
element: 'input',
|
||||
inputType: 'text',
|
||||
label: 'Version Option',
|
||||
description: 'Version option description',
|
||||
placeholder: '',
|
||||
propertyName: 'versionOption',
|
||||
required: true,
|
||||
secure: false,
|
||||
showWhen: { field: '', is: '' },
|
||||
validationRule: '',
|
||||
dependsOn: '',
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('notifier-versions utilities', () => {
|
||||
describe('canCreateNotifier', () => {
|
||||
it('should return true if notifier has no versions array', () => {
|
||||
const notifier = createNotifier({ versions: undefined });
|
||||
expect(canCreateNotifier(notifier)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if notifier has empty versions array', () => {
|
||||
const notifier = createNotifier({ versions: [] });
|
||||
expect(canCreateNotifier(notifier)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if at least one version has canCreate: true', () => {
|
||||
const notifier = createNotifier({
|
||||
versions: [
|
||||
createVersion({ version: 'v0mimir1', canCreate: false }),
|
||||
createVersion({ version: 'v1', canCreate: true }),
|
||||
],
|
||||
});
|
||||
expect(canCreateNotifier(notifier)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if at least one version has canCreate: undefined (defaults to true)', () => {
|
||||
const notifier = createNotifier({
|
||||
versions: [
|
||||
createVersion({ version: 'v0mimir1', canCreate: false }),
|
||||
createVersion({ version: 'v1', canCreate: undefined }),
|
||||
],
|
||||
});
|
||||
expect(canCreateNotifier(notifier)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if all versions have canCreate: false', () => {
|
||||
const notifier = createNotifier({
|
||||
versions: [
|
||||
createVersion({ version: 'v0mimir1', canCreate: false }),
|
||||
createVersion({ version: 'v0mimir2', canCreate: false }),
|
||||
],
|
||||
});
|
||||
expect(canCreateNotifier(notifier)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for notifiers like WeChat that only have legacy versions', () => {
|
||||
const wechatNotifier = createNotifier({
|
||||
name: 'WeChat',
|
||||
type: 'wechat',
|
||||
versions: [createVersion({ version: 'v0mimir1', canCreate: false })],
|
||||
});
|
||||
expect(canCreateNotifier(wechatNotifier)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLegacyVersion', () => {
|
||||
it('should return false if no version is specified', () => {
|
||||
const notifier = createNotifier({
|
||||
versions: [createVersion({ version: 'v0mimir1', canCreate: false })],
|
||||
});
|
||||
expect(isLegacyVersion(notifier, undefined)).toBe(false);
|
||||
expect(isLegacyVersion(notifier, '')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if notifier has no versions array', () => {
|
||||
const notifier = createNotifier({ versions: undefined });
|
||||
expect(isLegacyVersion(notifier, 'v0mimir1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if notifier has empty versions array', () => {
|
||||
const notifier = createNotifier({ versions: [] });
|
||||
expect(isLegacyVersion(notifier, 'v0mimir1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if version is not found in versions array', () => {
|
||||
const notifier = createNotifier({
|
||||
versions: [createVersion({ version: 'v1', canCreate: true })],
|
||||
});
|
||||
expect(isLegacyVersion(notifier, 'v0mimir1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if version has canCreate: true', () => {
|
||||
const notifier = createNotifier({
|
||||
versions: [createVersion({ version: 'v1', canCreate: true })],
|
||||
});
|
||||
expect(isLegacyVersion(notifier, 'v1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if version has canCreate: undefined', () => {
|
||||
const notifier = createNotifier({
|
||||
versions: [createVersion({ version: 'v1', canCreate: undefined })],
|
||||
});
|
||||
expect(isLegacyVersion(notifier, 'v1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true if version has canCreate: false', () => {
|
||||
const notifier = createNotifier({
|
||||
versions: [
|
||||
createVersion({ version: 'v0mimir1', canCreate: false }),
|
||||
createVersion({ version: 'v1', canCreate: true }),
|
||||
],
|
||||
});
|
||||
expect(isLegacyVersion(notifier, 'v0mimir1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should correctly identify legacy versions in a mixed notifier', () => {
|
||||
const notifier = createNotifier({
|
||||
versions: [
|
||||
createVersion({ version: 'v0mimir1', canCreate: false }),
|
||||
createVersion({ version: 'v0mimir2', canCreate: false }),
|
||||
createVersion({ version: 'v1', canCreate: true }),
|
||||
],
|
||||
});
|
||||
expect(isLegacyVersion(notifier, 'v0mimir1')).toBe(true);
|
||||
expect(isLegacyVersion(notifier, 'v0mimir2')).toBe(true);
|
||||
expect(isLegacyVersion(notifier, 'v1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOptionsForVersion', () => {
|
||||
const defaultOptions: NotificationChannelOption[] = [
|
||||
{
|
||||
element: 'input',
|
||||
inputType: 'text',
|
||||
label: 'Default URL',
|
||||
description: 'Default URL description',
|
||||
placeholder: '',
|
||||
propertyName: 'url',
|
||||
required: true,
|
||||
secure: false,
|
||||
showWhen: { field: '', is: '' },
|
||||
validationRule: '',
|
||||
dependsOn: '',
|
||||
},
|
||||
];
|
||||
|
||||
const v0Options: NotificationChannelOption[] = [
|
||||
{
|
||||
element: 'input',
|
||||
inputType: 'text',
|
||||
label: 'Legacy URL',
|
||||
description: 'Legacy URL description',
|
||||
placeholder: '',
|
||||
propertyName: 'legacyUrl',
|
||||
required: true,
|
||||
secure: false,
|
||||
showWhen: { field: '', is: '' },
|
||||
validationRule: '',
|
||||
dependsOn: '',
|
||||
},
|
||||
];
|
||||
|
||||
const v1Options: NotificationChannelOption[] = [
|
||||
{
|
||||
element: 'input',
|
||||
inputType: 'text',
|
||||
label: 'Modern URL',
|
||||
description: 'Modern URL description',
|
||||
placeholder: '',
|
||||
propertyName: 'modernUrl',
|
||||
required: true,
|
||||
secure: false,
|
||||
showWhen: { field: '', is: '' },
|
||||
validationRule: '',
|
||||
dependsOn: '',
|
||||
},
|
||||
];
|
||||
|
||||
it('should return options from default creatable version if no version is specified', () => {
|
||||
const notifier = createNotifier({
|
||||
options: defaultOptions,
|
||||
versions: [createVersion({ version: 'v1', options: v1Options, canCreate: true })],
|
||||
});
|
||||
// When no version specified, should use options from the default creatable version
|
||||
expect(getOptionsForVersion(notifier, undefined)).toBe(v1Options);
|
||||
});
|
||||
|
||||
it('should return default options if no version is specified and empty string is passed', () => {
|
||||
const notifier = createNotifier({
|
||||
options: defaultOptions,
|
||||
versions: [createVersion({ version: 'v1', options: v1Options, canCreate: true })],
|
||||
});
|
||||
// Empty string is still a falsy version, so should use default creatable version
|
||||
expect(getOptionsForVersion(notifier, '')).toBe(v1Options);
|
||||
});
|
||||
|
||||
it('should return default options if notifier has no versions array', () => {
|
||||
const notifier = createNotifier({
|
||||
options: defaultOptions,
|
||||
versions: undefined,
|
||||
});
|
||||
expect(getOptionsForVersion(notifier, 'v1')).toBe(defaultOptions);
|
||||
});
|
||||
|
||||
it('should return default options if notifier has empty versions array', () => {
|
||||
const notifier = createNotifier({
|
||||
options: defaultOptions,
|
||||
versions: [],
|
||||
});
|
||||
expect(getOptionsForVersion(notifier, 'v1')).toBe(defaultOptions);
|
||||
});
|
||||
|
||||
it('should return default options if version is not found', () => {
|
||||
const notifier = createNotifier({
|
||||
options: defaultOptions,
|
||||
versions: [createVersion({ version: 'v1', options: v1Options })],
|
||||
});
|
||||
expect(getOptionsForVersion(notifier, 'v0mimir1')).toBe(defaultOptions);
|
||||
});
|
||||
|
||||
it('should return version-specific options when version is found', () => {
|
||||
const notifier = createNotifier({
|
||||
options: defaultOptions,
|
||||
versions: [
|
||||
createVersion({ version: 'v0mimir1', options: v0Options }),
|
||||
createVersion({ version: 'v1', options: v1Options }),
|
||||
],
|
||||
});
|
||||
expect(getOptionsForVersion(notifier, 'v0mimir1')).toBe(v0Options);
|
||||
expect(getOptionsForVersion(notifier, 'v1')).toBe(v1Options);
|
||||
});
|
||||
|
||||
it('should return default options if version found but has no options', () => {
|
||||
const notifier = createNotifier({
|
||||
options: defaultOptions,
|
||||
versions: [
|
||||
{
|
||||
version: 'v1',
|
||||
label: 'V1',
|
||||
description: 'V1 description',
|
||||
options: undefined as unknown as NotificationChannelOption[],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(getOptionsForVersion(notifier, 'v1')).toBe(defaultOptions);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasLegacyIntegrations', () => {
|
||||
// Helper to create a minimal contact point for testing
|
||||
function createContactPoint(overrides: Partial<GrafanaManagedContactPoint> = {}): GrafanaManagedContactPoint {
|
||||
return {
|
||||
name: 'Test Contact Point',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Create notifiers with version info for testing
|
||||
const notifiersWithVersions: NotifierDTO[] = [
|
||||
createNotifier({
|
||||
type: 'slack',
|
||||
versions: [
|
||||
createVersion({ version: 'v0mimir1', canCreate: false }),
|
||||
createVersion({ version: 'v1', canCreate: true }),
|
||||
],
|
||||
}),
|
||||
createNotifier({
|
||||
type: 'webhook',
|
||||
versions: [
|
||||
createVersion({ version: 'v0mimir1', canCreate: false }),
|
||||
createVersion({ version: 'v0mimir2', canCreate: false }),
|
||||
createVersion({ version: 'v1', canCreate: true }),
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
it('should return false if contact point is undefined', () => {
|
||||
expect(hasLegacyIntegrations(undefined, notifiersWithVersions)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if notifiers is undefined', () => {
|
||||
const contactPoint = createContactPoint({
|
||||
grafana_managed_receiver_configs: [{ type: 'slack', settings: {}, version: 'v0mimir1' }],
|
||||
});
|
||||
expect(hasLegacyIntegrations(contactPoint, undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if contact point has no integrations', () => {
|
||||
const contactPoint = createContactPoint({ grafana_managed_receiver_configs: undefined });
|
||||
expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if contact point has empty integrations array', () => {
|
||||
const contactPoint = createContactPoint({ grafana_managed_receiver_configs: [] });
|
||||
expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if all integrations have v1 version (canCreate: true)', () => {
|
||||
const contactPoint = createContactPoint({
|
||||
grafana_managed_receiver_configs: [
|
||||
{ type: 'slack', settings: {}, version: 'v1' },
|
||||
{ type: 'webhook', settings: {}, version: 'v1' },
|
||||
],
|
||||
});
|
||||
expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if all integrations have no version', () => {
|
||||
const contactPoint = createContactPoint({
|
||||
grafana_managed_receiver_configs: [
|
||||
{ type: 'slack', settings: {} },
|
||||
{ type: 'webhook', settings: {} },
|
||||
],
|
||||
});
|
||||
expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true if any integration has a legacy version (canCreate: false)', () => {
|
||||
const contactPoint = createContactPoint({
|
||||
grafana_managed_receiver_configs: [
|
||||
{ type: 'slack', settings: {}, version: 'v0mimir1' },
|
||||
{ type: 'webhook', settings: {}, version: 'v1' },
|
||||
],
|
||||
});
|
||||
expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if all integrations have legacy versions', () => {
|
||||
const contactPoint = createContactPoint({
|
||||
grafana_managed_receiver_configs: [
|
||||
{ type: 'slack', settings: {}, version: 'v0mimir1' },
|
||||
{ type: 'webhook', settings: {}, version: 'v0mimir2' },
|
||||
],
|
||||
});
|
||||
expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if notifier type is not found in notifiers array', () => {
|
||||
const contactPoint = createContactPoint({
|
||||
grafana_managed_receiver_configs: [{ type: 'unknown', settings: {}, version: 'v0mimir1' }],
|
||||
});
|
||||
expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLegacyVersionLabel', () => {
|
||||
it('should return "Legacy" for undefined version', () => {
|
||||
expect(getLegacyVersionLabel(undefined)).toBe('Legacy');
|
||||
});
|
||||
|
||||
it('should return "Legacy" for empty string version', () => {
|
||||
expect(getLegacyVersionLabel('')).toBe('Legacy');
|
||||
});
|
||||
|
||||
it('should return "Legacy" for v0mimir1', () => {
|
||||
expect(getLegacyVersionLabel('v0mimir1')).toBe('Legacy');
|
||||
});
|
||||
|
||||
it('should return "Legacy v2" for v0mimir2', () => {
|
||||
expect(getLegacyVersionLabel('v0mimir2')).toBe('Legacy v2');
|
||||
});
|
||||
|
||||
it('should return "Legacy v3" for v0mimir3', () => {
|
||||
expect(getLegacyVersionLabel('v0mimir3')).toBe('Legacy v3');
|
||||
});
|
||||
|
||||
it('should return "Legacy" for v1 (trailing 1)', () => {
|
||||
expect(getLegacyVersionLabel('v1')).toBe('Legacy');
|
||||
});
|
||||
|
||||
it('should return "Legacy v2" for v2 (trailing 2)', () => {
|
||||
expect(getLegacyVersionLabel('v2')).toBe('Legacy v2');
|
||||
});
|
||||
|
||||
it('should return "Legacy" for version strings without trailing number', () => {
|
||||
expect(getLegacyVersionLabel('legacy')).toBe('Legacy');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Utilities for integration versioning
|
||||
*
|
||||
* These utilities help get version-specific options from the backend response
|
||||
* (via /api/alert-notifiers?version=2)
|
||||
*/
|
||||
|
||||
import { GrafanaManagedContactPoint } from 'app/plugins/datasource/alertmanager/types';
|
||||
|
||||
import { NotificationChannelOption, NotifierDTO } from '../types/alerting';
|
||||
|
||||
/**
|
||||
* Checks if a notifier can be used to create new integrations.
|
||||
* A notifier can be created if it has at least one version with canCreate: true,
|
||||
* or if it has no versions array (legacy behavior).
|
||||
*
|
||||
* @param notifier - The notifier DTO to check
|
||||
* @returns True if the notifier can be used to create new integrations
|
||||
*/
|
||||
export function canCreateNotifier(notifier: NotifierDTO): boolean {
|
||||
// If no versions array, assume it can be created (legacy behavior)
|
||||
if (!notifier.versions || notifier.versions.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if any version has canCreate: true (or undefined, which defaults to true)
|
||||
return notifier.versions.some((v) => v.canCreate !== false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a specific version is legacy (cannot be created).
|
||||
* A version is legacy if it has canCreate: false in the notifier's versions array.
|
||||
*
|
||||
* @param notifier - The notifier DTO containing versions array
|
||||
* @param version - The version string to check (e.g., 'v0mimir1', 'v1')
|
||||
* @returns True if the version is legacy (canCreate: false)
|
||||
*/
|
||||
export function isLegacyVersion(notifier: NotifierDTO, version?: string): boolean {
|
||||
// If no version specified or no versions array, it's not legacy
|
||||
if (!version || !notifier.versions || notifier.versions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find the matching version and check its canCreate property
|
||||
const versionData = notifier.versions.find((v) => v.version === version);
|
||||
|
||||
// A version is legacy if canCreate is explicitly false
|
||||
return versionData?.canCreate === false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the options for a specific version of a notifier.
|
||||
* Used to display the correct form fields based on integration version.
|
||||
*
|
||||
* @param notifier - The notifier DTO containing versions array
|
||||
* @param version - The version to get options for (e.g., 'v0', 'v1')
|
||||
* @returns The options for the specified version, or default options if version not found
|
||||
*/
|
||||
export function getOptionsForVersion(notifier: NotifierDTO, version?: string): NotificationChannelOption[] {
|
||||
// If no versions array, use default options
|
||||
if (!notifier.versions || notifier.versions.length === 0) {
|
||||
return notifier.options;
|
||||
}
|
||||
|
||||
// If version is specified, find the matching version
|
||||
if (version) {
|
||||
const versionData = notifier.versions.find((v) => v.version === version);
|
||||
// Return version-specific options if found, otherwise fall back to default
|
||||
return versionData?.options ?? notifier.options;
|
||||
}
|
||||
|
||||
// If no version specified, find the default creatable version (canCreate !== false)
|
||||
const defaultVersion = notifier.versions.find((v) => v.canCreate !== false);
|
||||
return defaultVersion?.options ?? notifier.options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a contact point has any legacy (imported) integrations.
|
||||
* A contact point has legacy integrations if any of its integrations uses a version
|
||||
* with canCreate: false in the corresponding notifier's versions array.
|
||||
*
|
||||
* @param contactPoint - The contact point to check
|
||||
* @param notifiers - Array of notifier DTOs to look up version info
|
||||
* @returns True if the contact point has at least one legacy/imported integration
|
||||
*/
|
||||
export function hasLegacyIntegrations(contactPoint?: GrafanaManagedContactPoint, notifiers?: NotifierDTO[]): boolean {
|
||||
if (!contactPoint?.grafana_managed_receiver_configs || !notifiers) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return contactPoint.grafana_managed_receiver_configs.some((config) => {
|
||||
const notifier = notifiers.find((n) => n.type === config.type);
|
||||
return notifier ? isLegacyVersion(notifier, config.version) : false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a user-friendly label for a legacy version.
|
||||
* Extracts the version number from the version string and formats it as:
|
||||
* - "Legacy" for version 1 (e.g., v0mimir1)
|
||||
* - "Legacy v2" for version 2 (e.g., v0mimir2)
|
||||
* - etc.
|
||||
*
|
||||
* Precondition: This function assumes the version is already known to be legacy
|
||||
* (i.e., canCreate: false). Use isLegacyVersion() to check before calling this.
|
||||
*
|
||||
* @param version - The version string (e.g., 'v0mimir1', 'v0mimir2')
|
||||
* @returns A user-friendly label like "Legacy" or "Legacy v2"
|
||||
*/
|
||||
export function getLegacyVersionLabel(version?: string): string {
|
||||
if (!version) {
|
||||
return 'Legacy';
|
||||
}
|
||||
|
||||
// Extract trailing number from version string (e.g., v0mimir1 → 1, v0mimir2 → 2)
|
||||
const match = version.match(/(\d+)$/);
|
||||
if (match) {
|
||||
const num = parseInt(match[1], 10);
|
||||
if (num === 1) {
|
||||
return 'Legacy';
|
||||
}
|
||||
return `Legacy v${num}`;
|
||||
}
|
||||
|
||||
return 'Legacy';
|
||||
}
|
||||
@@ -185,6 +185,7 @@ function grafanaChannelConfigToFormChannelValues(
|
||||
const values: GrafanaChannelValues = {
|
||||
__id: id,
|
||||
type: channel.type as NotifierType,
|
||||
version: channel.version,
|
||||
provenance: channel.provenance,
|
||||
settings: { ...channel.settings },
|
||||
secureFields: { ...channel.secureFields },
|
||||
@@ -239,6 +240,7 @@ export function formChannelValuesToGrafanaChannelConfig(
|
||||
}),
|
||||
secureFields: secureFieldsFromValues,
|
||||
type: values.type,
|
||||
version: values.version ?? existing?.version,
|
||||
name,
|
||||
disableResolveMessage:
|
||||
values.disableResolveMessage ?? existing?.disableResolveMessage ?? defaults.disableResolveMessage,
|
||||
|
||||
@@ -1108,12 +1108,7 @@ export class ElementState implements LayerElement {
|
||||
tabIndex={0}
|
||||
style={{ userSelect: 'none' }}
|
||||
>
|
||||
<item.display
|
||||
key={`${this.UID}/${this.revId}`}
|
||||
config={this.options.config}
|
||||
data={this.data}
|
||||
isSelected={isSelected}
|
||||
/>
|
||||
<item.display key={this.UID} config={this.options.config} data={this.data} isSelected={isSelected} />
|
||||
</div>
|
||||
{this.showActionConfirmation && this.renderActionsConfirmModal(this.getPrimaryAction())}
|
||||
{this.showActionVarsModal && this.renderVariablesInputModal(this.getPrimaryAction())}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { incidentsApi } from 'app/features/alerting/unified/api/incidentsApi';
|
||||
import { usePluginBridge } from 'app/features/alerting/unified/hooks/usePluginBridge';
|
||||
import { getIrmIfPresentOrIncidentPluginId } from 'app/features/alerting/unified/utils/config';
|
||||
import { useIrmPlugin } from 'app/features/alerting/unified/hooks/usePluginBridge';
|
||||
import { SupportedPlugin } from 'app/features/alerting/unified/types/pluginBridges';
|
||||
|
||||
interface IncidentsPluginConfig {
|
||||
isInstalled: boolean;
|
||||
@@ -10,11 +10,13 @@ interface IncidentsPluginConfig {
|
||||
}
|
||||
|
||||
export function useGetIncidentPluginConfig(): IncidentsPluginConfig {
|
||||
const { installed: incidentPluginInstalled, loading: loadingPluginSettings } = usePluginBridge(
|
||||
getIrmIfPresentOrIncidentPluginId()
|
||||
);
|
||||
const {
|
||||
pluginId,
|
||||
installed: incidentPluginInstalled,
|
||||
loading: loadingPluginSettings,
|
||||
} = useIrmPlugin(SupportedPlugin.Incident);
|
||||
const { data: incidentsConfig, isLoading: loadingPluginConfig } =
|
||||
incidentsApi.endpoints.getIncidentsPluginConfig.useQuery();
|
||||
incidentsApi.endpoints.getIncidentsPluginConfig.useQuery({ pluginId });
|
||||
|
||||
return {
|
||||
isInstalled: incidentPluginInstalled ?? false,
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
import { onCallApi } from 'app/features/alerting/unified/api/onCallApi';
|
||||
import { usePluginBridge } from 'app/features/alerting/unified/hooks/usePluginBridge';
|
||||
import { getIrmIfPresentOrOnCallPluginId } from 'app/features/alerting/unified/utils/config';
|
||||
import { useIrmPlugin } from 'app/features/alerting/unified/hooks/usePluginBridge';
|
||||
import { SupportedPlugin } from 'app/features/alerting/unified/types/pluginBridges';
|
||||
|
||||
export function useGetOnCallIntegrations() {
|
||||
const { installed: onCallPluginInstalled } = usePluginBridge(getIrmIfPresentOrOnCallPluginId());
|
||||
const { pluginId, installed: onCallPluginInstalled } = useIrmPlugin(SupportedPlugin.OnCall);
|
||||
|
||||
const { data: onCallIntegrations } = onCallApi.endpoints.grafanaOnCallIntegrations.useQuery(undefined, {
|
||||
skip: !onCallPluginInstalled,
|
||||
refetchOnFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchOnMountOrArgChange: true,
|
||||
});
|
||||
const { data: onCallIntegrations } = onCallApi.endpoints.grafanaOnCallIntegrations.useQuery(
|
||||
{ pluginId },
|
||||
{
|
||||
skip: !onCallPluginInstalled,
|
||||
refetchOnFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchOnMountOrArgChange: true,
|
||||
}
|
||||
);
|
||||
|
||||
return onCallIntegrations ?? [];
|
||||
}
|
||||
|
||||
function useGetOnCallConfigurationChecks() {
|
||||
const { data: onCallConfigChecks, isLoading } = onCallApi.endpoints.onCallConfigChecks.useQuery(undefined, {
|
||||
refetchOnFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchOnMountOrArgChange: true,
|
||||
});
|
||||
const { pluginId } = useIrmPlugin(SupportedPlugin.OnCall);
|
||||
|
||||
const { data: onCallConfigChecks, isLoading } = onCallApi.endpoints.onCallConfigChecks.useQuery(
|
||||
{ pluginId },
|
||||
{
|
||||
refetchOnFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchOnMountOrArgChange: true,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { FeatureToggles } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { RepositoryViewList } from 'app/api/clients/provisioning/v0alpha1';
|
||||
|
||||
export const requiredFeatureToggles: Array<keyof FeatureToggles> = ['provisioning', 'kubernetesDashboards'];
|
||||
export const requiredFeatureToggles: Array<keyof FeatureToggles> = ['kubernetesDashboards'];
|
||||
|
||||
/**
|
||||
* Checks if all required feature toggles are enabled
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { config } from '@grafana/runtime';
|
||||
import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport';
|
||||
import { RouteDescriptor } from 'app/core/navigation/types';
|
||||
import { DashboardRoutes } from 'app/types/dashboard';
|
||||
@@ -6,6 +7,11 @@ import { checkRequiredFeatures } from '../GettingStarted/features';
|
||||
import { CONNECTIONS_URL, CONNECT_URL, GETTING_STARTED_URL, PROVISIONING_URL } from '../constants';
|
||||
|
||||
export function getProvisioningRoutes(): RouteDescriptor[] {
|
||||
const featureToggles = config.featureToggles || {};
|
||||
if (!featureToggles.provisioning) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!checkRequiredFeatures()) {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -85,6 +85,10 @@ export type GrafanaManagedReceiverConfig = {
|
||||
// SecureSettings?: GrafanaManagedReceiverConfigSettings<boolean>;
|
||||
settings: GrafanaManagedReceiverConfigSettings;
|
||||
type: string;
|
||||
/**
|
||||
* Version of the integration (e.g. "v0" for Mimir legacy, "v1" for Grafana)
|
||||
*/
|
||||
version?: string;
|
||||
/**
|
||||
* Name of the _receiver_, which in most cases will be the
|
||||
* same as the contact point's name. This should not be used, and is optional because the
|
||||
|
||||
@@ -11906,7 +11906,53 @@
|
||||
"free-tier-limit-tooltip": "",
|
||||
"instance-fully-managed-tooltip": ""
|
||||
},
|
||||
"connection-form": {
|
||||
"alert-connection-deleted": "",
|
||||
"alert-connection-saved": "",
|
||||
"alert-connection-updated": "",
|
||||
"back-to-connections": "",
|
||||
"button-save": "",
|
||||
"button-saving": "",
|
||||
"description-app-id": "",
|
||||
"description-installation-id": "",
|
||||
"description-private-key": "",
|
||||
"description-provider": "",
|
||||
"error-delete-connection": "",
|
||||
"error-required": "",
|
||||
"error-save-connection": "",
|
||||
"label-app-id": "",
|
||||
"label-installation-id": "",
|
||||
"label-private-key": "",
|
||||
"label-provider": "",
|
||||
"not-found": "",
|
||||
"not-found-description": "",
|
||||
"page-subtitle": "",
|
||||
"page-title-create": "",
|
||||
"page-title-edit": "",
|
||||
"placeholder-app-id": "",
|
||||
"placeholder-installation-id": "",
|
||||
"placeholder-private-key": ""
|
||||
},
|
||||
"connections": {
|
||||
"add-connection": "",
|
||||
"cancel": "",
|
||||
"delete": "",
|
||||
"delete-confirm": "",
|
||||
"delete-title": "",
|
||||
"error-loading": "",
|
||||
"no-connections": "",
|
||||
"no-connections-message": "",
|
||||
"no-results": "",
|
||||
"page-subtitle": "",
|
||||
"page-title": "",
|
||||
"search-placeholder": "",
|
||||
"status-connected": "",
|
||||
"status-disconnected": "",
|
||||
"status-unknown": "",
|
||||
"view": ""
|
||||
},
|
||||
"delete-repository-button": {
|
||||
"button-cancel": "",
|
||||
"button-delete": "Odstranit",
|
||||
"confirm-delete-keep-resources": "Opravdu chcete odstranit konfiguraci úložiště, ale ponechat jeho zdroje?",
|
||||
"confirm-delete-with-resources": "Opravdu chcete odstranit konfiguraci úložiště a všechny jeho zdroje?",
|
||||
@@ -12174,6 +12220,7 @@
|
||||
"jobs": "Práce"
|
||||
},
|
||||
"repository-actions": {
|
||||
"connections": "",
|
||||
"settings": "Nastavení",
|
||||
"source-code": "Zdrojový kód"
|
||||
},
|
||||
|
||||
@@ -11806,7 +11806,53 @@
|
||||
"free-tier-limit-tooltip": "",
|
||||
"instance-fully-managed-tooltip": ""
|
||||
},
|
||||
"connection-form": {
|
||||
"alert-connection-deleted": "",
|
||||
"alert-connection-saved": "",
|
||||
"alert-connection-updated": "",
|
||||
"back-to-connections": "",
|
||||
"button-save": "",
|
||||
"button-saving": "",
|
||||
"description-app-id": "",
|
||||
"description-installation-id": "",
|
||||
"description-private-key": "",
|
||||
"description-provider": "",
|
||||
"error-delete-connection": "",
|
||||
"error-required": "",
|
||||
"error-save-connection": "",
|
||||
"label-app-id": "",
|
||||
"label-installation-id": "",
|
||||
"label-private-key": "",
|
||||
"label-provider": "",
|
||||
"not-found": "",
|
||||
"not-found-description": "",
|
||||
"page-subtitle": "",
|
||||
"page-title-create": "",
|
||||
"page-title-edit": "",
|
||||
"placeholder-app-id": "",
|
||||
"placeholder-installation-id": "",
|
||||
"placeholder-private-key": ""
|
||||
},
|
||||
"connections": {
|
||||
"add-connection": "",
|
||||
"cancel": "",
|
||||
"delete": "",
|
||||
"delete-confirm": "",
|
||||
"delete-title": "",
|
||||
"error-loading": "",
|
||||
"no-connections": "",
|
||||
"no-connections-message": "",
|
||||
"no-results": "",
|
||||
"page-subtitle": "",
|
||||
"page-title": "",
|
||||
"search-placeholder": "",
|
||||
"status-connected": "",
|
||||
"status-disconnected": "",
|
||||
"status-unknown": "",
|
||||
"view": ""
|
||||
},
|
||||
"delete-repository-button": {
|
||||
"button-cancel": "",
|
||||
"button-delete": "Löschen",
|
||||
"confirm-delete-keep-resources": "Sind Sie sicher, dass Sie die Repository-Konfiguration löschen, aber ihre Ressourcen behalten möchten?",
|
||||
"confirm-delete-with-resources": "Sind Sie sicher, dass Sie die Repository-Konfiguration und alle ihre Ressourcen löschen möchten?",
|
||||
@@ -12070,6 +12116,7 @@
|
||||
"jobs": "Aufträge"
|
||||
},
|
||||
"repository-actions": {
|
||||
"connections": "",
|
||||
"settings": "Einstellungen",
|
||||
"source-code": "Quellcode"
|
||||
},
|
||||
|
||||
@@ -807,7 +807,8 @@
|
||||
"label-integration": "Integration",
|
||||
"label-notification-settings": "Notification settings",
|
||||
"label-section": "Optional {{name}} settings",
|
||||
"test": "Test"
|
||||
"test": "Test",
|
||||
"tooltip-legacy-version": "This is a legacy integration (version: {{version}}). It cannot be modified."
|
||||
},
|
||||
"classic-condition-viewer": {
|
||||
"of": "OF",
|
||||
@@ -2176,7 +2177,9 @@
|
||||
"provisioning": {
|
||||
"badge-tooltip-provenance": "This resource has been provisioned via {{provenance}} and cannot be edited through the UI",
|
||||
"badge-tooltip-standard": "This resource has been provisioned and cannot be edited through the UI",
|
||||
"body-imported": "This contact point contains integrations that were imported from an external Alertmanager and is currently read-only. The integrations will become editable after the migration process is complete.",
|
||||
"body-provisioned": "This {{resource}} has been provisioned, that means it was created by config. Please contact your server admin to update this {{resource}}.",
|
||||
"title-imported": "This contact point was imported and cannot be edited through the UI",
|
||||
"title-provisioned": "This {{resource}} cannot be edited through the UI"
|
||||
},
|
||||
"provisioning-badge": {
|
||||
|
||||
@@ -11806,7 +11806,53 @@
|
||||
"free-tier-limit-tooltip": "",
|
||||
"instance-fully-managed-tooltip": ""
|
||||
},
|
||||
"connection-form": {
|
||||
"alert-connection-deleted": "",
|
||||
"alert-connection-saved": "",
|
||||
"alert-connection-updated": "",
|
||||
"back-to-connections": "",
|
||||
"button-save": "",
|
||||
"button-saving": "",
|
||||
"description-app-id": "",
|
||||
"description-installation-id": "",
|
||||
"description-private-key": "",
|
||||
"description-provider": "",
|
||||
"error-delete-connection": "",
|
||||
"error-required": "",
|
||||
"error-save-connection": "",
|
||||
"label-app-id": "",
|
||||
"label-installation-id": "",
|
||||
"label-private-key": "",
|
||||
"label-provider": "",
|
||||
"not-found": "",
|
||||
"not-found-description": "",
|
||||
"page-subtitle": "",
|
||||
"page-title-create": "",
|
||||
"page-title-edit": "",
|
||||
"placeholder-app-id": "",
|
||||
"placeholder-installation-id": "",
|
||||
"placeholder-private-key": ""
|
||||
},
|
||||
"connections": {
|
||||
"add-connection": "",
|
||||
"cancel": "",
|
||||
"delete": "",
|
||||
"delete-confirm": "",
|
||||
"delete-title": "",
|
||||
"error-loading": "",
|
||||
"no-connections": "",
|
||||
"no-connections-message": "",
|
||||
"no-results": "",
|
||||
"page-subtitle": "",
|
||||
"page-title": "",
|
||||
"search-placeholder": "",
|
||||
"status-connected": "",
|
||||
"status-disconnected": "",
|
||||
"status-unknown": "",
|
||||
"view": ""
|
||||
},
|
||||
"delete-repository-button": {
|
||||
"button-cancel": "",
|
||||
"button-delete": "Eliminar",
|
||||
"confirm-delete-keep-resources": "¿Seguro que quieres eliminar la configuración del repositorio pero conservar sus recursos?",
|
||||
"confirm-delete-with-resources": "¿Seguro que quieres eliminar la configuración del repositorio y todos sus recursos?",
|
||||
@@ -12070,6 +12116,7 @@
|
||||
"jobs": "Trabajos"
|
||||
},
|
||||
"repository-actions": {
|
||||
"connections": "",
|
||||
"settings": "Configuración",
|
||||
"source-code": "Código fuente"
|
||||
},
|
||||
|
||||
@@ -11806,7 +11806,53 @@
|
||||
"free-tier-limit-tooltip": "",
|
||||
"instance-fully-managed-tooltip": ""
|
||||
},
|
||||
"connection-form": {
|
||||
"alert-connection-deleted": "",
|
||||
"alert-connection-saved": "",
|
||||
"alert-connection-updated": "",
|
||||
"back-to-connections": "",
|
||||
"button-save": "",
|
||||
"button-saving": "",
|
||||
"description-app-id": "",
|
||||
"description-installation-id": "",
|
||||
"description-private-key": "",
|
||||
"description-provider": "",
|
||||
"error-delete-connection": "",
|
||||
"error-required": "",
|
||||
"error-save-connection": "",
|
||||
"label-app-id": "",
|
||||
"label-installation-id": "",
|
||||
"label-private-key": "",
|
||||
"label-provider": "",
|
||||
"not-found": "",
|
||||
"not-found-description": "",
|
||||
"page-subtitle": "",
|
||||
"page-title-create": "",
|
||||
"page-title-edit": "",
|
||||
"placeholder-app-id": "",
|
||||
"placeholder-installation-id": "",
|
||||
"placeholder-private-key": ""
|
||||
},
|
||||
"connections": {
|
||||
"add-connection": "",
|
||||
"cancel": "",
|
||||
"delete": "",
|
||||
"delete-confirm": "",
|
||||
"delete-title": "",
|
||||
"error-loading": "",
|
||||
"no-connections": "",
|
||||
"no-connections-message": "",
|
||||
"no-results": "",
|
||||
"page-subtitle": "",
|
||||
"page-title": "",
|
||||
"search-placeholder": "",
|
||||
"status-connected": "",
|
||||
"status-disconnected": "",
|
||||
"status-unknown": "",
|
||||
"view": ""
|
||||
},
|
||||
"delete-repository-button": {
|
||||
"button-cancel": "",
|
||||
"button-delete": "Supprimer",
|
||||
"confirm-delete-keep-resources": "Voulez-vous vraiment supprimer la configuration du référentiel tout en conservant ses ressources ?",
|
||||
"confirm-delete-with-resources": "Voulez-vous vraiment supprimer la configuration du référentiel ainsi que toutes ses ressources ?",
|
||||
@@ -12070,6 +12116,7 @@
|
||||
"jobs": "Missions"
|
||||
},
|
||||
"repository-actions": {
|
||||
"connections": "",
|
||||
"settings": "Paramètres",
|
||||
"source-code": "Code source"
|
||||
},
|
||||
|
||||
@@ -11806,7 +11806,53 @@
|
||||
"free-tier-limit-tooltip": "",
|
||||
"instance-fully-managed-tooltip": ""
|
||||
},
|
||||
"connection-form": {
|
||||
"alert-connection-deleted": "",
|
||||
"alert-connection-saved": "",
|
||||
"alert-connection-updated": "",
|
||||
"back-to-connections": "",
|
||||
"button-save": "",
|
||||
"button-saving": "",
|
||||
"description-app-id": "",
|
||||
"description-installation-id": "",
|
||||
"description-private-key": "",
|
||||
"description-provider": "",
|
||||
"error-delete-connection": "",
|
||||
"error-required": "",
|
||||
"error-save-connection": "",
|
||||
"label-app-id": "",
|
||||
"label-installation-id": "",
|
||||
"label-private-key": "",
|
||||
"label-provider": "",
|
||||
"not-found": "",
|
||||
"not-found-description": "",
|
||||
"page-subtitle": "",
|
||||
"page-title-create": "",
|
||||
"page-title-edit": "",
|
||||
"placeholder-app-id": "",
|
||||
"placeholder-installation-id": "",
|
||||
"placeholder-private-key": ""
|
||||
},
|
||||
"connections": {
|
||||
"add-connection": "",
|
||||
"cancel": "",
|
||||
"delete": "",
|
||||
"delete-confirm": "",
|
||||
"delete-title": "",
|
||||
"error-loading": "",
|
||||
"no-connections": "",
|
||||
"no-connections-message": "",
|
||||
"no-results": "",
|
||||
"page-subtitle": "",
|
||||
"page-title": "",
|
||||
"search-placeholder": "",
|
||||
"status-connected": "",
|
||||
"status-disconnected": "",
|
||||
"status-unknown": "",
|
||||
"view": ""
|
||||
},
|
||||
"delete-repository-button": {
|
||||
"button-cancel": "",
|
||||
"button-delete": "Törlés",
|
||||
"confirm-delete-keep-resources": "Biztosan törli az adattár konfigurációját, és megtartja az erőforrásait?",
|
||||
"confirm-delete-with-resources": "Biztosan törli az adattár konfigurációját és az összes erőforrását?",
|
||||
@@ -12070,6 +12116,7 @@
|
||||
"jobs": "Feladatok"
|
||||
},
|
||||
"repository-actions": {
|
||||
"connections": "",
|
||||
"settings": "Beállítások",
|
||||
"source-code": "Forráskód"
|
||||
},
|
||||
|
||||
@@ -11756,7 +11756,53 @@
|
||||
"free-tier-limit-tooltip": "",
|
||||
"instance-fully-managed-tooltip": ""
|
||||
},
|
||||
"connection-form": {
|
||||
"alert-connection-deleted": "",
|
||||
"alert-connection-saved": "",
|
||||
"alert-connection-updated": "",
|
||||
"back-to-connections": "",
|
||||
"button-save": "",
|
||||
"button-saving": "",
|
||||
"description-app-id": "",
|
||||
"description-installation-id": "",
|
||||
"description-private-key": "",
|
||||
"description-provider": "",
|
||||
"error-delete-connection": "",
|
||||
"error-required": "",
|
||||
"error-save-connection": "",
|
||||
"label-app-id": "",
|
||||
"label-installation-id": "",
|
||||
"label-private-key": "",
|
||||
"label-provider": "",
|
||||
"not-found": "",
|
||||
"not-found-description": "",
|
||||
"page-subtitle": "",
|
||||
"page-title-create": "",
|
||||
"page-title-edit": "",
|
||||
"placeholder-app-id": "",
|
||||
"placeholder-installation-id": "",
|
||||
"placeholder-private-key": ""
|
||||
},
|
||||
"connections": {
|
||||
"add-connection": "",
|
||||
"cancel": "",
|
||||
"delete": "",
|
||||
"delete-confirm": "",
|
||||
"delete-title": "",
|
||||
"error-loading": "",
|
||||
"no-connections": "",
|
||||
"no-connections-message": "",
|
||||
"no-results": "",
|
||||
"page-subtitle": "",
|
||||
"page-title": "",
|
||||
"search-placeholder": "",
|
||||
"status-connected": "",
|
||||
"status-disconnected": "",
|
||||
"status-unknown": "",
|
||||
"view": ""
|
||||
},
|
||||
"delete-repository-button": {
|
||||
"button-cancel": "",
|
||||
"button-delete": "Hapus",
|
||||
"confirm-delete-keep-resources": "Anda yakin ingin menghapus konfigurasi repositori, tetapi menyimpan sumber dayanya?",
|
||||
"confirm-delete-with-resources": "Anda yakin ingin menghapus konfigurasi repositori dan semua sumber dayanya?",
|
||||
@@ -12018,6 +12064,7 @@
|
||||
"jobs": "Pekerjaan"
|
||||
},
|
||||
"repository-actions": {
|
||||
"connections": "",
|
||||
"settings": "Pengaturan",
|
||||
"source-code": "Kode sumber"
|
||||
},
|
||||
|
||||
@@ -11806,7 +11806,53 @@
|
||||
"free-tier-limit-tooltip": "",
|
||||
"instance-fully-managed-tooltip": ""
|
||||
},
|
||||
"connection-form": {
|
||||
"alert-connection-deleted": "",
|
||||
"alert-connection-saved": "",
|
||||
"alert-connection-updated": "",
|
||||
"back-to-connections": "",
|
||||
"button-save": "",
|
||||
"button-saving": "",
|
||||
"description-app-id": "",
|
||||
"description-installation-id": "",
|
||||
"description-private-key": "",
|
||||
"description-provider": "",
|
||||
"error-delete-connection": "",
|
||||
"error-required": "",
|
||||
"error-save-connection": "",
|
||||
"label-app-id": "",
|
||||
"label-installation-id": "",
|
||||
"label-private-key": "",
|
||||
"label-provider": "",
|
||||
"not-found": "",
|
||||
"not-found-description": "",
|
||||
"page-subtitle": "",
|
||||
"page-title-create": "",
|
||||
"page-title-edit": "",
|
||||
"placeholder-app-id": "",
|
||||
"placeholder-installation-id": "",
|
||||
"placeholder-private-key": ""
|
||||
},
|
||||
"connections": {
|
||||
"add-connection": "",
|
||||
"cancel": "",
|
||||
"delete": "",
|
||||
"delete-confirm": "",
|
||||
"delete-title": "",
|
||||
"error-loading": "",
|
||||
"no-connections": "",
|
||||
"no-connections-message": "",
|
||||
"no-results": "",
|
||||
"page-subtitle": "",
|
||||
"page-title": "",
|
||||
"search-placeholder": "",
|
||||
"status-connected": "",
|
||||
"status-disconnected": "",
|
||||
"status-unknown": "",
|
||||
"view": ""
|
||||
},
|
||||
"delete-repository-button": {
|
||||
"button-cancel": "",
|
||||
"button-delete": "Elimina",
|
||||
"confirm-delete-keep-resources": "Vuoi davvero eliminare la configurazione del repository ma conservarne le risorse?",
|
||||
"confirm-delete-with-resources": "Vuoi davvero eliminare la configurazione del repository e tutte le sue risorse?",
|
||||
@@ -12070,6 +12116,7 @@
|
||||
"jobs": "Attività"
|
||||
},
|
||||
"repository-actions": {
|
||||
"connections": "",
|
||||
"settings": "Impostazioni",
|
||||
"source-code": "Codice sorgente"
|
||||
},
|
||||
|
||||
@@ -11756,7 +11756,53 @@
|
||||
"free-tier-limit-tooltip": "",
|
||||
"instance-fully-managed-tooltip": ""
|
||||
},
|
||||
"connection-form": {
|
||||
"alert-connection-deleted": "",
|
||||
"alert-connection-saved": "",
|
||||
"alert-connection-updated": "",
|
||||
"back-to-connections": "",
|
||||
"button-save": "",
|
||||
"button-saving": "",
|
||||
"description-app-id": "",
|
||||
"description-installation-id": "",
|
||||
"description-private-key": "",
|
||||
"description-provider": "",
|
||||
"error-delete-connection": "",
|
||||
"error-required": "",
|
||||
"error-save-connection": "",
|
||||
"label-app-id": "",
|
||||
"label-installation-id": "",
|
||||
"label-private-key": "",
|
||||
"label-provider": "",
|
||||
"not-found": "",
|
||||
"not-found-description": "",
|
||||
"page-subtitle": "",
|
||||
"page-title-create": "",
|
||||
"page-title-edit": "",
|
||||
"placeholder-app-id": "",
|
||||
"placeholder-installation-id": "",
|
||||
"placeholder-private-key": ""
|
||||
},
|
||||
"connections": {
|
||||
"add-connection": "",
|
||||
"cancel": "",
|
||||
"delete": "",
|
||||
"delete-confirm": "",
|
||||
"delete-title": "",
|
||||
"error-loading": "",
|
||||
"no-connections": "",
|
||||
"no-connections-message": "",
|
||||
"no-results": "",
|
||||
"page-subtitle": "",
|
||||
"page-title": "",
|
||||
"search-placeholder": "",
|
||||
"status-connected": "",
|
||||
"status-disconnected": "",
|
||||
"status-unknown": "",
|
||||
"view": ""
|
||||
},
|
||||
"delete-repository-button": {
|
||||
"button-cancel": "",
|
||||
"button-delete": "削除",
|
||||
"confirm-delete-keep-resources": "リポジトリ設定を削除するものの、そのリソースを保持してもよろしいですか?",
|
||||
"confirm-delete-with-resources": "リポジトリ設定とそのすべてのリソースを削除してもよろしいですか?",
|
||||
@@ -12018,6 +12064,7 @@
|
||||
"jobs": "ジョブ"
|
||||
},
|
||||
"repository-actions": {
|
||||
"connections": "",
|
||||
"settings": "設定",
|
||||
"source-code": "ソースコード"
|
||||
},
|
||||
|
||||
@@ -11756,7 +11756,53 @@
|
||||
"free-tier-limit-tooltip": "",
|
||||
"instance-fully-managed-tooltip": ""
|
||||
},
|
||||
"connection-form": {
|
||||
"alert-connection-deleted": "",
|
||||
"alert-connection-saved": "",
|
||||
"alert-connection-updated": "",
|
||||
"back-to-connections": "",
|
||||
"button-save": "",
|
||||
"button-saving": "",
|
||||
"description-app-id": "",
|
||||
"description-installation-id": "",
|
||||
"description-private-key": "",
|
||||
"description-provider": "",
|
||||
"error-delete-connection": "",
|
||||
"error-required": "",
|
||||
"error-save-connection": "",
|
||||
"label-app-id": "",
|
||||
"label-installation-id": "",
|
||||
"label-private-key": "",
|
||||
"label-provider": "",
|
||||
"not-found": "",
|
||||
"not-found-description": "",
|
||||
"page-subtitle": "",
|
||||
"page-title-create": "",
|
||||
"page-title-edit": "",
|
||||
"placeholder-app-id": "",
|
||||
"placeholder-installation-id": "",
|
||||
"placeholder-private-key": ""
|
||||
},
|
||||
"connections": {
|
||||
"add-connection": "",
|
||||
"cancel": "",
|
||||
"delete": "",
|
||||
"delete-confirm": "",
|
||||
"delete-title": "",
|
||||
"error-loading": "",
|
||||
"no-connections": "",
|
||||
"no-connections-message": "",
|
||||
"no-results": "",
|
||||
"page-subtitle": "",
|
||||
"page-title": "",
|
||||
"search-placeholder": "",
|
||||
"status-connected": "",
|
||||
"status-disconnected": "",
|
||||
"status-unknown": "",
|
||||
"view": ""
|
||||
},
|
||||
"delete-repository-button": {
|
||||
"button-cancel": "",
|
||||
"button-delete": "삭제",
|
||||
"confirm-delete-keep-resources": "정말 리포지토리 구성만 삭제하고 해당 리소스는 그대로 유지하시겠어요?",
|
||||
"confirm-delete-with-resources": "정말 리포지토리 구성과 해당하는 모든 리소스를 삭제하시겠어요?",
|
||||
@@ -12018,6 +12064,7 @@
|
||||
"jobs": "작업"
|
||||
},
|
||||
"repository-actions": {
|
||||
"connections": "",
|
||||
"settings": "설정",
|
||||
"source-code": "소스 코드"
|
||||
},
|
||||
|
||||
@@ -11806,7 +11806,53 @@
|
||||
"free-tier-limit-tooltip": "",
|
||||
"instance-fully-managed-tooltip": ""
|
||||
},
|
||||
"connection-form": {
|
||||
"alert-connection-deleted": "",
|
||||
"alert-connection-saved": "",
|
||||
"alert-connection-updated": "",
|
||||
"back-to-connections": "",
|
||||
"button-save": "",
|
||||
"button-saving": "",
|
||||
"description-app-id": "",
|
||||
"description-installation-id": "",
|
||||
"description-private-key": "",
|
||||
"description-provider": "",
|
||||
"error-delete-connection": "",
|
||||
"error-required": "",
|
||||
"error-save-connection": "",
|
||||
"label-app-id": "",
|
||||
"label-installation-id": "",
|
||||
"label-private-key": "",
|
||||
"label-provider": "",
|
||||
"not-found": "",
|
||||
"not-found-description": "",
|
||||
"page-subtitle": "",
|
||||
"page-title-create": "",
|
||||
"page-title-edit": "",
|
||||
"placeholder-app-id": "",
|
||||
"placeholder-installation-id": "",
|
||||
"placeholder-private-key": ""
|
||||
},
|
||||
"connections": {
|
||||
"add-connection": "",
|
||||
"cancel": "",
|
||||
"delete": "",
|
||||
"delete-confirm": "",
|
||||
"delete-title": "",
|
||||
"error-loading": "",
|
||||
"no-connections": "",
|
||||
"no-connections-message": "",
|
||||
"no-results": "",
|
||||
"page-subtitle": "",
|
||||
"page-title": "",
|
||||
"search-placeholder": "",
|
||||
"status-connected": "",
|
||||
"status-disconnected": "",
|
||||
"status-unknown": "",
|
||||
"view": ""
|
||||
},
|
||||
"delete-repository-button": {
|
||||
"button-cancel": "",
|
||||
"button-delete": "Verwijderen",
|
||||
"confirm-delete-keep-resources": "Weet je zeker dat je de repository-configuratie wilt verwijderen, maar de bronnen wilt behouden?",
|
||||
"confirm-delete-with-resources": "Weet je zeker dat je de repository-configuratie en alle bronnen wilt verwijderen?",
|
||||
@@ -12070,6 +12116,7 @@
|
||||
"jobs": "Taken"
|
||||
},
|
||||
"repository-actions": {
|
||||
"connections": "",
|
||||
"settings": "Instellingen",
|
||||
"source-code": "Broncode"
|
||||
},
|
||||
|
||||
@@ -11906,7 +11906,53 @@
|
||||
"free-tier-limit-tooltip": "",
|
||||
"instance-fully-managed-tooltip": ""
|
||||
},
|
||||
"connection-form": {
|
||||
"alert-connection-deleted": "",
|
||||
"alert-connection-saved": "",
|
||||
"alert-connection-updated": "",
|
||||
"back-to-connections": "",
|
||||
"button-save": "",
|
||||
"button-saving": "",
|
||||
"description-app-id": "",
|
||||
"description-installation-id": "",
|
||||
"description-private-key": "",
|
||||
"description-provider": "",
|
||||
"error-delete-connection": "",
|
||||
"error-required": "",
|
||||
"error-save-connection": "",
|
||||
"label-app-id": "",
|
||||
"label-installation-id": "",
|
||||
"label-private-key": "",
|
||||
"label-provider": "",
|
||||
"not-found": "",
|
||||
"not-found-description": "",
|
||||
"page-subtitle": "",
|
||||
"page-title-create": "",
|
||||
"page-title-edit": "",
|
||||
"placeholder-app-id": "",
|
||||
"placeholder-installation-id": "",
|
||||
"placeholder-private-key": ""
|
||||
},
|
||||
"connections": {
|
||||
"add-connection": "",
|
||||
"cancel": "",
|
||||
"delete": "",
|
||||
"delete-confirm": "",
|
||||
"delete-title": "",
|
||||
"error-loading": "",
|
||||
"no-connections": "",
|
||||
"no-connections-message": "",
|
||||
"no-results": "",
|
||||
"page-subtitle": "",
|
||||
"page-title": "",
|
||||
"search-placeholder": "",
|
||||
"status-connected": "",
|
||||
"status-disconnected": "",
|
||||
"status-unknown": "",
|
||||
"view": ""
|
||||
},
|
||||
"delete-repository-button": {
|
||||
"button-cancel": "",
|
||||
"button-delete": "Usuń",
|
||||
"confirm-delete-keep-resources": "Na pewno chcesz usunąć konfigurację repozytorium, ale zachować jego zasoby?",
|
||||
"confirm-delete-with-resources": "Na pewno chcesz usunąć konfigurację repozytorium i wszystkie jego zasoby?",
|
||||
@@ -12174,6 +12220,7 @@
|
||||
"jobs": "Zadania"
|
||||
},
|
||||
"repository-actions": {
|
||||
"connections": "",
|
||||
"settings": "Ustawienia",
|
||||
"source-code": "Kod źródłowy"
|
||||
},
|
||||
|
||||
@@ -11806,7 +11806,53 @@
|
||||
"free-tier-limit-tooltip": "",
|
||||
"instance-fully-managed-tooltip": ""
|
||||
},
|
||||
"connection-form": {
|
||||
"alert-connection-deleted": "",
|
||||
"alert-connection-saved": "",
|
||||
"alert-connection-updated": "",
|
||||
"back-to-connections": "",
|
||||
"button-save": "",
|
||||
"button-saving": "",
|
||||
"description-app-id": "",
|
||||
"description-installation-id": "",
|
||||
"description-private-key": "",
|
||||
"description-provider": "",
|
||||
"error-delete-connection": "",
|
||||
"error-required": "",
|
||||
"error-save-connection": "",
|
||||
"label-app-id": "",
|
||||
"label-installation-id": "",
|
||||
"label-private-key": "",
|
||||
"label-provider": "",
|
||||
"not-found": "",
|
||||
"not-found-description": "",
|
||||
"page-subtitle": "",
|
||||
"page-title-create": "",
|
||||
"page-title-edit": "",
|
||||
"placeholder-app-id": "",
|
||||
"placeholder-installation-id": "",
|
||||
"placeholder-private-key": ""
|
||||
},
|
||||
"connections": {
|
||||
"add-connection": "",
|
||||
"cancel": "",
|
||||
"delete": "",
|
||||
"delete-confirm": "",
|
||||
"delete-title": "",
|
||||
"error-loading": "",
|
||||
"no-connections": "",
|
||||
"no-connections-message": "",
|
||||
"no-results": "",
|
||||
"page-subtitle": "",
|
||||
"page-title": "",
|
||||
"search-placeholder": "",
|
||||
"status-connected": "",
|
||||
"status-disconnected": "",
|
||||
"status-unknown": "",
|
||||
"view": ""
|
||||
},
|
||||
"delete-repository-button": {
|
||||
"button-cancel": "",
|
||||
"button-delete": "Excluir",
|
||||
"confirm-delete-keep-resources": "Tem certeza de que deseja excluir a configuração do repositório, mas manter seus recursos?",
|
||||
"confirm-delete-with-resources": "Tem certeza de que deseja excluir a configuração do repositório e todos os recursos dele?",
|
||||
@@ -12070,6 +12116,7 @@
|
||||
"jobs": "Tarefas"
|
||||
},
|
||||
"repository-actions": {
|
||||
"connections": "",
|
||||
"settings": "Configurações",
|
||||
"source-code": "Código fonte"
|
||||
},
|
||||
|
||||
@@ -11806,7 +11806,53 @@
|
||||
"free-tier-limit-tooltip": "",
|
||||
"instance-fully-managed-tooltip": ""
|
||||
},
|
||||
"connection-form": {
|
||||
"alert-connection-deleted": "",
|
||||
"alert-connection-saved": "",
|
||||
"alert-connection-updated": "",
|
||||
"back-to-connections": "",
|
||||
"button-save": "",
|
||||
"button-saving": "",
|
||||
"description-app-id": "",
|
||||
"description-installation-id": "",
|
||||
"description-private-key": "",
|
||||
"description-provider": "",
|
||||
"error-delete-connection": "",
|
||||
"error-required": "",
|
||||
"error-save-connection": "",
|
||||
"label-app-id": "",
|
||||
"label-installation-id": "",
|
||||
"label-private-key": "",
|
||||
"label-provider": "",
|
||||
"not-found": "",
|
||||
"not-found-description": "",
|
||||
"page-subtitle": "",
|
||||
"page-title-create": "",
|
||||
"page-title-edit": "",
|
||||
"placeholder-app-id": "",
|
||||
"placeholder-installation-id": "",
|
||||
"placeholder-private-key": ""
|
||||
},
|
||||
"connections": {
|
||||
"add-connection": "",
|
||||
"cancel": "",
|
||||
"delete": "",
|
||||
"delete-confirm": "",
|
||||
"delete-title": "",
|
||||
"error-loading": "",
|
||||
"no-connections": "",
|
||||
"no-connections-message": "",
|
||||
"no-results": "",
|
||||
"page-subtitle": "",
|
||||
"page-title": "",
|
||||
"search-placeholder": "",
|
||||
"status-connected": "",
|
||||
"status-disconnected": "",
|
||||
"status-unknown": "",
|
||||
"view": ""
|
||||
},
|
||||
"delete-repository-button": {
|
||||
"button-cancel": "",
|
||||
"button-delete": "Eliminar",
|
||||
"confirm-delete-keep-resources": "Tem a certeza de que pretende eliminar a configuração do repositório, mas manter os seus recursos?",
|
||||
"confirm-delete-with-resources": "Tem a certeza de que pretende eliminar a configuração do repositório e todos os seus recursos?",
|
||||
@@ -12070,6 +12116,7 @@
|
||||
"jobs": "Trabalhos"
|
||||
},
|
||||
"repository-actions": {
|
||||
"connections": "",
|
||||
"settings": "Definições",
|
||||
"source-code": "Código-fonte"
|
||||
},
|
||||
|
||||
@@ -11906,7 +11906,53 @@
|
||||
"free-tier-limit-tooltip": "",
|
||||
"instance-fully-managed-tooltip": ""
|
||||
},
|
||||
"connection-form": {
|
||||
"alert-connection-deleted": "",
|
||||
"alert-connection-saved": "",
|
||||
"alert-connection-updated": "",
|
||||
"back-to-connections": "",
|
||||
"button-save": "",
|
||||
"button-saving": "",
|
||||
"description-app-id": "",
|
||||
"description-installation-id": "",
|
||||
"description-private-key": "",
|
||||
"description-provider": "",
|
||||
"error-delete-connection": "",
|
||||
"error-required": "",
|
||||
"error-save-connection": "",
|
||||
"label-app-id": "",
|
||||
"label-installation-id": "",
|
||||
"label-private-key": "",
|
||||
"label-provider": "",
|
||||
"not-found": "",
|
||||
"not-found-description": "",
|
||||
"page-subtitle": "",
|
||||
"page-title-create": "",
|
||||
"page-title-edit": "",
|
||||
"placeholder-app-id": "",
|
||||
"placeholder-installation-id": "",
|
||||
"placeholder-private-key": ""
|
||||
},
|
||||
"connections": {
|
||||
"add-connection": "",
|
||||
"cancel": "",
|
||||
"delete": "",
|
||||
"delete-confirm": "",
|
||||
"delete-title": "",
|
||||
"error-loading": "",
|
||||
"no-connections": "",
|
||||
"no-connections-message": "",
|
||||
"no-results": "",
|
||||
"page-subtitle": "",
|
||||
"page-title": "",
|
||||
"search-placeholder": "",
|
||||
"status-connected": "",
|
||||
"status-disconnected": "",
|
||||
"status-unknown": "",
|
||||
"view": ""
|
||||
},
|
||||
"delete-repository-button": {
|
||||
"button-cancel": "",
|
||||
"button-delete": "Удалить",
|
||||
"confirm-delete-keep-resources": "Вы уверены, что хотите удалить конфигурацию репозитория, но сохранить его ресурсы?",
|
||||
"confirm-delete-with-resources": "Вы уверены, что хотите удалить конфигурацию репозитория и все его ресурсы?",
|
||||
@@ -12174,6 +12220,7 @@
|
||||
"jobs": "Задания"
|
||||
},
|
||||
"repository-actions": {
|
||||
"connections": "",
|
||||
"settings": "Параметры",
|
||||
"source-code": "Исходный код"
|
||||
},
|
||||
|
||||
@@ -11806,7 +11806,53 @@
|
||||
"free-tier-limit-tooltip": "",
|
||||
"instance-fully-managed-tooltip": ""
|
||||
},
|
||||
"connection-form": {
|
||||
"alert-connection-deleted": "",
|
||||
"alert-connection-saved": "",
|
||||
"alert-connection-updated": "",
|
||||
"back-to-connections": "",
|
||||
"button-save": "",
|
||||
"button-saving": "",
|
||||
"description-app-id": "",
|
||||
"description-installation-id": "",
|
||||
"description-private-key": "",
|
||||
"description-provider": "",
|
||||
"error-delete-connection": "",
|
||||
"error-required": "",
|
||||
"error-save-connection": "",
|
||||
"label-app-id": "",
|
||||
"label-installation-id": "",
|
||||
"label-private-key": "",
|
||||
"label-provider": "",
|
||||
"not-found": "",
|
||||
"not-found-description": "",
|
||||
"page-subtitle": "",
|
||||
"page-title-create": "",
|
||||
"page-title-edit": "",
|
||||
"placeholder-app-id": "",
|
||||
"placeholder-installation-id": "",
|
||||
"placeholder-private-key": ""
|
||||
},
|
||||
"connections": {
|
||||
"add-connection": "",
|
||||
"cancel": "",
|
||||
"delete": "",
|
||||
"delete-confirm": "",
|
||||
"delete-title": "",
|
||||
"error-loading": "",
|
||||
"no-connections": "",
|
||||
"no-connections-message": "",
|
||||
"no-results": "",
|
||||
"page-subtitle": "",
|
||||
"page-title": "",
|
||||
"search-placeholder": "",
|
||||
"status-connected": "",
|
||||
"status-disconnected": "",
|
||||
"status-unknown": "",
|
||||
"view": ""
|
||||
},
|
||||
"delete-repository-button": {
|
||||
"button-cancel": "",
|
||||
"button-delete": "Ta bort",
|
||||
"confirm-delete-keep-resources": "Är du säker på att du vill radera lagringsplatskonfigurationen men behålla dess resurser?",
|
||||
"confirm-delete-with-resources": "Är du säker på att du vill radera lagringsplatskonfigurationen och alla dess resurser?",
|
||||
@@ -12070,6 +12116,7 @@
|
||||
"jobs": "Jobb"
|
||||
},
|
||||
"repository-actions": {
|
||||
"connections": "",
|
||||
"settings": "Inställningar",
|
||||
"source-code": "Källkod"
|
||||
},
|
||||
|
||||
@@ -11806,7 +11806,53 @@
|
||||
"free-tier-limit-tooltip": "",
|
||||
"instance-fully-managed-tooltip": ""
|
||||
},
|
||||
"connection-form": {
|
||||
"alert-connection-deleted": "",
|
||||
"alert-connection-saved": "",
|
||||
"alert-connection-updated": "",
|
||||
"back-to-connections": "",
|
||||
"button-save": "",
|
||||
"button-saving": "",
|
||||
"description-app-id": "",
|
||||
"description-installation-id": "",
|
||||
"description-private-key": "",
|
||||
"description-provider": "",
|
||||
"error-delete-connection": "",
|
||||
"error-required": "",
|
||||
"error-save-connection": "",
|
||||
"label-app-id": "",
|
||||
"label-installation-id": "",
|
||||
"label-private-key": "",
|
||||
"label-provider": "",
|
||||
"not-found": "",
|
||||
"not-found-description": "",
|
||||
"page-subtitle": "",
|
||||
"page-title-create": "",
|
||||
"page-title-edit": "",
|
||||
"placeholder-app-id": "",
|
||||
"placeholder-installation-id": "",
|
||||
"placeholder-private-key": ""
|
||||
},
|
||||
"connections": {
|
||||
"add-connection": "",
|
||||
"cancel": "",
|
||||
"delete": "",
|
||||
"delete-confirm": "",
|
||||
"delete-title": "",
|
||||
"error-loading": "",
|
||||
"no-connections": "",
|
||||
"no-connections-message": "",
|
||||
"no-results": "",
|
||||
"page-subtitle": "",
|
||||
"page-title": "",
|
||||
"search-placeholder": "",
|
||||
"status-connected": "",
|
||||
"status-disconnected": "",
|
||||
"status-unknown": "",
|
||||
"view": ""
|
||||
},
|
||||
"delete-repository-button": {
|
||||
"button-cancel": "",
|
||||
"button-delete": "Sil",
|
||||
"confirm-delete-keep-resources": "",
|
||||
"confirm-delete-with-resources": "",
|
||||
@@ -12070,6 +12116,7 @@
|
||||
"jobs": "İşler"
|
||||
},
|
||||
"repository-actions": {
|
||||
"connections": "",
|
||||
"settings": "Ayarlar",
|
||||
"source-code": "Kaynak kodu"
|
||||
},
|
||||
|
||||
@@ -11756,7 +11756,53 @@
|
||||
"free-tier-limit-tooltip": "",
|
||||
"instance-fully-managed-tooltip": ""
|
||||
},
|
||||
"connection-form": {
|
||||
"alert-connection-deleted": "",
|
||||
"alert-connection-saved": "",
|
||||
"alert-connection-updated": "",
|
||||
"back-to-connections": "",
|
||||
"button-save": "",
|
||||
"button-saving": "",
|
||||
"description-app-id": "",
|
||||
"description-installation-id": "",
|
||||
"description-private-key": "",
|
||||
"description-provider": "",
|
||||
"error-delete-connection": "",
|
||||
"error-required": "",
|
||||
"error-save-connection": "",
|
||||
"label-app-id": "",
|
||||
"label-installation-id": "",
|
||||
"label-private-key": "",
|
||||
"label-provider": "",
|
||||
"not-found": "",
|
||||
"not-found-description": "",
|
||||
"page-subtitle": "",
|
||||
"page-title-create": "",
|
||||
"page-title-edit": "",
|
||||
"placeholder-app-id": "",
|
||||
"placeholder-installation-id": "",
|
||||
"placeholder-private-key": ""
|
||||
},
|
||||
"connections": {
|
||||
"add-connection": "",
|
||||
"cancel": "",
|
||||
"delete": "",
|
||||
"delete-confirm": "",
|
||||
"delete-title": "",
|
||||
"error-loading": "",
|
||||
"no-connections": "",
|
||||
"no-connections-message": "",
|
||||
"no-results": "",
|
||||
"page-subtitle": "",
|
||||
"page-title": "",
|
||||
"search-placeholder": "",
|
||||
"status-connected": "",
|
||||
"status-disconnected": "",
|
||||
"status-unknown": "",
|
||||
"view": ""
|
||||
},
|
||||
"delete-repository-button": {
|
||||
"button-cancel": "",
|
||||
"button-delete": "删除",
|
||||
"confirm-delete-keep-resources": "您确定要删除存储库配置但保留其资源吗?",
|
||||
"confirm-delete-with-resources": "您确定要删除存储库配置及其所有资源吗?",
|
||||
@@ -12018,6 +12064,7 @@
|
||||
"jobs": "作业"
|
||||
},
|
||||
"repository-actions": {
|
||||
"connections": "",
|
||||
"settings": "设置",
|
||||
"source-code": "源代码"
|
||||
},
|
||||
|
||||
@@ -11756,7 +11756,53 @@
|
||||
"free-tier-limit-tooltip": "",
|
||||
"instance-fully-managed-tooltip": ""
|
||||
},
|
||||
"connection-form": {
|
||||
"alert-connection-deleted": "",
|
||||
"alert-connection-saved": "",
|
||||
"alert-connection-updated": "",
|
||||
"back-to-connections": "",
|
||||
"button-save": "",
|
||||
"button-saving": "",
|
||||
"description-app-id": "",
|
||||
"description-installation-id": "",
|
||||
"description-private-key": "",
|
||||
"description-provider": "",
|
||||
"error-delete-connection": "",
|
||||
"error-required": "",
|
||||
"error-save-connection": "",
|
||||
"label-app-id": "",
|
||||
"label-installation-id": "",
|
||||
"label-private-key": "",
|
||||
"label-provider": "",
|
||||
"not-found": "",
|
||||
"not-found-description": "",
|
||||
"page-subtitle": "",
|
||||
"page-title-create": "",
|
||||
"page-title-edit": "",
|
||||
"placeholder-app-id": "",
|
||||
"placeholder-installation-id": "",
|
||||
"placeholder-private-key": ""
|
||||
},
|
||||
"connections": {
|
||||
"add-connection": "",
|
||||
"cancel": "",
|
||||
"delete": "",
|
||||
"delete-confirm": "",
|
||||
"delete-title": "",
|
||||
"error-loading": "",
|
||||
"no-connections": "",
|
||||
"no-connections-message": "",
|
||||
"no-results": "",
|
||||
"page-subtitle": "",
|
||||
"page-title": "",
|
||||
"search-placeholder": "",
|
||||
"status-connected": "",
|
||||
"status-disconnected": "",
|
||||
"status-unknown": "",
|
||||
"view": ""
|
||||
},
|
||||
"delete-repository-button": {
|
||||
"button-cancel": "",
|
||||
"button-delete": "刪除",
|
||||
"confirm-delete-keep-resources": "確定要刪除儲存庫設定,但保留其資源嗎?",
|
||||
"confirm-delete-with-resources": "確定要刪除儲存庫設定及其所有資源嗎?",
|
||||
@@ -12018,6 +12064,7 @@
|
||||
"jobs": "作業"
|
||||
},
|
||||
"repository-actions": {
|
||||
"connections": "",
|
||||
"settings": "設定",
|
||||
"source-code": "原始碼"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user