mirror of
https://github.com/grafana/grafana.git
synced 2025-12-24 05:44:14 +08:00
Compare commits
12 Commits
macabu/rep
...
oscark/poc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f1e8bb94e | ||
|
|
93551386cc | ||
|
|
e796825e63 | ||
|
|
7fec275695 | ||
|
|
090078eb80 | ||
|
|
1a7c2a4f38 | ||
|
|
ece38641ca | ||
|
|
e9a2828f66 | ||
|
|
c2275f6ee4 | ||
|
|
b4eb02a6f0 | ||
|
|
a0751b6e71 | ||
|
|
b5793a5f73 |
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
460
packages/grafana-runtime/src/services/dashboardSceneJsonApi.ts
Normal file
460
packages/grafana-runtime/src/services/dashboardSceneJsonApi.ts
Normal 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),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export * from './templateSrv';
|
||||
export * from './live';
|
||||
export * from './LocationService';
|
||||
export * from './appEvents';
|
||||
export * from './dashboardSceneJsonApi';
|
||||
|
||||
export {
|
||||
setPluginComponentHook,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
1
pkg/services/featuremgmt/toggles_gen.csv
generated
1
pkg/services/featuremgmt/toggles_gen.csv
generated
@@ -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
|
||||
|
||||
|
14
pkg/services/featuremgmt/toggles_gen.json
generated
14
pkg/services/featuremgmt/toggles_gen.json
generated
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 scene’s 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 don’t persist/status-sync status in the scene; keep it stable and non-authoritative.
|
||||
status: {} as Status,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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[]) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
140
public/app/features/plugins/admin/components/PluginInsights.tsx
Normal file
140
public/app/features/plugins/admin/components/PluginInsights.tsx
Normal 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`,
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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...",
|
||||
|
||||
Reference in New Issue
Block a user