mirror of https://github.com/grafana/grafana
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 iconpull/54290/head
parent
37fde2eec6
commit
c68d7f1e35
@ -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) |
||||
) |
||||
@ -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); |
||||
@ -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(); |
||||
}); |
||||
}); |
||||
}); |
||||
@ -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 |
||||
); |
||||
@ -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> |
||||
); |
||||
}; |
||||
@ -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'] }; |
||||
@ -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> |
||||
); |
||||
@ -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> |
||||
); |
||||
} |
||||
@ -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 }), |
||||
})), |
||||
]; |
||||
} |
||||
@ -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'>; |
||||
@ -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, |
||||
}, |
||||
}; |
||||
}; |
||||
|
After Width: | Height: | Size: 465 B |
Loading…
Reference in new issue