diff --git a/.betterer.results b/.betterer.results index 53d62e11c3f..c9f78d230b0 100644 --- a/.betterer.results +++ b/.betterer.results @@ -4668,10 +4668,6 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings. Wrap text with ", "3"], [0, 0, 0, "No untranslated strings. Wrap text with ", "4"] ], - "public/app/features/explore/Logs/LogsNavigation.tsx:5381": [ - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "1"] - ], "public/app/features/explore/Logs/LogsSamplePanel.tsx:5381": [ [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d8d54103584..645625d3d19 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -535,6 +535,7 @@ playwright.config.ts @grafana/plugins-platform-frontend /public/app/plugins/panel/heatmap/ @grafana/dataviz-squad /public/app/plugins/panel/histogram/ @grafana/dataviz-squad /public/app/plugins/panel/logs/ @grafana/observability-logs +/public/app/plugins/panel/logs-new/ @grafana/observability-logs /public/app/plugins/panel/nodeGraph/ @grafana/observability-traces-and-profiling @grafana/app-o11y-visualizations /public/app/plugins/panel/traces/ @grafana/observability-traces-and-profiling /public/app/plugins/panel/flamegraph/ @grafana/observability-traces-and-profiling diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 030c81df2ed..9cd7fe7e1a7 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -232,6 +232,7 @@ Experimental features might be changed or removed without prior notice. | `grafanaAdvisor` | Enables Advisor app | | `elasticsearchImprovedParsing` | Enables less memory intensive Elasticsearch result parsing | | `datasourceConnectionsTab` | Shows defined connections for a data source in the plugins detail page | +| `newLogsPanel` | Enables the new logs panel in Explore | ## Development feature toggles diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 1cf9218c71f..452659bcf74 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -255,4 +255,5 @@ export interface FeatureToggles { fetchRulesUsingPost?: boolean; alertingAlertmanagerExtraDedupStage?: boolean; alertingAlertmanagerExtraDedupStageStopPipeline?: boolean; + newLogsPanel?: boolean; } diff --git a/packages/grafana-schema/src/raw/composable/logs(new)/panelcfg/x/LogsNewPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/logs(new)/panelcfg/x/LogsNewPanelCfg_types.gen.ts new file mode 100644 index 00000000000..91589b9cea1 --- /dev/null +++ b/packages/grafana-schema/src/raw/composable/logs(new)/panelcfg/x/LogsNewPanelCfg_types.gen.ts @@ -0,0 +1,22 @@ +// 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 const pluginVersion = "11.6.0-pre"; + +export interface Options { + dedupStrategy: common.LogsDedupStrategy; + enableInfiniteScrolling?: boolean; + enableLogDetails: boolean; + showTime: boolean; + sortOrder: common.LogsSortOrder; + wrapLogMessage: boolean; +} diff --git a/pkg/registry/schemas/composable_kind.go b/pkg/registry/schemas/composable_kind.go index e17a71e15a9..8d6126e8d08 100644 --- a/pkg/registry/schemas/composable_kind.go +++ b/pkg/registry/schemas/composable_kind.go @@ -248,6 +248,16 @@ func GetComposableKinds() ([]ComposableKind, error) { CueFile: logsCue, }) + logsnewCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/panel/logs-new/panelcfg.cue")) + if err != nil { + return nil, err + } + kinds = append(kinds, ComposableKind{ + Name: "logsnew", + Filename: "panelcfg.cue", + CueFile: logsnewCue, + }) + newsCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/panel/news/panelcfg.cue")) if err != nil { return nil, err diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index e1ff68d42bc..f0def056b77 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1777,6 +1777,13 @@ var ( HideFromDocs: true, RequiresRestart: true, }, + { + Name: "newLogsPanel", + Description: "Enables the new logs panel in Explore", + Stage: FeatureStageExperimental, + FrontendOnly: true, + Owner: grafanaObservabilityLogsSquad, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 9eab6e5ed1f..ad6203ebdc7 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -236,3 +236,4 @@ datasourceConnectionsTab,experimental,@grafana/plugins-platform-backend,false,fa fetchRulesUsingPost,experimental,@grafana/alerting-squad,false,false,false alertingAlertmanagerExtraDedupStage,experimental,@grafana/alerting-squad,false,true,false alertingAlertmanagerExtraDedupStageStopPipeline,experimental,@grafana/alerting-squad,false,true,false +newLogsPanel,experimental,@grafana/observability-logs,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 4e293dc9a47..8c36acbcc51 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -954,4 +954,8 @@ const ( // FlagAlertingAlertmanagerExtraDedupStageStopPipeline // works together with alertingAlertmanagerExtraDedupStage, if enabled, it will stop the pipeline if the timestamps are not matching. Otherwise, it will emit a warning FlagAlertingAlertmanagerExtraDedupStageStopPipeline = "alertingAlertmanagerExtraDedupStageStopPipeline" + + // FlagNewLogsPanel + // Enables the new logs panel in Explore + FlagNewLogsPanel = "newLogsPanel" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index ba79b481cba..e1673e7c8ab 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -2707,6 +2707,19 @@ "frontend": true } }, + { + "metadata": { + "name": "newLogsPanel", + "resourceVersion": "1738344859933", + "creationTimestamp": "2025-01-31T17:34:19Z" + }, + "spec": { + "description": "Enables the new logs panel in Explore", + "stage": "experimental", + "codeowner": "@grafana/observability-logs", + "frontend": true + } + }, { "metadata": { "name": "newPDFRendering", diff --git a/pkg/services/pluginsintegration/plugins_integration_test.go b/pkg/services/pluginsintegration/plugins_integration_test.go index 7d5bf5f8978..ecf452229ed 100644 --- a/pkg/services/pluginsintegration/plugins_integration_test.go +++ b/pkg/services/pluginsintegration/plugins_integration_test.go @@ -150,6 +150,7 @@ func verifyCorePluginCatalogue(t *testing.T, ctx context.Context, ps *pluginstor "histogram": {}, "live": {}, "logs": {}, + "logs-new": {}, "candlestick": {}, "news": {}, "nodeGraph": {}, diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index 2ed3584119d..fb05d6bee91 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -55,6 +55,8 @@ import { createAndCopyShortLink, getLogsPermalinkRange } from 'app/core/utils/sh 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'; +import { LogList } from 'app/features/logs/components/panel/LogList'; +import { ScrollToLogsEvent } from 'app/features/logs/components/panel/virtualization'; import { LogLevelColor, dedupLogRows, filterLogLevels } from 'app/features/logs/logsModel'; import { getLogLevel, getLogLevelFromKey, getLogLevelInfo } from 'app/features/logs/utils'; import { LokiQueryDirection } from 'app/plugins/datasource/loki/dataquery.gen'; @@ -709,7 +711,13 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { ); const scrollToTopLogs = useCallback(() => { - if (config.featureToggles.logsInfiniteScrolling) { + if (config.featureToggles.newLogsPanel) { + eventBus.publish( + new ScrollToLogsEvent({ + scrollTo: 'top', + }) + ); + } else if (config.featureToggles.logsInfiniteScrolling) { if (logsContainerRef.current) { logsContainerRef.current.scroll({ behavior: 'auto', @@ -718,7 +726,25 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { } } topLogsRef.current?.scrollIntoView(); - }, [logsContainerRef, topLogsRef]); + }, [eventBus]); + + const scrollToBottomLogs = useCallback(() => { + if (config.featureToggles.newLogsPanel) { + eventBus.publish( + new ScrollToLogsEvent({ + scrollTo: 'bottom', + }) + ); + } else if (config.featureToggles.logsInfiniteScrolling) { + if (logsContainerRef.current) { + logsContainerRef.current.scroll({ + behavior: 'auto', + top: logsContainerRef.current.scrollHeight, + }); + } + } + topLogsRef.current?.scrollTo(0, topLogsRef.current.scrollHeight); + }, [eventBus]); const onPinToContentOutlineClick = useCallback( (row: LogRowModel, allowUnPin = true) => { @@ -968,7 +994,7 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { /> )} - {visualisationType === 'logs' && hasData && ( + {visualisationType === 'logs' && hasData && !config.featureToggles.newLogsPanel && ( <>
= (props: Props) => { /> )} + {visualisationType === 'logs' && config.featureToggles.newLogsPanel && ( + <> +
+ {logsContainerRef.current && ( + + )} +
+ + + )} {!loading && !hasData && !scanning && (
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ū",