Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 861af005e0 | |||
| 14a05137e1 | |||
| cfe86378a1 | |||
| f7d7e09626 | |||
| ba416eab4e | |||
| 189d50d815 | |||
| 450eaba447 | |||
| 87f5d5e741 |
@@ -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.
|
||||
* */
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 ¬ifier{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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user