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:
153
web/src/containers/Studio/internal/components/log/logChunk.tsx
Normal file
153
web/src/containers/Studio/internal/components/log/logChunk.tsx
Normal 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
|
||||
@@ -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>
|
||||
|
||||
@@ -178,7 +178,8 @@ const useEditor = ({
|
||||
const log: LogObject = {
|
||||
body: logLines.join(`\n`),
|
||||
errors,
|
||||
warnings
|
||||
warnings,
|
||||
linesCount: logLines.length
|
||||
}
|
||||
|
||||
setLog(log)
|
||||
|
||||
@@ -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, '')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user