mirror of
https://github.com/grafana/grafana.git
synced 2025-02-20 11:48:34 -06:00
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:
parent
37fde2eec6
commit
c68d7f1e35
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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',
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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",
|
||||
|
@ -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"`
|
||||
|
11
pkg/services/correlations/accesscontrol.go
Normal file
11
pkg/services/correlations/accesscontrol.go
Normal 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)
|
||||
)
|
@ -20,6 +20,7 @@ describe('InputDatasource', () => {
|
||||
name: 'xxx',
|
||||
meta: {} as PluginMeta,
|
||||
access: 'proxy',
|
||||
readOnly: false,
|
||||
jsonData: {
|
||||
data,
|
||||
},
|
||||
|
@ -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' }),
|
||||
|
@ -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 },
|
||||
|
@ -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),
|
||||
|
3
public/app/core/utils/types.ts
Normal file
3
public/app/core/utils/types.ts
Normal 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);
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -136,6 +136,7 @@ const mockCloudRule = {
|
||||
meta: {} as PluginMeta,
|
||||
jsonData: {} as DataSourceJsonData,
|
||||
access: 'proxy',
|
||||
readOnly: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -59,6 +59,7 @@ export function mockDataSource<T extends DataSourceJsonData = DataSourceJsonData
|
||||
},
|
||||
...meta,
|
||||
} as any as DataSourcePluginMeta,
|
||||
readOnly: false,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
@ -55,6 +55,7 @@ describe('alertRuleToQueries', () => {
|
||||
access: 'proxy',
|
||||
meta: {} as PluginMeta,
|
||||
jsonData: {} as DataSourceJsonData,
|
||||
readOnly: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
412
public/app/features/correlations/CorrelationsPage.test.tsx
Normal file
412
public/app/features/correlations/CorrelationsPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
215
public/app/features/correlations/CorrelationsPage.tsx
Normal file
215
public/app/features/correlations/CorrelationsPage.tsx
Normal 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
|
||||
);
|
123
public/app/features/correlations/Forms/AddCorrelationForm.tsx
Normal file
123
public/app/features/correlations/Forms/AddCorrelationForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
};
|
11
public/app/features/correlations/Forms/types.ts
Normal file
11
public/app/features/correlations/Forms/types.ts
Normal 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'] };
|
18
public/app/features/correlations/Forms/useCorrelationForm.ts
Normal file
18
public/app/features/correlations/Forms/useCorrelationForm.ts
Normal 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 };
|
||||
};
|
@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
161
public/app/features/correlations/components/Table/index.tsx
Normal file
161
public/app/features/correlations/components/Table/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
36
public/app/features/correlations/components/Table/utils.ts
Normal file
36
public/app/features/correlations/components/Table/utils.ts
Normal 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 }),
|
||||
})),
|
||||
];
|
||||
}
|
17
public/app/features/correlations/types.ts
Normal file
17
public/app/features/correlations/types.ts
Normal 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'>;
|
88
public/app/features/correlations/useCorrelations.ts
Normal file
88
public/app/features/correlations/useCorrelations.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
@ -29,6 +29,7 @@ export class PublicDashboardDataSource extends DataSourceApi<any> {
|
||||
uid: PublicDashboardDataSource.resolveUid(datasource),
|
||||
jsonData: {},
|
||||
access: 'proxy',
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
this.interval = '1min';
|
||||
|
@ -128,6 +128,7 @@ function makeDatasourceSetup({ name = 'loki', id = 1 }: { name?: string; id?: nu
|
||||
meta,
|
||||
access: 'proxy',
|
||||
jsonData: {},
|
||||
readOnly: false,
|
||||
},
|
||||
api: {
|
||||
components: {
|
||||
|
@ -94,6 +94,7 @@ export const instanceSettings: DataSourceInstanceSettings = {
|
||||
},
|
||||
},
|
||||
jsonData: {},
|
||||
readOnly: true,
|
||||
};
|
||||
|
||||
export const dataSource = new ExpressionDatasourceApi(instanceSettings);
|
||||
|
@ -9,5 +9,6 @@ export function getDataSourceInstanceSetting(name: string, meta: DataSourcePlugi
|
||||
meta,
|
||||
access: 'proxy',
|
||||
jsonData: {} as unknown as DataSourceJsonData,
|
||||
readOnly: false,
|
||||
};
|
||||
}
|
||||
|
@ -13,6 +13,7 @@ export const createMockInstanceSetttings = (
|
||||
access: 'proxy',
|
||||
meta: {} as DataSourcePluginMeta,
|
||||
name: 'stackdriver',
|
||||
readOnly: false,
|
||||
|
||||
jsonData: {
|
||||
authenticationType: GoogleAuthType.JWT,
|
||||
|
@ -77,4 +77,5 @@ export const newMockDatasource = () =>
|
||||
links: [],
|
||||
},
|
||||
},
|
||||
readOnly: false,
|
||||
});
|
||||
|
@ -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' },
|
||||
|
@ -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);
|
||||
|
@ -116,6 +116,7 @@ function getTestContext({
|
||||
url: ELASTICSEARCH_MOCK_URL,
|
||||
database,
|
||||
jsonData,
|
||||
readOnly: false,
|
||||
};
|
||||
|
||||
const ds = new ElasticDatasource(instanceSettings, templateSrv);
|
||||
|
@ -10,6 +10,7 @@ export const createMockInstanceSetttings = (): AzureDataSourceInstanceSettings =
|
||||
access: 'proxy',
|
||||
meta: {} as DataSourcePluginMeta,
|
||||
name: 'azure',
|
||||
readOnly: false,
|
||||
|
||||
jsonData: {
|
||||
cloudName: 'azuremonitor',
|
||||
|
@ -189,6 +189,7 @@ const defaultSettings: DataSourceInstanceSettings<JaegerJsonData> = {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
readOnly: false,
|
||||
};
|
||||
|
||||
const defaultQuery: DataQueryRequest<JaegerQuery> = {
|
||||
|
@ -334,6 +334,7 @@ const defaultSettings: DataSourceInstanceSettings<JaegerJsonData> = {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
readOnly: false,
|
||||
};
|
||||
|
||||
const defaultQuery: DataQueryRequest<JaegerQuery> = {
|
||||
|
@ -25,6 +25,7 @@ describe('LokiQueryBuilderContainer', () => {
|
||||
url: '',
|
||||
jsonData: {},
|
||||
meta: {} as DataSourcePluginMeta,
|
||||
readOnly: false,
|
||||
},
|
||||
undefined,
|
||||
undefined
|
||||
|
@ -46,6 +46,7 @@ const datasource = new LokiDatasource(
|
||||
url: '',
|
||||
jsonData: {},
|
||||
meta: {} as DataSourcePluginMeta,
|
||||
readOnly: false,
|
||||
},
|
||||
undefined,
|
||||
undefined
|
||||
|
@ -85,6 +85,7 @@ const createProps = (
|
||||
url: '',
|
||||
jsonData: {},
|
||||
meta: {} as DataSourcePluginMeta,
|
||||
readOnly: false,
|
||||
},
|
||||
undefined,
|
||||
undefined
|
||||
|
@ -56,6 +56,7 @@ const defaultProps = {
|
||||
url: '',
|
||||
jsonData: {},
|
||||
meta: {} as any,
|
||||
readOnly: false,
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
|
@ -691,6 +691,7 @@ const defaultSettings: DataSourceInstanceSettings<TempoJsonData> = {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
readOnly: false,
|
||||
};
|
||||
|
||||
const rateMetric = new MutableDataFrame({
|
||||
|
@ -31,6 +31,7 @@ const defaultSettings: DataSourceInstanceSettings = {
|
||||
module: '',
|
||||
baseUrl: '',
|
||||
},
|
||||
readOnly: false,
|
||||
jsonData: {},
|
||||
};
|
||||
|
||||
|
@ -91,4 +91,5 @@ const defaultSettings: DataSourceInstanceSettings = {
|
||||
meta: {} as any,
|
||||
jsonData: {},
|
||||
access: 'proxy',
|
||||
readOnly: false,
|
||||
};
|
||||
|
@ -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(
|
||||
|
3
public/img/icons/custom/gf-glue.svg
Normal file
3
public/img/icons/custom/gf-glue.svg
Normal 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 |
Loading…
Reference in New Issue
Block a user