@ -7,167 +7,237 @@ import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { LogListFontSize } from './LogList' ;
import { LogListModel } from './processing' ;
let ctx : CanvasRenderingContext2D | null = null ;
let gridSize = 8 ;
let paddingBottom = gridSize * 0.75 ;
let lineHeight = 22 ;
let measurementMode : 'canvas' | 'dom' = 'canvas' ;
const iconWidth = 24 ;
export const LOG_LIST_MIN_WIDTH = 35 * gridSize ;
export const LOG_LIST_MIN_WIDTH = 35 * 8 ;
// Controls the space between fields in the log line, timestamp, level, displayed fields, and log line body
export const FIELD_GAP_MULTIPLIER = 1.5 ;
export const getLineHeight = ( ) = > lineHeight ;
export const DEFAULT_LINE_HEIGHT = 22 ;
export class LogLineVirtualization {
private ctx : CanvasRenderingContext2D | null = null ;
private gridSize ;
private paddingBottom ;
private lineHeight ;
private measurementMode : 'canvas' | 'dom' = 'canvas' ;
private textWidthMap : Map < number , number > ;
private logLineSizesMap : Map < string , number > ;
private spanElement = document . createElement ( 'span' ) ;
readonly fontSize : LogListFontSize ;
constructor ( theme : GrafanaTheme2 , fontSize : LogListFontSize ) {
this . fontSize = fontSize ;
let fontSizePx ;
if ( fontSize === 'default' ) {
fontSizePx = theme . typography . fontSize ;
this . lineHeight = theme . typography . fontSize * theme . typography . body . lineHeight ;
} else {
fontSizePx =
typeof theme . typography . bodySmall . fontSize === 'string' && theme . typography . bodySmall . fontSize . includes ( 'rem' )
? theme . typography . fontSize * parseFloat ( theme . typography . bodySmall . fontSize )
: parseInt ( theme . typography . bodySmall . fontSize , 10 ) ;
this . lineHeight = fontSizePx * theme . typography . bodySmall . lineHeight ;
}
export function init ( theme : GrafanaTheme2 , fontSize : LogListFontSize ) {
let fontSizePx = theme . typography . fontSize ;
this . gridSize = theme . spacing . gridSize ;
this . paddingBottom = this . gridSize * 0.75 ;
this . logLineSizesMap = new Map < string , number > ( ) ;
this . textWidthMap = new Map < number , number > ( ) ;
if ( fontSize === 'default' ) {
lineHeight = theme . typography . fontSize * theme . typography . body . lineHeight ;
} else {
fontSizePx =
typeof theme . typography . bodySmall . fontSize === 'string' && theme . typography . bodySmall . fontSize . includes ( 'rem' )
? theme . typography . fontSize * parseFloat ( theme . typography . bodySmall . fontSize )
: parseInt ( theme . typography . bodySmall . fontSize , 10 ) ;
lineHeight = fontSizePx * theme . typography . bodySmall . lineHeight ;
}
const font = ` ${ fontSizePx } px ${ theme . typography . fontFamilyMonospace } ` ;
const letterSpacing = theme . typography . body . letterSpacing ;
const font = ` ${ fontSizePx } px ${ theme . typography . fontFamilyMonospace } ` ;
const letterSpacing = theme . typography . body . letterSpacing ;
this . initDOMmeasurement ( font , letterSpacing ) ;
this . initCanvasMeasurement ( font , letterSpacing ) ;
this . determineMeasurementMode ( ) ;
}
initDOMmeasurement ( font , letterSpacing ) ;
initCanvasMeasurement ( font , letterSpacing ) ;
getLineHeight = ( ) = > this . lineHeight ;
getGridSize = ( ) = > this . gridSize ;
getPaddingBottom = ( ) = > this . paddingBottom ;
gridSize = theme . spacing . gridSize ;
paddingBottom = gridSize * 0.75 ;
// 2/3 of the viewport height
getTruncationLineCount = ( ) = > Math . round ( window . innerHeight / this . getLineHeight ( ) / 1.5 ) ;
widthMap = new Map < number , number > ( ) ;
resetLogLineSizes ( ) ;
getTruncationLength = ( container : HTMLDivElement | null ) = > {
const availableWidth = container ? getLogContainerWidth ( container ) : window . innerWidth ;
return ( availableWidth / this . measureTextWidth ( 'e' ) ) * this . getTruncationLineCount ( ) ;
} ;
determineMeasurementMode ( ) ;
determineMeasurementMode = ( ) = > {
if ( ! this . ctx ) {
this . measurementMode = 'dom' ;
return ;
}
const canvasCharWidth = this . ctx . measureText ( 'e' ) . width ;
const domCharWidth = this . measureTextWidthWithDOM ( 'e' ) ;
const diff = domCharWidth - canvasCharWidth ;
if ( diff >= 0.1 ) {
console . warn ( 'Virtualized log list: falling back to DOM for measurement' ) ;
this . measurementMode = 'dom' ;
}
} ;
return true ;
}
initCanvasMeasurement = ( font : string , letterSpacing : string | undefined ) = > {
const canvas = document . createElement ( 'canvas' ) ;
this . ctx = canvas . getContext ( '2d' ) ;
if ( ! this . ctx ) {
return ;
}
this . ctx . font = font ;
this . ctx . fontKerning = 'normal' ;
this . ctx . fontStretch = 'normal' ;
this . ctx . fontVariantCaps = 'normal' ;
this . ctx . textRendering = 'optimizeLegibility' ;
if ( letterSpacing ) {
this . ctx . letterSpacing = letterSpacing ;
}
} ;
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' ;
}
}
initDOMmeasurement = ( font : string , letterSpacing : string | undefined ) = > {
this . spanElement . style . font = font ;
this . spanElement . style . visibility = 'hidden' ;
this . spanElement . style . position = 'absolute' ;
this . spanElement . style . wordBreak = 'break-all' ;
if ( letterSpacing ) {
this . spanElement . style . letterSpacing = letterSpacing ;
}
} ;
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 ;
}
}
measureTextWidth = ( text : string ) : number = > {
if ( ! this . ctx ) {
throw new Error ( ` Measuring context canvas is not initialized. Call init() before. ` ) ;
}
const key = text . length ;
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 ;
}
}
const storedWidth = this . textWidthMap . get ( key ) ;
if ( storedWidth ) {
return storedWidth ;
}
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 width =
this . measurementMode === 'canvas' ? this . ctx . measureText ( text ) . width : this.measureTextWidthWithDOM ( text ) ;
this . textWidthMap . set ( key , width ) ;
const storedWidth = widthMap . get ( key ) ;
if ( storedWidth ) {
return storedWidth ;
}
return width ;
} ;
const width = measurementMode === 'canvas' ? ctx . measureText ( text ) . width : measureTextWidthWithDOM ( text ) ;
widthMap . set ( key , width ) ;
measureTextWidthWithDOM = ( text : string ) = > {
this . spanElement . textContent = text ;
return width ;
}
document . body . appendChild ( this . spanElement ) ;
const width = this . spanElement . getBoundingClientRect ( ) . width ;
document . body . removeChild ( this . spanElement ) ;
function measureTextWidthWithDOM ( text : string ) {
span . textContent = text ;
return width ;
} ;
document . body . appendChild ( span ) ;
const width = span . getBoundingClientRect ( ) . width ;
document . body . removeChild ( span ) ;
measureTextHeight = ( text : string , maxWidth : number , beforeWidth = 0 ) = > {
let logLines = 0 ;
const charWidth = this . 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 : this.getLineHeight ( ) + this . paddingBottom ,
} ;
}
return width ;
}
const availableWidth = maxWidth - beforeWidth ;
for ( const textLine of textLines ) {
for ( let start = 0 ; start < textLine . length ; ) {
let testLogLine : string ;
let width = 0 ;
let delta = 0 ;
do {
testLogLine = textLine . substring ( start , start + logLineCharsLength - delta ) ;
let measuredLine = testLogLine ;
if ( logLines > 0 ) {
measuredLine . trimStart ( ) ;
}
width = this . measureTextWidth ( measuredLine ) ;
delta += 1 ;
} while ( width >= availableWidth ) ;
if ( beforeWidth ) {
beforeWidth = 0 ;
}
logLines += 1 ;
start += testLogLine . length ;
}
}
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' ) ;
const height = logLines * this . getLineHeight ( ) + this . paddingBottom ;
// Skip unnecessary measurements
if ( textLines . length === 1 && text . length < firstLineCharsLength ) {
return {
lines : 1 ,
height : getLineHeight ( ) + paddingBottom ,
lines : logLines ,
height ,
} ;
}
} ;
const availableWidth = maxWidth - beforeWidth ;
for ( const textLine of textLines ) {
for ( let start = 0 ; start < textLine . length ; ) {
let testLogLine : string ;
let width = 0 ;
let delta = 0 ;
do {
testLogLine = textLine . substring ( start , start + logLineCharsLength - delta ) ;
let measuredLine = testLogLine ;
if ( logLines > 0 ) {
measuredLine . trimStart ( ) ;
}
width = measureTextWidth ( measuredLine ) ;
delta += 1 ;
} while ( width >= availableWidth ) ;
if ( beforeWidth ) {
beforeWidth = 0 ;
calculateFieldDimensions = ( logs : LogListModel [ ] , displayedFields : string [ ] = [ ] ) = > {
if ( ! logs . length ) {
return [ ] ;
}
let timestampWidth = 0 ;
let levelWidth = 0 ;
const fieldWidths : Record < string , number > = { } ;
for ( let i = 0 ; i < logs . length ; i ++ ) {
let width = this . measureTextWidth ( logs [ i ] . timestamp ) ;
if ( width > timestampWidth ) {
timestampWidth = Math . round ( width ) ;
}
width = this . measureTextWidth ( logs [ i ] . displayLevel ) ;
if ( width > levelWidth ) {
levelWidth = Math . round ( width ) ;
}
for ( const field of displayedFields ) {
width = this . measureTextWidth ( logs [ i ] . getDisplayedFieldValue ( field ) ) ;
fieldWidths [ field ] = ! fieldWidths [ field ] || width > fieldWidths [ field ] ? Math . round ( width ) : fieldWidths [ field ] ;
}
logLines += 1 ;
start += testLogLine . length ;
}
}
const dimensions : LogFieldDimension [ ] = [
{
field : 'timestamp' ,
width : timestampWidth ,
} ,
{
field : 'level' ,
width : levelWidth ,
} ,
] ;
for ( const field in fieldWidths ) {
// Skip the log line when it's a displayed field
if ( field === LOG_LINE_BODY_FIELD_NAME ) {
continue ;
}
dimensions . push ( {
field ,
width : fieldWidths [ field ] ,
} ) ;
}
return dimensions ;
} ;
resetLogLineSizes = ( ) = > {
this . logLineSizesMap = new Map < string , number > ( ) ;
} ;
const height = logLines * getLineHeight ( ) + paddingBottom ;
storeLogLineSize = ( id : string , container : HTMLDivElement , height : number ) = > {
const key = ` ${ id } _ ${ getLogContainerWidth ( container ) } _ ${ this . fontSize } ` ;
this . logLineSizesMap . set ( key , height ) ;
} ;
return {
lines : logLines ,
height ,
retrieveLogLineSize = ( id : string , container : HTMLDivElement ) = > {
const key = ` ${ id } _ ${ getLogContainerWidth ( container ) } _ ${ this . fontSize } ` ;
return this . logLineSizesMap . get ( key ) ;
} ;
}
export interface DisplayOptions {
fontSize : LogListFontSize ;
hasLogsWithErrors? : boolean ;
hasSampledLogs? : boolean ;
showDuplicates : boolean ;
@ -176,10 +246,11 @@ export interface DisplayOptions {
}
export function getLogLineSize (
virtualization : LogLineVirtualization ,
logs : LogListModel [ ] ,
container : HTMLDivElement | null ,
displayedFields : string [ ] ,
{ fontSize , hasLogsWithErrors , hasSampledLogs , showDuplicates , showTime , wrap } : DisplayOptions ,
{ hasLogsWithErrors , hasSampledLogs , showDuplicates , showTime , wrap } : DisplayOptions ,
index : number
) {
if ( ! container ) {
@ -187,31 +258,31 @@ export function getLogLineSize(
}
// !logs[index] means the line is not yet loaded by infinite scrolling
if ( ! wrap || ! logs [ index ] ) {
return getLineHeight ( ) + pa ddingBottom;
return virtualization . getLineHeight ( ) + virtualization . getPa ddingBottom( ) ;
}
// If a long line is collapsed, we show the line count + an extra line for the expand/collapse control
logs [ index ] . updateCollapsedState ( displayedFields , container ) ;
if ( logs [ index ] . collapsed ) {
return ( getTruncationLineCount ( ) + 1 ) * getLineHeight ( ) ;
return ( virtualization . getTruncationLineCount ( ) + 1 ) * virtualization . getLineHeight ( ) ;
}
const storedSize = retrieveLogLineSize ( logs [ index ] . uid , container , fontSize ) ;
const storedSize = virtualization . retrieveLogLineSize ( logs [ index ] . uid , container ) ;
if ( storedSize ) {
return storedSize ;
}
let textToMeasure = '' ;
const gap = gridSize * FIELD_GAP_MULTIPLIER ;
const iconsGap = gridSize * 0.5 ;
const gap = virtualization . getG ridSize ( ) * FIELD_GAP_MULTIPLIER ;
const iconsGap = virtualization . getG ridSize ( ) * 0.5 ;
let optionsWidth = 0 ;
if ( showDuplicates ) {
optionsWidth += gridSize * 4.5 + iconsGap ;
optionsWidth += virtualization . getG ridSize ( ) * 4.5 + iconsGap ;
}
if ( hasLogsWithErrors ) {
optionsWidth += gridSize * 2 + iconsGap ;
optionsWidth += virtualization . getG ridSize ( ) * 2 + iconsGap ;
}
if ( hasSampledLogs ) {
optionsWidth += gridSize * 2 + iconsGap ;
optionsWidth += virtualization . getG ridSize ( ) * 2 + iconsGap ;
}
if ( showTime ) {
optionsWidth += gap ;
@ -229,9 +300,9 @@ export function getLogLineSize(
textToMeasure += ansicolor . strip ( logs [ index ] . body ) ;
}
const { height } = measureTextHeight ( textToMeasure , getLogContainerWidth ( container ) , optionsWidth ) ;
const { height } = virtualization . measureTextHeight ( textToMeasure , getLogContainerWidth ( container ) , optionsWidth ) ;
// When the log is collapsed, add an extra line for the expand/collapse control
return logs [ index ] . collapsed === false ? height + getLineHeight ( ) : height ;
return logs [ index ] . collapsed === false ? height + virtualization . getLineHeight ( ) : height ;
}
export interface LogFieldDimension {
@ -239,80 +310,31 @@ export interface LogFieldDimension {
width : number ;
}
export const calculateFieldDimensions = ( logs : LogListModel [ ] , displayedFields : string [ ] = [ ] ) = > {
if ( ! logs . length ) {
return [ ] ;
}
let timestampWidth = 0 ;
let levelWidth = 0 ;
const fieldWidths : Record < string , number > = { } ;
for ( let i = 0 ; i < logs . length ; i ++ ) {
let width = measureTextWidth ( logs [ i ] . timestamp ) ;
if ( width > timestampWidth ) {
timestampWidth = Math . round ( width ) ;
}
width = measureTextWidth ( logs [ i ] . displayLevel ) ;
if ( width > levelWidth ) {
levelWidth = Math . round ( width ) ;
}
for ( const field of displayedFields ) {
width = measureTextWidth ( logs [ i ] . getDisplayedFieldValue ( field ) ) ;
fieldWidths [ field ] = ! fieldWidths [ field ] || width > fieldWidths [ field ] ? Math . round ( width ) : fieldWidths [ field ] ;
}
}
const dimensions : LogFieldDimension [ ] = [
{
field : 'timestamp' ,
width : timestampWidth ,
} ,
{
field : 'level' ,
width : levelWidth ,
} ,
] ;
for ( const field in fieldWidths ) {
// Skip the log line when it's a displayed field
if ( field === LOG_LINE_BODY_FIELD_NAME ) {
continue ;
}
dimensions . push ( {
field ,
width : fieldWidths [ field ] ,
} ) ;
}
return dimensions ;
} ;
// 2/3 of the viewport height
export const getTruncationLineCount = ( ) = > Math . round ( window . innerHeight / getLineHeight ( ) / 1.5 ) ;
export function getTruncationLength ( container : HTMLDivElement | null ) {
const availableWidth = container ? getLogContainerWidth ( container ) : window . innerWidth ;
return ( availableWidth / measureTextWidth ( 'e' ) ) * getTruncationLineCount ( ) ;
}
export function hasUnderOrOverflow (
virtualization : LogLineVirtualization ,
element : HTMLDivElement ,
calculatedHeight? : number ,
collapsed? : boolean
) : number | null {
if ( collapsed !== undefined && calculatedHeight ) {
calculatedHeight -= getLineHeight ( ) ;
calculatedHeight -= virtualization . getLineHeight ( ) ;
}
const height = calculatedHeight ? ? element . clientHeight ;
if ( element . scrollHeight > height ) {
return collapsed !== undefined ? element . scrollHeight + getLineHeight ( ) : element . scrollHeight ;
return collapsed !== undefined ? element . scrollHeight + virtualization . getLineHeight ( ) : element . scrollHeight ;
}
const child = element . children [ 1 ] ;
if ( child instanceof HTMLDivElement && child . clientHeight < height ) {
return collapsed !== undefined ? child . clientHeight + getLineHeight ( ) : child . clientHeight ;
return collapsed !== undefined ? child . clientHeight + virtualization . getLineHeight ( ) : child . clientHeight ;
}
return null ;
}
const logLineMenuIconWidth = 24 ;
const scrollBarWidth = getScrollbarWidth ( ) ;
export function getLogContainerWidth ( container : HTMLDivElement ) {
return container . clientWidth - scrollBarWidth - iconWidth ;
return container . clientWidth - scrollBarWidth - logLineMenuIconWidth ;
}
export function getScrollbarWidth() {
@ -331,21 +353,6 @@ export function getScrollbarWidth() {
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 , fontSize : LogListFontSize ) {
const key = ` ${ id } _ ${ getLogContainerWidth ( container ) } _ ${ fontSize } ` ;
logLineSizesMap . set ( key , height ) ;
}
export function retrieveLogLineSize ( id : string , container : HTMLDivElement , fontSize : LogListFontSize ) {
const key = ` ${ id } _ ${ getLogContainerWidth ( container ) } _ ${ fontSize } ` ;
return logLineSizesMap . get ( key ) ;
}
export interface ScrollToLogsEventPayload {
scrollTo : 'top' | 'bottom' ;
}