Last active
April 29, 2025 19:54
-
-
Save rany2/42f20f7c46eb9503dca168caf306c612 to your computer and use it in GitHub Desktop.
Stream the backscroll of a GitHub-Actions job step (works even if the log is over 4MB).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name Live backscroll viewer for GitHub Actions | |
// @namespace https://github.com/ | |
// @version 0.3 | |
// @description Stream the backscroll of a GitHub-Actions job step (works even if the log is over 4MB). | |
// @author rany <[email protected]> | |
// @match https://github.com/*/*/actions/runs/*/job/* | |
// @grant none | |
// ==/UserScript== | |
const $ = (sel, ctx = document) => ctx.querySelector(sel); | |
const $$ = (sel, ctx = document) => Array.from(ctx.querySelectorAll(sel)); | |
const style = document.createElement('style'); | |
style.textContent = ` | |
.live-backscroll-btn{ | |
border:0;background:none;padding:0;margin-left:auto;cursor:pointer; | |
color:var(--color-accent-fg); | |
} | |
.live-backscroll-btn .sr-only{clip:rect(1px,1px,1px,1px);position:absolute;} | |
.live-backscroll-pane{ | |
position:fixed;top:10vh;left:25vw;z-index:9999; | |
width:65vw;max-height:50vh;overflow:auto; | |
background:black; | |
color:white; | |
border: none; /* Hide border initially */ | |
resize: both; | |
contain: layout style; | |
border-radius:6px; | |
font-family:SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace; | |
font-size:12px;line-height:18px; | |
box-shadow:0 4px 12px rgba(0,0,0,.15); | |
} | |
.live-backscroll-pane.visible { | |
border: 1px solid dimgray; /* Show border when visible */ | |
} | |
.live-backscroll-pane .line{ | |
display:grid;grid-template-columns:auto 56px 1fr; | |
gap:8px;align-items:start; | |
white-space:pre; | |
padding:0 8px; | |
} | |
.live-backscroll-pane .ts{color:lightgray;} | |
.live-backscroll-pane .ln{color:lightgray;text-align:right;} | |
.live-backscroll-close{ | |
position:sticky;top:0;display:block;width:100%; | |
background:dimgray;color:white; | |
border:none;cursor:move;font-size:12px;text-align:center;padding:2px 0; | |
display: none; /* Initially hide */ | |
} | |
.live-backscroll-close.visible { | |
display: block; /* Show when pane is active */ | |
} | |
`; | |
document.head.appendChild(style); | |
function main() { | |
const steps = $$('[data-job-step-backscroll-url]'); | |
if (!steps.length) return; | |
for (const step of steps) { | |
if ($('.live-backscroll-btn', step)) continue; | |
const url = new URL(step.dataset.jobStepBackscrollUrl, window.location.origin).href; | |
const header = step.querySelector('.d-flex.flex-items-center'); | |
if (!header) continue; | |
const btn = document.createElement('button'); | |
btn.className = 'live-backscroll-btn'; | |
btn.innerHTML = ` | |
<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" class="octicon octicon-terminal"> | |
<path fill-rule="evenodd" d="M2.75 2A1.75 1.75 0 0 0 1 3.75v8.5C1 13.216 1.784 14 2.75 14h10.5A1.75 1.75 0 0 0 15 12.25v-8.5A1.75 1.75 0 0 0 13.25 2H2.75ZM2.5 3.75a.25.25 0 0 1 .25-.25h10.5a.25.25 0 0 1 .25.25v8.5a.25.25 0 0 1-.25-.25H2.75a.25.25 0 0 1-.25-.25v-8.5Zm2.22 1.28a.75.75 0 0 1 1.06 0l2 2a.75.75 0 0 1 0 1.06l-2 2a.75.75 0 1 1-1.06-1.06L5.44 8 4.72 7.28a.75.75 0 0 1 0-1.06Zm2.28 4.72a.75.75 0 0 1 .75-.75h3.5a.75.75 0 0 1 0 1.5h-3.5a.75.75 0 0 1-.75-.75Z"></path> | |
</svg> | |
<span class="sr-only">Live back-scroll</span>`; | |
header.prepend(btn); | |
let pane = null; | |
let lastId = ''; | |
let isDragging = false; | |
let dragStartX, dragStartY; | |
let paneOffsetX, paneOffsetY; | |
let timer = null; | |
const closePane = () => { | |
if (timer) clearInterval(timer); | |
timer = null; | |
if (pane) pane.remove(); | |
pane = null; | |
document.removeEventListener('mousemove', handleMouseMove); | |
document.removeEventListener('mouseup', handleMouseUp); | |
btn.disabled = false; | |
}; | |
const handleMouseMove = (e) => { | |
if (!isDragging) return; | |
let newX = e.clientX - paneOffsetX; | |
let newY = e.clientY - paneOffsetY; | |
const maxWidth = window.innerWidth - pane.offsetWidth; | |
const maxHeight = window.innerHeight - pane.offsetHeight; | |
newX = Math.max(0, Math.min(newX, maxWidth)); | |
newY = Math.max(0, Math.min(newY, maxHeight)); | |
pane.style.left = `${newX}px`; | |
pane.style.top = `${newY}px`; | |
pane.style.bottom = 'auto'; | |
pane.style.right = 'auto'; | |
}; | |
const handleMouseUp = () => { | |
if (isDragging) { | |
isDragging = false; | |
pane.style.cursor = ''; | |
document.removeEventListener('mousemove', handleMouseMove); | |
document.removeEventListener('mouseup', handleMouseUp); | |
} | |
}; | |
const handleMouseDown = (e) => { | |
if (e.button !== 0) return; | |
dragStartX = e.clientX; | |
dragStartY = e.clientY; | |
isDragging = true; | |
const rect = pane.getBoundingClientRect(); | |
paneOffsetX = e.clientX - rect.left; | |
paneOffsetY = e.clientY - rect.top; | |
pane.style.cursor = 'grabbing'; | |
document.addEventListener('mousemove', handleMouseMove); | |
document.addEventListener('mouseup', handleMouseUp); | |
e.preventDefault(); | |
}; | |
btn.addEventListener('click', async () => { | |
if (timer) return; // Already streaming | |
if (!pane) { | |
pane = document.createElement('div'); | |
pane.className = 'live-backscroll-pane'; | |
pane.insertAdjacentHTML('beforeend', ` | |
<button class="live-backscroll-close">× close (drag here)</button> | |
<div class="live-backscroll-content"></div> | |
`); | |
const closeButton = pane.querySelector('.live-backscroll-close'); | |
closeButton.onclick = (e) => { | |
const movedDistance = Math.sqrt( | |
Math.pow(e.clientX - (dragStartX ?? e.clientX), 2) + | |
Math.pow(e.clientY - (dragStartY ?? e.clientY), 2) | |
); | |
if (movedDistance < 5) { // Treat as click if mouse moved less than 5px | |
closePane(); | |
} | |
}; | |
closeButton.addEventListener('mousedown', handleMouseDown); | |
document.body.appendChild(pane); | |
} | |
btn.disabled = true; | |
const contentArea = pane.querySelector('.live-backscroll-content'); | |
// Initial fetch | |
await stream(url, pane, contentArea, id => { lastId = id; }, closePane, true, lastId); | |
// Polling function to fetch new data | |
const pollStream = async () => { | |
if (!document.body.contains(pane)) { | |
closePane(); | |
return; | |
} | |
const streamSuccess = await stream(url, pane, contentArea, id => { lastId = id; }, closePane, false, lastId); | |
const interval = streamSuccess ? 200 : 2000; | |
if (document.body.contains(pane)) { | |
timer = setTimeout(pollStream, interval); | |
} else { | |
closePane(); | |
} | |
}; | |
// Start polling only if the pane wasn't closed during the initial fetch | |
if (document.body.contains(pane)) { | |
timer = setTimeout(pollStream, 200); | |
} else { | |
btn.disabled = false; | |
closePane(); | |
} | |
}); | |
} | |
} | |
function fmtDateTime(epoch) { | |
const d = new Date(Number(epoch)); | |
return `${d.toLocaleDateString()} ${d.toLocaleTimeString('default', { hour12: false })}`; | |
} | |
function fmtTime(epoch) { | |
const d = new Date(Number(epoch)); | |
return `${d.toLocaleTimeString('default', { hour12: false })}.${String(d.getMilliseconds()).padStart(3, '0')}`; | |
} | |
async function stream(url, pane, contentArea, onLastId, closePaneCallback, initial = false, lastId = '') { | |
const isScrolledNearBottom = pane.scrollHeight - pane.scrollTop - pane.clientHeight < 10; | |
try { | |
const r = await fetch(url, { | |
credentials: 'same-origin', | |
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' } | |
}); | |
if (!r.ok) throw new Error(`Fetch failed: ${r.status}`); | |
const json = await r.json(); | |
if (!json || typeof json !== 'object' || Object.keys(json).length === 0) { | |
console.warn('Backscroll data is empty or invalid.'); | |
if (initial) { | |
alert('Backscroll data not available for this step.'); | |
closePaneCallback(); | |
} | |
return false; | |
} | |
// Remove previous error messages when new valid data arrives | |
contentArea.querySelectorAll('[data-error-line]').forEach(el => el.remove()); | |
// If the response is empty, show an error message | |
if (!json.lines || json.lines.length === 0) { | |
const errorMsg = '⚠️ No more logs available.'; | |
contentArea.insertAdjacentHTML('beforeend', `<div class="line" style="color:red;" data-error-line="true"><span>${errorMsg}</span></div>`); | |
return false; | |
} | |
// Filter out lines up through the last seen ID | |
let newLines = json.lines; | |
if (!initial && lastId) { | |
const idx = newLines.findIndex(l => l.id === lastId); | |
if (idx !== -1) newLines = newLines.slice(idx + 1); | |
} | |
if (newLines.length === 0) return true; // nothing new | |
let linesHtml = ''; | |
for (const l of newLines) { | |
const [epoch, lineNo] = l.id.split('-'); | |
const escapedLine = l.line.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); | |
linesHtml += ` | |
<div class="line"> | |
<span class="ts" title="${fmtDateTime(epoch)}">${fmtTime(epoch)}</span> | |
<span class="ln">${lineNo}</span> | |
<span>${escapedLine}</span> | |
</div>`; | |
onLastId(l.id); | |
} | |
contentArea.insertAdjacentHTML('beforeend', linesHtml); | |
return true; | |
} catch (e) { | |
console.error('Backscroll fetch/processing failed:', e); | |
contentArea.querySelectorAll('[data-error-line]').forEach(el => el.remove()); | |
const errorMsg = `⚠️ Error loading logs: ${e.message}. Retrying...`; | |
contentArea.insertAdjacentHTML('beforeend', `<div class="line" style="color:red;" data-error-line="true"><span>${errorMsg}</span></div>`); | |
return false; | |
} finally { | |
if (initial) { | |
pane.classList.add('visible'); | |
const closeButton = pane.querySelector('.live-backscroll-close'); | |
if (closeButton) closeButton.classList.add('visible'); | |
} | |
if (initial || isScrolledNearBottom) { | |
pane.scrollTop = pane.scrollHeight; | |
} | |
} | |
} | |
// Wait for GH dynamic content loading | |
const observer = new MutationObserver((_, o) => { | |
if ($('[data-job-step-backscroll-url]')) { | |
o.disconnect(); | |
main(); | |
} | |
}); | |
observer.observe(document.body, { childList: true, subtree: true }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment