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

Merge pull request #351 from sasjs/issue-346

Improve SAS log
This commit is contained in:
Allan Bowe
2023-04-28 10:29:13 +01:00
committed by GitHub
15 changed files with 1527 additions and 204 deletions

937
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -25,6 +25,7 @@
"react": "^17.0.2",
"react-copy-to-clipboard": "^5.1.0",
"react-dom": "^17.0.2",
"react-highlight": "^0.15.0",
"react-monaco-editor": "^0.48.0",
"react-router-dom": "^6.3.0",
"react-toastify": "^9.0.1"
@@ -41,6 +42,7 @@
"@types/react": "^17.0.37",
"@types/react-copy-to-clipboard": "^5.0.2",
"@types/react-dom": "^17.0.11",
"@types/react-highlight": "^0.12.5",
"@types/react-router-dom": "^5.3.1",
"babel-loader": "^8.2.3",
"babel-plugin-prismjs": "^2.1.0",
@@ -59,6 +61,7 @@
"style-loader": "^3.3.1",
"ts-loader": "^9.2.6",
"typescript": "^4.5.2",
"typescript-plugin-css-modules": "^5.0.1",
"webpack": "5.64.3",
"webpack-cli": "^4.9.2",
"webpack-dev-server": "4.7.4"

View File

@@ -9,7 +9,7 @@ import { PermissionsContext } from '../../../../context/permissionsContext'
import {
findExistingPermission,
findUpdatingPermission
} from '../../../../utils/helper'
} from '../../../../utils'
const useAddPermission = () => {
const {

View File

@@ -1,4 +1,4 @@
import React, { Dispatch, SetStateAction } from 'react'
import { Dispatch, SetStateAction } from 'react'
import {
Backdrop,
@@ -17,10 +17,14 @@ import { TabContext, TabList, TabPanel } from '@mui/lab'
import FilePathInputModal from '../../components/filePathInputModal'
import FileMenu from './internal/components/fileMenu'
import RunMenu from './internal/components/runMenu'
import LogComponent from './internal/components/log/logComponent'
import LogTabWithIcons from './internal/components/log/logTabWithIcons'
import { usePrompt } from '../../utils/hooks'
import { getLanguageFromExtension } from './internal/helper'
import useEditor from './internal/hooks/useEditor'
import { RunTimeType } from '../../context/appContext'
import { LogObject } from '../../utils'
const StyledTabPanel = styled(TabPanel)(() => ({
padding: '10px'
@@ -108,6 +112,10 @@ const SASjsEditor = ({
/>
)
// INFO: variable indicating if selected run type is SAS if there are any errors or warnings in the log
const logWithErrorsOrWarnings =
selectedRunTime === RunTimeType.SAS && log && typeof log === 'object'
return (
<Box sx={{ width: '100%', typography: 'body1', marginTop: '50px' }}>
<Backdrop
@@ -145,7 +153,22 @@ const SASjsEditor = ({
>
<TabList onChange={handleTabChange} centered>
<StyledTab label="Code" value="code" />
<StyledTab label="Log" value="log" />
<StyledTab
label={logWithErrorsOrWarnings ? '' : 'log'}
value="log"
icon={
logWithErrorsOrWarnings ? (
<LogTabWithIcons log={log as LogObject} />
) : (
''
)
}
onClick={() => {
const logWrapper = document.querySelector(`#logWrapper`)
if (logWrapper) logWrapper.scrollTop = 0
}}
/>
<StyledTab
label={
<Tooltip title="Displays content from the _webout fileref">
@@ -195,15 +218,9 @@ const SASjsEditor = ({
</Paper>
</StyledTabPanel>
<StyledTabPanel value="log">
<div>
<h2>Log</h2>
<pre
id="log"
style={{ overflow: 'auto', height: 'calc(100vh - 220px)' }}
>
{log}
</pre>
</div>
{log && (
<LogComponent log={log} selectedRunTime={selectedRunTime} />
)}
</StyledTabPanel>
<StyledTabPanel value="webout">
<div>

View File

@@ -0,0 +1,86 @@
.ChunkHeader {
color: #444;
cursor: pointer;
padding: 18px;
width: 100%;
text-align: left;
border: none;
outline: none;
transition: 0.4s;
box-shadow: 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;
}
.ChunkDetails {
display: flex;
flex-direction: row;
gap: 6px;
align-items: center;
}
.ChunkExpandIcon {
margin-left: auto;
}
.ChunkBody {
background-color: white;
overflow: hidden;
}
.ChunksContainer {
display: flex;
flex-direction: column;
gap: 10px;
}
.LogContainer {
background-color: #fbfbfb;
border: 1px solid #e2e2e2;
border-radius: 3px;
min-height: 50px;
padding: 10px;
box-sizing: border-box;
white-space: pre-wrap;
font-family: Monaco, Courier, monospace;
position: relative;
width: 100%;
}
.LogWrapper {
overflow-y: auto;
max-height: calc(100vh - 130px);
}
.LogBody {
overflow: auto;
height: calc(100vh - 220px);
}
.TreeContainer {
background-color: white;
padding-top: 10px;
padding-bottom: 10px;
}
.TabContainer {
display: flex;
flex-direction: row;
gap: 6px;
align-items: center;
}
.TabDownloadIcon {
margin-left: 20px;
}
.HighlightedLine {
background-color: #f6e30599;
}
.Icon {
font-size: 20px !important;
}
.GreenIcon {
color: green;
}

View File

@@ -0,0 +1,168 @@
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 CheckIcon from '@mui/icons-material/Check'
import FileDownloadIcon from '@mui/icons-material/FileDownload'
import {
defaultChunkSize,
parseErrorsAndWarnings,
LogInstance,
clearErrorsAndWarningsHtmlWrapping,
download
} from '../../../../../utils'
import { logStyles } from './logComponent'
import classes from './log.module.css'
interface LogChunkProps {
id: number
text: string
expanded: boolean
logLineCount: number
onClick: (evt: any, id: number) => void
scrollToLogInstance?: LogInstance
updated: number
}
const LogChunk = (props: LogChunkProps) => {
const { id, text, logLineCount } = props
const [scrollToLogInstance, setScrollToLogInstance] = useState(
props.scrollToLogInstance
)
const rowText = clearErrorsAndWarningsHtmlWrapping(text)
const styles = logStyles()
const [expanded, setExpanded] = useState(props.expanded)
const [copied, setCopied] = useState(false)
useEffect(() => {
setExpanded(props.expanded)
}, [props.expanded])
useEffect(() => {
if (props.expanded !== expanded) {
setExpanded(props.expanded)
}
if (
props.scrollToLogInstance &&
props.scrollToLogInstance !== scrollToLogInstance
) {
setScrollToLogInstance(props.scrollToLogInstance)
}
}, [props])
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) {
line.className = classes.HighlightedLine
line.scrollIntoView({ behavior: 'smooth', block: 'start' })
setTimeout(() => {
line.classList.remove(classes.HighlightedLine)
setScrollToLogInstance(undefined)
}, 3000)
}
}
}, [expanded, scrollToLogInstance, props])
const { errors, warnings } = parseErrorsAndWarnings(text)
const getLineRange = (separator = ' ... ') =>
`${id * defaultChunkSize}${separator}${
(id + 1) * defaultChunkSize < logLineCount
? (id + 1) * defaultChunkSize
: logLineCount
}`
return (
<div onClick={(evt) => props.onClick(evt, id)}>
<button className={classes.ChunkHeader}>
<Typography variant="subtitle1">
<div className={classes.ChunkDetails}>
<span>{`Lines: ${getLineRange()}`}</span>
{copied ? (
<CheckIcon
className={[classes.Icon, classes.GreenIcon].join(' ')}
/>
) : (
<ContentCopyIcon
className={classes.Icon}
onClick={(evt: SyntheticEvent) => {
evt.stopPropagation()
navigator.clipboard.writeText(rowText)
setCopied(true)
setTimeout(() => {
setCopied(false)
}, 1000)
}}
/>
)}
<FileDownloadIcon
onClick={(evt: SyntheticEvent) => {
download(evt, rowText, `.${getLineRange('-')}`)
}}
/>
{errors && errors.length !== 0 && (
<ErrorOutline
color="error"
className={classes.Icon}
onClick={() => {
setScrollToLogInstance(errors[0])
}}
/>
)}
{warnings && warnings.length !== 0 && (
<Warning
className={[classes.Icon, classes.GreenIcon].join(' ')}
onClick={(evt) => {
if (expanded) evt.stopPropagation()
setScrollToLogInstance(warnings[0])
}}
/>
)}{' '}
<ExpandMoreIcon
className={classes.ChunkExpandIcon}
style={{
transform: expanded ? 'rotate(180deg)' : 'unset'
}}
/>
</div>
</Typography>
</button>
<div
className={classes.ChunkBody}
style={{
display: expanded ? 'block' : 'none'
}}
>
<div
id={`log_container`}
className={[styles.expansionDescription, classes.LogContainer].join(
' '
)}
>
<Highlight className={'html'} innerHTML={true}>
{expanded ? text : ''}
</Highlight>
</div>
</div>
</div>
)
}
export default LogChunk

View File

@@ -0,0 +1,243 @@
import { useEffect, useState } from 'react'
import TreeView from '@mui/lab/TreeView'
import TreeItem from '@mui/lab/TreeItem'
import { ChevronRight, ExpandMore } from '@mui/icons-material'
import { Typography } from '@mui/material'
import { ListItemText } from '@mui/material'
import { makeStyles } from '@mui/styles'
import Highlight from 'react-highlight'
import { LogObject, defaultChunkSize } from '../../../../../utils'
import { RunTimeType } from '../../../../../context/appContext'
import { splitIntoChunks, LogInstance } from '../../../../../utils'
import LogChunk from './logChunk'
import classes from './log.module.css'
export const logStyles: any = makeStyles((theme: any) => ({
expansionDescription: {
[theme.breakpoints.down('sm')]: {
fontSize: theme.typography.pxToRem(12)
},
[theme.breakpoints.up('md')]: {
fontSize: theme.typography.pxToRem(16)
}
}
}))
interface LogComponentProps {
log: LogObject | string
selectedRunTime: RunTimeType | string
}
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 styles = logStyles()
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`)
if (logWrapper) {
logWrapper.scrollTop = logWrapper.scrollHeight
}
}
const getChunkToAutoCollapse = () => {
const openedChunks = logChunksState
.map((chunkState: boolean, id: number) => (chunkState ? id : undefined))
.filter((chunk) => chunk !== undefined)
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
}
}
const hasErrorsOrWarnings =
logObject.errors?.length !== 0 || logObject.warnings?.length !== 0
const logBody = typeof log === 'string' ? log : log.body
return (
<>
{selectedRunTime === RunTimeType.SAS && logObject.body ? (
<div id="logWrapper" className={classes.LogWrapper}>
<div>
{hasErrorsOrWarnings && (
<div className={classes.TreeContainer}>
<TreeView
defaultCollapseIcon={<ExpandMore />}
defaultExpandIcon={<ChevronRight />}
>
{logObject.errors && logObject.errors.length !== 0 && (
<TreeItem
nodeId="errors"
label={
<Typography color="error">
{`Errors (${logObject.errors.length})`}
</Typography>
}
>
{logObject.errors &&
logObject.errors.map((error, ind) => (
<TreeItem
nodeId={`error_${ind}`}
label={<ListItemText primary={error.body} />}
key={`error_${ind}`}
onClick={() => goToLogLine(error, ind)}
/>
))}
</TreeItem>
)}
{logObject.warnings && logObject.warnings.length !== 0 && (
<TreeItem
nodeId="warnings"
label={
<Typography>{`Warnings (${logObject.warnings.length})`}</Typography>
}
>
{logObject.warnings &&
logObject.warnings.map((warning, ind) => (
<TreeItem
nodeId={`warning_${ind}`}
label={<ListItemText primary={warning.body} />}
key={`warning_${ind}`}
onClick={() => goToLogLine(warning, ind)}
/>
))}
</TreeItem>
)}
</TreeView>
</div>
)}
</div>
<div className={classes.ChunksContainer}>
{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}
updated={Date.now()}
onClick={(_, 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={[
styles.expansionDescription,
classes.LogContainer
].join(' ')}
>
<Highlight className={'html'} innerHTML={true}>
{logChunks}
</Highlight>
</Typography>
)}
</div>
</div>
) : (
<div>
<h2>Log</h2>
<pre id="log" className={classes.LogBody}>
{logBody}
</pre>
</div>
)}
</>
)
}
export default LogComponent

View File

@@ -0,0 +1,41 @@
import { ErrorOutline, Warning } from '@mui/icons-material'
import FileDownloadIcon from '@mui/icons-material/FileDownload'
import {
LogObject,
download,
clearErrorsAndWarningsHtmlWrapping
} from '../../../../../utils'
import Tooltip from '@mui/material/Tooltip'
import classes from './log.module.css'
interface LogTabProps {
log: LogObject
}
const LogTabWithIcons = (props: LogTabProps) => {
const { errors, warnings, body } = props.log
return (
<div className={classes.TabContainer}>
<span>log</span>
{errors && errors.length !== 0 && (
<ErrorOutline color="error" className={classes.Icon} />
)}
{warnings && warnings.length !== 0 && (
<Warning className={[classes.Icon, classes.GreenIcon].join(' ')} />
)}
<Tooltip
title="Download entire log"
onClick={(evt) => {
download(evt, clearErrorsAndWarningsHtmlWrapping(body))
}}
>
<FileDownloadIcon
className={[classes.Icon, classes.TabDownloadIcon].join(' ')}
/>
</Tooltip>
</div>
)
}
export default LogTabWithIcons

View File

@@ -18,6 +18,7 @@ import {
useSnackbar,
useStateWithCallback
} from '../../../../utils/hooks'
import { parseErrorsAndWarnings, LogObject } from '../../../../utils'
const SASJS_LOGS_SEPARATOR =
'SASJS_LOGS_SEPARATOR_163ee17b6ff24f028928972d80a26784'
@@ -41,10 +42,12 @@ const useEditor = ({
const [prevFileContent, setPrevFileContent] = useStateWithCallback('')
const [fileContent, setFileContent] = useState('')
const [log, setLog] = useState('')
const [log, setLog] = useState<LogObject | string>()
const [webout, setWebout] = useState('')
const [runTimes, setRunTimes] = useState<string[]>([])
const [selectedRunTime, setSelectedRunTime] = useState('')
const [selectedRunTime, setSelectedRunTime] = useState<RunTimeType | string>(
''
)
const [selectedFileExtension, setSelectedFileExtension] = useState('')
const [openFilePathInputModal, setOpenFilePathInputModal] = useState(false)
const [showDiff, setShowDiff] = useState(false)
@@ -150,6 +153,13 @@ const useEditor = ({
const runCode = useCallback(
(code: string) => {
setIsLoading(true)
// Scroll to bottom of log
const logElement = document.getElementById('log')
if (logElement) logElement.scrollTop = logElement.scrollHeight
setIsLoading(false)
axios
.post(`/SASjsApi/code/execute`, {
code: programPathInjection(
@@ -160,8 +170,24 @@ const useEditor = ({
runTime: selectedRunTime
})
.then((res: any) => {
if (selectedRunTime === RunTimeType.SAS) {
const { errors, warnings, logLines } = parseErrorsAndWarnings(
res.data.split(SASJS_LOGS_SEPARATOR)[1]
)
const log: LogObject = {
body: logLines.join(`\n`),
errors,
warnings,
linesCount: logLines.length
}
setLog(log)
} else {
setLog(res.data.split(SASJS_LOGS_SEPARATOR)[1] ?? '')
}
setWebout(res.data.split(SASJS_LOGS_SEPARATOR)[0] ?? '')
setLog(res.data.split(SASJS_LOGS_SEPARATOR)[1] ?? '')
setTab('log')
// Scroll to bottom of log
@@ -249,7 +275,7 @@ const useEditor = ({
}, [appContext.runTimes])
useEffect(() => {
if (runTimes.length) setSelectedRunTime(runTimes[0])
if (runTimes.length) setSelectedRunTime(runTimes[0] as RunTimeType)
}, [runTimes])
useEffect(() => {
@@ -280,7 +306,6 @@ const useEditor = ({
const content = localStorage.getItem('fileContent') ?? ''
setFileContent(content)
}
setLog('')
setWebout('')
setTab('code')
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -294,7 +319,9 @@ const useEditor = ({
useEffect(() => {
const fileExtension = selectedFileExtension.toLowerCase()
if (runTimes.includes(fileExtension)) setSelectedRunTime(fileExtension)
if (runTimes.includes(fileExtension))
setSelectedRunTime(fileExtension as RunTimeType)
}, [selectedFileExtension, runTimes])
return {

4
web/src/types/declaration.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare module '*.module.css' {
const classes: { [key: string]: string }
export default classes
}

3
web/src/utils/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './log'
export * from './types'
export * from './helper'

133
web/src/utils/log.ts Normal file
View File

@@ -0,0 +1,133 @@
import { SyntheticEvent } from 'react'
import { LogInstance } from './'
export const parseErrorsAndWarnings = (log: string) => {
const logLines = log.split('\n')
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({
body: errorLine,
line: index,
type: 'error',
id: errorLines.length
})
}
// INFO: check if line starts with ERROR
else if (/^ERROR/gm.test(line)) {
errorLines.push({
body: line,
line: index,
type: 'error',
id: errorLines.length
})
logLines[index] =
`<font id="error_${
errorLines.length - 1
}" style="color: red;" ref={scrollTo}>` +
logLines[index] +
'</font>'
}
// 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({
body: warningLine,
line: index,
type: 'warning',
id: warningLines.length
})
}
// INFO: check if line starts with WARNING
else if (/^WARNING/gm.test(line)) {
warningLines.push({
body: line,
line: index,
type: 'warning',
id: warningLines.length
})
logLines[index] =
`<font id="warning_${warningLines.length - 1}" style="color: green;">` +
logLines[index] +
'</font>'
}
})
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) 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, '')
export const download = (evt: SyntheticEvent, log: string, fileName = '') => {
evt.stopPropagation()
const padWithZero = (num: number) => (num < 9 ? `0${num}` : `${num}`)
const date = new Date()
const datePrefix = [
date.getFullYear(),
padWithZero(date.getMonth() + 1),
padWithZero(date.getDate()),
padWithZero(date.getHours()),
padWithZero(date.getMinutes()),
padWithZero(date.getSeconds())
].join('')
const file = new Blob([log])
const url = URL.createObjectURL(file)
const a = document.createElement('a')
a.href = url
a.download = `${datePrefix}${fileName}.log`
document.body.appendChild(a)
a.click()
setTimeout(() => {
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
}, 0)
}

View File

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

View File

@@ -14,7 +14,8 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
"jsx": "react-jsx",
"plugins": [{ "name": "typescript-plugin-css-modules" }]
},
"include": ["src"]
}

View File

@@ -32,9 +32,18 @@ const config: Configuration = {
]
},
{
test: /\.css$/,
exclude: ['/node_modules/'],
use: ['style-loader', 'css-loader']
test: /\.css$/i,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
modules: {
localIdentName: '[local]--[hash:base64:5]'
}
}
}
]
},
{
test: /\.scss$/,