Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c6e870a3a | |||
| 975480f10e | |||
| 3445cd3378 | |||
| df790a4279 | |||
| f3e28cf440 | |||
| f8ab6ecc79 | |||
| 6a955eb6d1 |
@@ -77,14 +77,5 @@ export {
|
||||
getCorrelationsService,
|
||||
setCorrelationsService,
|
||||
} from './services/CorrelationsService';
|
||||
export {
|
||||
getDashboardMutationAPI,
|
||||
setDashboardMutationAPI,
|
||||
type DashboardMutationAPI,
|
||||
type MutationResult,
|
||||
type MutationChange,
|
||||
type MutationRequest,
|
||||
type MCPToolDefinition,
|
||||
} from './services/dashboardMutationAPI';
|
||||
export { getAppPluginVersion, isAppPluginInstalled } from './services/pluginMeta/apps';
|
||||
export { useAppPluginInstalled, useAppPluginVersion } from './services/pluginMeta/hooks';
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
/**
|
||||
* Dashboard Mutation API Service
|
||||
*
|
||||
* Provides a stable interface for programmatic dashboard modifications.
|
||||
*
|
||||
* The API is registered by DashboardScene when a dashboard is loaded and
|
||||
* cleared when the dashboard is deactivated.
|
||||
*/
|
||||
|
||||
/**
|
||||
* MCP Tool Definition - describes a tool that can be invoked
|
||||
* @see https://spec.modelcontextprotocol.io/specification/server/tools/
|
||||
*/
|
||||
export interface MCPToolDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: {
|
||||
type: 'object';
|
||||
properties: Record<string, unknown>;
|
||||
required?: string[];
|
||||
};
|
||||
annotations?: {
|
||||
title?: string;
|
||||
readOnlyHint?: boolean;
|
||||
destructiveHint?: boolean;
|
||||
idempotentHint?: boolean;
|
||||
confirmationHint?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MutationResult {
|
||||
success: boolean;
|
||||
/** ID of the affected panel (for panel operations) */
|
||||
panelId?: string;
|
||||
/** Error message if success is false */
|
||||
error?: string;
|
||||
/** List of changes made by the mutation */
|
||||
changes?: MutationChange[];
|
||||
/** Warnings (non-fatal issues) */
|
||||
warnings?: string[];
|
||||
/** Data returned by read-only operations (e.g., GET_DASHBOARD_INFO) */
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export interface MutationChange {
|
||||
/** JSON path to the changed value */
|
||||
path: string;
|
||||
/** Value before the change */
|
||||
previousValue: unknown;
|
||||
/** Value after the change */
|
||||
newValue: unknown;
|
||||
}
|
||||
|
||||
export interface MutationRequest {
|
||||
/** Type of mutation (e.g., 'ADD_PANEL', 'REMOVE_PANEL', 'UPDATE_PANEL') */
|
||||
type: string;
|
||||
/** Payload specific to the mutation type */
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard info returned by getDashboardMutationAPI().getDashboardInfo()
|
||||
*/
|
||||
export interface DashboardMutationInfo {
|
||||
available: boolean;
|
||||
uid?: string;
|
||||
title?: string;
|
||||
canEdit: boolean;
|
||||
isEditing: boolean;
|
||||
availableTools: string[];
|
||||
}
|
||||
|
||||
export interface DashboardMutationAPI {
|
||||
/**
|
||||
* Execute a mutation on the dashboard
|
||||
*/
|
||||
execute(mutation: MutationRequest): Promise<MutationResult>;
|
||||
|
||||
/**
|
||||
* Check if the current user can edit the dashboard
|
||||
*/
|
||||
canEdit(): boolean;
|
||||
|
||||
/**
|
||||
* Get the UID of the currently loaded dashboard
|
||||
*/
|
||||
getDashboardUID(): string | undefined;
|
||||
|
||||
/**
|
||||
* Get the title of the currently loaded dashboard
|
||||
*/
|
||||
getDashboardTitle(): string | undefined;
|
||||
|
||||
/**
|
||||
* Check if the dashboard is in edit mode
|
||||
*/
|
||||
isEditing(): boolean;
|
||||
|
||||
/**
|
||||
* Enter edit mode if not already editing
|
||||
*/
|
||||
enterEditMode(): void;
|
||||
|
||||
/**
|
||||
* Get the available MCP tool definitions for this dashboard
|
||||
*/
|
||||
getTools(): MCPToolDefinition[];
|
||||
|
||||
/**
|
||||
* Get comprehensive dashboard info in a single call
|
||||
*/
|
||||
getDashboardInfo(): DashboardMutationInfo;
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let _dashboardMutationAPI: DashboardMutationAPI | null = null;
|
||||
|
||||
// Expose on window for cross-bundle access (plugins use different bundle)
|
||||
declare global {
|
||||
interface Window {
|
||||
__grafanaDashboardMutationAPI?: DashboardMutationAPI | null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the dashboard mutation API instance.
|
||||
* Called by DashboardScene when a dashboard is activated.
|
||||
*
|
||||
* @param api - The mutation API instance, or null to clear
|
||||
* @internal
|
||||
*/
|
||||
export function setDashboardMutationAPI(api: DashboardMutationAPI | null): void {
|
||||
_dashboardMutationAPI = api;
|
||||
// Also expose on window for plugins that use a different @grafana/runtime bundle
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__grafanaDashboardMutationAPI = api;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the dashboard mutation API for the currently loaded dashboard.
|
||||
*
|
||||
* @returns The mutation API, or null if no dashboard is loaded
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { getDashboardMutationAPI } from '@grafana/runtime';
|
||||
*
|
||||
* const api = getDashboardMutationAPI();
|
||||
* if (api && api.canEdit()) {
|
||||
* await api.execute({
|
||||
* type: 'ADD_PANEL',
|
||||
* payload: { ... }
|
||||
* });
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function getDashboardMutationAPI(): DashboardMutationAPI | null {
|
||||
return _dashboardMutationAPI;
|
||||
}
|
||||
@@ -42,13 +42,3 @@ export {
|
||||
export { setCurrentUser } from './user';
|
||||
export { RuntimeDataSource } from './RuntimeDataSource';
|
||||
export { ScopesContext, type ScopesContextValueState, type ScopesContextValue, useScopes } from './ScopesContext';
|
||||
export {
|
||||
getDashboardMutationAPI,
|
||||
setDashboardMutationAPI,
|
||||
type DashboardMutationAPI,
|
||||
type DashboardMutationInfo,
|
||||
type MutationResult,
|
||||
type MutationChange,
|
||||
type MutationRequest,
|
||||
type MCPToolDefinition,
|
||||
} from './dashboardMutationAPI';
|
||||
|
||||
@@ -20,8 +20,10 @@ import (
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/plugins/config"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/pluginfakes"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/registry"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/signature"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/signature/statickey"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginassets/modulehash"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
|
||||
accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
@@ -80,7 +82,8 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features featuremgmt.F
|
||||
var pluginsAssets = passets
|
||||
if pluginsAssets == nil {
|
||||
sig := signature.ProvideService(pluginsCfg, statickey.New())
|
||||
pluginsAssets = pluginassets.ProvideService(pluginsCfg, pluginsCDN, sig, pluginStore)
|
||||
calc := modulehash.NewCalculator(pluginsCfg, registry.NewInMemory(), pluginsCDN, sig)
|
||||
pluginsAssets = pluginassets.ProvideService(pluginsCfg, pluginsCDN, calc)
|
||||
}
|
||||
|
||||
hs := &HTTPServer{
|
||||
@@ -714,6 +717,8 @@ func newPluginAssets() func() *pluginassets.Service {
|
||||
|
||||
func newPluginAssetsWithConfig(pCfg *config.PluginManagementCfg) func() *pluginassets.Service {
|
||||
return func() *pluginassets.Service {
|
||||
return pluginassets.ProvideService(pCfg, pluginscdn.ProvideService(pCfg), signature.ProvideService(pCfg, statickey.New()), &pluginstore.FakePluginStore{})
|
||||
cdn := pluginscdn.ProvideService(pCfg)
|
||||
calc := modulehash.NewCalculator(pCfg, registry.NewInMemory(), cdn, signature.ProvideService(pCfg, statickey.New()))
|
||||
return pluginassets.ProvideService(pCfg, cdn, calc)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/registry"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/signature"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/signature/statickey"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginassets/modulehash"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginerrs"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
|
||||
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
@@ -849,7 +850,8 @@ func Test_PluginsSettings(t *testing.T) {
|
||||
pCfg := &config.PluginManagementCfg{}
|
||||
pluginCDN := pluginscdn.ProvideService(pCfg)
|
||||
sig := signature.ProvideService(pCfg, statickey.New())
|
||||
hs.pluginAssets = pluginassets.ProvideService(pCfg, pluginCDN, sig, hs.pluginStore)
|
||||
calc := modulehash.NewCalculator(pCfg, registry.NewInMemory(), pluginCDN, sig)
|
||||
hs.pluginAssets = pluginassets.ProvideService(pCfg, pluginCDN, calc)
|
||||
hs.pluginErrorResolver = pluginerrs.ProvideStore(errTracker)
|
||||
hs.pluginsUpdateChecker, err = updatemanager.ProvidePluginsService(
|
||||
hs.Cfg,
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package modulehash
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/plugins/config"
|
||||
"github.com/grafana/grafana/pkg/plugins/log"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/registry"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/signature"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
|
||||
)
|
||||
|
||||
type Calculator struct {
|
||||
reg registry.Service
|
||||
cfg *config.PluginManagementCfg
|
||||
cdn *pluginscdn.Service
|
||||
signature *signature.Signature
|
||||
log log.Logger
|
||||
|
||||
moduleHashCache sync.Map
|
||||
}
|
||||
|
||||
func NewCalculator(cfg *config.PluginManagementCfg, reg registry.Service, cdn *pluginscdn.Service, signature *signature.Signature) *Calculator {
|
||||
return &Calculator{
|
||||
cfg: cfg,
|
||||
reg: reg,
|
||||
cdn: cdn,
|
||||
signature: signature,
|
||||
log: log.New("modulehash"),
|
||||
}
|
||||
}
|
||||
|
||||
// ModuleHash returns the module.js SHA256 hash for a plugin in the format expected by the browser for SRI checks.
|
||||
// The module hash is read from the plugin's MANIFEST.txt file.
|
||||
// The plugin can also be a nested plugin.
|
||||
// If the plugin is unsigned, an empty string is returned.
|
||||
// The results are cached to avoid repeated reads from the MANIFEST.txt file.
|
||||
func (c *Calculator) ModuleHash(ctx context.Context, pluginID, pluginVersion string) string {
|
||||
p, ok := c.reg.Plugin(ctx, pluginID, pluginVersion)
|
||||
if !ok {
|
||||
c.log.Error("Failed to calculate module hash as plugin is not registered", "pluginId", pluginID)
|
||||
return ""
|
||||
}
|
||||
k := c.moduleHashCacheKey(pluginID, pluginVersion)
|
||||
cachedValue, ok := c.moduleHashCache.Load(k)
|
||||
if ok {
|
||||
return cachedValue.(string)
|
||||
}
|
||||
mh, err := c.moduleHash(ctx, p, "")
|
||||
if err != nil {
|
||||
c.log.Error("Failed to calculate module hash", "pluginId", p.ID, "error", err)
|
||||
}
|
||||
c.moduleHashCache.Store(k, mh)
|
||||
return mh
|
||||
}
|
||||
|
||||
// moduleHash is the underlying function for ModuleHash. See its documentation for more information.
|
||||
// If the plugin is not a CDN plugin, the function will return an empty string.
|
||||
// It will read the module hash from the MANIFEST.txt in the [[plugins.FS]] of the provided plugin.
|
||||
// If childFSBase is provided, the function will try to get the hash from MANIFEST.txt for the provided children's
|
||||
// module.js file, rather than for the provided plugin.
|
||||
func (c *Calculator) moduleHash(ctx context.Context, p *plugins.Plugin, childFSBase string) (r string, err error) {
|
||||
if !c.cfg.Features.SriChecksEnabled {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Ignore unsigned plugins
|
||||
if !p.Signature.IsValid() {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if p.Parent != nil {
|
||||
// The module hash is contained within the parent's MANIFEST.txt file.
|
||||
// For example, the parent's MANIFEST.txt will contain an entry similar to this:
|
||||
//
|
||||
// ```
|
||||
// "datasource/module.js": "1234567890abcdef..."
|
||||
// ```
|
||||
//
|
||||
// Recursively call moduleHash with the parent plugin and with the children plugin folder path
|
||||
// to get the correct module hash for the nested plugin.
|
||||
if childFSBase == "" {
|
||||
childFSBase = p.FS.Base()
|
||||
}
|
||||
return c.moduleHash(ctx, p.Parent, childFSBase)
|
||||
}
|
||||
|
||||
// Only CDN plugins are supported for SRI checks.
|
||||
// CDN plugins have the version as part of the URL, which acts as a cache-buster.
|
||||
// Needed due to: https://github.com/grafana/plugin-tools/pull/1426
|
||||
// FS plugins build before this change will have SRI mismatch issues.
|
||||
if !c.cdnEnabled(p.ID, p.FS) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
manifest, err := c.signature.ReadPluginManifestFromFS(ctx, p.FS)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read plugin manifest: %w", err)
|
||||
}
|
||||
if !manifest.IsV2() {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var childPath string
|
||||
if childFSBase != "" {
|
||||
// Calculate the relative path of the child plugin folder from the parent plugin folder.
|
||||
childPath, err = p.FS.Rel(childFSBase)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("rel path: %w", err)
|
||||
}
|
||||
// MANIFETS.txt uses forward slashes as path separators.
|
||||
childPath = filepath.ToSlash(childPath)
|
||||
}
|
||||
moduleHash, ok := manifest.Files[path.Join(childPath, "module.js")]
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
return convertHashForSRI(moduleHash)
|
||||
}
|
||||
|
||||
func (c *Calculator) cdnEnabled(pluginID string, fs plugins.FS) bool {
|
||||
return c.cdn.PluginSupported(pluginID) || fs.Type().CDN()
|
||||
}
|
||||
|
||||
// convertHashForSRI takes a SHA256 hash string and returns it as expected by the browser for SRI checks.
|
||||
func convertHashForSRI(h string) (string, error) {
|
||||
hb, err := hex.DecodeString(h)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("hex decode string: %w", err)
|
||||
}
|
||||
return "sha256-" + base64.StdEncoding.EncodeToString(hb), nil
|
||||
}
|
||||
|
||||
// moduleHashCacheKey returns a unique key for the module hash cache.
|
||||
func (c *Calculator) moduleHashCacheKey(pluginId, pluginVersion string) string {
|
||||
return pluginId + ":" + pluginVersion
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
package modulehash
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/plugins/config"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/registry"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/signature"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/signature/statickey"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
|
||||
)
|
||||
|
||||
func Test_ModuleHash(t *testing.T) {
|
||||
const (
|
||||
pluginID = "grafana-test-datasource"
|
||||
parentPluginID = "grafana-test-app"
|
||||
)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
features *config.Features
|
||||
registry []*plugins.Plugin
|
||||
|
||||
// Can be used to configure plugin's fs
|
||||
// fs cdn type = loaded from CDN with no files on disk
|
||||
// fs local type = files on disk but served from CDN only if cdn=true
|
||||
plugin string
|
||||
|
||||
// When true, set cdn=true in config
|
||||
cdn bool
|
||||
expModuleHash string
|
||||
}{
|
||||
{
|
||||
name: "unsigned should not return module hash",
|
||||
plugin: pluginID,
|
||||
registry: []*plugins.Plugin{newPlugin(pluginID, withSignatureStatus(plugins.SignatureStatusUnsigned))},
|
||||
cdn: false,
|
||||
features: &config.Features{SriChecksEnabled: false},
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
plugin: pluginID,
|
||||
registry: []*plugins.Plugin{newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid"))),
|
||||
withClass(plugins.ClassExternal),
|
||||
)},
|
||||
cdn: true,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"),
|
||||
},
|
||||
{
|
||||
plugin: pluginID,
|
||||
registry: []*plugins.Plugin{newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid"))),
|
||||
withClass(plugins.ClassExternal),
|
||||
)},
|
||||
cdn: true,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"),
|
||||
},
|
||||
{
|
||||
plugin: pluginID,
|
||||
registry: []*plugins.Plugin{newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid"))),
|
||||
)},
|
||||
cdn: false,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
plugin: pluginID,
|
||||
registry: []*plugins.Plugin{newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid"))),
|
||||
)},
|
||||
cdn: true,
|
||||
features: &config.Features{SriChecksEnabled: false},
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
plugin: pluginID,
|
||||
registry: []*plugins.Plugin{newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid"))),
|
||||
)},
|
||||
cdn: false,
|
||||
features: &config.Features{SriChecksEnabled: false},
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
// parentPluginID (/)
|
||||
// └── pluginID (/datasource)
|
||||
name: "nested plugin should return module hash from parent MANIFEST.txt",
|
||||
plugin: pluginID,
|
||||
registry: []*plugins.Plugin{
|
||||
newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid-nested", "datasource"))),
|
||||
withParent(newPlugin(
|
||||
parentPluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid-nested"))),
|
||||
)),
|
||||
),
|
||||
},
|
||||
cdn: true,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: newSRIHash(t, "04d70db091d96c4775fb32ba5a8f84cc22893eb43afdb649726661d4425c6711"),
|
||||
},
|
||||
{
|
||||
// parentPluginID (/)
|
||||
// └── pluginID (/panels/one)
|
||||
name: "nested plugin deeper than one subfolder should return module hash from parent MANIFEST.txt",
|
||||
plugin: pluginID,
|
||||
registry: []*plugins.Plugin{
|
||||
newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid-nested", "panels", "one"))),
|
||||
withParent(newPlugin(
|
||||
parentPluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid-nested"))),
|
||||
)),
|
||||
),
|
||||
},
|
||||
cdn: true,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: newSRIHash(t, "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f"),
|
||||
},
|
||||
{
|
||||
// grand-parent-app (/)
|
||||
// ├── parent-datasource (/datasource)
|
||||
// │ └── child-panel (/datasource/panels/one)
|
||||
name: "nested plugin of a nested plugin should return module hash from parent MANIFEST.txt",
|
||||
registry: []*plugins.Plugin{
|
||||
newPlugin(
|
||||
"child-panel",
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid-deeply-nested", "datasource", "panels", "one"))),
|
||||
withParent(newPlugin(
|
||||
"parent-datasource",
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid-deeply-nested", "datasource"))),
|
||||
withParent(newPlugin(
|
||||
"grand-parent-app",
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid-deeply-nested"))),
|
||||
)),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
plugin: "child-panel",
|
||||
cdn: true,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: newSRIHash(t, "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f"),
|
||||
},
|
||||
{
|
||||
name: "nested plugin should not return module hash from parent if it's not registered in the registry",
|
||||
plugin: pluginID,
|
||||
registry: []*plugins.Plugin{newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid-nested", "panels", "one"))),
|
||||
withParent(newPlugin(
|
||||
parentPluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid-nested"))),
|
||||
)),
|
||||
)},
|
||||
cdn: false,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
name: "missing module.js entry from MANIFEST.txt should not return module hash",
|
||||
plugin: pluginID,
|
||||
registry: []*plugins.Plugin{newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-no-module-js"))),
|
||||
)},
|
||||
cdn: false,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
name: "signed status but missing MANIFEST.txt should not return module hash",
|
||||
plugin: pluginID,
|
||||
registry: []*plugins.Plugin{newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-no-manifest-txt"))),
|
||||
)},
|
||||
cdn: false,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: "",
|
||||
},
|
||||
} {
|
||||
if tc.name == "" {
|
||||
var expS string
|
||||
if tc.expModuleHash == "" {
|
||||
expS = "should not return module hash"
|
||||
} else {
|
||||
expS = "should return module hash"
|
||||
}
|
||||
tc.name = fmt.Sprintf("feature=%v, cdn_config=%v %s", tc.features.SriChecksEnabled, tc.cdn, expS)
|
||||
}
|
||||
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var pluginSettings config.PluginSettings
|
||||
if tc.cdn {
|
||||
pluginSettings = config.PluginSettings{
|
||||
pluginID: {
|
||||
"cdn": "true",
|
||||
},
|
||||
parentPluginID: map[string]string{
|
||||
"cdn": "true",
|
||||
},
|
||||
"grand-parent-app": map[string]string{
|
||||
"cdn": "true",
|
||||
},
|
||||
}
|
||||
}
|
||||
features := tc.features
|
||||
if features == nil {
|
||||
features = &config.Features{}
|
||||
}
|
||||
pCfg := &config.PluginManagementCfg{
|
||||
PluginsCDNURLTemplate: "http://cdn.example.com",
|
||||
PluginSettings: pluginSettings,
|
||||
Features: *features,
|
||||
}
|
||||
|
||||
svc := NewCalculator(
|
||||
pCfg,
|
||||
newPluginRegistry(t, tc.registry...),
|
||||
pluginscdn.ProvideService(pCfg),
|
||||
signature.ProvideService(pCfg, statickey.New()),
|
||||
)
|
||||
mh := svc.ModuleHash(context.Background(), tc.plugin, "")
|
||||
require.Equal(t, tc.expModuleHash, mh)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_ModuleHash_Cache(t *testing.T) {
|
||||
pCfg := &config.PluginManagementCfg{
|
||||
PluginSettings: config.PluginSettings{},
|
||||
Features: config.Features{SriChecksEnabled: true},
|
||||
}
|
||||
svc := NewCalculator(
|
||||
pCfg,
|
||||
newPluginRegistry(t),
|
||||
pluginscdn.ProvideService(pCfg),
|
||||
signature.ProvideService(pCfg, statickey.New()),
|
||||
)
|
||||
const pluginID = "grafana-test-datasource"
|
||||
|
||||
t.Run("cache key", func(t *testing.T) {
|
||||
t.Run("with version", func(t *testing.T) {
|
||||
const pluginVersion = "1.0.0"
|
||||
p := newPlugin(pluginID, withInfo(plugins.Info{Version: pluginVersion}))
|
||||
k := svc.moduleHashCacheKey(p.ID, p.Info.Version)
|
||||
require.Equal(t, pluginID+":"+pluginVersion, k, "cache key should be correct")
|
||||
})
|
||||
|
||||
t.Run("without version", func(t *testing.T) {
|
||||
p := newPlugin(pluginID)
|
||||
k := svc.moduleHashCacheKey(p.ID, p.Info.Version)
|
||||
require.Equal(t, pluginID+":", k, "cache key should be correct")
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("ModuleHash usage", func(t *testing.T) {
|
||||
pV1 := newPlugin(
|
||||
pluginID,
|
||||
withInfo(plugins.Info{Version: "1.0.0"}),
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid"))),
|
||||
)
|
||||
|
||||
pCfg = &config.PluginManagementCfg{
|
||||
PluginsCDNURLTemplate: "https://cdn.grafana.com",
|
||||
PluginSettings: config.PluginSettings{
|
||||
pluginID: {
|
||||
"cdn": "true",
|
||||
},
|
||||
},
|
||||
Features: config.Features{SriChecksEnabled: true},
|
||||
}
|
||||
reg := newPluginRegistry(t, pV1)
|
||||
svc = NewCalculator(
|
||||
pCfg,
|
||||
reg,
|
||||
pluginscdn.ProvideService(pCfg),
|
||||
signature.ProvideService(pCfg, statickey.New()),
|
||||
)
|
||||
|
||||
k := svc.moduleHashCacheKey(pV1.ID, pV1.Info.Version)
|
||||
|
||||
_, ok := svc.moduleHashCache.Load(k)
|
||||
require.False(t, ok, "cache should initially be empty")
|
||||
|
||||
mhV1 := svc.ModuleHash(context.Background(), pV1.ID, pV1.Info.Version)
|
||||
pV1Exp := newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03")
|
||||
require.Equal(t, pV1Exp, mhV1, "returned value should be correct")
|
||||
|
||||
cachedMh, ok := svc.moduleHashCache.Load(k)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, pV1Exp, cachedMh, "cache should contain the returned value")
|
||||
|
||||
t.Run("different version uses different cache key", func(t *testing.T) {
|
||||
pV2 := newPlugin(
|
||||
pluginID,
|
||||
withInfo(plugins.Info{Version: "2.0.0"}),
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
// different fs for different hash
|
||||
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid-nested"))),
|
||||
)
|
||||
err := reg.Add(context.Background(), pV2)
|
||||
require.NoError(t, err)
|
||||
|
||||
mhV2 := svc.ModuleHash(context.Background(), pV2.ID, pV2.Info.Version)
|
||||
require.NotEqual(t, mhV2, mhV1, "different version should have different hash")
|
||||
require.Equal(t, newSRIHash(t, "266c19bc148b22ddef2a288fc5f8f40855bda22ccf60be53340b4931e469ae2a"), mhV2)
|
||||
})
|
||||
|
||||
t.Run("cache should be used", func(t *testing.T) {
|
||||
// edit cache directly
|
||||
svc.moduleHashCache.Store(k, "hax")
|
||||
require.Equal(t, "hax", svc.ModuleHash(context.Background(), pV1.ID, pV1.Info.Version))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestConvertHashFromSRI(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
hash string
|
||||
expHash string
|
||||
expErr bool
|
||||
}{
|
||||
{
|
||||
hash: "ddfcb449445064e6c39f0c20b15be3cb6a55837cf4781df23d02de005f436811",
|
||||
expHash: "sha256-3fy0SURQZObDnwwgsVvjy2pVg3z0eB3yPQLeAF9DaBE=",
|
||||
},
|
||||
{
|
||||
hash: "not-a-valid-hash",
|
||||
expErr: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.hash, func(t *testing.T) {
|
||||
r, err := convertHashForSRI(tc.hash)
|
||||
if tc.expErr {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expHash, r)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newPlugin(pluginID string, cbs ...func(p *plugins.Plugin) *plugins.Plugin) *plugins.Plugin {
|
||||
p := &plugins.Plugin{
|
||||
JSONData: plugins.JSONData{
|
||||
ID: pluginID,
|
||||
},
|
||||
}
|
||||
for _, cb := range cbs {
|
||||
p = cb(p)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func withInfo(info plugins.Info) func(p *plugins.Plugin) *plugins.Plugin {
|
||||
return func(p *plugins.Plugin) *plugins.Plugin {
|
||||
p.Info = info
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
func withFS(fs plugins.FS) func(p *plugins.Plugin) *plugins.Plugin {
|
||||
return func(p *plugins.Plugin) *plugins.Plugin {
|
||||
p.FS = fs
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
func withSignatureStatus(status plugins.SignatureStatus) func(p *plugins.Plugin) *plugins.Plugin {
|
||||
return func(p *plugins.Plugin) *plugins.Plugin {
|
||||
p.Signature = status
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
func withParent(parent *plugins.Plugin) func(p *plugins.Plugin) *plugins.Plugin {
|
||||
return func(p *plugins.Plugin) *plugins.Plugin {
|
||||
p.Parent = parent
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
func withClass(class plugins.Class) func(p *plugins.Plugin) *plugins.Plugin {
|
||||
return func(p *plugins.Plugin) *plugins.Plugin {
|
||||
p.Class = class
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
func newSRIHash(t *testing.T, s string) string {
|
||||
r, err := convertHashForSRI(s)
|
||||
require.NoError(t, err)
|
||||
return r
|
||||
}
|
||||
|
||||
type pluginRegistry struct {
|
||||
registry.Service
|
||||
|
||||
reg map[string]*plugins.Plugin
|
||||
}
|
||||
|
||||
func newPluginRegistry(t *testing.T, ps ...*plugins.Plugin) *pluginRegistry {
|
||||
reg := &pluginRegistry{
|
||||
reg: make(map[string]*plugins.Plugin),
|
||||
}
|
||||
for _, p := range ps {
|
||||
err := reg.Add(context.Background(), p)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
return reg
|
||||
}
|
||||
|
||||
func (f *pluginRegistry) Plugin(_ context.Context, id, version string) (*plugins.Plugin, bool) {
|
||||
key := fmt.Sprintf("%s-%s", id, version)
|
||||
p, exists := f.reg[key]
|
||||
return p, exists
|
||||
}
|
||||
|
||||
func (f *pluginRegistry) Add(_ context.Context, p *plugins.Plugin) error {
|
||||
key := fmt.Sprintf("%s-%s", p.ID, p.Info.Version)
|
||||
f.reg[key] = p
|
||||
return nil
|
||||
}
|
||||
Generated
+4
-2
@@ -715,7 +715,8 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
|
||||
return nil, err
|
||||
}
|
||||
pluginscdnService := pluginscdn.ProvideService(pluginManagementCfg)
|
||||
pluginassetsService := pluginassets2.ProvideService(pluginManagementCfg, pluginscdnService, signatureSignature, pluginstoreService)
|
||||
calculator := pluginassets2.ProvideModuleHashCalculator(pluginManagementCfg, pluginscdnService, signatureSignature, inMemory)
|
||||
pluginassetsService := pluginassets2.ProvideService(pluginManagementCfg, pluginscdnService, calculator)
|
||||
avatarCacheServer := avatar.ProvideAvatarCacheServer(cfg)
|
||||
prefService := prefimpl.ProvideService(sqlStore, cfg)
|
||||
dashboardPermissionsService, err := ossaccesscontrol.ProvideDashboardPermissions(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, dashboardService, folderimplService, acimplService, teamService, userService, actionSetService, dashboardServiceImpl, eventualRestConfigProvider)
|
||||
@@ -1383,7 +1384,8 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
|
||||
return nil, err
|
||||
}
|
||||
pluginscdnService := pluginscdn.ProvideService(pluginManagementCfg)
|
||||
pluginassetsService := pluginassets2.ProvideService(pluginManagementCfg, pluginscdnService, signatureSignature, pluginstoreService)
|
||||
calculator := pluginassets2.ProvideModuleHashCalculator(pluginManagementCfg, pluginscdnService, signatureSignature, inMemory)
|
||||
pluginassetsService := pluginassets2.ProvideService(pluginManagementCfg, pluginscdnService, calculator)
|
||||
avatarCacheServer := avatar.ProvideAvatarCacheServer(cfg)
|
||||
prefService := prefimpl.ProvideService(sqlStore, cfg)
|
||||
dashboardPermissionsService, err := ossaccesscontrol.ProvideDashboardPermissions(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, dashboardService, folderimplService, acimplService, teamService, userService, actionSetService, dashboardServiceImpl, eventualRestConfigProvider)
|
||||
|
||||
@@ -2,19 +2,15 @@ package pluginassets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/Masterminds/semver/v3"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/plugins/config"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/registry"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/signature"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginassets/modulehash"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
|
||||
)
|
||||
@@ -28,24 +24,27 @@ var (
|
||||
scriptLoadingMinSupportedVersion = semver.MustParse(CreatePluginVersionScriptSupportEnabled)
|
||||
)
|
||||
|
||||
func ProvideService(cfg *config.PluginManagementCfg, cdn *pluginscdn.Service, sig *signature.Signature, store pluginstore.Store) *Service {
|
||||
func ProvideService(cfg *config.PluginManagementCfg, cdn *pluginscdn.Service,
|
||||
calc *modulehash.Calculator) *Service {
|
||||
return &Service{
|
||||
cfg: cfg,
|
||||
cdn: cdn,
|
||||
signature: sig,
|
||||
store: store,
|
||||
log: log.New("pluginassets"),
|
||||
cfg: cfg,
|
||||
cdn: cdn,
|
||||
log: log.New("pluginassets"),
|
||||
calc: calc,
|
||||
}
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
cfg *config.PluginManagementCfg
|
||||
cdn *pluginscdn.Service
|
||||
signature *signature.Signature
|
||||
store pluginstore.Store
|
||||
log log.Logger
|
||||
func ProvideModuleHashCalculator(cfg *config.PluginManagementCfg, cdn *pluginscdn.Service,
|
||||
signature *signature.Signature, reg registry.Service) *modulehash.Calculator {
|
||||
return modulehash.NewCalculator(cfg, reg, cdn, signature)
|
||||
}
|
||||
|
||||
moduleHashCache sync.Map
|
||||
type Service struct {
|
||||
cfg *config.PluginManagementCfg
|
||||
cdn *pluginscdn.Service
|
||||
calc *modulehash.Calculator
|
||||
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
// LoadingStrategy calculates the loading strategy for a plugin.
|
||||
@@ -83,92 +82,8 @@ func (s *Service) LoadingStrategy(_ context.Context, p pluginstore.Plugin) plugi
|
||||
}
|
||||
|
||||
// ModuleHash returns the module.js SHA256 hash for a plugin in the format expected by the browser for SRI checks.
|
||||
// The module hash is read from the plugin's MANIFEST.txt file.
|
||||
// The plugin can also be a nested plugin.
|
||||
// If the plugin is unsigned, an empty string is returned.
|
||||
// The results are cached to avoid repeated reads from the MANIFEST.txt file.
|
||||
func (s *Service) ModuleHash(ctx context.Context, p pluginstore.Plugin) string {
|
||||
k := s.moduleHashCacheKey(p)
|
||||
cachedValue, ok := s.moduleHashCache.Load(k)
|
||||
if ok {
|
||||
return cachedValue.(string)
|
||||
}
|
||||
mh, err := s.moduleHash(ctx, p, "")
|
||||
if err != nil {
|
||||
s.log.Error("Failed to calculate module hash", "plugin", p.ID, "error", err)
|
||||
}
|
||||
s.moduleHashCache.Store(k, mh)
|
||||
return mh
|
||||
}
|
||||
|
||||
// moduleHash is the underlying function for ModuleHash. See its documentation for more information.
|
||||
// If the plugin is not a CDN plugin, the function will return an empty string.
|
||||
// It will read the module hash from the MANIFEST.txt in the [[plugins.FS]] of the provided plugin.
|
||||
// If childFSBase is provided, the function will try to get the hash from MANIFEST.txt for the provided children's
|
||||
// module.js file, rather than for the provided plugin.
|
||||
func (s *Service) moduleHash(ctx context.Context, p pluginstore.Plugin, childFSBase string) (r string, err error) {
|
||||
if !s.cfg.Features.SriChecksEnabled {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Ignore unsigned plugins
|
||||
if !p.Signature.IsValid() {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if p.Parent != nil {
|
||||
// Nested plugin
|
||||
parent, ok := s.store.Plugin(ctx, p.Parent.ID)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("parent plugin plugin %q for child plugin %q not found", p.Parent.ID, p.ID)
|
||||
}
|
||||
|
||||
// The module hash is contained within the parent's MANIFEST.txt file.
|
||||
// For example, the parent's MANIFEST.txt will contain an entry similar to this:
|
||||
//
|
||||
// ```
|
||||
// "datasource/module.js": "1234567890abcdef..."
|
||||
// ```
|
||||
//
|
||||
// Recursively call moduleHash with the parent plugin and with the children plugin folder path
|
||||
// to get the correct module hash for the nested plugin.
|
||||
if childFSBase == "" {
|
||||
childFSBase = p.Base()
|
||||
}
|
||||
return s.moduleHash(ctx, parent, childFSBase)
|
||||
}
|
||||
|
||||
// Only CDN plugins are supported for SRI checks.
|
||||
// CDN plugins have the version as part of the URL, which acts as a cache-buster.
|
||||
// Needed due to: https://github.com/grafana/plugin-tools/pull/1426
|
||||
// FS plugins build before this change will have SRI mismatch issues.
|
||||
if !s.cdnEnabled(p.ID, p.FS) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
manifest, err := s.signature.ReadPluginManifestFromFS(ctx, p.FS)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read plugin manifest: %w", err)
|
||||
}
|
||||
if !manifest.IsV2() {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var childPath string
|
||||
if childFSBase != "" {
|
||||
// Calculate the relative path of the child plugin folder from the parent plugin folder.
|
||||
childPath, err = p.FS.Rel(childFSBase)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("rel path: %w", err)
|
||||
}
|
||||
// MANIFETS.txt uses forward slashes as path separators.
|
||||
childPath = filepath.ToSlash(childPath)
|
||||
}
|
||||
moduleHash, ok := manifest.Files[path.Join(childPath, "module.js")]
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
return convertHashForSRI(moduleHash)
|
||||
return s.calc.ModuleHash(ctx, p.ID, p.Info.Version)
|
||||
}
|
||||
|
||||
func (s *Service) compatibleCreatePluginVersion(ps map[string]string) bool {
|
||||
@@ -188,17 +103,3 @@ func (s *Service) compatibleCreatePluginVersion(ps map[string]string) bool {
|
||||
func (s *Service) cdnEnabled(pluginID string, fs plugins.FS) bool {
|
||||
return s.cdn.PluginSupported(pluginID) || fs.Type().CDN()
|
||||
}
|
||||
|
||||
// convertHashForSRI takes a SHA256 hash string and returns it as expected by the browser for SRI checks.
|
||||
func convertHashForSRI(h string) (string, error) {
|
||||
hb, err := hex.DecodeString(h)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("hex decode string: %w", err)
|
||||
}
|
||||
return "sha256-" + base64.StdEncoding.EncodeToString(hb), nil
|
||||
}
|
||||
|
||||
// moduleHashCacheKey returns a unique key for the module hash cache.
|
||||
func (s *Service) moduleHashCacheKey(p pluginstore.Plugin) string {
|
||||
return p.ID + ":" + p.Info.Version
|
||||
}
|
||||
|
||||
@@ -2,19 +2,14 @@ package pluginassets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/plugins/config"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/pluginfakes"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/signature"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/signature/statickey"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
|
||||
)
|
||||
@@ -179,349 +174,6 @@ func TestService_Calculate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ModuleHash(t *testing.T) {
|
||||
const (
|
||||
pluginID = "grafana-test-datasource"
|
||||
parentPluginID = "grafana-test-app"
|
||||
)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
features *config.Features
|
||||
store []pluginstore.Plugin
|
||||
|
||||
// Can be used to configure plugin's fs
|
||||
// fs cdn type = loaded from CDN with no files on disk
|
||||
// fs local type = files on disk but served from CDN only if cdn=true
|
||||
plugin pluginstore.Plugin
|
||||
|
||||
// When true, set cdn=true in config
|
||||
cdn bool
|
||||
expModuleHash string
|
||||
}{
|
||||
{
|
||||
name: "unsigned should not return module hash",
|
||||
plugin: newPlugin(pluginID, withSignatureStatus(plugins.SignatureStatusUnsigned)),
|
||||
cdn: false,
|
||||
features: &config.Features{SriChecksEnabled: false},
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
plugin: newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
|
||||
withClass(plugins.ClassExternal),
|
||||
),
|
||||
cdn: true,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"),
|
||||
},
|
||||
{
|
||||
plugin: newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
|
||||
withClass(plugins.ClassExternal),
|
||||
),
|
||||
cdn: true,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"),
|
||||
},
|
||||
{
|
||||
plugin: newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
|
||||
),
|
||||
cdn: false,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
plugin: newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
|
||||
),
|
||||
cdn: true,
|
||||
features: &config.Features{SriChecksEnabled: false},
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
plugin: newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
|
||||
),
|
||||
cdn: false,
|
||||
features: &config.Features{SriChecksEnabled: false},
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
// parentPluginID (/)
|
||||
// └── pluginID (/datasource)
|
||||
name: "nested plugin should return module hash from parent MANIFEST.txt",
|
||||
store: []pluginstore.Plugin{
|
||||
newPlugin(
|
||||
parentPluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested"))),
|
||||
),
|
||||
},
|
||||
plugin: newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "datasource"))),
|
||||
withParent(parentPluginID),
|
||||
),
|
||||
cdn: true,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: newSRIHash(t, "04d70db091d96c4775fb32ba5a8f84cc22893eb43afdb649726661d4425c6711"),
|
||||
},
|
||||
{
|
||||
// parentPluginID (/)
|
||||
// └── pluginID (/panels/one)
|
||||
name: "nested plugin deeper than one subfolder should return module hash from parent MANIFEST.txt",
|
||||
store: []pluginstore.Plugin{
|
||||
newPlugin(
|
||||
parentPluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested"))),
|
||||
),
|
||||
},
|
||||
plugin: newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "panels", "one"))),
|
||||
withParent(parentPluginID),
|
||||
),
|
||||
cdn: true,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: newSRIHash(t, "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f"),
|
||||
},
|
||||
{
|
||||
// grand-parent-app (/)
|
||||
// ├── parent-datasource (/datasource)
|
||||
// │ └── child-panel (/datasource/panels/one)
|
||||
name: "nested plugin of a nested plugin should return module hash from parent MANIFEST.txt",
|
||||
store: []pluginstore.Plugin{
|
||||
newPlugin(
|
||||
"grand-parent-app",
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested"))),
|
||||
),
|
||||
newPlugin(
|
||||
"parent-datasource",
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested", "datasource"))),
|
||||
withParent("grand-parent-app"),
|
||||
),
|
||||
},
|
||||
plugin: newPlugin(
|
||||
"child-panel",
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested", "datasource", "panels", "one"))),
|
||||
withParent("parent-datasource"),
|
||||
),
|
||||
cdn: true,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: newSRIHash(t, "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f"),
|
||||
},
|
||||
{
|
||||
name: "nested plugin should not return module hash from parent if it's not registered in the store",
|
||||
store: []pluginstore.Plugin{},
|
||||
plugin: newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "panels", "one"))),
|
||||
withParent(parentPluginID),
|
||||
),
|
||||
cdn: false,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
name: "missing module.js entry from MANIFEST.txt should not return module hash",
|
||||
plugin: newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-no-module-js"))),
|
||||
),
|
||||
cdn: false,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
name: "signed status but missing MANIFEST.txt should not return module hash",
|
||||
plugin: newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-no-manifest-txt"))),
|
||||
),
|
||||
cdn: false,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: "",
|
||||
},
|
||||
} {
|
||||
if tc.name == "" {
|
||||
var expS string
|
||||
if tc.expModuleHash == "" {
|
||||
expS = "should not return module hash"
|
||||
} else {
|
||||
expS = "should return module hash"
|
||||
}
|
||||
tc.name = fmt.Sprintf("feature=%v, cdn_config=%v, class=%v %s", tc.features.SriChecksEnabled, tc.cdn, tc.plugin.Class, expS)
|
||||
}
|
||||
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var pluginSettings config.PluginSettings
|
||||
if tc.cdn {
|
||||
pluginSettings = config.PluginSettings{
|
||||
pluginID: {
|
||||
"cdn": "true",
|
||||
},
|
||||
parentPluginID: map[string]string{
|
||||
"cdn": "true",
|
||||
},
|
||||
"grand-parent-app": map[string]string{
|
||||
"cdn": "true",
|
||||
},
|
||||
}
|
||||
}
|
||||
features := tc.features
|
||||
if features == nil {
|
||||
features = &config.Features{}
|
||||
}
|
||||
pCfg := &config.PluginManagementCfg{
|
||||
PluginsCDNURLTemplate: "http://cdn.example.com",
|
||||
PluginSettings: pluginSettings,
|
||||
Features: *features,
|
||||
}
|
||||
svc := ProvideService(
|
||||
pCfg,
|
||||
pluginscdn.ProvideService(pCfg),
|
||||
signature.ProvideService(pCfg, statickey.New()),
|
||||
pluginstore.NewFakePluginStore(tc.store...),
|
||||
)
|
||||
mh := svc.ModuleHash(context.Background(), tc.plugin)
|
||||
require.Equal(t, tc.expModuleHash, mh)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ModuleHash_Cache(t *testing.T) {
|
||||
pCfg := &config.PluginManagementCfg{
|
||||
PluginSettings: config.PluginSettings{},
|
||||
Features: config.Features{SriChecksEnabled: true},
|
||||
}
|
||||
svc := ProvideService(
|
||||
pCfg,
|
||||
pluginscdn.ProvideService(pCfg),
|
||||
signature.ProvideService(pCfg, statickey.New()),
|
||||
pluginstore.NewFakePluginStore(),
|
||||
)
|
||||
const pluginID = "grafana-test-datasource"
|
||||
|
||||
t.Run("cache key", func(t *testing.T) {
|
||||
t.Run("with version", func(t *testing.T) {
|
||||
const pluginVersion = "1.0.0"
|
||||
p := newPlugin(pluginID, withInfo(plugins.Info{Version: pluginVersion}))
|
||||
k := svc.moduleHashCacheKey(p)
|
||||
require.Equal(t, pluginID+":"+pluginVersion, k, "cache key should be correct")
|
||||
})
|
||||
|
||||
t.Run("without version", func(t *testing.T) {
|
||||
p := newPlugin(pluginID)
|
||||
k := svc.moduleHashCacheKey(p)
|
||||
require.Equal(t, pluginID+":", k, "cache key should be correct")
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("ModuleHash usage", func(t *testing.T) {
|
||||
pV1 := newPlugin(
|
||||
pluginID,
|
||||
withInfo(plugins.Info{Version: "1.0.0"}),
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
|
||||
)
|
||||
|
||||
pCfg = &config.PluginManagementCfg{
|
||||
PluginsCDNURLTemplate: "https://cdn.grafana.com",
|
||||
PluginSettings: config.PluginSettings{
|
||||
pluginID: {
|
||||
"cdn": "true",
|
||||
},
|
||||
},
|
||||
Features: config.Features{SriChecksEnabled: true},
|
||||
}
|
||||
svc = ProvideService(
|
||||
pCfg,
|
||||
pluginscdn.ProvideService(pCfg),
|
||||
signature.ProvideService(pCfg, statickey.New()),
|
||||
pluginstore.NewFakePluginStore(),
|
||||
)
|
||||
|
||||
k := svc.moduleHashCacheKey(pV1)
|
||||
|
||||
_, ok := svc.moduleHashCache.Load(k)
|
||||
require.False(t, ok, "cache should initially be empty")
|
||||
|
||||
mhV1 := svc.ModuleHash(context.Background(), pV1)
|
||||
pV1Exp := newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03")
|
||||
require.Equal(t, pV1Exp, mhV1, "returned value should be correct")
|
||||
|
||||
cachedMh, ok := svc.moduleHashCache.Load(k)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, pV1Exp, cachedMh, "cache should contain the returned value")
|
||||
|
||||
t.Run("different version uses different cache key", func(t *testing.T) {
|
||||
pV2 := newPlugin(
|
||||
pluginID,
|
||||
withInfo(plugins.Info{Version: "2.0.0"}),
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
// different fs for different hash
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested"))),
|
||||
)
|
||||
mhV2 := svc.ModuleHash(context.Background(), pV2)
|
||||
require.NotEqual(t, mhV2, mhV1, "different version should have different hash")
|
||||
require.Equal(t, newSRIHash(t, "266c19bc148b22ddef2a288fc5f8f40855bda22ccf60be53340b4931e469ae2a"), mhV2)
|
||||
})
|
||||
|
||||
t.Run("cache should be used", func(t *testing.T) {
|
||||
// edit cache directly
|
||||
svc.moduleHashCache.Store(k, "hax")
|
||||
require.Equal(t, "hax", svc.ModuleHash(context.Background(), pV1))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestConvertHashFromSRI(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
hash string
|
||||
expHash string
|
||||
expErr bool
|
||||
}{
|
||||
{
|
||||
hash: "ddfcb449445064e6c39f0c20b15be3cb6a55837cf4781df23d02de005f436811",
|
||||
expHash: "sha256-3fy0SURQZObDnwwgsVvjy2pVg3z0eB3yPQLeAF9DaBE=",
|
||||
},
|
||||
{
|
||||
hash: "not-a-valid-hash",
|
||||
expErr: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.hash, func(t *testing.T) {
|
||||
r, err := convertHashForSRI(tc.hash)
|
||||
if tc.expErr {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expHash, r)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newPlugin(pluginID string, cbs ...func(p pluginstore.Plugin) pluginstore.Plugin) pluginstore.Plugin {
|
||||
p := pluginstore.Plugin{
|
||||
JSONData: plugins.JSONData{
|
||||
@@ -534,13 +186,6 @@ func newPlugin(pluginID string, cbs ...func(p pluginstore.Plugin) pluginstore.Pl
|
||||
return p
|
||||
}
|
||||
|
||||
func withInfo(info plugins.Info) func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
return func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
p.Info = info
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
func withFS(fs plugins.FS) func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
return func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
p.FS = fs
|
||||
@@ -548,13 +193,6 @@ func withFS(fs plugins.FS) func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
func withSignatureStatus(status plugins.SignatureStatus) func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
return func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
p.Signature = status
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
func withAngular(angular bool) func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
return func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
p.Angular = plugins.AngularMeta{Detected: angular}
|
||||
@@ -562,13 +200,6 @@ func withAngular(angular bool) func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
func withParent(parentID string) func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
return func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
p.Parent = &pluginstore.ParentPlugin{ID: parentID}
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
func withClass(class plugins.Class) func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
return func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
p.Class = class
|
||||
@@ -587,9 +218,3 @@ func newPluginSettings(pluginID string, kv map[string]string) config.PluginSetti
|
||||
pluginID: kv,
|
||||
}
|
||||
}
|
||||
|
||||
func newSRIHash(t *testing.T, s string) string {
|
||||
r, err := convertHashForSRI(s)
|
||||
require.NoError(t, err)
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -131,6 +131,7 @@ var WireSet = wire.NewSet(
|
||||
plugincontext.ProvideBaseService,
|
||||
wire.Bind(new(plugincontext.BasePluginContextProvider), new(*plugincontext.BaseProvider)),
|
||||
plugininstaller.ProvideService,
|
||||
pluginassets.ProvideModuleHashCalculator,
|
||||
pluginassets.ProvideService,
|
||||
pluginchecker.ProvidePreinstall,
|
||||
wire.Bind(new(pluginchecker.Preinstall), new(*pluginchecker.PreinstallImpl)),
|
||||
|
||||
@@ -1,288 +0,0 @@
|
||||
/**
|
||||
* Mutation Executor
|
||||
*
|
||||
* Executes dashboard mutations with transaction support and event emission.
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import type { DashboardScene } from '../scene/DashboardScene';
|
||||
|
||||
import {
|
||||
handleAddPanel,
|
||||
handleRemovePanel,
|
||||
handleUpdatePanel,
|
||||
handleMovePanel,
|
||||
handleAddVariable,
|
||||
handleRemoveVariable,
|
||||
handleAddRow,
|
||||
handleUpdateTimeSettings,
|
||||
handleUpdateDashboardMeta,
|
||||
handleGetDashboardInfo,
|
||||
type MutationContext,
|
||||
type MutationTransactionInternal,
|
||||
type MutationHandler,
|
||||
} from './handlers';
|
||||
import {
|
||||
type Mutation,
|
||||
type MutationType,
|
||||
type MutationResult,
|
||||
type MutationEvent,
|
||||
type MutationPayloadMap,
|
||||
} from './types';
|
||||
|
||||
// ============================================================================
|
||||
// Event Bus
|
||||
// ============================================================================
|
||||
|
||||
type MutationEventListener = (event: MutationEvent) => void;
|
||||
|
||||
class MutationEventBus {
|
||||
private listeners: Set<MutationEventListener> = new Set();
|
||||
|
||||
subscribe(listener: MutationEventListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
emit(event: MutationEvent): void {
|
||||
this.listeners.forEach((listener) => {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (error) {
|
||||
console.error('Event listener error:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mutation Executor
|
||||
// ============================================================================
|
||||
|
||||
export class MutationExecutor {
|
||||
private scene!: DashboardScene;
|
||||
private handlers: Map<MutationType, MutationHandler> = new Map();
|
||||
private eventBus = new MutationEventBus();
|
||||
private _currentTransaction: MutationTransactionInternal | null = null;
|
||||
|
||||
constructor() {
|
||||
this.registerDefaultHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the dashboard scene to operate on
|
||||
*/
|
||||
setScene(scene: DashboardScene): void {
|
||||
this.scene = scene;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to mutation events
|
||||
*/
|
||||
onMutation(listener: MutationEventListener): () => void {
|
||||
return this.eventBus.subscribe(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single mutation
|
||||
*/
|
||||
async execute(mutation: Mutation): Promise<MutationResult> {
|
||||
const results = await this.executeBatch([mutation]);
|
||||
return results[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute multiple mutations atomically
|
||||
*/
|
||||
async executeBatch(mutations: Mutation[]): Promise<MutationResult[]> {
|
||||
if (!this.scene) {
|
||||
throw new Error('No scene set. Call setScene() first.');
|
||||
}
|
||||
|
||||
// Create transaction
|
||||
const transaction: MutationTransactionInternal = {
|
||||
id: uuidv4(),
|
||||
mutations,
|
||||
status: 'pending',
|
||||
startedAt: Date.now(),
|
||||
changes: [],
|
||||
};
|
||||
|
||||
this._currentTransaction = transaction;
|
||||
|
||||
const results: MutationResult[] = [];
|
||||
const context: MutationContext = { scene: this.scene, transaction };
|
||||
|
||||
try {
|
||||
// Execute each mutation
|
||||
for (const mutation of mutations) {
|
||||
const handler = this.handlers.get(mutation.type);
|
||||
if (!handler) {
|
||||
throw new Error(`No handler registered for mutation type: ${mutation.type}`);
|
||||
}
|
||||
|
||||
const result = await handler(mutation.payload, context);
|
||||
results.push(result);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || `Mutation ${mutation.type} failed`);
|
||||
}
|
||||
|
||||
// Emit success event
|
||||
this.eventBus.emit({
|
||||
type: 'mutation_applied',
|
||||
mutation,
|
||||
result,
|
||||
transaction,
|
||||
timestamp: Date.now(),
|
||||
source: 'assistant',
|
||||
});
|
||||
}
|
||||
|
||||
// Commit transaction
|
||||
transaction.status = 'committed';
|
||||
transaction.completedAt = Date.now();
|
||||
|
||||
// Trigger scene refresh
|
||||
this.scene.forceRender();
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
// Probably need a rollback mechanism here... but skipping this for POC
|
||||
console.error('Mutation batch failed:', error);
|
||||
|
||||
transaction.status = 'rolled_back';
|
||||
transaction.completedAt = Date.now();
|
||||
|
||||
// Emit failure event
|
||||
this.eventBus.emit({
|
||||
type: 'mutation_rolled_back',
|
||||
mutation: mutations[0],
|
||||
result: { success: false, error: String(error), changes: [] },
|
||||
transaction,
|
||||
timestamp: Date.now(),
|
||||
source: 'assistant',
|
||||
});
|
||||
|
||||
// Return error results for remaining mutations
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
while (results.length < mutations.length) {
|
||||
results.push({
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
changes: [],
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
} finally {
|
||||
this._currentTransaction = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current transaction (for debugging)
|
||||
*/
|
||||
get currentTransaction(): MutationTransactionInternal | null {
|
||||
return this._currentTransaction;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Handler Registration
|
||||
// ==========================================================================
|
||||
|
||||
private registerDefaultHandlers(): void {
|
||||
// Panel operations
|
||||
this.registerHandler('ADD_PANEL', handleAddPanel);
|
||||
this.registerHandler('REMOVE_PANEL', handleRemovePanel);
|
||||
this.registerHandler('UPDATE_PANEL', handleUpdatePanel);
|
||||
this.registerHandler('MOVE_PANEL', handleMovePanel);
|
||||
this.registerHandler('DUPLICATE_PANEL', this.notImplemented('DUPLICATE_PANEL'));
|
||||
|
||||
// Variable operations
|
||||
this.registerHandler('ADD_VARIABLE', handleAddVariable);
|
||||
this.registerHandler('REMOVE_VARIABLE', handleRemoveVariable);
|
||||
this.registerHandler('UPDATE_VARIABLE', this.notImplemented('UPDATE_VARIABLE'));
|
||||
|
||||
// Row operations
|
||||
this.registerHandler('ADD_ROW', handleAddRow);
|
||||
this.registerHandler('REMOVE_ROW', this.notImplemented('REMOVE_ROW'));
|
||||
this.registerHandler('COLLAPSE_ROW', this.notImplemented('COLLAPSE_ROW'));
|
||||
|
||||
// Tab operations
|
||||
this.registerHandler('ADD_TAB', this.notImplemented('ADD_TAB'));
|
||||
this.registerHandler('REMOVE_TAB', this.notImplemented('REMOVE_TAB'));
|
||||
|
||||
// Library panel operations
|
||||
this.registerHandler('ADD_LIBRARY_PANEL', this.notImplemented('ADD_LIBRARY_PANEL'));
|
||||
this.registerHandler('UNLINK_LIBRARY_PANEL', this.notImplemented('UNLINK_LIBRARY_PANEL'));
|
||||
this.registerHandler('SAVE_AS_LIBRARY_PANEL', this.notImplemented('SAVE_AS_LIBRARY_PANEL'));
|
||||
|
||||
// Repeat configuration
|
||||
this.registerHandler('CONFIGURE_PANEL_REPEAT', this.notImplemented('CONFIGURE_PANEL_REPEAT'));
|
||||
this.registerHandler('CONFIGURE_ROW_REPEAT', this.notImplemented('CONFIGURE_ROW_REPEAT'));
|
||||
|
||||
// Conditional rendering
|
||||
this.registerHandler('SET_CONDITIONAL_RENDERING', this.notImplemented('SET_CONDITIONAL_RENDERING'));
|
||||
|
||||
// Layout
|
||||
this.registerHandler('CHANGE_LAYOUT_TYPE', this.notImplemented('CHANGE_LAYOUT_TYPE'));
|
||||
|
||||
// Annotation operations
|
||||
this.registerHandler('ADD_ANNOTATION', this.notImplemented('ADD_ANNOTATION'));
|
||||
this.registerHandler('UPDATE_ANNOTATION', this.notImplemented('UPDATE_ANNOTATION'));
|
||||
this.registerHandler('REMOVE_ANNOTATION', this.notImplemented('REMOVE_ANNOTATION'));
|
||||
|
||||
// Link operations
|
||||
this.registerHandler('ADD_DASHBOARD_LINK', this.notImplemented('ADD_DASHBOARD_LINK'));
|
||||
this.registerHandler('REMOVE_DASHBOARD_LINK', this.notImplemented('REMOVE_DASHBOARD_LINK'));
|
||||
this.registerHandler('ADD_PANEL_LINK', this.notImplemented('ADD_PANEL_LINK'));
|
||||
this.registerHandler('ADD_DATA_LINK', this.notImplemented('ADD_DATA_LINK'));
|
||||
|
||||
// Field configuration
|
||||
this.registerHandler('ADD_FIELD_OVERRIDE', this.notImplemented('ADD_FIELD_OVERRIDE'));
|
||||
this.registerHandler('ADD_VALUE_MAPPING', this.notImplemented('ADD_VALUE_MAPPING'));
|
||||
this.registerHandler('ADD_TRANSFORMATION', this.notImplemented('ADD_TRANSFORMATION'));
|
||||
|
||||
// Dashboard settings
|
||||
this.registerHandler('UPDATE_TIME_SETTINGS', handleUpdateTimeSettings);
|
||||
this.registerHandler('UPDATE_DASHBOARD_META', handleUpdateDashboardMeta);
|
||||
|
||||
// Dashboard management (backend operations)
|
||||
this.registerHandler('MOVE_TO_FOLDER', this.notImplemented('MOVE_TO_FOLDER'));
|
||||
this.registerHandler('TOGGLE_FAVORITE', this.notImplemented('TOGGLE_FAVORITE'));
|
||||
|
||||
// Version management (backend operations)
|
||||
this.registerHandler('LIST_VERSIONS', this.notImplemented('LIST_VERSIONS'));
|
||||
this.registerHandler('COMPARE_VERSIONS', this.notImplemented('COMPARE_VERSIONS'));
|
||||
this.registerHandler('RESTORE_VERSION', this.notImplemented('RESTORE_VERSION'));
|
||||
|
||||
// Read-only operations
|
||||
this.registerHandler('GET_DASHBOARD_INFO', handleGetDashboardInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a stub handler for not-yet-implemented mutations
|
||||
*/
|
||||
private notImplemented(mutationType: string): MutationHandler {
|
||||
return async (): Promise<MutationResult> => {
|
||||
return {
|
||||
success: false,
|
||||
changes: [],
|
||||
error: `${mutationType} is not fully implemented in POC`,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a mutation handler
|
||||
*/
|
||||
private registerHandler<T extends MutationType>(
|
||||
type: T,
|
||||
handler: (payload: MutationPayloadMap[T], context: MutationContext) => Promise<MutationResult>
|
||||
): void {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
this.handlers.set(type, handler as MutationHandler);
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
/**
|
||||
* Dashboard settings mutation handlers
|
||||
*/
|
||||
|
||||
import type { TimeSettingsSpec } from '@grafana/schema/src/schema/dashboard/v2beta1/types.spec.gen';
|
||||
|
||||
import { DASHBOARD_MCP_TOOLS } from '../mcpTools';
|
||||
import type { MutationResult, MutationChange, UpdateDashboardMetaPayload, AddRowPayload } from '../types';
|
||||
|
||||
import type { MutationContext } from './types';
|
||||
|
||||
/**
|
||||
* Add a row (stub - not fully implemented)
|
||||
*/
|
||||
export async function handleAddRow(_payload: AddRowPayload, _context: MutationContext): Promise<MutationResult> {
|
||||
return {
|
||||
success: true,
|
||||
changes: [],
|
||||
warnings: ['Add row is not fully implemented in POC - requires RowsLayout'],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update dashboard time settings
|
||||
*/
|
||||
export async function handleUpdateTimeSettings(
|
||||
payload: Partial<TimeSettingsSpec>,
|
||||
context: MutationContext
|
||||
): Promise<MutationResult> {
|
||||
const { scene, transaction } = context;
|
||||
const { from, to, timezone, autoRefresh } = payload;
|
||||
|
||||
try {
|
||||
const timeRange = scene.state.$timeRange;
|
||||
if (!timeRange) {
|
||||
throw new Error('Dashboard has no time range');
|
||||
}
|
||||
|
||||
const previousState = { ...timeRange.state };
|
||||
|
||||
// Apply updates based on TimeSettingsSpec fields
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (from !== undefined) {
|
||||
updates.from = from;
|
||||
}
|
||||
if (to !== undefined) {
|
||||
updates.to = to;
|
||||
}
|
||||
if (timezone !== undefined) {
|
||||
updates.timeZone = timezone;
|
||||
}
|
||||
if (autoRefresh !== undefined) {
|
||||
// autoRefresh would be applied to the dashboard refresh interval
|
||||
updates.refreshInterval = autoRefresh;
|
||||
}
|
||||
|
||||
timeRange.setState(updates);
|
||||
|
||||
const changes: MutationChange[] = [{ path: '/timeSettings', previousValue: previousState, newValue: updates }];
|
||||
transaction.changes.push(...changes);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
inverseMutation: {
|
||||
type: 'UPDATE_TIME_SETTINGS',
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
payload: previousState as Partial<TimeSettingsSpec>,
|
||||
},
|
||||
changes,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
changes: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update dashboard metadata (title, description, tags, etc.)
|
||||
*/
|
||||
export async function handleUpdateDashboardMeta(
|
||||
payload: UpdateDashboardMetaPayload,
|
||||
context: MutationContext
|
||||
): Promise<MutationResult> {
|
||||
const { scene, transaction } = context;
|
||||
const { title, description, tags, editable } = payload;
|
||||
|
||||
try {
|
||||
const previousState = {
|
||||
title: scene.state.title,
|
||||
description: scene.state.description,
|
||||
tags: scene.state.tags,
|
||||
editable: scene.state.editable,
|
||||
};
|
||||
|
||||
// Apply updates
|
||||
const updates: Partial<UpdateDashboardMetaPayload> = {};
|
||||
if (title !== undefined) {
|
||||
updates.title = title;
|
||||
}
|
||||
if (description !== undefined) {
|
||||
updates.description = description;
|
||||
}
|
||||
if (tags !== undefined) {
|
||||
updates.tags = tags;
|
||||
}
|
||||
if (editable !== undefined) {
|
||||
updates.editable = editable;
|
||||
}
|
||||
|
||||
scene.setState(updates);
|
||||
|
||||
const changes: MutationChange[] = [{ path: '/meta', previousValue: previousState, newValue: updates }];
|
||||
transaction.changes.push(...changes);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
inverseMutation: {
|
||||
type: 'UPDATE_DASHBOARD_META',
|
||||
payload: previousState,
|
||||
},
|
||||
changes,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
changes: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dashboard info (read-only operation)
|
||||
*/
|
||||
export async function handleGetDashboardInfo(
|
||||
_payload: Record<string, never>,
|
||||
context: MutationContext
|
||||
): Promise<MutationResult> {
|
||||
const { scene } = context;
|
||||
|
||||
// Return dashboard info in the result's data field
|
||||
const info = {
|
||||
available: true,
|
||||
uid: scene.state.uid,
|
||||
title: scene.state.title,
|
||||
canEdit: scene.canEditDashboard(),
|
||||
isEditing: scene.state.isEditing ?? false,
|
||||
availableTools: DASHBOARD_MCP_TOOLS.map((t) => t.name),
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
changes: [],
|
||||
data: info,
|
||||
};
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* Mutation Handlers
|
||||
*
|
||||
* Pure functions that implement dashboard mutations.
|
||||
* Each handler receives a payload and context, and returns a MutationResult.
|
||||
*/
|
||||
|
||||
// Types
|
||||
// eslint-disable-next-line no-barrel-files/no-barrel-files
|
||||
export type { MutationContext, MutationTransactionInternal, MutationHandler } from './types';
|
||||
|
||||
// Panel handlers
|
||||
// eslint-disable-next-line no-barrel-files/no-barrel-files
|
||||
export { handleAddPanel, handleRemovePanel, handleUpdatePanel, handleMovePanel } from './panelHandlers';
|
||||
|
||||
// Variable handlers
|
||||
// eslint-disable-next-line no-barrel-files/no-barrel-files
|
||||
export { handleAddVariable, handleRemoveVariable } from './variableHandlers';
|
||||
|
||||
// Dashboard handlers
|
||||
// eslint-disable-next-line no-barrel-files/no-barrel-files
|
||||
export {
|
||||
handleAddRow,
|
||||
handleUpdateTimeSettings,
|
||||
handleUpdateDashboardMeta,
|
||||
handleGetDashboardInfo,
|
||||
} from './dashboardHandlers';
|
||||
@@ -1,224 +0,0 @@
|
||||
/**
|
||||
* Panel mutation handlers
|
||||
*/
|
||||
|
||||
import type { MutationResult, MutationChange, AddPanelPayload, RemovePanelPayload, UpdatePanelPayload } from '../types';
|
||||
|
||||
import type { MutationContext } from './types';
|
||||
|
||||
/**
|
||||
* Add a new panel to the dashboard
|
||||
*/
|
||||
export async function handleAddPanel(payload: AddPanelPayload, context: MutationContext): Promise<MutationResult> {
|
||||
const { scene, transaction } = context;
|
||||
|
||||
try {
|
||||
// Extract values with defaults
|
||||
// Top-level fields take precedence, then spec fields, then defaults
|
||||
const title = payload.title ?? payload.spec?.title ?? 'New Panel';
|
||||
// VizConfigKind.group contains the plugin ID
|
||||
const vizType = payload.vizType ?? payload.spec?.vizConfig?.group ?? 'timeseries';
|
||||
const description = payload.description ?? payload.spec?.description ?? '';
|
||||
|
||||
// Position is for future layout placement (not yet implemented)
|
||||
const _position = payload.position;
|
||||
void _position; // Suppress unused variable warning until layout positioning is implemented
|
||||
|
||||
// Generate unique element name
|
||||
const elementName = `panel-${title.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${Date.now()}`;
|
||||
|
||||
// Use scene's addPanel method (simplified for POC)
|
||||
const body = scene.state.body;
|
||||
if (!body) {
|
||||
throw new Error('Dashboard has no body');
|
||||
}
|
||||
|
||||
// For POC: Create a basic panel using VizPanel directly
|
||||
// Real implementation would use proper panel building utilities
|
||||
const { VizPanel } = await import('@grafana/scenes');
|
||||
|
||||
const vizPanel = new VizPanel({
|
||||
title,
|
||||
pluginId: vizType,
|
||||
description,
|
||||
options: {},
|
||||
fieldConfig: { defaults: {}, overrides: [] },
|
||||
key: elementName,
|
||||
});
|
||||
|
||||
// Add panel to scene
|
||||
scene.addPanel(vizPanel);
|
||||
|
||||
const changes: MutationChange[] = [
|
||||
{ path: `/elements/${elementName}`, previousValue: undefined, newValue: { title, vizType } },
|
||||
];
|
||||
transaction.changes.push(...changes);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
inverseMutation: {
|
||||
type: 'REMOVE_PANEL',
|
||||
payload: { elementName },
|
||||
},
|
||||
changes,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
changes: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a panel from the dashboard
|
||||
*/
|
||||
export async function handleRemovePanel(
|
||||
payload: RemovePanelPayload,
|
||||
context: MutationContext
|
||||
): Promise<MutationResult> {
|
||||
const { scene, transaction } = context;
|
||||
const { elementName, panelId } = payload;
|
||||
|
||||
try {
|
||||
// Find the panel
|
||||
const body = scene.state.body;
|
||||
if (!body) {
|
||||
throw new Error('Dashboard has no body');
|
||||
}
|
||||
|
||||
// Find panel by element name or ID
|
||||
const { VizPanel } = await import('@grafana/scenes');
|
||||
let panelToRemove: InstanceType<typeof VizPanel> | null = null;
|
||||
let panelState: Record<string, unknown> = {};
|
||||
|
||||
// Search through the scene's panels
|
||||
const panels = body.getVizPanels?.() || [];
|
||||
for (const panel of panels) {
|
||||
const state = panel.state;
|
||||
if (elementName && state.key === elementName) {
|
||||
panelToRemove = panel;
|
||||
panelState = { ...state };
|
||||
break;
|
||||
}
|
||||
// panelId is stored internally, use key for matching
|
||||
if (panelId !== undefined && state.key && String(state.key).includes(String(panelId))) {
|
||||
panelToRemove = panel;
|
||||
panelState = { ...state };
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!panelToRemove) {
|
||||
throw new Error(`Panel not found: ${elementName || panelId}`);
|
||||
}
|
||||
|
||||
// Remove the panel
|
||||
scene.removePanel(panelToRemove);
|
||||
|
||||
const changes: MutationChange[] = [
|
||||
{ path: `/elements/${elementName || panelId}`, previousValue: panelState, newValue: undefined },
|
||||
];
|
||||
transaction.changes.push(...changes);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
inverseMutation: {
|
||||
type: 'REMOVE_PANEL',
|
||||
payload: { elementName: String(panelState.key) },
|
||||
},
|
||||
changes,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
changes: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing panel
|
||||
*/
|
||||
export async function handleUpdatePanel(
|
||||
payload: UpdatePanelPayload,
|
||||
context: MutationContext
|
||||
): Promise<MutationResult> {
|
||||
const { scene, transaction } = context;
|
||||
const { elementName, panelId, updates } = payload;
|
||||
|
||||
try {
|
||||
// Find the panel
|
||||
const body = scene.state.body;
|
||||
if (!body) {
|
||||
throw new Error('Dashboard has no body');
|
||||
}
|
||||
|
||||
const { VizPanel } = await import('@grafana/scenes');
|
||||
const panels = body.getVizPanels?.() || [];
|
||||
let panelToUpdate: InstanceType<typeof VizPanel> | null = null;
|
||||
|
||||
for (const panel of panels) {
|
||||
const state = panel.state;
|
||||
if (elementName && state.key === elementName) {
|
||||
panelToUpdate = panel;
|
||||
break;
|
||||
}
|
||||
// panelId is stored internally, use key for matching
|
||||
if (panelId !== undefined && state.key && String(state.key).includes(String(panelId))) {
|
||||
panelToUpdate = panel;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!panelToUpdate) {
|
||||
throw new Error(`Panel not found: ${elementName || panelId}`);
|
||||
}
|
||||
|
||||
// Store previous state for rollback
|
||||
const previousState = { ...panelToUpdate.state };
|
||||
|
||||
// Apply updates from PanelSpec
|
||||
if (updates.title !== undefined) {
|
||||
panelToUpdate.setState({ title: updates.title });
|
||||
}
|
||||
if (updates.description !== undefined) {
|
||||
panelToUpdate.setState({ description: updates.description });
|
||||
}
|
||||
// More updates would be handled here based on PanelSpec fields
|
||||
|
||||
const changes: MutationChange[] = [
|
||||
{ path: `/elements/${elementName || panelId}`, previousValue: previousState, newValue: updates },
|
||||
];
|
||||
transaction.changes.push(...changes);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
inverseMutation: {
|
||||
type: 'UPDATE_PANEL',
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
payload: { elementName, panelId, updates: previousState as UpdatePanelPayload['updates'] },
|
||||
},
|
||||
changes,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
changes: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a panel (stub - not fully implemented)
|
||||
*/
|
||||
export async function handleMovePanel(): Promise<MutationResult> {
|
||||
return {
|
||||
success: true,
|
||||
changes: [],
|
||||
warnings: ['Move panel is not fully implemented in POC'],
|
||||
};
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* Shared types for mutation handlers
|
||||
*/
|
||||
|
||||
import type { DashboardScene } from '../../scene/DashboardScene';
|
||||
import type { MutationResult, MutationChange, MutationType, MutationPayloadMap, MutationTransaction } from '../types';
|
||||
|
||||
/**
|
||||
* Context passed to all mutation handlers
|
||||
*/
|
||||
export interface MutationContext {
|
||||
scene: DashboardScene;
|
||||
transaction: MutationTransactionInternal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal transaction type with mutable changes array
|
||||
*/
|
||||
export interface MutationTransactionInternal extends MutationTransaction {
|
||||
changes: MutationChange[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A mutation handler function
|
||||
*/
|
||||
export type MutationHandler<T extends MutationType = MutationType> = (
|
||||
payload: MutationPayloadMap[T],
|
||||
context: MutationContext
|
||||
) => Promise<MutationResult>;
|
||||
@@ -1,72 +0,0 @@
|
||||
/**
|
||||
* Variable mutation handlers
|
||||
*/
|
||||
|
||||
import type { MutationResult, MutationChange, AddVariablePayload, RemoveVariablePayload } from '../types';
|
||||
|
||||
import type { MutationContext } from './types';
|
||||
|
||||
/**
|
||||
* Add a variable (stub - not fully implemented)
|
||||
*/
|
||||
export async function handleAddVariable(
|
||||
_payload: AddVariablePayload,
|
||||
_context: MutationContext
|
||||
): Promise<MutationResult> {
|
||||
// TODO: Variable creation requires access to internal serialization functions
|
||||
// (createSceneVariableFromVariableModel) which are not currently exported.
|
||||
// This needs to be addressed by exporting the function or creating a public API.
|
||||
return {
|
||||
success: true,
|
||||
changes: [],
|
||||
warnings: ['Add variable is not fully implemented in POC - requires exported variable factory'],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a variable from the dashboard
|
||||
*/
|
||||
export async function handleRemoveVariable(
|
||||
payload: RemoveVariablePayload,
|
||||
context: MutationContext
|
||||
): Promise<MutationResult> {
|
||||
const { scene, transaction } = context;
|
||||
const { name } = payload;
|
||||
|
||||
try {
|
||||
const variables = scene.state.$variables;
|
||||
if (!variables) {
|
||||
throw new Error('Dashboard has no variable set');
|
||||
}
|
||||
|
||||
const variable = variables.getByName(name);
|
||||
if (!variable) {
|
||||
throw new Error(`Variable '${name}' not found`);
|
||||
}
|
||||
|
||||
const previousState = variable.state;
|
||||
|
||||
// Remove variable
|
||||
variables.setState({
|
||||
variables: variables.state.variables.filter((v: { state: { name: string } }) => v.state.name !== name),
|
||||
});
|
||||
|
||||
const changes: MutationChange[] = [
|
||||
{ path: `/variables/${name}`, previousValue: previousState, newValue: undefined },
|
||||
];
|
||||
transaction.changes.push(...changes);
|
||||
|
||||
// inverse mutation would need to reconstruct the VariableKind from SceneVariable state
|
||||
// This is simplified for POC
|
||||
return {
|
||||
success: true,
|
||||
changes,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
changes: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
/**
|
||||
* Dashboard Mutation API
|
||||
*
|
||||
* This module provides a stable API for programmatic dashboard modifications.
|
||||
* It is designed for use by Grafana Assistant and other tools that need to modify dashboards.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { getDashboardMutationAPI } from '@grafana/runtime';
|
||||
*
|
||||
* const api = getDashboardMutationAPI();
|
||||
* if (api && api.canEdit()) {
|
||||
* // Simple: just title and vizType
|
||||
* const result = await api.execute({
|
||||
* type: 'ADD_PANEL',
|
||||
* payload: { title: 'CPU Usage', vizType: 'timeseries' },
|
||||
* });
|
||||
*
|
||||
* // Advanced: with full spec
|
||||
* const result2 = await api.execute({
|
||||
* type: 'ADD_PANEL',
|
||||
* payload: {
|
||||
* title: 'Memory Usage',
|
||||
* spec: {
|
||||
* vizConfig: { kind: 'VizConfig', spec: { pluginId: 'stat' } },
|
||||
* data: { kind: 'QueryGroup', spec: { queries: [] } },
|
||||
* },
|
||||
* },
|
||||
* });
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Types - intentionally re-exported as public API surface
|
||||
// eslint-disable-next-line no-barrel-files/no-barrel-files
|
||||
export type {
|
||||
// Mutation types
|
||||
MutationType,
|
||||
Mutation,
|
||||
MutationPayloadMap,
|
||||
MutationResult,
|
||||
MutationChange,
|
||||
MutationTransaction,
|
||||
MutationEvent,
|
||||
|
||||
// Payload types (use schema types directly where possible)
|
||||
AddPanelPayload,
|
||||
RemovePanelPayload,
|
||||
UpdatePanelPayload,
|
||||
MovePanelPayload,
|
||||
DuplicatePanelPayload,
|
||||
AddVariablePayload,
|
||||
RemoveVariablePayload,
|
||||
UpdateVariablePayload,
|
||||
AddRowPayload,
|
||||
RemoveRowPayload,
|
||||
CollapseRowPayload,
|
||||
UpdateTimeSettingsPayload,
|
||||
UpdateDashboardMetaPayload,
|
||||
|
||||
// Supporting types
|
||||
LayoutPosition,
|
||||
|
||||
// MCP types
|
||||
MCPToolDefinition,
|
||||
MCPResourceDefinition,
|
||||
MCPPromptDefinition,
|
||||
} from './types';
|
||||
|
||||
// Mutation Executor
|
||||
// eslint-disable-next-line no-barrel-files/no-barrel-files
|
||||
export { MutationExecutor } from './MutationExecutor';
|
||||
|
||||
// MCP Tool Definitions
|
||||
// eslint-disable-next-line no-barrel-files/no-barrel-files
|
||||
export { DASHBOARD_MCP_TOOLS, DASHBOARD_MCP_RESOURCES, DASHBOARD_MCP_PROMPTS } from './mcpTools';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,420 +0,0 @@
|
||||
/**
|
||||
* Dashboard Mutation API - Core Types
|
||||
*
|
||||
* This module defines the types for the MCP-based dashboard mutation API.
|
||||
* It provides a standardized interface for programmatic dashboard modifications.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Import v2 schema types - these are the source of truth.
|
||||
*
|
||||
* The mutation API uses these types directly to ensure compatibility with the dashboard schema.
|
||||
* No custom payload types are created - we use schema types with Omit for auto-generated fields.
|
||||
*/
|
||||
import type {
|
||||
// Panel types
|
||||
PanelSpec,
|
||||
DataLink,
|
||||
// Variable types
|
||||
VariableKind,
|
||||
// Layout types
|
||||
GridLayoutItemSpec,
|
||||
RowsLayoutRowSpec,
|
||||
TabsLayoutTabSpec,
|
||||
AutoGridLayoutSpec,
|
||||
RepeatOptions,
|
||||
ConditionalRenderingGroupSpec,
|
||||
// Annotation types
|
||||
AnnotationQuerySpec,
|
||||
// Dashboard types
|
||||
DashboardLink,
|
||||
TimeSettingsSpec,
|
||||
// Field config types
|
||||
DynamicConfigValue,
|
||||
MatcherConfig,
|
||||
ValueMapping,
|
||||
DataTransformerConfig,
|
||||
} from '@grafana/schema/src/schema/dashboard/v2beta1/types.spec.gen';
|
||||
|
||||
// ============================================================================
|
||||
// Mutation Types
|
||||
// ============================================================================
|
||||
|
||||
export type MutationType =
|
||||
// Panel operations
|
||||
| 'ADD_PANEL'
|
||||
| 'REMOVE_PANEL'
|
||||
| 'UPDATE_PANEL'
|
||||
| 'MOVE_PANEL'
|
||||
| 'DUPLICATE_PANEL'
|
||||
// Variable operations
|
||||
| 'ADD_VARIABLE'
|
||||
| 'REMOVE_VARIABLE'
|
||||
| 'UPDATE_VARIABLE'
|
||||
// Row operations
|
||||
| 'ADD_ROW'
|
||||
| 'REMOVE_ROW'
|
||||
| 'COLLAPSE_ROW'
|
||||
// Tab operations
|
||||
| 'ADD_TAB'
|
||||
| 'REMOVE_TAB'
|
||||
// Library panel operations
|
||||
| 'ADD_LIBRARY_PANEL'
|
||||
| 'UNLINK_LIBRARY_PANEL'
|
||||
| 'SAVE_AS_LIBRARY_PANEL'
|
||||
// Repeat configuration
|
||||
| 'CONFIGURE_PANEL_REPEAT'
|
||||
| 'CONFIGURE_ROW_REPEAT'
|
||||
// Conditional rendering
|
||||
| 'SET_CONDITIONAL_RENDERING'
|
||||
// Layout
|
||||
| 'CHANGE_LAYOUT_TYPE'
|
||||
// Annotation operations
|
||||
| 'ADD_ANNOTATION'
|
||||
| 'UPDATE_ANNOTATION'
|
||||
| 'REMOVE_ANNOTATION'
|
||||
// Link operations
|
||||
| 'ADD_DASHBOARD_LINK'
|
||||
| 'REMOVE_DASHBOARD_LINK'
|
||||
| 'ADD_PANEL_LINK'
|
||||
| 'ADD_DATA_LINK'
|
||||
// Field configuration
|
||||
| 'ADD_FIELD_OVERRIDE'
|
||||
| 'ADD_VALUE_MAPPING'
|
||||
| 'ADD_TRANSFORMATION'
|
||||
// Dashboard settings
|
||||
| 'UPDATE_TIME_SETTINGS'
|
||||
| 'UPDATE_DASHBOARD_META'
|
||||
// Dashboard management (backend)
|
||||
| 'MOVE_TO_FOLDER'
|
||||
| 'TOGGLE_FAVORITE'
|
||||
// Version management (backend)
|
||||
| 'LIST_VERSIONS'
|
||||
| 'COMPARE_VERSIONS'
|
||||
| 'RESTORE_VERSION'
|
||||
// Read-only operations
|
||||
| 'GET_DASHBOARD_INFO';
|
||||
|
||||
// ============================================================================
|
||||
// Mutation Payloads
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Payload for adding a panel.
|
||||
*
|
||||
* Uses Partial<PanelSpec> so callers can provide just the fields they care about.
|
||||
* Missing fields are filled with sensible defaults (title defaults to "New Panel", etc.)
|
||||
* The `id` field is always auto-generated by the system.
|
||||
*
|
||||
* Minimal example: { title: "My Panel" }
|
||||
* Full example: { title: "My Panel", description: "...", vizConfig: {...}, data: {...} }
|
||||
*/
|
||||
export interface AddPanelPayload {
|
||||
/** Panel title (required for meaningful panels) */
|
||||
title?: string;
|
||||
/** Visualization type shorthand (e.g., "timeseries", "stat", "table") */
|
||||
vizType?: string;
|
||||
/** Panel description */
|
||||
description?: string;
|
||||
/** Full panel spec - for advanced use cases. Fields here override top-level fields. */
|
||||
spec?: Partial<Omit<PanelSpec, 'id'>>;
|
||||
/** Position in the layout */
|
||||
position?: LayoutPosition;
|
||||
}
|
||||
|
||||
export interface RemovePanelPayload {
|
||||
/** Element name in the elements map */
|
||||
elementName?: string;
|
||||
/** Alternative: Panel ID */
|
||||
panelId?: number;
|
||||
}
|
||||
|
||||
export interface UpdatePanelPayload {
|
||||
/** Element name or panel ID to update */
|
||||
elementName?: string;
|
||||
panelId?: number;
|
||||
/** Updates to apply - partial PanelSpec (id cannot be changed) */
|
||||
updates: Partial<Omit<PanelSpec, 'id'>>;
|
||||
}
|
||||
|
||||
export interface MovePanelPayload {
|
||||
/** Element name to move */
|
||||
elementName: string;
|
||||
/** Target position */
|
||||
targetPosition: LayoutPosition;
|
||||
}
|
||||
|
||||
export interface DuplicatePanelPayload {
|
||||
/** Element name to duplicate */
|
||||
elementName: string;
|
||||
/** New title (optional, defaults to "Copy of {original}") */
|
||||
newTitle?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for adding a variable.
|
||||
* Uses VariableKind from schema directly - the union of all variable types.
|
||||
*/
|
||||
export interface AddVariablePayload {
|
||||
/** The complete variable definition from v2 schema */
|
||||
variable: VariableKind;
|
||||
/** Position in the variables array (optional, appends if not specified) */
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export interface RemoveVariablePayload {
|
||||
/** Variable name to remove */
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface UpdateVariablePayload {
|
||||
/** Variable name to update */
|
||||
name: string;
|
||||
/** The updated variable definition - replaces the existing one */
|
||||
variable: VariableKind;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for adding a row.
|
||||
* Uses RowsLayoutRowSpec from schema, but layout is optional (created empty).
|
||||
*/
|
||||
export interface AddRowPayload {
|
||||
/** Row spec - uses schema type. Layout is created empty if not provided. */
|
||||
spec: Omit<RowsLayoutRowSpec, 'layout'> & {
|
||||
layout?: RowsLayoutRowSpec['layout'];
|
||||
};
|
||||
/** Position index (0 = first) */
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export interface RemoveRowPayload {
|
||||
/** Row title or index to identify the row */
|
||||
rowTitle?: string;
|
||||
rowIndex?: number;
|
||||
/** What to do with panels in the row */
|
||||
panelHandling?: 'delete' | 'moveToRoot';
|
||||
}
|
||||
|
||||
export interface CollapseRowPayload {
|
||||
/** Row title or index to identify the row */
|
||||
rowTitle?: string;
|
||||
rowIndex?: number;
|
||||
/** Whether to collapse or expand */
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for updating time settings.
|
||||
* Uses TimeSettingsSpec from schema.
|
||||
*/
|
||||
export type UpdateTimeSettingsPayload = Partial<TimeSettingsSpec>;
|
||||
|
||||
/**
|
||||
* Payload for updating dashboard metadata.
|
||||
* These are top-level DashboardV2Spec fields.
|
||||
*/
|
||||
export interface UpdateDashboardMetaPayload {
|
||||
title?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
editable?: boolean;
|
||||
preload?: boolean;
|
||||
liveNow?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Supporting Types - derived from schema types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Layout position for placing elements.
|
||||
* Combines GridLayoutItemSpec position fields with container targeting.
|
||||
*/
|
||||
export type LayoutPosition = Pick<GridLayoutItemSpec, 'x' | 'y' | 'width' | 'height' | 'repeat'> & {
|
||||
/** Target row title (for RowsLayout) */
|
||||
targetRow?: string;
|
||||
/** Target tab title (for TabsLayout) */
|
||||
targetTab?: string;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Mutation Definition
|
||||
// ============================================================================
|
||||
|
||||
export interface Mutation<T extends MutationType = MutationType> {
|
||||
type: T;
|
||||
payload: MutationPayloadMap[T];
|
||||
}
|
||||
|
||||
export interface MutationPayloadMap {
|
||||
// Panel operations
|
||||
ADD_PANEL: AddPanelPayload;
|
||||
REMOVE_PANEL: RemovePanelPayload;
|
||||
UPDATE_PANEL: UpdatePanelPayload;
|
||||
MOVE_PANEL: MovePanelPayload;
|
||||
DUPLICATE_PANEL: DuplicatePanelPayload;
|
||||
|
||||
// Variable operations
|
||||
ADD_VARIABLE: AddVariablePayload;
|
||||
REMOVE_VARIABLE: RemoveVariablePayload;
|
||||
UPDATE_VARIABLE: UpdateVariablePayload;
|
||||
|
||||
// Row operations
|
||||
ADD_ROW: AddRowPayload;
|
||||
REMOVE_ROW: RemoveRowPayload;
|
||||
COLLAPSE_ROW: CollapseRowPayload;
|
||||
|
||||
// Tab operations - uses TabsLayoutTabSpec from schema
|
||||
ADD_TAB: {
|
||||
spec: Omit<TabsLayoutTabSpec, 'layout'> & { layout?: TabsLayoutTabSpec['layout'] };
|
||||
position?: number;
|
||||
};
|
||||
REMOVE_TAB: { tabTitle?: string; tabIndex?: number; panelHandling?: 'delete' | 'moveToRoot' };
|
||||
|
||||
// Library panel operations
|
||||
ADD_LIBRARY_PANEL: { libraryPanelUid?: string; libraryPanelName?: string; position?: LayoutPosition };
|
||||
UNLINK_LIBRARY_PANEL: { elementName: string };
|
||||
SAVE_AS_LIBRARY_PANEL: { elementName: string; libraryPanelName: string; folderUid?: string };
|
||||
|
||||
// Repeat configuration - uses RepeatOptions from schema
|
||||
CONFIGURE_PANEL_REPEAT: { elementName: string; repeat: RepeatOptions | null };
|
||||
CONFIGURE_ROW_REPEAT: { rowTitle?: string; rowIndex?: number; repeat: RowsLayoutRowSpec['repeat'] | null };
|
||||
|
||||
// Conditional rendering - uses ConditionalRenderingGroupSpec from schema
|
||||
SET_CONDITIONAL_RENDERING: {
|
||||
elementName: string;
|
||||
conditionalRendering: ConditionalRenderingGroupSpec | null;
|
||||
};
|
||||
|
||||
// Layout - uses AutoGridLayoutSpec for options
|
||||
CHANGE_LAYOUT_TYPE: {
|
||||
layoutType: 'GridLayout' | 'RowsLayout' | 'AutoGridLayout' | 'TabsLayout';
|
||||
options?: Partial<AutoGridLayoutSpec>;
|
||||
};
|
||||
|
||||
// Annotation operations - uses AnnotationQuerySpec from schema
|
||||
ADD_ANNOTATION: Omit<AnnotationQuerySpec, 'query'> & { query?: AnnotationQuerySpec['query'] };
|
||||
UPDATE_ANNOTATION: { name: string; updates: Partial<AnnotationQuerySpec> };
|
||||
REMOVE_ANNOTATION: { name: string };
|
||||
|
||||
// Link operations - uses DashboardLink from schema
|
||||
ADD_DASHBOARD_LINK: DashboardLink;
|
||||
REMOVE_DASHBOARD_LINK: { title?: string; index?: number };
|
||||
|
||||
// Panel link operations - uses DataLink from schema
|
||||
ADD_PANEL_LINK: { elementName: string; link: DataLink };
|
||||
ADD_DATA_LINK: { elementName: string; link: DataLink };
|
||||
|
||||
// Field configuration - uses schema types
|
||||
ADD_FIELD_OVERRIDE: {
|
||||
elementName: string;
|
||||
matcher: MatcherConfig;
|
||||
properties: DynamicConfigValue[];
|
||||
};
|
||||
ADD_VALUE_MAPPING: { elementName: string; mapping: ValueMapping };
|
||||
ADD_TRANSFORMATION: { elementName: string; transformation: Omit<DataTransformerConfig, 'id'> & { id: string } };
|
||||
|
||||
// Dashboard settings
|
||||
UPDATE_TIME_SETTINGS: UpdateTimeSettingsPayload;
|
||||
UPDATE_DASHBOARD_META: UpdateDashboardMetaPayload;
|
||||
|
||||
// Dashboard management (backend)
|
||||
MOVE_TO_FOLDER: { folderUid?: string; folderTitle?: string };
|
||||
TOGGLE_FAVORITE: { favorite: boolean };
|
||||
|
||||
// Version management (backend)
|
||||
LIST_VERSIONS: { limit?: number };
|
||||
COMPARE_VERSIONS: { baseVersion: number; newVersion: number };
|
||||
RESTORE_VERSION: { version: number };
|
||||
|
||||
// Read-only operations (no payload required)
|
||||
GET_DASHBOARD_INFO: Record<string, never>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mutation Result
|
||||
// ============================================================================
|
||||
|
||||
export interface MutationResult {
|
||||
success: boolean;
|
||||
/** Mutation to apply to undo this change */
|
||||
inverseMutation?: Mutation;
|
||||
/** Changes that were applied */
|
||||
changes: MutationChange[];
|
||||
/** Error message if failed */
|
||||
error?: string;
|
||||
/** Warnings (non-fatal issues) */
|
||||
warnings?: string[];
|
||||
/** Data returned by read-only operations (e.g., GET_DASHBOARD_INFO) */
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export interface MutationChange {
|
||||
path: string;
|
||||
previousValue: unknown;
|
||||
newValue: unknown;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Transaction
|
||||
// ============================================================================
|
||||
|
||||
export interface MutationTransaction {
|
||||
id: string;
|
||||
mutations: Mutation[];
|
||||
status: 'pending' | 'committed' | 'rolled_back';
|
||||
startedAt: number;
|
||||
completedAt?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Event Types
|
||||
// ============================================================================
|
||||
|
||||
export interface MutationEvent {
|
||||
type: 'mutation_applied' | 'mutation_failed' | 'mutation_rolled_back';
|
||||
mutation: Mutation;
|
||||
result: MutationResult;
|
||||
transaction?: MutationTransaction;
|
||||
timestamp: number;
|
||||
source: 'assistant' | 'ui' | 'api';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MCP Tool Types
|
||||
// ============================================================================
|
||||
|
||||
export interface MCPToolDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: {
|
||||
type: 'object';
|
||||
properties: Record<string, unknown>;
|
||||
required?: string[];
|
||||
};
|
||||
annotations?: {
|
||||
title?: string;
|
||||
readOnlyHint?: boolean;
|
||||
destructiveHint?: boolean;
|
||||
idempotentHint?: boolean;
|
||||
confirmationHint?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MCPResourceDefinition {
|
||||
uri: string;
|
||||
uriTemplate?: boolean;
|
||||
name: string;
|
||||
description: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
export interface MCPPromptDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
arguments: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
}>;
|
||||
}
|
||||
@@ -2,13 +2,7 @@ import * as H from 'history';
|
||||
|
||||
import { CoreApp, DataQueryRequest, locationUtil, NavIndex, NavModelItem } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import {
|
||||
config,
|
||||
locationService,
|
||||
RefreshEvent,
|
||||
setDashboardMutationAPI,
|
||||
type DashboardMutationAPI,
|
||||
} from '@grafana/runtime';
|
||||
import { config, locationService, RefreshEvent } from '@grafana/runtime';
|
||||
import {
|
||||
sceneGraph,
|
||||
SceneObject,
|
||||
@@ -51,9 +45,6 @@ import {
|
||||
} from '../../apiserver/types';
|
||||
import { DashboardEditPane } from '../edit-pane/DashboardEditPane';
|
||||
import { dashboardEditActions } from '../edit-pane/shared';
|
||||
import { MutationExecutor } from '../mutation-api/MutationExecutor';
|
||||
import { DASHBOARD_MCP_TOOLS } from '../mutation-api/mcpTools';
|
||||
import type { Mutation } from '../mutation-api/types';
|
||||
import { PanelEditor } from '../panel-edit/PanelEditor';
|
||||
import { DashboardSceneChangeTracker } from '../saving/DashboardSceneChangeTracker';
|
||||
import { SaveDashboardDrawer } from '../saving/SaveDashboardDrawer';
|
||||
@@ -102,11 +93,6 @@ import { clearClipboard } from './layouts-shared/paste';
|
||||
import { DashboardLayoutManager } from './types/DashboardLayoutManager';
|
||||
import { LayoutParent } from './types/LayoutParent';
|
||||
|
||||
// Type for window with mutation API (for cross-bundle access with plugins)
|
||||
interface WindowWithMutationAPI extends Window {
|
||||
__grafanaDashboardMutationAPI?: DashboardMutationAPI | null;
|
||||
}
|
||||
|
||||
export const PERSISTED_PROPS = ['title', 'description', 'tags', 'editable', 'graphTooltip', 'links', 'meta', 'preload'];
|
||||
export const PANEL_SEARCH_VAR = 'systemPanelFilterVar';
|
||||
export const PANELS_PER_ROW_VAR = 'systemDynamicRowSizeVar';
|
||||
@@ -233,9 +219,6 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
|
||||
|
||||
window.__grafanaSceneContext = this;
|
||||
|
||||
// Register Dashboard Mutation API for Grafana Assistant and other tools
|
||||
this._registerMutationAPI();
|
||||
|
||||
this._initializePanelSearch();
|
||||
|
||||
if (this.state.isEditing) {
|
||||
@@ -264,10 +247,6 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
|
||||
// Deactivation logic
|
||||
return () => {
|
||||
window.__grafanaSceneContext = prevSceneContext;
|
||||
// Clear mutation API
|
||||
setDashboardMutationAPI(null);
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
(window as WindowWithMutationAPI).__grafanaDashboardMutationAPI = null;
|
||||
clearKeyBindings();
|
||||
this._changeTracker.terminate();
|
||||
oldDashboardWrapper.destroy();
|
||||
@@ -275,49 +254,6 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the Dashboard Mutation API for use by Grafana Assistant and other tools.
|
||||
* This provides a stable interface for programmatic dashboard modifications.
|
||||
*
|
||||
* The API is exposed on window.__grafanaDashboardMutationAPI for cross-bundle access,
|
||||
* since plugins use a different @grafana/runtime bundle.
|
||||
*/
|
||||
private _registerMutationAPI() {
|
||||
const dashboard = this;
|
||||
const executor = new MutationExecutor();
|
||||
executor.setScene(this);
|
||||
|
||||
const api: DashboardMutationAPI = {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
execute: (mutation) => executor.executeMutation(mutation as Mutation),
|
||||
canEdit: () => dashboard.canEditDashboard(),
|
||||
getDashboardUID: () => dashboard.state.uid,
|
||||
getDashboardTitle: () => dashboard.state.title,
|
||||
isEditing: () => dashboard.state.isEditing ?? false,
|
||||
enterEditMode: () => {
|
||||
if (!dashboard.state.isEditing) {
|
||||
dashboard.onEnterEditMode();
|
||||
}
|
||||
},
|
||||
getTools: () => DASHBOARD_MCP_TOOLS,
|
||||
getDashboardInfo: () => ({
|
||||
available: true,
|
||||
uid: dashboard.state.uid,
|
||||
title: dashboard.state.title,
|
||||
canEdit: dashboard.canEditDashboard(),
|
||||
isEditing: dashboard.state.isEditing ?? false,
|
||||
availableTools: DASHBOARD_MCP_TOOLS.map((t) => t.name),
|
||||
}),
|
||||
};
|
||||
|
||||
// Register via @grafana/runtime for same-bundle access
|
||||
setDashboardMutationAPI(api);
|
||||
|
||||
// Also expose on window for cross-bundle access (plugins use different bundle)
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
(window as WindowWithMutationAPI).__grafanaDashboardMutationAPI = api;
|
||||
}
|
||||
|
||||
private _initializePanelSearch() {
|
||||
const systemPanelFilter = sceneGraph.lookupVariable(PANEL_SEARCH_VAR, this)?.getValue();
|
||||
if (typeof systemPanelFilter === 'string') {
|
||||
|
||||
@@ -2,9 +2,8 @@ import { render, screen } from '@testing-library/react';
|
||||
import { defaultsDeep } from 'lodash';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import { CoreApp, EventBusSrv, FieldType, getDefaultTimeRange, LoadingState } from '@grafana/data';
|
||||
import { config, PanelDataErrorViewProps } from '@grafana/runtime';
|
||||
import { usePanelContext } from '@grafana/ui';
|
||||
import { FieldType, getDefaultTimeRange, LoadingState } from '@grafana/data';
|
||||
import { PanelDataErrorViewProps } from '@grafana/runtime';
|
||||
import { configureStore } from 'app/store/configureStore';
|
||||
|
||||
import { PanelDataErrorView } from './PanelDataErrorView';
|
||||
@@ -17,24 +16,7 @@ jest.mock('app/features/dashboard/services/DashboardSrv', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@grafana/ui', () => ({
|
||||
...jest.requireActual('@grafana/ui'),
|
||||
usePanelContext: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUsePanelContext = jest.mocked(usePanelContext);
|
||||
const RUN_QUERY_MESSAGE = 'Run a query to visualize it here or go to all visualizations to add other panel types';
|
||||
const panelContextRoot = {
|
||||
app: CoreApp.Dashboard,
|
||||
eventsScope: 'global',
|
||||
eventBus: new EventBusSrv(),
|
||||
};
|
||||
|
||||
describe('PanelDataErrorView', () => {
|
||||
beforeEach(() => {
|
||||
mockUsePanelContext.mockReturnValue(panelContextRoot);
|
||||
});
|
||||
|
||||
it('show No data when there is no data', () => {
|
||||
renderWithProps();
|
||||
|
||||
@@ -88,45 +70,6 @@ describe('PanelDataErrorView', () => {
|
||||
|
||||
expect(screen.getByText('Query returned nothing')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show "Run a query..." message when no query is configured and feature toggle is enabled', () => {
|
||||
mockUsePanelContext.mockReturnValue(panelContextRoot);
|
||||
|
||||
const originalFeatureToggle = config.featureToggles.newVizSuggestions;
|
||||
config.featureToggles.newVizSuggestions = true;
|
||||
|
||||
renderWithProps({
|
||||
data: {
|
||||
state: LoadingState.Done,
|
||||
series: [],
|
||||
timeRange: getDefaultTimeRange(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByText(RUN_QUERY_MESSAGE)).toBeInTheDocument();
|
||||
|
||||
config.featureToggles.newVizSuggestions = originalFeatureToggle;
|
||||
});
|
||||
|
||||
it('should show "No data" message when feature toggle is disabled even without queries', () => {
|
||||
mockUsePanelContext.mockReturnValue(panelContextRoot);
|
||||
|
||||
const originalFeatureToggle = config.featureToggles.newVizSuggestions;
|
||||
config.featureToggles.newVizSuggestions = false;
|
||||
|
||||
renderWithProps({
|
||||
data: {
|
||||
state: LoadingState.Done,
|
||||
series: [],
|
||||
timeRange: getDefaultTimeRange(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByText('No data')).toBeInTheDocument();
|
||||
expect(screen.queryByText(RUN_QUERY_MESSAGE)).not.toBeInTheDocument();
|
||||
|
||||
config.featureToggles.newVizSuggestions = originalFeatureToggle;
|
||||
});
|
||||
});
|
||||
|
||||
function renderWithProps(overrides?: Partial<PanelDataErrorViewProps>) {
|
||||
|
||||
@@ -5,15 +5,14 @@ import {
|
||||
FieldType,
|
||||
getPanelDataSummary,
|
||||
GrafanaTheme2,
|
||||
PanelData,
|
||||
PanelDataSummary,
|
||||
PanelPluginVisualizationSuggestion,
|
||||
} from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { t, Trans } from '@grafana/i18n';
|
||||
import { PanelDataErrorViewProps, locationService, config } from '@grafana/runtime';
|
||||
import { PanelDataErrorViewProps, locationService } from '@grafana/runtime';
|
||||
import { VizPanel } from '@grafana/scenes';
|
||||
import { Icon, usePanelContext, useStyles2 } from '@grafana/ui';
|
||||
import { usePanelContext, useStyles2 } from '@grafana/ui';
|
||||
import { CardButton } from 'app/core/components/CardButton';
|
||||
import { LS_VISUALIZATION_SELECT_TAB_KEY } from 'app/core/constants';
|
||||
import store from 'app/core/store';
|
||||
@@ -25,11 +24,6 @@ import { findVizPanelByKey, getVizPanelKeyForPanelId } from 'app/features/dashbo
|
||||
import { useDispatch } from 'app/types/store';
|
||||
|
||||
import { changePanelPlugin } from '../state/actions';
|
||||
import { hasData } from '../suggestions/utils';
|
||||
|
||||
function hasNoQueryConfigured(data: PanelData): boolean {
|
||||
return !data.request?.targets || data.request.targets.length === 0;
|
||||
}
|
||||
|
||||
export function PanelDataErrorView(props: PanelDataErrorViewProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
@@ -99,14 +93,8 @@ export function PanelDataErrorView(props: PanelDataErrorViewProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const noData = !hasData(props.data);
|
||||
const noQueryConfigured = hasNoQueryConfigured(props.data);
|
||||
const showEmptyState =
|
||||
config.featureToggles.newVizSuggestions && context.app === CoreApp.PanelEditor && noQueryConfigured && noData;
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
{showEmptyState && <Icon name="chart-line" size="xxxl" className={styles.emptyStateIcon} />}
|
||||
<div className={styles.message} data-testid={selectors.components.Panels.Panel.PanelDataErrorMessage}>
|
||||
{message}
|
||||
</div>
|
||||
@@ -143,17 +131,7 @@ function getMessageFor(
|
||||
return message;
|
||||
}
|
||||
|
||||
const noData = !hasData(data);
|
||||
const noQueryConfigured = hasNoQueryConfigured(data);
|
||||
|
||||
if (config.featureToggles.newVizSuggestions && noQueryConfigured && noData) {
|
||||
return t(
|
||||
'dashboard.new-panel.empty-state-message',
|
||||
'Run a query to visualize it here or go to all visualizations to add other panel types'
|
||||
);
|
||||
}
|
||||
|
||||
if (noData) {
|
||||
if (!data.series || data.series.length === 0 || data.series.every((frame) => frame.length === 0)) {
|
||||
return fieldConfig?.defaults.noValue ?? t('panel.panel-data-error-view.no-value.default', 'No data');
|
||||
}
|
||||
|
||||
@@ -198,9 +176,5 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
width: '100%',
|
||||
maxWidth: '600px',
|
||||
}),
|
||||
emptyStateIcon: css({
|
||||
color: theme.colors.text.secondary,
|
||||
marginBottom: theme.spacing(2),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
+15
-12
@@ -1,26 +1,29 @@
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { RadioButtonGroup } from '@grafana/ui';
|
||||
|
||||
import { useDispatch } from '../../hooks/useStatelessReducer';
|
||||
import { EditorType } from '../../types';
|
||||
|
||||
import { useQuery } from './ElasticsearchQueryContext';
|
||||
import { changeEditorTypeAndResetQuery } from './state';
|
||||
|
||||
const BASE_OPTIONS: Array<SelectableValue<EditorType>> = [
|
||||
{ value: 'builder', label: 'Builder' },
|
||||
{ value: 'code', label: 'Code' },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
value: EditorType;
|
||||
onChange: (editorType: EditorType) => void;
|
||||
}
|
||||
export const EditorTypeSelector = () => {
|
||||
const query = useQuery();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Default to 'builder' if editorType is empty
|
||||
const editorType: EditorType = query.editorType === 'code' ? 'code' : 'builder';
|
||||
|
||||
const onChange = (newEditorType: EditorType) => {
|
||||
dispatch(changeEditorTypeAndResetQuery(newEditorType));
|
||||
};
|
||||
|
||||
export const EditorTypeSelector = ({ value, onChange }: Props) => {
|
||||
return (
|
||||
<RadioButtonGroup<EditorType>
|
||||
data-testid="elasticsearch-editor-type-toggle"
|
||||
size="sm"
|
||||
options={BASE_OPTIONS}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<RadioButtonGroup<EditorType> fullWidth={false} options={BASE_OPTIONS} value={editorType} onChange={onChange} />
|
||||
);
|
||||
};
|
||||
|
||||
+14
-36
@@ -10,13 +10,9 @@ interface Props {
|
||||
onRunQuery: () => void;
|
||||
}
|
||||
|
||||
// This offset was chosen by testing to match Prometheus behavior
|
||||
const EDITOR_HEIGHT_OFFSET = 2;
|
||||
|
||||
export function RawQueryEditor({ value, onChange, onRunQuery }: Props) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const editorRef = useRef<monacoTypes.editor.IStandaloneCodeEditor | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const handleEditorDidMount = useCallback(
|
||||
(editor: monacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco) => {
|
||||
@@ -26,22 +22,6 @@ export function RawQueryEditor({ value, onChange, onRunQuery }: Props) {
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => {
|
||||
onRunQuery();
|
||||
});
|
||||
|
||||
// Make the editor resize itself so that the content fits (grows taller when necessary)
|
||||
// this code comes from the Prometheus query editor.
|
||||
// We may wish to consider abstracting it into the grafana/ui repo in the future
|
||||
const updateElementHeight = () => {
|
||||
const containerDiv = containerRef.current;
|
||||
if (containerDiv !== null) {
|
||||
const pixelHeight = editor.getContentHeight();
|
||||
containerDiv.style.height = `${pixelHeight + EDITOR_HEIGHT_OFFSET}px`;
|
||||
const pixelWidth = containerDiv.clientWidth;
|
||||
editor.layout({ width: pixelWidth, height: pixelHeight });
|
||||
}
|
||||
};
|
||||
|
||||
editor.onDidContentSizeChange(updateElementHeight);
|
||||
updateElementHeight();
|
||||
},
|
||||
[onRunQuery]
|
||||
);
|
||||
@@ -85,17 +65,7 @@ export function RawQueryEditor({ value, onChange, onRunQuery }: Props) {
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<div ref={containerRef} className={styles.editorContainer}>
|
||||
<CodeEditor
|
||||
value={value ?? ''}
|
||||
language="json"
|
||||
width="100%"
|
||||
onBlur={handleQueryChange}
|
||||
monacoOptions={monacoOptions}
|
||||
onEditorDidMount={handleEditorDidMount}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.footer}>
|
||||
<div className={styles.header}>
|
||||
<Stack gap={1}>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -106,8 +76,20 @@ export function RawQueryEditor({ value, onChange, onRunQuery }: Props) {
|
||||
>
|
||||
Format
|
||||
</Button>
|
||||
<Button size="sm" variant="primary" icon="play" onClick={onRunQuery} tooltip="Run query (Ctrl/Cmd+Enter)">
|
||||
Run
|
||||
</Button>
|
||||
</Stack>
|
||||
</div>
|
||||
<CodeEditor
|
||||
value={value ?? ''}
|
||||
language="json"
|
||||
height={200}
|
||||
width="100%"
|
||||
onBlur={handleQueryChange}
|
||||
monacoOptions={monacoOptions}
|
||||
onEditorDidMount={handleEditorDidMount}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -118,11 +100,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(1),
|
||||
}),
|
||||
editorContainer: css({
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
}),
|
||||
footer: css({
|
||||
header: css({
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
padding: theme.spacing(0.5, 0),
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useCallback, useEffect, useId, useState } from 'react';
|
||||
import { useEffect, useId, useState } from 'react';
|
||||
import { SemVer } from 'semver';
|
||||
|
||||
import { getDefaultTimeRange, GrafanaTheme2, QueryEditorProps } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Alert, ConfirmModal, InlineField, InlineLabel, Input, QueryField, useStyles2 } from '@grafana/ui';
|
||||
import { Alert, InlineField, InlineLabel, Input, QueryField, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { ElasticsearchDataQuery } from '../../dataquery.gen';
|
||||
import { ElasticDatasource } from '../../datasource';
|
||||
import { useNextId } from '../../hooks/useNextId';
|
||||
import { useDispatch } from '../../hooks/useStatelessReducer';
|
||||
import { EditorType, ElasticsearchOptions } from '../../types';
|
||||
import { ElasticsearchOptions } from '../../types';
|
||||
import { isSupportedVersion, isTimeSeriesQuery, unsupportedVersionMessage } from '../../utils';
|
||||
|
||||
import { BucketAggregationsEditor } from './BucketAggregationsEditor';
|
||||
@@ -20,7 +20,7 @@ import { MetricAggregationsEditor } from './MetricAggregationsEditor';
|
||||
import { metricAggregationConfig } from './MetricAggregationsEditor/utils';
|
||||
import { QueryTypeSelector } from './QueryTypeSelector';
|
||||
import { RawQueryEditor } from './RawQueryEditor';
|
||||
import { changeAliasPattern, changeEditorTypeAndResetQuery, changeQuery, changeRawDSLQuery } from './state';
|
||||
import { changeAliasPattern, changeQuery, changeRawDSLQuery } from './state';
|
||||
|
||||
export type ElasticQueryEditorProps = QueryEditorProps<ElasticDatasource, ElasticsearchDataQuery, ElasticsearchOptions>;
|
||||
|
||||
@@ -97,61 +97,31 @@ const QueryEditorForm = ({ value, onRunQuery }: Props & { onRunQuery: () => void
|
||||
const inputId = useId();
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const [switchModalOpen, setSwitchModalOpen] = useState(false);
|
||||
const [pendingEditorType, setPendingEditorType] = useState<EditorType | null>(null);
|
||||
|
||||
const isTimeSeries = isTimeSeriesQuery(value);
|
||||
|
||||
const isCodeEditor = value.editorType === 'code';
|
||||
const rawDSLFeatureEnabled = config.featureToggles.elasticsearchRawDSLQuery;
|
||||
|
||||
// Default to 'builder' if editorType is empty
|
||||
const currentEditorType: EditorType = value.editorType === 'code' ? 'code' : 'builder';
|
||||
|
||||
const showBucketAggregationsEditor = value.metrics?.every(
|
||||
(metric) => metricAggregationConfig[metric.type].impliedQueryType === 'metrics'
|
||||
);
|
||||
|
||||
const onEditorTypeChange = useCallback((newEditorType: EditorType) => {
|
||||
// Show warning modal when switching modes
|
||||
setPendingEditorType(newEditorType);
|
||||
setSwitchModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const confirmEditorTypeChange = useCallback(() => {
|
||||
if (pendingEditorType) {
|
||||
dispatch(changeEditorTypeAndResetQuery(pendingEditorType));
|
||||
}
|
||||
setSwitchModalOpen(false);
|
||||
setPendingEditorType(null);
|
||||
}, [dispatch, pendingEditorType]);
|
||||
|
||||
const cancelEditorTypeChange = useCallback(() => {
|
||||
setSwitchModalOpen(false);
|
||||
setPendingEditorType(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfirmModal
|
||||
isOpen={switchModalOpen}
|
||||
title="Switch editor"
|
||||
body="Switching between editors will reset your query. Are you sure you want to continue?"
|
||||
confirmText="Continue"
|
||||
onConfirm={confirmEditorTypeChange}
|
||||
onDismiss={cancelEditorTypeChange}
|
||||
/>
|
||||
<div className={styles.root}>
|
||||
<InlineLabel width={17}>Query type</InlineLabel>
|
||||
<div className={styles.queryItem}>
|
||||
<QueryTypeSelector />
|
||||
</div>
|
||||
{rawDSLFeatureEnabled && (
|
||||
<div style={{ marginLeft: 'auto' }}>
|
||||
<EditorTypeSelector value={currentEditorType} onChange={onEditorTypeChange} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{rawDSLFeatureEnabled && (
|
||||
<div className={styles.root}>
|
||||
<InlineLabel width={17}>Editor type</InlineLabel>
|
||||
<div className={styles.queryItem}>
|
||||
<EditorTypeSelector />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCodeEditor && rawDSLFeatureEnabled && (
|
||||
<RawQueryEditor
|
||||
|
||||
Reference in New Issue
Block a user