mirror of https://github.com/grafana/grafana
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 pluginspull/100089/head
parent
87bb7c3947
commit
ff926c5ac5
@ -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; |
||||
} |
|
@ -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'; |
||||
} |
@ -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', |
||||
}), |
||||
}); |
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 }); |
||||
} |
||||
} |
||||
} |
Loading…
Reference in new issue