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