diff --git a/public/app/features/explore/Logs/LogsNavigation.tsx b/public/app/features/explore/Logs/LogsNavigation.tsx
index 945c679bcd0..5c312fa7b37 100644
--- a/public/app/features/explore/Logs/LogsNavigation.tsx
+++ b/public/app/features/explore/Logs/LogsNavigation.tsx
@@ -7,6 +7,7 @@ import { config, reportInteraction } from '@grafana/runtime';
import { DataQuery, TimeZone } from '@grafana/schema';
import { Button, Icon, Spinner, useTheme2 } from '@grafana/ui';
import { TOP_BAR_LEVEL_HEIGHT } from 'app/core/components/AppChrome/types';
+import { t, Trans } from 'app/core/internationalization';
import { LogsNavigationPages } from './LogsNavigationPages';
@@ -19,6 +20,7 @@ type Props = {
logsSortOrder?: LogsSortOrder | null;
onChangeTime: (range: AbsoluteTimeRange) => void;
scrollToTopLogs: () => void;
+ scrollToBottomLogs?: () => void;
addResultsToCache: () => void;
clearCache: () => void;
};
@@ -35,6 +37,7 @@ function LogsNavigation({
loading,
onChangeTime,
scrollToTopLogs,
+ scrollToBottomLogs,
visibleRange,
queries,
clearCache,
@@ -126,7 +129,7 @@ function LogsNavigation({
>
{loading ? : }
- Older logs
+ Older logs
);
@@ -156,7 +159,9 @@ function LogsNavigation({
{loading && }
{onFirstPage || loading ? null : }
- {onFirstPage ? 'Start of range' : 'Newer logs'}
+ {onFirstPage
+ ? t('logs.logs-navigation.start-of-range', 'Start of range')
+ : t('logs.logs-navigation.newer-logs', 'Newer logs')}
);
@@ -178,6 +183,11 @@ function LogsNavigation({
scrollToTopLogs();
}, [scrollToTopLogs]);
+ const onScrollToBottomClick = useCallback(() => {
+ reportInteraction('grafana_explore_logs_scroll_bottom_clicked');
+ scrollToBottomLogs?.();
+ }, [scrollToBottomLogs]);
+
return (
{!config.featureToggles.logsInfiniteScrolling && (
@@ -194,12 +204,23 @@ function LogsNavigation({
{oldestLogsFirst ? newerLogsButton : olderLogsButton}
>
)}
+ {scrollToBottomLogs && (
+
+ )}
@@ -244,6 +265,16 @@ const getStyles = (theme: GrafanaTheme2, oldestLogsFirst: boolean) => {
height: '100%',
whiteSpace: 'normal',
}),
+ scrollToBottomButton: css({
+ width: '40px',
+ height: '40px',
+ display: 'flex',
+ flexDirection: 'column',
+ justifyContent: 'center',
+ alignItems: 'center',
+ position: 'absolute',
+ top: 0,
+ }),
scrollToTopButton: css({
width: '40px',
height: '40px',
diff --git a/public/app/features/logs/components/panel/LogLine.tsx b/public/app/features/logs/components/panel/LogLine.tsx
new file mode 100644
index 00000000000..02b2b464dff
--- /dev/null
+++ b/public/app/features/logs/components/panel/LogLine.tsx
@@ -0,0 +1,116 @@
+import { css } from '@emotion/css';
+import { CSSProperties, useEffect, useRef } from 'react';
+
+import { GrafanaTheme2 } from '@grafana/data';
+import { useTheme2 } from '@grafana/ui';
+
+import { ProcessedLogModel } from './processing';
+import { hasUnderOrOverflow } from './virtualization';
+
+interface Props {
+ index: number;
+ log: ProcessedLogModel;
+ showTime: boolean;
+ style: CSSProperties;
+ onOverflow?: (index: number, id: string, height: number) => void;
+ wrapLogMessage: boolean;
+}
+
+export const LogLine = ({ index, log, style, onOverflow, showTime, wrapLogMessage }: Props) => {
+ const theme = useTheme2();
+ const styles = getStyles(theme);
+ const logLineRef = useRef
(null);
+
+ useEffect(() => {
+ if (!onOverflow || !logLineRef.current) {
+ return;
+ }
+ const calculatedHeight = typeof style.height === 'number' ? style.height : undefined;
+ const actualHeight = hasUnderOrOverflow(logLineRef.current, calculatedHeight);
+ if (actualHeight) {
+ onOverflow(index, log.uid, actualHeight);
+ }
+ }, [index, log.uid, onOverflow, style.height]);
+
+ return (
+
+
+ {showTime && {log.timestamp}}
+ {log.logLevel && {log.logLevel}}
+ {log.body}
+
+
+ );
+};
+
+const getStyles = (theme: GrafanaTheme2) => {
+ const colors = {
+ critical: '#B877D9',
+ error: '#FF5286',
+ warning: '#FBAD37',
+ debug: '#6CCF8E',
+ trace: '#6ed0e0',
+ info: '#6E9FFF',
+ };
+
+ return {
+ logLine: css({
+ color: theme.colors.text.primary,
+ fontFamily: theme.typography.fontFamilyMonospace,
+ fontSize: theme.typography.fontSize,
+ wordBreak: 'break-all',
+ '&:hover': {
+ opacity: 0.9,
+ },
+ }),
+ timestamp: css({
+ color: theme.colors.text.secondary,
+ display: 'inline-block',
+ marginRight: theme.spacing(1),
+ '&.level-critical': {
+ color: colors.critical,
+ },
+ '&.level-error': {
+ color: colors.error,
+ },
+ '&.level-warning': {
+ color: colors.warning,
+ },
+ '&.level-debug': {
+ color: colors.debug,
+ },
+ }),
+ level: css({
+ color: theme.colors.text.secondary,
+ fontWeight: theme.typography.fontWeightBold,
+ display: 'inline-block',
+ marginRight: theme.spacing(1),
+ '&.level-critical': {
+ color: colors.critical,
+ },
+ '&.level-error': {
+ color: colors.error,
+ },
+ '&.level-warning': {
+ color: colors.warning,
+ },
+ '&.level-info': {
+ color: colors.info,
+ },
+ '&.level-debug': {
+ color: colors.debug,
+ },
+ }),
+ overflows: css({
+ outline: 'solid 1px red',
+ }),
+ unwrappedLogLine: css({
+ whiteSpace: 'pre',
+ paddingBottom: theme.spacing(0.5),
+ }),
+ wrappedLogLine: css({
+ whiteSpace: 'pre-wrap',
+ paddingBottom: theme.spacing(0.5),
+ }),
+ };
+};
diff --git a/public/app/features/logs/components/panel/LogList.tsx b/public/app/features/logs/components/panel/LogList.tsx
new file mode 100644
index 00000000000..8c08e96bfef
--- /dev/null
+++ b/public/app/features/logs/components/panel/LogList.tsx
@@ -0,0 +1,135 @@
+import { debounce } from 'lodash';
+import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
+import { ListChildComponentProps, VariableSizeList } from 'react-window';
+
+import { CoreApp, EventBus, LogRowModel, LogsSortOrder } from '@grafana/data';
+import { useTheme2 } from '@grafana/ui';
+
+import { LogLine } from './LogLine';
+import { preProcessLogs, ProcessedLogModel } from './processing';
+import {
+ getLogLineSize,
+ init as initVirtualization,
+ resetLogLineSizes,
+ ScrollToLogsEvent,
+ storeLogLineSize,
+} from './virtualization';
+
+interface Props {
+ app: CoreApp;
+ logs: LogRowModel[];
+ containerElement: HTMLDivElement;
+ eventBus: EventBus;
+ forceEscape?: boolean;
+ showTime: boolean;
+ sortOrder: LogsSortOrder;
+ timeZone: string;
+ wrapLogMessage: boolean;
+}
+
+export const LogList = ({
+ app,
+ containerElement,
+ logs,
+ eventBus,
+ forceEscape = false,
+ showTime,
+ sortOrder,
+ timeZone,
+ wrapLogMessage,
+}: Props) => {
+ const [processedLogs, setProcessedLogs] = useState([]);
+ const [listHeight, setListHeight] = useState(
+ app === CoreApp.Explore ? window.innerHeight * 0.75 : containerElement.clientHeight
+ );
+ const theme = useTheme2();
+ const listRef = useRef(null);
+ const widthRef = useRef(containerElement.clientWidth);
+
+ useEffect(() => {
+ initVirtualization(theme);
+ }, [theme]);
+
+ useEffect(() => {
+ const subscription = eventBus.subscribe(ScrollToLogsEvent, (e: ScrollToLogsEvent) => {
+ if (e.payload.scrollTo === 'top') {
+ listRef.current?.scrollTo(0);
+ } else {
+ listRef.current?.scrollToItem(processedLogs.length - 1);
+ }
+ });
+ return () => subscription.unsubscribe();
+ }, [eventBus, processedLogs.length]);
+
+ useEffect(() => {
+ setProcessedLogs(preProcessLogs(logs, { wrap: wrapLogMessage, escape: forceEscape, order: sortOrder, timeZone }));
+ listRef.current?.resetAfterIndex(0);
+ listRef.current?.scrollTo(0);
+ }, [forceEscape, logs, sortOrder, timeZone, wrapLogMessage]);
+
+ useEffect(() => {
+ const handleResize = debounce(() => {
+ setListHeight(app === CoreApp.Explore ? window.innerHeight * 0.75 : containerElement.clientHeight);
+ }, 50);
+ window.addEventListener('resize', handleResize);
+ handleResize();
+ return () => {
+ window.removeEventListener('resize', handleResize);
+ };
+ }, [app, containerElement.clientHeight]);
+
+ useLayoutEffect(() => {
+ if (widthRef.current === containerElement.clientWidth) {
+ return;
+ }
+ resetLogLineSizes();
+ listRef.current?.resetAfterIndex(0);
+ widthRef.current = containerElement.clientWidth;
+ });
+
+ const handleOverflow = useCallback(
+ (index: number, id: string, height: number) => {
+ if (containerElement) {
+ storeLogLineSize(id, containerElement, height);
+ listRef.current?.resetAfterIndex(index);
+ }
+ },
+ [containerElement]
+ );
+
+ const Renderer = useCallback(
+ ({ index, style }: ListChildComponentProps) => {
+ return (
+
+ );
+ },
+ [handleOverflow, processedLogs, showTime, wrapLogMessage]
+ );
+
+ if (!containerElement || listHeight == null) {
+ // Wait for container to be rendered
+ return null;
+ }
+
+ return (
+ processedLogs[index].uid}
+ layout="vertical"
+ ref={listRef}
+ style={{ overflowY: 'scroll' }}
+ width="100%"
+ >
+ {Renderer}
+
+ );
+};
diff --git a/public/app/features/logs/components/panel/processing.ts b/public/app/features/logs/components/panel/processing.ts
new file mode 100644
index 00000000000..68986505ccc
--- /dev/null
+++ b/public/app/features/logs/components/panel/processing.ts
@@ -0,0 +1,66 @@
+import { dateTimeFormat, LogRowModel, LogsSortOrder } from '@grafana/data';
+
+import { escapeUnescapedString, sortLogRows } from '../../utils';
+
+import { measureTextWidth } from './virtualization';
+
+export interface ProcessedLogModel extends LogRowModel {
+ body: string;
+ timestamp: string;
+ dimensions: LogDimensions;
+}
+
+export interface LogDimensions {
+ timestampWidth: number;
+ levelWidth: number;
+}
+
+interface PreProcessOptions {
+ escape: boolean;
+ order: LogsSortOrder;
+ timeZone: string;
+ wrap: boolean;
+}
+
+export const preProcessLogs = (
+ logs: LogRowModel[],
+ { escape, order, timeZone, wrap }: PreProcessOptions
+): ProcessedLogModel[] => {
+ const orderedLogs = sortLogRows(logs, order);
+ return orderedLogs.map((log) => preProcessLog(log, { wrap, escape, timeZone, expanded: false }));
+};
+
+interface PreProcessLogOptions {
+ escape: boolean;
+ expanded: boolean; // Not yet implemented
+ timeZone: string;
+ wrap: boolean;
+}
+const preProcessLog = (
+ log: LogRowModel,
+ { escape, expanded, timeZone, wrap }: PreProcessLogOptions
+): ProcessedLogModel => {
+ let body = log.entry;
+ const timestamp = dateTimeFormat(log.timeEpochMs, {
+ timeZone,
+ defaultWithMS: true,
+ });
+
+ if (escape && log.hasUnescapedContent) {
+ body = escapeUnescapedString(body);
+ }
+ // With wrapping disabled, we want to turn it into a single-line log entry unless the line is expanded
+ if (!wrap && !expanded) {
+ body = body.replace(/(\r\n|\n|\r)/g, '');
+ }
+
+ return {
+ ...log,
+ body,
+ timestamp,
+ dimensions: {
+ timestampWidth: measureTextWidth(timestamp),
+ levelWidth: measureTextWidth(log.logLevel),
+ },
+ };
+};
diff --git a/public/app/features/logs/components/panel/virtualization.ts b/public/app/features/logs/components/panel/virtualization.ts
new file mode 100644
index 00000000000..6c9bfbfbb30
--- /dev/null
+++ b/public/app/features/logs/components/panel/virtualization.ts
@@ -0,0 +1,229 @@
+import { BusEventWithPayload, GrafanaTheme2 } from '@grafana/data';
+
+import { ProcessedLogModel } from './processing';
+
+let ctx: CanvasRenderingContext2D | null = null;
+let gridSize = 8;
+let paddingBottom = gridSize * 0.5;
+let lineHeight = 22;
+let measurementMode: 'canvas' | 'dom' = 'canvas';
+
+export function init(theme: GrafanaTheme2) {
+ const font = `${theme.typography.fontSize}px ${theme.typography.fontFamilyMonospace}`;
+ const letterSpacing = theme.typography.body.letterSpacing;
+
+ initDOMmeasurement(font, letterSpacing);
+ initCanvasMeasurement(font, letterSpacing);
+
+ gridSize = theme.spacing.gridSize;
+ paddingBottom = gridSize * 0.5;
+ lineHeight = theme.typography.fontSize * theme.typography.body.lineHeight;
+
+ widthMap = new Map();
+ resetLogLineSizes();
+
+ determineMeasurementMode();
+
+ return true;
+}
+
+function determineMeasurementMode() {
+ if (!ctx) {
+ measurementMode = 'dom';
+ return;
+ }
+ const canvasCharWidth = ctx.measureText('e').width;
+ const domCharWidth = measureTextWidthWithDOM('e');
+ const diff = domCharWidth - canvasCharWidth;
+ if (diff >= 0.1) {
+ console.warn('Virtualized log list: falling back to DOM for measurement');
+ measurementMode = 'dom';
+ }
+}
+
+function initCanvasMeasurement(font: string, letterSpacing: string | undefined) {
+ const canvas = document.createElement('canvas');
+ ctx = canvas.getContext('2d');
+ if (!ctx) {
+ return;
+ }
+ ctx.font = font;
+ ctx.fontKerning = 'normal';
+ ctx.fontStretch = 'normal';
+ ctx.fontVariantCaps = 'normal';
+ ctx.textRendering = 'optimizeLegibility';
+ if (letterSpacing) {
+ ctx.letterSpacing = letterSpacing;
+ }
+}
+
+const span = document.createElement('span');
+function initDOMmeasurement(font: string, letterSpacing: string | undefined) {
+ span.style.font = font;
+ span.style.visibility = 'hidden';
+ span.style.position = 'absolute';
+ span.style.wordBreak = 'break-all';
+ if (letterSpacing) {
+ span.style.letterSpacing = letterSpacing;
+ }
+}
+
+let widthMap = new Map();
+export function measureTextWidth(text: string): number {
+ if (!ctx) {
+ throw new Error(`Measuring context canvas is not initialized. Call init() before.`);
+ }
+ const key = text.length;
+
+ const storedWidth = widthMap.get(key);
+ if (storedWidth) {
+ return storedWidth;
+ }
+
+ const width = measurementMode === 'canvas' ? ctx.measureText(text).width : measureTextWidthWithDOM(text);
+ widthMap.set(key, width);
+
+ return width;
+}
+
+function measureTextWidthWithDOM(text: string) {
+ span.textContent = text;
+
+ document.body.appendChild(span);
+ const width = span.getBoundingClientRect().width;
+ document.body.removeChild(span);
+
+ return width;
+}
+
+export function measureTextHeight(text: string, maxWidth: number, beforeWidth = 0) {
+ let logLines = 0;
+ const charWidth = measureTextWidth('e');
+ let logLineCharsLength = Math.round(maxWidth / charWidth);
+ const firstLineCharsLength = Math.floor((maxWidth - beforeWidth) / charWidth) - 2 * charWidth;
+ const textLines = text.split('\n');
+
+ // Skip unnecessary measurements
+ if (textLines.length === 1 && text.length < firstLineCharsLength) {
+ return {
+ lines: 1,
+ height: lineHeight + paddingBottom,
+ };
+ }
+
+ for (const textLine of textLines) {
+ for (let start = 0; start < textLine.length; ) {
+ let testLogLine: string;
+ let width = 0;
+ let delta = 0;
+ let availableWidth = maxWidth - beforeWidth;
+ do {
+ testLogLine = textLine.substring(start, start + logLineCharsLength - delta);
+ width = measureTextWidth(testLogLine);
+ delta += 1;
+ } while (width >= availableWidth);
+ if (beforeWidth) {
+ beforeWidth = 0;
+ }
+ logLines += 1;
+ start += testLogLine.length;
+ }
+ }
+
+ const height = logLines * lineHeight + paddingBottom;
+
+ return {
+ lines: logLines,
+ height,
+ };
+}
+
+interface DisplayOptions {
+ wrap: boolean;
+ showTime: boolean;
+}
+
+export function getLogLineSize(
+ logs: ProcessedLogModel[],
+ container: HTMLDivElement | null,
+ { wrap, showTime }: DisplayOptions,
+ index: number
+) {
+ if (!container) {
+ return 0;
+ }
+ if (!wrap) {
+ return lineHeight + paddingBottom;
+ }
+ const storedSize = retrieveLogLineSize(logs[index].uid, container);
+ if (storedSize) {
+ return storedSize;
+ }
+ const gap = gridSize;
+ let optionsWidth = 0;
+ if (showTime) {
+ optionsWidth += logs[index].dimensions.timestampWidth + gap;
+ }
+ if (logs[index].logLevel) {
+ optionsWidth += logs[index].dimensions.levelWidth + gap;
+ }
+ const { height } = measureTextHeight(logs[index].body, getLogContainerWidth(container), optionsWidth);
+ return height;
+}
+
+export function hasUnderOrOverflow(element: HTMLDivElement, calculatedHeight?: number): number | null {
+ const height = calculatedHeight ?? element.clientHeight;
+ if (element.scrollHeight > height) {
+ return element.scrollHeight;
+ }
+ const child = element.firstChild;
+ if (child instanceof HTMLDivElement && child.clientHeight < height) {
+ return child.clientHeight;
+ }
+ return null;
+}
+
+const scrollBarWidth = getScrollbarWidth();
+
+export function getLogContainerWidth(container: HTMLDivElement) {
+ return container.clientWidth - scrollBarWidth;
+}
+
+export function getScrollbarWidth() {
+ const hiddenDiv = document.createElement('div');
+
+ hiddenDiv.style.width = '100px';
+ hiddenDiv.style.height = '100px';
+ hiddenDiv.style.overflow = 'scroll';
+ hiddenDiv.style.position = 'absolute';
+ hiddenDiv.style.top = '-9999px';
+
+ document.body.appendChild(hiddenDiv);
+ const width = hiddenDiv.offsetWidth - hiddenDiv.clientWidth;
+ document.body.removeChild(hiddenDiv);
+
+ return width;
+}
+
+let logLineSizesMap = new Map();
+export function resetLogLineSizes() {
+ logLineSizesMap = new Map();
+}
+
+export function storeLogLineSize(id: string, container: HTMLDivElement, height: number) {
+ const key = `${id}_${getLogContainerWidth(container)}`;
+ logLineSizesMap.set(key, height);
+}
+
+export function retrieveLogLineSize(id: string, container: HTMLDivElement) {
+ const key = `${id}_${getLogContainerWidth(container)}`;
+ return logLineSizesMap.get(key);
+}
+
+export interface ScrollToLogsEventPayload {
+ scrollTo: 'top' | 'bottom';
+}
+
+export class ScrollToLogsEvent extends BusEventWithPayload {
+ static type = 'logs-panel-scroll-to';
+}
diff --git a/public/app/features/logs/logsModel.ts b/public/app/features/logs/logsModel.ts
index a298b7f0bb9..60aef4364bd 100644
--- a/public/app/features/logs/logsModel.ts
+++ b/public/app/features/logs/logsModel.ts
@@ -92,13 +92,11 @@ export function dedupLogRows(rows: LogRowModel[], strategy?: LogsDedupStrategy):
}
return rows.reduce((result: LogRowModel[], row: LogRowModel, index) => {
- const rowCopy = { ...row };
const previous = result[result.length - 1];
if (index > 0 && isDuplicateRow(row, previous, strategy)) {
previous.duplicates!++;
} else {
- rowCopy.duplicates = 0;
- result.push(rowCopy);
+ result.push({ ...row, duplicates: 0 });
}
return result;
}, []);
diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts
index 1036baa2e89..9f8ac9cda32 100644
--- a/public/app/features/plugins/built_in_plugins.ts
+++ b/public/app/features/plugins/built_in_plugins.ts
@@ -45,6 +45,8 @@ const histogramPanel = async () =>
await import(/* webpackChunkName: "histogramPanel" */ 'app/plugins/panel/histogram/module');
const livePanel = async () => await import(/* webpackChunkName: "livePanel" */ 'app/plugins/panel/live/module');
const logsPanel = async () => await import(/* webpackChunkName: "logsPanel" */ 'app/plugins/panel/logs/module');
+const newLogsPanel = async () =>
+ await import(/* webpackChunkName: "newLogsPanel" */ 'app/plugins/panel/logs-new/module');
const newsPanel = async () => await import(/* webpackChunkName: "newsPanel" */ 'app/plugins/panel/news/module');
const pieChartPanel = async () =>
await import(/* webpackChunkName: "pieChartPanel" */ 'app/plugins/panel/piechart/module');
@@ -116,6 +118,7 @@ const builtInPlugins: Record Promise {}
+
+export const LogsPanel = ({
+ data,
+ timeZone,
+ fieldConfig,
+ options: { showTime, wrapLogMessage, sortOrder, dedupStrategy },
+ id,
+}: LogsPanelProps) => {
+ const isAscending = sortOrder === LogsSortOrder.Ascending;
+ const style = useStyles2(getStyles);
+ const [logsContainer, setLogsContainer] = useState(null);
+ const [panelData, setPanelData] = useState(data);
+ // Prevents the scroll position to change when new data from infinite scrolling is received
+ const keepScrollPositionRef = useRef(false);
+ const { eventBus } = usePanelContext();
+
+ const logs = useMemo(() => {
+ const logsModel = panelData
+ ? dataFrameToLogsModel(panelData.series, data.request?.intervalMs, undefined, data.request?.targets)
+ : null;
+ return logsModel ? dedupLogRows(logsModel.rows, dedupStrategy) : [];
+ }, [data.request?.intervalMs, data.request?.targets, dedupStrategy, panelData]);
+
+ useEffect(() => {
+ setPanelData(data);
+ }, [data]);
+
+ useLayoutEffect(() => {
+ if (keepScrollPositionRef.current) {
+ keepScrollPositionRef.current = false;
+ return;
+ }
+ /**
+ * 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 (data.request?.app === CoreApp.Dashboard || data.request?.app === CoreApp.PanelEditor) {
+ eventBus.publish(
+ new ScrollToLogsEvent({
+ scrollTo: isAscending ? 'top' : 'bottom',
+ })
+ );
+ }
+ }, [data.request?.app, eventBus, isAscending, logs]);
+
+ if (!logs.length) {
+ return ;
+ }
+
+ return (
+ setLogsContainer(element)}>
+ {logs.length > 0 && logsContainer && (
+
+ )}
+
+ );
+};
+
+const getStyles = (theme: GrafanaTheme2) => ({
+ container: css({
+ marginBottom: theme.spacing(1.5),
+ minHeight: '100%',
+ maxHeight: '100%',
+ display: 'flex',
+ flex: 1,
+ flexDirection: 'column',
+ }),
+});
diff --git a/public/app/plugins/panel/logs-new/img/icn-logs-panel.svg b/public/app/plugins/panel/logs-new/img/icn-logs-panel.svg
new file mode 100644
index 00000000000..046b59454ad
--- /dev/null
+++ b/public/app/plugins/panel/logs-new/img/icn-logs-panel.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/app/plugins/panel/logs-new/module.tsx b/public/app/plugins/panel/logs-new/module.tsx
new file mode 100644
index 00000000000..1e024cfcbd3
--- /dev/null
+++ b/public/app/plugins/panel/logs-new/module.tsx
@@ -0,0 +1,73 @@
+import { PanelPlugin, LogsSortOrder, LogsDedupStrategy, LogsDedupDescription } from '@grafana/data';
+
+import { LogsPanel } from './LogsPanel';
+import { Options } from './panelcfg.gen';
+import { LogsPanelSuggestionsSupplier } from './suggestions';
+
+export const plugin = new PanelPlugin(LogsPanel)
+ .setPanelOptions((builder) => {
+ builder
+ .addBooleanSwitch({
+ path: 'showTime',
+ name: 'Time',
+ description: '',
+ defaultValue: false,
+ })
+ .addBooleanSwitch({
+ path: 'wrapLogMessage',
+ name: 'Wrap lines',
+ description: '',
+ defaultValue: false,
+ })
+ .addBooleanSwitch({
+ path: 'enableLogDetails',
+ name: 'Enable log details',
+ 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',
+ description: '',
+ settings: {
+ options: [
+ { value: LogsDedupStrategy.none, label: 'None', description: LogsDedupDescription[LogsDedupStrategy.none] },
+ {
+ value: LogsDedupStrategy.exact,
+ label: 'Exact',
+ description: LogsDedupDescription[LogsDedupStrategy.exact],
+ },
+ {
+ value: LogsDedupStrategy.numbers,
+ label: 'Numbers',
+ description: LogsDedupDescription[LogsDedupStrategy.numbers],
+ },
+ {
+ value: LogsDedupStrategy.signature,
+ label: 'Signature',
+ description: LogsDedupDescription[LogsDedupStrategy.signature],
+ },
+ ],
+ },
+ defaultValue: LogsDedupStrategy.none,
+ })
+ .addRadio({
+ path: 'sortOrder',
+ name: 'Order',
+ description: '',
+ settings: {
+ options: [
+ { value: LogsSortOrder.Descending, label: 'Newest first' },
+ { value: LogsSortOrder.Ascending, label: 'Oldest first' },
+ ],
+ },
+ defaultValue: LogsSortOrder.Descending,
+ });
+ })
+ .setSuggestionsSupplier(new LogsPanelSuggestionsSupplier());
diff --git a/public/app/plugins/panel/logs-new/panelcfg.cue b/public/app/plugins/panel/logs-new/panelcfg.cue
new file mode 100644
index 00000000000..ff5d3351ef3
--- /dev/null
+++ b/public/app/plugins/panel/logs-new/panelcfg.cue
@@ -0,0 +1,40 @@
+// Copyright 2023 Grafana Labs
+//
+// Licensed under the Apache License, Version 2.0 (the "License")
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package grafanaplugin
+
+import (
+ "github.com/grafana/grafana/packages/grafana-schema/src/common"
+)
+
+composableKinds: PanelCfg: {
+ maturity: "experimental"
+
+ lineage: {
+ schemas: [{
+ version: [0, 0]
+ schema: {
+ Options: {
+ showTime: bool
+ wrapLogMessage: bool
+ enableLogDetails: bool
+ sortOrder: common.LogsSortOrder
+ dedupStrategy: common.LogsDedupStrategy
+ enableInfiniteScrolling?: bool
+ } @cuetsy(kind="interface")
+ }
+ }]
+ lenses: []
+ }
+}
diff --git a/public/app/plugins/panel/logs-new/panelcfg.gen.ts b/public/app/plugins/panel/logs-new/panelcfg.gen.ts
new file mode 100644
index 00000000000..e8d58be30ce
--- /dev/null
+++ b/public/app/plugins/panel/logs-new/panelcfg.gen.ts
@@ -0,0 +1,20 @@
+// Code generated - EDITING IS FUTILE. DO NOT EDIT.
+//
+// Generated by:
+// public/app/plugins/gen.go
+// Using jennies:
+// TSTypesJenny
+// PluginTsTypesJenny
+//
+// Run 'make gen-cue' from repository root to regenerate.
+
+import * as common from '@grafana/schema';
+
+export interface Options {
+ dedupStrategy: common.LogsDedupStrategy;
+ enableInfiniteScrolling?: boolean;
+ enableLogDetails: boolean;
+ showTime: boolean;
+ sortOrder: common.LogsSortOrder;
+ wrapLogMessage: boolean;
+}
diff --git a/public/app/plugins/panel/logs-new/plugin.json b/public/app/plugins/panel/logs-new/plugin.json
new file mode 100644
index 00000000000..54357e1cd97
--- /dev/null
+++ b/public/app/plugins/panel/logs-new/plugin.json
@@ -0,0 +1,17 @@
+{
+ "type": "panel",
+ "name": "Logs (new)",
+ "id": "logs-new",
+ "state": "alpha",
+
+ "info": {
+ "author": {
+ "name": "Grafana Labs",
+ "url": "https://grafana.com"
+ },
+ "logos": {
+ "small": "img/icn-logs-panel.svg",
+ "large": "img/icn-logs-panel.svg"
+ }
+ }
+}
diff --git a/public/app/plugins/panel/logs-new/suggestions.ts b/public/app/plugins/panel/logs-new/suggestions.ts
new file mode 100644
index 00000000000..5229b4fb7f0
--- /dev/null
+++ b/public/app/plugins/panel/logs-new/suggestions.ts
@@ -0,0 +1,33 @@
+import { VisualizationSuggestionsBuilder, VisualizationSuggestionScore } from '@grafana/data';
+import { SuggestionName } from 'app/types/suggestions';
+
+import { Options } from './panelcfg.gen';
+
+export class LogsPanelSuggestionsSupplier {
+ getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {
+ const list = builder.getListAppender({
+ name: '',
+ pluginId: 'logs-new',
+ options: {},
+ fieldConfig: {
+ defaults: {
+ custom: {},
+ },
+ overrides: [],
+ },
+ });
+
+ const { dataSummary: ds } = builder;
+
+ // Require a string & time field
+ if (!ds.hasData || !ds.hasTimeField || !ds.hasStringField) {
+ return;
+ }
+
+ if (ds.preferredVisualisationType === 'logs') {
+ list.append({ name: SuggestionName.Logs, score: VisualizationSuggestionScore.Best });
+ } else {
+ list.append({ name: SuggestionName.Logs });
+ }
+ }
+}
diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json
index f97f3beb611..a17c05d5399 100644
--- a/public/locales/en-US/grafana.json
+++ b/public/locales/en-US/grafana.json
@@ -1911,6 +1911,13 @@
"shortcut": "alt+select to enable again"
}
},
+ "logs-navigation": {
+ "newer-logs": "Newer logs",
+ "older-logs": "Older logs",
+ "scroll-bottom": "Scroll to bottom",
+ "scroll-top": "Scroll to top",
+ "start-of-range": "Start of range"
+ },
"popover-menu": {
"copy": "Copy selection",
"disable-menu": "Disable menu",
diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json
index 76b82980d04..b0948db6816 100644
--- a/public/locales/pseudo-LOCALE/grafana.json
+++ b/public/locales/pseudo-LOCALE/grafana.json
@@ -1911,6 +1911,13 @@
"shortcut": "äľŧ+şęľęčŧ ŧő ęʼnäþľę äģäįʼn"
}
},
+ "logs-navigation": {
+ "newer-logs": "Ńęŵęř ľőģş",
+ "older-logs": "Øľđęř ľőģş",
+ "scroll-bottom": "Ŝčřőľľ ŧő þőŧŧőm",
+ "scroll-top": "Ŝčřőľľ ŧő ŧőp",
+ "start-of-range": "Ŝŧäřŧ őƒ řäʼnģę"
+ },
"popover-menu": {
"copy": "Cőpy şęľęčŧįőʼn",
"disable-menu": "Đįşäþľę męʼnū",