The open and composable observability and data visualization platform. Visualize metrics, logs, and traces from multiple sources like Prometheus, Loki, Elasticsearch, InfluxDB, Postgres and many more.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
grafana/public/app/features/correlations/CorrelationsPage.tsx

303 lines
9.0 KiB

import { css } from '@emotion/css';
import { negate } from 'lodash';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { isFetchError, reportInteraction } from '@grafana/runtime';
import {
Badge,
Button,
DeleteButton,
LoadingPlaceholder,
useStyles2,
Alert,
InteractiveTable,
type Column,
type CellProps,
type SortByFn,
Pagination,
Icon,
} 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 { Trans, t } from 'app/core/internationalization';
import { AccessControlAction } from 'app/types';
import { AddCorrelationForm } from './Forms/AddCorrelationForm';
import { EditCorrelationForm } from './Forms/EditCorrelationForm';
import { EmptyCorrelationsCTA } from './components/EmptyCorrelationsCTA';
import type { 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 isCorrelationsReadOnly = (correlation: CorrelationData) => correlation.provisioned;
const loaderWrapper = css`
display: flex;
justify-content: center;
`;
export default function CorrelationsPage() {
const navModel = useNavModel('correlations');
const [isAdding, setIsAddingValue] = useState(false);
const page = useRef(1);
const setIsAdding = (value: boolean) => {
setIsAddingValue(value);
if (value) {
reportInteraction('grafana_correlations_adding_started');
}
};
const {
remove,
get: { execute: fetchCorrelations, ...get },
} = useCorrelations();
const canWriteCorrelations = contextSrv.hasPermission(AccessControlAction.DataSourcesWrite);
const handleAdded = useCallback(() => {
reportInteraction('grafana_correlations_added');
fetchCorrelations({ page: page.current });
setIsAdding(false);
}, [fetchCorrelations]);
const handleUpdated = useCallback(() => {
reportInteraction('grafana_correlations_edited');
fetchCorrelations({ page: page.current });
}, [fetchCorrelations]);
const handleDelete = useCallback(
async (params: RemoveCorrelationParams, isLastRow: boolean) => {
await remove.execute(params);
reportInteraction('grafana_correlations_deleted');
if (isLastRow) {
page.current--;
}
fetchCorrelations({ page: page.current });
},
[remove, fetchCorrelations]
);
useEffect(() => {
fetchCorrelations({ page: page.current });
}, [fetchCorrelations]);
const RowActions = useCallback(
({
row: {
index,
original: {
source: { uid: sourceUID },
provisioned,
uid,
},
},
}: CellProps<CorrelationData, void>) => {
return (
!provisioned && (
<DeleteButton
aria-label={t('correlations.list.delete', 'delete correlation')}
onConfirm={() =>
handleDelete({ sourceUID, uid }, page.current > 1 && index === 0 && data?.correlations.length === 1)
}
closeOnConfirm
/>
)
);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[handleDelete]
);
const columns = useMemo<Array<Column<CorrelationData>>>(
() => [
{
id: 'info',
cell: InfoCell,
disableGrow: true,
visible: (data) => data.some(isCorrelationsReadOnly),
},
{
id: 'source',
header: t('correlations.list.source', 'Source'),
cell: DataSourceCell,
sortType: sortDatasource,
},
{
id: 'target',
header: t('correlations.list.target', 'Target'),
cell: DataSourceCell,
sortType: sortDatasource,
},
{ id: 'label', header: t('correlations.list.label', 'Label'), sortType: 'alphanumeric' },
{
id: 'actions',
cell: RowActions,
disableGrow: true,
visible: (data) => canWriteCorrelations && data.some(negate(isCorrelationsReadOnly)),
},
],
[RowActions, canWriteCorrelations]
);
const data = useMemo(() => get.value, [get.value]);
const showEmptyListCTA = data?.correlations.length === 0 && !isAdding && !get.error;
const addButton = canWriteCorrelations && data?.correlations?.length !== 0 && data !== undefined && !isAdding && (
<Button icon="plus" onClick={() => setIsAdding(true)}>
<Trans i18nKey="correlations.add-new">Add new</Trans>
</Button>
);
return (
<Page
navModel={navModel}
subTitle={
<>
<Trans i18nKey="correlations.sub-title">
Define how data living in different data sources relates to each other. Read more in the{' '}
<a
href="https://grafana.com/docs/grafana/next/administration/correlations/"
target="_blank"
rel="noreferrer"
>
documentation
<Icon name="external-link-alt" />
</a>
</Trans>
</>
}
actions={addButton}
>
<Page.Contents>
<div>
{!data && get.loading && (
<div className={loaderWrapper}>
<LoadingPlaceholder text={t('correlations.list.loading', 'loading...')} />
</div>
)}
{showEmptyListCTA && (
<EmptyCorrelationsCTA canWriteCorrelations={canWriteCorrelations} onClick={() => setIsAdding(true)} />
)}
{
// This error is not actionable, it'd be nice to have a recovery button
get.error && (
<Alert
severity="error"
title={t('correlations.alert.title', 'Error fetching correlation data')}
topSpacing={2}
>
{(isFetchError(get.error) && get.error.data?.message) ||
t(
'correlations.alert.error-message',
'An unknown error occurred while fetching correlation data. Please try again.'
)}
</Alert>
)
}
{isAdding && <AddCorrelationForm onClose={() => setIsAdding(false)} onCreated={handleAdded} />}
{data && data.correlations.length >= 1 && (
<>
<InteractiveTable
renderExpandedRow={(correlation) => (
<ExpendedRow
correlation={correlation}
onUpdated={handleUpdated}
readOnly={isCorrelationsReadOnly(correlation) || !canWriteCorrelations}
/>
)}
columns={columns}
data={data.correlations}
getRowId={(correlation) => `${correlation.source.uid}-${correlation.uid}`}
/>
<Pagination
currentPage={page.current}
numberOfPages={Math.ceil(data.totalCount / data.limit)}
onNavigate={(toPage: number) => {
fetchCorrelations({ page: (page.current = toPage) });
}}
/>
</>
)}
</div>
</Page.Contents>
</Page>
);
}
interface ExpandedRowProps {
correlation: CorrelationData;
readOnly: boolean;
onUpdated: () => void;
}
function ExpendedRow({ correlation: { source, target, ...correlation }, readOnly, onUpdated }: ExpandedRowProps) {
useEffect(
() => reportInteraction('grafana_correlations_details_expanded'),
// we only want to fire this on first render
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
return (
<EditCorrelationForm
correlation={{ ...correlation, sourceUID: source.uid, targetUID: target.uid }}
onUpdated={onUpdated}
readOnly={readOnly}
/>
);
}
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} alt="" 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.provisioned;
if (readOnly) {
return <Badge text={t('correlations.list.read-only', 'Read only')} color="purple" className={noWrap} />;
} else {
return null;
}
},
(props, prevProps) => props.row.original.source.readOnly === prevProps.row.original.source.readOnly
);