Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 171 additions & 19 deletions static/js/logs.js
Original file line number Diff line number Diff line change
@@ -1,28 +1,37 @@
let shouldAutoScroll = true
const evtSource = new window.EventSource('api/livelog')
const livelog = document.getElementById('livelog')
const tbody = document.getElementById('livelog-body')
const filterInput = document.getElementById('log-filter')
const table = document.getElementById('table')
const loading = document.getElementById('log-loading')
const bootTime = performance.now()

evtSource.onmessage = (event) => {
const timestamp = new Date(parseInt(event.lastEventId))
const [severity, source, message] = JSON.parse(event.data)
const allLogs = []
const pendingLogs = []

const tdTs = document.createElement('td')
tdTs.textContent = timestamp.toLocaleString()
const tdSev = document.createElement('td')
tdSev.textContent = severity
const tdSrc = document.createElement('td')
tdSrc.textContent = source
const preMsg = document.createElement('pre')
preMsg.textContent = message
const tdMsg = document.createElement('td')
tdMsg.appendChild(preMsg)
const MAX_ROWS = 2000
const INITIAL_ROWS = 1000
const ROWS_PER_FLUSH = 200
const BOOT_MIN_LOGS = INITIAL_ROWS
const BOOT_MAX = 10000
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We wait 10s or for 1000 lines to arrive before doing first rendering? I think that might be a bit too long.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea was a high enough threshold that would avoid too much flutter if theres a lot of backlog, but can absolutely bring it down. Would 500ms feel better?
In my testing, 500ms still allows the loading status to appear and renders batches without too much repainting. WDYT?

const FLUSH_MS = 1000

const tr = document.createElement('tr')
tr.append(tdTs, tdSev, tdSrc, tdMsg)
const row = tbody.appendChild(tr)
let shouldAutoScroll = true
let currentRegex = null
let booting = true
let flushTimer = null

if (shouldAutoScroll) row.scrollIntoView()
// SSE
evtSource.onmessage = (event) => {
const timestamp = new Date(parseInt(event.lastEventId))
const [severity, source, message] = JSON.parse(event.data)
const log = { timestamp, severity, source, message }
pendingLogs.push(log)
if (booting) {
table.style.visibility = 'hidden'
loading.hidden = false
}
scheduleFlush()
}

evtSource.onerror = () => {
Expand All @@ -41,6 +50,149 @@ function forbidden () {
tblError.style.display = 'block'
}

// Build Table
const buildRow = (log) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prefer function declaration to assignment

Suggested change
const buildRow = (log) => {
function buildRow(log) {

const tdTs = document.createElement('td')
tdTs.textContent = log.timestamp.toLocaleString()
const tdSev = document.createElement('td')
tdSev.textContent = log.severity
const tdSrc = document.createElement('td')
tdSrc.textContent = log.source
const preMsg = document.createElement('pre')
preMsg.textContent = log.message
const tdMsg = document.createElement('td')
tdMsg.appendChild(preMsg)

const tr = document.createElement('tr')
tr.append(tdTs, tdSev, tdSrc, tdMsg)
return tr
}

// Paint table with Document Fragment. Batches depend on booting or streaming.
const paintIncoming = () => {
flushTimer = null
const now = performance.now()

// BOOTING PHASE: wait for a big chunk to render
if (booting) {
const minLogs = pendingLogs.length >= BOOT_MIN_LOGS
const minWait = (now - bootTime) >= BOOT_MAX

if (!minLogs && !minWait) {
scheduleFlush()
return
}

const bootTake = Math.min(pendingLogs.length, INITIAL_ROWS)
const bootBatch = pendingLogs.splice(0, bootTake)

// Build off-DOM
const frag = document.createDocumentFragment()
for (const log of bootBatch) {
allLogs.push(log)
if (allLogs.length > MAX_ROWS) allLogs.shift()
if (matches(log)) frag.appendChild(buildRow(log))
}

tbody.textContent = ''
if (frag.childNodes.length) tbody.appendChild(frag)

while (tbody.rows.length > MAX_ROWS) tbody.deleteRow(0)

table.style.visibility = 'visible'
loading.hidden = true
booting = false

livelog.scrollTop = livelog.scrollHeight

if (pendingLogs.length) scheduleFlush()
return
}

// STREAM PHASE: small batches regularly
if (pendingLogs.length === 0) return

const take = Math.min(pendingLogs.length, ROWS_PER_FLUSH)
const batch = pendingLogs.splice(0, take)

const frag = document.createDocumentFragment()
for (const log of batch) {
allLogs.push(log)
if (allLogs.length > MAX_ROWS) allLogs.shift()
if (matches(log)) frag.appendChild(buildRow(log))
}

if (frag.childNodes.length) tbody.appendChild(frag)

while (tbody.rows.length > MAX_ROWS) tbody.deleteRow(0)

livelog.scrollTop = livelog.scrollHeight

if (pendingLogs.length) {
scheduleFlush()
}
}

// Helpers
const scheduleFlush = () => {
if (flushTimer) return
flushTimer = setTimeout(paintIncoming, FLUSH_MS)
}

const matches = (log) => {
if (!currentRegex) return true
const searchString = `${log.timestamp.toISOString()} ${log.severity} ${log.source}${log.message ?? ''}`
return currentRegex.test(searchString)
}

// On filter change:
const rebuildFromAllLogs = () => {
const frag = document.createDocumentFragment()
for (const log of allLogs) {
if (matches(log)) frag.appendChild(buildRow(log))
}
tbody.textContent = ''
tbody.appendChild(frag)

while (tbody.rows.length > MAX_ROWS) {
tbody.deleteRow(0)
}

if (shouldAutoScroll) livelog.scrollTop = livelog.scrollHeight
}

// Live typing: compile & rebuild
filterInput.addEventListener('input', () => {
const q = filterInput.value.trim()
try {
currentRegex = q ? new RegExp(q, 'i') : null
filterInput.classList.toggle('invalid', false)
} catch {
currentRegex = null
filterInput.classList.toggle('invalid', true)
}
rebuildFromAllLogs()
})

// Fires when the native clear “×” is clicked (because type="search")
filterInput.addEventListener('search', () => {
if (filterInput.value === '') {
currentRegex = null
rebuildFromAllLogs()
}
})

filterInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault()
rebuildFromAllLogs()
} else if (e.key === 'Escape' && filterInput.value) {
currentRegex = null
filterInput.value = ''
rebuildFromAllLogs()
}
})

let lastScrollTop = livelog.pageYOffset || livelog.scrollTop
livelog.addEventListener('scroll', event => {
const { scrollHeight, scrollTop, clientHeight } = event.target
Expand All @@ -53,4 +205,4 @@ livelog.addEventListener('scroll', event => {
lastScrollTop = st <= 0 ? 0 : st
})

livelog.addEventListener('beforeunload', () => livelog.close())
window.addEventListener('beforeunload', () => evtSource.close())
13 changes: 13 additions & 0 deletions static/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -2056,6 +2056,19 @@ pre.arguments .inactive-argument:before {
text-decoration: none;
}

#log-loading[hidden] { display: none; }

#log-loading {
position: sticky;
top: 0;
z-index: 5;
display: flex;
justify-content: center;
padding: .5rem .75rem;
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-theme-switcher-border);
}

#livelog table td,
#livelog table th {
padding: 0;
Expand Down
10 changes: 8 additions & 2 deletions views/logs.ecr
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,15 @@
<main class="main-grid">
<div id="breadcrumbs" class="cols-12">
<h2 class="page-title"><span><%=pagename%></span></h2>
<div class="tiny-badge" id="pagename-label"></div>
</div>
<section id="livelog" class="card livelog">
<button id="download-logs" class="btn btn-green"><a download href="api/logs">Download logs</a></button>
<section class="card">
<label for="log-filter" class="sr-only"></label>
<input id="log-filter" class="filter-table log-toolbar" type="search" placeholder="Filter regex">
<a id="download-logs" class="btn btn-green toolbar-action" download href="api/logs">Download logs</a>
</div>
<div id="livelog" class="livelog">
<div id="log-loading" class="loading" role="status" aria-live="polite" aria-busy="true" hidden>Loading recent logs…</div>
<div id="table-error"></div>
<table id="table" class="table">
<thead>
Expand Down
Loading