Compare commits

..

8 Commits

Author SHA1 Message Date
Will Assis 861af005e0 unified-storage: fix listModifiedSince in storage_backend.go and enable its tests for both badger and sqlkv 2026-01-14 19:24:15 -03:00
Will Assis 14a05137e1 fmt 2026-01-14 15:43:30 -03:00
Will Assis cfe86378a1 dont run tests on sqlite (yet) 2026-01-14 15:41:51 -03:00
Will Assis f7d7e09626 unified-storage: sqlkv enable more tests 2026-01-14 15:25:42 -03:00
Will Assis ba416eab4e unified-storage: dont use polling notifier with sqlite in sqlkv (#116283)
* unified-storage: dont use polling notifier with sqlite in sqlkv
2026-01-14 18:22:39 +00:00
Alan Martin 189d50d815 UI: Use react-table column header types in InteractiveTable with story and tests (#116091)
* feat(InteractiveTable): allow custom header rendering

* docs(InteractiveTable): add story for custom header rendering

* test(InteractiveTable): add tests for custom header rendering

* docs(InteractiveTable): add custom header rendering documentation

* fix: test failure from non-a11y code
2026-01-14 17:59:03 +00:00
Mariell Hoversholm 450eaba447 test: skip integration test in short mode (#116280) 2026-01-14 18:33:55 +01:00
Kristina Demeshchik 87f5d5e741 Dashboard: Hide export options in collapsible row (#116155)
* Introduce export options

* Reset keys

* Introduce a new key

* Generate new keys

* Rename the label

* re-generate key

* Fix the spacing

* Remove debuggers

* Add subtitle

* refactor component

* update labels

* faield tests

* Update tooltip

* Linting issue
2026-01-14 12:12:33 -05:00
33 changed files with 572 additions and 3123 deletions
-9
View File
@@ -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';
@@ -117,6 +117,44 @@ export const MyComponent = () => {
};
```
### Custom Header Rendering
Column headers can be customized using strings, React elements, or renderer functions. The `header` property accepts any value that matches React Table's `Renderer` type.
**Important:** When using custom header content, prefer inline elements (like `<span>`) over block elements (like `<div>`) to avoid layout issues. Block-level elements can cause extra spacing and alignment problems in table headers because they disrupt the table's inline flow. Use `display: inline-flex` or `display: inline-block` when you need flexbox or block-like behavior.
```tsx
const columns: Array<Column<TableData>> = [
// React element header
{
id: 'checkbox',
header: (
<>
<label htmlFor="select-all" className="sr-only">
Select all rows
</label>
<Checkbox id="select-all" />
</>
),
cell: () => <Checkbox aria-label="Select row" />,
},
// Function renderer header
{
id: 'firstName',
header: () => (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '8px' }}>
<Icon name="user" size="sm" />
<span>First Name</span>
</span>
),
},
// String header
{ id: 'lastName', header: 'Last name' },
];
```
### Custom Cell Rendering
Individual cells can be rendered using custom content dy defining a `cell` property on the column definition.
@@ -3,8 +3,11 @@ import { useCallback, useMemo, useState } from 'react';
import { CellProps } from 'react-table';
import { LinkButton } from '../Button/Button';
import { Checkbox } from '../Forms/Checkbox';
import { Field } from '../Forms/Field';
import { Icon } from '../Icon/Icon';
import { Input } from '../Input/Input';
import { Text } from '../Text/Text';
import { FetchDataArgs, InteractiveTable, InteractiveTableHeaderTooltip } from './InteractiveTable';
import mdx from './InteractiveTable.mdx';
@@ -297,4 +300,40 @@ export const WithControlledSort: StoryFn<typeof InteractiveTable> = (args) => {
return <InteractiveTable {...args} data={data} pageSize={15} fetchData={fetchData} />;
};
export const WithCustomHeader: TableStoryObj = {
args: {
columns: [
// React element header
{
id: 'checkbox',
header: (
<>
<label htmlFor="select-all" className="sr-only">
Select all rows
</label>
<Checkbox id="select-all" />
</>
),
cell: () => <Checkbox aria-label="Select row" />,
},
// Function renderer header
{
id: 'firstName',
header: () => (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '8px' }}>
<Icon name="user" size="sm" />
<Text element="span">First Name</Text>
</span>
),
sortType: 'string',
},
// String header
{ id: 'lastName', header: 'Last name', sortType: 'string' },
{ id: 'car', header: 'Car', sortType: 'string' },
{ id: 'age', header: 'Age', sortType: 'number' },
],
data: pageableData.slice(0, 10),
getRowId: (r) => r.id,
},
};
export default meta;
@@ -2,6 +2,9 @@ import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as React from 'react';
import { Checkbox } from '../Forms/Checkbox';
import { Icon } from '../Icon/Icon';
import { InteractiveTable } from './InteractiveTable';
import { Column } from './types';
@@ -247,4 +250,104 @@ describe('InteractiveTable', () => {
expect(fetchData).toHaveBeenCalledWith({ sortBy: [{ id: 'id', desc: false }] });
});
});
describe('custom header rendering', () => {
it('should render string headers', () => {
const columns: Array<Column<TableData>> = [{ id: 'id', header: 'ID' }];
const data: TableData[] = [{ id: '1', value: '1', country: 'Sweden' }];
render(<InteractiveTable columns={columns} data={data} getRowId={getRowId} />);
expect(screen.getByRole('columnheader', { name: 'ID' })).toBeInTheDocument();
});
it('should render React element headers', () => {
const columns: Array<Column<TableData>> = [
{
id: 'checkbox',
header: (
<>
<label htmlFor="select-all" className="sr-only">
Select all rows
</label>
<Checkbox id="select-all" data-testid="header-checkbox" />
</>
),
cell: () => <Checkbox data-testid="cell-checkbox" aria-label="Select row" />,
},
];
const data: TableData[] = [{ id: '1', value: '1', country: 'Sweden' }];
render(<InteractiveTable columns={columns} data={data} getRowId={getRowId} />);
expect(screen.getByTestId('header-checkbox')).toBeInTheDocument();
expect(screen.getByTestId('cell-checkbox')).toBeInTheDocument();
expect(screen.getByLabelText('Select all rows')).toBeInTheDocument();
expect(screen.getByLabelText('Select row')).toBeInTheDocument();
expect(screen.getByText('Select all rows')).toBeInTheDocument();
});
it('should render function renderer headers', () => {
const columns: Array<Column<TableData>> = [
{
id: 'firstName',
header: () => (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '8px' }}>
<Icon name="user" size="sm" data-testid="header-icon" />
<span>First Name</span>
</span>
),
sortType: 'string',
},
];
const data: TableData[] = [{ id: '1', value: '1', country: 'Sweden' }];
render(<InteractiveTable columns={columns} data={data} getRowId={getRowId} />);
expect(screen.getByTestId('header-icon')).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: /first name/i })).toBeInTheDocument();
});
it('should render all header types together', () => {
const columns: Array<Column<TableData>> = [
{
id: 'checkbox',
header: (
<>
<label htmlFor="select-all" className="sr-only">
Select all rows
</label>
<Checkbox id="select-all" data-testid="header-checkbox" />
</>
),
cell: () => <Checkbox aria-label="Select row" />,
},
{
id: 'id',
header: () => (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '8px' }}>
<Icon name="user" size="sm" data-testid="header-icon" />
<span>ID</span>
</span>
),
sortType: 'string',
},
{ id: 'country', header: 'Country', sortType: 'string' },
{ id: 'value', header: 'Value' },
];
const data: TableData[] = [
{ id: '1', value: 'Value 1', country: 'Sweden' },
{ id: '2', value: 'Value 2', country: 'Norway' },
];
render(<InteractiveTable columns={columns} data={data} getRowId={getRowId} />);
expect(screen.getByTestId('header-checkbox')).toBeInTheDocument();
expect(screen.getByTestId('header-icon')).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: 'Country' })).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: 'Value' })).toBeInTheDocument();
// Verify data is rendered
expect(screen.getByText('Sweden')).toBeInTheDocument();
expect(screen.getByText('Norway')).toBeInTheDocument();
expect(screen.getByText('Value 1')).toBeInTheDocument();
expect(screen.getByText('Value 2')).toBeInTheDocument();
});
});
});
@@ -1,5 +1,5 @@
import { ReactNode } from 'react';
import { CellProps, DefaultSortTypes, IdType, SortByFn } from 'react-table';
import { CellProps, DefaultSortTypes, HeaderProps, IdType, Renderer, SortByFn } from 'react-table';
export interface Column<TableData extends object> {
/**
@@ -11,9 +11,9 @@ export interface Column<TableData extends object> {
*/
cell?: (props: CellProps<TableData>) => ReactNode;
/**
* Header name. if `undefined` the header will be empty. Useful for action columns.
* Header name. Can be a string, renderer function, or undefined. If `undefined` the header will be empty. Useful for action columns.
*/
header?: string;
header?: Renderer<HeaderProps<TableData>>;
/**
* Column sort type. If `undefined` the column will not be sortable.
* */
+4 -4
View File
@@ -184,9 +184,9 @@ func (n *eventStore) Get(ctx context.Context, key EventKey) (Event, error) {
}
// ListSince returns a sequence of events since the given resource version.
func (n *eventStore) ListKeysSince(ctx context.Context, sinceRV int64) iter.Seq2[string, error] {
func (n *eventStore) ListKeysSince(ctx context.Context, sinceRV int64, sortOrder SortOrder) iter.Seq2[string, error] {
opts := ListOptions{
Sort: SortOrderAsc,
Sort: sortOrder,
StartKey: fmt.Sprintf("%d", sinceRV),
}
return func(yield func(string, error) bool) {
@@ -202,9 +202,9 @@ func (n *eventStore) ListKeysSince(ctx context.Context, sinceRV int64) iter.Seq2
}
}
func (n *eventStore) ListSince(ctx context.Context, sinceRV int64) iter.Seq2[Event, error] {
func (n *eventStore) ListSince(ctx context.Context, sinceRV int64, sortOrder SortOrder) iter.Seq2[Event, error] {
return func(yield func(Event, error) bool) {
for evtKey, err := range n.ListKeysSince(ctx, sinceRV) {
for evtKey, err := range n.ListKeysSince(ctx, sinceRV, sortOrder) {
if err != nil {
yield(Event{}, err)
return
@@ -369,7 +369,7 @@ func testEventStoreListKeysSince(t *testing.T, ctx context.Context, store *event
// List events since RV 1500 (should get events with RV 2000 and 3000)
retrievedEvents := make([]string, 0, 2)
for eventKey, err := range store.ListKeysSince(ctx, 1500) {
for eventKey, err := range store.ListKeysSince(ctx, 1500, SortOrderAsc) {
require.NoError(t, err)
retrievedEvents = append(retrievedEvents, eventKey)
}
@@ -429,7 +429,7 @@ func testEventStoreListSince(t *testing.T, ctx context.Context, store *eventStor
// List events since RV 1500 (should get events with RV 2000 and 3000)
retrievedEvents := make([]Event, 0, 2)
for event, err := range store.ListSince(ctx, 1500) {
for event, err := range store.ListSince(ctx, 1500, SortOrderAsc) {
require.NoError(t, err)
retrievedEvents = append(retrievedEvents, event)
}
@@ -453,7 +453,7 @@ func TestEventStore_ListSince_Empty(t *testing.T) {
func testEventStoreListSinceEmpty(t *testing.T, ctx context.Context, store *eventStore) {
// List events when store is empty
retrievedEvents := make([]Event, 0)
for event, err := range store.ListSince(ctx, 0) {
for event, err := range store.ListSince(ctx, 0, SortOrderAsc) {
require.NoError(t, err)
retrievedEvents = append(retrievedEvents, event)
}
@@ -825,7 +825,7 @@ func testListKeysSinceWithSnowflakeTime(t *testing.T, ctx context.Context, store
// List events since 90 minutes ago using subtractDurationFromSnowflake
sinceRV := subtractDurationFromSnowflake(snowflakeFromTime(now), 90*time.Minute)
retrievedEvents := make([]string, 0)
for eventKey, err := range store.ListKeysSince(ctx, sinceRV) {
for eventKey, err := range store.ListKeysSince(ctx, sinceRV, SortOrderAsc) {
require.NoError(t, err)
retrievedEvents = append(retrievedEvents, eventKey)
}
@@ -842,7 +842,7 @@ func testListKeysSinceWithSnowflakeTime(t *testing.T, ctx context.Context, store
// List events since 30 minutes ago using subtractDurationFromSnowflake
sinceRV = subtractDurationFromSnowflake(snowflakeFromTime(now), 30*time.Minute)
retrievedEvents = make([]string, 0)
for eventKey, err := range store.ListKeysSince(ctx, sinceRV) {
for eventKey, err := range store.ListKeysSince(ctx, sinceRV, SortOrderAsc) {
require.NoError(t, err)
retrievedEvents = append(retrievedEvents, eventKey)
}
+24 -8
View File
@@ -19,13 +19,18 @@ const (
defaultBufferSize = 10000
)
type notifier struct {
type notifier interface {
Watch(context.Context, watchOptions) <-chan Event
}
type pollingNotifier struct {
eventStore *eventStore
log logging.Logger
}
type notifierOptions struct {
log logging.Logger
log logging.Logger
useChannelNotifier bool
}
type watchOptions struct {
@@ -44,15 +49,26 @@ func defaultWatchOptions() watchOptions {
}
}
func newNotifier(eventStore *eventStore, opts notifierOptions) *notifier {
func newNotifier(eventStore *eventStore, opts notifierOptions) notifier {
if opts.log == nil {
opts.log = &logging.NoOpLogger{}
}
return &notifier{eventStore: eventStore, log: opts.log}
if opts.useChannelNotifier {
return &channelNotifier{}
}
return &pollingNotifier{eventStore: eventStore, log: opts.log}
}
type channelNotifier struct{}
func (cn *channelNotifier) Watch(ctx context.Context, opts watchOptions) <-chan Event {
return nil
}
// Return the last resource version from the event store
func (n *notifier) lastEventResourceVersion(ctx context.Context) (int64, error) {
func (n *pollingNotifier) lastEventResourceVersion(ctx context.Context) (int64, error) {
e, err := n.eventStore.LastEventKey(ctx)
if err != nil {
return 0, err
@@ -60,11 +76,11 @@ func (n *notifier) lastEventResourceVersion(ctx context.Context) (int64, error)
return e.ResourceVersion, nil
}
func (n *notifier) cacheKey(evt Event) string {
func (n *pollingNotifier) cacheKey(evt Event) string {
return fmt.Sprintf("%s~%s~%s~%s~%d", evt.Namespace, evt.Group, evt.Resource, evt.Name, evt.ResourceVersion)
}
func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event {
func (n *pollingNotifier) Watch(ctx context.Context, opts watchOptions) <-chan Event {
if opts.MinBackoff <= 0 {
opts.MinBackoff = defaultMinBackoff
}
@@ -103,7 +119,7 @@ func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event {
return
case <-time.After(currentInterval):
foundEvents := false
for evt, err := range n.eventStore.ListSince(ctx, subtractDurationFromSnowflake(lastRV, opts.LookbackPeriod)) {
for evt, err := range n.eventStore.ListSince(ctx, subtractDurationFromSnowflake(lastRV, opts.LookbackPeriod), SortOrderAsc) {
if err != nil {
n.log.Error("Failed to list events since", "error", err)
continue
+12 -12
View File
@@ -13,7 +13,7 @@ import (
"github.com/stretchr/testify/require"
)
func setupTestNotifier(t *testing.T) (*notifier, *eventStore) {
func setupTestNotifier(t *testing.T) (*pollingNotifier, *eventStore) {
db := setupTestBadgerDB(t)
t.Cleanup(func() {
err := db.Close()
@@ -22,10 +22,10 @@ func setupTestNotifier(t *testing.T) (*notifier, *eventStore) {
kv := NewBadgerKV(db)
eventStore := newEventStore(kv)
notifier := newNotifier(eventStore, notifierOptions{log: &logging.NoOpLogger{}})
return notifier, eventStore
return notifier.(*pollingNotifier), eventStore
}
func setupTestNotifierSqlKv(t *testing.T) (*notifier, *eventStore) {
func setupTestNotifierSqlKv(t *testing.T) (*pollingNotifier, *eventStore) {
dbstore := db.InitTestDB(t)
eDB, err := dbimpl.ProvideResourceDB(dbstore, setting.NewCfg(), nil)
require.NoError(t, err)
@@ -33,7 +33,7 @@ func setupTestNotifierSqlKv(t *testing.T) (*notifier, *eventStore) {
require.NoError(t, err)
eventStore := newEventStore(kv)
notifier := newNotifier(eventStore, notifierOptions{log: &logging.NoOpLogger{}})
return notifier, eventStore
return notifier.(*pollingNotifier), eventStore
}
func TestNewNotifier(t *testing.T) {
@@ -49,7 +49,7 @@ func TestDefaultWatchOptions(t *testing.T) {
assert.Equal(t, defaultBufferSize, opts.BufferSize)
}
func runNotifierTestWith(t *testing.T, storeName string, newStoreFn func(*testing.T) (*notifier, *eventStore), testFn func(*testing.T, context.Context, *notifier, *eventStore)) {
func runNotifierTestWith(t *testing.T, storeName string, newStoreFn func(*testing.T) (*pollingNotifier, *eventStore), testFn func(*testing.T, context.Context, *pollingNotifier, *eventStore)) {
t.Run(storeName, func(t *testing.T) {
ctx := context.Background()
notifier, eventStore := newStoreFn(t)
@@ -62,7 +62,7 @@ func TestNotifier_lastEventResourceVersion(t *testing.T) {
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierLastEventResourceVersion)
}
func testNotifierLastEventResourceVersion(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
func testNotifierLastEventResourceVersion(t *testing.T, ctx context.Context, notifier *pollingNotifier, eventStore *eventStore) {
// Test with no events
rv, err := notifier.lastEventResourceVersion(ctx)
assert.Error(t, err)
@@ -113,7 +113,7 @@ func TestNotifier_cachekey(t *testing.T) {
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierCachekey)
}
func testNotifierCachekey(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
func testNotifierCachekey(t *testing.T, ctx context.Context, notifier *pollingNotifier, eventStore *eventStore) {
tests := []struct {
name string
event Event
@@ -167,7 +167,7 @@ func TestNotifier_Watch_NoEvents(t *testing.T) {
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchNoEvents)
}
func testNotifierWatchNoEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
func testNotifierWatchNoEvents(t *testing.T, ctx context.Context, notifier *pollingNotifier, eventStore *eventStore) {
ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
defer cancel()
@@ -208,7 +208,7 @@ func TestNotifier_Watch_WithExistingEvents(t *testing.T) {
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchWithExistingEvents)
}
func testNotifierWatchWithExistingEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
func testNotifierWatchWithExistingEvents(t *testing.T, ctx context.Context, notifier *pollingNotifier, eventStore *eventStore) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
@@ -282,7 +282,7 @@ func TestNotifier_Watch_EventDeduplication(t *testing.T) {
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchEventDeduplication)
}
func testNotifierWatchEventDeduplication(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
func testNotifierWatchEventDeduplication(t *testing.T, ctx context.Context, notifier *pollingNotifier, eventStore *eventStore) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
@@ -348,7 +348,7 @@ func TestNotifier_Watch_ContextCancellation(t *testing.T) {
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchContextCancellation)
}
func testNotifierWatchContextCancellation(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
func testNotifierWatchContextCancellation(t *testing.T, ctx context.Context, notifier *pollingNotifier, eventStore *eventStore) {
ctx, cancel := context.WithCancel(ctx)
// Add an initial event so that lastEventResourceVersion doesn't return ErrNotFound
@@ -394,7 +394,7 @@ func TestNotifier_Watch_MultipleEvents(t *testing.T) {
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchMultipleEvents)
}
func testNotifierWatchMultipleEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
func testNotifierWatchMultipleEvents(t *testing.T, ctx context.Context, notifier *pollingNotifier, eventStore *eventStore) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
rv := time.Now().UnixNano()
+24 -18
View File
@@ -61,7 +61,7 @@ type kvStorageBackend struct {
bulkLock *BulkLock
dataStore *dataStore
eventStore *eventStore
notifier *notifier
notifier notifier
builder DocumentBuilder
log logging.Logger
withPruner bool
@@ -91,6 +91,7 @@ type KVBackendOptions struct {
Tracer trace.Tracer // TODO add tracing
Reg prometheus.Registerer // TODO add metrics
UseChannelNotifier bool
// Adding RvManager overrides the RV generated with snowflake in order to keep backwards compatibility with
// unified/sql
RvManager *rvmanager.ResourceVersionManager
@@ -121,7 +122,7 @@ func NewKVStorageBackend(opts KVBackendOptions) (KVBackend, error) {
bulkLock: NewBulkLock(),
dataStore: newDataStore(kv),
eventStore: eventStore,
notifier: newNotifier(eventStore, notifierOptions{}),
notifier: newNotifier(eventStore, notifierOptions{useChannelNotifier: opts.UseChannelNotifier}),
snowflake: s,
builder: StandardDocumentBuilder(), // For now we use the standard document builder.
log: &logging.NoOpLogger{}, // Make this configurable
@@ -800,8 +801,20 @@ func (k *kvStorageBackend) ListModifiedSince(ctx context.Context, key Namespaced
}
}
// Generate a new resource version for the list
listRV := k.snowflake.Generate().Int64()
latestEvent, err := k.eventStore.LastEventKey(ctx)
if err != nil {
if errors.Is(err, ErrNotFound) {
return sinceRv, func(yield func(*ModifiedResource, error) bool) { /* nothing to return */ }
}
return 0, func(yield func(*ModifiedResource, error) bool) {
yield(nil, fmt.Errorf("error trying to retrieve last event key: %s", err))
}
}
if latestEvent.ResourceVersion == sinceRv {
return sinceRv, func(yield func(*ModifiedResource, error) bool) { /* nothing to return */ }
}
// Check if sinceRv is older than 1 hour
sinceRvTimestamp := snowflake.ID(sinceRv).Time()
@@ -810,11 +823,11 @@ func (k *kvStorageBackend) ListModifiedSince(ctx context.Context, key Namespaced
if sinceRvAge > time.Hour {
k.log.Debug("ListModifiedSince using data store", "sinceRv", sinceRv, "sinceRvAge", sinceRvAge)
return listRV, k.listModifiedSinceDataStore(ctx, key, sinceRv)
return latestEvent.ResourceVersion, k.listModifiedSinceDataStore(ctx, key, sinceRv)
}
k.log.Debug("ListModifiedSince using event store", "sinceRv", sinceRv, "sinceRvAge", sinceRvAge)
return listRV, k.listModifiedSinceEventStore(ctx, key, sinceRv)
return latestEvent.ResourceVersion, k.listModifiedSinceEventStore(ctx, key, sinceRv)
}
func convertEventType(action DataAction) resourcepb.WatchEvent_Type {
@@ -915,9 +928,9 @@ func (k *kvStorageBackend) listModifiedSinceDataStore(ctx context.Context, key N
func (k *kvStorageBackend) listModifiedSinceEventStore(ctx context.Context, key NamespacedResource, sinceRv int64) iter.Seq2[*ModifiedResource, error] {
return func(yield func(*ModifiedResource, error) bool) {
// store all events ordered by RV for the given tenant here
eventKeys := make([]EventKey, 0)
for evtKeyStr, err := range k.eventStore.ListKeysSince(ctx, subtractDurationFromSnowflake(sinceRv, defaultLookbackPeriod)) {
// we only care about the latest revision of every resource in the list
seen := make(map[string]struct{})
for evtKeyStr, err := range k.eventStore.ListKeysSince(ctx, subtractDurationFromSnowflake(sinceRv, defaultLookbackPeriod), SortOrderDesc) {
if err != nil {
yield(&ModifiedResource{}, err)
return
@@ -937,18 +950,11 @@ func (k *kvStorageBackend) listModifiedSinceEventStore(ctx context.Context, key
continue
}
eventKeys = append(eventKeys, evtKey)
}
// we only care about the latest revision of every resource in the list
seen := make(map[string]struct{})
for i := len(eventKeys) - 1; i >= 0; i -= 1 {
evtKey := eventKeys[i]
if _, ok := seen[evtKey.Name]; ok {
continue
}
seen[evtKey.Name] = struct{}{}
seen[evtKey.Name] = struct{}{}
value, err := k.getValueFromDataStore(ctx, DataKey(evtKey))
if err != nil {
yield(&ModifiedResource{}, err)
@@ -1306,7 +1312,7 @@ func (b *kvStorageBackend) ProcessBulk(ctx context.Context, setting BulkSettings
if setting.RebuildCollection {
for _, key := range setting.Collection {
events := make([]string, 0)
for evtKeyStr, err := range b.eventStore.ListKeysSince(ctx, 1) {
for evtKeyStr, err := range b.eventStore.ListKeysSince(ctx, 1, SortOrderAsc) {
if err != nil {
b.log.Error("failed to list event: %s", err)
return rsp
+7 -6
View File
@@ -99,6 +99,9 @@ func NewResourceServer(opts ServerOptions) (resource.ResourceServer, error) {
return nil, err
}
isHA := isHighAvailabilityEnabled(opts.Cfg.SectionWithEnvOverrides("database"),
opts.Cfg.SectionWithEnvOverrides("resource_api"))
if opts.Cfg.EnableSQLKVBackend {
sqlkv, err := resource.NewSQLKV(eDB)
if err != nil {
@@ -106,9 +109,10 @@ func NewResourceServer(opts ServerOptions) (resource.ResourceServer, error) {
}
kvBackendOpts := resource.KVBackendOptions{
KvStore: sqlkv,
Tracer: opts.Tracer,
Reg: opts.Reg,
KvStore: sqlkv,
Tracer: opts.Tracer,
Reg: opts.Reg,
UseChannelNotifier: !isHA,
}
ctx := context.Background()
@@ -140,9 +144,6 @@ func NewResourceServer(opts ServerOptions) (resource.ResourceServer, error) {
serverOptions.Backend = kvBackend
serverOptions.Diagnostics = kvBackend
} else {
isHA := isHighAvailabilityEnabled(opts.Cfg.SectionWithEnvOverrides("database"),
opts.Cfg.SectionWithEnvOverrides("resource_api"))
backend, err := NewBackend(BackendOptions{
DBProvider: eDB,
Reg: opts.Reg,
+11 -6
View File
@@ -23,6 +23,7 @@ import (
"github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
sqldb "github.com/grafana/grafana/pkg/storage/unified/sql/db"
@@ -99,6 +100,10 @@ func RunStorageBackendTest(t *testing.T, newBackend NewBackendFunc, opts *TestOp
}
t.Run(tc.name, func(t *testing.T) {
if db.IsTestDbSQLite() {
t.Skip("Skipping tests on sqlite until channel notifier is implemented")
}
tc.fn(t, newBackend(context.Background()), opts.NSPrefix)
})
}
@@ -550,7 +555,7 @@ func runTestIntegrationBackendListModifiedSince(t *testing.T, backend resource.S
Resource: "resource",
}
latestRv, seq := backend.ListModifiedSince(ctx, key, rvCreated)
require.Greater(t, latestRv, rvCreated)
require.Equal(t, latestRv, rvDeleted)
counter := 0
for res, err := range seq {
@@ -624,11 +629,11 @@ func runTestIntegrationBackendListModifiedSince(t *testing.T, backend resource.S
rvCreated3, _ := writeEvent(ctx, backend, "bItem", resourcepb.WatchEvent_ADDED, WithNamespace(ns))
latestRv, seq := backend.ListModifiedSince(ctx, key, rvCreated1-1)
require.Greater(t, latestRv, rvCreated3)
require.Equal(t, latestRv, rvCreated3)
counter := 0
names := []string{"aItem", "bItem", "cItem"}
rvs := []int64{rvCreated2, rvCreated3, rvCreated1}
names := []string{"bItem", "aItem", "cItem"}
rvs := []int64{rvCreated3, rvCreated2, rvCreated1}
for res, err := range seq {
require.NoError(t, err)
require.Equal(t, key.Namespace, res.Key.Namespace)
@@ -1166,7 +1171,7 @@ func runTestIntegrationBackendCreateNewResource(t *testing.T, backend resource.S
}))
server := newServer(t, backend)
ns := nsPrefix + "-create-resource"
ns := nsPrefix + "-create-rsrce" // create-resource
ctx = request.WithNamespace(ctx, ns)
request := &resourcepb.CreateRequest{
@@ -1607,7 +1612,7 @@ func (s *sliceBulkRequestIterator) RollbackRequested() bool {
func runTestIntegrationBackendOptimisticLocking(t *testing.T, backend resource.StorageBackend, nsPrefix string) {
ctx := testutil.NewTestContext(t, time.Now().Add(30*time.Second))
ns := nsPrefix + "-optimistic-locking"
ns := nsPrefix + "-optimis-lock" // optimistic-locking. need to cut down on characters to not exceed namespace character limit (40)
t.Run("concurrent updates with same RV - only one succeeds", func(t *testing.T) {
// Create initial resource with rv0 (no previous RV)
@@ -36,6 +36,10 @@ func NewTestSqlKvBackend(t *testing.T, ctx context.Context, withRvManager bool)
KvStore: kv,
}
if db.DriverName() == "sqlite3" {
kvOpts.UseChannelNotifier = true
}
if withRvManager {
dialect := sqltemplate.DialectForDriver(db.DriverName())
rvManager, err := rvmanager.NewResourceVersionManager(rvmanager.ResourceManagerOptions{
@@ -8,6 +8,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/util/testutil"
)
func TestBadgerKVStorageBackend(t *testing.T) {
@@ -29,26 +30,18 @@ func TestBadgerKVStorageBackend(t *testing.T) {
SkipTests: map[string]bool{
// TODO: fix these tests and remove this skip
TestBlobSupport: true,
TestListModifiedSince: true,
// Badger does not support bulk import yet.
TestGetResourceLastImportTime: true,
},
})
}
func TestSQLKVStorageBackend(t *testing.T) {
func TestIntegrationSQLKVStorageBackend(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
skipTests := map[string]bool{
TestWatchWriteEvents: true,
TestList: true,
TestBlobSupport: true,
TestGetResourceStats: true,
TestListHistory: true,
TestListHistoryErrorReporting: true,
TestListModifiedSince: true,
TestListTrash: true,
TestCreateNewResource: true,
TestGetResourceLastImportTime: true,
TestOptimisticLocking: true,
}
t.Run("Without RvManager", func(t *testing.T) {
@@ -56,7 +49,7 @@ func TestSQLKVStorageBackend(t *testing.T) {
backend, _ := NewTestSqlKvBackend(t, ctx, false)
return backend
}, &TestOptions{
NSPrefix: "sqlkvstorage-test",
NSPrefix: "sqlkvstoragetest",
SkipTests: skipTests,
})
})
@@ -66,7 +59,7 @@ func TestSQLKVStorageBackend(t *testing.T) {
backend, _ := NewTestSqlKvBackend(t, ctx, true)
return backend
}, &TestOptions{
NSPrefix: "sqlkvstorage-withrvmanager-test",
NSPrefix: "sqlkvstoragetest-rvmanager",
SkipTests: skipTests,
})
})
@@ -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') {
@@ -25,6 +25,10 @@ export class ExportAsCode extends ShareExportTab {
public getTabLabel(): string {
return t('export.json.title', 'Export dashboard');
}
public getSubtitle(): string | undefined {
return t('export.json.info-text', 'Copy or download a file containing the definition of your dashboard');
}
}
function ExportAsCodeRenderer({ model }: SceneComponentProps<ExportAsCode>) {
@@ -53,12 +57,6 @@ function ExportAsCodeRenderer({ model }: SceneComponentProps<ExportAsCode>) {
return (
<div data-testid={selector.container} className={styles.container}>
<p>
<Trans i18nKey="export.json.info-text">
Copy or download a file containing the definition of your dashboard
</Trans>
</p>
{config.featureToggles.kubernetesDashboards ? (
<ResourceExport
dashboardJson={dashboardJson}
@@ -0,0 +1,189 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { AsyncState } from 'react-use/lib/useAsync';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { Dashboard } from '@grafana/schema';
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { ExportMode, ResourceExport } from './ResourceExport';
type DashboardJsonState = AsyncState<{
json: Dashboard | DashboardV2Spec | { error: unknown };
hasLibraryPanels?: boolean;
initialSaveModelVersion: 'v1' | 'v2';
}>;
const selector = e2eSelectors.pages.ExportDashboardDrawer.ExportAsJson;
const createDefaultProps = (overrides?: Partial<Parameters<typeof ResourceExport>[0]>) => {
const defaultProps: Parameters<typeof ResourceExport>[0] = {
dashboardJson: {
loading: false,
value: {
json: { title: 'Test Dashboard' } as Dashboard,
hasLibraryPanels: false,
initialSaveModelVersion: 'v1',
},
} as DashboardJsonState,
isSharingExternally: false,
exportMode: ExportMode.Classic,
isViewingYAML: false,
onExportModeChange: jest.fn(),
onShareExternallyChange: jest.fn(),
onViewYAML: jest.fn(),
};
return { ...defaultProps, ...overrides };
};
const createV2DashboardJson = (hasLibraryPanels = false): DashboardJsonState => ({
loading: false,
value: {
json: {
title: 'Test V2 Dashboard',
spec: {
elements: {},
},
} as unknown as DashboardV2Spec,
hasLibraryPanels,
initialSaveModelVersion: 'v2',
},
});
const expandOptions = async () => {
const button = screen.getByRole('button', { expanded: false });
await userEvent.click(button);
};
describe('ResourceExport', () => {
describe('export mode options for v1 dashboard', () => {
it('should show three export mode options in correct order: Classic, V1 Resource, V2 Resource', async () => {
render(<ResourceExport {...createDefaultProps()} />);
await expandOptions();
const radioGroup = screen.getByRole('radiogroup', { name: /model/i });
const labels = within(radioGroup)
.getAllByRole('radio')
.map((radio) => radio.parentElement?.textContent?.trim());
expect(labels).toHaveLength(3);
expect(labels).toEqual(['Classic', 'V1 Resource', 'V2 Resource']);
});
it('should have first option selected by default when exportMode is Classic', async () => {
render(<ResourceExport {...createDefaultProps({ exportMode: ExportMode.Classic })} />);
await expandOptions();
const radioGroup = screen.getByRole('radiogroup', { name: /model/i });
const radios = within(radioGroup).getAllByRole('radio');
expect(radios[0]).toBeChecked();
});
it('should call onExportModeChange when export mode is changed', async () => {
const onExportModeChange = jest.fn();
render(<ResourceExport {...createDefaultProps({ onExportModeChange })} />);
await expandOptions();
const radioGroup = screen.getByRole('radiogroup', { name: /model/i });
const radios = within(radioGroup).getAllByRole('radio');
await userEvent.click(radios[1]); // V1 Resource
expect(onExportModeChange).toHaveBeenCalledWith(ExportMode.V1Resource);
});
});
describe('export mode options for v2 dashboard', () => {
it('should not show export mode options', async () => {
render(<ResourceExport {...createDefaultProps({ dashboardJson: createV2DashboardJson() })} />);
await expandOptions();
expect(screen.queryByRole('radiogroup', { name: /model/i })).not.toBeInTheDocument();
});
});
describe('format options', () => {
it('should not show format options when export mode is Classic', async () => {
render(<ResourceExport {...createDefaultProps({ exportMode: ExportMode.Classic })} />);
await expandOptions();
expect(screen.getByRole('radiogroup', { name: /model/i })).toBeInTheDocument();
expect(screen.queryByRole('radiogroup', { name: /format/i })).not.toBeInTheDocument();
});
it.each([ExportMode.V1Resource, ExportMode.V2Resource])(
'should show format options when export mode is %s',
async (exportMode) => {
render(<ResourceExport {...createDefaultProps({ exportMode })} />);
await expandOptions();
expect(screen.getByRole('radiogroup', { name: /model/i })).toBeInTheDocument();
expect(screen.getByRole('radiogroup', { name: /format/i })).toBeInTheDocument();
}
);
it('should have first format option selected when isViewingYAML is false', async () => {
render(<ResourceExport {...createDefaultProps({ exportMode: ExportMode.V1Resource, isViewingYAML: false })} />);
await expandOptions();
const formatGroup = screen.getByRole('radiogroup', { name: /format/i });
const formatRadios = within(formatGroup).getAllByRole('radio');
expect(formatRadios[0]).toBeChecked(); // JSON
});
it('should have second format option selected when isViewingYAML is true', async () => {
render(<ResourceExport {...createDefaultProps({ exportMode: ExportMode.V1Resource, isViewingYAML: true })} />);
await expandOptions();
const formatGroup = screen.getByRole('radiogroup', { name: /format/i });
const formatRadios = within(formatGroup).getAllByRole('radio');
expect(formatRadios[1]).toBeChecked(); // YAML
});
it('should call onViewYAML when format is changed', async () => {
const onViewYAML = jest.fn();
render(<ResourceExport {...createDefaultProps({ exportMode: ExportMode.V1Resource, onViewYAML })} />);
await expandOptions();
const formatGroup = screen.getByRole('radiogroup', { name: /format/i });
const formatRadios = within(formatGroup).getAllByRole('radio');
await userEvent.click(formatRadios[1]); // YAML
expect(onViewYAML).toHaveBeenCalled();
});
});
describe('share externally switch', () => {
it('should show share externally switch for Classic mode', () => {
render(<ResourceExport {...createDefaultProps({ exportMode: ExportMode.Classic })} />);
expect(screen.getByTestId(selector.exportExternallyToggle)).toBeInTheDocument();
});
it('should show share externally switch for V2Resource mode with V2 dashboard', () => {
render(
<ResourceExport
{...createDefaultProps({
dashboardJson: createV2DashboardJson(),
exportMode: ExportMode.V2Resource,
})}
/>
);
expect(screen.getByTestId(selector.exportExternallyToggle)).toBeInTheDocument();
});
it('should call onShareExternallyChange when switch is toggled', async () => {
const onShareExternallyChange = jest.fn();
render(<ResourceExport {...createDefaultProps({ exportMode: ExportMode.Classic, onShareExternallyChange })} />);
const switchElement = screen.getByTestId(selector.exportExternallyToggle);
await userEvent.click(switchElement);
expect(onShareExternallyChange).toHaveBeenCalled();
});
it('should reflect isSharingExternally value in switch', () => {
render(<ResourceExport {...createDefaultProps({ exportMode: ExportMode.Classic, isSharingExternally: true })} />);
expect(screen.getByTestId(selector.exportExternallyToggle)).toBeChecked();
});
});
});
@@ -4,7 +4,8 @@ import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n';
import { Dashboard } from '@grafana/schema';
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { Alert, Label, RadioButtonGroup, Stack, Switch } from '@grafana/ui';
import { Alert, Icon, Label, RadioButtonGroup, Stack, Switch, Box, Tooltip } from '@grafana/ui';
import { QueryOperationRow } from 'app/core/components/QueryOperationRow/QueryOperationRow';
import { DashboardJson } from 'app/features/manage-dashboards/types';
import { ExportableResource } from '../ShareExportTab';
@@ -48,80 +49,90 @@ export function ResourceExport({
const switchExportLabel =
exportMode === ExportMode.V2Resource
? t('export.json.export-remove-ds-refs', 'Remove deployment details')
: t('share-modal.export.share-externally-label', `Export for sharing externally`);
? t('dashboard-scene.resource-export.share-externally', 'Share dashboard with another instance')
: t('share-modal.export.share-externally-label', 'Export for sharing externally');
const switchExportTooltip = t(
'dashboard-scene.resource-export.share-externally-tooltip',
'Removes all instance-specific metadata and data source references from the resource before export.'
);
const switchExportModeLabel = t('export.json.export-mode', 'Model');
const switchExportFormatLabel = t('export.json.export-format', 'Format');
const exportResourceOptions = [
{
label: t('dashboard-scene.resource-export.label.classic', 'Classic'),
value: ExportMode.Classic,
},
{
label: t('dashboard-scene.resource-export.label.v1-resource', 'V1 Resource'),
value: ExportMode.V1Resource,
},
{
label: t('dashboard-scene.resource-export.label.v2-resource', 'V2 Resource'),
value: ExportMode.V2Resource,
},
];
return (
<Stack gap={2} direction="column">
<Stack gap={1} direction="column">
{initialSaveModelVersion === 'v1' && (
<Stack alignItems="center">
<Label>{switchExportModeLabel}</Label>
<RadioButtonGroup
options={[
{ label: t('dashboard-scene.resource-export.label.classic', 'Classic'), value: ExportMode.Classic },
{
label: t('dashboard-scene.resource-export.label.v1-resource', 'V1 Resource'),
value: ExportMode.V1Resource,
},
{
label: t('dashboard-scene.resource-export.label.v2-resource', 'V2 Resource'),
value: ExportMode.V2Resource,
},
]}
value={exportMode}
onChange={(value) => onExportModeChange(value)}
/>
<>
<QueryOperationRow
id="Advanced options"
index={0}
title={t('dashboard-scene.resource-export.label.advanced-options', 'Advanced options')}
isOpen={false}
>
<Box marginTop={2}>
<Stack gap={1} direction="column">
{initialSaveModelVersion === 'v1' && (
<Stack gap={1} alignItems="center">
<Label>{switchExportModeLabel}</Label>
<RadioButtonGroup
options={exportResourceOptions}
value={exportMode}
onChange={(value) => onExportModeChange(value)}
aria-label={switchExportModeLabel}
/>
</Stack>
)}
{exportMode !== ExportMode.Classic && (
<Stack gap={1} alignItems="center">
<Label>{switchExportFormatLabel}</Label>
<RadioButtonGroup
options={[
{ label: t('dashboard-scene.resource-export.label.json', 'JSON'), value: 'json' },
{ label: t('dashboard-scene.resource-export.label.yaml', 'YAML'), value: 'yaml' },
]}
value={isViewingYAML ? 'yaml' : 'json'}
onChange={onViewYAML}
aria-label={switchExportFormatLabel}
/>
</Stack>
)}
</Stack>
)}
{initialSaveModelVersion === 'v2' && (
<Stack alignItems="center">
<Label>{switchExportModeLabel}</Label>
<RadioButtonGroup
options={[
{
label: t('dashboard-scene.resource-export.label.v2-resource', 'V2 Resource'),
value: ExportMode.V2Resource,
},
{
label: t('dashboard-scene.resource-export.label.v1-resource', 'V1 Resource'),
value: ExportMode.V1Resource,
},
]}
value={exportMode}
onChange={(value) => onExportModeChange(value)}
/>
</Stack>
)}
{exportMode !== ExportMode.Classic && (
<Stack gap={1} alignItems="center">
<Label>{switchExportFormatLabel}</Label>
<RadioButtonGroup
options={[
{ label: t('dashboard-scene.resource-export.label.json', 'JSON'), value: 'json' },
{ label: t('dashboard-scene.resource-export.label.yaml', 'YAML'), value: 'yaml' },
]}
value={isViewingYAML ? 'yaml' : 'json'}
onChange={onViewYAML}
/>
</Stack>
)}
{(isV2Dashboard ||
exportMode === ExportMode.Classic ||
(initialSaveModelVersion === 'v2' && exportMode === ExportMode.V1Resource)) && (
<Stack gap={1} alignItems="start">
<Label>{switchExportLabel}</Label>
<Switch
label={switchExportLabel}
value={isSharingExternally}
onChange={onShareExternallyChange}
data-testid={selector.exportExternallyToggle}
/>
</Stack>
)}
</Stack>
</Box>
</QueryOperationRow>
{(isV2Dashboard ||
exportMode === ExportMode.Classic ||
(initialSaveModelVersion === 'v2' && exportMode === ExportMode.V1Resource)) && (
<Stack gap={1} alignItems="start">
<Label>
<Stack gap={0.5} alignItems="center">
<Tooltip content={switchExportTooltip} placement="bottom">
<Icon name="info-circle" size="sm" />
</Tooltip>
{switchExportLabel}
</Stack>
</Label>
<Switch
label={switchExportLabel}
value={isSharingExternally}
onChange={onShareExternallyChange}
data-testid={selector.exportExternallyToggle}
/>
</Stack>
)}
{showV2LibPanelAlert && (
<Alert
@@ -130,6 +141,7 @@ export function ResourceExport({
'Library panels will be converted to regular panels'
)}
severity="warning"
topSpacing={2}
>
<Trans i18nKey="dashboard-scene.save-dashboard-form.schema-v2-library-panels-export">
Due to limitations in the new dashboard schema (V2), library panels will be converted to regular panels with
@@ -137,6 +149,6 @@ export function ResourceExport({
</Trans>
</Alert>
)}
</Stack>
</>
);
}
@@ -66,7 +66,12 @@ function ShareDrawerRenderer({ model }: SceneComponentProps<ShareDrawer>) {
const dashboard = getDashboardSceneFor(model);
return (
<Drawer title={activeShare?.getTabLabel()} onClose={model.onDismiss} size="md">
<Drawer
title={activeShare?.getTabLabel()}
subtitle={activeShare?.getSubtitle?.()}
onClose={model.onDismiss}
size="md"
>
<ShareDrawerContext.Provider value={{ dashboard, onDismiss: model.onDismiss }}>
{activeShare && <activeShare.Component model={activeShare} />}
</ShareDrawerContext.Provider>
@@ -66,6 +66,10 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
return t('share-modal.tab-title.export', 'Export');
}
public getSubtitle(): string | undefined {
return undefined;
}
public onShareExternallyChange = () => {
this.setState({
isSharingExternally: !this.state.isSharingExternally,
@@ -15,5 +15,6 @@ export interface SceneShareTab<T extends SceneShareTabState = SceneShareTabState
export interface ShareView extends SceneObject {
getTabLabel(): string;
getSubtitle?(): string | undefined;
onDismiss?: () => void;
}
+4 -2
View File
@@ -6383,12 +6383,15 @@
},
"resource-export": {
"label": {
"advanced-options": "Advanced options",
"classic": "Classic",
"json": "JSON",
"v1-resource": "V1 Resource",
"v2-resource": "V2 Resource",
"yaml": "YAML"
}
},
"share-externally": "Share dashboard with another instance",
"share-externally-tooltip": "Removes all instance-specific metadata and data source references from the resource before export."
},
"revert-dashboard-modal": {
"body-restore-version": "Are you sure you want to restore the dashboard to version {{version}}? All unsaved changes will be lost.",
@@ -7842,7 +7845,6 @@
"export-externally-label": "Export the dashboard to use in another instance",
"export-format": "Format",
"export-mode": "Model",
"export-remove-ds-refs": "Remove deployment details",
"info-text": "Copy or download a file containing the definition of your dashboard",
"title": "Export dashboard"
},