Skip to content

Instantly share code, notes, and snippets.

@rany2
Last active April 29, 2025 19:54
Show Gist options
  • Save rany2/42f20f7c46eb9503dca168caf306c612 to your computer and use it in GitHub Desktop.
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).
// ==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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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