Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
25 changes: 11 additions & 14 deletions src/gha_logs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,22 +193,17 @@ pub async fn gha_logs(
<title>{job_name} - {owner}/{repo}@{short_sha}</title>
{icon_status}
<style>
[data-pseudo-content]::before {{
content: attr(data-pseudo-content);
}}
body {{
font: 14px SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace;
background: #0C0C0C;
color: #CCC;
}}
table {{
white-space: pre;
width: auto;
border-spacing: 0;
border-collapse: collapse;
.logs {{
display: flex;
line-height: 1.5;
}}
tr.selected {{
background-color: #ae7c1426; /* similar to GitHub’s yellow-ish */
.logs > div {{
white-space: pre;
}}
.timestamp {{
color: #848484;
Expand All @@ -218,6 +213,9 @@ tr.selected {{
.timestamp:hover {{
text-decoration: underline;
}}
.selected {{
background-color: #ae7c1426; /* similar to GitHub’s yellow-ish */
}}
.error-marker {{
scroll-margin-bottom: 15vh;
color: #e5534b;
Expand Down Expand Up @@ -270,10 +268,9 @@ tr.selected {{
</script>
</head>
<body>
<table>
<tbody id="logs">
</tbody>
</table>
<div class="logs">
<div id="logs-timestamps"></div>
<div id="logs-lines"></div>
</body>
</html>"###,
);
Expand Down
73 changes: 48 additions & 25 deletions src/gha_logs/gha_logs.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Logic for triagebot GitHub Actions logs viewer

const logsEl = document.getElementById("logs");
const logsTimestampsEl = document.getElementById("logs-timestamps");
const logsLinesEl = document.getElementById("logs-lines");

const ansi_up = new AnsiUp();
ansi_up.use_classes = true;

Expand All @@ -19,24 +21,35 @@ html = html.replace(/\r\n/g, "\n");
const tsRegex = /^(?:(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z) )?(.*)/;
const lines = html.split('\n');

html = "";
let logsTimestamps = "";
let logsLines = "";

for (let line of lines) {
// Ignore completly empty lines that don't even contain a timestamp
if (line.length == 0)
continue;

// Get the log content and maybe the timestamp
const [, ts, log] = line.match(tsRegex);

// Add the timestamp and log line to it's div
if (ts !== undefined) {
html += `<tr><td><a id="${ts}" href="#${ts}" class="timestamp" data-pseudo-content="${ts}"></a></td><td>${log}</td></tr>`;
logsTimestamps += `<div><a id="${ts}" href="#${ts}" class="timestamp">${ts}</a>\n</div>`;
logsLines += `<div>${log}\n</div>`;
} else {
html += `<tr><td></td><td>${log}</td></tr>`;
logsTimestamps += "<div>\n</div>";
logsLines += `<div>${log}\n</div>`;
}
}

// 4. Add a anchor around every "##[error]" string
let errorCounter = -1;
html = html.replace(/##\[error\]/g, () =>
logsLines = logsLines.replace(/##\[error\]/g, () =>
`<a id="error-${++errorCounter}" class="error-marker">##[error]</a>`
);

// 4.b Add a span around every "##[warning]" string
html = html.replace(/##\[warning\]/g, () =>
logsLines = logsLines.replace(/##\[warning\]/g, () =>
`<span class="warning-marker">##[warning]</span>`
);

Expand Down Expand Up @@ -68,13 +81,14 @@ const pathRegex = new RegExp(
+ "(?::(?<line>[0-9]+):(?<col>[0-9]+))?)(?<boundary_end>[^a-zA-Z0-9.])",
"g"
);
html = html.replace(pathRegex, (match, boundary_start, inner, path, line, col, boundary_end) => {
logsLines = logsLines.replace(pathRegex, (match, boundary_start, inner, path, line, col, boundary_end) => {
const pos = (line !== undefined) ? `#L${line}` : "";
return `${boundary_start}<a href="https://github.com/${owner}/${repo}/blob/${sha}/${path}${pos}" class="path-marker">${inner}</a>${boundary_end}`;
});

// 6. Add the html to the table
logsEl.innerHTML = html;
// 6. Add the html timestamps and lines to the DOM
logsTimestampsEl.innerHTML = logsTimestamps;
logsLinesEl.innerHTML = logsLines;

// 7. If no anchor is given, scroll to the last error
if (location.hash === "" && errorCounter >= 0) {
Expand All @@ -93,35 +107,42 @@ if (location.hash !== "") {

if (match) {
const [startId, endId] = [match[1], match[2] || match[1]].map(decodeURIComponent);
const startRow = logsEl.querySelector(`a[id="${startId}"]`)?.closest('tr');
let startRow = logsTimestampsEl.querySelector(`a[id="${startId}"]`)?.parentElement;

if (startRow) {
startingAnchorId = startId;
highlightTimestampRange(startId, endId);

// Scroll to the highlighted part (either the timestamp or the log line depending on the viewport size)
const hasSmallViewport = window.outerWidth <= 750;
const scrollToElement = hasSmallViewport ? startRow.querySelector("td:nth-child(2)") : startRow;

scrollToElement.scrollIntoView({
behavior: 'instant',
block: 'center',
inline: 'start'
});
if (hasSmallViewport) {
// Find the corresponding line in `logsLinesEl`
const indexInTimestamps = Array.from(logsTimestampsEl.children).indexOf(startRow);
startRow = logsLinesEl.children[indexInTimestamps];
}

if (startRow) {
startRow.scrollIntoView({
behavior: 'instant',
block: 'center',
inline: 'start'
});
}
}
}
}

// 9. Add a copy handler that force plain/text copy
logsEl.addEventListener("copy", function(e) {
logsLinesEl.addEventListener("copy", function(e) {
var text = window.getSelection().toString();
e.clipboardData.setData('text/plain', text);
e.preventDefault();
});

// 10. Add click event to handle custom hightling
logsEl.addEventListener('click', (e) => {
const rowEl = e.target.closest('tr');
logsTimestampsEl.addEventListener('click', (e) => {
const rowEl = e.target.parentElement;
if (!rowEl || !e.target.classList.contains("timestamp")) return;

const ctrlOrMeta = e.ctrlKey || e.metaKey;
Expand All @@ -146,25 +167,27 @@ logsEl.addEventListener('click', (e) => {
}

// Update our URL hash after every selection change
const ids = Array.from(logsEl.querySelectorAll('tr.selected')).map(getRowId).sort();
const ids = Array.from(logsTimestampsEl.querySelectorAll('.selected')).map(getRowId).sort();
window.location.hash = ids.length ?
(ids.length === 1 ? `L${ids[0]}` : `L${ids[0]}-L${ids[ids.length-1]}`) : '';
});

// Helper function to get the ID of the given row
function getRowId(rowEl) {
return rowEl.querySelector('a.timestamp').id; // "2025-12-12T21:28:09.6347029Z"
return rowEl.querySelector('a.timestamp')?.id; // "2025-12-12T21:28:09.6347029Z"
}

// Helper function to highlight (toggle the selected class) on the given timestamp range
function highlightTimestampRange(startId, endId) {
const rows = Array.from(logsEl.querySelectorAll('tr')).filter(r => r.querySelector('.timestamp'));
const logsTimestampsRows = Array.from(logsTimestampsEl.children);
const logsLinesRows = Array.from(logsLinesEl.children);

const startIndex = rows.findIndex(row => getRowId(row) === startId);
const endIndex = rows.findIndex(row => getRowId(row) === endId);
const startIndex = logsTimestampsRows.findIndex(row => getRowId(row) === startId);
const endIndex = logsTimestampsRows.findIndex(row => getRowId(row) === endId);

const start = Math.min(startIndex, endIndex);
const end = Math.max(startIndex, endIndex);

rows.forEach((row, index) => row.classList.toggle('selected', index >= start && index <= end));
logsTimestampsRows.forEach((row, index) => row.classList.toggle('selected', index >= start && index <= end));
logsLinesRows.forEach((row, index) => row.classList.toggle('selected', index >= start && index <= end));
}