Logs Panel: Add infinite scrolling support for Dashboards and Apps (#97095)
* LogsPanel: expose infinite scrolling to apps * Logs panel: implement infinite scrolling callback and type guards * Logs panel: expose infinite scrolling toggle in panel options * Logs permalink: extract range function to a module * Update unit test * Logs panel: migrate test to dataplane format * Formatting and docs * Shortlinks: remove unnecessary mock * Remove unnecessary params * Formatting
This commit is contained in:
@@ -15,6 +15,7 @@ export const pluginVersion = "11.4.0-pre";
|
||||
export interface Options {
|
||||
dedupStrategy: common.LogsDedupStrategy;
|
||||
displayedFields?: Array<string>;
|
||||
enableInfiniteScrolling?: boolean;
|
||||
enableLogDetails: boolean;
|
||||
isFilterLabelActive?: unknown;
|
||||
logRowMenuIconsAfter?: unknown;
|
||||
@@ -28,6 +29,7 @@ export interface Options {
|
||||
onClickFilterString?: unknown;
|
||||
onClickHideField?: unknown;
|
||||
onClickShowField?: unknown;
|
||||
onNewLogsReceived?: unknown;
|
||||
prettifyLogMessage: boolean;
|
||||
showCommonLabels: boolean;
|
||||
showLabels: boolean;
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { createShortLink, createAndCopyShortLink } from './shortLinks';
|
||||
import { LogRowModel } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { createLogRow } from 'app/features/logs/components/__mocks__/logRow';
|
||||
|
||||
import { createShortLink, createAndCopyShortLink, getLogsPermalinkRange } from './shortLinks';
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
@@ -25,3 +29,57 @@ describe('createAndCopyShortLink', () => {
|
||||
expect(document.execCommand).toHaveBeenCalledWith('copy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLogsPermalinkRange', () => {
|
||||
let row: LogRowModel, rows: LogRowModel[];
|
||||
beforeEach(() => {
|
||||
config.featureToggles.logsInfiniteScrolling = true;
|
||||
row = createLogRow({
|
||||
timeEpochMs: 1111112222222,
|
||||
});
|
||||
rows = [
|
||||
createLogRow({
|
||||
timeEpochMs: 1111113333333,
|
||||
}),
|
||||
row,
|
||||
];
|
||||
});
|
||||
afterAll(() => {
|
||||
config.featureToggles.logsInfiniteScrolling = false;
|
||||
});
|
||||
|
||||
it('returns the original range if infinite scrolling is not enabled', () => {
|
||||
config.featureToggles.logsInfiniteScrolling = false;
|
||||
const range = {
|
||||
from: 1111111111111,
|
||||
to: 1111112222222,
|
||||
};
|
||||
const expectedRange = {
|
||||
from: new Date(1111111111111).toISOString(),
|
||||
to: new Date(1111112222222).toISOString(),
|
||||
};
|
||||
expect(getLogsPermalinkRange(row, [row], range)).toEqual(expectedRange);
|
||||
});
|
||||
|
||||
it('returns the range relative to the previous log line', () => {
|
||||
const range = {
|
||||
from: 1111111111111,
|
||||
to: 1111114444444,
|
||||
};
|
||||
expect(getLogsPermalinkRange(row, rows, range)).toEqual({
|
||||
from: '2005-03-18T01:58:31.111Z',
|
||||
to: '2005-03-18T02:35:33.333Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the range relative to the previous log line', () => {
|
||||
const range = {
|
||||
from: 1111111111110,
|
||||
to: 1111111111111,
|
||||
};
|
||||
expect(getLogsPermalinkRange(row, [row], range)).toEqual({
|
||||
from: '2005-03-18T01:58:31.110Z',
|
||||
to: '2005-03-18T02:17:02.223Z',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import memoizeOne from 'memoize-one';
|
||||
|
||||
import { UrlQueryMap } from '@grafana/data';
|
||||
import { AbsoluteTimeRange, LogRowModel, UrlQueryMap } from '@grafana/data';
|
||||
import { getBackendSrv, config, locationService } from '@grafana/runtime';
|
||||
import { sceneGraph, SceneTimeRangeLike, VizPanel } from '@grafana/scenes';
|
||||
import { notifyApp } from 'app/core/actions';
|
||||
@@ -96,3 +96,43 @@ export const getShareUrlParams = (
|
||||
|
||||
return urlParamsUpdate;
|
||||
};
|
||||
|
||||
function getPreviousLog(row: LogRowModel, allLogs: LogRowModel[]): LogRowModel | null {
|
||||
for (let i = allLogs.indexOf(row) - 1; i >= 0; i--) {
|
||||
if (allLogs[i].timeEpochMs > row.timeEpochMs) {
|
||||
return allLogs[i];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getLogsPermalinkRange(row: LogRowModel, rows: LogRowModel[], absoluteRange: AbsoluteTimeRange) {
|
||||
const range = {
|
||||
from: new Date(absoluteRange.from).toISOString(),
|
||||
to: new Date(absoluteRange.to).toISOString(),
|
||||
};
|
||||
if (!config.featureToggles.logsInfiniteScrolling) {
|
||||
return range;
|
||||
}
|
||||
|
||||
// With infinite scrolling, the time range of the log line can be after the absolute range or beyond the request line limit, so we need to adjust
|
||||
// Look for the previous sibling log, and use its timestamp
|
||||
const allLogs = rows.filter((logRow) => logRow.dataFrame.refId === row.dataFrame.refId);
|
||||
const prevLog = getPreviousLog(row, allLogs);
|
||||
|
||||
if (row.timeEpochMs > absoluteRange.to && !prevLog) {
|
||||
// Because there's no sibling and the current `to` is oldest than the log, we have no reference we can use for the interval
|
||||
// This only happens when you scroll into the future and you want to share the first log of the list
|
||||
return {
|
||||
from: new Date(absoluteRange.from).toISOString(),
|
||||
// Slide 1ms otherwise it's very likely to be omitted in the results
|
||||
to: new Date(row.timeEpochMs + 1).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
from: new Date(absoluteRange.from).toISOString(),
|
||||
to: new Date(prevLog ? prevLog.timeEpochMs : absoluteRange.to).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ import {
|
||||
import { mapMouseEventToMode } from '@grafana/ui/src/components/VizLegend/utils';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
import store from 'app/core/store';
|
||||
import { createAndCopyShortLink } from 'app/core/utils/shortLinks';
|
||||
import { createAndCopyShortLink, getLogsPermalinkRange } from 'app/core/utils/shortLinks';
|
||||
import { InfiniteScroll } from 'app/features/logs/components/InfiniteScroll';
|
||||
import { LogRows } from 'app/features/logs/components/LogRows';
|
||||
import { LogRowContextModal } from 'app/features/logs/components/log-context/LogRowContextModal';
|
||||
@@ -651,49 +651,6 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
onCloseCallbackRef.current = onClose;
|
||||
};
|
||||
|
||||
const getPreviousLog = useCallback((row: LogRowModel, allLogs: LogRowModel[]) => {
|
||||
for (let i = allLogs.indexOf(row) - 1; i >= 0; i--) {
|
||||
if (allLogs[i].timeEpochMs > row.timeEpochMs) {
|
||||
return allLogs[i];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const getPermalinkRange = useCallback(
|
||||
(row: LogRowModel) => {
|
||||
const range = {
|
||||
from: new Date(absoluteRange.from).toISOString(),
|
||||
to: new Date(absoluteRange.to).toISOString(),
|
||||
};
|
||||
if (!config.featureToggles.logsInfiniteScrolling) {
|
||||
return range;
|
||||
}
|
||||
|
||||
// With infinite scrolling, the time range of the log line can be after the absolute range or beyond the request line limit, so we need to adjust
|
||||
// Look for the previous sibling log, and use its timestamp
|
||||
const allLogs = logRows.filter((logRow) => logRow.dataFrame.refId === row.dataFrame.refId);
|
||||
const prevLog = getPreviousLog(row, allLogs);
|
||||
|
||||
if (row.timeEpochMs > absoluteRange.to && !prevLog) {
|
||||
// Because there's no sibling and the current `to` is oldest than the log, we have no reference we can use for the interval
|
||||
// This only happens when you scroll into the future and you want to share the first log of the list
|
||||
return {
|
||||
from: new Date(absoluteRange.from).toISOString(),
|
||||
// Slide 1ms otherwise it's very likely to be omitted in the results
|
||||
to: new Date(row.timeEpochMs + 1).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
from: new Date(absoluteRange.from).toISOString(),
|
||||
to: new Date(prevLog ? prevLog.timeEpochMs : absoluteRange.to).toISOString(),
|
||||
};
|
||||
},
|
||||
[absoluteRange.from, absoluteRange.to, getPreviousLog, logRows]
|
||||
);
|
||||
|
||||
const onPermalinkClick = async (row: LogRowModel) => {
|
||||
// this is an extra check, to be sure that we are not
|
||||
// creating permalinks for logs without an id-field.
|
||||
@@ -709,7 +666,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
|
||||
...panelState,
|
||||
logs: { id: row.uid, visualisationType: visualisationType ?? getDefaultVisualisationType(), displayedFields },
|
||||
};
|
||||
urlState.range = getPermalinkRange(row);
|
||||
urlState.range = getLogsPermalinkRange(row, logRows, absoluteRange);
|
||||
|
||||
// append changed urlState to baseUrl
|
||||
const serializedState = serializeStateToUrlParam(urlState);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
getDefaultTimeRange,
|
||||
LogsDedupStrategy,
|
||||
EventBusSrv,
|
||||
DataFrameType,
|
||||
} from '@grafana/data';
|
||||
import * as grafanaUI from '@grafana/ui';
|
||||
import * as styles from 'app/features/logs/components/getLogRowStyles';
|
||||
@@ -42,6 +43,9 @@ jest.mock('@grafana/runtime', () => ({
|
||||
}));
|
||||
|
||||
const hasLogsContextSupport = jest.fn().mockImplementation((ds) => {
|
||||
if (!ds) {
|
||||
return false;
|
||||
}
|
||||
return ds.name === 'show-context';
|
||||
});
|
||||
jest.mock('@grafana/data', () => ({
|
||||
@@ -49,34 +53,100 @@ jest.mock('@grafana/data', () => ({
|
||||
hasLogsContextSupport: (ds: MockDataSourceApi) => hasLogsContextSupport(ds),
|
||||
}));
|
||||
|
||||
const defaultProps = {
|
||||
data: {
|
||||
error: undefined,
|
||||
request: {
|
||||
panelId: 4,
|
||||
app: 'dashboard',
|
||||
requestId: 'A',
|
||||
timezone: 'browser',
|
||||
interval: '30s',
|
||||
intervalMs: 30000,
|
||||
maxDataPoints: 823,
|
||||
targets: [],
|
||||
range: getDefaultTimeRange(),
|
||||
scopedVars: {},
|
||||
startTime: 1,
|
||||
},
|
||||
series: [],
|
||||
state: LoadingState.Done,
|
||||
timeRange: getDefaultTimeRange(),
|
||||
},
|
||||
timeZone: 'utc',
|
||||
timeRange: getDefaultTimeRange(),
|
||||
options: {
|
||||
showLabels: false,
|
||||
showTime: false,
|
||||
wrapLogMessage: false,
|
||||
showCommonLabels: false,
|
||||
prettifyLogMessage: false,
|
||||
sortOrder: LogsSortOrder.Descending,
|
||||
dedupStrategy: LogsDedupStrategy.none,
|
||||
enableLogDetails: false,
|
||||
showLogContextToggle: false,
|
||||
},
|
||||
title: 'Logs panel',
|
||||
id: 1,
|
||||
transparent: false,
|
||||
width: 400,
|
||||
height: 100,
|
||||
renderCounter: 0,
|
||||
fieldConfig: {
|
||||
defaults: {},
|
||||
overrides: [],
|
||||
},
|
||||
eventBus: new EventBusSrv(),
|
||||
onOptionsChange: jest.fn(),
|
||||
onFieldConfigChange: jest.fn(),
|
||||
replaceVariables: jest.fn(),
|
||||
onChangeTimeRange: jest.fn(),
|
||||
};
|
||||
|
||||
describe('LogsPanel', () => {
|
||||
describe('when returned series include common labels', () => {
|
||||
const seriesWithCommonLabels = [
|
||||
createDataFrame({
|
||||
fields: [
|
||||
{
|
||||
name: 'time',
|
||||
name: 'timestamp',
|
||||
type: FieldType.time,
|
||||
values: ['2019-04-26T09:28:11.352440161Z', '2019-04-26T14:42:50.991981292Z'],
|
||||
},
|
||||
{
|
||||
name: 'message',
|
||||
name: 'body',
|
||||
type: FieldType.string,
|
||||
values: [
|
||||
't=2019-04-26T11:05:28+0200 lvl=info msg="Initializing DatasourceCacheService" logger=server',
|
||||
't=2019-04-26T16:42:50+0200 lvl=eror msg="new token…t unhashed token=56d9fdc5c8b7400bd51b060eea8ca9d7',
|
||||
],
|
||||
labels: {
|
||||
app: 'common_app',
|
||||
job: 'common_job',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'labels',
|
||||
type: FieldType.other,
|
||||
values: [
|
||||
{
|
||||
app: 'common_app',
|
||||
job: 'common_job',
|
||||
},
|
||||
{
|
||||
app: 'common_app',
|
||||
job: 'common_job',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
type: DataFrameType.LogLines,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
it('shows common labels when showCommonLabels is set to true', async () => {
|
||||
setup({ data: { series: seriesWithCommonLabels }, options: { showCommonLabels: true } });
|
||||
setup({
|
||||
data: { ...defaultProps.data, series: seriesWithCommonLabels },
|
||||
options: { ...defaultProps.options, showCommonLabels: true },
|
||||
});
|
||||
|
||||
expect(await screen.findByText(/common labels:/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/common_app/i)).toBeInTheDocument();
|
||||
@@ -84,22 +154,25 @@ describe('LogsPanel', () => {
|
||||
});
|
||||
it('shows common labels on top when descending sort order', async () => {
|
||||
const { container } = setup({
|
||||
data: { series: seriesWithCommonLabels },
|
||||
options: { showCommonLabels: true, sortOrder: LogsSortOrder.Descending },
|
||||
data: { ...defaultProps.data, series: seriesWithCommonLabels },
|
||||
options: { ...defaultProps.options, showCommonLabels: true, sortOrder: LogsSortOrder.Descending },
|
||||
});
|
||||
expect(await screen.findByText(/common labels:/i)).toBeInTheDocument();
|
||||
expect(container.firstChild?.childNodes[0].textContent).toMatch(/^Common labels:app=common_appjob=common_job/);
|
||||
});
|
||||
it('shows common labels on bottom when ascending sort order', async () => {
|
||||
const { container } = setup({
|
||||
data: { series: seriesWithCommonLabels },
|
||||
options: { showCommonLabels: true, sortOrder: LogsSortOrder.Ascending },
|
||||
data: { ...defaultProps.data, series: seriesWithCommonLabels },
|
||||
options: { ...defaultProps.options, showCommonLabels: true, sortOrder: LogsSortOrder.Ascending },
|
||||
});
|
||||
expect(await screen.findByText(/common labels:/i)).toBeInTheDocument();
|
||||
expect(container.firstChild?.childNodes[0].textContent).toMatch(/Common labels:app=common_appjob=common_job$/);
|
||||
});
|
||||
it('does not show common labels when showCommonLabels is set to false', async () => {
|
||||
setup({ data: { series: seriesWithCommonLabels }, options: { showCommonLabels: false } });
|
||||
setup({
|
||||
data: { ...defaultProps.data, series: seriesWithCommonLabels },
|
||||
options: { ...defaultProps.options, showCommonLabels: false },
|
||||
});
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(screen.queryByText(/common labels:/i)).toBeNull();
|
||||
@@ -113,12 +186,12 @@ describe('LogsPanel', () => {
|
||||
createDataFrame({
|
||||
fields: [
|
||||
{
|
||||
name: 'time',
|
||||
name: 'timestamp',
|
||||
type: FieldType.time,
|
||||
values: ['2019-04-26T09:28:11.352440161Z', '2019-04-26T14:42:50.991981292Z'],
|
||||
},
|
||||
{
|
||||
name: 'message',
|
||||
name: 'body',
|
||||
type: FieldType.string,
|
||||
values: [
|
||||
't=2019-04-26T11:05:28+0200 lvl=info msg="Initializing DatasourceCacheService" logger=server',
|
||||
@@ -126,16 +199,25 @@ describe('LogsPanel', () => {
|
||||
],
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
type: DataFrameType.LogLines,
|
||||
},
|
||||
}),
|
||||
];
|
||||
it('shows (no common labels) when showCommonLabels is set to true', async () => {
|
||||
setup({ data: { series: seriesWithoutCommonLabels }, options: { showCommonLabels: true } });
|
||||
setup({
|
||||
data: { ...defaultProps.data, series: seriesWithoutCommonLabels },
|
||||
options: { ...defaultProps.options, showCommonLabels: true },
|
||||
});
|
||||
|
||||
expect(await screen.findByText(/common labels:/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/(no common labels)/i)).toBeInTheDocument();
|
||||
});
|
||||
it('does not show common labels when showCommonLabels is set to false', async () => {
|
||||
setup({ data: { series: seriesWithoutCommonLabels }, options: { showCommonLabels: false } });
|
||||
setup({
|
||||
data: { ...defaultProps.data, series: seriesWithoutCommonLabels },
|
||||
options: { ...defaultProps.options, showCommonLabels: false },
|
||||
});
|
||||
await waitFor(async () => {
|
||||
expect(screen.queryByText(/common labels:/i)).toBeNull();
|
||||
expect(screen.queryByText(/(no common labels)/i)).toBeNull();
|
||||
@@ -149,20 +231,33 @@ describe('LogsPanel', () => {
|
||||
refId: 'A',
|
||||
fields: [
|
||||
{
|
||||
name: 'time',
|
||||
name: 'timestamp',
|
||||
type: FieldType.time,
|
||||
values: ['2019-04-26T09:28:11.352440161Z', '2019-04-26T14:42:50.991981292Z'],
|
||||
},
|
||||
{
|
||||
name: 'message',
|
||||
name: 'body',
|
||||
type: FieldType.string,
|
||||
values: ['logline text'],
|
||||
labels: {
|
||||
app: 'common_app',
|
||||
job: 'common_job',
|
||||
},
|
||||
values: ['logline text', 'more text'],
|
||||
},
|
||||
{
|
||||
name: 'labels',
|
||||
type: FieldType.other,
|
||||
values: [
|
||||
{
|
||||
app: 'common_app',
|
||||
job: 'common_job',
|
||||
},
|
||||
{
|
||||
app: 'common_app',
|
||||
job: 'common_job',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
type: DataFrameType.LogLines,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -173,9 +268,10 @@ describe('LogsPanel', () => {
|
||||
it('should not show the toggle if the datasource does not support show context', async () => {
|
||||
setup({
|
||||
data: {
|
||||
...defaultProps.data,
|
||||
series,
|
||||
options: { showCommonLabels: false },
|
||||
request: {
|
||||
...defaultProps.data.request,
|
||||
app: CoreApp.Dashboard,
|
||||
targets: [{ refId: 'A', datasource: { uid: 'no-show-context' } }],
|
||||
},
|
||||
@@ -191,9 +287,10 @@ describe('LogsPanel', () => {
|
||||
it('should show the toggle if the datasource does support show context', async () => {
|
||||
setup({
|
||||
data: {
|
||||
...defaultProps.data,
|
||||
series,
|
||||
options: { showCommonLabels: false },
|
||||
request: {
|
||||
...defaultProps.data.request,
|
||||
app: CoreApp.Dashboard,
|
||||
targets: [{ refId: 'A', datasource: { uid: 'show-context' } }],
|
||||
},
|
||||
@@ -209,9 +306,10 @@ describe('LogsPanel', () => {
|
||||
it('should not show the toggle if the datasource does support show context but the app is not Dashboard', async () => {
|
||||
setup({
|
||||
data: {
|
||||
...defaultProps.data,
|
||||
series,
|
||||
options: { showCommonLabels: false },
|
||||
request: {
|
||||
...defaultProps.data.request,
|
||||
app: CoreApp.CloudAlerting,
|
||||
targets: [{ refId: 'A', datasource: { uid: 'show-context' } }],
|
||||
},
|
||||
@@ -227,9 +325,10 @@ describe('LogsPanel', () => {
|
||||
it('should render the mocked `LogRowContextModal` after click', async () => {
|
||||
setup({
|
||||
data: {
|
||||
...defaultProps.data,
|
||||
series,
|
||||
options: { showCommonLabels: false },
|
||||
request: {
|
||||
...defaultProps.data.request,
|
||||
app: CoreApp.Dashboard,
|
||||
targets: [{ refId: 'A', datasource: { uid: 'show-context' } }],
|
||||
},
|
||||
@@ -245,9 +344,10 @@ describe('LogsPanel', () => {
|
||||
it('should call `getLogRowContext` if the user clicks the show context toggle', async () => {
|
||||
setup({
|
||||
data: {
|
||||
...defaultProps.data,
|
||||
series,
|
||||
options: { showCommonLabels: false },
|
||||
request: {
|
||||
...defaultProps.data.request,
|
||||
app: CoreApp.Dashboard,
|
||||
targets: [{ refId: 'A', datasource: { uid: 'show-context' } }],
|
||||
},
|
||||
@@ -271,17 +371,17 @@ describe('LogsPanel', () => {
|
||||
<grafanaUI.IconButton name="rss" tooltip="Addon after" aria-label="Addon after" key={1} />,
|
||||
];
|
||||
|
||||
setup(
|
||||
{
|
||||
data: {
|
||||
series,
|
||||
},
|
||||
setup({
|
||||
data: {
|
||||
...defaultProps.data,
|
||||
series,
|
||||
},
|
||||
{
|
||||
options: {
|
||||
...defaultProps.options,
|
||||
logRowMenuIconsBefore,
|
||||
logRowMenuIconsAfter,
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(async () => {
|
||||
await userEvent.hover(screen.getByText(/logline text/i));
|
||||
@@ -297,20 +397,29 @@ describe('LogsPanel', () => {
|
||||
refId: 'A',
|
||||
fields: [
|
||||
{
|
||||
name: 'time',
|
||||
name: 'timestamp',
|
||||
type: FieldType.time,
|
||||
values: ['2019-04-26T09:28:11.352440161Z'],
|
||||
},
|
||||
{
|
||||
name: 'message',
|
||||
name: 'body',
|
||||
type: FieldType.string,
|
||||
values: ['logline text'],
|
||||
labels: {
|
||||
app: 'common_app',
|
||||
job: 'common_job',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'labels',
|
||||
type: FieldType.other,
|
||||
values: [
|
||||
{
|
||||
app: 'common_app',
|
||||
job: 'common_job',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
type: DataFrameType.LogLines,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -325,6 +434,7 @@ describe('LogsPanel', () => {
|
||||
it('does not rerender without changes', async () => {
|
||||
const { rerender, props } = setup({
|
||||
data: {
|
||||
...defaultProps.data,
|
||||
series,
|
||||
},
|
||||
});
|
||||
@@ -340,6 +450,7 @@ describe('LogsPanel', () => {
|
||||
it('rerenders when prop changes', async () => {
|
||||
const { rerender, props } = setup({
|
||||
data: {
|
||||
...defaultProps.data,
|
||||
series,
|
||||
},
|
||||
});
|
||||
@@ -359,19 +470,28 @@ describe('LogsPanel', () => {
|
||||
refId: 'A',
|
||||
fields: [
|
||||
{
|
||||
name: 'time',
|
||||
name: 'timestamp',
|
||||
type: FieldType.time,
|
||||
values: ['2019-04-26T09:28:11.352440161Z'],
|
||||
},
|
||||
{
|
||||
name: 'message',
|
||||
name: 'body',
|
||||
type: FieldType.string,
|
||||
values: ['logline text'],
|
||||
labels: {
|
||||
app: 'common_app',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'labels',
|
||||
type: FieldType.other,
|
||||
values: [
|
||||
{
|
||||
app: 'common_app',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
type: DataFrameType.LogLines,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -381,9 +501,11 @@ describe('LogsPanel', () => {
|
||||
const isFilterLabelActiveMock = jest.fn();
|
||||
setup({
|
||||
data: {
|
||||
...defaultProps.data,
|
||||
series,
|
||||
},
|
||||
options: {
|
||||
...defaultProps.options,
|
||||
showLabels: false,
|
||||
showTime: false,
|
||||
wrapLogMessage: false,
|
||||
@@ -418,8 +540,13 @@ describe('LogsPanel', () => {
|
||||
|
||||
setup({
|
||||
data: {
|
||||
...defaultProps.data,
|
||||
series,
|
||||
},
|
||||
options: {
|
||||
...defaultProps.options,
|
||||
enableLogDetails: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(await screen.findByRole('row')).toBeInTheDocument();
|
||||
@@ -438,8 +565,13 @@ describe('LogsPanel', () => {
|
||||
|
||||
setup({
|
||||
data: {
|
||||
...defaultProps.data,
|
||||
series,
|
||||
},
|
||||
options: {
|
||||
...defaultProps.options,
|
||||
enableLogDetails: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(await screen.findByRole('row')).toBeInTheDocument();
|
||||
@@ -460,28 +592,39 @@ describe('LogsPanel', () => {
|
||||
refId: 'A',
|
||||
fields: [
|
||||
{
|
||||
name: 'time',
|
||||
name: 'timestamp',
|
||||
type: FieldType.time,
|
||||
values: ['2019-04-26T09:28:11.352440161Z'],
|
||||
},
|
||||
{
|
||||
name: 'message',
|
||||
name: 'body',
|
||||
type: FieldType.string,
|
||||
values: ['logline text'],
|
||||
labels: {
|
||||
app: 'common_app',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'labels',
|
||||
type: FieldType.other,
|
||||
values: [
|
||||
{
|
||||
app: 'common_app',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
type: DataFrameType.LogLines,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
it('displays the provided fields instead of the log line', async () => {
|
||||
setup({
|
||||
data: {
|
||||
...defaultProps.data,
|
||||
series,
|
||||
},
|
||||
options: {
|
||||
...defaultProps.options,
|
||||
showLabels: false,
|
||||
showTime: false,
|
||||
wrapLogMessage: false,
|
||||
@@ -511,9 +654,11 @@ describe('LogsPanel', () => {
|
||||
it('updates the provided fields instead of the log line', async () => {
|
||||
const { rerender, props } = setup({
|
||||
data: {
|
||||
...defaultProps.data,
|
||||
series,
|
||||
},
|
||||
options: {
|
||||
...defaultProps.options,
|
||||
showLabels: false,
|
||||
showTime: false,
|
||||
wrapLogMessage: false,
|
||||
@@ -538,9 +683,11 @@ describe('LogsPanel', () => {
|
||||
it('enables the behavior with a default implementation', async () => {
|
||||
setup({
|
||||
data: {
|
||||
...defaultProps.data,
|
||||
series,
|
||||
},
|
||||
options: {
|
||||
...defaultProps.options,
|
||||
showLabels: false,
|
||||
showTime: false,
|
||||
wrapLogMessage: false,
|
||||
@@ -572,9 +719,11 @@ describe('LogsPanel', () => {
|
||||
|
||||
setup({
|
||||
data: {
|
||||
...defaultProps.data,
|
||||
series,
|
||||
},
|
||||
options: {
|
||||
...defaultProps.options,
|
||||
showLabels: false,
|
||||
showTime: false,
|
||||
wrapLogMessage: false,
|
||||
@@ -598,57 +747,15 @@ describe('LogsPanel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
const setup = (propsOverrides?: {}, optionOverrides?: {}) => {
|
||||
const setup = (propsOverrides?: Partial<LogsPanelProps>) => {
|
||||
const props: LogsPanelProps = {
|
||||
...defaultProps,
|
||||
data: {
|
||||
error: undefined,
|
||||
request: {
|
||||
panelId: 4,
|
||||
app: 'dashboard',
|
||||
requestId: 'A',
|
||||
timezone: 'browser',
|
||||
interval: '30s',
|
||||
intervalMs: 30000,
|
||||
maxDataPoints: 823,
|
||||
targets: [],
|
||||
range: getDefaultTimeRange(),
|
||||
scopedVars: {},
|
||||
startTime: 1,
|
||||
},
|
||||
series: [],
|
||||
state: LoadingState.Done,
|
||||
timeRange: getDefaultTimeRange(),
|
||||
...(propsOverrides?.data || defaultProps.data),
|
||||
},
|
||||
timeZone: 'utc',
|
||||
timeRange: getDefaultTimeRange(),
|
||||
options: {
|
||||
showLabels: false,
|
||||
showTime: false,
|
||||
wrapLogMessage: false,
|
||||
showCommonLabels: false,
|
||||
prettifyLogMessage: false,
|
||||
sortOrder: LogsSortOrder.Descending,
|
||||
dedupStrategy: LogsDedupStrategy.none,
|
||||
enableLogDetails: true,
|
||||
showLogContextToggle: false,
|
||||
...optionOverrides,
|
||||
...(propsOverrides?.options || defaultProps.options),
|
||||
},
|
||||
title: 'Logs panel',
|
||||
id: 1,
|
||||
transparent: false,
|
||||
width: 400,
|
||||
height: 100,
|
||||
renderCounter: 0,
|
||||
fieldConfig: {
|
||||
defaults: {},
|
||||
overrides: [],
|
||||
},
|
||||
eventBus: new EventBusSrv(),
|
||||
onOptionsChange: jest.fn(),
|
||||
onFieldConfigChange: jest.fn(),
|
||||
replaceVariables: jest.fn(),
|
||||
onChangeTimeRange: jest.fn(),
|
||||
...propsOverrides,
|
||||
};
|
||||
|
||||
return { ...render(<LogsPanel {...props} />), props };
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { groupBy } from 'lodash';
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import * as React from 'react';
|
||||
import { isObservable, lastValueFrom } from 'rxjs';
|
||||
|
||||
import {
|
||||
AbsoluteTimeRange,
|
||||
CoreApp,
|
||||
DataFrame,
|
||||
DataHoverClearEvent,
|
||||
DataHoverEvent,
|
||||
DataQueryResponse,
|
||||
DataSourceApi,
|
||||
dateTimeForTimeZone,
|
||||
Field,
|
||||
GrafanaTheme2,
|
||||
hasLogsContextSupport,
|
||||
@@ -15,17 +21,23 @@ import {
|
||||
LogRowContextOptions,
|
||||
LogRowModel,
|
||||
LogsSortOrder,
|
||||
PanelData,
|
||||
PanelProps,
|
||||
TimeRange,
|
||||
TimeZone,
|
||||
toUtc,
|
||||
urlUtil,
|
||||
} from '@grafana/data';
|
||||
import { convertRawToRange } from '@grafana/data/src/datetime/rangeutil';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { ScrollContainer, usePanelContext, useStyles2 } from '@grafana/ui';
|
||||
import { getFieldLinksForExplore } from 'app/features/explore/utils/links';
|
||||
import { InfiniteScroll } from 'app/features/logs/components/InfiniteScroll';
|
||||
import { LogRowContextModal } from 'app/features/logs/components/log-context/LogRowContextModal';
|
||||
import { PanelDataErrorView } from 'app/features/panel/components/PanelDataErrorView';
|
||||
import { combineResponses } from 'app/plugins/datasource/loki/mergeResponses';
|
||||
|
||||
import { createAndCopyShortLink } from '../../../core/utils/shortLinks';
|
||||
import { createAndCopyShortLink, getLogsPermalinkRange } from '../../../core/utils/shortLinks';
|
||||
import { LogLabels } from '../../../features/logs/components/LogLabels';
|
||||
import { LogRows } from '../../../features/logs/components/LogRows';
|
||||
import { COMMON_LABELS, dataFrameToLogsModel, dedupLogRows } from '../../../features/logs/logsModel';
|
||||
@@ -38,7 +50,9 @@ import {
|
||||
isOnClickFilterString,
|
||||
isOnClickHideField,
|
||||
isOnClickShowField,
|
||||
isOnNewLogsReceivedType,
|
||||
isReactNodeArray,
|
||||
onNewLogsReceivedType,
|
||||
Options,
|
||||
} from './types';
|
||||
import { useDatasourcesFromTargets } from './useDatasourcesFromTargets';
|
||||
@@ -74,6 +88,9 @@ interface LogsPanelProps extends PanelProps<Options> {
|
||||
*
|
||||
* Passed to the LogRowMenuCell component to be rendered after the default actions in the menu.
|
||||
* logRowMenuIconsAfter?: ReactNode[];
|
||||
*
|
||||
* Callback to be invoked when enableInfiniteScrolling and new logs have been received after an scroll event.
|
||||
* onNewLogsReceived?: (allLogs: DataFrame[], newLogs: DataFrame[]) => void;
|
||||
*/
|
||||
}
|
||||
interface LogsPermalinkUrlState {
|
||||
@@ -105,6 +122,8 @@ export const LogsPanel = ({
|
||||
isFilterLabelActive,
|
||||
logRowMenuIconsBefore,
|
||||
logRowMenuIconsAfter,
|
||||
enableInfiniteScrolling,
|
||||
onNewLogsReceived,
|
||||
...options
|
||||
},
|
||||
id,
|
||||
@@ -114,10 +133,14 @@ export const LogsPanel = ({
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const logsContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [contextRow, setContextRow] = useState<LogRowModel | null>(null);
|
||||
const timeRange = data.timeRange;
|
||||
const dataSourcesMap = useDatasourcesFromTargets(data.request?.targets);
|
||||
const [scrollElement, setScrollElement] = useState<HTMLDivElement | null>(null);
|
||||
const [displayedFields, setDisplayedFields] = useState<string[]>(options.displayedFields ?? []);
|
||||
// Loading state to be passed as a prop to the <InfiniteScroll> component
|
||||
const [infiniteScrolling, setInfiniteScrolling] = useState(false);
|
||||
// Loading ref to prevent firing multiple requests
|
||||
const loadingRef = useRef(false);
|
||||
const [panelData, setPanelData] = useState(data);
|
||||
let closeCallback = useRef<() => void>();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -159,13 +182,6 @@ export const LogsPanel = ({
|
||||
[closeCallback]
|
||||
);
|
||||
|
||||
const onPermalinkClick = useCallback(
|
||||
async (row: LogRowModel) => {
|
||||
return await copyDashboardUrl(row, timeRange);
|
||||
},
|
||||
[timeRange]
|
||||
);
|
||||
|
||||
const showContextToggle = useCallback(
|
||||
(row: LogRowModel): boolean => {
|
||||
if (
|
||||
@@ -241,14 +257,25 @@ export const LogsPanel = ({
|
||||
|
||||
// Important to memoize stuff here, as panel rerenders a lot for example when resizing.
|
||||
const [logRows, deduplicatedRows, commonLabels] = useMemo(() => {
|
||||
const logs = data
|
||||
? dataFrameToLogsModel(data.series, data.request?.intervalMs, undefined, data.request?.targets)
|
||||
const logs = panelData
|
||||
? dataFrameToLogsModel(panelData.series, data.request?.intervalMs, undefined, data.request?.targets)
|
||||
: null;
|
||||
const logRows = logs?.rows || [];
|
||||
const commonLabels = logs?.meta?.find((m) => m.label === COMMON_LABELS);
|
||||
const deduplicatedRows = dedupLogRows(logRows, dedupStrategy);
|
||||
return [logRows, deduplicatedRows, commonLabels];
|
||||
}, [data, dedupStrategy]);
|
||||
}, [data.request?.intervalMs, data.request?.targets, dedupStrategy, panelData]);
|
||||
|
||||
const onPermalinkClick = useCallback(
|
||||
async (row: LogRowModel) => {
|
||||
return await copyDashboardUrl(row, logRows, data.timeRange);
|
||||
},
|
||||
[data.timeRange, logRows]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPanelData(data);
|
||||
}, [data]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isAscending && logsContainerRef.current) {
|
||||
@@ -326,6 +353,35 @@ export const LogsPanel = ({
|
||||
}
|
||||
}, [options.displayedFields]);
|
||||
|
||||
const loadMoreLogs = useCallback(
|
||||
async (scrollRange: AbsoluteTimeRange) => {
|
||||
if (!data.request || !config.featureToggles.logsInfiniteScrolling || loadingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadingRef.current = true;
|
||||
setInfiniteScrolling(true);
|
||||
|
||||
const onNewLogsReceivedCallback = isOnNewLogsReceivedType(onNewLogsReceived) ? onNewLogsReceived : undefined;
|
||||
|
||||
let newSeries: DataFrame[] = [];
|
||||
try {
|
||||
newSeries = await requestMoreLogs(dataSourcesMap, panelData, scrollRange, timeZone, onNewLogsReceivedCallback);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setInfiniteScrolling(false);
|
||||
loadingRef.current = false;
|
||||
}
|
||||
|
||||
setPanelData({
|
||||
...panelData,
|
||||
series: newSeries,
|
||||
});
|
||||
},
|
||||
[data.request, dataSourcesMap, onNewLogsReceived, panelData, timeZone]
|
||||
);
|
||||
|
||||
if (!data || logRows.length === 0) {
|
||||
return <PanelDataErrorView fieldConfig={fieldConfig} panelId={id} data={data} needsStringField />;
|
||||
}
|
||||
@@ -363,44 +419,54 @@ export const LogsPanel = ({
|
||||
<ScrollContainer ref={(scrollElement) => setScrollElement(scrollElement)}>
|
||||
<div onMouseLeave={onLogContainerMouseLeave} className={style.container} ref={logsContainerRef}>
|
||||
{showCommonLabels && !isAscending && renderCommonLabels()}
|
||||
<LogRows
|
||||
containerRendered={logsContainerRef.current !== null}
|
||||
scrollIntoView={scrollIntoView}
|
||||
permalinkedRowId={getLogsPanelState()?.logs?.id ?? undefined}
|
||||
onPermalinkClick={showPermaLink() ? onPermalinkClick : undefined}
|
||||
logRows={logRows}
|
||||
showContextToggle={showContextToggle}
|
||||
deduplicatedRows={deduplicatedRows}
|
||||
dedupStrategy={dedupStrategy}
|
||||
showLabels={showLabels}
|
||||
showTime={showTime}
|
||||
wrapLogMessage={wrapLogMessage}
|
||||
prettifyLogMessage={prettifyLogMessage}
|
||||
<InfiniteScroll
|
||||
loading={infiniteScrolling}
|
||||
loadMoreLogs={enableInfiniteScrolling ? loadMoreLogs : undefined}
|
||||
range={data.timeRange}
|
||||
timeZone={timeZone}
|
||||
getFieldLinks={getFieldLinks}
|
||||
logsSortOrder={sortOrder}
|
||||
enableLogDetails={enableLogDetails}
|
||||
previewLimit={isAscending ? logRows.length : undefined}
|
||||
onLogRowHover={onLogRowHover}
|
||||
app={CoreApp.Dashboard}
|
||||
onOpenContext={onOpenContext}
|
||||
onClickFilterLabel={
|
||||
isOnClickFilterLabel(onClickFilterLabel) ? onClickFilterLabel : defaultOnClickFilterLabel
|
||||
}
|
||||
onClickFilterOutLabel={
|
||||
isOnClickFilterOutLabel(onClickFilterOutLabel) ? onClickFilterOutLabel : defaultOnClickFilterOutLabel
|
||||
}
|
||||
onClickFilterString={isOnClickFilterString(onClickFilterString) ? onClickFilterString : undefined}
|
||||
onClickFilterOutString={
|
||||
isOnClickFilterOutString(onClickFilterOutString) ? onClickFilterOutString : undefined
|
||||
}
|
||||
isFilterLabelActive={isIsFilterLabelActive(isFilterLabelActive) ? isFilterLabelActive : undefined}
|
||||
displayedFields={displayedFields}
|
||||
onClickShowField={displayedFields !== undefined ? onClickShowField : undefined}
|
||||
onClickHideField={displayedFields !== undefined ? onClickHideField : undefined}
|
||||
logRowMenuIconsBefore={isReactNodeArray(logRowMenuIconsBefore) ? logRowMenuIconsBefore : undefined}
|
||||
logRowMenuIconsAfter={isReactNodeArray(logRowMenuIconsAfter) ? logRowMenuIconsAfter : undefined}
|
||||
/>
|
||||
rows={logRows}
|
||||
scrollElement={scrollElement ?? undefined}
|
||||
sortOrder={sortOrder}
|
||||
>
|
||||
<LogRows
|
||||
containerRendered={logsContainerRef.current !== null}
|
||||
scrollIntoView={scrollIntoView}
|
||||
permalinkedRowId={getLogsPanelState()?.logs?.id ?? undefined}
|
||||
onPermalinkClick={showPermaLink() ? onPermalinkClick : undefined}
|
||||
logRows={logRows}
|
||||
showContextToggle={showContextToggle}
|
||||
deduplicatedRows={deduplicatedRows}
|
||||
dedupStrategy={dedupStrategy}
|
||||
showLabels={showLabels}
|
||||
showTime={showTime}
|
||||
wrapLogMessage={wrapLogMessage}
|
||||
prettifyLogMessage={prettifyLogMessage}
|
||||
timeZone={timeZone}
|
||||
getFieldLinks={getFieldLinks}
|
||||
logsSortOrder={sortOrder}
|
||||
enableLogDetails={enableLogDetails}
|
||||
previewLimit={isAscending ? logRows.length : undefined}
|
||||
onLogRowHover={onLogRowHover}
|
||||
app={CoreApp.Dashboard}
|
||||
onOpenContext={onOpenContext}
|
||||
onClickFilterLabel={
|
||||
isOnClickFilterLabel(onClickFilterLabel) ? onClickFilterLabel : defaultOnClickFilterLabel
|
||||
}
|
||||
onClickFilterOutLabel={
|
||||
isOnClickFilterOutLabel(onClickFilterOutLabel) ? onClickFilterOutLabel : defaultOnClickFilterOutLabel
|
||||
}
|
||||
onClickFilterString={isOnClickFilterString(onClickFilterString) ? onClickFilterString : undefined}
|
||||
onClickFilterOutString={
|
||||
isOnClickFilterOutString(onClickFilterOutString) ? onClickFilterOutString : undefined
|
||||
}
|
||||
isFilterLabelActive={isIsFilterLabelActive(isFilterLabelActive) ? isFilterLabelActive : undefined}
|
||||
displayedFields={displayedFields}
|
||||
onClickShowField={displayedFields !== undefined ? onClickShowField : undefined}
|
||||
onClickHideField={displayedFields !== undefined ? onClickHideField : undefined}
|
||||
logRowMenuIconsBefore={isReactNodeArray(logRowMenuIconsBefore) ? logRowMenuIconsBefore : undefined}
|
||||
logRowMenuIconsAfter={isReactNodeArray(logRowMenuIconsAfter) ? logRowMenuIconsAfter : undefined}
|
||||
/>
|
||||
</InfiniteScroll>
|
||||
{showCommonLabels && isAscending && renderCommonLabels()}
|
||||
</div>
|
||||
</ScrollContainer>
|
||||
@@ -446,7 +512,7 @@ function getLogsPanelState(): LogsPermalinkUrlState | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function copyDashboardUrl(row: LogRowModel, timeRange: TimeRange) {
|
||||
async function copyDashboardUrl(row: LogRowModel, rows: LogRowModel[], timeRange: TimeRange) {
|
||||
// this is an extra check, to be sure that we are not
|
||||
// creating permalinks for logs without an id-field.
|
||||
// normally it should never happen, because we do not
|
||||
@@ -465,10 +531,68 @@ async function copyDashboardUrl(row: LogRowModel, timeRange: TimeRange) {
|
||||
|
||||
// Add panel state containing the rowId, and absolute time range from the current query, but leave everything else the same, if the user is in edit mode when grabbing the link, that's what will be linked to, etc.
|
||||
currentURL.searchParams.set('panelState', JSON.stringify(panelState));
|
||||
currentURL.searchParams.set('from', toUtc(timeRange.from).valueOf().toString(10));
|
||||
currentURL.searchParams.set('to', toUtc(timeRange.to).valueOf().toString(10));
|
||||
const range = getLogsPermalinkRange(row, rows, {
|
||||
from: toUtc(timeRange.from).valueOf(),
|
||||
to: toUtc(timeRange.to).valueOf(),
|
||||
});
|
||||
currentURL.searchParams.set('from', range.from.toString());
|
||||
currentURL.searchParams.set('to', range.to.toString());
|
||||
|
||||
await createAndCopyShortLink(currentURL.toString());
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async function requestMoreLogs(
|
||||
dataSourcesMap: Map<string, DataSourceApi>,
|
||||
panelData: PanelData,
|
||||
timeRange: AbsoluteTimeRange,
|
||||
timeZone: TimeZone,
|
||||
onNewLogsReceived?: onNewLogsReceivedType
|
||||
) {
|
||||
if (!panelData.request) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const range: TimeRange = convertRawToRange({
|
||||
from: dateTimeForTimeZone(timeZone, timeRange.from),
|
||||
to: dateTimeForTimeZone(timeZone, timeRange.to),
|
||||
});
|
||||
|
||||
const targetGroups = groupBy(panelData.request.targets, 'datasource.uid');
|
||||
const dataRequests = [];
|
||||
|
||||
for (const uid in targetGroups) {
|
||||
const dataSource = dataSourcesMap.get(panelData.request.targets[0].refId);
|
||||
if (!dataSource) {
|
||||
console.log('no ds');
|
||||
continue;
|
||||
}
|
||||
dataRequests.push(
|
||||
dataSource.query({
|
||||
...panelData.request,
|
||||
range,
|
||||
targets: targetGroups[uid],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const responses = await Promise.all(dataRequests);
|
||||
let updatedSeries = panelData.series;
|
||||
for (const response of responses) {
|
||||
const newData = isObservable(response) ? await lastValueFrom(response) : response;
|
||||
|
||||
updatedSeries = combineResponses(
|
||||
{
|
||||
data: updatedSeries,
|
||||
},
|
||||
{ data: newData.data }
|
||||
).data;
|
||||
|
||||
if (onNewLogsReceived) {
|
||||
onNewLogsReceived(updatedSeries, newData.data);
|
||||
}
|
||||
}
|
||||
|
||||
return updatedSeries;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,12 @@ export const plugin = new PanelPlugin<Options>(LogsPanel)
|
||||
description: '',
|
||||
defaultValue: true,
|
||||
})
|
||||
.addBooleanSwitch({
|
||||
path: 'enableInfiniteScrolling',
|
||||
name: 'Enable infinite scrolling',
|
||||
description: 'Experimental. Request more results by scrolling to the bottom of the logs list.',
|
||||
defaultValue: false,
|
||||
})
|
||||
.addRadio({
|
||||
path: 'dedupStrategy',
|
||||
name: 'Deduplication',
|
||||
|
||||
@@ -26,15 +26,16 @@ composableKinds: PanelCfg: {
|
||||
version: [0, 0]
|
||||
schema: {
|
||||
Options: {
|
||||
showLabels: bool
|
||||
showCommonLabels: bool
|
||||
showTime: bool
|
||||
showLogContextToggle: bool
|
||||
wrapLogMessage: bool
|
||||
prettifyLogMessage: bool
|
||||
enableLogDetails: bool
|
||||
showLabels: bool
|
||||
showCommonLabels: bool
|
||||
showTime: bool
|
||||
showLogContextToggle: bool
|
||||
wrapLogMessage: bool
|
||||
prettifyLogMessage: bool
|
||||
enableLogDetails: bool
|
||||
sortOrder: common.LogsSortOrder
|
||||
dedupStrategy: common.LogsDedupStrategy
|
||||
enableInfiniteScrolling?: bool
|
||||
// TODO: figure out how to define callbacks
|
||||
onClickFilterLabel?: _
|
||||
onClickFilterOutLabel?: _
|
||||
@@ -45,6 +46,7 @@ composableKinds: PanelCfg: {
|
||||
onClickHideField?: _
|
||||
logRowMenuIconsBefore?: _
|
||||
logRowMenuIconsAfter?: _
|
||||
onNewLogsReceived?: _
|
||||
displayedFields?: [...string]
|
||||
} @cuetsy(kind="interface")
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import * as common from '@grafana/schema';
|
||||
export interface Options {
|
||||
dedupStrategy: common.LogsDedupStrategy;
|
||||
displayedFields?: Array<string>;
|
||||
enableInfiniteScrolling?: boolean;
|
||||
enableLogDetails: boolean;
|
||||
isFilterLabelActive?: unknown;
|
||||
logRowMenuIconsAfter?: unknown;
|
||||
@@ -26,6 +27,7 @@ export interface Options {
|
||||
onClickFilterString?: unknown;
|
||||
onClickHideField?: unknown;
|
||||
onClickShowField?: unknown;
|
||||
onNewLogsReceived?: unknown;
|
||||
prettifyLogMessage: boolean;
|
||||
showCommonLabels: boolean;
|
||||
showLabels: boolean;
|
||||
|
||||
@@ -11,6 +11,7 @@ type onClickFilterOutStringType = (value: string, refId?: string) => void;
|
||||
type isFilterLabelActiveType = (key: string, value: string, refId?: string) => Promise<boolean>;
|
||||
type isOnClickShowFieldType = (value: string) => void;
|
||||
type isOnClickHideFieldType = (value: string) => void;
|
||||
export type onNewLogsReceivedType = (allLogs: DataFrame[], newLogs: DataFrame[]) => void;
|
||||
|
||||
export function isOnClickFilterLabel(callback: unknown): callback is onClickFilterLabelType {
|
||||
return typeof callback === 'function';
|
||||
@@ -40,6 +41,10 @@ export function isOnClickHideField(callback: unknown): callback is isOnClickHide
|
||||
return typeof callback === 'function';
|
||||
}
|
||||
|
||||
export function isOnNewLogsReceivedType(callback: unknown): callback is onNewLogsReceivedType {
|
||||
return typeof callback === 'function';
|
||||
}
|
||||
|
||||
export function isReactNodeArray(node: unknown): node is ReactNode[] {
|
||||
return Array.isArray(node) && node.every(React.isValidElement);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user