feat(logs): number the lines of the log, the source and the generated code #485

Merged
allan merged 1 commits from feat/logs-line-numbers into release/7.23 2026-11-06 12:37:08 +00:00
4 changed files with 530 additions and 148 deletions

No files matched your search

@@ -58,7 +58,13 @@
Errors ({{ programLog.errors!.length }})
@for (issue of programLog.errors; track $index) {
<clr-tree-node
(click)="goToLogLine(issue, 'request_' + i)"
(click)="
goToLogLine(
issue,
programLog,
'request_' + i
)
"
>
<button class="clr-treenode-link">
@if (issue.code) {
@@ -84,7 +90,13 @@
track $index
) {
<clr-tree-node
(click)="goToLogLine(issue, 'request_' + i)"
(click)="
goToLogLine(
issue,
programLog,
'request_' + i
)
"
>
<button class="clr-treenode-link">
@if (issue.code) {
@@ -104,7 +116,13 @@
Notes ({{ programLog.notes!.length }})
@for (issue of programLog.notes; track $index) {
<clr-tree-node
(click)="goToLogLine(issue, 'request_' + i)"
(click)="
goToLogLine(
issue,
programLog,
'request_' + i
)
"
>
<button class="clr-treenode-link">
@if (issue.code) {
@@ -119,17 +137,41 @@
</clr-tree-node>
}
</clr-tree>
@if (serverType !== 'SASVIYA') {
<div
class="log-wrapper saslog"
[innerHTML]="programLog.logHtml"
></div>
}
@if (serverType === 'SASVIYA') {
<div
class="log-wrapper saslog viya"
[textContent]="programLog.logFile"
></div>
@if (programLog.logWindow; as logWindow) {
@if (logWindow.visible.length) {
<div class="log-wrapper saslog">
@for (line of logWindow.visible; track $index) {
<div class="log-line">
<span class="log-line-number">{{
$index + 1
}}</span>
@if (programLog.logIsHtml) {
<span
class="log-line-text"
[innerHTML]="line"
></span>
} @else {
<span class="log-line-text">{{
line
}}</span>
}
</div>
}
</div>
@if (hasMoreLines(logWindow)) {
<p class="log-more">
Showing {{ logWindow.visible.length }} of
{{ logWindow.all.length }} lines
<button
type="button"
class="btn btn-link"
(click)="showMoreLines(logWindow)"
>
Show more
</button>
</p>
}
}
}
@if (programLog.logFile.length > 0) {
<button
@@ -147,9 +189,31 @@
<button clrTabLink>SAS Source Code</button>
<ng-template [(clrIfActive)]="sasSourceCodeActive">
<clr-tab-content>
<div class="log-wrapper">
{{ programLog.sourceCode }}
</div>
@if (programLog.sourceWindow; as sourceWindow) {
<div class="log-wrapper">
@for (line of sourceWindow.visible; track $index) {
<div class="log-line">
<span class="log-line-number">{{
$index + 1
}}</span>
<span class="log-line-text">{{ line }}</span>
</div>
}
</div>
@if (hasMoreLines(sourceWindow)) {
<p class="log-more">
Showing {{ sourceWindow.visible.length }} of
{{ sourceWindow.all.length }} lines
<button
type="button"
class="btn btn-link"
(click)="showMoreLines(sourceWindow)"
>
Show more
</button>
</p>
}
}
@if (programLog.sourceCode.length > 0) {
<button
(click)="downloadSourceCode(programLog.sourceCode)"
@@ -166,9 +230,34 @@
<button clrTabLink>SAS Generated Code</button>
<ng-template [(clrIfActive)]="sasGeneratedCodeActive">
<clr-tab-content>
<div class="log-wrapper">
{{ programLog.generatedCode }}
</div>
@if (programLog.generatedWindow; as generatedWindow) {
<div class="log-wrapper">
@for (
line of generatedWindow.visible;
track $index
) {
<div class="log-line">
<span class="log-line-number">{{
$index + 1
}}</span>
<span class="log-line-text">{{ line }}</span>
</div>
}
</div>
@if (hasMoreLines(generatedWindow)) {
<p class="log-more">
Showing {{ generatedWindow.visible.length }} of
{{ generatedWindow.all.length }} lines
<button
type="button"
class="btn btn-link"
(click)="showMoreLines(generatedWindow)"
>
Show more
</button>
</p>
}
}
@if (programLog.generatedCode.length > 0) {
<button
(click)="
@@ -1,5 +1,9 @@
import { LogIssue } from '@sasjs/utils/logs'
import { SasLogsModalComponent } from './sas-logs-modal.component'
import { LogIssue, parseLog } from '@sasjs/utils/logs'
import {
FIRST_RENDERED_LINES,
MORE_RENDERED_LINES,
SasLogsModalComponent
} from './sas-logs-modal.component'
const LOG = [
'1 data want;',
@@ -35,7 +39,8 @@ describe('SasLogsModalComponent', () => {
const buildComponent = (
requests: any[] = [],
serverType: string = 'SAS9'
serverType: string = 'SAS9',
changeDetector: any = { detectChanges: () => {} }
): SasLogsModalComponent => {
sasService = {
getSasjsConfig: () => ({ appLoc: '/Public/app' }),
@@ -50,7 +55,8 @@ describe('SasLogsModalComponent', () => {
return new SasLogsModalComponent(
sasService,
{ log: () => {} } as any,
helperService
helperService,
changeDetector
)
}
@@ -107,16 +113,65 @@ describe('SasLogsModalComponent', () => {
expect(request.logIssues).toEqual([])
expect(request.errors).toEqual([])
expect(request.logHtml).toEqual('')
expect(request.logWindow).toEqual({ all: [], visible: [] })
})
})
describe('splitLines', () => {
it('splits the log, the source and the generated code into lines', () => {
const request: any = {
logFile: LOG,
sourceCode: 'data want;\nrun;\n',
generatedCode: 'proc print;\nrun;'
}
component.analyseLog(request)
expect(request.logWindow.all).toHaveSize(10)
expect(request.logWindow.all[5]).toContain('WARNING: Division by zero')
expect(request.sourceWindow.all).toEqual(['data want;', 'run;'])
expect(request.generatedWindow.all).toEqual(['proc print;', 'run;'])
})
it('does not count the newline that ends the text as a line', () => {
expect(component.splitLines('a;\nb;\n')).toEqual(['a;', 'b;'])
expect(component.splitLines('a;\nb;')).toEqual(['a;', 'b;'])
})
it('keeps a carriage return out of the line it ends', () => {
expect(component.splitLines('NOTE: a.\r\nNOTE: b.\r\n')).toEqual([
'NOTE: a.',
'NOTE: b.'
])
})
it('reports no lines when there is nothing to show', () => {
expect(component.splitLines('')).toEqual([])
expect(component.splitLines(undefined)).toEqual([])
})
it('renders a Viya log as text rather than markup', () => {
component = buildComponent([], 'SASVIYA')
const request: any = { logFile: LOG }
component.analyseLog(request)
expect(request.logIsHtml).toBeFalse()
expect(request.logWindow.all[5]).toContain('WARNING: Division by zero')
expect(request.logWindow.all[5]).not.toContain('<font')
})
it('renders a SAS 9 log with its error and warning lines marked', () => {
const request: any = { logFile: LOG }
component.analyseLog(request)
expect(request.logIsHtml).toBeTrue()
expect(request.logWindow.all[5]).toContain('<font class="warning-line">')
expect(request.logWindow.all[6]).toContain('<font class="error-line">')
})
})
describe('markIssueLines', () => {
it('marks every line an issue occupies, the wrapped ones included', () => {
const request: any = { logFile: LOG }
component.analyseLog(request)
const lines: string[] = request.logHtml.split('\n')
const lines = component.markIssueLines(LOG, parseLog(LOG)).split('\n')
expect(lines[5]).toEqual(
'<font class="warning-line">WARNING: Division by zero detected at line 3 column 11.</font>'
@@ -129,10 +184,7 @@ describe('SasLogsModalComponent', () => {
})
it('leaves a notable note unmarked, and the other lines untouched', () => {
const request: any = { logFile: LOG }
component.analyseLog(request)
const lines: string[] = request.logHtml.split('\n')
const lines = component.markIssueLines(LOG, parseLog(LOG)).split('\n')
expect(lines[9]).toEqual('NOTE: Invalid data for age in line 42 3-5.')
expect(lines[0]).toEqual('1 data want;')
@@ -146,72 +198,208 @@ describe('SasLogsModalComponent', () => {
})
})
describe('the rendered window', () => {
const block = (count: number) =>
Array.from({ length: count }, (_, index) => `line ${index + 1}`).join(
'\n'
)
it('renders the first block of a long pane and keeps the rest back', () => {
const pane = component.lineWindow(block(5000))
expect(pane.all).toHaveSize(5000)
expect(pane.visible).toHaveSize(FIRST_RENDERED_LINES)
expect(component.hasMoreLines(pane)).toBeTrue()
})
it('renders everything when the pane is shorter than one block', () => {
const pane = component.lineWindow(block(12))
expect(pane.visible).toHaveSize(12)
expect(component.hasMoreLines(pane)).toBeFalse()
})
it('adds one block per Show more and stops at the last line', () => {
const pane = component.lineWindow(block(5000))
component.showMoreLines(pane)
expect(pane.visible).toHaveSize(
FIRST_RENDERED_LINES + MORE_RENDERED_LINES
)
while (component.hasMoreLines(pane)) component.showMoreLines(pane)
expect(pane.visible).toHaveSize(5000)
expect(pane.visible[4999]).toEqual('line 5000')
})
it('renders through a line a link points at, once', () => {
const pane = component.lineWindow(block(5000))
expect(component.renderThrough(pane, 10)).toBeFalse()
expect(component.renderThrough(pane, 1200)).toBeTrue()
expect(pane.visible).toHaveSize(1201)
expect(component.renderThrough(pane, 1200)).toBeFalse()
// A line past the end renders the rest rather than nothing.
expect(component.renderThrough(pane, 9000)).toBeTrue()
expect(pane.visible).toHaveSize(5000)
expect(component.renderThrough(pane, 9000)).toBeFalse()
})
})
describe('goToLogLine', () => {
let wrapper: HTMLElement
beforeEach(() => {
/* The rendered log: one .log-line row per line, as the template draws it. */
const renderLines = (count: number, requestId = 'request_0') => {
const stackBlock = document.createElement('div')
stackBlock.id = 'request_0'
stackBlock.id = requestId
wrapper = document.createElement('div')
wrapper.className = 'log-wrapper saslog'
wrapper.textContent = LOG
wrapper.style.height = '40px'
wrapper.style.overflow = 'auto'
for (let index = 0; index < count; index++) {
const row = document.createElement('div')
row.className = 'log-line'
const number = document.createElement('span')
number.className = 'log-line-number'
number.textContent = `${index + 1}`
const text = document.createElement('span')
text.className = 'log-line-text'
text.textContent = `line ${index + 1}`
row.appendChild(number)
row.appendChild(text)
wrapper.appendChild(row)
}
stackBlock.appendChild(wrapper)
document.body.appendChild(stackBlock)
// The height of a rendered line needs a layout; a fixed value keeps the
// arithmetic under test deterministic.
;(component as any).measureLineHeight = () => 10
return stackBlock
}
afterEach(() => wrapper?.parentElement?.remove())
const row = (index: number) => wrapper.querySelectorAll('.log-line')[index]
it('scrolls the line the parser reported to the top of the log', () => {
renderLines(60)
const target = row(40) as HTMLElement
const lineTop =
target.getBoundingClientRect().top - wrapper.getBoundingClientRect().top
component.goToLogLine(issue({ line: 40 }), undefined, 'request_0')
expect(
Math.abs(wrapper.scrollTop - (lineTop - wrapper.clientTop))
).toBeLessThan(1.5)
expect(lineTop).toBeGreaterThan(wrapper.clientHeight)
})
afterEach(() => wrapper.parentElement?.remove())
it('keeps the border out of the scroll offset', () => {
renderLines(60)
wrapper.style.border = '6px solid #c9c9c9'
const target = row(40) as HTMLElement
const lineTop =
target.getBoundingClientRect().top - wrapper.getBoundingClientRect().top
it('positions the highlight from the line the parser reported', () => {
component.goToLogLine(issue({ line: 6 }), 'request_0')
component.goToLogLine(issue({ line: 40 }), undefined, 'request_0')
expect(
document.documentElement.style.getPropertyValue('--line-top')
).toEqual('60px')
expect(
document.documentElement.style.getPropertyValue('--line-height')
).toEqual('10px')
expect(
document.documentElement.style.getPropertyValue('--line-count')
).toEqual('1')
expect(wrapper.clientTop).toEqual(6)
expect(Math.abs(wrapper.scrollTop - (lineTop - 6))).toBeLessThan(1.5)
})
it('does not jump to the first of two identical messages', () => {
const repeated = issue({ line: 7, message: 'a message' })
it('treats an issue that ends before it starts as one line', () => {
renderLines(60)
component.goToLogLine(repeated, 'request_0')
expect(
document.documentElement.style.getPropertyValue('--line-top')
).toEqual('70px')
})
it('covers every line a wrapped message runs to', () => {
component.goToLogLine(issue({ line: 7, endLine: 8 }), 'request_0')
expect(
document.documentElement.style.getPropertyValue('--line-count')
).toEqual('2')
})
it('flashes the colour of the severity it was given', () => {
component.goToLogLine(
issue({ line: 5, severity: 'warning' }),
issue({ line: 40, endLine: 0 }),
undefined,
'request_0'
)
const overlay = wrapper.querySelector('.line-highlight-overlay')
expect(row(40).classList).toContain('error-highlight')
expect(row(41).classList).not.toContain('error-highlight')
})
expect(overlay).not.toBeNull()
expect(overlay!.classList).toContain('warning-highlight')
it('does not jump to the first of two identical messages', () => {
renderLines(60)
const lineTop =
(row(41) as HTMLElement).getBoundingClientRect().top -
wrapper.getBoundingClientRect().top
component.goToLogLine(
issue({ line: 41, message: 'a message' }),
undefined,
'request_0'
)
expect(Math.abs(wrapper.scrollTop - lineTop)).toBeLessThan(1.5)
})
it('flashes the colour of the severity it was given', () => {
renderLines(60)
component.goToLogLine(
issue({ line: 5, severity: 'warning' }),
undefined,
'request_0'
)
expect(row(5).classList).toContain('warning-highlight')
expect(row(4).classList).not.toContain('warning-highlight')
})
it('flashes every line a wrapped message runs to', () => {
renderLines(60)
component.goToLogLine(
issue({ line: 40, endLine: 42 }),
undefined,
'request_0'
)
expect(row(40).classList).toContain('error-highlight')
expect(row(41).classList).toContain('error-highlight')
expect(row(42).classList).toContain('error-highlight')
expect(row(43).classList).not.toContain('error-highlight')
})
it('does nothing when the line is not on screen', () => {
renderLines(3)
expect(() =>
component.goToLogLine(issue({ line: 90 }), undefined, 'request_0')
).not.toThrow()
})
it('renders the lines below the window before scrolling to one of them', () => {
const long = Array.from(
{ length: 500 },
(_, index) => `line ${index + 1}`
).join('\n')
const request: any = { logFile: long }
const detectChanges = jasmine.createSpy('detectChanges')
component = buildComponent([], 'SAS9', { detectChanges })
component.analyseLog(request)
expect(request.logWindow.visible).toHaveSize(FIRST_RENDERED_LINES)
component.goToLogLine(issue({ line: 450 }), request, 'request_0')
expect(request.logWindow.visible).toHaveSize(451)
expect(detectChanges).toHaveBeenCalled()
})
it('does nothing when the request has no log on screen', () => {
expect(() =>
component.goToLogLine(issue({ line: 1 }), 'request_9')
component.goToLogLine(issue({ line: 1 }), undefined, 'request_9')
).not.toThrow()
})
})
@@ -1,4 +1,5 @@
import {
ChangeDetectorRef,
Component,
EventEmitter,
Input,
@@ -21,11 +22,32 @@ interface SASjsRequestExtended extends SASjsRequest {
errors?: LogIssue[]
warnings?: LogIssue[]
notes?: LogIssue[]
/** The log with its error and warning lines wrapped in markup, for rendering. */
logHtml?: string
/** The log, the source and the generated code, as rendered lines. */
logWindow?: LineWindow
sourceWindow?: LineWindow
generatedWindow?: LineWindow
/** Whether the log carries markup (SAS 9) or plain text (Viya, SASjs). */
logIsHtml?: boolean
selectedTable?: string
}
/**
* A pane's text as every line, and the prefix currently in the DOM. A row per
* line costs Angular a binding and a sanitizer pass each - roughly 0.25ms a
* line - so a log of tens of thousands of lines would freeze the modal for
* seconds. Render a window and grow it on demand instead.
*/
export interface LineWindow {
all: string[]
visible: string[]
}
/** Lines a pane renders when it first appears. */
export const FIRST_RENDERED_LINES = 400
/** Lines each "Show more" adds, and each jump to a far line renders. */
export const MORE_RENDERED_LINES = 2000
@Component({
selector: 'app-sas-logs-modal',
templateUrl: './sas-logs-modal.component.html',
@@ -61,7 +83,8 @@ export class SasLogsModalComponent implements OnInit {
constructor(
private sasService: SasService,
private loggerService: LoggerService,
private helperService: HelperService
private helperService: HelperService,
private changeDetector: ChangeDetectorRef
) {
this.sasjsConfig = this.sasService.getSasjsConfig()
this.serverType = this.sasService.getServerType()
@@ -111,8 +134,9 @@ export class SasLogsModalComponent implements OnInit {
/**
* Reads the request's log once and keeps what the template renders: the
* issues SAS wrote, grouped by severity, and - for a log carrying markup -
* the log itself with those lines marked.
* issues SAS wrote, grouped by severity, the log itself with those lines
* marked, and every pane as one entry per rendered line so that each line
* carries a number and can be addressed without counting from the top.
*/
public analyseLog(request: SASjsRequestExtended) {
const log = typeof request.logFile === 'string' ? request.logFile : ''
@@ -122,7 +146,64 @@ export class SasLogsModalComponent implements OnInit {
request.errors = issues.filter((issue) => issue.severity === 'error')
request.warnings = issues.filter((issue) => issue.severity === 'warning')
request.notes = issues.filter((issue) => issue.severity === 'note')
request.logHtml = this.markIssueLines(log, issues)
const logIsHtml = this.serverType !== ServerType.SasViya
request.logIsHtml = logIsHtml
request.logWindow = this.lineWindow(
logIsHtml ? this.markIssueLines(log, issues) : log
)
request.sourceWindow = this.lineWindow(request.sourceCode)
request.generatedWindow = this.lineWindow(request.generatedCode)
}
/** A text block as every line, with the prefix the view starts with. */
public lineWindow(text: string | undefined): LineWindow {
const all = this.splitLines(text)
return { all, visible: all.slice(0, FIRST_RENDERED_LINES) }
}
/** Whether a pane still holds lines it has not rendered. */
public hasMoreLines(pane: LineWindow | undefined): boolean {
return !!pane && pane.visible.length < pane.all.length
}
/** Adds the next block of lines to what the pane renders. */
public showMoreLines(pane: LineWindow) {
this.renderThrough(pane, pane.visible.length + MORE_RENDERED_LINES - 1)
}
/**
* Renders every line up to `line`, so a link to a line far down a long log
* has a row to scroll to. Returns whether anything was added.
*/
public renderThrough(pane: LineWindow, line: number): boolean {
if (line < pane.visible.length || pane.visible.length === pane.all.length) {
return false
}
pane.visible.push(
...pane.all.slice(
pane.visible.length,
Math.min(pane.all.length, line + 1)
)
)
return true
}
/** A text block as the lines the view renders, numbered from one. */
public splitLines(text: string | undefined): string[] {
if (!text) return []
const lines = text.split(/\r?\n/)
// The newline that ends the text does not open a line of its own; a blank
// line in the middle of it does.
if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop()
return lines
}
/**
@@ -155,58 +236,59 @@ export class SasLogsModalComponent implements OnInit {
}
/**
* Scrolls the log to an issue and flashes its line, using the line number the
* parser reported rather than searching the rendered text - a log repeats its
* messages, so a search lands on the first of them every time.
* Scrolls the log to an issue and flashes the lines it occupies, using the
* rendered line element rather than a search of the text or arithmetic over
* a measured line height: a log repeats its messages, so a search lands on
* the first copy, and a line height rounded to whole pixels drifts further
* from the line it means the longer the log is.
*/
public goToLogLine(issue: LogIssue, requestStackId: string) {
public goToLogLine(
issue: LogIssue,
request: SASjsRequestExtended | undefined,
requestStackId: string
) {
const endLine = Math.max(issue.endLine ?? issue.line, issue.line)
// A line past what the pane renders has no row to scroll to yet.
if (request?.logWindow && this.renderThrough(request.logWindow, endLine)) {
this.changeDetector.detectChanges()
}
const logWrapper = document.querySelector(
`#${requestStackId} .log-wrapper.saslog`
) as HTMLElement | null
if (!logWrapper) return
const lineHeight = this.measureLineHeight(logWrapper)
const lineCount = issue.endLine - issue.line + 1
const top = lineHeight * issue.line
const rows = Array.from(
logWrapper.querySelectorAll<HTMLElement>('.log-line')
).slice(issue.line, endLine + 1)
logWrapper.scrollTop = top
const firstRow = rows[0]
// The height and position reach the stylesheet as CSS variables, which
// keeps the inline style away from the element and therefore away from CSP.
document.documentElement.style.setProperty(
'--line-height',
`${lineHeight}px`
)
document.documentElement.style.setProperty('--line-count', `${lineCount}`)
document.documentElement.style.setProperty('--line-top', `${top}px`)
if (!firstRow) return
const overlay = document.createElement('div')
overlay.className = `line-highlight-overlay ${issue.severity}-highlight`
overlay.classList.add('temp-height-setter')
overlay.classList.add('line-position-setter')
// The border is outside the scrollable area, so it comes off the offset
// rather than being scrolled past.
logWrapper.scrollTop =
firstRow.getBoundingClientRect().top -
logWrapper.getBoundingClientRect().top -
logWrapper.clientTop +
logWrapper.scrollTop
logWrapper.appendChild(overlay)
const flashClass = `${issue.severity}-highlight`
for (const row of rows) {
row.classList.add(flashClass)
}
setTimeout(() => {
if (logWrapper.contains(overlay)) {
logWrapper.removeChild(overlay)
for (const row of rows) {
row.classList.remove(flashClass)
}
}, 3000)
}
/** The height of one rendered log line, measured rather than assumed. */
private measureLineHeight(logWrapper: HTMLElement): number {
const tempDiv = document.createElement('div')
tempDiv.className = 'temp-line-height-calc'
tempDiv.textContent = 'X'
logWrapper.appendChild(tempDiv)
const lineHeight = tempDiv.clientHeight
logWrapper.removeChild(tempDiv)
return lineHeight
}
downloadLog(logFile: string) {
const timestamp = new Date().valueOf()
this.helperService.downloadTextFile(`logFile-${timestamp}`, logFile)
+56 -33
View File
@@ -1568,10 +1568,6 @@ body[cds-theme='light'] {
// SAS-LOGS-MODAL.COMPONENT
app-sas-logs-modal {
--line-height: 16px;
--line-count: 1;
--line-top: 0px;
.issue-code {
margin-right: 4px;
font-family: monospace;
@@ -1588,50 +1584,77 @@ app-sas-logs-modal {
}
}
.line-highlight-overlay {
position: absolute;
// One row per rendered line, so the number sits on the line it counts and a
// wrapped line hangs under the text rather than under the number.
.log-line {
display: flex;
align-items: flex-start;
}
.log-line-number {
position: sticky;
left: 0;
width: 100%;
pointer-events: none;
animation: fadeOut 3s forwards;
z-index: 10;
z-index: 1;
flex: 0 0 auto;
min-width: 3.5em;
padding-right: 0.75em;
margin-right: 0.75em;
border-right: 1px solid #d9d9d9;
// The modal's own background, so a line that is scrolled sideways runs
// under the numbers rather than through them, in either theme.
background-color: var(--cds-alias-object-container-background, #fff);
color: #9a9a9a;
text-align: right;
user-select: none;
}
.temp-height-setter {
height: calc(var(--line-height) * var(--line-count));
.log-line-text {
flex: 1 1 auto;
min-width: 0;
}
.line-position-setter {
top: var(--line-top);
.log-more {
margin: 4px 0 0;
font-size: 0.7rem;
color: #6b6b6b;
}
.temp-line-height-calc {
position: absolute;
visibility: hidden;
height: auto;
.log-line.error-highlight {
animation: flash-error 3s ease-out forwards;
}
.error-highlight {
background-color: rgba(255, 0, 0, 0.2);
.log-line.warning-highlight {
animation: flash-warning 3s ease-out forwards;
}
.warning-highlight {
background-color: rgba(242, 175, 0, 0.25);
.log-line.note-highlight {
animation: flash-note 3s ease-out forwards;
}
.note-highlight {
background-color: rgba(0, 120, 210, 0.15);
}
@keyframes fadeOut {
0% {
opacity: 1;
@keyframes flash-error {
from {
background-color: rgba(255, 0, 0, 0.2);
}
70% {
opacity: 1;
to {
background-color: transparent;
}
100% {
opacity: 0;
}
@keyframes flash-warning {
from {
background-color: rgba(242, 175, 0, 0.25);
}
to {
background-color: transparent;
}
}
@keyframes flash-note {
from {
background-color: rgba(0, 120, 210, 0.15);
}
to {
background-color: transparent;
}
}