1
0
mirror of https://github.com/sasjs/server.git synced 2025-12-10 19:34:34 +00:00

feat(log): split large log into chunks

This commit is contained in:
Yury Shkoda
2023-04-18 11:42:10 +03:00
parent c72ecc7e59
commit 75f5a3c0b3
5 changed files with 387 additions and 40 deletions

View File

@@ -0,0 +1,153 @@
import { useState, useEffect, SyntheticEvent } from 'react'
import { Typography } from '@mui/material'
import Highlight from 'react-highlight'
import { ErrorOutline, Warning } from '@mui/icons-material'
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import { makeStyles } from '@mui/styles'
import {
defaultChunkSize,
parseErrorsAndWarnings,
LogInstance,
clearErrorsAndWarningsHtmlWrapping
} from '../../../../../utils'
const useStyles: any = makeStyles((theme: any) => ({
expansionDescription: {
backgroundColor: '#fbfbfb',
border: '1px solid #e2e2e2',
borderRadius: '3px',
minHeight: '50px',
padding: '10px',
boxSizing: 'border-box',
whiteSpace: 'pre-wrap',
fontFamily: 'Monaco, Courier, monospace',
position: 'relative',
width: '100%',
[theme.breakpoints.down('sm')]: {
fontSize: theme.typography.pxToRem(12)
},
[theme.breakpoints.up('md')]: {
fontSize: theme.typography.pxToRem(16)
}
}
}))
interface LogChunkProps {
id: number
text: string
expanded: boolean
logLineCount: number
onClick: (evt: any, id: number) => void
scrollToLogInstance?: LogInstance
}
const LogChunk = (props: LogChunkProps) => {
const { id, text, logLineCount, scrollToLogInstance } = props
const classes = useStyles()
const [expanded, setExpanded] = useState(props.expanded)
useEffect(() => {
setExpanded(props.expanded)
}, [props.expanded])
useEffect(() => {
if (expanded && scrollToLogInstance) {
const { type, id } = scrollToLogInstance
const line = document.getElementById(`${type}_${id}`)
const logWrapper: HTMLDivElement | null =
document.querySelector(`#logWrapper`)
const logContainer: HTMLHeadElement | null =
document.querySelector(`#log_container`)
if (line && logWrapper && logContainer) {
const initialColor = line.style.color
line.style.backgroundColor = '#f6e30599'
line.scrollIntoView({ behavior: 'smooth', block: 'start' })
setTimeout(() => {
line.setAttribute('style', `color: ${initialColor};`)
}, 3000)
}
}
}, [expanded, scrollToLogInstance])
const { errors, warnings } = parseErrorsAndWarnings(text)
return (
<div onClick={(evt) => props.onClick(evt, id)}>
<button
style={{
color: '#444',
cursor: 'pointer',
padding: '18px',
width: '100%',
textAlign: 'left',
border: 'none',
outline: 'none',
transition: '0.4s',
boxShadow:
'rgba(0, 0, 0, 0.2) 0px 2px 1px -1px, rgba(0, 0, 0, 0.14) 0px 1px 1px 0px, rgba(0, 0, 0, 0.12) 0px 1px 3px 0px'
}}
>
<Typography variant="subtitle1">
<div
style={{
display: 'flex',
flexDirection: 'row',
gap: 6,
alignItems: 'center'
}}
>
<span>{`Lines: ${id * defaultChunkSize} ... ${
(id + 1) * defaultChunkSize < logLineCount
? (id + 1) * defaultChunkSize
: logLineCount
}`}</span>
<ContentCopyIcon
style={{ fontSize: 20 }}
onClick={(evt: SyntheticEvent) => {
evt.stopPropagation()
navigator.clipboard.writeText(
clearErrorsAndWarningsHtmlWrapping(text)
)
}}
/>
{errors && errors.length !== 0 && (
<ErrorOutline color="error" style={{ fontSize: 20 }} />
)}
{warnings && warnings.length !== 0 && (
<Warning style={{ fontSize: 20, color: 'green' }} />
)}{' '}
<ExpandMoreIcon
style={{
marginLeft: 'auto',
transform: expanded ? 'rotate(180deg)' : 'unset'
}}
/>
</div>
</Typography>
</button>
<div
style={{
padding: '0 18px',
backgroundColor: 'white',
display: expanded ? 'block' : 'none',
overflow: 'hidden'
}}
>
<div id={`log_container`} className={classes.expansionDescription}>
<Highlight className={'html'} innerHTML={true}>
{expanded ? text : ''}
</Highlight>
</div>
</div>
</div>
)
}
export default LogChunk

View File

@@ -5,8 +5,14 @@ import { Typography } from '@mui/material'
import { ListItemText } from '@mui/material'
import { makeStyles } from '@mui/styles'
import Highlight from 'react-highlight'
import { LogObject } from '../../../../../utils'
import { LogObject, defaultChunkSize } from '../../../../../utils'
import { RunTimeType } from '../../../../../context/appContext'
import { splitIntoChunks, LogInstance } from '../../../../../utils'
import LogChunk from './logChunk'
import { useEffect, useState } from 'react'
// TODO:
// link to download log.log
const useStyles: any = makeStyles((theme: any) => ({
expansionDescription: {
@@ -37,32 +43,96 @@ interface LogComponentProps {
const LogComponent = (props: LogComponentProps) => {
const { log, selectedRunTime } = props
const logObject = log as LogObject
const logChunks = splitIntoChunks(logObject?.body || '')
const [logChunksState, setLogChunksState] = useState<boolean[]>(
new Array(logChunks.length).fill(false)
)
const [scrollToLogInstance, setScrollToLogInstance] = useState<LogInstance>()
const [oldestExpandedChunk, setOldestExpandedChunk] = useState<number>(
logChunksState.length - 1
)
const maxOpenedChunks = 2
const classes = useStyles()
const goToLogLine = (type: 'error' | 'warning', ind: number) => {
const line = document.getElementById(`${type}_${ind}`)
const goToLogLine = (logInstance: LogInstance, ind: number) => {
let chunkNumber = 0
for (
let i = 0;
i <= Math.ceil(logObject.linesCount / defaultChunkSize);
i++
) {
if (logInstance.line < (i + 1) * defaultChunkSize) {
chunkNumber = i
break
}
}
setLogChunksState((prevState) => {
const newState = [...prevState]
newState[chunkNumber] = true
const chunkToCollapse = getChunkToAutoCollapse()
if (chunkToCollapse !== undefined) {
newState[chunkToCollapse] = false
}
return newState
})
setScrollToLogInstance(logInstance)
}
useEffect(() => {
// INFO: expand the last chunk by default
setLogChunksState((prevState) => {
const lastChunk = prevState.length - 1
const newState = [...prevState]
newState[lastChunk] = true
return newState
})
setTimeout(() => {
scrollToTheBottom()
}, 100)
}, [])
// INFO: scroll to the bottom of the log
const scrollToTheBottom = () => {
const logWrapper: HTMLDivElement | null =
document.querySelector(`#logWrapper`)
const logContainer: HTMLHeadElement | null =
document.querySelector(`#log_container`)
if (line && logWrapper && logContainer) {
line.style.backgroundColor = '#f6e30599'
logWrapper.scrollTop =
line.offsetTop - logWrapper.offsetTop + logContainer.offsetTop
setTimeout(() => {
line.setAttribute('style', '')
}, 3000)
if (logWrapper) {
logWrapper.scrollTop = logWrapper.scrollHeight
}
}
const decodeHtml = (encodedString: string) => {
const tempElement = document.createElement('textarea')
tempElement.innerHTML = encodedString
const getChunkToAutoCollapse = () => {
const openedChunks = logChunksState
.map((chunkState: boolean, id: number) => (chunkState ? id : undefined))
.filter((chunk) => chunk !== undefined)
return tempElement.value
if (openedChunks.length < maxOpenedChunks) return undefined
else {
const chunkToCollapse = oldestExpandedChunk
const newOldestChunk = openedChunks.filter(
(chunk) => chunk !== chunkToCollapse
)[0]
if (newOldestChunk !== undefined) {
setOldestExpandedChunk(newOldestChunk)
return chunkToCollapse
}
return undefined
}
}
return (
@@ -100,9 +170,19 @@ const LogComponent = (props: LogComponentProps) => {
logObject.errors.map((error, ind) => (
<TreeItem
nodeId={`error_${ind}`}
label={<ListItemText primary={error} />}
label={<ListItemText primary={error.body} />}
key={`error_${ind}`}
onClick={() => goToLogLine('error', ind)}
onClick={() => {
setLogChunksState((prevState) => {
const newState = [...prevState]
newState[ind] = true
return newState
})
goToLogLine(error, ind)
}}
/>
))}
</TreeItem>
@@ -118,9 +198,19 @@ const LogComponent = (props: LogComponentProps) => {
logObject.warnings.map((warning, ind) => (
<TreeItem
nodeId={`warning_${ind}`}
label={<ListItemText primary={warning} />}
label={<ListItemText primary={warning.body} />}
key={`warning_${ind}`}
onClick={() => goToLogLine('warning', ind)}
onClick={() => {
setLogChunksState((prevState) => {
const newState = [...prevState]
newState[ind] = true
return newState
})
goToLogLine(warning, ind)
}}
/>
))}
</TreeItem>
@@ -129,15 +219,48 @@ const LogComponent = (props: LogComponentProps) => {
</div>
</div>
<Typography
id={`log_container`}
variant="h5"
className={classes.expansionDescription}
>
<Highlight className={'html'} innerHTML={true}>
{decodeHtml(logObject?.body || '')}
</Highlight>
</Typography>
{Array.isArray(logChunks) ? (
logChunks.map((chunk: string, id: number) => (
<LogChunk
id={id}
text={chunk}
expanded={logChunksState[id]}
key={`log-chunk-${id}`}
logLineCount={logObject.linesCount}
scrollToLogInstance={scrollToLogInstance}
onClick={(evt, chunkNumber) => {
setLogChunksState((prevState) => {
const newState = [...prevState]
const expand = !newState[chunkNumber]
newState[chunkNumber] = expand
if (expand) {
const chunkToCollapse = getChunkToAutoCollapse()
if (chunkToCollapse !== undefined) {
newState[chunkToCollapse] = false
}
}
return newState
})
setScrollToLogInstance(undefined)
}}
/>
))
) : (
<Typography
id={`log_container`}
variant="h5"
className={classes.expansionDescription}
>
<Highlight className={'html'} innerHTML={true}>
{logChunks}
</Highlight>
</Typography>
)}
</div>
) : (
<div>

View File

@@ -178,7 +178,8 @@ const useEditor = ({
const log: LogObject = {
body: logLines.join(`\n`),
errors,
warnings
warnings,
linesCount: logLines.length
}
setLog(log)

View File

@@ -1,21 +1,36 @@
import { LogInstance } from './'
export const parseErrorsAndWarnings = (log: string) => {
const logLines = log.split('\n')
const errorLines: string[] = []
const warningLines: string[] = []
const errorLines: LogInstance[] = []
const warningLines: LogInstance[] = []
logLines.forEach((line: string, index: number) => {
// INFO: check if content in element starts with ERROR
if (/<.*>ERROR/gm.test(line)) {
const errorLine = line.substring(line.indexOf('E'), line.length - 1)
errorLines.push(errorLine)
errorLines.push({
body: errorLine,
line: index,
type: 'error',
id: errorLines.length
})
}
// INFO: check if line starts with ERROR
else if (/^ERROR/gm.test(line)) {
errorLines.push(line)
errorLines.push({
body: line,
line: index,
type: 'error',
id: errorLines.length
})
logLines[index] =
`<font id="error_${errorLines.length - 1}" style="color: red;">` +
`<font id="error_${
errorLines.length - 1
}" style="color: red;" ref={scrollTo}>` +
logLines[index] +
'</font>'
}
@@ -23,12 +38,23 @@ export const parseErrorsAndWarnings = (log: string) => {
// INFO: check if content in element starts with WARNING
else if (/<.*>WARNING/gm.test(line)) {
const warningLine = line.substring(line.indexOf('W'), line.length - 1)
warningLines.push(warningLine)
warningLines.push({
body: warningLine,
line: index,
type: 'warning',
id: warningLines.length
})
}
// INFO: check if line starts with WARNING
else if (/^WARNING/gm.test(line)) {
warningLines.push(line)
warningLines.push({
body: line,
line: index,
type: 'warning',
id: warningLines.length
})
logLines[index] =
`<font id="warning_${warningLines.length - 1}" style="color: green;">` +
@@ -39,3 +65,38 @@ export const parseErrorsAndWarnings = (log: string) => {
return { errors: errorLines, warnings: warningLines, logLines }
}
export const defaultChunkSize = 20000
export const isTheLastChunk = (
lineCount: number,
chunkNumber: number,
chunkSize = defaultChunkSize
) => {
if (lineCount <= chunkSize) return true
const chunksNumber = Math.ceil(lineCount / chunkSize)
return chunkNumber === chunksNumber
}
export const splitIntoChunks = (log: string, chunkSize = defaultChunkSize) => {
if (!log.length) return []
const logLines: string[] = log.split(`\n`)
if (logLines.length <= chunkSize) return log
const chunks: string[] = []
while (logLines.length) {
const chunk = logLines.splice(0, chunkSize)
chunks.push(chunk.join(`\n`))
}
return chunks
}
export const clearErrorsAndWarningsHtmlWrapping = (log: string) =>
log.replace(/^<font[^>]*>/gm, '').replace(/<\/font>/gm, '')

View File

@@ -40,8 +40,17 @@ export interface TreeNode {
children: Array<TreeNode>
}
export interface LogInstance {
body: string
line: number
type: 'error' | 'warning'
id: number
ref?: any
}
export interface LogObject {
body: string
errors?: string[]
warnings?: string[]
errors?: LogInstance[]
warnings?: LogInstance[]
linesCount: number
}