Logs Panel: Base elements for the new visualization (#99084)

* Create base components

* Create measurement service

* Add container for list

* Use measurement to render virtualized log lines

* Match rendered styles in 2d context for measuring

* Improve virtualization initialization and handle resize

* Introduce log line processing

* Virtualization: fix measurement of lines with line endings

* Virtualization: include scrollbar width in calculation

* Remove logs

* Virtualization: optimize text measurement

* Add support for forceEscape

* Log line: properly style wrapped/unwrapped lines

* Virtualization: handle possible overflows

* Improve overflow handling

* LogList: remove scroll position ref

* Remove logs

* Remove log

* Add top/bottom navigation buttons

* Add timestamp to pre-processing

* Add showtime support

* Fix imports

* Chore: simplify dedup

* Show level

* Refactor measurement and measure level and timestamp

* Virtualization: skip unnecessary measurements

* Improve measurements to minimize overflow chance

* Introduce logline colors

* Update palette

* Remove pretiffying

* Add comment

* Remove unused variable

* Add color for info level

* Fix dependencies

* Refactor overflow to account for smaller estimations

* Debounce resizing

* Fix imports

* Further optimize height calculation

* Remove outline

* Unused import

* Use less under/overflow method

* Respond to height changes

* Refactor size adjustment to account for layout changes

* Add Logs Panel support

* Add margin bottom to log lines

* Remove unused option

* LogList: container div should never be null

Bad API design

* Log List: make app not undefined and update containerElement usages

* New Logs Panel: Create as new visualization (#99427)

* Logs Panel: clean up old panel

* Logs Panel New: create as new visualization

* Plugin: mark as alpha

* Logs panel new: hold container in a state variable

* Logs panel: fix no data state

* Create newLogsPanel feature flag

* Logs: use new feature flag

* Prettier

* Add new panel to code owners

* Logs Navigation: add translations

* Address betterer issues

* Fix import

* Extract translations

* Update virtualization.ts

* Virtualization: add DOM fallback for text measurement

* Run gen-cue

* plugins_integration_test: add logs-new to expected plugins
pull/100089/head
Matias Chomicki 4 months ago committed by GitHub
parent 87bb7c3947
commit ff926c5ac5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      .betterer.results
  2. 1
      .github/CODEOWNERS
  3. 1
      docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
  4. 1
      packages/grafana-data/src/types/featureToggles.gen.ts
  5. 22
      packages/grafana-schema/src/raw/composable/logs(new)/panelcfg/x/LogsNewPanelCfg_types.gen.ts
  6. 10
      pkg/registry/schemas/composable_kind.go
  7. 7
      pkg/services/featuremgmt/registry.go
  8. 1
      pkg/services/featuremgmt/toggles_gen.csv
  9. 4
      pkg/services/featuremgmt/toggles_gen.go
  10. 13
      pkg/services/featuremgmt/toggles_gen.json
  11. 1
      pkg/services/pluginsintegration/plugins_integration_test.go
  12. 64
      public/app/features/explore/Logs/Logs.tsx
  13. 37
      public/app/features/explore/Logs/LogsNavigation.tsx
  14. 116
      public/app/features/logs/components/panel/LogLine.tsx
  15. 135
      public/app/features/logs/components/panel/LogList.tsx
  16. 66
      public/app/features/logs/components/panel/processing.ts
  17. 229
      public/app/features/logs/components/panel/virtualization.ts
  18. 4
      public/app/features/logs/logsModel.ts
  19. 3
      public/app/features/plugins/built_in_plugins.ts
  20. 91
      public/app/plugins/panel/logs-new/LogsPanel.tsx
  21. 1
      public/app/plugins/panel/logs-new/img/icn-logs-panel.svg
  22. 73
      public/app/plugins/panel/logs-new/module.tsx
  23. 40
      public/app/plugins/panel/logs-new/panelcfg.cue
  24. 20
      public/app/plugins/panel/logs-new/panelcfg.gen.ts
  25. 17
      public/app/plugins/panel/logs-new/plugin.json
  26. 33
      public/app/plugins/panel/logs-new/suggestions.ts
  27. 7
      public/locales/en-US/grafana.json
  28. 7
      public/locales/pseudo-LOCALE/grafana.json

@ -4668,10 +4668,6 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"]
],
"public/app/features/explore/Logs/LogsNavigation.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
],
"public/app/features/explore/Logs/LogsSamplePanel.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],

@ -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

@ -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

@ -255,4 +255,5 @@ export interface FeatureToggles {
fetchRulesUsingPost?: boolean;
alertingAlertmanagerExtraDedupStage?: boolean;
alertingAlertmanagerExtraDedupStageStopPipeline?: boolean;
newLogsPanel?: boolean;
}

@ -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;
}

@ -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

@ -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,
},
}
)

@ -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

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
236 fetchRulesUsingPost experimental @grafana/alerting-squad false false false
237 alertingAlertmanagerExtraDedupStage experimental @grafana/alerting-squad false true false
238 alertingAlertmanagerExtraDedupStageStopPipeline experimental @grafana/alerting-squad false true false
239 newLogsPanel experimental @grafana/observability-logs false false true

@ -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"
)

@ -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",

@ -150,6 +150,7 @@ func verifyCorePluginCatalogue(t *testing.T, ctx context.Context, ps *pluginstor
"histogram": {},
"live": {},
"logs": {},
"logs-new": {},
"candlestick": {},
"news": {},
"nodeGraph": {},

@ -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: 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: 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: Props) => {
/>
</div>
)}
{visualisationType === 'logs' && hasData && (
{visualisationType === 'logs' && hasData && !config.featureToggles.newLogsPanel && (
<>
<div
className={config.featureToggles.logsInfiniteScrolling ? styles.scrollableLogRows : styles.logRows}
@ -1037,6 +1063,38 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
/>
</>
)}
{visualisationType === 'logs' && config.featureToggles.newLogsPanel && (
<>
<div data-testid="logRows" ref={logsContainerRef} className={styles.logRows}>
{logsContainerRef.current && (
<LogList
app={CoreApp.Explore}
containerElement={logsContainerRef.current}
eventBus={eventBus}
forceEscape={forceEscape}
logs={dedupedRows}
showTime={showTime}
sortOrder={logsSortOrder}
timeZone={timeZone}
wrapLogMessage={wrapLogMessage}
/>
)}
</div>
<LogsNavigation
logsSortOrder={logsSortOrder}
visibleRange={navigationRange ?? absoluteRange}
absoluteRange={absoluteRange}
timeZone={timeZone}
onChangeTime={onChangeTime}
loading={loading}
queries={logsQueries ?? []}
scrollToTopLogs={scrollToTopLogs}
scrollToBottomLogs={scrollToBottomLogs}
addResultsToCache={addResultsToCache}
clearCache={clearCache}
/>
</>
)}
{!loading && !hasData && !scanning && (
<div className={styles.noDataWrapper}>
<div className={styles.noData}>

@ -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({
>
<div className={styles.navButtonContent}>
{loading ? <Spinner /> : <Icon name={oldestLogsFirst ? 'angle-up' : 'angle-down'} size="lg" />}
Older logs
<Trans i18nKey={'logs.logs-navigation.older-logs'}>Older logs</Trans>
</div>
</Button>
);
@ -156,7 +159,9 @@ function LogsNavigation({
<div className={styles.navButtonContent}>
{loading && <Spinner />}
{onFirstPage || loading ? null : <Icon name={oldestLogsFirst ? 'angle-down' : 'angle-up'} size="lg" />}
{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')}
</div>
</Button>
);
@ -178,6 +183,11 @@ function LogsNavigation({
scrollToTopLogs();
}, [scrollToTopLogs]);
const onScrollToBottomClick = useCallback(() => {
reportInteraction('grafana_explore_logs_scroll_bottom_clicked');
scrollToBottomLogs?.();
}, [scrollToBottomLogs]);
return (
<div className={styles.navContainer}>
{!config.featureToggles.logsInfiniteScrolling && (
@ -194,12 +204,23 @@ function LogsNavigation({
{oldestLogsFirst ? newerLogsButton : olderLogsButton}
</>
)}
{scrollToBottomLogs && (
<Button
data-testid="scrollToBottom"
className={styles.scrollToBottomButton}
variant="secondary"
onClick={onScrollToBottomClick}
title={t('logs.logs-navigation.scroll-bottom', 'Scroll to bottom')}
>
<Icon name="arrow-down" size="lg" />
</Button>
)}
<Button
data-testid="scrollToTop"
className={styles.scrollToTopButton}
variant="secondary"
onClick={onScrollToTopClick}
title="Scroll to top"
title={t('logs.logs-navigation.scroll-top', 'Scroll to top')}
>
<Icon name="arrow-up" size="lg" />
</Button>
@ -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',

@ -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<HTMLDivElement | null>(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 (
<div style={style} className={styles.logLine} ref={onOverflow ? logLineRef : undefined}>
<div className={wrapLogMessage ? styles.wrappedLogLine : styles.unwrappedLogLine}>
{showTime && <span className={`${styles.timestamp} level-${log.logLevel}`}>{log.timestamp}</span>}
{log.logLevel && <span className={`${styles.level} level-${log.logLevel}`}>{log.logLevel}</span>}
{log.body}
</div>
</div>
);
};
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),
}),
};
};

@ -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<ProcessedLogModel[]>([]);
const [listHeight, setListHeight] = useState(
app === CoreApp.Explore ? window.innerHeight * 0.75 : containerElement.clientHeight
);
const theme = useTheme2();
const listRef = useRef<VariableSizeList | null>(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 (
<LogLine
index={index}
log={processedLogs[index]}
showTime={showTime}
style={style}
wrapLogMessage={wrapLogMessage}
onOverflow={handleOverflow}
/>
);
},
[handleOverflow, processedLogs, showTime, wrapLogMessage]
);
if (!containerElement || listHeight == null) {
// Wait for container to be rendered
return null;
}
return (
<VariableSizeList
height={listHeight}
itemCount={processedLogs.length}
itemSize={getLogLineSize.bind(null, processedLogs, containerElement, { wrap: wrapLogMessage, showTime })}
itemKey={(index: number) => processedLogs[index].uid}
layout="vertical"
ref={listRef}
style={{ overflowY: 'scroll' }}
width="100%"
>
{Renderer}
</VariableSizeList>
);
};

@ -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),
},
};
};

@ -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<number, number>();
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<number, number>();
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<string, number>();
export function resetLogLineSizes() {
logLineSizesMap = new Map<string, number>();
}
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<ScrollToLogsEventPayload> {
static type = 'logs-panel-scroll-to';
}

@ -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;
}, []);

@ -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<string, System.Module | (() => Promise<System.Modul
'core:plugin/bargauge': barGaugePanel,
'core:plugin/barchart': barChartPanel,
'core:plugin/logs': logsPanel,
'core:plugin/logs-new': newLogsPanel,
'core:plugin/traces': tracesPanel,
'core:plugin/welcome': welcomeBanner,
'core:plugin/nodeGraph': nodeGraph,

@ -0,0 +1,91 @@
import { css } from '@emotion/css';
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { CoreApp, GrafanaTheme2, LogsSortOrder, PanelProps } from '@grafana/data';
import { usePanelContext, useStyles2 } from '@grafana/ui';
import { LogList } from 'app/features/logs/components/panel/LogList';
import { ScrollToLogsEvent } from 'app/features/logs/components/panel/virtualization';
import { PanelDataErrorView } from 'app/features/panel/components/PanelDataErrorView';
import { dataFrameToLogsModel, dedupLogRows } from '../../../features/logs/logsModel';
import { Options } from './panelcfg.gen';
interface LogsPanelProps extends PanelProps<Options> {}
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<HTMLDivElement | null>(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 <PanelDataErrorView fieldConfig={fieldConfig} panelId={id} data={data} needsStringField />;
}
return (
<div className={style.container} ref={(element: HTMLDivElement) => setLogsContainer(element)}>
{logs.length > 0 && logsContainer && (
<LogList
app={CoreApp.Dashboard}
containerElement={logsContainer}
eventBus={eventBus}
logs={logs}
showTime={showTime}
sortOrder={sortOrder}
timeZone={timeZone}
wrapLogMessage={wrapLogMessage}
/>
)}
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
container: css({
marginBottom: theme.spacing(1.5),
minHeight: '100%',
maxHeight: '100%',
display: 'flex',
flex: 1,
flexDirection: 'column',
}),
});

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 80.49 80.99"><defs><style>.cls-1{fill:#3865ab;}.cls-2{fill:#8ab8ff;}.cls-3{fill:url(#linear-gradient);}.cls-4{fill:url(#linear-gradient-2);}.cls-5{fill:#3a76d0;}</style><linearGradient id="linear-gradient" x1="-3918.19" y1="8047.29" x2="-3910.1" y2="8047.29" gradientTransform="translate(-3910.1 8051.33) rotate(180)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f2cc0c"/><stop offset="1" stop-color="#ff9830"/></linearGradient><linearGradient id="linear-gradient-2" x1="-3918.19" y1="8010.84" x2="-3910.1" y2="8010.84" xlink:href="#linear-gradient"/></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M79.49,8H14.54V0H79.49a1,1,0,0,1,1,1V7A1,1,0,0,1,79.49,8Z"/><path class="cls-1" d="M59.49,26.27H14.54v-8h45a1,1,0,0,1,1,1v6A1,1,0,0,1,59.49,26.27Z"/><path class="cls-2" d="M6.92,19.38a4,4,0,0,0-1.34-.85,4.1,4.1,0,0,0-3.08,0,4,4,0,0,0-1.33.85,4,4,0,0,0-.85,1.34,4,4,0,0,0,.85,4.41A4.44,4.44,0,0,0,2.5,26a3.76,3.76,0,0,0,3.08,0,4.61,4.61,0,0,0,1.34-.85,4.19,4.19,0,0,0,0-5.75Z"/><path class="cls-1" d="M48.49,44.49H14.54v-8h34a1,1,0,0,1,1,1v6A1,1,0,0,1,48.49,44.49Z"/><path class="cls-1" d="M79.49,81H14.54V73H79.49a1,1,0,0,1,1,1v6A1,1,0,0,1,79.49,81Z"/><path class="cls-2" d="M7.77,75.4a4,4,0,0,1-.85,4.41,4.61,4.61,0,0,1-1.34.85,3.76,3.76,0,0,1-3.08,0,4.44,4.44,0,0,1-1.33-.85A4,4,0,0,1,.32,75.4a4,4,0,0,1,.85-1.34,4,4,0,0,1,1.33-.85,4.1,4.1,0,0,1,3.08,0,4,4,0,0,1,1.34.85A4,4,0,0,1,7.77,75.4Z"/><path class="cls-3" d="M6.92,1.15A4.36,4.36,0,0,0,5.58.3,4.1,4.1,0,0,0,2.5.3a4.32,4.32,0,0,0-1.33.85A4,4,0,0,0,.32,2.49a4,4,0,0,0,.85,4.42,4.87,4.87,0,0,0,1.33.85,3.84,3.84,0,0,0,3.08,0,5.07,5.07,0,0,0,1.34-.85,4.07,4.07,0,0,0,.85-4.42A4,4,0,0,0,6.92,1.15Z"/><path class="cls-4" d="M6.92,37.61a4,4,0,0,0-1.34-.85,4,4,0,0,0-3.08,0,4,4,0,0,0-1.33.85,4,4,0,0,0-.85,1.33,4,4,0,0,0,.85,4.42,4.64,4.64,0,0,0,1.33.85,3.84,3.84,0,0,0,3.08,0,4.83,4.83,0,0,0,1.34-.85,4.07,4.07,0,0,0,.85-4.42A4,4,0,0,0,6.92,37.61Z"/><path class="cls-1" d="M70.49,62.72H14.54v-8h56a1,1,0,0,1,1,1v6A1,1,0,0,1,70.49,62.72Z"/><path class="cls-5" d="M6.92,55.83A4.36,4.36,0,0,0,5.58,55,4.1,4.1,0,0,0,2.5,55a4.32,4.32,0,0,0-1.33.85,4.2,4.2,0,0,0,0,5.76,4.87,4.87,0,0,0,1.33.85,3.84,3.84,0,0,0,3.08,0,5.07,5.07,0,0,0,1.34-.85,4.07,4.07,0,0,0,.85-4.42A4,4,0,0,0,6.92,55.83Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

@ -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<Options>(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());

@ -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: []
}
}

@ -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;
}

@ -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"
}
}
}

@ -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<Options, {}>({
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 });
}
}
}

@ -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",

@ -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ū",

Loading…
Cancel
Save