mirror of https://github.com/grafana/grafana.git
629 lines
21 KiB
TypeScript
629 lines
21 KiB
TypeScript
import { css, cx } from '@emotion/css';
|
|
import { partition } from 'lodash';
|
|
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
import { useAsync } from 'react-use';
|
|
|
|
import {
|
|
DataQueryResponse,
|
|
DataSourceWithLogsContextSupport,
|
|
GrafanaTheme2,
|
|
LogRowContextOptions,
|
|
LogRowContextQueryDirection,
|
|
LogRowModel,
|
|
LogsDedupStrategy,
|
|
LogsSortOrder,
|
|
dateTime,
|
|
TimeRange,
|
|
} from '@grafana/data';
|
|
import { config, reportInteraction } from '@grafana/runtime';
|
|
import { DataQuery, LoadingState, TimeZone } from '@grafana/schema';
|
|
import { Button, Modal, useTheme2 } from '@grafana/ui';
|
|
import store from 'app/core/store';
|
|
import { SETTINGS_KEYS } from 'app/features/explore/Logs/utils/logs';
|
|
import { splitOpen } from 'app/features/explore/state/main';
|
|
import { useDispatch } from 'app/types';
|
|
|
|
import { dataFrameToLogsModel } from '../../logsModel';
|
|
import { sortLogRows } from '../../utils';
|
|
import { LogRows } from '../LogRows';
|
|
|
|
import { LoadingIndicator } from './LoadingIndicator';
|
|
import { LogContextButtons } from './LogContextButtons';
|
|
import { Place } from './types';
|
|
|
|
const getStyles = (theme: GrafanaTheme2) => {
|
|
return {
|
|
modal: css`
|
|
width: 85vw;
|
|
${theme.breakpoints.down('md')} {
|
|
width: 100%;
|
|
}
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
`,
|
|
sticky: css`
|
|
position: sticky;
|
|
z-index: 1;
|
|
top: -1px;
|
|
bottom: -1px;
|
|
`,
|
|
entry: css`
|
|
& > td {
|
|
padding: ${theme.spacing(1)} 0 ${theme.spacing(1)} 0;
|
|
}
|
|
background: ${theme.colors.emphasize(theme.colors.background.secondary)};
|
|
|
|
& > table {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
& .log-row-menu {
|
|
margin-top: -6px;
|
|
}
|
|
`,
|
|
datasourceUi: css`
|
|
padding-bottom: ${theme.spacing(1.25)};
|
|
display: flex;
|
|
align-items: center;
|
|
`,
|
|
logRowGroups: css`
|
|
overflow: auto;
|
|
max-height: 75%;
|
|
align-self: stretch;
|
|
display: inline-block;
|
|
border: 1px solid ${theme.colors.border.weak};
|
|
border-radius: ${theme.shape.radius.default};
|
|
& > table {
|
|
min-width: 100%;
|
|
}
|
|
`,
|
|
flexColumn: css`
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 0 ${theme.spacing(3)} ${theme.spacing(3)} ${theme.spacing(3)};
|
|
`,
|
|
flexRow: css`
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
& > div:last-child {
|
|
margin-left: auto;
|
|
}
|
|
`,
|
|
noMarginBottom: css`
|
|
& > table {
|
|
margin-bottom: 0;
|
|
}
|
|
`,
|
|
hidden: css`
|
|
display: none;
|
|
`,
|
|
paddingTop: css`
|
|
padding-top: ${theme.spacing(1)};
|
|
`,
|
|
paddingBottom: css`
|
|
padding-bottom: ${theme.spacing(1)};
|
|
`,
|
|
link: css`
|
|
color: ${theme.colors.text.secondary};
|
|
font-size: ${theme.typography.bodySmall.fontSize};
|
|
:hover {
|
|
color: ${theme.colors.text.link};
|
|
}
|
|
`,
|
|
loadingCell: css`
|
|
position: sticky;
|
|
left: 50%;
|
|
display: inline-block;
|
|
transform: translateX(-50%);
|
|
`,
|
|
};
|
|
};
|
|
|
|
interface LogRowContextModalProps {
|
|
row: LogRowModel;
|
|
open: boolean;
|
|
timeZone: TimeZone;
|
|
onClose: () => void;
|
|
getRowContext: (row: LogRowModel, options: LogRowContextOptions) => Promise<DataQueryResponse>;
|
|
|
|
getRowContextQuery?: (row: LogRowModel, options?: LogRowContextOptions) => Promise<DataQuery | null>;
|
|
logsSortOrder: LogsSortOrder;
|
|
runContextQuery?: () => void;
|
|
getLogRowContextUi?: DataSourceWithLogsContextSupport['getLogRowContextUi'];
|
|
}
|
|
|
|
type Section = {
|
|
loadingState: LoadingState;
|
|
rows: LogRowModel[];
|
|
};
|
|
type Context = Record<Place, Section>;
|
|
|
|
const makeEmptyContext = (): Context => ({
|
|
above: { loadingState: LoadingState.NotStarted, rows: [] },
|
|
below: { loadingState: LoadingState.NotStarted, rows: [] },
|
|
});
|
|
|
|
const getLoadMoreDirection = (place: Place, sortOrder: LogsSortOrder): LogRowContextQueryDirection => {
|
|
if (place === 'above' && sortOrder === LogsSortOrder.Descending) {
|
|
return LogRowContextQueryDirection.Forward;
|
|
}
|
|
if (place === 'below' && sortOrder === LogsSortOrder.Ascending) {
|
|
return LogRowContextQueryDirection.Forward;
|
|
}
|
|
|
|
return LogRowContextQueryDirection.Backward;
|
|
};
|
|
|
|
type LoadCounter = Record<Place, number>;
|
|
|
|
const normalizeLogRowRefId = (row: LogRowModel, counter: LoadCounter): LogRowModel => {
|
|
// the datasoure plugins often create the context-query based on the row's dataframe's refId,
|
|
// by appending something to it. for example:
|
|
// - let's say the row's dataframe's refId is "query"
|
|
// - the datasource plugin will take "query" and append "-context" to it, so it becomes "query-context".
|
|
// - later we want to load even more lines, so we make a context query
|
|
// - the datasource plugin does the same transform again, but now the source is "query-context",
|
|
// so the new refId becomes "query-context-context"
|
|
// - next time it becomes "query-context-context-context", and so on.
|
|
// we do not want refIds to grow unbounded.
|
|
// to avoid this, we set the refId to a value that does not grow.
|
|
// on the other hand, the refId is also used in generating the row's UID, so it is useful
|
|
// when the refId is not always the exact same string, otherwise UID duplication can occur,
|
|
// which may cause problems.
|
|
// so we go with an approach where the refId always changes, but does not grow.
|
|
return {
|
|
...row,
|
|
dataFrame: {
|
|
...row.dataFrame,
|
|
refId: `context_${counter.above}_${counter.below}`,
|
|
},
|
|
};
|
|
};
|
|
|
|
const containsRow = (rows: LogRowModel[], row: LogRowModel) => {
|
|
return rows.some((r) => r.entry === row.entry && r.timeEpochNs === row.timeEpochNs);
|
|
};
|
|
|
|
const PAGE_SIZE = 100;
|
|
|
|
export const LogRowContextModal: React.FunctionComponent<LogRowContextModalProps> = ({
|
|
row,
|
|
open,
|
|
logsSortOrder,
|
|
timeZone,
|
|
getLogRowContextUi,
|
|
getRowContextQuery,
|
|
onClose,
|
|
getRowContext,
|
|
}) => {
|
|
const scrollElement = useRef<HTMLDivElement | null>(null);
|
|
const entryElement = useRef<HTMLTableRowElement | null>(null);
|
|
// We can not use `entryElement` to scroll to the right element because it's
|
|
// sticky. That's why we add another row and use this ref to scroll to that
|
|
// first.
|
|
const preEntryElement = useRef<HTMLTableRowElement | null>(null);
|
|
|
|
const prevScrollHeightRef = useRef<number | null>(null);
|
|
const prevClientHeightRef = useRef<number | null>(null);
|
|
|
|
const aboveLoadingElement = useRef<HTMLDivElement | null>(null);
|
|
const belowLoadingElement = useRef<HTMLDivElement | null>(null);
|
|
|
|
const loadCountRef = useRef<LoadCounter>({ above: 0, below: 0 });
|
|
|
|
const dispatch = useDispatch();
|
|
const theme = useTheme2();
|
|
const styles = getStyles(theme);
|
|
|
|
const [sticky, setSticky] = useState(true);
|
|
|
|
// we need to keep both the "above" and "below" rows
|
|
// in the same react-state, to be able to atomically change both
|
|
// at the same time.
|
|
// we create the `setSection` convenience function to adjust any
|
|
// part of it easily.
|
|
const [context, setContext] = useState<Context>(makeEmptyContext());
|
|
const setSection = (place: Place, fun: (s: Section) => Section) => {
|
|
setContext((c) => {
|
|
const newContext = { ...c };
|
|
newContext[place] = fun(c[place]);
|
|
return newContext;
|
|
});
|
|
};
|
|
|
|
// this is used to "cancel" the ongoing load-more requests.
|
|
// whenever we want to cancel them, we increment this number.
|
|
// and when those requests return, we check if the number
|
|
// is still the same as when we started. and if it is not the same,
|
|
// we ignore the results.
|
|
//
|
|
// best would be to literally cancel those requests,
|
|
// but right now there's no way with the current logs-context API.
|
|
const generationRef = useRef(1);
|
|
|
|
const [contextQuery, setContextQuery] = useState<DataQuery | null>(null);
|
|
const [wrapLines, setWrapLines] = useState(
|
|
store.getBool(SETTINGS_KEYS.logContextWrapLogMessage, store.getBool(SETTINGS_KEYS.wrapLogMessage, true))
|
|
);
|
|
const getFullTimeRange = useCallback(() => {
|
|
const { below, above } = context;
|
|
const allRows = sortLogRows([...below.rows, row, ...above.rows], LogsSortOrder.Ascending);
|
|
const fromMs = allRows[0].timeEpochMs;
|
|
let toMs = allRows[allRows.length - 1].timeEpochMs;
|
|
// In case we have a lot of logs and from and to have same millisecond
|
|
// we add 1 millisecond to toMs to make sure we have a range
|
|
if (fromMs === toMs) {
|
|
toMs += 1;
|
|
}
|
|
const from = dateTime(fromMs);
|
|
const to = dateTime(toMs);
|
|
|
|
const range: TimeRange = {
|
|
from,
|
|
to,
|
|
raw: {
|
|
from,
|
|
to,
|
|
},
|
|
};
|
|
return range;
|
|
}, [context, row]);
|
|
|
|
const updateContextQuery = useCallback(async () => {
|
|
const contextQuery = getRowContextQuery ? await getRowContextQuery(row) : null;
|
|
setContextQuery(contextQuery);
|
|
}, [row, getRowContextQuery]);
|
|
|
|
const updateResults = async () => {
|
|
await updateContextQuery();
|
|
setContext(makeEmptyContext());
|
|
loadCountRef.current = { above: 0, below: 0 };
|
|
generationRef.current += 1; // results from currently running loadMore calls will be ignored
|
|
};
|
|
|
|
const loadMore = async (place: Place, allRows: LogRowModel[]): Promise<LogRowModel[]> => {
|
|
loadCountRef.current[place] += 1;
|
|
const refRow = allRows.at(place === 'above' ? 0 : -1);
|
|
if (refRow == null) {
|
|
throw new Error('should never happen. the array always contains at least 1 item (the middle row)');
|
|
}
|
|
|
|
reportInteraction('grafana_explore_logs_log_context_load_more_called', {
|
|
datasourceType: refRow.datasourceType,
|
|
above: loadCountRef.current.above,
|
|
below: loadCountRef.current.below,
|
|
});
|
|
|
|
const direction = getLoadMoreDirection(place, logsSortOrder);
|
|
|
|
const result = await getRowContext(normalizeLogRowRefId(refRow, loadCountRef.current), {
|
|
limit: PAGE_SIZE,
|
|
direction,
|
|
});
|
|
const newRows = dataFrameToLogsModel(result.data).rows;
|
|
|
|
if (logsSortOrder === LogsSortOrder.Ascending) {
|
|
newRows.reverse();
|
|
}
|
|
|
|
const out = newRows.filter((r) => {
|
|
return !containsRow(allRows, r);
|
|
});
|
|
|
|
return out;
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
updateContextQuery();
|
|
}
|
|
}, [updateContextQuery, open]);
|
|
|
|
const [displayedFields, setDisplayedFields] = useState<string[]>([]);
|
|
|
|
const showField = (key: string) => {
|
|
const index = displayedFields.indexOf(key);
|
|
|
|
if (index === -1) {
|
|
setDisplayedFields([...displayedFields, key]);
|
|
}
|
|
};
|
|
|
|
const hideField = (key: string) => {
|
|
const index = displayedFields.indexOf(key);
|
|
|
|
if (index > -1) {
|
|
displayedFields.splice(index, 1);
|
|
setDisplayedFields([...displayedFields]);
|
|
}
|
|
};
|
|
|
|
const maybeLoadMore = async (place: Place) => {
|
|
const { below, above } = context;
|
|
const section = context[place];
|
|
if (section.loadingState === LoadingState.Loading) {
|
|
return;
|
|
}
|
|
|
|
setSection(place, (section) => ({
|
|
...section,
|
|
loadingState: LoadingState.Loading,
|
|
}));
|
|
|
|
const currentGen = generationRef.current;
|
|
try {
|
|
// we consider all the currently existing rows, even the original row,
|
|
// this way this array of rows will never be empty
|
|
const allRows = [...above.rows, row, ...below.rows];
|
|
|
|
const newRows = await loadMore(place, allRows);
|
|
const [older, newer] = partition(newRows, (newRow) => newRow.timeEpochNs > row.timeEpochNs);
|
|
const newAbove = logsSortOrder === LogsSortOrder.Ascending ? newer : older;
|
|
const newBelow = logsSortOrder === LogsSortOrder.Ascending ? older : newer;
|
|
|
|
if (currentGen === generationRef.current) {
|
|
setContext((c) => {
|
|
// we should only modify the row-arrays if necessary
|
|
const sortedNewAbove =
|
|
newAbove.length > 0 ? sortLogRows([...newAbove, ...c.above.rows], logsSortOrder) : c.above.rows;
|
|
const sortedNewBelow =
|
|
newBelow.length > 0 ? sortLogRows([...c.below.rows, ...newBelow], logsSortOrder) : c.below.rows;
|
|
return {
|
|
above: {
|
|
rows: sortedNewAbove,
|
|
loadingState:
|
|
place === 'above'
|
|
? newRows.length === 0
|
|
? LoadingState.Done
|
|
: LoadingState.NotStarted
|
|
: c.above.loadingState,
|
|
},
|
|
below: {
|
|
rows: sortedNewBelow,
|
|
loadingState:
|
|
place === 'below'
|
|
? newRows.length === 0
|
|
? LoadingState.Done
|
|
: LoadingState.NotStarted
|
|
: c.below.loadingState,
|
|
},
|
|
};
|
|
});
|
|
}
|
|
} catch {
|
|
setSection(place, (section) => ({
|
|
rows: section.rows,
|
|
loadingState: LoadingState.Error,
|
|
}));
|
|
}
|
|
};
|
|
|
|
const onScrollHit = async (entries: IntersectionObserverEntry[], observer: IntersectionObserver) => {
|
|
for (const entry of entries) {
|
|
// If the element is not intersecting, skip to the next one
|
|
if (!entry.isIntersecting) {
|
|
continue;
|
|
}
|
|
|
|
const targetElement = entry.target;
|
|
|
|
if (targetElement === aboveLoadingElement.current) {
|
|
maybeLoadMore('above');
|
|
} else if (targetElement === belowLoadingElement.current) {
|
|
maybeLoadMore('below');
|
|
}
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
const scroll = scrollElement.current;
|
|
const aboveElem = aboveLoadingElement.current;
|
|
const belowElem = belowLoadingElement.current;
|
|
|
|
if (scroll == null) {
|
|
// should not happen, but need to make typescript happy
|
|
return;
|
|
}
|
|
|
|
const observer = new IntersectionObserver(onScrollHit, { root: scroll });
|
|
|
|
if (aboveElem != null) {
|
|
observer.observe(aboveElem);
|
|
}
|
|
|
|
if (belowElem != null) {
|
|
observer.observe(belowElem);
|
|
}
|
|
|
|
return () => {
|
|
observer.disconnect();
|
|
};
|
|
}); // on every render, why not
|
|
|
|
const scrollToCenter = useCallback(() => {
|
|
preEntryElement.current?.scrollIntoView({ block: 'center' });
|
|
entryElement.current?.scrollIntoView({ block: 'center' });
|
|
}, [preEntryElement, entryElement]);
|
|
|
|
useLayoutEffect(() => {
|
|
const scrollE = scrollElement.current;
|
|
if (scrollE == null) {
|
|
return;
|
|
}
|
|
|
|
const prevClientHeight = prevClientHeightRef.current;
|
|
const currentClientHeight = scrollE.clientHeight;
|
|
prevClientHeightRef.current = currentClientHeight;
|
|
if (prevClientHeight !== currentClientHeight) {
|
|
// height has changed, we scroll to the center
|
|
scrollToCenter();
|
|
return;
|
|
}
|
|
|
|
// if the newly loaded content is part of the initial load of `above` and `below`,
|
|
// we scroll to center, to keep the chosen log-row centered
|
|
if (loadCountRef.current.above <= 1 && loadCountRef.current.below <= 1) {
|
|
scrollToCenter();
|
|
return;
|
|
}
|
|
|
|
const prevScrollHeight = prevScrollHeightRef.current;
|
|
const currentHeight = scrollE.scrollHeight;
|
|
prevScrollHeightRef.current = currentHeight;
|
|
if (prevScrollHeight != null) {
|
|
const newScrollTop = scrollE.scrollTop + (currentHeight - prevScrollHeight);
|
|
scrollE.scrollTop = newScrollTop;
|
|
}
|
|
}, [context.above.rows, scrollToCenter]);
|
|
|
|
useAsync(updateContextQuery, [getRowContextQuery, row]);
|
|
|
|
const loadingStateAbove = context.above.loadingState;
|
|
const loadingStateBelow = context.below.loadingState;
|
|
|
|
return (
|
|
<Modal
|
|
isOpen={open}
|
|
title="Log context"
|
|
contentClassName={styles.flexColumn}
|
|
className={styles.modal}
|
|
onDismiss={onClose}
|
|
>
|
|
{config.featureToggles.logsContextDatasourceUi && getLogRowContextUi && (
|
|
<div className={styles.datasourceUi}>{getLogRowContextUi(row, updateResults)}</div>
|
|
)}
|
|
<div className={cx(styles.flexRow, styles.paddingBottom)}>
|
|
<div>
|
|
<LogContextButtons
|
|
wrapLines={wrapLines}
|
|
onChangeWrapLines={setWrapLines}
|
|
onScrollCenterClick={scrollToCenter}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div ref={scrollElement} className={styles.logRowGroups}>
|
|
<table>
|
|
<tbody>
|
|
<tr>
|
|
<td className={styles.loadingCell}>
|
|
{loadingStateAbove !== LoadingState.Done && loadingStateAbove !== LoadingState.Error && (
|
|
<div ref={aboveLoadingElement}>
|
|
<LoadingIndicator place="above" />
|
|
</div>
|
|
)}
|
|
{loadingStateAbove === LoadingState.Error && <div>Error loading log more logs.</div>}
|
|
{loadingStateAbove === LoadingState.Done && <div>No more logs available.</div>}
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td className={styles.noMarginBottom}>
|
|
<LogRows
|
|
logRows={context.above.rows}
|
|
dedupStrategy={LogsDedupStrategy.none}
|
|
showLabels={store.getBool(SETTINGS_KEYS.showLabels, false)}
|
|
showTime={store.getBool(SETTINGS_KEYS.showTime, true)}
|
|
wrapLogMessage={wrapLines}
|
|
prettifyLogMessage={store.getBool(SETTINGS_KEYS.prettifyLogMessage, false)}
|
|
enableLogDetails={true}
|
|
timeZone={timeZone}
|
|
displayedFields={displayedFields}
|
|
onClickShowField={showField}
|
|
onClickHideField={hideField}
|
|
/>
|
|
</td>
|
|
</tr>
|
|
<tr ref={preEntryElement}></tr>
|
|
<tr ref={entryElement} className={cx(styles.entry, sticky ? styles.sticky : null)} data-testid="entry-row">
|
|
<td className={styles.noMarginBottom}>
|
|
<LogRows
|
|
logRows={[row]}
|
|
dedupStrategy={LogsDedupStrategy.none}
|
|
showLabels={store.getBool(SETTINGS_KEYS.showLabels, false)}
|
|
showTime={store.getBool(SETTINGS_KEYS.showTime, true)}
|
|
wrapLogMessage={wrapLines}
|
|
prettifyLogMessage={store.getBool(SETTINGS_KEYS.prettifyLogMessage, false)}
|
|
enableLogDetails={true}
|
|
timeZone={timeZone}
|
|
displayedFields={displayedFields}
|
|
onClickShowField={showField}
|
|
onClickHideField={hideField}
|
|
onUnpinLine={() => setSticky(false)}
|
|
onPinLine={() => setSticky(true)}
|
|
pinnedRowId={sticky ? row.uid : undefined}
|
|
/>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>
|
|
<>
|
|
<LogRows
|
|
logRows={context.below.rows}
|
|
dedupStrategy={LogsDedupStrategy.none}
|
|
showLabels={store.getBool(SETTINGS_KEYS.showLabels, false)}
|
|
showTime={store.getBool(SETTINGS_KEYS.showTime, true)}
|
|
wrapLogMessage={wrapLines}
|
|
prettifyLogMessage={store.getBool(SETTINGS_KEYS.prettifyLogMessage, false)}
|
|
enableLogDetails={true}
|
|
timeZone={timeZone}
|
|
displayedFields={displayedFields}
|
|
onClickShowField={showField}
|
|
onClickHideField={hideField}
|
|
/>
|
|
</>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td className={styles.loadingCell}>
|
|
{loadingStateBelow !== LoadingState.Done && loadingStateBelow !== LoadingState.Error && (
|
|
<div ref={belowLoadingElement}>
|
|
<LoadingIndicator place="below" />
|
|
</div>
|
|
)}
|
|
{loadingStateBelow === LoadingState.Error && <div>Error loading log more logs.</div>}
|
|
{loadingStateBelow === LoadingState.Done && <div>No more logs available.</div>}
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<Modal.ButtonRow>
|
|
{contextQuery?.datasource?.uid && (
|
|
<Button
|
|
variant="secondary"
|
|
onClick={async () => {
|
|
let rowId = row.uid;
|
|
if (row.dataFrame.refId) {
|
|
// the orignal row has the refid from the base query and not the refid from the context query, so we need to replace it.
|
|
rowId = row.uid.replace(row.dataFrame.refId, contextQuery.refId);
|
|
}
|
|
|
|
dispatch(
|
|
splitOpen({
|
|
queries: [contextQuery],
|
|
range: getFullTimeRange(),
|
|
datasourceUid: contextQuery.datasource!.uid!,
|
|
panelsState: {
|
|
logs: {
|
|
id: rowId,
|
|
},
|
|
},
|
|
})
|
|
);
|
|
onClose();
|
|
reportInteraction('grafana_explore_logs_log_context_open_split_view_clicked', {
|
|
datasourceType: row.datasourceType,
|
|
logRowUid: row.uid,
|
|
});
|
|
}}
|
|
>
|
|
Open in split view
|
|
</Button>
|
|
)}
|
|
</Modal.ButtonRow>
|
|
</Modal>
|
|
);
|
|
};
|