New Logs Panel: Render new panel using the current visualization (#105968)
Actionlint / Lint GitHub Actions files (push) Waiting to run Details
Backend Code Checks / Validate Backend Configs (push) Waiting to run Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (1/8) (push) Waiting to run Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (2/8) (push) Waiting to run Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (3/8) (push) Waiting to run Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (4/8) (push) Waiting to run Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (5/8) (push) Waiting to run Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (6/8) (push) Waiting to run Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (7/8) (push) Waiting to run Details
Backend Unit Tests / Grafana (${{ matrix.shard }}) (8/8) (push) Waiting to run Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (1/8) (push) Waiting to run Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (2/8) (push) Waiting to run Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (3/8) (push) Waiting to run Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (4/8) (push) Waiting to run Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (5/8) (push) Waiting to run Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (6/8) (push) Waiting to run Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (7/8) (push) Waiting to run Details
Backend Unit Tests / Grafana Enterprise (${{ matrix.shard }}) (8/8) (push) Waiting to run Details
CodeQL checks / Analyze (actions) (push) Waiting to run Details
CodeQL checks / Analyze (go) (push) Waiting to run Details
CodeQL checks / Analyze (javascript) (push) Waiting to run Details
CodeQL checks / Analyze (python) (push) Waiting to run Details
Lint Frontend / Verify i18n (push) Waiting to run Details
Lint Frontend / Lint (push) Waiting to run Details
Lint Frontend / Typecheck (push) Waiting to run Details
Lint Frontend / Betterer (push) Waiting to run Details
End-to-end tests / Build & Package Grafana (push) Waiting to run Details
End-to-end tests / ${{ matrix.suite }} (dashboards-suite) (push) Blocked by required conditions Details
End-to-end tests / ${{ matrix.suite }} (panels-suite) (push) Blocked by required conditions Details
End-to-end tests / ${{ matrix.suite }} (smoke-tests-suite) (push) Blocked by required conditions Details
End-to-end tests / ${{ matrix.suite }} (various-suite) (push) Blocked by required conditions Details
End-to-end tests / ${{ matrix.suite }} (old arch) (old-arch/dashboards-suite) (push) Blocked by required conditions Details
End-to-end tests / ${{ matrix.suite }} (old arch) (old-arch/panels-suite) (push) Blocked by required conditions Details
End-to-end tests / ${{ matrix.suite }} (old arch) (old-arch/smoke-tests-suite) (push) Blocked by required conditions Details
End-to-end tests / ${{ matrix.suite }} (old arch) (old-arch/various-suite) (push) Blocked by required conditions Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (1) (push) Waiting to run Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (2) (push) Waiting to run Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (3) (push) Waiting to run Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (4) (push) Waiting to run Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (5) (push) Waiting to run Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (6) (push) Waiting to run Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (7) (push) Waiting to run Details
Frontend tests / Unit tests (${{ matrix.chunk }} / 8) (8) (push) Waiting to run Details
Integration Tests / Sqlite (${{ matrix.shard }}) (1/8) (push) Waiting to run Details
Integration Tests / Sqlite (${{ matrix.shard }}) (2/8) (push) Waiting to run Details
Integration Tests / Sqlite (${{ matrix.shard }}) (3/8) (push) Waiting to run Details
Integration Tests / Sqlite (${{ matrix.shard }}) (4/8) (push) Waiting to run Details
Integration Tests / Sqlite (${{ matrix.shard }}) (5/8) (push) Waiting to run Details
Integration Tests / Sqlite (${{ matrix.shard }}) (6/8) (push) Waiting to run Details
Integration Tests / Sqlite (${{ matrix.shard }}) (7/8) (push) Waiting to run Details
Integration Tests / Sqlite (${{ matrix.shard }}) (8/8) (push) Waiting to run Details
Integration Tests / MySQL (${{ matrix.shard }}) (1/8) (push) Waiting to run Details
Integration Tests / MySQL (${{ matrix.shard }}) (2/8) (push) Waiting to run Details
Integration Tests / MySQL (${{ matrix.shard }}) (3/8) (push) Waiting to run Details
Integration Tests / MySQL (${{ matrix.shard }}) (4/8) (push) Waiting to run Details
Integration Tests / MySQL (${{ matrix.shard }}) (5/8) (push) Waiting to run Details
Integration Tests / MySQL (${{ matrix.shard }}) (6/8) (push) Waiting to run Details
Integration Tests / MySQL (${{ matrix.shard }}) (7/8) (push) Waiting to run Details
Integration Tests / MySQL (${{ matrix.shard }}) (8/8) (push) Waiting to run Details
Integration Tests / Postgres (${{ matrix.shard }}) (1/8) (push) Waiting to run Details
Integration Tests / Postgres (${{ matrix.shard }}) (2/8) (push) Waiting to run Details
Integration Tests / Postgres (${{ matrix.shard }}) (3/8) (push) Waiting to run Details
Integration Tests / Postgres (${{ matrix.shard }}) (4/8) (push) Waiting to run Details
Integration Tests / Postgres (${{ matrix.shard }}) (5/8) (push) Waiting to run Details
Integration Tests / Postgres (${{ matrix.shard }}) (6/8) (push) Waiting to run Details
Integration Tests / Postgres (${{ matrix.shard }}) (7/8) (push) Waiting to run Details
Integration Tests / Postgres (${{ matrix.shard }}) (8/8) (push) Waiting to run Details
Reject GitHub secrets / reject-gh-secrets (push) Waiting to run Details
Run dashboard schema v2 e2e / dashboard-schema-v2-e2e (push) Waiting to run Details
Dispatch sync to mirror / dispatch-job (push) Waiting to run Details
publish-kinds-next / main (push) Has been cancelled Details

* LogsPanel: integrate new panel via feature flag

* Log Line: move sampled/errors/deduplication count outside of log line body

* LogList: increase overscan count

* Logs Panel: enable deduplication for infinite scrolling

* Logs Panel: remove margin overflowing drilldown

* Logs Panel: add missing dependency to effect

* Logs Panel: pass missing callback

* Remove console log

* LogLine: show cursor pointer only when interactable

* LogLineDetails: make resize handler more obvious

* LogsPanel: add missing props to from panel

* LogLineMenu: add support for custom items

* LogsPanel: pass custom menu items to LogList

* Fix imports

* Chore: comments and missing argument

* LogLineMenu: pass log to event listener

* LogListContext: filter log details when no longer present in the new response

* chore: log

* LogsPanel: conditionally show options per feature flag status

* LogLine: align logs when some of them are sampled or with errors

* Chore: update tests

* LogLineMenu: test custom options

* LogsSamplePanel: show controls

* LogsPanel: move return after hooks to prevent bugs
This commit is contained in:
Matias Chomicki 2025-06-01 14:29:49 +02:00 committed by GitHub
parent 5b4d188638
commit b3596e8c72
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 329 additions and 107 deletions

View File

@ -19,6 +19,7 @@ export interface Options {
enableInfiniteScrolling?: boolean;
enableLogDetails: boolean;
isFilterLabelActive?: unknown;
logLineMenuCustomItems?: unknown;
logRowMenuIconsAfter?: unknown;
logRowMenuIconsBefore?: unknown;
/**

View File

@ -24,7 +24,7 @@ import { LogRows } from '../../logs/components/LogRows';
import { dataFrameToLogsModel } from '../../logs/logsModel';
import { SupplementaryResultError } from '../SupplementaryResultError';
import { SETTINGS_KEYS } from './utils/logs';
import { SETTING_KEY_ROOT, SETTINGS_KEYS } from './utils/logs';
type Props = {
queryResponse: DataQueryResponse | undefined;
@ -118,8 +118,9 @@ export function LogsSamplePanel(props: Props) {
enableLogDetails
dedupStrategy={LogsDedupStrategy.none}
displayedFields={[]}
logOptionsStorageKey={SETTING_KEY_ROOT}
logs={logs.rows}
showControls={false}
showControls
showTime={store.getBool(SETTINGS_KEYS.showTime, true)}
sortOrder={store.get(SETTINGS_KEYS.logsSortOrder) || LogsSortOrder.Descending}
timeRange={props.timeRange}

View File

@ -22,7 +22,6 @@ const contextProps = {
app: CoreApp.Unknown,
dedupStrategy: LogsDedupStrategy.exact,
displayedFields: [],
logs: [],
showControls: false,
showTime: false,
sortOrder: LogsSortOrder.Ascending,
@ -33,6 +32,7 @@ describe('LogLine', () => {
let log: LogListModel, defaultProps: Props;
beforeEach(() => {
log = createLogLine({ labels: { place: 'luna' }, entry: `log message 1` });
contextProps.logs = [log];
defaultProps = {
displayedFields: [],
index: 0,
@ -106,9 +106,10 @@ describe('LogLine', () => {
test('Shows log lines with errors', async () => {
log.hasError = true;
log.labels.__error__ = 'error message';
jest.spyOn(log, 'errorMessage', 'get').mockReturnValue('error message');
render(
<LogListContextProvider {...contextProps} dedupStrategy={LogsDedupStrategy.signature}>
<LogListContextProvider {...contextProps} dedupStrategy={LogsDedupStrategy.signature} logs={[log]}>
<LogLine {...defaultProps} />
</LogListContextProvider>
);
@ -118,9 +119,10 @@ describe('LogLine', () => {
test('Shows sampled log lines', async () => {
log.isSampled = true;
log.labels.__adaptive_logs_sampled__ = 'true';
jest.spyOn(log, 'sampledMessage', 'get').mockReturnValue('sampled message');
render(
<LogListContextProvider {...contextProps} dedupStrategy={LogsDedupStrategy.signature}>
<LogListContextProvider {...contextProps} dedupStrategy={LogsDedupStrategy.signature} logs={[log]}>
<LogLine {...defaultProps} />
</LogListContextProvider>
);

View File

@ -45,7 +45,8 @@ export const LogLine = ({
variant,
wrapLogMessage,
}: Props) => {
const { detailsDisplayed, onLogLineHover } = useLogListContext();
const { detailsDisplayed, dedupStrategy, enableLogDetails, hasLogsWithErrors, hasSampledLogs, onLogLineHover } =
useLogListContext();
const [collapsed, setCollapsed] = useState<boolean | undefined>(
wrapLogMessage && log.collapsed !== undefined ? log.collapsed : undefined
);
@ -99,10 +100,49 @@ export const LogLine = ({
onFocus={handleMouseOver}
>
<LogLineMenu styles={styles} log={log} />
{dedupStrategy !== LogsDedupStrategy.none && (
<div className={`${styles.duplicates}`}>
{log.duplicates && log.duplicates > 0 ? `${log.duplicates + 1}x` : null}
</div>
)}
{hasLogsWithErrors && (
<div className={`${styles.hasError}`}>
{log.hasError && (
<Tooltip
content={t('logs.log-line.tooltip-error', 'Error: {{errorMessage}}', {
errorMessage: log.errorMessage,
})}
placement="right"
theme="error"
>
<Icon
className={styles.logIconError}
name="exclamation-triangle"
aria-label={t('logs.log-line.has-error', 'Has errors')}
size="xs"
/>
</Tooltip>
)}
</div>
)}
{hasSampledLogs && (
<div className={`${styles.isSampled}`}>
{log.isSampled && (
<Tooltip content={log.sampledMessage ?? ''} placement="right" theme="info">
<Icon
className={styles.logIconInfo}
name="info-circle"
size="xs"
aria-label={t('logs.log-line.is-sampled', 'Is sampled')}
/>
</Tooltip>
)}
</div>
)}
{/* A button element could be used but in Safari it prevents text selection. Fallback available for a11y in LogLineMenu */}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */}
<div
className={`${wrapLogMessage ? styles.wrappedLogLine : `${styles.unwrappedLogLine} unwrapped-log-line`} ${collapsed === true ? styles.collapsedLogLine : ''}`}
className={`${wrapLogMessage ? styles.wrappedLogLine : `${styles.unwrappedLogLine} unwrapped-log-line`} ${collapsed === true ? styles.collapsedLogLine : ''} ${enableLogDetails ? styles.clickable : ''}`}
onClick={handleClick}
>
<Log
@ -153,43 +193,8 @@ interface LogProps {
}
const Log = ({ displayedFields, log, showTime, styles, wrapLogMessage }: LogProps) => {
const { dedupStrategy } = useLogListContext();
const { t } = useTranslate();
return (
<>
{dedupStrategy !== LogsDedupStrategy.none && (
<span className={`${styles.duplicates} field`}>
{log.duplicates && log.duplicates > 0 ? `${log.duplicates + 1}x` : null}
</span>
)}
{log.hasError && (
<span className={`${styles.hasError} field`}>
<Tooltip
content={t('logs.log-line.tooltip-error', 'Error: {{errorMessage}}', { errorMessage: log.errorMessage })}
placement="right"
theme="error"
>
<Icon
className={styles.logIconError}
name="exclamation-triangle"
aria-label={t('logs.log-line.has-error', 'Has errors')}
size="xs"
/>
</Tooltip>
</span>
)}
{log.isSampled && (
<span className={`${styles.isSampled} field`}>
<Tooltip content={log.sampledMessage ?? ''} placement="right" theme="info">
<Icon
className={styles.logIconInfo}
name="info-circle"
size="xs"
aria-label={t('logs.log-line.is-sampled', 'Is sampled')}
/>
</Tooltip>
</span>
)}
{showTime && <span className={`${styles.timestamp} level-${log.logLevel} field`}>{log.timestamp}</span>}
{
// When logs are unwrapped, we want an empty column space to align with other log lines.
@ -334,12 +339,12 @@ export const getStyles = (theme: GrafanaTheme2) => {
display: 'inline-block',
}),
duplicates: css({
display: 'inline-block',
flexShrink: 0,
textAlign: 'center',
width: theme.spacing(4.5),
}),
hasError: css({
display: 'inline-block',
flexShrink: 0,
width: theme.spacing(2),
'& svg': {
position: 'relative',
@ -347,7 +352,7 @@ export const getStyles = (theme: GrafanaTheme2) => {
},
}),
isSampled: css({
display: 'inline-block',
flexShrink: 0,
width: theme.spacing(2),
'& svg': {
position: 'relative',
@ -389,15 +394,16 @@ export const getStyles = (theme: GrafanaTheme2) => {
overflows: css({
outline: 'solid 1px red',
}),
unwrappedLogLine: css({
clickable: css({
cursor: 'pointer',
}),
unwrappedLogLine: css({
display: 'grid',
gridColumnGap: theme.spacing(FIELD_GAP_MULTIPLIER),
whiteSpace: 'pre',
paddingBottom: theme.spacing(0.75),
}),
wrappedLogLine: css({
cursor: 'pointer',
alignSelf: 'flex-start',
paddingBottom: theme.spacing(0.75),
whiteSpace: 'pre-wrap',

View File

@ -54,7 +54,7 @@ export const LogLineDetails = ({ containerElement, getFieldLinks, logs, onResize
return (
<Resizable
onResize={handleResize}
handleClasses={{ left: dragStyles.dragHandleBaseVertical }}
handleClasses={{ left: dragStyles.dragHandleVertical }}
defaultSize={{ width: detailsWidth, height: containerElement.clientHeight }}
enable={{ left: true }}
minWidth={40}

View File

@ -6,7 +6,7 @@ import { CoreApp, createTheme, LogsDedupStrategy, LogsSortOrder } from '@grafana
import { createLogLine } from '../__mocks__/logRow';
import { getStyles } from './LogLine';
import { LogLineMenu } from './LogLineMenu';
import { LogLineMenu, LogLineMenuCustomItem } from './LogLineMenu';
import { LogListContextProvider } from './LogListContext';
import { defaultProps, defaultValue } from './__mocks__/LogListContext';
import { LogListModel } from './processing';
@ -54,6 +54,33 @@ describe('LogLineMenu', () => {
expect(onPermalinkClick).toHaveBeenCalledTimes(1);
});
test('Allows to copy a permalink', async () => {
const customOption1onClick = jest.fn();
const logLineMenuCustomItems: LogLineMenuCustomItem[] = [
{
label: 'Custom option 1',
onClick: customOption1onClick,
},
{
divider: true,
},
{
label: 'Custom option 2',
onClick: jest.fn(),
},
];
render(
<LogListContextProvider {...contextProps} logLineMenuCustomItems={logLineMenuCustomItems}>
<LogLineMenu log={log} styles={styles} />
</LogListContextProvider>
);
await userEvent.click(screen.getByLabelText('Log menu'));
await screen.findByText('Custom option 1');
await screen.findByText('Custom option 2');
await userEvent.click(screen.getByText('Custom option 1'));
expect(customOption1onClick).toHaveBeenCalledTimes(1);
});
test('Allows to open show context', async () => {
const onOpenContext = jest.fn();
const logSupportsContext = jest.fn().mockReturnValue(true);

View File

@ -17,6 +17,17 @@ export type GetRowContextQueryFn = (
cacheFilters?: boolean
) => Promise<DataQuery | null>;
type MenuItem = {
label: string;
onClick(log: LogListModel): void;
};
type MenuItemDivider = {
divider: true;
};
export type LogLineMenuCustomItem = MenuItem | MenuItemDivider;
interface Props {
log: LogListModel;
styles: LogLineStyles;
@ -31,6 +42,7 @@ export const LogLineMenu = ({ log, styles }: Props) => {
onPermalinkClick,
onPinLine,
onUnpinLine,
logLineMenuCustomItems = [],
logSupportsContext,
toggleDetails,
} = useLogListContext();
@ -98,6 +110,15 @@ export const LogLineMenu = ({ log, styles }: Props) => {
{onPermalinkClick && log.rowId !== undefined && log.uid && (
<Menu.Item onClick={copyLinkToLogLine} label={t('logs.log-line-menu.copy-link', 'Copy link to log line')} />
)}
{logLineMenuCustomItems.map((item, i) => {
if (isDivider(item)) {
return <Menu.Divider key={i} />;
}
if (isItem(item)) {
return <Menu.Item onClick={() => item.onClick(log)} label={item.label} key={i} />;
}
return null;
})}
</Menu>
),
[
@ -106,6 +127,7 @@ export const LogLineMenu = ({ log, styles }: Props) => {
detailsDisplayed,
enableLogDetails,
log,
logLineMenuCustomItems,
onPermalinkClick,
onPinLine,
onUnpinLine,
@ -128,3 +150,11 @@ export const LogLineMenu = ({ log, styles }: Props) => {
</Dropdown>
);
};
function isDivider(item: LogLineMenuCustomItem) {
return 'divider' in item && item.divider;
}
function isItem(item: LogLineMenuCustomItem) {
return 'onClick' in item && 'label' in item;
}

View File

@ -24,7 +24,7 @@ import { GetFieldLinksFn } from 'app/plugins/panel/logs/types';
import { InfiniteScroll } from './InfiniteScroll';
import { getGridTemplateColumns } from './LogLine';
import { LogLineDetails } from './LogLineDetails';
import { GetRowContextQueryFn } from './LogLineMenu';
import { GetRowContextQueryFn, LogLineMenuCustomItem } from './LogLineMenu';
import { LogListContextProvider, LogListState, useLogListContext } from './LogListContext';
import { LogListControls } from './LogListControls';
import { preProcessLogs, LogListModel } from './processing';
@ -53,6 +53,7 @@ export interface Props {
isLabelFilterActive?: (key: string, value: string, refId?: string) => Promise<boolean>;
loading?: boolean;
loadMore?: (range: AbsoluteTimeRange) => void;
logLineMenuCustomItems?: LogLineMenuCustomItem[];
logOptionsStorageKey?: string;
logs: LogRowModel[];
logsMeta?: LogsMetaItem[];
@ -111,6 +112,7 @@ export const LogList = ({
isLabelFilterActive,
loading,
loadMore,
logLineMenuCustomItems,
logOptionsStorageKey,
logs,
logsMeta,
@ -150,6 +152,7 @@ export const LogList = ({
isLabelFilterActive={isLabelFilterActive}
logs={logs}
logsMeta={logsMeta}
logLineMenuCustomItems={logLineMenuCustomItems}
logOptionsStorageKey={logOptionsStorageKey}
logSupportsContext={logSupportsContext}
onClickFilterLabel={onClickFilterLabel}
@ -209,6 +212,8 @@ const LogListComponent = ({
dedupStrategy,
filterLevels,
forceEscape,
hasLogsWithErrors,
hasSampledLogs,
permalinkedLogId,
showDetails,
showTime,
@ -265,7 +270,7 @@ const LogListComponent = ({
useEffect(() => {
listRef.current?.resetAfterIndex(0);
}, [wrapLogMessage, showDetails, displayedFields]);
}, [wrapLogMessage, showDetails, displayedFields, dedupStrategy]);
useEffect(() => {
const handleResize = debounce(() => {
@ -362,6 +367,8 @@ const LogListComponent = ({
height={listHeight}
itemCount={itemCount}
itemSize={getLogLineSize.bind(null, filteredLogs, widthContainer, displayedFields, {
hasLogsWithErrors,
hasSampledLogs,
showDuplicates: dedupStrategy !== LogsDedupStrategy.none,
showTime,
wrap: wrapLogMessage,
@ -370,6 +377,7 @@ const LogListComponent = ({
layout="vertical"
onItemsRendered={onItemsRendered}
outerRef={scrollRef}
overscanCount={5}
ref={listRef}
style={{ overflowY: 'scroll' }}
width="100%"

View File

@ -6,6 +6,7 @@ import {
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
@ -22,9 +23,9 @@ import {
} from '@grafana/data';
import { PopoverContent } from '@grafana/ui';
import { DownloadFormat, downloadLogs as download } from '../../utils';
import { DownloadFormat, checkLogsError, checkLogsSampled, downloadLogs as download } from '../../utils';
import { GetRowContextQueryFn } from './LogLineMenu';
import { GetRowContextQueryFn, LogLineMenuCustomItem } from './LogLineMenu';
import { LogListModel } from './processing';
export interface LogListContextData extends Omit<Props, 'containerElement' | 'logs' | 'logsMeta' | 'showControls'> {
@ -34,7 +35,10 @@ export interface LogListContextData extends Omit<Props, 'containerElement' | 'lo
downloadLogs: (format: DownloadFormat) => void;
enableLogDetails: boolean;
filterLevels: LogLevel[];
hasLogsWithErrors?: boolean;
hasSampledLogs?: boolean;
hasUnescapedContent?: boolean;
logLineMenuCustomItems?: LogLineMenuCustomItem[];
setDedupStrategy: (dedupStrategy: LogsDedupStrategy) => void;
setDetailsWidth: (width: number) => void;
setFilterLevels: (filterLevels: LogLevel[]) => void;
@ -129,6 +133,7 @@ export interface Props {
getRowContextQuery?: GetRowContextQueryFn;
isLabelFilterActive?: (key: string, value: string, refId?: string) => Promise<boolean>;
logs: LogRowModel[];
logLineMenuCustomItems?: LogLineMenuCustomItem[];
logsMeta?: LogsMetaItem[];
logOptionsStorageKey?: string;
logSupportsContext?: (row: LogRowModel) => boolean;
@ -169,6 +174,7 @@ export const LogListContextProvider = ({
isLabelFilterActive,
getRowContextQuery,
logs,
logLineMenuCustomItems,
logsMeta,
logOptionsStorageKey,
logSupportsContext,
@ -260,6 +266,18 @@ export const LogListContextProvider = ({
}
}, [logListState, pinnedLogs]);
useEffect(() => {
if (!showDetails.length) {
return;
}
const newShowDetails = showDetails.filter(
(expandedLog) => logs.findIndex((log) => log.uid === expandedLog.uid) >= 0
);
if (newShowDetails.length !== showDetails.length) {
setShowDetails(newShowDetails);
}
}, [logs, showDetails]);
const detailsDisplayed = useCallback(
(log: LogListModel) => !!showDetails.find((shownLog) => shownLog.uid === log.uid),
[showDetails]
@ -403,6 +421,9 @@ export const LogListContextProvider = ({
[logOptionsStorageKey]
);
const hasLogsWithErrors = useMemo(() => logs.some((log) => !!checkLogsError(log)), [logs]);
const hasSampledLogs = useMemo(() => logs.some((log) => !!checkLogsSampled(log)), [logs]);
const defaultWidth = (containerElement?.clientWidth ?? 0) * 0.4;
const detailsWidth = logOptionsStorageKey
? parseInt(store.get(`${logOptionsStorageKey}.detailsWidth`), 10)
@ -421,10 +442,13 @@ export const LogListContextProvider = ({
enableLogDetails,
filterLevels: logListState.filterLevels,
forceEscape: logListState.forceEscape,
hasLogsWithErrors,
hasSampledLogs,
hasUnescapedContent: logListState.hasUnescapedContent,
isLabelFilterActive,
getRowContextQuery,
logSupportsContext,
logLineMenuCustomItems,
onClickFilterLabel,
onClickFilterOutLabel,
onClickFilterString,

View File

@ -1,6 +1,7 @@
import { createContext, useContext } from 'react';
import { CoreApp, LogsDedupStrategy, LogsSortOrder } from '@grafana/data';
import { checkLogsError, checkLogsSampled } from 'app/features/logs/utils';
import { LogListContextData, Props } from '../LogListContext';
import { LogListModel } from '../processing';
@ -114,6 +115,8 @@ export const LogListContextProvider = ({
enableLogDetails = false,
filterLevels = [],
getRowContextQuery = jest.fn(),
logLineMenuCustomItems = undefined,
logs = [],
logSupportsContext = jest.fn(),
onLogLineHover,
onPermalinkClick = jest.fn(),
@ -127,6 +130,9 @@ export const LogListContextProvider = ({
syntaxHighlighting = true,
wrapLogMessage = true,
}: Partial<Props>) => {
const hasLogsWithErrors = logs.some((log) => !!checkLogsError(log));
const hasSampledLogs = logs.some((log) => !!checkLogsSampled(log));
return (
<LogListContext.Provider
value={{
@ -136,8 +142,11 @@ export const LogListContextProvider = ({
displayedFields,
downloadLogs: jest.fn(),
enableLogDetails,
hasLogsWithErrors,
hasSampledLogs,
filterLevels,
getRowContextQuery,
logLineMenuCustomItems,
logSupportsContext,
onLogLineHover,
onPermalinkClick,

View File

@ -14,6 +14,13 @@ const THREE_LINES_HEIGHT = 3 * LINE_HEIGHT + PADDING_BOTTOM;
let LETTER_WIDTH: number;
let CONTAINER_SIZE = 200;
let TWO_LINES_OF_CHARACTERS: number;
const defaultOptions = {
wrap: false,
showTime: false,
showDuplicates: false,
hasLogsWithErrors: false,
hasSampledLogs: false,
};
describe('Virtualization', () => {
let log: LogListModel, container: HTMLDivElement;
@ -28,7 +35,7 @@ describe('Virtualization', () => {
describe('getLogLineSize', () => {
test('Returns the a single line if the display mode is unwrapped', () => {
const size = getLogLineSize([log], container, [], { wrap: false, showTime: true, showDuplicates: false }, 0);
const size = getLogLineSize([log], container, [], { ...defaultOptions, showTime: true }, 0);
expect(size).toBe(SINGLE_LINE_HEIGHT);
});
@ -38,7 +45,7 @@ describe('Virtualization', () => {
logs,
container,
[],
{ wrap: true, showTime: true, showDuplicates: false },
{ ...defaultOptions, wrap: true, showTime: true },
logs.length + 1
);
expect(size).toBe(SINGLE_LINE_HEIGHT);
@ -48,12 +55,18 @@ describe('Virtualization', () => {
// Very small container
log.collapsed = true;
jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(10);
const size = getLogLineSize([log], container, [], { wrap: true, showTime: true, showDuplicates: false }, 0);
const size = getLogLineSize([log], container, [], { ...defaultOptions, wrap: true, showTime: true }, 0);
expect(size).toBe((TRUNCATION_LINE_COUNT + 1) * LINE_HEIGHT);
});
test.each([true, false])('Measures a log line with controls %s and displayed time %s', (showTime: boolean) => {
const size = getLogLineSize([log], container, [], { wrap: true, showTime, showDuplicates: false }, 0);
const size = getLogLineSize(
[log],
container,
[],
{ wrap: true, showTime, showDuplicates: false, hasLogsWithErrors: false, hasSampledLogs: false },
0
);
expect(size).toBe(SINGLE_LINE_HEIGHT);
});
@ -64,14 +77,14 @@ describe('Virtualization', () => {
logLevel: undefined,
});
const size = getLogLineSize([log], container, [], { wrap: true, showTime: false, showDuplicates: false }, 0);
const size = getLogLineSize([log], container, [], { ...defaultOptions, wrap: true }, 0);
expect(size).toBe(TWO_LINES_HEIGHT);
});
test('Measures a multi-line log line with level, controls, and displayed time', () => {
log = createLogLine({ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') });
const size = getLogLineSize([log], container, [], { wrap: true, showTime: true, showDuplicates: false }, 0);
const size = getLogLineSize([log], container, [], { ...defaultOptions, wrap: true, showTime: true }, 0);
// Two lines for the log and one extra for level and time
expect(size).toBe(THREE_LINES_HEIGHT);
});
@ -87,7 +100,7 @@ describe('Virtualization', () => {
[log],
container,
['place', LOG_LINE_BODY_FIELD_NAME],
{ wrap: true, showTime: false, showDuplicates: false },
{ ...defaultOptions, wrap: true },
0
);
// Two lines for the log and one extra for the displayed fields
@ -97,13 +110,7 @@ describe('Virtualization', () => {
test('Measures displayed fields in a log line with level, controls, and displayed time', () => {
log = createLogLine({ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') });
const size = getLogLineSize(
[log],
container,
['place'],
{ wrap: true, showTime: true, showDuplicates: false },
0
);
const size = getLogLineSize([log], container, ['place'], { ...defaultOptions, wrap: true, showTime: true }, 0);
// Only renders a short displayed field, so a single line
expect(size).toBe(SINGLE_LINE_HEIGHT);
});
@ -112,25 +119,23 @@ describe('Virtualization', () => {
log = createLogLine({ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') });
log.duplicates = 1;
const size = getLogLineSize([log], container, [], { wrap: true, showTime: false, showDuplicates: true }, 0);
const size = getLogLineSize([log], container, [], { ...defaultOptions, wrap: true, showDuplicates: true }, 0);
// Two lines for the log and one extra for duplicates
expect(size).toBe(THREE_LINES_HEIGHT);
});
test('Measures a multi-line log line with errors', () => {
log = createLogLine({ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') });
log.hasError = true;
const size = getLogLineSize([log], container, [], { wrap: true, showTime: false, showDuplicates: false }, 0);
const size = getLogLineSize([log], container, [], { ...defaultOptions, wrap: true, hasLogsWithErrors: true }, 0);
// Two lines for the log and one extra for the error icon
expect(size).toBe(THREE_LINES_HEIGHT);
});
test('Measures a multi-line sampled log line', () => {
log = createLogLine({ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') });
log.isSampled = true;
const size = getLogLineSize([log], container, [], { wrap: true, showTime: false, showDuplicates: false }, 0);
const size = getLogLineSize([log], container, [], { ...defaultOptions, wrap: true, hasSampledLogs: true }, 0);
// Two lines for the log and one extra for the sampled icon
expect(size).toBe(THREE_LINES_HEIGHT);
});
@ -138,7 +143,7 @@ describe('Virtualization', () => {
test('Adds an extra line for the expand/collapse controls if present', () => {
jest.spyOn(log, 'updateCollapsedState').mockImplementation(() => undefined);
log.collapsed = false;
const size = getLogLineSize([log], container, [], { wrap: true, showTime: false, showDuplicates: false }, 0);
const size = getLogLineSize([log], container, [], { ...defaultOptions, wrap: true }, 0);
expect(size).toBe(TWO_LINES_HEIGHT);
});
});

View File

@ -147,6 +147,8 @@ export function measureTextHeight(text: string, maxWidth: number, beforeWidth =
}
interface DisplayOptions {
hasLogsWithErrors?: boolean;
hasSampledLogs?: boolean;
showDuplicates: boolean;
showTime: boolean;
wrap: boolean;
@ -156,7 +158,7 @@ export function getLogLineSize(
logs: LogListModel[],
container: HTMLDivElement | null,
displayedFields: string[],
{ showDuplicates, showTime, wrap }: DisplayOptions,
{ hasLogsWithErrors, hasSampledLogs, showDuplicates, showTime, wrap }: DisplayOptions,
index: number
) {
if (!container) {
@ -179,15 +181,16 @@ export function getLogLineSize(
let textToMeasure = '';
const gap = gridSize * FIELD_GAP_MULTIPLIER;
const iconsGap = gridSize * 0.5;
let optionsWidth = 0;
if (showDuplicates) {
optionsWidth += gridSize * 4.5 + gap;
optionsWidth += gridSize * 4.5 + iconsGap;
}
if (logs[index].hasError) {
optionsWidth += gridSize * 2 + gap;
if (hasLogsWithErrors) {
optionsWidth += gridSize * 2 + iconsGap;
}
if (logs[index].isSampled) {
optionsWidth += gridSize * 2 + gap;
if (hasSampledLogs) {
optionsWidth += gridSize * 2 + iconsGap;
}
if (showTime) {
optionsWidth += gap;

View File

@ -37,6 +37,7 @@ import { getFieldLinksForExplore } from 'app/features/explore/utils/links';
import { ControlledLogRows } from 'app/features/logs/components/ControlledLogRows';
import { InfiniteScroll } from 'app/features/logs/components/InfiniteScroll';
import { LogRowContextModal } from 'app/features/logs/components/log-context/LogRowContextModal';
import { LogList } from 'app/features/logs/components/panel/LogList';
import { PanelDataErrorView } from 'app/features/panel/components/PanelDataErrorView';
import { combineResponses } from 'app/plugins/datasource/loki/mergeResponses';
@ -49,6 +50,7 @@ import {
GetFieldLinksFn,
isCoreApp,
isIsFilterLabelActive,
isLogLineMenuCustomItems,
isOnClickFilterLabel,
isOnClickFilterOutLabel,
isOnClickFilterOutString,
@ -108,6 +110,10 @@ interface LogsPanelProps extends PanelProps<Options> {
*
* If controls are enabled, this function is called when a change is made in one of the options from the controls.
* onLogOptionsChange?: (option: keyof LogListControlOptions, value: string | boolean | string[]) => void;
*
* When the feature toggle newLogsPanel is enabled, you can pass extra options to the LogLineMenu component.
* These options are an array of items with { label, onClick } or { divider: true } for dividers.
* logLineMenuCustomItems?: LogLineMenuCustomItem[];
*/
}
interface LogsPermalinkUrlState {
@ -142,6 +148,7 @@ export const LogsPanel = ({
isFilterLabelActive,
logRowMenuIconsBefore,
logRowMenuIconsAfter,
logLineMenuCustomItems,
enableInfiniteScrolling,
onNewLogsReceived,
...options
@ -285,13 +292,19 @@ export const LogsPanel = ({
// Important to memoize stuff here, as panel rerenders a lot for example when resizing.
const [logRows, deduplicatedRows, commonLabels] = useMemo(() => {
const logs = panelData
? dataFrameToLogsModel(panelData.series, panelData.request?.intervalMs, undefined, panelData.request?.targets)
? dataFrameToLogsModel(
panelData.series,
panelData.request?.intervalMs,
undefined,
panelData.request?.targets,
Boolean(enableInfiniteScrolling)
)
: null;
const logRows = logs?.rows || [];
const commonLabels = logs?.meta?.find((m) => m.label === COMMON_LABELS);
const deduplicatedRows = dedupLogRows(logRows, dedupStrategy);
return [logRows, deduplicatedRows, commonLabels];
}, [dedupStrategy, panelData]);
}, [dedupStrategy, enableInfiniteScrolling, panelData]);
const onPermalinkClick = useCallback(
async (row: LogRowModel) => {
@ -307,6 +320,9 @@ export const LogsPanel = ({
}, [data]);
useLayoutEffect(() => {
if (config.featureToggles.newLogsPanel) {
return;
}
if (!logsContainerRef.current || !scrollElement || keepScrollPositionRef.current) {
keepScrollPositionRef.current =
keepScrollPositionRef.current === 'infinite-scroll' ? null : keepScrollPositionRef.current;
@ -449,10 +465,6 @@ export const LogsPanel = ({
[data.request, dataSourcesMap, onNewLogsReceived, panelData, timeZone]
);
if (!data || logRows.length === 0) {
return <PanelDataErrorView fieldConfig={fieldConfig} panelId={id} data={data} needsStringField />;
}
const renderCommonLabels = () => (
<div className={cx(style.labelContainer, isAscending && style.labelContainerAscending)}>
<span className={style.label}>
@ -465,6 +477,31 @@ export const LogsPanel = ({
</div>
);
const initialScrollPosition = useMemo(() => {
/**
* In dashboards, users with newest logs at the bottom have the expectation of keeping the scroll at the bottom
* when new data is received. See https://github.com/grafana/grafana/pull/37634
*/
if (app === CoreApp.Dashboard || app === CoreApp.PanelEditor) {
return sortOrder === LogsSortOrder.Ascending ? 'bottom' : 'top';
}
return 'top';
}, [app, sortOrder]);
const storageKey = useMemo(() => {
if (controlsStorageKey) {
return controlsStorageKey;
}
if (!data.request) {
return undefined;
}
return `${data.request?.dashboardUID}.${id}`;
}, [controlsStorageKey, data.request, id]);
if (!data || logRows.length === 0) {
return <PanelDataErrorView fieldConfig={fieldConfig} panelId={id} data={data} needsStringField />;
}
// Passing callbacks control the display of the filtering buttons. We want to pass it only if onAddAdHocFilter is defined.
const defaultOnClickFilterLabel = onAddAdHocFilter ? handleOnClickFilterLabel : undefined;
const defaultOnClickFilterOutLabel = onAddAdHocFilter ? handleOnClickFilterOutLabel : undefined;
@ -485,7 +522,55 @@ export const LogsPanel = ({
getLogRowContextUi={getLogRowContextUi}
/>
)}
{!showControls ? (
{config.featureToggles.newLogsPanel && (
<div
onMouseLeave={onLogContainerMouseLeave}
className={style.logListContainer}
ref={(element: HTMLDivElement) => setScrollElement(element)}
>
{deduplicatedRows.length > 0 && scrollElement && (
<LogList
app={isCoreApp(app) ? app : CoreApp.Dashboard}
containerElement={scrollElement}
dedupStrategy={dedupStrategy}
displayedFields={displayedFields}
enableLogDetails={enableLogDetails}
getFieldLinks={getFieldLinks}
isLabelFilterActive={isIsFilterLabelActive(isFilterLabelActive) ? isFilterLabelActive : undefined}
initialScrollPosition={initialScrollPosition}
loading={infiniteScrolling}
logLineMenuCustomItems={
isLogLineMenuCustomItems(logLineMenuCustomItems) ? logLineMenuCustomItems : undefined
}
logs={deduplicatedRows}
logSupportsContext={showContextToggle}
loadMore={enableInfiniteScrolling ? loadMoreLogs : undefined}
onClickFilterLabel={
isOnClickFilterLabel(onClickFilterLabel) ? onClickFilterLabel : defaultOnClickFilterLabel
}
onClickFilterOutLabel={
isOnClickFilterOutLabel(onClickFilterOutLabel) ? onClickFilterOutLabel : defaultOnClickFilterOutLabel
}
onClickShowField={displayedFields !== undefined ? onClickShowField : undefined}
onClickHideField={displayedFields !== undefined ? onClickHideField : undefined}
onLogLineHover={onLogRowHover}
onLogOptionsChange={isOnLogOptionsChange(onLogOptionsChange) ? onLogOptionsChange : undefined}
onOpenContext={onOpenContext}
onPermalinkClick={showPermaLink() ? onPermalinkClick : undefined}
permalinkedLogId={getLogsPanelState()?.logs?.id ?? undefined}
showControls={Boolean(showControls)}
showTime={showTime}
sortOrder={sortOrder}
logOptionsStorageKey={storageKey}
syntaxHighlighting={prettifyLogMessage}
timeRange={data.timeRange}
timeZone={timeZone}
wrapLogMessage={wrapLogMessage}
/>
)}
</div>
)}
{!config.featureToggles.newLogsPanel && !showControls && (
<ScrollContainer ref={(scrollElement) => setScrollElement(scrollElement)}>
<div onMouseLeave={onLogContainerMouseLeave} className={style.container} ref={logsContainerRef}>
{showCommonLabels && !isAscending && renderCommonLabels()}
@ -542,7 +627,8 @@ export const LogsPanel = ({
{showCommonLabels && isAscending && renderCommonLabels()}
</div>
</ScrollContainer>
) : (
)}
{!config.featureToggles.newLogsPanel && showControls && (
<div onMouseLeave={onLogContainerMouseLeave} className={style.controlledLogsContainer}>
{showCommonLabels && !isAscending && renderCommonLabels()}
<ControlledLogRows
@ -588,7 +674,7 @@ export const LogsPanel = ({
onLogOptionsChange={isOnLogOptionsChange(onLogOptionsChange) ? onLogOptionsChange : undefined}
logOptionsStorageKey={controlsStorageKey}
// Ascending order causes scroll to stick to the bottom, so previewing is futile
renderPreview={false}
renderPreview={isAscending ? false : true}
/>
{showCommonLabels && isAscending && renderCommonLabels()}
</div>
@ -601,6 +687,13 @@ const getStyles = (theme: GrafanaTheme2) => ({
container: css({
marginBottom: theme.spacing(1.5),
}),
logListContainer: css({
minHeight: '100%',
maxHeight: '100%',
display: 'flex',
flex: 1,
flexDirection: 'column',
}),
controlledLogsContainer: css({
height: '100%',
}),

View File

@ -1,4 +1,5 @@
import { PanelPlugin, LogsSortOrder, LogsDedupStrategy, LogsDedupDescription } from '@grafana/data';
import { config } from '@grafana/runtime';
import { LogsPanel } from './LogsPanel';
import { Options } from './panelcfg.gen';
@ -6,25 +7,30 @@ import { LogsPanelSuggestionsSupplier } from './suggestions';
export const plugin = new PanelPlugin<Options>(LogsPanel)
.setPanelOptions((builder) => {
builder.addBooleanSwitch({
path: 'showTime',
name: 'Time',
description: '',
defaultValue: false,
});
if (!config.featureToggles.newLogsPanel) {
builder
.addBooleanSwitch({
path: 'showLabels',
name: 'Unique labels',
description: '',
defaultValue: false,
})
.addBooleanSwitch({
path: 'showCommonLabels',
name: 'Common labels',
description: '',
defaultValue: false,
});
}
builder
.addBooleanSwitch({
path: 'showTime',
name: 'Time',
description: '',
defaultValue: false,
})
.addBooleanSwitch({
path: 'showLabels',
name: 'Unique labels',
description: '',
defaultValue: false,
})
.addBooleanSwitch({
path: 'showCommonLabels',
name: 'Common labels',
description: '',
defaultValue: false,
})
.addBooleanSwitch({
path: 'wrapLogMessage',
name: 'Wrap lines',
@ -33,7 +39,7 @@ export const plugin = new PanelPlugin<Options>(LogsPanel)
})
.addBooleanSwitch({
path: 'prettifyLogMessage',
name: 'Prettify JSON',
name: config.featureToggles.newLogsPanel ? 'Enable log message highlighting' : 'Prettify JSON',
description: '',
defaultValue: false,
})

View File

@ -49,6 +49,7 @@ composableKinds: PanelCfg: {
onLogOptionsChange?: _
logRowMenuIconsBefore?: _
logRowMenuIconsAfter?: _
logLineMenuCustomItems?: _
onNewLogsReceived?: _
displayedFields?: [...string]
} @cuetsy(kind="interface")

View File

@ -17,6 +17,7 @@ export interface Options {
enableInfiniteScrolling?: boolean;
enableLogDetails: boolean;
isFilterLabelActive?: unknown;
logLineMenuCustomItems?: unknown;
logRowMenuIconsAfter?: unknown;
logRowMenuIconsBefore?: unknown;
/**

View File

@ -1,6 +1,7 @@
import React, { ReactNode } from 'react';
import { CoreApp, DataFrame, Field, LinkModel, ScopedVars } from '@grafana/data';
import { LogLineMenuCustomItem } from 'app/features/logs/components/panel/LogLineMenu';
import { LogListControlOptions } from 'app/features/logs/components/panel/LogList';
export type { Options } from './panelcfg.gen';
@ -66,3 +67,7 @@ export function isCoreApp(app: unknown): app is CoreApp {
const apps = Object.values(CoreApp).map((coreApp) => coreApp.toString());
return typeof app === 'string' && apps.includes(app);
}
export function isLogLineMenuCustomItems(items: unknown): items is LogLineMenuCustomItem[] {
return Array.isArray(items) && items.every((item) => 'divider' in item || ('onClick' in item && 'label' in item));
}