Compare commits

...

12 Commits

Author SHA1 Message Date
oscarkilhed
0f1e8bb94e add more ops, fix panel plugin changes not updating 2025-12-20 20:57:48 +01:00
oscarkilhed
93551386cc support partial updates 2025-12-20 19:05:55 +01:00
oscarkilhed
e796825e63 add schema 2025-12-20 17:24:07 +01:00
oscarkilhed
7fec275695 add navigation and structure api 2025-12-20 16:37:49 +01:00
oscarkilhed
090078eb80 get dashboard errors 2025-12-20 15:09:57 +01:00
oscarkilhed
1a7c2a4f38 dashboard: expose DashboardScene JSON API with recovery fallback 2025-12-20 14:55:18 +01:00
Marcus Andersson
ece38641ca Dashboards: Make sure to render dashboard links even if they are marked as "in controls menu" (#115381)
links with type dashboard will now be visible.
2025-12-19 13:48:53 +01:00
Yulia Shanyrova
e9a2828f66 Plugins: Add PluginInsights UI (#115616)
* Add getInsights endpoint, add new component PluginInsights

* fix linting and add styles

* add version option to insights request

* Add plugininsights tests, remove console.logs

* fix the insight items types

* Add getting insights to all the mocks to fix the tests

* remove deprecated lint package

* Add theme colors, added tests to PluginDetailsPanel

* Fix eslint error for plugin details page

* Add pluginInsights feature toggle

* change getInsights with version API call, resolve conflicts with main

* fix typecheck and translation

* updated UI

* update registry go

* fix translation

* light css changes

* remove duplicated feature toggle

* fix the build

* update plugin insights tests

* fix typecheck

* rudderstack added, feedback form added

* fix translation

* Remove isPluginTabId function
2025-12-19 13:40:41 +01:00
Sonia Aguilar
c2275f6ee4 Alerting: Add Cursor frontmatter to CLAUDE.md for auto-loading (#115613)
add Cursor frontmatter to CLAUDE.md for auto-loading
2025-12-19 12:03:45 +00:00
Yulia Shanyrova
b4eb02a6f0 Plugins: Change pageId parameter type in usePluginDetailsTabs (#115612)
* change usePluginDetailsTabs pageId parameter type

* add eslint suppressions
2025-12-19 12:45:15 +01:00
Roberto Jiménez Sánchez
a0751b6e71 Provisioning: Default to folder sync only and block new instance sync repositories (#115569)
* Default to folder sync only and block new instance sync repositories

- Change default allowed_targets to folder-only in backend configuration
- Modify validation to only enforce allowedTargets on CREATE operations
- Add deprecation warning for existing instance sync repositories
- Update frontend defaults and tests to reflect new behavior

Fixes #619

* Update warning message: change 'deprecated' to 'not fully supported'

* Fix health check: don't validate allowedTargets for existing repositories

Health checks for existing repositories should treat them as UPDATE operations,
not CREATE operations, so they don't fail validation for instance sync target.

* Fix tests and update i18n translations

- Update BootstrapStep tests to reflect folder-only default behavior
- Run i18n-extract to update translation file structure

* Fix integration tests

* Fix tests

* Fix provisioning test wizard

* Fix fronted test
2025-12-19 11:44:15 +00:00
Alexander Akhmetov
b5793a5f73 Alerting: Fix receiver_name and has_prometheus_definition filters with compact=true (#115582) 2025-12-19 11:43:46 +01:00
60 changed files with 3857 additions and 97 deletions

View File

@@ -23,7 +23,13 @@ func NewSimpleRepositoryTester(validator RepositoryValidator) SimpleRepositoryTe
// TestRepository validates the repository and then runs a health check
func (t *SimpleRepositoryTester) TestRepository(ctx context.Context, repo Repository) (*provisioning.TestResults, error) {
errors := t.validator.ValidateRepository(repo)
// Determine if this is a CREATE or UPDATE operation
// If the repository has been observed by the controller (ObservedGeneration > 0),
// it's an existing repository and we should treat it as UPDATE
cfg := repo.Config()
isCreate := cfg.Status.ObservedGeneration == 0
errors := t.validator.ValidateRepository(repo, isCreate)
if len(errors) > 0 {
rsp := &provisioning.TestResults{
Code: http.StatusUnprocessableEntity, // Invalid

View File

@@ -32,7 +32,9 @@ func NewValidator(minSyncInterval time.Duration, allowedTargets []provisioning.S
}
// ValidateRepository solely does configuration checks on the repository object. It does not run a health check or compare against existing repositories.
func (v *RepositoryValidator) ValidateRepository(repo Repository) field.ErrorList {
// isCreate indicates whether this is a CREATE operation (true) or UPDATE operation (false).
// When isCreate is false, allowedTargets validation is skipped to allow existing repositories to continue working.
func (v *RepositoryValidator) ValidateRepository(repo Repository, isCreate bool) field.ErrorList {
list := repo.Validate()
cfg := repo.Config()
@@ -44,7 +46,7 @@ func (v *RepositoryValidator) ValidateRepository(repo Repository) field.ErrorLis
if cfg.Spec.Sync.Target == "" {
list = append(list, field.Required(field.NewPath("spec", "sync", "target"),
"The target type is required when sync is enabled"))
} else if !slices.Contains(v.allowedTargets, cfg.Spec.Sync.Target) {
} else if isCreate && !slices.Contains(v.allowedTargets, cfg.Spec.Sync.Target) {
list = append(list,
field.Invalid(
field.NewPath("spec", "target"),

View File

@@ -303,7 +303,8 @@ func TestValidateRepository(t *testing.T) {
validator := NewValidator(10*time.Second, []provisioning.SyncTargetType{provisioning.SyncTargetTypeFolder}, false)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errors := validator.ValidateRepository(tt.repository)
// Tests validate new configurations, so always pass isCreate=true
errors := validator.ValidateRepository(tt.repository, true)
require.Len(t, errors, tt.expectedErrs)
if tt.validateError != nil {
tt.validateError(t, errors)

View File

@@ -2264,7 +2264,7 @@ fail_tests_on_console = true
# List of targets that can be controlled by a repository, separated by |.
# Instance means the whole grafana instance will be controlled by a repository.
# Folder limits it to a folder within the grafana instance.
allowed_targets = instance|folder
allowed_targets = folder
# Whether image rendering is allowed for dashboard previews.
# Requires image rendering service to be configured.

View File

@@ -2868,11 +2868,6 @@
"count": 1
}
},
"public/app/features/plugins/admin/components/PluginDetailsPage.tsx": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
}
},
"public/app/features/plugins/admin/helpers.ts": {
"no-restricted-syntax": {
"count": 2

View File

@@ -1193,6 +1193,11 @@ export interface FeatureToggles {
*/
onlyStoreActionSets?: boolean;
/**
* Show insights for plugins in the plugin details page
* @default false
*/
pluginInsights?: boolean;
/**
* Enables a new panel time settings drawer
*/
panelTimeSettings?: boolean;

View File

@@ -0,0 +1,460 @@
export interface DashboardSceneJsonApiV2 {
/**
* Read the currently open dashboard as v2beta1 Dashboard kind JSON (JSON string).
*/
getCurrentDashboard(space?: number): string;
/**
* Read query errors for the currently open dashboard (JSON string).
*
* This returns a JSON array of objects shaped like:
* `{ panelId, panelTitle, refId?, datasource?, message, severity }`.
*/
getCurrentDashboardErrors(space?: number): string;
/**
* Read current dashboard variables (JSON string).
*
* This returns JSON shaped like:
* `{ variables: [{ name, value }] }`
* where `value` is `string | string[]`.
*/
getCurrentDashboardVariables(space?: number): string;
/**
* Apply dashboard variable values (JSON string).
*
* Accepts either:
* - `{ variables: [{ name, value }] }`
* - or a map `{ [name]: value }`
*
* where `value` is `string | string[]`.
*/
applyCurrentDashboardVariables(varsJson: string): void;
/**
* Read the current dashboard time range (JSON string).
*
* This returns JSON shaped like:
* `{ from, to, timezone? }`.
*/
getCurrentDashboardTimeRange(space?: number): string;
/**
* Apply the current dashboard time range (JSON string).
*
* Accepts JSON shaped like:
* `{ from, to, timezone? }` where `from/to` are Grafana raw strings (e.g. `now-6h`, `now`).
*/
applyCurrentDashboardTimeRange(timeRangeJson: string): void;
/**
* Select a tab within the current dashboard (JSON string).
*
* Accepts JSON shaped like:
* `{ title?: string, slug?: string }`.
*/
selectCurrentDashboardTab(tabJson: string): void;
/**
* Read current in-dashboard navigation state (JSON string).
*
* This returns JSON shaped like:
* `{ tab: { slug: string, title?: string } | null }`.
*/
getCurrentDashboardNavigation(space?: number): string;
/**
* Read the currently selected element (edit pane selection) (JSON string).
*
* This returns JSON shaped like:
* `{ isEditing: boolean, selection: null | { mode: "single" | "multi", item?: object, items?: object[] } }`.
*
* Note: selection is only meaningful in edit mode. Implementations should return `selection: null` when not editing.
*/
getCurrentDashboardSelection(space?: number): string;
/**
* Scroll/focus a row within the current dashboard (JSON string).
*
* Accepts JSON shaped like:
* `{ title?: string, rowKey?: string }`.
*/
focusCurrentDashboardRow(rowJson: string): void;
/**
* Scroll/focus a panel within the current dashboard (JSON string).
*
* Accepts JSON shaped like:
* `{ panelId: number }`.
*/
focusCurrentDashboardPanel(panelJson: string): void;
/**
* Apply a v2beta1 Dashboard kind JSON (JSON string).
*
* Implementations must enforce **spec-only** updates by rejecting any changes to
* `apiVersion`, `kind`, `metadata`, or `status`.
*/
applyCurrentDashboard(resourceJson: string): void;
/**
* Preview a set of in-place dashboard operations (JSON string).
*
* Returns JSON shaped like:
* `{ ok: boolean, applied?: number, unsupported?: number, errors?: string[] }`
*
* @internal Experimental: this API is intended for automation and may evolve.
*/
previewCurrentDashboardOps(opsJson: string): string;
/**
* Apply a set of in-place dashboard operations (JSON string).
*
* Implementations should update the currently open DashboardScene in place when possible,
* to avoid unnecessary panel/query reloads.
*
* Returns JSON shaped like:
* `{ ok: boolean, applied?: number, didRebuild?: boolean, errors?: string[] }`
*
* @internal Experimental: this API is intended for automation and may evolve.
*/
applyCurrentDashboardOps(opsJson: string): string;
}
let singletonInstance: DashboardSceneJsonApiV2 | undefined;
/**
* Used during startup by Grafana to register the implementation.
*
* @internal
*/
export function setDashboardSceneJsonApiV2(instance: DashboardSceneJsonApiV2) {
singletonInstance = instance;
}
/**
* Returns the registered DashboardScene JSON API.
*
* @public
*/
export function getDashboardSceneJsonApiV2(): DashboardSceneJsonApiV2 {
if (!singletonInstance) {
throw new Error('DashboardScene JSON API is not available');
}
return singletonInstance;
}
/**
* A grouped, ergonomic API wrapper around the DashboardScene JSON API.
*
* This is purely a convenience layer: it calls the same underlying registered implementation
* as the top-level helper functions, but organizes functionality into namespaces like
* `navigation`, `variables`, and `timeRange`.
*
* @public
*/
export function getDashboardApi() {
const api = getDashboardSceneJsonApiV2();
let cachedDashboardSchemaBundle: { bundle: unknown; loadedAt: number } | undefined;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function collectRefs(value: unknown, out: Set<string>) {
if (Array.isArray(value)) {
for (const v of value) {
collectRefs(v, out);
}
return;
}
if (!isRecord(value)) {
return;
}
for (const [k, v] of Object.entries(value)) {
if (k === '$ref' && typeof v === 'string') {
out.add(v);
} else {
collectRefs(v, out);
}
}
}
function buildOpenApiSchemaBundle(openapi: unknown) {
if (!isRecord(openapi)) {
throw new Error('OpenAPI document is not an object');
}
const info = openapi['info'];
if (!isRecord(info)) {
throw new Error('OpenAPI document is missing info');
}
const title = info['title'];
if (typeof title !== 'string' || title.length === 0) {
throw new Error('OpenAPI document is missing info.title');
}
// This endpoint is expected to return the group/version doc, so validate it explicitly.
if (title !== 'dashboard.grafana.app/v2beta1') {
throw new Error(`OpenAPI document is not dashboard.grafana.app/v2beta1 (info.title="${title}")`);
}
const components = openapi['components'];
if (!isRecord(components)) {
throw new Error('OpenAPI document is missing components');
}
const schemas = components['schemas'];
if (!isRecord(schemas)) {
throw new Error('OpenAPI document is missing components.schemas');
}
// Find the Dashboard kind schema key by GVK annotation.
const dashboardKey = Object.entries(schemas).find(([_, schema]) => {
if (!isRecord(schema)) {
return false;
}
const gvk = schema['x-kubernetes-group-version-kind'];
if (!Array.isArray(gvk)) {
return false;
}
return gvk.some((x) => {
return (
isRecord(x) &&
x['group'] === 'dashboard.grafana.app' &&
x['version'] === 'v2beta1' &&
x['kind'] === 'Dashboard'
);
});
})?.[0];
if (!dashboardKey) {
throw new Error('Could not find dashboard.grafana.app/v2beta1 Dashboard schema in OpenAPI document');
}
const rootRef = `#/components/schemas/${dashboardKey}`;
const pickedSchemas: Record<string, unknown> = {};
const visited = new Set<string>();
const queue: string[] = [rootRef];
while (queue.length) {
const ref = queue.shift()!;
if (!ref.startsWith('#/components/schemas/')) {
continue;
}
const key = ref.slice('#/components/schemas/'.length);
if (visited.has(key)) {
continue;
}
visited.add(key);
const schema = schemas[key];
if (!schema) {
continue;
}
pickedSchemas[key] = schema;
const refs = new Set<string>();
collectRefs(schema, refs);
for (const r of refs) {
if (r.startsWith('#/components/schemas/')) {
queue.push(r);
}
}
}
// Sanity-check the root schema shape (helps LLM consumers, and catches wrong schema sources quickly).
const rootSchema = pickedSchemas[dashboardKey];
if (!isRecord(rootSchema)) {
throw new Error('Dashboard schema is not an object');
}
const required = rootSchema['required'];
if (!Array.isArray(required)) {
throw new Error('Dashboard schema is missing required fields list');
}
for (const req of ['apiVersion', 'kind', 'metadata', 'spec']) {
if (!required.includes(req)) {
throw new Error(`Dashboard schema is missing required field "${req}"`);
}
}
return {
format: 'openapi3.schemaBundle',
source: {
url: '/openapi/v3/apis/dashboard.grafana.app/v2beta1',
},
group: 'dashboard.grafana.app',
version: 'v2beta1',
kind: 'Dashboard',
root: { $ref: rootRef },
stats: { schemas: Object.keys(pickedSchemas).length },
validation: {
ok: true,
info: {
title,
},
root: {
ref: rootRef,
required: ['apiVersion', 'kind', 'metadata', 'spec'],
},
},
components: {
schemas: pickedSchemas,
},
};
}
return {
/**
* Prints/returns a quick reference for the grouped dashboard API, including expected JSON shapes.
*/
help: () => {
const text = [
'Dashboard API (DashboardScene JSON API, schema v2 kinds)',
'',
'All inputs/outputs are JSON strings.',
'Edits are spec-only: apiVersion/kind/metadata/status must not change.',
'',
'Schema (for LLMs):',
'- schema.getSources(space?): string',
'- schema.getDashboard(space?): Promise<string>',
'- schema.getDashboardSync(space?): string',
' - getDashboard() fetches the OpenAPI v3 document for dashboard.grafana.app/v2beta1 and returns a schema bundle.',
' - getDashboard() validates the document is for dashboard.grafana.app/v2beta1 and that the root schema is the Dashboard kind.',
'',
'Read/apply dashboard:',
'- dashboard.getCurrent(space?): string',
'- dashboard.apply(resourceJson: string): void',
'- dashboard.previewOps(opsJson: string): string',
'- dashboard.applyOps(opsJson: string): string',
' - Ops are applied in-place when possible to avoid full dashboard reloads.',
' - Supported ops (JSON array elements):',
' - { op: "mergePanelConfig", panelId, merge: { vizConfig: { fieldConfig: { defaults: {...} }, options: {...} } } }',
' - { op: "setPanelTitle", panelId, title }',
' - { op: "setGridPos", panelId, x, y, w, h }',
' - { op: "addPanel", title?, pluginId?, rowTitle?, rowKey? }',
' - { op: "removePanel", panelId }',
' - { op: "addRow", title? }',
' - { op: "removeRow", title? | rowKey? }',
' - { op: "movePanelToRow", panelId, rowKey? | rowTitle? }',
' - { op: "movePanelToTab", panelId, tabTitle? | tabSlug? }',
' - { op: "addTab", title? }',
' - { op: "removeTab", title? | slug? }',
' - resourceJson must be a v2beta1 Dashboard kind object:',
' { apiVersion: "dashboard.grafana.app/v2beta1", kind: "Dashboard", metadata: {...}, spec: {...}, status: {...} }',
'',
'Errors:',
'- errors.getCurrent(space?): string',
' - returns JSON: { errors: [{ panelId, panelTitle, refId?, datasource?, message, severity }] }',
'',
'Variables:',
'- variables.getCurrent(space?): string',
' - returns JSON: { variables: [{ name, value }] } where value is string | string[]',
'- variables.apply(varsJson: string): void',
' - accepts JSON: { variables: [{ name, value }] } OR { [name]: value }',
'',
'Time range:',
'- timeRange.getCurrent(space?): string',
' - returns JSON: { from: string, to: string, timezone?: string }',
'- timeRange.apply(timeRangeJson: string): void',
' - accepts JSON: { from: "now-6h", to: "now", timezone?: "browser" | "utc" | ... }',
'',
'Navigation:',
'- navigation.getCurrent(space?): string',
' - returns JSON: { tab: { slug: string, title?: string } | null }',
'- navigation.getSelection(space?): string',
' - returns JSON: { isEditing: boolean, selection: null | { mode: "single" | "multi", item?: object, items?: object[] } }',
'- navigation.selectTab(tabJson: string): void',
' - accepts JSON: { title?: string, slug?: string }',
'- navigation.focusRow(rowJson: string): void',
' - accepts JSON: { title?: string, rowKey?: string }',
'- navigation.focusPanel(panelJson: string): void',
' - accepts JSON: { panelId: number }',
'',
'Examples:',
'- const schema = JSON.parse(await window.dashboardApi.schema.getDashboard(0))',
'- window.dashboardApi.timeRange.apply(JSON.stringify({ from: "now-6h", to: "now", timezone: "browser" }))',
'- window.dashboardApi.navigation.selectTab(JSON.stringify({ title: "Overview" }))',
'- window.dashboardApi.navigation.focusPanel(JSON.stringify({ panelId: 12 }))',
].join('\n');
// Calling help is an explicit action; logging is useful in the browser console.
// Return the text as well so callers can print/store it as they prefer.
try {
// eslint-disable-next-line no-console
console.log(text);
} catch {
// ignore
}
return text;
},
schema: {
/**
* Returns where this API loads schema documents from.
*/
getSources: (space = 2) => {
return JSON.stringify(
{
openapi3: {
url: '/openapi/v3/apis/dashboard.grafana.app/v2beta1',
note: 'This is the Kubernetes-style OpenAPI document for dashboard.grafana.app/v2beta1. `schema.getDashboard()` extracts the Dashboard schemas into a smaller bundle.',
},
},
null,
space
);
},
/**
* Fetches and returns an OpenAPI schema bundle for `dashboard.grafana.app/v2beta1` `Dashboard`.
*
* Returns a JSON string (async) shaped like:
* `{ format, source, group, version, kind, root, stats, components: { schemas } }`.
*/
getDashboard: async (space = 2) => {
if (!cachedDashboardSchemaBundle) {
const rsp = await fetch('/openapi/v3/apis/dashboard.grafana.app/v2beta1', { credentials: 'same-origin' });
if (!rsp.ok) {
throw new Error(
`Failed to fetch OpenAPI document from /openapi/v3/apis/dashboard.grafana.app/v2beta1 (status ${rsp.status})`
);
}
const openapi: unknown = await rsp.json();
cachedDashboardSchemaBundle = { bundle: buildOpenApiSchemaBundle(openapi), loadedAt: Date.now() };
}
return JSON.stringify(cachedDashboardSchemaBundle.bundle, null, space);
},
/**
* Returns the cached schema bundle (sync), if previously loaded by `schema.getDashboard()`.
*/
getDashboardSync: (space = 2) => {
if (!cachedDashboardSchemaBundle) {
throw new Error('Schema bundle is not loaded. Call `await dashboardApi.schema.getDashboard()` first.');
}
return JSON.stringify(cachedDashboardSchemaBundle.bundle, null, space);
},
},
dashboard: {
getCurrent: (space = 2) => api.getCurrentDashboard(space),
apply: (resourceJson: string) => api.applyCurrentDashboard(resourceJson),
previewOps: (opsJson: string) => api.previewCurrentDashboardOps(opsJson),
applyOps: (opsJson: string) => api.applyCurrentDashboardOps(opsJson),
},
errors: {
getCurrent: (space = 2) =>
JSON.stringify({ errors: JSON.parse(api.getCurrentDashboardErrors(space)) }, null, space),
},
variables: {
getCurrent: (space = 2) => api.getCurrentDashboardVariables(space),
apply: (varsJson: string) => api.applyCurrentDashboardVariables(varsJson),
},
timeRange: {
getCurrent: (space = 2) => api.getCurrentDashboardTimeRange(space),
apply: (timeRangeJson: string) => api.applyCurrentDashboardTimeRange(timeRangeJson),
},
navigation: {
getCurrent: (space = 2) => api.getCurrentDashboardNavigation(space),
getSelection: (space = 2) => api.getCurrentDashboardSelection(space),
selectTab: (tabJson: string) => api.selectCurrentDashboardTab(tabJson),
focusRow: (rowJson: string) => api.focusCurrentDashboardRow(rowJson),
focusPanel: (panelJson: string) => api.focusCurrentDashboardPanel(panelJson),
},
};
}

View File

@@ -6,6 +6,7 @@ export * from './templateSrv';
export * from './live';
export * from './LocationService';
export * from './appEvents';
export * from './dashboardSceneJsonApi';
export {
setPluginComponentHook,

View File

@@ -673,7 +673,8 @@ func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o adm
//
// the only time to add configuration checks here is if you need to compare
// the incoming change to the current configuration
list := b.validator.ValidateRepository(repo)
isCreate := a.GetOperation() == admission.Create
list := b.validator.ValidateRepository(repo, isCreate)
cfg := repo.Config()
if a.GetOperation() == admission.Update {

View File

@@ -1968,6 +1968,14 @@ var (
Owner: identityAccessTeam,
Expression: "true",
},
{
Name: "pluginInsights",
Description: "Show insights for plugins in the plugin details page",
Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: grafanaPluginsPlatformSquad,
Expression: "false",
},
{
Name: "panelTimeSettings",
Description: "Enables a new panel time settings drawer",

View File

@@ -267,6 +267,7 @@ jaegerEnableGrpcEndpoint,experimental,@grafana/oss-big-tent,false,false,false
pluginStoreServiceLoading,experimental,@grafana/plugins-platform-backend,false,false,false
newPanelPadding,preview,@grafana/dashboards-squad,false,false,true
onlyStoreActionSets,GA,@grafana/identity-access-team,false,false,false
pluginInsights,experimental,@grafana/plugins-platform-backend,false,false,true
panelTimeSettings,experimental,@grafana/dashboards-squad,false,false,false
elasticsearchRawDSLQuery,experimental,@grafana/partner-datasources,false,false,false
kubernetesAnnotations,experimental,@grafana/grafana-backend-services-squad,false,false,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
267 pluginStoreServiceLoading experimental @grafana/plugins-platform-backend false false false
268 newPanelPadding preview @grafana/dashboards-squad false false true
269 onlyStoreActionSets GA @grafana/identity-access-team false false false
270 pluginInsights experimental @grafana/plugins-platform-backend false false true
271 panelTimeSettings experimental @grafana/dashboards-squad false false false
272 elasticsearchRawDSLQuery experimental @grafana/partner-datasources false false false
273 kubernetesAnnotations experimental @grafana/grafana-backend-services-squad false false false

View File

@@ -2720,6 +2720,20 @@
"expression": "false"
}
},
{
"metadata": {
"name": "pluginInsights",
"resourceVersion": "1761300628147",
"creationTimestamp": "2025-10-24T10:10:28Z"
},
"spec": {
"description": "Show insights for plugins in the plugin details page",
"stage": "experimental",
"codeowner": "@grafana/plugins-platform-backend",
"frontend": true,
"expression": "false"
}
},
{
"metadata": {
"name": "pluginInstallAPISync",

View File

@@ -2169,6 +2169,57 @@ func TestRouteGetRuleStatuses(t *testing.T) {
})
})
t.Run("compact mode with receiver_name filter returns only matching rules", func(t *testing.T) {
fakeStore, _, api := setupAPI(t)
ruleA := gen.With(
gen.WithGroupKey(ngmodels.AlertRuleGroupKey{
NamespaceUID: "folder-1",
RuleGroup: "group-1",
OrgID: orgID,
}),
gen.WithNotificationSettings(
ngmodels.NotificationSettings{
Receiver: "receiver-a",
GroupBy: []string{"alertname"},
},
),
).GenerateRef()
fakeStore.PutRule(context.Background(), ruleA)
ruleB := gen.With(
gen.WithGroupKey(ngmodels.AlertRuleGroupKey{
NamespaceUID: "folder-2",
RuleGroup: "group-2",
OrgID: orgID,
}),
gen.WithNotificationSettings(
ngmodels.NotificationSettings{
Receiver: "receiver-b",
GroupBy: []string{"alertname"},
},
),
).GenerateRef()
fakeStore.PutRule(context.Background(), ruleB)
r, err := http.NewRequest("GET", "/api/v1/rules?compact=true&receiver_name=receiver-a", nil)
require.NoError(t, err)
c := &contextmodel.ReqContext{
Context: &web.Context{Req: r},
SignedInUser: &user.SignedInUser{
OrgID: orgID,
Permissions: queryPermissions,
},
}
resp := api.RouteGetRuleStatuses(c)
require.Equal(t, http.StatusOK, resp.Status())
var res apimodels.RuleResponse
require.NoError(t, json.Unmarshal(resp.Body(), &res))
require.Len(t, res.Data.RuleGroups, 1)
require.Equal(t, "group-1", res.Data.RuleGroups[0].Name)
require.Empty(t, res.Data.RuleGroups[0].Rules[0].Query, "Query should be empty in compact mode")
})
t.Run("provenance as expected", func(t *testing.T) {
fakeStore, fakeAIM, api, provStore := setupAPIFull(t)
// Rule without provenance

View File

@@ -642,6 +642,23 @@ func (st DBstore) ListAlertRulesByGroup(ctx context.Context, query *ngmodels.Lis
_ = rows.Close()
}()
opts := AlertRuleConvertOptions{}
if query.Compact {
opts.ExcludeAlertQueries = true
opts.ExcludeNotificationSettings = true
opts.ExcludeMetadata = true
if query.ReceiverName != "" || query.TimeIntervalName != "" {
// Need NotificationSettings for these filters
opts.ExcludeNotificationSettings = false
}
if query.HasPrometheusRuleDefinition != nil {
// Need Metadata for this filter
opts.ExcludeMetadata = false
}
}
// Process rules and implement per-group pagination
var groupsFetched int64
var rulesFetched int64
@@ -653,12 +670,7 @@ func (st DBstore) ListAlertRulesByGroup(ctx context.Context, query *ngmodels.Lis
continue
}
var converted ngmodels.AlertRule
if query.Compact {
converted, err = alertRuleToModelsAlertRuleCompact(*rule, st.Logger)
} else {
converted, err = alertRuleToModelsAlertRule(*rule, st.Logger)
}
converted, err := convertAlertRuleToModel(*rule, st.Logger, opts)
if err != nil {
st.Logger.Error("Invalid rule found in DB store, cannot convert, ignoring it", "func", "ListAlertRulesByGroup", "error", err)

View File

@@ -15,22 +15,23 @@ type compactQuery struct {
DatasourceUID string `json:"datasourceUid"`
}
func alertRuleToModelsAlertRule(ar alertRule, l log.Logger) (models.AlertRule, error) {
return convertAlertRuleToModel(ar, l, false)
// AlertRuleConvertOptions controls which fields to parse during conversion from alertRule to models.AlertRule.
// By default all fields are included. Set Exclude* to true to skip parsing expensive fields.
type AlertRuleConvertOptions struct {
ExcludeAlertQueries bool // Only parse datasource UIDs from queries
ExcludeNotificationSettings bool
ExcludeMetadata bool
}
// alertRuleToModelsAlertRuleCompact transforms an alertRule to a models.AlertRule
// ignoring alert queries (except for data source UIDs), notification settings, and metadata.
func alertRuleToModelsAlertRuleCompact(ar alertRule, l log.Logger) (models.AlertRule, error) {
return convertAlertRuleToModel(ar, l, true)
func alertRuleToModelsAlertRule(ar alertRule, l log.Logger) (models.AlertRule, error) {
return convertAlertRuleToModel(ar, l, AlertRuleConvertOptions{})
}
// convertAlertRuleToModel creates a models.AlertRule from an alertRule.
// When 'compact' is set to 'true', it skips parsing the alert queries (except for the data source UID), notification
// settings, and metadata, thus reducing the number of JSON serializations needed.
func convertAlertRuleToModel(ar alertRule, l log.Logger, compact bool) (models.AlertRule, error) {
// opts.Exclude* fields control which expensive fields to skip parsing, reducing JSON serializations.
func convertAlertRuleToModel(ar alertRule, l log.Logger, opts AlertRuleConvertOptions) (models.AlertRule, error) {
var data []models.AlertQuery
if compact {
if opts.ExcludeAlertQueries {
var cqs []compactQuery
if err := json.Unmarshal([]byte(ar.Data), &cqs); err != nil {
return models.AlertRule{}, fmt.Errorf("failed to parse data: %w", err)
@@ -118,7 +119,7 @@ func convertAlertRuleToModel(ar alertRule, l log.Logger, compact bool) (models.A
}
}
if !compact && ar.NotificationSettings != "" {
if !opts.ExcludeNotificationSettings && ar.NotificationSettings != "" {
ns, err := parseNotificationSettings(ar.NotificationSettings)
if err != nil {
return models.AlertRule{}, fmt.Errorf("failed to parse notification settings: %w", err)
@@ -126,7 +127,7 @@ func convertAlertRuleToModel(ar alertRule, l log.Logger, compact bool) (models.A
result.NotificationSettings = ns
}
if !compact && ar.Metadata != "" {
if !opts.ExcludeMetadata && ar.Metadata != "" {
err = json.Unmarshal([]byte(ar.Metadata), &result.Metadata)
if err != nil {
return models.AlertRule{}, fmt.Errorf("failed to metadata: %w", err)

View File

@@ -84,7 +84,11 @@ func TestAlertRuleToModelsAlertRuleCompact(t *testing.T) {
Metadata: `{"editor_settings":{"simplified_query_and_expressions_section":true}}`,
}
compactResult, err := alertRuleToModelsAlertRuleCompact(rule, &logtest.Fake{})
compactResult, err := convertAlertRuleToModel(rule, &logtest.Fake{}, AlertRuleConvertOptions{
ExcludeAlertQueries: true,
ExcludeNotificationSettings: true,
ExcludeMetadata: true,
})
require.NoError(t, err)
// Should have datasource UIDs.
@@ -142,6 +146,82 @@ func TestAlertRuleToModelsAlertRuleCompact(t *testing.T) {
// Should have metadata (metadata is parsed from JSON to struct).
require.NotEqual(t, ngmodels.AlertRuleMetadata{}, fullResult.Metadata)
})
t.Run("compact mode with notification settings included for filtering", func(t *testing.T) {
rule := alertRule{
ID: 1,
OrgID: 1,
UID: "test-uid",
Title: "Test Rule",
Condition: "A",
Data: `[{"datasourceUid":"ds1","refId":"A","queryType":"test","model":{"expr":"up"}}]`,
IntervalSeconds: 60,
Version: 1,
NamespaceUID: "ns-uid",
RuleGroup: "test-group",
NoDataState: "NoData",
ExecErrState: "Error",
NotificationSettings: `[{"receiver":"test-receiver"}]`,
Metadata: `{"editor_settings":{"simplified_query_and_expressions_section":true}}`,
}
result, err := convertAlertRuleToModel(rule, &logtest.Fake{}, AlertRuleConvertOptions{
ExcludeAlertQueries: true,
ExcludeNotificationSettings: false,
ExcludeMetadata: true,
})
require.NoError(t, err)
// Should have compact query data (only datasource UIDs).
require.Len(t, result.Data, 1)
require.Equal(t, "ds1", result.Data[0].DatasourceUID)
require.Empty(t, result.Data[0].RefID)
// Should have notification settings for filtering.
require.Len(t, result.NotificationSettings, 1)
require.Equal(t, "test-receiver", result.NotificationSettings[0].Receiver)
// Should not have metadata.
require.Equal(t, ngmodels.AlertRuleMetadata{}, result.Metadata)
})
t.Run("compact mode with metadata included for filtering", func(t *testing.T) {
rule := alertRule{
ID: 1,
OrgID: 1,
UID: "test-uid",
Title: "Test Rule",
Condition: "A",
Data: `[{"datasourceUid":"ds1","refId":"A","queryType":"test","model":{"expr":"up"}}]`,
IntervalSeconds: 60,
Version: 1,
NamespaceUID: "ns-uid",
RuleGroup: "test-group",
NoDataState: "NoData",
ExecErrState: "Error",
NotificationSettings: `[{"receiver":"test-receiver"}]`,
Metadata: `{"prometheus_style_rule":{"original_rule_definition":"alert: TestAlert\n expr: rate(metric[5m]) > 1"}}`,
}
result, err := convertAlertRuleToModel(rule, &logtest.Fake{}, AlertRuleConvertOptions{
ExcludeAlertQueries: true,
ExcludeNotificationSettings: true,
ExcludeMetadata: false,
})
require.NoError(t, err)
// Should have compact query data (only datasource UIDs).
require.Len(t, result.Data, 1)
require.Equal(t, "ds1", result.Data[0].DatasourceUID)
require.Empty(t, result.Data[0].RefID)
// Should not have notification settings.
require.Empty(t, result.NotificationSettings)
// Should have metadata for filtering.
require.NotEqual(t, ngmodels.AlertRuleMetadata{}, result.Metadata)
require.True(t, result.HasPrometheusRuleDefinition())
})
}
func TestAlertRuleVersionToAlertRule(t *testing.T) {

View File

@@ -2167,7 +2167,7 @@ func (cfg *Cfg) readProvisioningSettings(iniFile *ini.File) error {
}
cfg.ProvisioningAllowedTargets = iniFile.Section("provisioning").Key("allowed_targets").Strings("|")
if len(cfg.ProvisioningAllowedTargets) == 0 {
cfg.ProvisioningAllowedTargets = []string{"instance", "folder"}
cfg.ProvisioningAllowedTargets = []string{"folder"}
}
cfg.ProvisioningAllowImageRendering = iniFile.Section("provisioning").Key("allow_image_rendering").MustBool(true)
cfg.ProvisioningMinSyncInterval = iniFile.Section("provisioning").Key("min_sync_interval").MustDuration(10 * time.Second)

View File

@@ -44,6 +44,7 @@ func TestIntegrationProvisioning_ExportUnifiedToRepository(t *testing.T) {
const repo = "local-repository"
testRepo := TestRepo{
Name: repo,
Target: "instance", // Export is only supported for instance sync
Copies: map[string]string{}, // No initial files needed for export test
ExpectedDashboards: 4, // 4 dashboards created above (v0, v1, v2alpha1, v2beta1)
ExpectedFolders: 0, // No folders expected after sync
@@ -177,6 +178,7 @@ func TestIntegrationProvisioning_ExportDashboardsWithStoredVersions(t *testing.T
const repo = "version-test-repository"
testRepo := TestRepo{
Name: repo,
Target: "instance", // Export is only supported for instance sync
Copies: map[string]string{},
ExpectedDashboards: len(tests),
ExpectedFolders: 0,

View File

@@ -695,6 +695,9 @@ func runGrafana(t *testing.T, options ...grafanaOption) *provisioningTestHelper
},
},
PermittedProvisioningPaths: ".|" + provisioningPath,
// Allow both folder and instance sync targets for tests
// (instance is needed for export jobs, folder for most operations)
ProvisioningAllowedTargets: []string{"folder", "instance"},
}
for _, o := range options {
o(&opts)

View File

@@ -24,10 +24,10 @@ func TestIntegrationProvisioning_JobValidation(t *testing.T) {
const repo = "job-validation-test-repo"
testRepo := TestRepo{
Name: repo,
Target: "instance",
Target: "folder",
Copies: map[string]string{},
ExpectedDashboards: 0,
ExpectedFolders: 0,
ExpectedFolders: 1, // folder sync creates a folder
}
helper.CreateRepo(t, testRepo)

View File

@@ -24,14 +24,15 @@ func TestIntegrationProvisioning_MoveJob(t *testing.T) {
ctx := context.Background()
const repo = "move-test-repo"
testRepo := TestRepo{
Name: repo,
Name: repo,
Target: "folder",
Copies: map[string]string{
"testdata/all-panels.json": "dashboard1.json",
"testdata/text-options.json": "dashboard2.json",
"testdata/timeline-demo.json": "folder/dashboard3.json",
},
ExpectedDashboards: 3,
ExpectedFolders: 1,
ExpectedFolders: 2, // folder sync creates a folder for the repo + one nested folder
}
helper.CreateRepo(t, testRepo)
@@ -236,6 +237,7 @@ func TestIntegrationProvisioning_MoveJob(t *testing.T) {
const refRepo = "move-ref-test-repo"
helper.CreateRepo(t, TestRepo{
Name: refRepo,
Target: "folder",
SkipResourceAssertions: true, // HACK: I am not sure why sometimes it's 6 or 3 dashbaords.
})

View File

@@ -578,7 +578,13 @@ func TestIntegrationProvisioning_RunLocalRepository(t *testing.T) {
const targetPath = "all-panels.json"
// Set up the repository.
helper.CreateRepo(t, TestRepo{Name: repo})
helper.CreateRepo(t, TestRepo{
Name: repo,
Target: "folder",
ExpectedDashboards: 0,
ExpectedFolders: 1, // folder sync creates a folder for the repo
SkipResourceAssertions: false,
})
// Write a file -- this will create it *both* in the local file system, and in grafana
t.Run("write all panels", func(t *testing.T) {
@@ -744,10 +750,10 @@ func TestIntegrationProvisioning_ImportAllPanelsFromLocalRepository(t *testing.T
// Set up the repository and the file to import.
testRepo := TestRepo{
Name: repo,
Target: "instance",
Target: "folder",
Copies: map[string]string{"testdata/all-panels.json": "all-panels.json"},
ExpectedDashboards: 1,
ExpectedFolders: 0,
ExpectedFolders: 1, // folder sync creates a folder
}
// We create the repository
helper.CreateRepo(t, testRepo)

View File

@@ -21,13 +21,14 @@ func TestIntegrationProvisioning_Stats(t *testing.T) {
const repo = "stats-test-repo1"
testRepo := TestRepo{
Name: repo,
Name: repo,
Target: "folder",
Copies: map[string]string{
"testdata/all-panels.json": "dashboard1.json",
"testdata/text-options.json": "folder/dashboard2.json",
},
ExpectedDashboards: 2,
ExpectedFolders: 1,
ExpectedFolders: 2, // folder sync creates a folder for the repo + one nested folder
}
helper.CreateRepo(t, testRepo)
@@ -94,7 +95,7 @@ func TestIntegrationProvisioning_Stats(t *testing.T) {
require.Equal(t, int64(2), count, "repo should manage 2 dashboards")
} else if group == "folder.grafana.app" && resource == "folders" {
count, _, _ := unstructured.NestedInt64(stat, "count")
require.Equal(t, int64(1), count, "repo should manage 1 folder")
require.Equal(t, int64(2), count, "repo should manage 2 folders (repo folder + nested folder)")
}
}
}

View File

@@ -580,6 +580,12 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) {
_, err = pathsSect.NewKey("permitted_provisioning_paths", opts.PermittedProvisioningPaths)
require.NoError(t, err)
}
if len(opts.ProvisioningAllowedTargets) > 0 {
provisioningSect, err := getOrCreateSection("provisioning")
require.NoError(t, err)
_, err = provisioningSect.NewKey("allowed_targets", strings.Join(opts.ProvisioningAllowedTargets, "|"))
require.NoError(t, err)
}
if opts.EnableSCIM {
scimSection, err := getOrCreateSection("auth.scim")
require.NoError(t, err)
@@ -669,6 +675,7 @@ type GrafanaOpts struct {
UnifiedStorageEnableSearch bool
UnifiedStorageMaxPageSizeBytes int
PermittedProvisioningPaths string
ProvisioningAllowedTargets []string
GrafanaComSSOAPIToken string
LicensePath string
EnableRecordingRules bool

View File

@@ -41,6 +41,7 @@ import {
setCorrelationsService,
setPluginFunctionsHook,
setMegaMenuOpenHook,
setDashboardSceneJsonApiV2,
} from '@grafana/runtime';
import {
initOpenFeature,
@@ -85,6 +86,7 @@ import { startMeasure, stopMeasure } from './core/utils/metrics';
import { initAlerting } from './features/alerting/unified/initAlerting';
import { initAuthConfig } from './features/auth-config';
import { getTimeSrv } from './features/dashboard/services/TimeSrv';
import { dashboardSceneJsonApiV2 } from './features/dashboard-scene/api/runtimeDashboardSceneJsonApiV2';
import { EmbeddedDashboardLazy } from './features/dashboard-scene/embedding/EmbeddedDashboardLazy';
import { DashboardLevelTimeMacro } from './features/dashboard-scene/scene/DashboardLevelTimeMacro';
import { initGrafanaLive } from './features/live';
@@ -251,6 +253,10 @@ export class GrafanaApp {
const dataSourceSrv = new DatasourceSrv();
dataSourceSrv.init(config.datasources, config.defaultDatasource);
setDataSourceSrv(dataSourceSrv);
// Expose current-dashboard schema-v2 JSON APIs to plugins via @grafana/runtime
setDashboardSceneJsonApiV2(dashboardSceneJsonApiV2);
initWindowRuntime();
// Do not pre-load apps if rendererDisableAppPluginsPreload is true and the request comes from the image renderer

View File

@@ -1,3 +1,10 @@
---
title: Alerting Squad Guidelines
description: Alerting-specific patterns and conventions for Grafana
globs:
- 'public/app/features/alerting/**'
---
# Alerting Squad - Claude Code Configuration
This file provides context for Claude Code when working on the Grafana Alerting codebase. It contains alerting-specific patterns and references to Grafana's coding standards.

View File

@@ -0,0 +1,73 @@
import { getCurrentDashboardErrors } from './currentDashboardErrors';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
config: {
featureToggles: {
kubernetesDashboards: true,
kubernetesDashboardsV2: true,
dashboardNewLayouts: false,
},
},
}));
jest.mock('../pages/DashboardScenePageStateManager', () => ({
getDashboardScenePageStateManager: jest.fn(),
}));
jest.mock('../utils/dashboardSceneGraph', () => ({
dashboardSceneGraph: {
getVizPanels: jest.fn(),
},
}));
jest.mock('../utils/utils', () => ({
getPanelIdForVizPanel: jest.fn(),
getQueryRunnerFor: jest.fn(),
}));
describe('getCurrentDashboardErrors', () => {
const { getDashboardScenePageStateManager } = jest.requireMock('../pages/DashboardScenePageStateManager');
const { dashboardSceneGraph } = jest.requireMock('../utils/dashboardSceneGraph');
const { getPanelIdForVizPanel, getQueryRunnerFor } = jest.requireMock('../utils/utils');
beforeEach(() => {
jest.clearAllMocks();
});
it('returns an empty list when there are no query errors', () => {
getDashboardScenePageStateManager.mockReturnValue({ state: { dashboard: {} } });
dashboardSceneGraph.getVizPanels.mockReturnValue([{ state: { title: 'P1' } }]);
getPanelIdForVizPanel.mockReturnValue(1);
getQueryRunnerFor.mockReturnValue({ state: { data: { errors: [] } } });
expect(getCurrentDashboardErrors()).toEqual([]);
});
it('returns per-panel error summaries with refId and datasource when available', () => {
const panel = { state: { title: 'My panel' } };
getDashboardScenePageStateManager.mockReturnValue({ state: { dashboard: {} } });
dashboardSceneGraph.getVizPanels.mockReturnValue([panel]);
getPanelIdForVizPanel.mockReturnValue(42);
getQueryRunnerFor.mockReturnValue({
state: {
datasource: { uid: 'fallback-ds' },
queries: [{ refId: 'A', datasource: { name: 'gdev-mysql' } }],
data: { errors: [{ message: 'boom', refId: 'A' }] },
},
});
expect(getCurrentDashboardErrors()).toEqual([
{
panelId: 42,
panelTitle: 'My panel',
refId: 'A',
datasource: 'gdev-mysql',
message: 'boom',
severity: 'error',
},
]);
});
});

View File

@@ -0,0 +1,119 @@
import { DataQueryError, DataQueryErrorType } from '@grafana/data';
import { config } from '@grafana/runtime';
import type { SceneDataQuery, SceneQueryRunner, VizPanel } from '@grafana/scenes';
import { getDashboardScenePageStateManager } from '../pages/DashboardScenePageStateManager';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
import { getPanelIdForVizPanel, getQueryRunnerFor } from '../utils/utils';
export type DashboardPanelErrorSeverity = 'error' | 'warning';
export interface DashboardPanelErrorSummary {
panelId: number;
panelTitle: string;
refId?: string;
datasource?: string;
message: string;
severity: DashboardPanelErrorSeverity;
}
function assertDashboardV2Enabled() {
const isKubernetesDashboardsEnabled = Boolean(config.featureToggles.kubernetesDashboards);
const isV2Enabled = Boolean(config.featureToggles.kubernetesDashboardsV2 || config.featureToggles.dashboardNewLayouts);
if (!isKubernetesDashboardsEnabled || !isV2Enabled) {
throw new Error('V2 dashboard kinds API requires kubernetes dashboards v2 to be enabled');
}
}
function getCurrentDashboardScene() {
const mgr = getDashboardScenePageStateManager();
const dashboard = mgr.state.dashboard;
if (!dashboard) {
throw new Error('No dashboard is currently open');
}
return dashboard;
}
function toMessage(err: DataQueryError): string {
return err.message || err.data?.message || err.data?.error || 'Query error';
}
function toSeverity(err: DataQueryError): DashboardPanelErrorSeverity {
// Treat cancellations as warnings; everything else is an error.
if (err.type === DataQueryErrorType.Cancelled) {
return 'warning';
}
return 'error';
}
function formatDatasourceRef(ds: unknown): string | undefined {
if (!ds || typeof ds !== 'object') {
return undefined;
}
const obj = ds as Record<string, unknown>;
const uid = obj.uid;
const name = obj.name;
const type = obj.type;
if (typeof uid === 'string' && uid.length) {
return uid;
}
if (typeof name === 'string' && name.length) {
return name;
}
if (typeof type === 'string' && type.length) {
return type;
}
return undefined;
}
function getDatasourceForError(queryRunner: SceneQueryRunner, refId?: string): string | undefined {
const queries = (queryRunner.state.queries ?? []) as SceneDataQuery[];
const q = refId ? queries.find((qq) => qq.refId === refId) : undefined;
const ds = q?.datasource ?? queryRunner.state.datasource;
return formatDatasourceRef(ds);
}
export function getCurrentDashboardErrors(): DashboardPanelErrorSummary[] {
assertDashboardV2Enabled();
const dashboard = getCurrentDashboardScene();
const panels = dashboardSceneGraph.getVizPanels(dashboard);
const out: DashboardPanelErrorSummary[] = [];
for (const panel of panels) {
const queryRunner = getQueryRunnerFor(panel);
const errors = queryRunner?.state.data?.errors ?? [];
if (!queryRunner || errors.length === 0) {
continue;
}
const panelId = getPanelIdForVizPanel(panel);
const panelTitle = (panel as VizPanel).state.title ?? '';
for (const err of errors) {
const refId = err.refId;
out.push({
panelId,
panelTitle,
refId,
datasource: getDatasourceForError(queryRunner, refId),
message: toMessage(err),
severity: toSeverity(err),
});
}
}
// Stable ordering for LLM consumption.
out.sort((a, b) => {
if (a.panelId !== b.panelId) {
return a.panelId - b.panelId;
}
return (a.refId ?? '').localeCompare(b.refId ?? '');
});
return out;
}

View File

@@ -0,0 +1,68 @@
import { defaultSpec as defaultDashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { getCurrentDashboardKindV2 } from './currentDashboardKindV2';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
config: {
featureToggles: {
kubernetesDashboards: true,
kubernetesDashboardsV2: true,
dashboardNewLayouts: false,
},
},
}));
jest.mock('../pages/DashboardScenePageStateManager', () => ({
getDashboardScenePageStateManager: jest.fn(),
}));
describe('getCurrentDashboardKindV2', () => {
const { getDashboardScenePageStateManager } = jest.requireMock('../pages/DashboardScenePageStateManager');
beforeEach(() => {
jest.clearAllMocks();
});
it('returns v2beta1 Dashboard kind JSON for the currently open dashboard', () => {
const spec = defaultDashboardV2Spec();
const dashboard = {
state: {
uid: 'dash-uid',
meta: {
k8s: {
name: 'dash-uid',
resourceVersion: '1',
creationTimestamp: 'now',
annotations: {},
labels: {},
},
},
},
getSaveResource: () => ({
apiVersion: 'dashboard.grafana.app/v2beta1',
kind: 'Dashboard',
metadata: { name: 'dash-uid' },
spec,
}),
};
getDashboardScenePageStateManager.mockReturnValue({
state: { dashboard },
});
const res = getCurrentDashboardKindV2();
expect(res.apiVersion).toBe('dashboard.grafana.app/v2beta1');
expect(res.kind).toBe('Dashboard');
expect(res.metadata.name).toBe('dash-uid');
expect(res.spec).toBe(spec);
});
it('throws if no dashboard is currently open', () => {
getDashboardScenePageStateManager.mockReturnValue({ state: { dashboard: undefined } });
expect(() => getCurrentDashboardKindV2()).toThrow('No dashboard is currently open');
});
});

View File

@@ -0,0 +1,70 @@
import { config } from '@grafana/runtime';
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { Status } from '@grafana/schema/src/schema/dashboard/v2';
import { Resource } from 'app/features/apiserver/types';
import { isDashboardV2Spec } from 'app/features/dashboard/api/utils';
import { getDashboardScenePageStateManager } from '../pages/DashboardScenePageStateManager';
function assertDashboardV2Enabled() {
const isKubernetesDashboardsEnabled = Boolean(config.featureToggles.kubernetesDashboards);
const isV2Enabled = Boolean(config.featureToggles.kubernetesDashboardsV2 || config.featureToggles.dashboardNewLayouts);
if (!isKubernetesDashboardsEnabled || !isV2Enabled) {
throw new Error('V2 dashboard kinds API requires kubernetes dashboards v2 to be enabled');
}
}
function getCurrentDashboardScene() {
const mgr = getDashboardScenePageStateManager();
const dashboard = mgr.state.dashboard;
if (!dashboard) {
throw new Error('No dashboard is currently open');
}
return dashboard;
}
/**
* Returns the currently open dashboard as a v2beta1 Dashboard kind JSON resource.
*
* Note: This is intentionally scoped to the current `DashboardScene` only (no lookups by UID).
*/
export function getCurrentDashboardKindV2(): Resource<DashboardV2Spec, Status, 'Dashboard'> {
assertDashboardV2Enabled();
const scene = getCurrentDashboardScene();
// Use the scenes canonical “save resource” representation to avoid hand-assembling fields.
const saveResource = scene.getSaveResource({ isNew: !scene.state.uid });
if (saveResource.apiVersion !== 'dashboard.grafana.app/v2beta1' || saveResource.kind !== 'Dashboard') {
throw new Error('Current dashboard is not a v2beta1 Dashboard resource');
}
const spec = saveResource.spec as unknown;
if (!isDashboardV2Spec(spec)) {
throw new Error('Current dashboard is not using schema v2 spec');
}
const k8sMeta = scene.state.meta.k8s;
if (!k8sMeta) {
throw new Error('Current dashboard is missing Kubernetes metadata');
}
return {
apiVersion: saveResource.apiVersion,
kind: 'Dashboard',
metadata: {
...k8sMeta,
// Prefer the metadata coming from the save resource for name/generateName if present.
name: (saveResource.metadata?.name ?? k8sMeta.name) as string,
namespace: saveResource.metadata?.namespace ?? k8sMeta.namespace,
labels: saveResource.metadata?.labels ?? k8sMeta.labels,
annotations: saveResource.metadata?.annotations ?? k8sMeta.annotations,
},
spec,
// We currently dont persist/status-sync status in the scene; keep it stable and non-authoritative.
status: {} as Status,
};
}

View File

@@ -0,0 +1,196 @@
import { defaultSpec as defaultDashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { getCurrentDashboardKindV2 } from './currentDashboardKindV2';
import { applyCurrentDashboardKindV2, applyCurrentDashboardSpecV2 } from './currentDashboardSpecApplyV2';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
config: {
featureToggles: {
kubernetesDashboards: true,
kubernetesDashboardsV2: true,
dashboardNewLayouts: false,
},
},
}));
jest.mock('../pages/DashboardScenePageStateManager', () => ({
getDashboardScenePageStateManager: jest.fn(),
}));
jest.mock('../serialization/transformSaveModelSchemaV2ToScene', () => ({
transformSaveModelSchemaV2ToScene: jest.fn(),
}));
describe('current dashboard spec apply API', () => {
const { getDashboardScenePageStateManager } = jest.requireMock('../pages/DashboardScenePageStateManager');
const { transformSaveModelSchemaV2ToScene } = jest.requireMock('../serialization/transformSaveModelSchemaV2ToScene');
beforeEach(() => {
jest.clearAllMocks();
});
it('applyCurrentDashboardSpecV2 swaps in a new scene immediately and marks it dirty', () => {
const currentSpec = defaultDashboardV2Spec();
const nextSpec = { ...defaultDashboardV2Spec(), title: 'new title' };
const currentScene = {
state: {
uid: 'dash-uid',
meta: {
url: '/d/dash-uid/slug',
slug: 'slug',
canSave: true,
canEdit: true,
canDelete: true,
canShare: true,
canStar: true,
canAdmin: true,
publicDashboardEnabled: false,
k8s: {
name: 'dash-uid',
resourceVersion: '1',
creationTimestamp: 'now',
annotations: {},
labels: {},
},
},
isEditing: true,
},
getSaveModel: () => currentSpec,
getInitialSaveModel: () => currentSpec,
};
const nextScene = {
onEnterEditMode: jest.fn(),
setState: jest.fn(),
setInitialSaveModel: jest.fn(),
};
transformSaveModelSchemaV2ToScene.mockReturnValue(nextScene);
const mgr = {
state: { dashboard: currentScene },
setSceneCache: jest.fn(),
setState: jest.fn(),
};
getDashboardScenePageStateManager.mockReturnValue(mgr);
applyCurrentDashboardSpecV2(nextSpec);
expect(transformSaveModelSchemaV2ToScene).toHaveBeenCalledTimes(1);
expect(nextScene.setInitialSaveModel).toHaveBeenCalledWith(currentSpec, currentScene.state.meta.k8s, 'dashboard.grafana.app/v2beta1');
expect(nextScene.onEnterEditMode).toHaveBeenCalled();
expect(nextScene.setState).toHaveBeenCalledWith({ isDirty: true });
expect(mgr.setSceneCache).toHaveBeenCalledWith('dash-uid', nextScene);
expect(mgr.setState).toHaveBeenCalledWith({ dashboard: nextScene });
});
it('applyCurrentDashboardSpecV2 does not mark dirty when the applied spec matches the saved baseline', () => {
const currentSpec = defaultDashboardV2Spec();
const nextSpec = { ...currentSpec };
const currentScene = {
state: {
uid: 'dash-uid',
meta: {
url: '/d/dash-uid/slug',
slug: 'slug',
canSave: true,
canEdit: true,
canDelete: true,
canShare: true,
canStar: true,
canAdmin: true,
publicDashboardEnabled: false,
k8s: {
name: 'dash-uid',
resourceVersion: '1',
creationTimestamp: 'now',
annotations: {},
labels: {},
},
},
isEditing: true,
},
getSaveModel: () => currentSpec,
getInitialSaveModel: () => currentSpec,
};
const nextScene = {
onEnterEditMode: jest.fn(),
setState: jest.fn(),
setInitialSaveModel: jest.fn(),
};
transformSaveModelSchemaV2ToScene.mockReturnValue(nextScene);
const mgr = {
state: { dashboard: currentScene },
setSceneCache: jest.fn(),
setState: jest.fn(),
};
getDashboardScenePageStateManager.mockReturnValue(mgr);
applyCurrentDashboardSpecV2(nextSpec);
expect(nextScene.setState).toHaveBeenCalledWith({ isDirty: false });
});
it('applyCurrentDashboardKindV2 rejects metadata changes and applies only spec when unchanged', () => {
const spec = defaultDashboardV2Spec();
const currentScene = {
state: {
uid: 'dash-uid',
meta: {
url: '/d/dash-uid/slug',
slug: 'slug',
canSave: true,
canEdit: true,
canDelete: true,
canShare: true,
canStar: true,
canAdmin: true,
publicDashboardEnabled: false,
k8s: {
name: 'dash-uid',
resourceVersion: '1',
creationTimestamp: 'now',
annotations: {},
labels: {},
},
},
isEditing: true,
},
getSaveModel: () => spec,
getSaveResource: () => ({
apiVersion: 'dashboard.grafana.app/v2beta1',
kind: 'Dashboard',
metadata: { name: 'dash-uid' },
spec,
}),
};
const nextScene = { onEnterEditMode: jest.fn(), setState: jest.fn() };
transformSaveModelSchemaV2ToScene.mockReturnValue(nextScene);
const mgr = { state: { dashboard: currentScene }, setSceneCache: jest.fn(), setState: jest.fn() };
getDashboardScenePageStateManager.mockReturnValue(mgr);
const current = getCurrentDashboardKindV2();
expect(() =>
applyCurrentDashboardKindV2({
...current,
metadata: {
...current.metadata,
annotations: { ...(current.metadata.annotations ?? {}), 'grafana.app/message': 'changed' },
},
})
).toThrow('Changing metadata is not allowed');
});
});

View File

@@ -0,0 +1,135 @@
import { isEqual } from 'lodash';
import { config } from '@grafana/runtime';
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { Resource } from 'app/features/apiserver/types';
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
import { isDashboardV2Spec } from 'app/features/dashboard/api/utils';
import { getDashboardScenePageStateManager } from '../pages/DashboardScenePageStateManager';
import { transformSaveModelSchemaV2ToScene } from '../serialization/transformSaveModelSchemaV2ToScene';
import { validateDashboardSchemaV2 } from '../serialization/transformSceneToSaveModelSchemaV2';
import { getCurrentDashboardKindV2 } from './currentDashboardKindV2';
function assertDashboardV2Enabled() {
const isKubernetesDashboardsEnabled = Boolean(config.featureToggles.kubernetesDashboards);
const isV2Enabled = Boolean(config.featureToggles.kubernetesDashboardsV2 || config.featureToggles.dashboardNewLayouts);
if (!isKubernetesDashboardsEnabled || !isV2Enabled) {
throw new Error('V2 dashboard kinds API requires kubernetes dashboards v2 to be enabled');
}
}
function getCurrentSceneOrThrow() {
const mgr = getDashboardScenePageStateManager();
const dashboard = mgr.state.dashboard;
if (!dashboard) {
throw new Error('No dashboard is currently open');
}
return { mgr, dashboard };
}
/**
* Immediately applies a schema-v2 dashboard spec to the currently open `DashboardScene`.
*
* This is a **spec-only** mutation API: it does not allow changing `apiVersion`, `kind`, `metadata`, or `status`.
*/
export function applyCurrentDashboardSpecV2(nextSpec: DashboardV2Spec): void {
assertDashboardV2Enabled();
// Validate (throws on error)
validateDashboardSchemaV2(nextSpec);
const { mgr, dashboard: currentScene } = getCurrentSceneOrThrow();
// Only operate on v2 scenes
const currentModel = currentScene.getSaveModel();
if (!isDashboardV2Spec(currentModel)) {
throw new Error('Current dashboard is not using schema v2');
}
const currentResource = getCurrentDashboardKindV2();
const k8sMeta = currentResource.metadata;
if (!k8sMeta) {
throw new Error('Current dashboard is missing Kubernetes metadata');
}
// Rebuild a new scene from the current immutable wrapper (metadata/access) + new spec.
// This guarantees the UI updates immediately to match the JSON.
const dto: DashboardWithAccessInfo<DashboardV2Spec> = {
apiVersion: 'dashboard.grafana.app/v2beta1',
kind: 'DashboardWithAccessInfo',
metadata: k8sMeta,
spec: nextSpec,
status: {},
access: {
url: currentScene.state.meta.url,
slug: currentScene.state.meta.slug,
canSave: currentScene.state.meta.canSave,
canEdit: currentScene.state.meta.canEdit,
canDelete: currentScene.state.meta.canDelete,
canShare: currentScene.state.meta.canShare,
canStar: currentScene.state.meta.canStar,
canAdmin: currentScene.state.meta.canAdmin,
annotationsPermissions: currentScene.state.meta.annotationsPermissions,
isPublic: currentScene.state.meta.publicDashboardEnabled,
},
};
const nextScene = transformSaveModelSchemaV2ToScene(dto);
// IMPORTANT: Preserve the *saved baseline* (initialSaveModel) from the currently loaded dashboard,
// otherwise the change tracker will treat the applied spec as the new baseline and the dashboard
// won't become saveable (Save button stays non-primary).
const initialSaveModel = currentScene.getInitialSaveModel();
const hasBaseline = Boolean(initialSaveModel && isDashboardV2Spec(initialSaveModel));
if (initialSaveModel && isDashboardV2Spec(initialSaveModel)) {
nextScene.setInitialSaveModel(initialSaveModel, k8sMeta, 'dashboard.grafana.app/v2beta1');
}
// Preserve edit/view mode. Don't force-enter edit mode; that's a UI side effect.
if (currentScene.state.isEditing) {
nextScene.onEnterEditMode();
}
// Set dirty based on the saved baseline (if present). This prevents the save button from being
// stuck "blue" when the applied spec matches the baseline.
const shouldBeDirty = hasBaseline ? !isEqual(nextSpec, initialSaveModel) : true;
nextScene.setState({ isDirty: shouldBeDirty });
// Keep cache coherent for the currently open dashboard
if (currentScene.state.uid) {
mgr.setSceneCache(currentScene.state.uid, nextScene);
}
mgr.setState({ dashboard: nextScene });
}
/**
* Convenience helper that accepts a full Dashboard kind JSON object, but enforces **spec-only** updates.
*
* It rejects any attempt to change `apiVersion`, `kind`, `metadata`, or `status` from the currently open dashboard.
*/
export function applyCurrentDashboardKindV2(resource: Resource<DashboardV2Spec, unknown, 'Dashboard'>): void {
assertDashboardV2Enabled();
const current = getCurrentDashboardKindV2();
if (!isEqual(resource.apiVersion, current.apiVersion)) {
throw new Error('Changing apiVersion is not allowed');
}
if (!isEqual(resource.kind, current.kind)) {
throw new Error('Changing kind is not allowed');
}
if (!isEqual(resource.metadata, current.metadata)) {
throw new Error('Changing metadata is not allowed');
}
if ('status' in resource && !isEqual(resource.status, current.status)) {
throw new Error('Changing status is not allowed');
}
applyCurrentDashboardSpecV2(resource.spec);
}

View File

@@ -0,0 +1,157 @@
import { config, locationService } from '@grafana/runtime';
import { CustomVariable, SceneTimeRange, TextBoxVariable, sceneGraph } from '@grafana/scenes';
jest.mock('../pages/DashboardScenePageStateManager', () => ({
getDashboardScenePageStateManager: jest.fn(),
}));
jest.mock('../utils/dashboardSceneGraph', () => ({
dashboardSceneGraph: {
getVizPanels: jest.fn(() => []),
},
}));
jest.mock('../utils/utils', () => ({
getPanelIdForVizPanel: jest.fn(),
getQueryRunnerFor: jest.fn(),
}));
import { getDashboardScenePageStateManager } from '../pages/DashboardScenePageStateManager';
import { RowItem } from '../scene/layout-rows/RowItem';
import { TabItem } from '../scene/layout-tabs/TabItem';
import { TabsLayoutManager } from '../scene/layout-tabs/TabsLayoutManager';
import { dashboardSceneJsonApiV2 } from './runtimeDashboardSceneJsonApiV2';
describe('dashboardSceneJsonApiV2 (navigation/variables/time)', () => {
beforeEach(() => {
jest.restoreAllMocks();
// Enable v2 API gate for tests.
config.featureToggles.kubernetesDashboards = true;
config.featureToggles.kubernetesDashboardsV2 = true;
jest.spyOn(locationService, 'partial').mockImplementation(() => {});
});
it('getCurrentDashboardVariables returns a stable JSON shape and applyCurrentDashboardVariables updates values', () => {
const customVar = new CustomVariable({ name: 'customVar', query: 'a,b,c', value: 'a', text: 'a' });
const textVar = new TextBoxVariable({ type: 'textbox', name: 'tb', value: 'x' });
const dashboard = {
state: {
$variables: { state: { variables: [customVar, textVar] } },
},
publishEvent: jest.fn(),
};
(getDashboardScenePageStateManager as jest.Mock).mockReturnValue({ state: { dashboard } });
const before = JSON.parse(dashboardSceneJsonApiV2.getCurrentDashboardVariables(0));
expect(before.variables).toEqual(
expect.arrayContaining([
{ name: 'customVar', value: 'a' },
{ name: 'tb', value: 'x' },
])
);
dashboardSceneJsonApiV2.applyCurrentDashboardVariables(JSON.stringify({ customVar: ['b', 'c'], tb: 'y' }));
expect(customVar.getValue()).toEqual(['b', 'c']);
expect(textVar.getValue()).toBe('y');
expect(locationService.partial).toHaveBeenCalledWith({ 'var-customVar': ['b', 'c'] }, true);
expect(locationService.partial).toHaveBeenCalledWith({ 'var-tb': 'y' }, true);
});
it('getCurrentDashboardTimeRange returns raw values and applyCurrentDashboardTimeRange calls SceneTimeRange APIs', () => {
const tr = new SceneTimeRange({ from: 'now-1h', to: 'now', timeZone: 'browser' });
const tzSpy = jest.spyOn(tr, 'onTimeZoneChange');
const trSpy = jest.spyOn(tr, 'onTimeRangeChange');
const refreshSpy = jest.spyOn(tr, 'onRefresh');
jest.spyOn(sceneGraph, 'getTimeRange').mockReturnValue(tr);
const dashboard = { state: {}, publishEvent: jest.fn() };
(getDashboardScenePageStateManager as jest.Mock).mockReturnValue({ state: { dashboard } });
const current = JSON.parse(dashboardSceneJsonApiV2.getCurrentDashboardTimeRange(0));
expect(current).toEqual({ from: 'now-1h', to: 'now', timezone: 'browser' });
dashboardSceneJsonApiV2.applyCurrentDashboardTimeRange(JSON.stringify({ from: 'now-6h', to: 'now', timezone: 'utc' }));
expect(tzSpy).toHaveBeenCalledWith('utc');
expect(trSpy).toHaveBeenCalled();
expect(refreshSpy).toHaveBeenCalled();
});
it('selectCurrentDashboardTab selects a matching tab by title', () => {
const tabA = new TabItem({ key: 'tab-a', title: 'A' });
const tabB = new TabItem({ key: 'tab-b', title: 'B' });
const tabs = new TabsLayoutManager({ tabs: [tabA, tabB] });
const dashboard = {
state: { body: tabs },
publishEvent: jest.fn(),
};
(getDashboardScenePageStateManager as jest.Mock).mockReturnValue({ state: { dashboard } });
const spy = jest.spyOn(tabs, 'switchToTab');
dashboardSceneJsonApiV2.selectCurrentDashboardTab(JSON.stringify({ title: 'B' }));
expect(spy).toHaveBeenCalledWith(tabB);
});
it('getCurrentDashboardNavigation returns the active tab', () => {
const tabA = new TabItem({ key: 'tab-a', title: 'Overview' });
const tabB = new TabItem({ key: 'tab-b', title: 'Explore' });
const tabs = new TabsLayoutManager({ tabs: [tabA, tabB], currentTabSlug: tabB.getSlug() });
const dashboard = {
state: { body: tabs },
publishEvent: jest.fn(),
};
(getDashboardScenePageStateManager as jest.Mock).mockReturnValue({ state: { dashboard } });
const nav = JSON.parse(dashboardSceneJsonApiV2.getCurrentDashboardNavigation(0));
expect(nav).toEqual({ tab: { slug: tabB.getSlug(), title: 'Explore' } });
});
it('getCurrentDashboardSelection returns null when not editing', () => {
const editPane = { getSelection: jest.fn(() => undefined) };
const dashboard = { state: { isEditing: false, editPane }, publishEvent: jest.fn() };
(getDashboardScenePageStateManager as jest.Mock).mockReturnValue({ state: { dashboard } });
const res = JSON.parse(dashboardSceneJsonApiV2.getCurrentDashboardSelection(0));
expect(res).toEqual({ isEditing: false, selection: null });
});
it('getCurrentDashboardSelection returns the selected tab context when editing', () => {
const selectedTab = new TabItem({ key: 'tab-a', title: 'Overview' });
const editPane = { getSelection: jest.fn(() => selectedTab) };
const dashboard = { state: { isEditing: true, editPane }, publishEvent: jest.fn() };
(getDashboardScenePageStateManager as jest.Mock).mockReturnValue({ state: { dashboard } });
const res = JSON.parse(dashboardSceneJsonApiV2.getCurrentDashboardSelection(0));
expect(res.isEditing).toBe(true);
expect(res.selection.mode).toBe('single');
expect(res.selection.item).toEqual(
expect.objectContaining({ type: 'tab', tabSlug: selectedTab.getSlug(), title: 'Overview', key: 'tab-a' })
);
});
it('focusCurrentDashboardRow expands a collapsed row and calls scrollIntoView', () => {
const row = new RowItem({ title: 'request duration', collapse: true });
const scrollSpy = jest.spyOn(row, 'scrollIntoView').mockImplementation(() => {});
jest.spyOn(sceneGraph, 'findAllObjects').mockReturnValue([row]);
const dashboard = { state: {}, publishEvent: jest.fn() };
(getDashboardScenePageStateManager as jest.Mock).mockReturnValue({ state: { dashboard } });
dashboardSceneJsonApiV2.focusCurrentDashboardRow(JSON.stringify({ title: 'request duration' }));
expect(row.getCollapsedState()).toBe(false);
expect(scrollSpy).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,152 @@
import { config } from '@grafana/runtime';
import { SceneQueryRunner, VizPanel } from '@grafana/scenes';
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
import { RowItem } from '../scene/layout-rows/RowItem';
import { RowsLayoutManager } from '../scene/layout-rows/RowsLayoutManager';
import { dashboardSceneJsonApiV2 } from './runtimeDashboardSceneJsonApiV2';
jest.mock('../utils/utils', () => {
const actual = jest.requireActual('../utils/utils');
return {
...actual,
getDefaultVizPanel: jest.fn(),
};
});
jest.mock('../pages/DashboardScenePageStateManager', () => ({
getDashboardScenePageStateManager: jest.fn(),
}));
jest.mock('../utils/dashboardSceneGraph', () => ({
dashboardSceneGraph: {
getVizPanels: jest.fn(),
},
}));
describe('dashboardSceneJsonApiV2 (ops)', () => {
const { getDashboardScenePageStateManager } = jest.requireMock('../pages/DashboardScenePageStateManager');
const { dashboardSceneGraph } = jest.requireMock('../utils/dashboardSceneGraph');
const { getDefaultVizPanel } = jest.requireMock('../utils/utils');
beforeEach(() => {
jest.clearAllMocks();
config.featureToggles.kubernetesDashboards = true;
config.featureToggles.kubernetesDashboardsV2 = true;
});
it('mergePanelConfig updates fieldConfig defaults in-place (preserves SceneQueryRunner identity)', () => {
const runner = new SceneQueryRunner({ queries: [], data: undefined });
const panel = new VizPanel({
key: 'panel-2',
title: 'Time series',
fieldConfig: { defaults: { unit: 'short' }, overrides: [] },
$data: runner,
});
const dashboard = {
state: { isEditing: true },
setState: jest.fn(),
};
getDashboardScenePageStateManager.mockReturnValue({ state: { dashboard } });
dashboardSceneGraph.getVizPanels.mockReturnValue([panel]);
const res = JSON.parse(
dashboardSceneJsonApiV2.applyCurrentDashboardOps(
JSON.stringify([
{
op: 'mergePanelConfig',
panelId: 2,
merge: { vizConfig: { fieldConfig: { defaults: { unit: 'ms' } } } },
},
])
)
);
expect(res.ok).toBe(true);
expect(res.applied).toBe(1);
expect(panel.state.fieldConfig.defaults.unit).toBe('ms');
expect(panel.state.$data).toBe(runner);
expect(dashboard.setState).toHaveBeenCalledWith({ isDirty: true });
});
it('addPanel uses the current layout addPanel (does not rebuild existing panels)', () => {
const existingRunner = new SceneQueryRunner({ queries: [], data: undefined });
const existingPanel = new VizPanel({
key: 'panel-1',
title: 'Existing',
$data: existingRunner,
fieldConfig: { defaults: {}, overrides: [] },
});
const newRunner = new SceneQueryRunner({ queries: [], data: undefined });
const newPanel = new VizPanel({
// key is assigned by layout.addPanel in real code; the op reads it afterwards.
title: 'New panel',
$data: newRunner,
fieldConfig: { defaults: {}, overrides: [] },
});
getDefaultVizPanel.mockReturnValue(newPanel);
const layout = {
addPanel: jest.fn((p: VizPanel) => {
// emulate DefaultGridLayoutManager assigning next panel id/key
p.setState({ key: 'panel-2' });
}),
};
const dashboard = {
state: { isEditing: true, body: layout },
setState: jest.fn(),
removePanel: jest.fn(),
};
getDashboardScenePageStateManager.mockReturnValue({ state: { dashboard } });
dashboardSceneGraph.getVizPanels.mockReturnValue([existingPanel]);
const res = JSON.parse(dashboardSceneJsonApiV2.applyCurrentDashboardOps(JSON.stringify([{ op: 'addPanel', title: 'Hello' }])));
expect(res.ok).toBe(true);
expect(res.applied).toBe(1);
expect(layout.addPanel).toHaveBeenCalledTimes(1);
expect(newPanel.state.key).toBe('panel-2');
// existing panel runner identity preserved
expect(existingPanel.state.$data).toBe(existingRunner);
});
it('movePanelToRow supports moving into a GridLayout row (DefaultGridLayoutManager)', () => {
const panel = new VizPanel({
key: 'panel-6',
title: 'Bar chart (steps)',
pluginId: 'barchart',
fieldConfig: { defaults: {}, overrides: [] },
$data: new SceneQueryRunner({ queries: [], data: undefined }),
});
const rowA = new RowItem({ title: 'Charts', layout: DefaultGridLayoutManager.fromVizPanels([panel]) });
const rowB = new RowItem({ title: 'Data', layout: DefaultGridLayoutManager.fromVizPanels([]) });
const rows = new RowsLayoutManager({ rows: [rowA, rowB] });
const dashboard = {
state: { isEditing: true, body: rows },
setState: jest.fn(),
};
getDashboardScenePageStateManager.mockReturnValue({ state: { dashboard } });
dashboardSceneGraph.getVizPanels.mockReturnValue([panel]);
const res = JSON.parse(
dashboardSceneJsonApiV2.applyCurrentDashboardOps(JSON.stringify([{ op: 'movePanelToRow', panelId: 6, rowTitle: 'Data' }]))
);
expect(res.ok).toBe(true);
expect(res.applied).toBe(1);
expect(rowA.state.layout.getVizPanels()).toHaveLength(0);
expect(rowB.state.layout.getVizPanels()).toContain(panel);
});
});

View File

@@ -0,0 +1,92 @@
import { dashboardSceneJsonApiV2 } from './runtimeDashboardSceneJsonApiV2';
jest.mock('./currentDashboardKindV2', () => ({
getCurrentDashboardKindV2: jest.fn(),
}));
jest.mock('./currentDashboardSpecApplyV2', () => ({
applyCurrentDashboardSpecV2: jest.fn(),
}));
describe('dashboardSceneJsonApiV2 (runtime adapter)', () => {
const { getCurrentDashboardKindV2 } = jest.requireMock('./currentDashboardKindV2');
const { applyCurrentDashboardSpecV2 } = jest.requireMock('./currentDashboardSpecApplyV2');
const baseResource = {
apiVersion: 'dashboard.grafana.app/v2beta1',
kind: 'Dashboard',
metadata: { name: 'dash-uid', namespace: 'default' },
spec: { title: 'x' },
status: {},
};
beforeEach(() => {
jest.clearAllMocks();
window.history.pushState({}, '', '/d/dash-uid/slug');
});
it('getCurrentDashboard returns cached JSON if live serialization fails', () => {
getCurrentDashboardKindV2.mockReturnValue(baseResource);
const first = dashboardSceneJsonApiV2.getCurrentDashboard(0);
expect(JSON.parse(first).metadata.name).toBe('dash-uid');
getCurrentDashboardKindV2.mockImplementation(() => {
throw new Error('Unsupported transformation type');
});
const second = dashboardSceneJsonApiV2.getCurrentDashboard(0);
expect(second).toBe(first);
});
it('applyCurrentDashboard uses cached baseline to enforce immutability if live serialization fails', () => {
// Prime cache
getCurrentDashboardKindV2.mockReturnValue(baseResource);
dashboardSceneJsonApiV2.getCurrentDashboard(0);
// Now break live serialization
getCurrentDashboardKindV2.mockImplementation(() => {
throw new Error('Unsupported transformation type');
});
expect(() =>
dashboardSceneJsonApiV2.applyCurrentDashboard(
JSON.stringify({
...baseResource,
apiVersion: 'dashboard.grafana.app/v2alpha1',
})
)
).toThrow('Changing apiVersion is not allowed');
});
it('applyCurrentDashboard can recover without a baseline by validating against URL UID and applying spec', () => {
getCurrentDashboardKindV2.mockImplementation(() => {
throw new Error('Unsupported transformation type');
});
const nextSpec = { title: 'recovered' };
dashboardSceneJsonApiV2.applyCurrentDashboard(
JSON.stringify({
...baseResource,
spec: nextSpec,
})
);
expect(applyCurrentDashboardSpecV2).toHaveBeenCalledWith(nextSpec);
});
it('applyCurrentDashboard rejects recovery attempts targeting a different dashboard UID', () => {
getCurrentDashboardKindV2.mockImplementation(() => {
throw new Error('Unsupported transformation type');
});
expect(() =>
dashboardSceneJsonApiV2.applyCurrentDashboard(
JSON.stringify({
...baseResource,
metadata: { ...baseResource.metadata, name: 'other-uid' },
})
)
).toThrow('Changing metadata is not allowed');
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -5,11 +5,7 @@ import { isDashboardDataLayerSetState } from '../DashboardDataLayerSet';
import { DashboardScene } from '../DashboardScene';
export function getDashboardControlsLinks(links: DashboardLink[]) {
// Dashboard links are not supported at the moment.
// Reason: nesting <Dropdown> components causes issues since the inner dropdown is rendered in a portal,
// so clicking it closes the parent dropdown (the parent sees it as an overlay click, and the event cannot easily be intercepted,
// as it is in different HTML subtree).
return links.filter((link) => link.placement === 'inControlsMenu' && link.type !== 'dashboards');
return links.filter((link) => link.placement === 'inControlsMenu');
}
export function getDashboardControlsVariables(variables: SceneVariable[]) {

View File

@@ -140,9 +140,13 @@ export function transformSceneToSaveModelSchemaV2(scene: DashboardScene, isSnaps
};
try {
// Remove nulls before validation since schema v2 does not support null for many fields,
// but the live scene state can still contain nulls (for example, in thresholds step values).
const cleaned = sortedDeepCloneWithoutNulls(dashboardSchemaV2, true);
// validateDashboardSchemaV2 will throw an error if the dashboard is not valid
if (validateDashboardSchemaV2(dashboardSchemaV2)) {
return sortedDeepCloneWithoutNulls(dashboardSchemaV2, true);
if (validateDashboardSchemaV2(cleaned)) {
return cleaned;
}
// should never reach this point, validation should throw an error
throw new Error('Error we could transform the dashboard to schema v2: ' + dashboardSchemaV2);

View File

@@ -8,6 +8,7 @@ import {
LocalPlugin,
RemotePlugin,
CatalogPluginDetails,
CatalogPluginInsights,
Version,
PluginVersion,
InstancePlugin,
@@ -47,6 +48,21 @@ export async function getPluginDetails(id: string): Promise<CatalogPluginDetails
};
}
export async function getPluginInsights(id: string, version: string | undefined): Promise<CatalogPluginInsights> {
if (!version) {
throw new Error('Version is required');
}
try {
const insights = await getBackendSrv().get(`${GCOM_API_ROOT}/plugins/${id}/versions/${version}/insights`);
return insights;
} catch (error) {
if (isFetchError(error)) {
error.isHandled = true;
}
throw error;
}
}
export async function getRemotePlugins(): Promise<RemotePlugin[]> {
try {
const { items: remotePlugins }: { items: RemotePlugin[] } = await getBackendSrv().get(`${GCOM_API_ROOT}/plugins`, {

View File

@@ -62,10 +62,12 @@ const plugin: CatalogPlugin = {
angularDetected: false,
isFullyInstalled: true,
accessControl: {},
insights: { id: 1, name: 'test-plugin', version: '1.0.0', insights: [] },
};
jest.mock('../state/hooks', () => ({
useGetSingle: jest.fn(),
useGetPluginInsights: jest.fn(),
useFetchStatus: jest.fn().mockReturnValue({ isLoading: false }),
useFetchDetailsStatus: () => ({ isLoading: false }),
useIsRemotePluginsAvailable: () => false,

View File

@@ -16,8 +16,7 @@ import { PluginDetailsPanel } from '../components/PluginDetailsPanel';
import { PluginDetailsSignature } from '../components/PluginDetailsSignature';
import { usePluginDetailsTabs } from '../hooks/usePluginDetailsTabs';
import { usePluginPageExtensions } from '../hooks/usePluginPageExtensions';
import { useGetSingle, useFetchStatus, useFetchDetailsStatus } from '../state/hooks';
import { PluginTabIds } from '../types';
import { useGetSingle, useFetchStatus, useFetchDetailsStatus, useGetPluginInsights } from '../state/hooks';
import { PluginDetailsDeprecatedWarning } from './PluginDetailsDeprecatedWarning';
@@ -49,12 +48,10 @@ export function PluginDetailsPage({
};
const queryParams = new URLSearchParams(location.search);
const plugin = useGetSingle(pluginId); // fetches the plugin settings for this Grafana instance
useGetPluginInsights(pluginId, plugin?.isInstalled ? plugin?.installedVersion : plugin?.latestVersion);
const isNarrowScreen = useMedia('(max-width: 600px)');
const { navModel, activePageId } = usePluginDetailsTabs(
plugin,
queryParams.get('page') as PluginTabIds,
isNarrowScreen
);
const { navModel, activePageId } = usePluginDetailsTabs(plugin, queryParams.get('page'), isNarrowScreen);
const { actions, info, subtitle } = usePluginPageExtensions(plugin);
const { isLoading: isFetchLoading } = useFetchStatus();
const { isLoading: isFetchDetailsLoading } = useFetchDetailsStatus();

View File

@@ -1,11 +1,23 @@
import userEvent from '@testing-library/user-event';
import { render, screen } from 'test/test-utils';
import { PluginSignatureStatus, PluginSignatureType, PluginType } from '@grafana/data';
import { config } from '@grafana/runtime';
import { CatalogPlugin } from '../types';
import { CatalogPlugin, SCORE_LEVELS } from '../types';
import { PluginDetailsPanel } from './PluginDetailsPanel';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
config: {
...jest.requireActual('@grafana/runtime').config,
featureToggles: {
pluginInsights: false,
},
},
}));
const mockPlugin: CatalogPlugin = {
description: 'Test plugin description',
downloads: 1000,
@@ -185,4 +197,61 @@ describe('PluginDetailsPanel', () => {
expect(regularLinks).toContainElement(raiseIssueLink);
expect(regularLinks).not.toContainElement(websiteLink);
});
it('should render plugin insights when plugin has insights', async () => {
config.featureToggles.pluginInsights = true;
const pluginWithInsights = {
...mockPlugin,
insights: {
id: 1,
name: 'test-plugin',
version: '1.0.0',
insights: [
{
name: 'security',
scoreValue: 90,
scoreLevel: SCORE_LEVELS.EXCELLENT,
items: [
{
id: 'signature',
name: 'Signature verified',
level: 'ok' as const,
},
],
},
],
},
};
render(<PluginDetailsPanel plugin={pluginWithInsights} pluginExtentionsInfo={mockInfo} />);
expect(screen.getByTestId('plugin-insights-container')).toBeInTheDocument();
expect(screen.getByText('Plugin insights')).toBeInTheDocument();
expect(screen.queryByText('Security')).toBeInTheDocument();
await userEvent.click(screen.getByText('Security'));
expect(screen.getByTestId('plugin-insight-item-signature')).toBeInTheDocument();
});
it('should not render plugin insights when plugin has no insights', () => {
const pluginWithoutInsights = {
...mockPlugin,
insights: undefined,
};
render(<PluginDetailsPanel plugin={pluginWithoutInsights} pluginExtentionsInfo={mockInfo} />);
expect(screen.queryByTestId('plugin-insights-container')).not.toBeInTheDocument();
expect(screen.queryByText('Plugin insights')).not.toBeInTheDocument();
});
it('should not render plugin insights when insights array is empty', () => {
const pluginWithEmptyInsights = {
...mockPlugin,
insights: {
id: 1,
name: 'test-plugin',
version: '1.0.0',
insights: [],
},
};
render(<PluginDetailsPanel plugin={pluginWithEmptyInsights} pluginExtentionsInfo={mockInfo} />);
expect(screen.queryByTestId('plugin-insights-container')).not.toBeInTheDocument();
expect(screen.queryByText('Plugin insights')).not.toBeInTheDocument();
});
});

View File

@@ -3,7 +3,7 @@ import { useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { t, Trans } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { config, reportInteraction } from '@grafana/runtime';
import { PageInfoItem } from '@grafana/runtime/internal';
import {
Stack,
@@ -22,6 +22,8 @@ import { formatDate } from 'app/core/internationalization/dates';
import { CatalogPlugin } from '../types';
import { PluginInsights } from './PluginInsights';
type Props = { pluginExtentionsInfo: PageInfoItem[]; plugin: CatalogPlugin; width?: string };
export function PluginDetailsPanel(props: Props): React.ReactElement | null {
@@ -69,6 +71,11 @@ export function PluginDetailsPanel(props: Props): React.ReactElement | null {
return (
<>
<Stack direction="column" gap={3} shrink={0} grow={0} width={width} data-testid="plugin-details-panel">
{config.featureToggles.pluginInsights && plugin.insights && plugin.insights?.insights?.length > 0 && (
<Box borderRadius="lg" padding={2} borderColor="medium" borderStyle="solid">
<PluginInsights pluginInsights={plugin.insights} />
</Box>
)}
<Box borderRadius="lg" padding={2} borderColor="medium" borderStyle="solid">
<Stack direction="column" gap={2}>
{pluginExtentionsInfo.map((infoItem, index) => {

View File

@@ -0,0 +1,171 @@
import userEvent from '@testing-library/user-event';
import { render, screen } from 'test/test-utils';
import { CatalogPluginInsights, InsightLevel, SCORE_LEVELS } from '../types';
import { PluginInsights } from './PluginInsights';
const mockPluginInsights: CatalogPluginInsights = {
id: 1,
name: 'test-plugin',
version: '1.0.0',
insights: [
{
name: 'security',
scoreValue: 90,
scoreLevel: SCORE_LEVELS.EXCELLENT,
items: [
{
id: 'signature',
name: 'Signature verified',
description: 'Plugin signature is valid',
level: 'ok' as InsightLevel,
},
{
id: 'trackingscripts',
name: 'No unsafe JavaScript detected',
level: 'good' as InsightLevel,
},
],
},
{
name: 'quality',
scoreValue: 60,
scoreLevel: SCORE_LEVELS.FAIR,
items: [
{
id: 'metadatavalid',
name: 'Metadata is valid',
level: 'ok' as InsightLevel,
},
{
id: 'code-rules',
name: 'Missing code rules',
description: 'Plugin lacks comprehensive code rules',
level: 'warning' as InsightLevel,
},
],
},
],
};
const mockPluginInsightsWithPoorLevel: CatalogPluginInsights = {
id: 3,
name: 'test-plugin-poor',
version: '0.8.0',
insights: [
{
name: 'quality',
scoreValue: 35,
scoreLevel: SCORE_LEVELS.POOR,
items: [
{
id: 'legacy-platform',
name: 'Quality issues detected',
level: 'warning' as InsightLevel,
},
],
},
],
};
describe('PluginInsights', () => {
it('should render plugin insights section', () => {
render(<PluginInsights pluginInsights={mockPluginInsights} />);
const insightsSection = screen.getByTestId('plugin-insights-container');
expect(insightsSection).toBeInTheDocument();
expect(screen.getByText('Plugin insights')).toBeInTheDocument();
});
it('should render all insight categories with test ids', () => {
render(<PluginInsights pluginInsights={mockPluginInsights} />);
expect(screen.getByTestId('plugin-insight-security')).toBeInTheDocument();
expect(screen.getByTestId('plugin-insight-quality')).toBeInTheDocument();
});
it('should render category names with test ids', () => {
render(<PluginInsights pluginInsights={mockPluginInsights} />);
const securityCategory = screen.getByTestId('plugin-insight-security');
const qualityCategory = screen.getByTestId('plugin-insight-quality');
expect(securityCategory).toBeInTheDocument();
expect(securityCategory).toHaveTextContent('Security');
expect(qualityCategory).toBeInTheDocument();
expect(qualityCategory).toHaveTextContent('Quality');
});
it('should render individual insight items with test ids', async () => {
render(<PluginInsights pluginInsights={mockPluginInsights} />);
await userEvent.click(screen.getByText('Security'));
expect(screen.getByTestId('plugin-insight-item-signature')).toBeInTheDocument();
expect(screen.getByTestId('plugin-insight-item-trackingscripts')).toBeInTheDocument();
await userEvent.click(screen.getByText('Quality'));
expect(screen.getByTestId('plugin-insight-item-metadatavalid')).toBeInTheDocument();
expect(screen.getByTestId('plugin-insight-item-code-rules')).toBeInTheDocument();
});
it('should display correct icons for Excellent score level', () => {
render(<PluginInsights pluginInsights={mockPluginInsights} />);
const securityCategory = screen.getByTestId('plugin-insight-security');
const securityIcon = securityCategory.querySelector('[data-testid="excellent-icon"]');
expect(securityIcon).toBeInTheDocument();
});
it('should display correct icons for Poor score levels', () => {
// Test Poor level - should show exclamation-triangle
render(<PluginInsights pluginInsights={mockPluginInsightsWithPoorLevel} />);
const poorCategory = screen.getByTestId('plugin-insight-quality');
const poorIcon = poorCategory.querySelector('[data-testid="poor-icon"]');
expect(poorIcon).toBeInTheDocument();
});
it('should handle multiple items with different insight levels', async () => {
const multiLevelInsights: CatalogPluginInsights = {
id: 5,
name: 'multi-level-plugin',
version: '2.0.0',
insights: [
{
name: 'quality',
scoreValue: 75,
scoreLevel: SCORE_LEVELS.GOOD,
items: [
{
id: 'code-rules',
name: 'Info level item',
level: 'info' as InsightLevel,
},
{
id: 'sdk-usage',
name: 'OK level item',
level: 'ok' as InsightLevel,
},
{
id: 'jsMap',
name: 'Good level item',
level: 'good' as InsightLevel,
},
{
id: 'gosec',
name: 'Warning level item',
level: 'warning' as InsightLevel,
},
{
id: 'legacy-builder',
name: 'Danger level item',
level: 'danger' as InsightLevel,
},
],
},
],
};
render(<PluginInsights pluginInsights={multiLevelInsights} />);
await userEvent.click(screen.getByText('Quality'));
expect(screen.getByText('Info level item')).toBeInTheDocument();
expect(screen.getByText('OK level item')).toBeInTheDocument();
expect(screen.getByText('Good level item')).toBeInTheDocument();
expect(screen.getByText('Warning level item')).toBeInTheDocument();
expect(screen.getByText('Danger level item')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,140 @@
import { css } from '@emotion/css';
import { capitalize } from 'lodash';
import { useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { Stack, Text, TextLink, CollapsableSection, Tooltip, Icon, useStyles2, useTheme2 } from '@grafana/ui';
import { CatalogPluginInsights } from '../types';
type Props = { pluginInsights: CatalogPluginInsights | undefined };
const PLUGINS_INSIGHTS_OPENED_EVENT_NAME = 'plugins_insights_opened';
export function PluginInsights(props: Props): React.ReactElement | null {
const { pluginInsights } = props;
const styles = useStyles2(getStyles);
const theme = useTheme2();
const [openInsights, setOpenInsights] = useState<Record<string, boolean>>({});
const handleInsightToggle = (insightName: string, isOpen: boolean) => {
if (isOpen) {
reportInteraction(PLUGINS_INSIGHTS_OPENED_EVENT_NAME, { insight: insightName });
}
setOpenInsights((prev) => ({ ...prev, [insightName]: isOpen }));
};
const tooltipInfo = (
<Stack direction="column" gap={0.5}>
<Stack direction="row" alignItems="center">
<Icon name="check-circle" size="md" color={theme.colors.success.main} />
<Text color="primary" variant="body">
<Trans i18nKey="plugins.details.labels.pluginInsightsSuccessTooltip">
All relevant signals are present and verified
</Trans>
</Text>
</Stack>
<Stack direction="row" alignItems="center">
<Icon name="exclamation-triangle" size="md" />
<Text color="primary" variant="body">
<Trans i18nKey="plugins.details.labels.pluginInsightsWarningTooltip">
One or more signals are missing or need attention
</Trans>
</Text>
</Stack>
<hr className={styles.pluginInsightsTooltipSeparator} />
<Text color="secondary" variant="body">
<Trans i18nKey="plugins.details.labels.moreDetails">
Do you find Plugin Insights usefull? Please share your feedback{' '}
<TextLink href="https://forms.gle/1ZVLbecyQ8aY9mDYA" external>
here
</TextLink>
.
</Trans>
</Text>
</Stack>
);
return (
<>
<Stack direction="column" gap={0.5} shrink={0} grow={0} data-testid="plugin-insights-container">
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Text color="secondary" variant="h6" data-testid="plugin-insights-header">
<Trans i18nKey="plugins.details.labels.pluginInsights.header">Plugin insights</Trans>
</Text>
<Tooltip content={tooltipInfo} placement="right-end" interactive>
<Icon name="info-circle" size="md" />
</Tooltip>
</Stack>
{pluginInsights?.insights.map((insightItem, index) => {
return (
<Stack key={index} wrap direction="column" gap={1}>
<CollapsableSection
isOpen={openInsights[insightItem.name] ?? false}
onToggle={(isOpen) => handleInsightToggle(insightItem.name, isOpen)}
label={
<Stack
direction="row"
gap={1}
alignItems="center"
data-testid={`plugin-insight-${insightItem.name.toLowerCase()}`}
>
{insightItem.scoreLevel === 'Excellent' ? (
<Icon
name="check-circle"
size="lg"
color={theme.colors.success.main}
data-testid="excellent-icon"
/>
) : (
<Icon name="exclamation-triangle" size="lg" data-testid="poor-icon" />
)}
<Text
color="primary"
variant="body"
data-testid={`plugin-insight-color-${insightItem.name.toLowerCase()}`}
>
{capitalize(insightItem.name)}
</Text>
</Stack>
}
contentClassName={styles.pluginInsightsItems}
>
<Stack direction="column" gap={1}>
{insightItem.items.map((item, idx) => (
<Stack key={idx} direction="row" gap={1} alignItems="flex-start">
<span>
{item.level === 'good' ? (
<Icon name="check-circle" size="sm" color={theme.colors.success.main} />
) : (
<Icon name="exclamation-triangle" size="sm" />
)}
</span>
<Text color="secondary" variant="body" data-testid={`plugin-insight-item-${item.id}`}>
{item.name}
</Text>
</Stack>
))}
</Stack>
</CollapsableSection>
</Stack>
);
})}
</Stack>
</>
);
}
export const getStyles = (theme: GrafanaTheme2) => {
return {
pluginVersionDetails: css({ wordBreak: 'break-word' }),
pluginInsightsItems: css({ marginLeft: '26px', paddingTop: '0 !important' }),
pluginInsightsTooltipSeparator: css({
border: 'none',
borderTop: `1px solid ${theme.colors.border.medium}`,
margin: `${theme.spacing(1)} 0`,
}),
};
};

View File

@@ -18,9 +18,9 @@ type ReturnType = {
};
function getCurrentPageId(
pageId: PluginTabIds | undefined,
isNarrowScreen: boolean | undefined,
defaultTab: string
defaultTab: string,
pageId?: PluginTabIds | string | null
): PluginTabIds | string {
if (!isNarrowScreen && pageId === PluginTabIds.PLUGINDETAILS) {
return defaultTab;
@@ -30,7 +30,7 @@ function getCurrentPageId(
export const usePluginDetailsTabs = (
plugin?: CatalogPlugin,
pageId?: PluginTabIds,
pageId?: PluginTabIds | string | null,
isNarrowScreen?: boolean
): ReturnType => {
const { loading, error, value: pluginConfig } = usePluginConfig(plugin);
@@ -38,7 +38,7 @@ export const usePluginDetailsTabs = (
const defaultTab = useDefaultPage(plugin, pluginConfig);
const isPublished = Boolean(plugin?.isPublished);
const currentPageId = getCurrentPageId(pageId, isNarrowScreen, defaultTab);
const currentPageId = getCurrentPageId(isNarrowScreen, defaultTab, pageId);
const navModelChildren = useMemo(() => {
const canConfigurePlugins = plugin && contextSrv.hasPermissionInMetadata(AccessControlAction.PluginsWrite, plugin);

View File

@@ -34,6 +34,7 @@ export default {
updatedAt: '2021-08-25T15:03:49.000Z',
version: '4.2.2',
error: undefined,
insights: { id: 1, name: 'alexanderzobnin-zabbix-app', version: '4.2.2', insights: [] },
details: {
grafanaDependency: '>=8.0.0',
pluginDependencies: [],
@@ -381,6 +382,7 @@ export const datasourcePlugin = {
angularDetected: false,
isFullyInstalled: true,
latestVersion: '1.20.0',
insights: { id: 2, name: 'grafana-redshift-datasource', version: '1.20.0', insights: [] },
details: {
grafanaDependency: '>=8.0.0',
pluginDependencies: [],

View File

@@ -31,6 +31,9 @@ export const getPluginsStateMock = (plugins: CatalogPlugin[] = []): ReducerState
'plugins/fetchDetails': {
status: RequestStatus.Fulfilled,
},
'plugins/fetchPluginInsights': {
status: RequestStatus.Fulfilled,
},
},
// Backward compatibility
plugins: [],
@@ -75,6 +78,11 @@ export const mockPluginApis = ({
return Promise.resolve({ items: versions });
}
// Mock plugin insights - return empty insights to avoid API call errors
if (path.includes('/insights')) {
return Promise.resolve({ id: 1, name: '', version: '', insights: [] });
}
// Mock local plugin settings (installed) if necessary
if (local && path === `${API_ROOT}/${local.id}/settings`) {
return Promise.resolve(local);

View File

@@ -13,6 +13,7 @@ import {
getPluginErrors,
getLocalPlugins,
getPluginDetails,
getPluginInsights,
installPlugin,
uninstallPlugin,
getInstancePlugins,
@@ -165,6 +166,22 @@ export const fetchDetails = createAsyncThunk<Update<CatalogPlugin, string>, stri
}
);
export const fetchPluginInsights = createAsyncThunk<Update<CatalogPlugin, string>, { id: string; version?: string }>(
`${STATE_PREFIX}/fetchPluginInsights`,
async ({ id, version }, thunkApi) => {
try {
const insights = await getPluginInsights(id, version);
return {
id,
changes: { insights },
};
} catch (e) {
return thunkApi.rejectWithValue('Unknown error.');
}
}
);
export const addPlugins = createAction<CatalogPlugin[]>(`${STATE_PREFIX}/addPlugins`);
// 1. gets remote equivalents from the store (if there are any)
@@ -265,7 +282,8 @@ export const panelPluginLoaded = createAction<PanelPlugin>(`${STATE_PREFIX}/pane
// TODO<remove once the "plugin_admin_enabled" feature flag is removed>
export const loadPanelPlugin = (id: string): ThunkResult<Promise<PanelPlugin>> => {
return async (dispatch, getStore) => {
let plugin = getStore().plugins.panels[id];
const state = getStore();
let plugin = state.plugins.panels[id];
if (!plugin) {
plugin = await importPanelPlugin(id);

View File

@@ -6,7 +6,16 @@ import { useDispatch, useSelector } from 'app/types/store';
import { sortPlugins, Sorters, isPluginUpdatable } from '../helpers';
import { CatalogPlugin, PluginStatus } from '../types';
import { fetchAll, fetchDetails, fetchRemotePlugins, install, uninstall, fetchAllLocal, unsetInstall } from './actions';
import {
fetchAll,
fetchDetails,
fetchRemotePlugins,
install,
uninstall,
fetchAllLocal,
unsetInstall,
fetchPluginInsights,
} from './actions';
import {
selectPlugins,
selectById,
@@ -44,13 +53,18 @@ export const useGetUpdatable = () => {
};
};
export const useGetSingle = (id: string): CatalogPlugin | undefined => {
export const useGetSingle = (id: string, version?: string): CatalogPlugin | undefined => {
useFetchAll();
useFetchDetails(id);
return useSelector((state) => selectById(state, id));
};
export const useGetPluginInsights = (id: string, version: string | undefined): CatalogPlugin | undefined => {
useFetchPluginInsights(id, version);
return useSelector((state) => selectById(state, id));
};
export const useGetSingleLocalWithoutDetails = (id: string): CatalogPlugin | undefined => {
useFetchAllLocal();
return useSelector((state) => selectById(state, id));
@@ -153,6 +167,17 @@ export const useFetchDetails = (id: string) => {
}, [plugin]); // eslint-disable-line
};
export const useFetchPluginInsights = (id: string, version: string | undefined) => {
const dispatch = useDispatch();
const plugin = useSelector((state) => selectById(state, id));
const isNotFetching = !useSelector(selectIsRequestPending(fetchPluginInsights.typePrefix));
const shouldFetch = isNotFetching && plugin && !plugin.insights && version;
useEffect(() => {
shouldFetch && dispatch(fetchPluginInsights({ id, version }));
}, [plugin, version]); // eslint-disable-line
};
export const useFetchDetailsLazy = () => {
const dispatch = useDispatch();

View File

@@ -7,6 +7,7 @@ import { CatalogPlugin, ReducerState, RequestStatus } from '../types';
import {
fetchDetails,
fetchPluginInsights,
install,
uninstall,
loadPluginDashboards,
@@ -63,6 +64,10 @@ const slice = createSlice({
.addCase(fetchDetails.fulfilled, (state, action) => {
pluginsAdapter.updateOne(state.items, action.payload);
})
// Fetch Plugin Insights
.addCase(fetchPluginInsights.fulfilled, (state, action) => {
pluginsAdapter.updateOne(state.items, action.payload);
})
// Install
.addCase(install.fulfilled, (state, action) => {
pluginsAdapter.updateOne(state.items, action.payload);

View File

@@ -55,6 +55,7 @@ export interface CatalogPlugin extends WithAccessControlMetadata {
updatedAt: string;
installedVersion?: string;
details?: CatalogPluginDetails;
insights?: CatalogPluginInsights;
error?: PluginErrorCode;
angularDetected?: boolean;
// instance plugins may not be fully installed, which means a new instance
@@ -90,6 +91,54 @@ export interface CatalogPluginDetails {
screenshots?: Screenshots[] | null;
}
export type InsightLevel = 'ok' | 'warning' | 'danger' | 'good' | 'info';
export const SCORE_LEVELS = {
EXCELLENT: 'Excellent',
GOOD: 'Good',
FAIR: 'Fair',
POOR: 'Poor',
CRITICAL: 'Critical',
} as const;
export type ScoreLevel = (typeof SCORE_LEVELS)[keyof typeof SCORE_LEVELS];
export const INSIGHT_CATEGORIES = {
SECURITY: 'security',
QUALITY: 'quality',
PERFORMANCE: 'performance',
} as const;
export const INSIGHT_LEVELS = {
GOOD: 'good',
OK: 'ok',
WARNING: 'warning',
DANGER: 'danger',
INFO: 'info',
} as const;
export interface InsightItem {
id: string;
name: string;
description?: string;
level: InsightLevel;
link?: string;
}
export interface InsightCategory {
name: string;
items: InsightItem[];
scoreValue: number;
scoreLevel: ScoreLevel;
}
export interface CatalogPluginInsights {
id: number;
name: string;
version: string;
insights: InsightCategory[];
}
export interface CatalogPluginInfo {
logos: { large: string; small: string };
keywords: string[];

View File

@@ -80,10 +80,7 @@ export function ConfigForm({ data }: ConfigFormProps) {
const [isLoading, setIsLoading] = useState(false);
const navigate = useNavigate();
const [type, readOnly] = watch(['type', 'readOnly']);
const targetOptions = useMemo(
() => getTargetOptions(settings.data?.allowedTargets || ['instance', 'folder']),
[settings.data]
);
const targetOptions = useMemo(() => getTargetOptions(settings.data?.allowedTargets || ['folder']), [settings.data]);
const isGitBased = isGitProvider(type);
const {

View File

@@ -11,7 +11,7 @@ export interface GetDefaultValuesOptions {
export function getDefaultValues({
repository,
allowedTargets = ['instance', 'folder'],
allowedTargets = ['folder'],
}: GetDefaultValuesOptions = {}): RepositoryFormData {
if (!repository) {
const defaultTarget = allowedTargets.includes('folder') ? 'folder' : 'instance';

View File

@@ -22,6 +22,7 @@ export function RepositoryList({ items }: Props) {
const filteredItems = items.filter((item) => item.metadata?.name?.includes(query));
const { instanceConnected } = checkSyncSettings(items);
const hasInstanceSyncRepo = items.some((item) => item.spec?.sync?.target === 'instance');
const getResourceCountSection = () => {
if (isProvisionedInstance) {
@@ -77,6 +78,17 @@ export function RepositoryList({ items }: Props) {
return (
<>
{getResourceCountSection()}
{hasInstanceSyncRepo && (
<Alert
title={t('provisioning.instance-sync-deprecation.title', 'Instance sync is not fully supported')}
severity="warning"
>
<Trans i18nKey="provisioning.instance-sync-deprecation.message">
Instance sync is currently not fully supported and breaks library panels and alerts. To use library panels
and alerts, disconnect your repository and reconnect it using folder sync instead.
</Trans>
</Alert>
)}
<Stack direction={'column'} gap={3}>
{!instanceConnected && (
<Stack gap={2}>

View File

@@ -32,7 +32,7 @@ function FormWrapper({ children, defaultValues }: { children: ReactNode; default
url: 'https://github.com/test/repo',
title: '',
sync: {
target: 'instance',
target: 'folder',
enabled: true,
},
branch: 'main',
@@ -101,12 +101,6 @@ describe('BootstrapStep', () => {
(useModeOptions as jest.Mock).mockReturnValue({
enabledOptions: [
{
target: 'instance',
label: 'Sync all resources with external storage',
description: 'Resources will be synced with external storage',
subtitle: 'Use this option if you want to sync your entire instance',
},
{
target: 'folder',
label: 'Sync external storage to a new Grafana folder',
@@ -142,8 +136,8 @@ describe('BootstrapStep', () => {
it('should render correct info for GitHub repository type', async () => {
setup();
expect(screen.getAllByText('External storage')).toHaveLength(2);
expect(screen.getAllByText('Empty')).toHaveLength(4); // Four elements should show "Empty" (2 external + 2 unmanaged, one per card)
expect(screen.getAllByText('External storage')).toHaveLength(1); // Only folder sync is shown by default
expect(screen.getAllByText('Empty')).toHaveLength(2); // Two elements should have the role "Empty" (1 external + 1 unmanaged)
});
it('should render correct info for local file repository type', async () => {
@@ -171,10 +165,12 @@ describe('BootstrapStep', () => {
setup();
expect(await screen.getAllByText('2 files')).toHaveLength(2);
expect(await screen.getAllByText('2 files')).toHaveLength(1); // Only folder sync is shown by default
});
it('should display resource counts when resources exist', async () => {
// Note: Resource counts are only shown for instance sync, but instance sync is not available by default
// This test is kept for when instance sync is explicitly enabled via settings
(useGetResourceStatsQuery as jest.Mock).mockReturnValue({
data: {
instance: [
@@ -196,10 +192,29 @@ describe('BootstrapStep', () => {
shouldSkipSync: false,
});
setup();
// Mock settings to allow instance sync for this test
(useModeOptions as jest.Mock).mockReturnValue({
enabledOptions: [
{
target: 'instance',
label: 'Sync all resources with external storage',
description: 'Resources will be synced with external storage',
subtitle: 'Use this option if you want to sync your entire instance',
},
],
disabledOptions: [],
});
// Two elements display "7 resources": one in the external storage card and one in unmanaged resources card
expect(await screen.findAllByText('7 resources')).toHaveLength(2);
setup({
settingsData: {
allowedTargets: ['instance', 'folder'],
allowImageRendering: true,
items: [],
availableRepositoryTypes: [],
},
});
expect(await screen.findByText('7 resources')).toBeInTheDocument();
});
});
@@ -208,16 +223,30 @@ describe('BootstrapStep', () => {
setup();
const mockUseResourceStats = require('./hooks/useResourceStats').useResourceStats;
expect(mockUseResourceStats).toHaveBeenCalledWith('test-repo', 'instance');
expect(mockUseResourceStats).toHaveBeenCalledWith('test-repo', 'folder');
});
it('should use useResourceStats hook with settings data', async () => {
setup({
settingsData: {
allowedTargets: ['folder'],
allowImageRendering: true,
items: [],
availableRepositoryTypes: [],
},
});
const mockUseResourceStats = require('./hooks/useResourceStats').useResourceStats;
expect(mockUseResourceStats).toHaveBeenCalledWith('test-repo', 'folder');
});
});
describe('sync target options', () => {
it('should display both instance and folder options by default', async () => {
it('should display only folder option by default', async () => {
setup();
expect(await screen.findByText('Sync all resources with external storage')).toBeInTheDocument();
expect(await screen.findByText('Sync external storage to a new Grafana folder')).toBeInTheDocument();
expect(screen.queryByText('Sync all resources with external storage')).not.toBeInTheDocument();
});
it('should only display instance option when legacy storage exists', async () => {
@@ -242,6 +271,7 @@ describe('BootstrapStep', () => {
setup({
settingsData: {
allowedTargets: ['instance', 'folder'],
allowImageRendering: true,
items: [],
availableRepositoryTypes: [],
@@ -264,15 +294,10 @@ describe('BootstrapStep', () => {
});
describe('title field visibility', () => {
it('should show title field only for folder sync target', async () => {
const { user } = setup();
// Initially should not show title field (default is instance)
expect(screen.queryByRole('textbox', { name: /display name/i })).not.toBeInTheDocument();
const folderOption = await screen.findByText('Sync external storage to a new Grafana folder');
await user.click(folderOption);
it('should show title field for folder sync target', async () => {
setup();
// Default is folder, so title field should be visible
expect(await screen.findByRole('textbox', { name: /display name/i })).toBeInTheDocument();
});
});

View File

@@ -227,6 +227,15 @@ describe('ProvisioningWizard', () => {
error: null,
refetch: jest.fn(),
});
// Mock files to ensure sync step is not skipped for folder sync
mockUseGetRepositoryFilesQuery.mockReturnValue({
data: {
items: [{ name: 'test.json', path: 'test.json' }],
},
isLoading: false,
error: null,
refetch: jest.fn(),
});
const { user } = setup(<ProvisioningWizard type="github" />);
await fillConnectionForm(user, 'github', {
@@ -500,10 +509,10 @@ describe('ProvisioningWizard', () => {
});
it('should show button text changes based on current step', async () => {
// Mock resources to ensure sync step is not skipped
mockUseGetResourceStatsQuery.mockReturnValue({
// Mock files to ensure sync step is not skipped for folder sync
mockUseGetRepositoryFilesQuery.mockReturnValue({
data: {
instance: [{ group: 'dashboard.grafana.app', count: 1 }],
items: [{ name: 'test.json', path: 'test.json' }],
},
isLoading: false,
error: null,

View File

@@ -10,7 +10,7 @@ import { ModeOption } from '../types';
*/
function filterModeOptions(modeOptions: ModeOption[], repoName: string, settings?: RepositoryViewList): ModeOption[] {
const folderConnected = settings?.items?.some((item) => item.target === 'folder' && item.name !== repoName);
const allowedTargets = settings?.allowedTargets || ['instance', 'folder'];
const allowedTargets = settings?.allowedTargets || ['folder'];
return modeOptions.map((option) => {
if (option.disabled) {

View File

@@ -1,4 +1,5 @@
import { PanelData, RawTimeRange } from '@grafana/data';
import { getDashboardApi } from '@grafana/runtime';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
@@ -11,6 +12,42 @@ declare global {
getDashboardTimeRange: () => { from: number; to: number; raw: RawTimeRange };
getPanelData: () => Record<number, PanelData | undefined> | undefined;
};
/**
* Exposes the current-dashboard schema v2 JSON API for debugging / automation.
* Intended for browser console usage.
*/
dashboardApi?: {
help: () => string;
schema: {
getSources: (space?: number) => string;
getDashboard: (space?: number) => Promise<string>;
getDashboardSync: (space?: number) => string;
};
navigation: {
getCurrent: (space?: number) => string;
getSelection: (space?: number) => string;
selectTab: (tabJson: string) => void;
focusRow: (rowJson: string) => void;
focusPanel: (panelJson: string) => void;
};
variables: {
getCurrent: (space?: number) => string;
apply: (varsJson: string) => void;
};
timeRange: {
getCurrent: (space?: number) => string;
apply: (timeRangeJson: string) => void;
};
errors: {
getCurrent: (space?: number) => string;
};
dashboard: {
getCurrent: (space?: number) => string;
apply: (resourceJson: string) => void;
previewOps: (opsJson: string) => string;
applyOps: (opsJson: string) => string;
};
};
}
}
@@ -54,4 +91,8 @@ export function initWindowRuntime() {
}, {});
},
};
// Expose the same API that plugins use via @grafana/runtime, but on `window` for easy console access.
// Only the grouped API surface is exposed.
window.dashboardApi = getDashboardApi();
}

View File

@@ -11406,6 +11406,12 @@
"latestReleaseDate": "Latest release date:",
"latestVersion": "Latest Version",
"license": "License",
"moreDetails": "Do you find Plugin Insights usefull? Please share your feedback <2>here</2>.",
"pluginInsights": {
"header": "Plugin insights"
},
"pluginInsightsSuccessTooltip": "All relevant signals are present and verified",
"pluginInsightsWarningTooltip": "One or more signals are missing or need attention",
"raiseAnIssue": "Raise an issue",
"reportAbuse": "Report a concern",
"reportAbuseTooltip": "Report issues related to malicious or harmful plugins directly to Grafana Labs.",
@@ -11951,6 +11957,10 @@
"unsupported-repository-type": "Unsupported repository type: {{repositoryType}}"
},
"inline-secure-values-warning": "You need to save your access tokens again due to a system update",
"instance-sync-deprecation": {
"message": "Instance sync is currently not fully supported and breaks library panels and alerts. To use library panels and alerts, disconnect your repository and reconnect it using folder sync instead.",
"title": "Instance sync is not fully supported"
},
"job-status": {
"label-view-details": "View details",
"loading-finished-job": "Loading finished job...",