feat(logs): number the lines of the log, the source and the generated code #485
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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user