Correlations: Add CorrelationSettings Page (#53821)

* GrafanaUI: add option to close DeleteButton on confirm click

* add datasource readOnly info to frontend settings

* move isTruthy utility type guard

* add generic non-visualization table component

* Add correlations settings page

* add missing readOnly in mock

* Fix typo

* avoid reloading correlations after add/remove

* use DeepPartial from rhf

* validate source data source

* fix validation logic

* fix navmodel test

* add missing readonly property

* remove unused styles

* handle multiple clicks on elements

* better UX for loading states

* fix remove handler

* add glue icon
This commit is contained in:
Giordano Ricci 2022-08-26 11:27:28 +01:00 committed by GitHub
parent 37fde2eec6
commit c68d7f1e35
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
51 changed files with 1307 additions and 8 deletions

View File

@ -574,6 +574,7 @@ export interface DataSourceInstanceSettings<T extends DataSourceJsonData = DataS
type: string;
name: string;
meta: DataSourcePluginMeta;
readOnly: boolean;
url?: string;
jsonData: T;
username?: string;

View File

@ -13,9 +13,11 @@ export interface Props {
/** Disable button click action */
disabled?: boolean;
'aria-label'?: string;
/** Close after delete button is clicked */
closeOnConfirm?: boolean;
}
export const DeleteButton: FC<Props> = ({ size, disabled, onConfirm, 'aria-label': ariaLabel }) => {
export const DeleteButton: FC<Props> = ({ size, disabled, onConfirm, 'aria-label': ariaLabel, closeOnConfirm }) => {
return (
<ConfirmButton
confirmText="Delete"
@ -23,6 +25,7 @@ export const DeleteButton: FC<Props> = ({ size, disabled, onConfirm, 'aria-label
size={size || 'md'}
disabled={disabled}
onConfirm={onConfirm}
closeOnConfirm={closeOnConfirm}
>
<Button aria-label={ariaLabel} variant="destructive" icon="times" size={size || 'sm'} />
</ConfirmButton>

View File

@ -93,6 +93,7 @@ export const getAvailableIcons = () =>
'gf-bar-alignment-after',
'gf-bar-alignment-before',
'gf-bar-alignment-center',
'gf-glue',
'gf-grid',
'gf-interpolation-linear',
'gf-interpolation-smooth',

View File

@ -40,6 +40,7 @@ import (
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/correlations"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/featuremgmt"
@ -87,7 +88,8 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/org/new", authorizeInOrg(reqGrafanaAdmin, ac.UseGlobalOrg, orgsCreateAccessEvaluator), hs.Index)
r.Get("/datasources/", authorize(reqOrgAdmin, datasources.ConfigurationPageAccess), hs.Index)
r.Get("/datasources/new", authorize(reqOrgAdmin, datasources.NewPageAccess), hs.Index)
r.Get("/datasources/edit/*", authorize(reqOrgAdmin, datasources.EditPageAccess), hs.Index)
r.Get("/datasources/edit/*", authorize(reqOrgAdmin, datasources.ConfigurationPageAccess), hs.Index)
r.Get("/datasources/correlations", authorize(reqOrgAdmin, correlations.ConfigurationPageAccess), hs.Index)
r.Get("/org/users", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRead)), hs.Index)
r.Get("/org/users/new", reqOrgAdmin, hs.Index)
r.Get("/org/users/invite", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersAdd)), hs.Index)

View File

@ -236,6 +236,7 @@ func (hs *HTTPServer) getFSDataSources(c *models.ReqContext, enabledPlugins Enab
URL: url,
IsDefault: ds.IsDefault,
Access: string(ds.Access),
ReadOnly: ds.ReadOnly,
}
plugin, exists := enabledPlugins.Get(plugins.DataSource, ds.Type)

View File

@ -12,6 +12,7 @@ import (
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/correlations"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/featuremgmt"
@ -265,6 +266,16 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool, prefs *
})
}
if hasAccess(ac.ReqOrgAdmin, correlations.ConfigurationPageAccess) {
configNodes = append(configNodes, &dtos.NavLink{
Text: "Correlations",
Icon: "gf-glue",
Description: "Add and configure correlations",
Id: "correlations",
Url: hs.Cfg.AppSubURL + "/datasources/correlations",
})
}
if hasAccess(ac.ReqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRead)) {
configNodes = append(configNodes, &dtos.NavLink{
Text: "Users",

View File

@ -220,6 +220,7 @@ type DataSourceDTO struct {
Preload bool `json:"preload"`
Module string `json:"module,omitempty"`
JSONData map[string]interface{} `json:"jsonData"`
ReadOnly bool `json:"readOnly"`
BasicAuth string `json:"basicAuth,omitempty"`
WithCredentials bool `json:"withCredentials,omitempty"`

View File

@ -0,0 +1,11 @@
package correlations
import (
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/datasources"
)
var (
// ConfigurationPageAccess is used to protect the "Configure > correlations" tab access
ConfigurationPageAccess = accesscontrol.EvalPermission(datasources.ActionRead)
)

View File

@ -20,6 +20,7 @@ describe('InputDatasource', () => {
name: 'xxx',
meta: {} as PluginMeta,
access: 'proxy',
readOnly: false,
jsonData: {
data,
},

View File

@ -43,6 +43,7 @@ const TRANSLATED_MENU_ITEMS: Record<string, MessageDescriptor> = {
cfg: defineMessage({ id: 'nav.config', message: 'Configuration' }),
datasources: defineMessage({ id: 'nav.datasources', message: 'Data sources' }),
correlations: defineMessage({ id: 'nav.correlations', message: 'Correlations' }),
users: defineMessage({ id: 'nav.users', message: 'Users' }),
teams: defineMessage({ id: 'nav.teams', message: 'Teams' }),
plugins: defineMessage({ id: 'nav.plugins', message: 'Plugins' }),

View File

@ -44,6 +44,7 @@ describe('navModelReducer', () => {
it('then state should be correct', () => {
const originalCfg = { id: 'cfg', subTitle: 'Organization: Org 1', text: 'Configuration' };
const datasources = { id: 'datasources', text: 'Data Sources' };
const correlations = { id: 'correlations', text: 'Correlations' };
const users = { id: 'users', text: 'Users' };
const teams = { id: 'teams', text: 'Teams' };
const plugins = { id: 'plugins', text: 'Plugins' };
@ -53,6 +54,7 @@ describe('navModelReducer', () => {
const initialState = {
cfg: { ...originalCfg, children: [datasources, users, teams, plugins, orgsettings, apikeys] },
datasources: { ...datasources, parentItem: originalCfg },
correlations: { ...correlations, parentItem: originalCfg },
users: { ...users, parentItem: originalCfg },
teams: { ...teams, parentItem: originalCfg },
plugins: { ...plugins, parentItem: originalCfg },
@ -66,6 +68,7 @@ describe('navModelReducer', () => {
const expectedState = {
cfg: { ...newCfg, children: [datasources, users, teams, plugins, orgsettings, apikeys] },
datasources: { ...datasources, parentItem: newCfg },
correlations: { ...correlations, parentItem: newCfg },
users: { ...users, parentItem: newCfg },
teams: { ...teams, parentItem: newCfg },
plugins: { ...plugins, parentItem: newCfg },

View File

@ -79,6 +79,7 @@ export const navIndexReducer = (state: NavIndex = initialState, action: AnyActio
...state,
cfg: { ...state.cfg, subTitle },
datasources: getItemWithNewSubTitle(state.datasources, subTitle),
correlations: getItemWithNewSubTitle(state.correlations, subTitle),
users: getItemWithNewSubTitle(state.users, subTitle),
teams: getItemWithNewSubTitle(state.teams, subTitle),
plugins: getItemWithNewSubTitle(state.plugins, subTitle),

View File

@ -0,0 +1,3 @@
type Truthy<T> = T extends false | '' | 0 | null | undefined ? never : T;
export const isTruthy = <T>(value: T): value is Truthy<T> => Boolean(value);

View File

@ -41,6 +41,7 @@ const mockRuleSourceByName = () => {
meta: {} as PluginMeta,
jsonData: {} as DataSourceJsonData,
access: 'proxy',
readOnly: false,
});
};
@ -99,6 +100,7 @@ const mockedRules: CombinedRule[] = [
meta: {} as PluginMeta,
jsonData: {} as DataSourceJsonData,
access: 'proxy',
readOnly: false,
},
},
},
@ -128,6 +130,7 @@ const mockedRules: CombinedRule[] = [
meta: {} as PluginMeta,
jsonData: {} as DataSourceJsonData,
access: 'proxy',
readOnly: false,
},
},
},

View File

@ -136,6 +136,7 @@ const mockCloudRule = {
meta: {} as PluginMeta,
jsonData: {} as DataSourceJsonData,
access: 'proxy',
readOnly: false,
},
},
};

View File

@ -59,6 +59,7 @@ export function mockDataSource<T extends DataSourceJsonData = DataSourceJsonData
},
...meta,
} as any as DataSourcePluginMeta,
readOnly: false,
...partial,
};
}

View File

@ -55,6 +55,7 @@ describe('alertRuleToQueries', () => {
access: 'proxy',
meta: {} as PluginMeta,
jsonData: {} as DataSourceJsonData,
readOnly: false,
},
},
};

View File

@ -0,0 +1,412 @@
import { render, waitFor, screen, fireEvent } from '@testing-library/react';
import { merge, uniqueId } from 'lodash';
import React from 'react';
import { DeepPartial } from 'react-hook-form';
import { Provider } from 'react-redux';
import { Observable } from 'rxjs';
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
import { DataSourcePluginMeta } from '@grafana/data';
import { BackendSrv, FetchError, FetchResponse, setDataSourceSrv, BackendSrvRequest } from '@grafana/runtime';
import { GrafanaContext } from 'app/core/context/GrafanaContext';
import { contextSrv } from 'app/core/services/context_srv';
import { configureStore } from 'app/store/configureStore';
import { mockDataSource, MockDataSourceSrv } from '../alerting/unified/mocks';
import CorrelationsPage from './CorrelationsPage';
import { Correlation, CreateCorrelationParams } from './types';
function createFetchResponse<T>(overrides?: DeepPartial<FetchResponse>): FetchResponse<T> {
return merge(
{
data: undefined,
status: 200,
url: '',
config: { url: '' },
type: 'basic',
statusText: 'Ok',
redirected: false,
headers: {} as unknown as Headers,
ok: true,
},
overrides
);
}
function createFetchError(overrides?: DeepPartial<FetchError>): FetchError {
return merge(
createFetchResponse(),
{
status: 500,
statusText: 'Internal Server Error',
ok: false,
},
overrides
);
}
jest.mock('app/core/services/context_srv');
const mocks = {
contextSrv: jest.mocked(contextSrv),
};
const renderWithContext = async (
datasources: ConstructorParameters<typeof MockDataSourceSrv>[0] = {},
correlations: Correlation[] = []
) => {
const backend = {
delete: async (url: string) => {
const matches = url.match(
/^\/api\/datasources\/uid\/(?<dsUid>[a-zA-Z0-9]+)\/correlations\/(?<correlationUid>[a-zA-Z0-9]+)$/
);
if (matches?.groups) {
const { dsUid, correlationUid } = matches.groups;
correlations = correlations.filter((c) => c.uid !== correlationUid || c.sourceUID !== dsUid);
return createFetchResponse({
data: {
message: 'Correlation deleted',
},
});
}
throw createFetchError({
data: {
message: 'Correlation not found',
},
status: 404,
});
},
post: async (url: string, data: Omit<CreateCorrelationParams, 'sourceUID'>) => {
const matches = url.match(/^\/api\/datasources\/uid\/(?<sourceUID>[a-zA-Z0-9]+)\/correlations$/);
if (matches?.groups) {
const { sourceUID } = matches.groups;
const correlation = { sourceUID, ...data, uid: uniqueId() };
correlations.push(correlation);
return correlation;
}
throw createFetchError({
status: 404,
data: {
message: 'Source datasource not found',
},
});
},
patch: async (url: string, data: Omit<CreateCorrelationParams, 'sourceUID'>) => {
const matches = url.match(
/^\/api\/datasources\/uid\/(?<sourceUID>[a-zA-Z0-9]+)\/correlations\/(?<correlationUid>[a-zA-Z0-9]+)$/
);
if (matches?.groups) {
const { sourceUID, correlationUid } = matches.groups;
correlations = correlations.map((c) => {
if (c.uid === correlationUid && sourceUID === c.sourceUID) {
return { ...c, ...data };
}
return c;
});
return createFetchResponse({
data: { sourceUID, ...data },
});
}
throw createFetchError({
data: { message: 'either correlation uid or source id not found' },
status: 404,
});
},
fetch: (options: BackendSrvRequest) => {
return new Observable((s) => {
if (correlations.length) {
s.next(merge(createFetchResponse({ url: options.url, data: correlations })));
} else {
s.error(merge(createFetchError({ config: { url: options.url }, status: 404 })));
}
s.complete();
});
},
} as unknown as BackendSrv;
const grafanaContext = getGrafanaContextMock({ backend });
setDataSourceSrv(new MockDataSourceSrv(datasources));
render(
<Provider store={configureStore({})}>
<GrafanaContext.Provider value={grafanaContext}>
<CorrelationsPage />
</GrafanaContext.Provider>
</Provider>
);
await waitFor(() => {
expect(screen.queryByText('Loading')).not.toBeInTheDocument();
});
};
beforeAll(() => {
mocks.contextSrv.hasPermission.mockImplementation(() => true);
});
afterAll(() => {
jest.restoreAllMocks();
});
describe('CorrelationsPage', () => {
describe('With no correlations', () => {
beforeEach(async () => {
await renderWithContext({
loki: mockDataSource(
{
uid: 'loki',
name: 'loki',
readOnly: false,
jsonData: {},
access: 'direct',
type: 'datasource',
},
{ logs: true }
),
prometheus: mockDataSource(
{
uid: 'prometheus',
name: 'prometheus',
readOnly: false,
jsonData: {},
access: 'direct',
type: 'datasource',
},
{ metrics: true }
),
});
});
it('shows CTA', async () => {
// insert form should not be present
expect(screen.queryByRole('button', { name: /add$/i })).not.toBeInTheDocument();
// "add new" button is the button on the top of the page, not visible when the CTA is rendered
expect(screen.queryByRole('button', { name: /add new$/i })).not.toBeInTheDocument();
// there's no table in the page
expect(screen.queryByRole('table')).not.toBeInTheDocument();
const CTAButton = screen.getByRole('button', { name: /add correlation/i });
expect(CTAButton).toBeInTheDocument();
fireEvent.click(CTAButton);
// form's submit button
expect(screen.getByRole('button', { name: /add$/i })).toBeInTheDocument();
});
it('correctly adds correlations', async () => {
const CTAButton = screen.getByRole('button', { name: /add correlation/i });
expect(CTAButton).toBeInTheDocument();
// there's no table in the page, as we are adding the first correlation
expect(screen.queryByRole('table')).not.toBeInTheDocument();
fireEvent.click(CTAButton);
fireEvent.change(screen.getByRole('textbox', { name: /label/i }), { target: { value: 'A Label' } });
fireEvent.change(screen.getByRole('textbox', { name: /description/i }), { target: { value: 'A Description' } });
// set source datasource picker value
fireEvent.keyDown(screen.getByLabelText(/^source$/i), { keyCode: 40 });
fireEvent.click(screen.getByText('loki'));
// set target datasource picker value
fireEvent.keyDown(screen.getByLabelText(/^target$/i), { keyCode: 40 });
fireEvent.click(screen.getByText('prometheus'));
fireEvent.click(screen.getByRole('button', { name: /add$/i }));
// Waits for the form to be removed, meaning the correlation got successfully saved
await waitFor(() => {
expect(screen.queryByRole('button', { name: /add$/i })).not.toBeInTheDocument();
});
// the table showing correlations should have appeared
expect(screen.getByRole('table')).toBeInTheDocument();
});
});
describe('With correlations', () => {
beforeEach(async () => {
await renderWithContext(
{
loki: mockDataSource(
{
uid: 'loki',
name: 'loki',
readOnly: false,
jsonData: {},
access: 'direct',
type: 'datasource',
},
{
logs: true,
}
),
prometheus: mockDataSource(
{
uid: 'prometheus',
name: 'prometheus',
readOnly: false,
jsonData: {},
access: 'direct',
type: 'datasource',
},
{
metrics: true,
}
),
elastic: mockDataSource(
{
uid: 'elastic',
name: 'elastic',
readOnly: false,
jsonData: {},
access: 'direct',
type: 'datasource',
},
{
metrics: true,
logs: true,
}
),
},
[{ sourceUID: 'loki', targetUID: 'loki', uid: '1', label: 'Some label' }]
);
});
it('shows a table with correlations', async () => {
await renderWithContext();
expect(screen.getByRole('table')).toBeInTheDocument();
});
it('correctly adds correlations', async () => {
const addNewButton = screen.getByRole('button', { name: /add new/i });
expect(addNewButton).toBeInTheDocument();
fireEvent.click(addNewButton);
fireEvent.change(screen.getByRole('textbox', { name: /label/i }), { target: { value: 'A Label' } });
fireEvent.change(screen.getByRole('textbox', { name: /description/i }), { target: { value: 'A Description' } });
// set source datasource picker value
fireEvent.keyDown(screen.getByLabelText(/^source$/i), { keyCode: 40 });
fireEvent.click(screen.getByText('prometheus'));
// set target datasource picker value
fireEvent.keyDown(screen.getByLabelText(/^target$/i), { keyCode: 40 });
fireEvent.click(screen.getByText('elastic'));
fireEvent.click(screen.getByRole('button', { name: /add$/i }));
// the form should get removed after successful submissions
await waitFor(() => {
expect(screen.queryByRole('button', { name: /add$/i })).not.toBeInTheDocument();
});
});
it('correctly closes the form when clicking on the close icon', async () => {
const addNewButton = screen.getByRole('button', { name: /add new/i });
expect(addNewButton).toBeInTheDocument();
fireEvent.click(addNewButton);
fireEvent.click(screen.getByRole('button', { name: /close$/i }));
await waitFor(() => {
expect(screen.queryByRole('button', { name: /add$/i })).not.toBeInTheDocument();
});
});
it('correctly deletes correlations', async () => {
// A row with the correlation should exist
expect(screen.getByRole('cell', { name: /some label/i })).toBeInTheDocument();
const deleteButton = screen.getByRole('button', { name: /delete correlation/i });
expect(deleteButton).toBeInTheDocument();
fireEvent.click(deleteButton);
const confirmButton = screen.getByRole('button', { name: /delete$/i });
expect(confirmButton).toBeInTheDocument();
fireEvent.click(confirmButton);
await waitFor(() => {
expect(screen.queryByRole('cell', { name: /some label/i })).not.toBeInTheDocument();
});
});
it('correctly edits correlations', async () => {
const rowExpanderButton = screen.getByRole('button', { name: /toggle row expanded/i });
fireEvent.click(rowExpanderButton);
fireEvent.change(screen.getByRole('textbox', { name: /label/i }), { target: { value: 'edited label' } });
fireEvent.change(screen.getByRole('textbox', { name: /description/i }), {
target: { value: 'edited description' },
});
expect(screen.queryByRole('cell', { name: /edited label$/i })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /save$/i }));
await waitFor(() => {
expect(screen.queryByRole('cell', { name: /edited label$/i })).toBeInTheDocument();
});
});
});
describe('Read only correlations', () => {
const correlations = [{ sourceUID: 'loki', targetUID: 'loki', uid: '1', label: 'Some label' }];
beforeEach(async () => {
await renderWithContext(
{
loki: mockDataSource({
uid: 'loki',
name: 'loki',
readOnly: true,
jsonData: {},
access: 'direct',
meta: { info: { logos: {} } } as DataSourcePluginMeta,
type: 'datasource',
}),
},
correlations
);
});
it("doesn't render delete button", async () => {
// A row with the correlation should exist
expect(screen.getByRole('cell', { name: /some label/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /delete correlation/i })).not.toBeInTheDocument();
});
it('edit form is read only', async () => {
// A row with the correlation should exist
const rowExpanderButton = screen.getByRole('button', { name: /toggle row expanded/i });
fireEvent.click(rowExpanderButton);
// form elements should be readonly
const labelInput = screen.getByRole('textbox', { name: /label/i });
expect(labelInput).toBeInTheDocument();
expect(labelInput).toHaveAttribute('readonly');
const descriptionInput = screen.getByRole('textbox', { name: /description/i });
expect(descriptionInput).toBeInTheDocument();
expect(descriptionInput).toHaveAttribute('readonly');
// we don't expect the save button to be rendered
expect(screen.queryByRole('button', { name: 'save' })).not.toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,215 @@
import { css } from '@emotion/css';
import { negate } from 'lodash';
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { CellProps, SortByFn } from 'react-table';
import { GrafanaTheme2 } from '@grafana/data';
import { Badge, Button, DeleteButton, HorizontalGroup, LoadingPlaceholder, useStyles2, Alert } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { contextSrv } from 'app/core/core';
import { useNavModel } from 'app/core/hooks/useNavModel';
import { AccessControlAction } from 'app/types';
import { AddCorrelationForm } from './Forms/AddCorrelationForm';
import { EditCorrelationForm } from './Forms/EditCorrelationForm';
import { EmptyCorrelationsCTA } from './components/EmptyCorrelationsCTA';
import { Column, Table } from './components/Table';
import { RemoveCorrelationParams } from './types';
import { CorrelationData, useCorrelations } from './useCorrelations';
const sortDatasource: SortByFn<CorrelationData> = (a, b, column) =>
a.values[column].name.localeCompare(b.values[column].name);
const isSourceReadOnly = ({ source }: Pick<CorrelationData, 'source'>) => source.readOnly;
const loaderWrapper = css`
display: flex;
justify-content: center;
`;
export default function CorrelationsPage() {
const navModel = useNavModel('correlations');
const [isAdding, setIsAdding] = useState(false);
const { remove, get } = useCorrelations();
useEffect(() => {
get.execute();
// we only want to fetch data on first render
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const canWriteCorrelations = contextSrv.hasPermission(AccessControlAction.DataSourcesWrite);
const handleAdd = useCallback(() => {
get.execute();
setIsAdding(false);
}, [get]);
const handleUpdate = useCallback(() => {
get.execute();
}, [get]);
const handleRemove = useCallback<(params: RemoveCorrelationParams) => void>(
async (correlation) => {
await remove.execute(correlation);
get.execute();
},
[remove, get]
);
const RowActions = useCallback(
({
row: {
original: {
source: { uid: sourceUID, readOnly },
uid,
},
},
}: CellProps<CorrelationData, void>) =>
!readOnly && (
<DeleteButton
aria-label="delete correlation"
onConfirm={() => handleRemove({ sourceUID, uid })}
closeOnConfirm
/>
),
[handleRemove]
);
const columns = useMemo<Array<Column<CorrelationData>>>(
() => [
{
cell: InfoCell,
shrink: true,
visible: (data) => data.some(isSourceReadOnly),
},
{
id: 'source',
header: 'Source',
cell: DataSourceCell,
sortType: sortDatasource,
},
{
id: 'target',
header: 'Target',
cell: DataSourceCell,
sortType: sortDatasource,
},
{ id: 'label', header: 'Label', sortType: 'alphanumeric' },
{
cell: RowActions,
shrink: true,
visible: (data) => canWriteCorrelations && data.some(negate(isSourceReadOnly)),
},
],
[RowActions, canWriteCorrelations]
);
const data = useMemo(() => get.value, [get.value]);
const showEmptyListCTA = data?.length === 0 && !isAdding && (!get.error || get.error.status === 404);
return (
<Page navModel={navModel}>
<Page.Contents>
<div>
<HorizontalGroup justify="space-between">
<div>
<h4>Correlations</h4>
<p>Define how data living in different data sources relates to each other.</p>
</div>
{canWriteCorrelations && data?.length !== 0 && data !== undefined && !isAdding && (
<Button icon="plus" onClick={() => setIsAdding(true)}>
Add new
</Button>
)}
</HorizontalGroup>
</div>
{!data && get.loading && (
<div className={loaderWrapper}>
<LoadingPlaceholder text="loading..." />
</div>
)}
{showEmptyListCTA && <EmptyCorrelationsCTA onClick={() => setIsAdding(true)} />}
{
// This error is not actionable, it'd be nice to have a recovery button
get.error && get.error.status !== 404 && (
<Alert severity="error" title="Error fetching correlation data" topSpacing={2}>
<HorizontalGroup>
{get.error.data.message ||
'An unknown error occurred while fetching correlation data. Please try again.'}
</HorizontalGroup>
</Alert>
)
}
{isAdding && <AddCorrelationForm onClose={() => setIsAdding(false)} onCreated={handleAdd} />}
{data && data.length >= 1 && (
<Table
renderExpandedRow={({ target, source, ...correlation }) => (
<EditCorrelationForm
defaultValues={{ sourceUID: source.uid, ...correlation }}
onUpdated={handleUpdate}
readOnly={isSourceReadOnly({ source }) || !canWriteCorrelations}
/>
)}
columns={columns}
data={data}
getRowId={(correlation) => `${correlation.source.uid}-${correlation.uid}`}
/>
)}
</Page.Contents>
</Page>
);
}
const getDatasourceCellStyles = (theme: GrafanaTheme2) => ({
root: css`
display: flex;
align-items: center;
`,
dsLogo: css`
margin-right: ${theme.spacing()};
height: 16px;
width: 16px;
`,
});
const DataSourceCell = memo(
function DataSourceCell({
cell: { value },
}: CellProps<CorrelationData, CorrelationData['source'] | CorrelationData['target']>) {
const styles = useStyles2(getDatasourceCellStyles);
return (
<span className={styles.root}>
<img src={value.meta.info.logos.small} className={styles.dsLogo} />
{value.name}
</span>
);
},
({ cell: { value } }, { cell: { value: prevValue } }) => {
return value.type === prevValue.type && value.name === prevValue.name;
}
);
const noWrap = css`
white-space: nowrap;
`;
const InfoCell = memo(
function InfoCell({ ...props }: CellProps<CorrelationData, void>) {
const readOnly = props.row.original.source.readOnly;
if (readOnly) {
return <Badge text="Read only" color="purple" className={noWrap} />;
} else {
return null;
}
},
(props, prevProps) => props.row.original.source.readOnly === prevProps.row.original.source.readOnly
);

View File

@ -0,0 +1,123 @@
import { css } from '@emotion/css';
import React, { useCallback } from 'react';
import { Controller } from 'react-hook-form';
import { DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data';
import { DataSourcePicker } from '@grafana/runtime';
import { Button, Field, HorizontalGroup, PanelContainer, useStyles2 } from '@grafana/ui';
import { CloseButton } from 'app/core/components/CloseButton/CloseButton';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { useCorrelations } from '../useCorrelations';
import { CorrelationDetailsFormPart } from './CorrelationDetailsFormPart';
import { FormDTO } from './types';
import { useCorrelationForm } from './useCorrelationForm';
const getStyles = (theme: GrafanaTheme2) => ({
panelContainer: css`
position: relative;
padding: ${theme.spacing(1)};
margin-bottom: ${theme.spacing(2)};
`,
linksToContainer: css`
flex-grow: 1;
/* This is the width of the textarea minus the sum of the label&description fields,
* so that this element takes exactly the remaining space and the inputs will be
* nicely aligned with the textarea
**/
max-width: ${theme.spacing(80 - 64)};
margin-top: ${theme.spacing(3)};
text-align: right;
padding-right: ${theme.spacing(1)};
`,
// we can't use HorizontalGroup because it wraps elements in divs and sets margins on them
horizontalGroup: css`
display: flex;
`,
});
interface Props {
onClose: () => void;
onCreated: () => void;
}
const withDsUID = (fn: Function) => (ds: DataSourceInstanceSettings) => fn(ds.uid);
export const AddCorrelationForm = ({ onClose, onCreated }: Props) => {
const styles = useStyles2(getStyles);
const { create } = useCorrelations();
const onSubmit = useCallback(
async (correlation) => {
await create.execute(correlation);
onCreated();
},
[create, onCreated]
);
const { control, handleSubmit, register, errors } = useCorrelationForm<FormDTO>({ onSubmit });
return (
<PanelContainer className={styles.panelContainer}>
<CloseButton onClick={onClose} />
<form onSubmit={handleSubmit}>
<div className={styles.horizontalGroup}>
<Controller
control={control}
name="sourceUID"
rules={{
required: { value: true, message: 'This field is required.' },
validate: {
writable: (uid: string) =>
!getDatasourceSrv().getInstanceSettings(uid)?.readOnly || "Source can't be a read-only data source.",
},
}}
render={({ field: { onChange, value } }) => (
<Field label="Source" htmlFor="source" invalid={!!errors.sourceUID} error={errors.sourceUID?.message}>
<DataSourcePicker
onChange={withDsUID(onChange)}
noDefault
current={value}
inputId="source"
width={32}
/>
</Field>
)}
/>
<div className={styles.linksToContainer}>Links to</div>
<Controller
control={control}
name="targetUID"
rules={{ required: { value: true, message: 'This field is required.' } }}
render={({ field: { onChange, value } }) => (
<Field label="Target" htmlFor="target" invalid={!!errors.targetUID} error={errors.targetUID?.message}>
<DataSourcePicker
onChange={withDsUID(onChange)}
noDefault
current={value}
inputId="target"
width={32}
/>
</Field>
)}
/>
</div>
<CorrelationDetailsFormPart register={register} />
<HorizontalGroup justify="flex-end">
<Button
variant="primary"
icon={create.loading ? 'fa fa-spinner' : 'plus'}
type="submit"
disabled={create.loading}
>
Add
</Button>
</HorizontalGroup>
</form>
</PanelContainer>
);
};

View File

@ -0,0 +1,59 @@
import { css, cx } from '@emotion/css';
import React from 'react';
import { RegisterOptions, UseFormRegisterReturn } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data';
import { Field, Input, TextArea, useStyles2 } from '@grafana/ui';
import { EditFormDTO } from './types';
const getInputId = (inputName: string, correlation?: EditFormDTO) => {
if (!correlation) {
return inputName;
}
return `${inputName}_${correlation.sourceUID}-${correlation.uid}`;
};
const getStyles = (theme: GrafanaTheme2) => ({
marginless: css`
margin: 0;
`,
label: css`
max-width: ${theme.spacing(32)};
`,
description: css`
max-width: ${theme.spacing(80)};
`,
});
interface Props {
register: (path: 'label' | 'description', options?: RegisterOptions) => UseFormRegisterReturn;
readOnly?: boolean;
correlation?: EditFormDTO;
}
export function CorrelationDetailsFormPart({ register, readOnly = false, correlation }: Props) {
const styles = useStyles2(getStyles);
return (
<>
<Field label="Label" className={styles.label}>
<Input
id={getInputId('label', correlation)}
{...register('label')}
readOnly={readOnly}
placeholder="i.e. Tempo traces"
/>
</Field>
<Field
label="Description"
// the Field component automatically adds margin to itself, so we are forced to workaround it by overriding its styles
className={cx(readOnly && styles.marginless, styles.description)}
>
<TextArea id={getInputId('description', correlation)} {...register('description')} readOnly={readOnly} />
</Field>
</>
);
}

View File

@ -0,0 +1,50 @@
import React, { useCallback } from 'react';
import { Button, HorizontalGroup } from '@grafana/ui';
import { useCorrelations } from '../useCorrelations';
import { CorrelationDetailsFormPart } from './CorrelationDetailsFormPart';
import { EditFormDTO } from './types';
import { useCorrelationForm } from './useCorrelationForm';
interface Props {
onUpdated: () => void;
defaultValues: EditFormDTO;
readOnly?: boolean;
}
export const EditCorrelationForm = ({ onUpdated, defaultValues, readOnly = false }: Props) => {
const { update } = useCorrelations();
const onSubmit = useCallback(
async (correlation) => {
await update.execute(correlation);
onUpdated();
},
[update, onUpdated]
);
const { handleSubmit, register } = useCorrelationForm<EditFormDTO>({ onSubmit, defaultValues });
return (
<form onSubmit={readOnly ? (e) => e.preventDefault() : handleSubmit}>
<input type="hidden" {...register('uid')} />
<input type="hidden" {...register('sourceUID')} />
<CorrelationDetailsFormPart register={register} readOnly={readOnly} correlation={defaultValues} />
{!readOnly && (
<HorizontalGroup justify="flex-end">
<Button
variant="primary"
icon={update.loading ? 'fa fa-spinner' : 'save'}
type="submit"
disabled={update.loading}
>
Save
</Button>
</HorizontalGroup>
)}
</form>
);
};

View File

@ -0,0 +1,11 @@
import { Correlation } from '../types';
export interface FormDTO {
sourceUID: string;
targetUID: string;
label: string;
description: string;
}
type FormDTOWithoutTarget = Omit<FormDTO, 'targetUID'>;
export type EditFormDTO = Partial<FormDTOWithoutTarget> & Pick<FormDTO, 'sourceUID'> & { uid: Correlation['uid'] };

View File

@ -0,0 +1,18 @@
import { DeepPartial, SubmitHandler, UnpackNestedValue, useForm } from 'react-hook-form';
interface UseCorrelationFormOptions<T> {
onSubmit: SubmitHandler<T>;
defaultValues?: UnpackNestedValue<DeepPartial<T>>;
}
export const useCorrelationForm = <T>({ onSubmit, defaultValues }: UseCorrelationFormOptions<T>) => {
const {
handleSubmit: submit,
control,
register,
formState: { errors },
} = useForm<T>({ defaultValues });
const handleSubmit = submit(onSubmit);
return { control, handleSubmit, register, errors };
};

View File

@ -0,0 +1,20 @@
import React from 'react';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
interface Props {
onClick?: () => void;
}
export const EmptyCorrelationsCTA = ({ onClick }: Props) => {
// TODO: if there are no datasources show a different message
return (
<EmptyListCTA
title="You haven't defined any correlation yet."
buttonIcon="gf-glue"
onClick={onClick}
buttonTitle="Add correlation"
proTip="you can also define correlations via datasource provisioning"
/>
);
};

View File

@ -0,0 +1,22 @@
import { css } from '@emotion/css';
import React from 'react';
import { CellProps } from 'react-table';
import { IconButton } from '@grafana/ui';
const expanderContainerStyles = css`
display: flex;
align-items: center;
height: 100%;
`;
export const ExpanderCell = ({ row }: CellProps<object, void>) => (
<div className={expanderContainerStyles}>
<IconButton
// @ts-expect-error react-table doesn't ship with useExpanded types and we can't use declaration merging without affecting the table viz
name={row.isExpanded ? 'angle-down' : 'angle-right'}
// @ts-expect-error same as the line above
{...row.getToggleRowExpandedProps({})}
/>
</div>
);

View File

@ -0,0 +1,161 @@
import { cx, css } from '@emotion/css';
import React, { useMemo, Fragment, ReactNode } from 'react';
import {
CellProps,
SortByFn,
useExpanded,
useSortBy,
useTable,
DefaultSortTypes,
TableOptions,
IdType,
} from 'react-table';
import { GrafanaTheme2 } from '@grafana/data';
import { Icon, useStyles2 } from '@grafana/ui';
import { isTruthy } from 'app/core/utils/types';
import { EXPANDER_CELL_ID, getColumns } from './utils';
const getStyles = (theme: GrafanaTheme2) => ({
table: css`
border-radius: ${theme.shape.borderRadius()};
border: solid 1px ${theme.colors.border.weak};
background-color: ${theme.colors.background.secondary};
width: 100%;
td,
th {
padding: ${theme.spacing(1)};
min-width: ${theme.spacing(3)};
}
`,
evenRow: css`
background: ${theme.colors.background.primary};
`,
shrink: css`
width: 0%;
`,
});
export interface Column<TableData extends object> {
/**
* ID of the column.
* Set this to the matching object key of your data or `undefined` if the column doesn't have any associated data with it.
* This must be unique among all other columns.
*/
id?: IdType<TableData>;
cell?: (props: CellProps<TableData>) => ReactNode;
header?: (() => ReactNode | string) | string;
sortType?: DefaultSortTypes | SortByFn<TableData>;
shrink?: boolean;
visible?: (col: TableData[]) => boolean;
}
interface Props<TableData extends object> {
columns: Array<Column<TableData>>;
data: TableData[];
renderExpandedRow?: (row: TableData) => JSX.Element;
className?: string;
getRowId: TableOptions<TableData>['getRowId'];
}
/**
* non-viz table component.
* Will need most likely to be moved in @grafana/ui
*/
export function Table<TableData extends object>({
data,
className,
columns,
renderExpandedRow,
getRowId,
}: Props<TableData>) {
const styles = useStyles2(getStyles);
const tableColumns = useMemo(() => {
const cols = getColumns<TableData>(columns);
return cols;
}, [columns]);
const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = useTable<TableData>(
{
columns: tableColumns,
data,
autoResetExpanded: false,
autoResetSortBy: false,
getRowId,
initialState: {
hiddenColumns: [
!renderExpandedRow && EXPANDER_CELL_ID,
...tableColumns
.filter((col) => !(col.visible?.(data) ?? true))
.map((c) => c.id)
.filter(isTruthy),
].filter(isTruthy),
},
},
useSortBy,
useExpanded
);
// This should be called only for rows thar we'd want to actually render, which is all at this stage.
// We may want to revisit this if we decide to add pagination and/or virtualized tables.
rows.forEach(prepareRow);
return (
<table {...getTableProps()} className={cx(styles.table, className)}>
<thead>
{headerGroups.map((headerGroup) => {
const { key, ...headerRowProps } = headerGroup.getHeaderGroupProps();
return (
<tr key={key} {...headerRowProps}>
{headerGroup.headers.map((column) => {
// TODO: if the column is a function, it should also provide an accessible name as a string to be used a the column title in getSortByToggleProps
const { key, ...headerCellProps } = column.getHeaderProps(
column.canSort ? column.getSortByToggleProps() : undefined
);
return (
<th key={key} className={cx(column.width === 0 && styles.shrink)} {...headerCellProps}>
{column.render('Header')}
{column.isSorted && <Icon name={column.isSortedDesc ? 'angle-down' : 'angle-up'} />}
</th>
);
})}
</tr>
);
})}
</thead>
<tbody {...getTableBodyProps()}>
{rows.map((row, rowIndex) => {
const className = cx(rowIndex % 2 === 0 && styles.evenRow);
const { key, ...otherRowProps } = row.getRowProps();
return (
<Fragment key={key}>
<tr className={className} {...otherRowProps}>
{row.cells.map((cell) => {
const { key, ...otherCellProps } = cell.getCellProps();
return (
<td key={key} {...otherCellProps}>
{cell.render('Cell')}
</td>
);
})}
</tr>
{
// @ts-expect-error react-table doesn't ship with useExpanded types and we can't use declaration merging without affecting the table viz
row.isExpanded && renderExpandedRow && (
<tr className={className} {...otherRowProps}>
<td colSpan={row.cells.length}>{renderExpandedRow(row.original)}</td>
</tr>
)
}
</Fragment>
);
})}
</tbody>
</table>
);
}

View File

@ -0,0 +1,36 @@
import { uniqueId } from 'lodash';
import { Column as RTColumn } from 'react-table';
import { ExpanderCell } from './ExpanderCell';
import { Column } from '.';
export const EXPANDER_CELL_ID = '__expander';
type InternalColumn<T extends object> = RTColumn<T> & {
visible?: (data: T[]) => boolean;
};
// Returns the columns in a "react-table" acceptable format
export function getColumns<K extends object>(columns: Array<Column<K>>): Array<InternalColumn<K>> {
return [
{
id: EXPANDER_CELL_ID,
Cell: ExpanderCell,
disableSortBy: true,
width: 0,
},
// @ts-expect-error react-table expects each column key(id) to have data associated with it and therefore complains about
// column.id being possibly undefined and not keyof T (where T is the data object)
// We do not want to be that strict as we simply pass undefined to cells that do not have data associated with them.
...columns.map((column) => ({
Header: column.header || (() => null),
accessor: column.id || uniqueId(),
sortType: column.sortType || 'alphanumeric',
disableSortBy: !Boolean(column.sortType),
width: column.shrink ? 0 : undefined,
visible: column.visible,
...(column.cell && { Cell: column.cell }),
})),
];
}

View File

@ -0,0 +1,17 @@
export interface AddCorrelationResponse {
correlation: Correlation;
}
export type GetCorrelationsResponse = Correlation[];
export interface Correlation {
uid: string;
sourceUID: string;
targetUID: string;
label?: string;
description?: string;
}
export type RemoveCorrelationParams = Pick<Correlation, 'sourceUID' | 'uid'>;
export type CreateCorrelationParams = Omit<Correlation, 'uid'>;
export type UpdateCorrelationParams = Omit<Correlation, 'targetUID'>;

View File

@ -0,0 +1,88 @@
import { useState } from 'react';
import { useAsyncFn } from 'react-use';
import { lastValueFrom } from 'rxjs';
import { DataSourceInstanceSettings } from '@grafana/data';
import { getDataSourceSrv, FetchResponse, FetchError } from '@grafana/runtime';
import { useGrafana } from 'app/core/context/GrafanaContext';
import { Correlation, CreateCorrelationParams, RemoveCorrelationParams, UpdateCorrelationParams } from './types';
export interface CorrelationData extends Omit<Correlation, 'sourceUID' | 'targetUID'> {
source: DataSourceInstanceSettings;
target: DataSourceInstanceSettings;
}
const toEnrichedCorrelationData = ({ sourceUID, targetUID, ...correlation }: Correlation): CorrelationData => ({
...correlation,
source: getDataSourceSrv().getInstanceSettings(sourceUID)!,
target: getDataSourceSrv().getInstanceSettings(targetUID)!,
});
const toEnrichedCorrelationsData = (correlations: Correlation[]) => correlations.map(toEnrichedCorrelationData);
function getData<T>(response: FetchResponse<T>) {
return response.data;
}
/**
* hook for managing correlations data.
* TODO: ideally this hook shouldn't have any side effect like showing notifications on error
* and let consumers handle them. It works nicely with the correlations settings page, but when we'll
* expose this we'll have to remove those side effects.
*/
export const useCorrelations = () => {
const { backend } = useGrafana();
const [error, setError] = useState<FetchError | null>(null);
const [getInfo, get] = useAsyncFn<() => Promise<CorrelationData[]>>(
() =>
lastValueFrom(
backend.fetch<Correlation[]>({ url: '/api/datasources/correlations', method: 'GET', showErrorAlert: false })
)
.then(getData, (e) => {
setError(e);
return [];
})
.then(toEnrichedCorrelationsData),
[backend]
);
const [createInfo, create] = useAsyncFn<(params: CreateCorrelationParams) => Promise<CorrelationData>>(
({ sourceUID, ...correlation }) =>
backend.post(`/api/datasources/uid/${sourceUID}/correlations`, correlation).then(toEnrichedCorrelationData),
[backend]
);
const [removeInfo, remove] = useAsyncFn<(params: RemoveCorrelationParams) => Promise<void>>(
({ sourceUID, uid }) => backend.delete(`/api/datasources/uid/${sourceUID}/correlations/${uid}`),
[backend]
);
const [updateInfo, update] = useAsyncFn<(params: UpdateCorrelationParams) => Promise<CorrelationData>>(
({ sourceUID, uid, ...correlation }) =>
backend
.patch(`/api/datasources/uid/${sourceUID}/correlations/${uid}`, correlation)
.then(toEnrichedCorrelationData),
[backend]
);
return {
create: {
execute: create,
...createInfo,
},
update: {
execute: update,
...updateInfo,
},
get: {
execute: get,
...getInfo,
error,
},
remove: {
execute: remove,
...removeInfo,
},
};
};

View File

@ -29,6 +29,7 @@ export class PublicDashboardDataSource extends DataSourceApi<any> {
uid: PublicDashboardDataSource.resolveUid(datasource),
jsonData: {},
access: 'proxy',
readOnly: true,
});
this.interval = '1min';

View File

@ -128,6 +128,7 @@ function makeDatasourceSetup({ name = 'loki', id = 1 }: { name?: string; id?: nu
meta,
access: 'proxy',
jsonData: {},
readOnly: false,
},
api: {
components: {

View File

@ -94,6 +94,7 @@ export const instanceSettings: DataSourceInstanceSettings = {
},
},
jsonData: {},
readOnly: true,
};
export const dataSource = new ExpressionDatasourceApi(instanceSettings);

View File

@ -9,5 +9,6 @@ export function getDataSourceInstanceSetting(name: string, meta: DataSourcePlugi
meta,
access: 'proxy',
jsonData: {} as unknown as DataSourceJsonData,
readOnly: false,
};
}

View File

@ -13,6 +13,7 @@ export const createMockInstanceSetttings = (
access: 'proxy',
meta: {} as DataSourcePluginMeta,
name: 'stackdriver',
readOnly: false,
jsonData: {
authenticationType: GoogleAuthType.JWT,

View File

@ -77,4 +77,5 @@ export const newMockDatasource = () =>
links: [],
},
},
readOnly: false,
});

View File

@ -3,11 +3,10 @@ import { valid } from 'semver';
import { DataSourceSettings, SelectableValue } from '@grafana/data';
import { FieldSet, InlineField, Input, Select, InlineSwitch } from '@grafana/ui';
import { isTruthy } from 'app/core/utils/types';
import { ElasticsearchOptions, Interval } from '../types';
import { isTruthy } from './utils';
const indexPatternTypes: Array<SelectableValue<'none' | Interval>> = [
{ label: 'No pattern', value: 'none' },
{ label: 'Hourly', value: 'Hourly', example: '[logstash-]YYYY.MM.DD.HH' },

View File

@ -39,7 +39,3 @@ export const isValidOptions = (options: DataSourceSettings<ElasticsearchOptions,
options.jsonData.logLevelField !== undefined
);
};
type Truthy<T> = T extends false | '' | 0 | null | undefined ? never : T;
export const isTruthy = <T>(value: T): value is Truthy<T> => Boolean(value);

View File

@ -116,6 +116,7 @@ function getTestContext({
url: ELASTICSEARCH_MOCK_URL,
database,
jsonData,
readOnly: false,
};
const ds = new ElasticDatasource(instanceSettings, templateSrv);

View File

@ -10,6 +10,7 @@ export const createMockInstanceSetttings = (): AzureDataSourceInstanceSettings =
access: 'proxy',
meta: {} as DataSourcePluginMeta,
name: 'azure',
readOnly: false,
jsonData: {
cloudName: 'azuremonitor',

View File

@ -189,6 +189,7 @@ const defaultSettings: DataSourceInstanceSettings<JaegerJsonData> = {
enabled: true,
},
},
readOnly: false,
};
const defaultQuery: DataQueryRequest<JaegerQuery> = {

View File

@ -334,6 +334,7 @@ const defaultSettings: DataSourceInstanceSettings<JaegerJsonData> = {
enabled: true,
},
},
readOnly: false,
};
const defaultQuery: DataQueryRequest<JaegerQuery> = {

View File

@ -25,6 +25,7 @@ describe('LokiQueryBuilderContainer', () => {
url: '',
jsonData: {},
meta: {} as DataSourcePluginMeta,
readOnly: false,
},
undefined,
undefined

View File

@ -46,6 +46,7 @@ const datasource = new LokiDatasource(
url: '',
jsonData: {},
meta: {} as DataSourcePluginMeta,
readOnly: false,
},
undefined,
undefined

View File

@ -85,6 +85,7 @@ const createProps = (
url: '',
jsonData: {},
meta: {} as DataSourcePluginMeta,
readOnly: false,
},
undefined,
undefined

View File

@ -56,6 +56,7 @@ const defaultProps = {
url: '',
jsonData: {},
meta: {} as any,
readOnly: false,
},
undefined,
undefined,

View File

@ -691,6 +691,7 @@ const defaultSettings: DataSourceInstanceSettings<TempoJsonData> = {
enabled: true,
},
},
readOnly: false,
};
const rateMetric = new MutableDataFrame({

View File

@ -31,6 +31,7 @@ const defaultSettings: DataSourceInstanceSettings = {
module: '',
baseUrl: '',
},
readOnly: false,
jsonData: {},
};

View File

@ -91,4 +91,5 @@ const defaultSettings: DataSourceInstanceSettings = {
meta: {} as any,
jsonData: {},
access: 'proxy',
readOnly: false,
};

View File

@ -117,6 +117,12 @@ export function getAppRoutes(): RouteDescriptor[] {
() => import(/* webpackChunkName: "NewDataSourcePage"*/ '../features/datasources/pages/NewDataSourcePage')
),
},
{
path: '/datasources/correlations',
component: SafeDynamicImport(
() => import(/* webpackChunkName: "CorrelationsPage" */ 'app/features/correlations/CorrelationsPage')
),
},
{
path: '/dashboards',
component: SafeDynamicImport(

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 14.87">
<path d="M9.8 13.1a3.286 3.286 0 0 0 4.48 1.33c1.6-.89 2.19-2.91 1.31-4.53a3.286 3.286 0 0 0-4.48-1.33c-1.6.89-2.19 2.91-1.31 4.53ZM9.63.45C8.05-.47 6.03.08 5.12 1.67c-.46.8-.55 1.71-.33 2.53.68 2.52.24 3.62-2.34 4.1-.82.22-1.55.76-2 1.56-.91 1.59-.37 3.64 1.21 4.56 1.58.92 3.6.37 4.51-1.22.46-.8.55-1.71.33-2.53-.76-2.36-.32-3.55 2.34-4.1.82-.22 1.55-.76 2-1.56.91-1.6.37-3.64-1.21-4.56Z"/>
</svg>

After

Width:  |  Height:  |  Size: 465 B