Skip to content

Commit d6af7c5

Browse files
authored
Merge pull request #2234 from Urgau/gha-logs-highlighting
Add highlighting of log lines with URL support
2 parents baf5b11 + a048a1d commit d6af7c5

File tree

2 files changed

+168
-85
lines changed

2 files changed

+168
-85
lines changed

src/gha_logs.rs

Lines changed: 10 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use std::collections::VecDeque;
1313
use std::sync::Arc;
1414
use uuid::Uuid;
1515

16+
pub const GHA_LOGS_JS: &str = include_str!("gha_logs/gha_logs.js");
1617
pub const ANSI_UP_URL: &str = "/gha_logs/[email protected]";
1718
pub const SUCCESS_URL: &str = "/gha_logs/[email protected]";
1819
pub const FAILURE_URL: &str = "/gha_logs/[email protected]";
@@ -224,6 +225,11 @@ table {{
224225
white-space: pre;
225226
table-layout: fixed;
226227
width: 100%;
228+
border-spacing: 0;
229+
border-collapse: collapse;
230+
}}
231+
tr.selected {{
232+
background: #1d1a16; /* similar to GitHub’s yellow-ish */
227233
}}
228234
.timestamp {{
229235
color: #848484;
@@ -271,92 +277,11 @@ table {{
271277
272278
const logs = {logs};
273279
const tree_roots = {tree_roots};
274-
const ansi_up = new AnsiUp();
275-
ansi_up.use_classes = true;
276-
277-
// 1. Tranform the ANSI escape codes to HTML
278-
var html = ansi_up.ansi_to_html(logs);
279-
280-
// 2. Remove UTF-8 useless BOM and Windows Carriage Return
281-
html = html.replace(/^\uFEFF/gm, "");
282-
html = html.replace(/\r\n/g, "\n");
283-
284-
// 3. Transform each log lines that doesn't start with a timestamp into a row where everything is in the second column
285-
const untsRegex = /^(?!\d{{4}}-\d{{2}}-\d{{2}}T\d{{2}}:\d{{2}}:\d{{2}}\.\d+Z)(.*)(\n)?/gm;
286-
html = html.replace(untsRegex, (match, log) =>
287-
`<tr><td></td><td>${{log}}</td></tr>`
288-
);
289-
290-
// 3.b Transform each log lines that start with a timestamp in a row with two columns and make the timestamp be a
291-
// self-referencial anchor.
292-
const tsRegex = /^(\d{{4}}-\d{{2}}-\d{{2}}T\d{{2}}:\d{{2}}:\d{{2}}\.\d+Z) (.*)(\n)?/gm;
293-
html = html.replace(tsRegex, (match, ts, log) =>
294-
`<tr><td><a id="${{ts}}" href="#${{ts}}" class="timestamp" data-pseudo-content="${{ts}}"></a></td><td>${{log}}</td></tr>`
295-
);
296-
297-
// 4. Add a anchor around every "##[error]" string
298-
let errorCounter = -1;
299-
html = html.replace(/##\[error\]/g, () =>
300-
`<a id="error-${{++errorCounter}}" class="error-marker">##[error]</a>`
301-
);
302-
303-
// 4.b Add a span around every "##[warning]" string
304-
html = html.replace(/##\[warning\]/g, () =>
305-
`<span class="warning-marker">##[warning]</span>`
306-
);
307-
308-
// pre-5. Polyfill the recently (2025) added `RegExp.escape` function.
309-
// Inspired by the former MDN section on escaping:
310-
// https://web.archive.org/web/20230806114646/https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions#escaping
311-
const escapeRegExp = RegExp.escape || function(string) {{
312-
return string.replace(/[.*+?^${{}}()|[\]\\/]/g, "\\$&");
313-
}};
314-
315-
// 5. Add anchors around some paths
316-
// Detailed examples of what the regex does is at https://regex101.com/r/vCnx9Y/2
317-
//
318-
// But simply speaking the regex tries to find absolute (with `/checkout` prefix) and
319-
// relative paths, the path must start with one of the repository level-2 directories.
320-
// We also try to retrieve the lines and cols if given (`<path>:line:col`).
321-
//
322-
// Some examples of paths we want to find:
323-
// - src/tools/test-float-parse/src/traits.rs:173:11
324-
// - /checkout/compiler/rustc_macros
325-
// - /checkout/src/doc/rustdoc/src/advanced-features.md
326-
//
327-
// Any other paths, in particular if prefixed by `./` or `obj/` should not taken.
328-
const pathRegex = new RegExp(
329-
"(?<boundary_start>[^a-zA-Z0-9.\\/])"
330-
+ "(?<inner>(?:[\\\/]?(?:checkout[\\\/])?(?<path>(?:"
331-
+ tree_roots.map(p => escapeRegExp(p)).join("|")
332-
+ ")(?:[\\\/][a-zA-Z0-9_$\\\-.\\\/]+)?))"
333-
+ "(?::(?<line>[0-9]+):(?<col>[0-9]+))?)(?<boundary_end>[^a-zA-Z0-9.])",
334-
"g"
335-
);
336-
html = html.replace(pathRegex, (match, boundary_start, inner, path, line, col, boundary_end) => {{
337-
const pos = (line !== undefined) ? `#L${{line}}` : "";
338-
return `${{boundary_start}}<a href="https://github.com/{owner}/{repo}/blob/{sha}/${{path}}${{pos}}" class="path-marker">${{inner}}</a>${{boundary_end}}`;
339-
}});
340-
341-
// 6. Add the html to the table
342-
document.getElementById("logs").innerHTML = html;
343-
344-
// 7. If no anchor is given, scroll to the last error
345-
if (location.hash === "" && errorCounter >= 0) {{
346-
const hasSmallViewport = window.innerWidth <= 750;
347-
document.getElementById(`error-${{errorCounter}}`).scrollIntoView({{
348-
behavior: 'instant',
349-
block: 'end',
350-
inline: hasSmallViewport ? 'start' : 'center'
351-
}});
352-
}}
280+
const owner = "{owner}";
281+
const repo = "{repo}";
282+
const sha = "{sha}";
353283
354-
// 8. Add a copy handler that force plain/text copy
355-
document.addEventListener("copy", function(e) {{
356-
var text = window.getSelection().toString();
357-
e.clipboardData.setData('text/plain', text);
358-
e.preventDefault();
359-
}});
284+
{GHA_LOGS_JS}
360285
361286
}} catch (e) {{
362287
console.error(e);

src/gha_logs/gha_logs.js

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
// Logic for triagebot GitHub Actions logs viewer
2+
3+
const logsEl = document.getElementById("logs");
4+
const ansi_up = new AnsiUp();
5+
ansi_up.use_classes = true;
6+
7+
let startingAnchorId = null;
8+
9+
// 1. Tranform the ANSI escape codes to HTML
10+
var html = ansi_up.ansi_to_html(logs);
11+
12+
// 2. Remove UTF-8 useless BOM and Windows Carriage Return
13+
html = html.replace(/^\uFEFF/gm, "");
14+
html = html.replace(/\r\n/g, "\n");
15+
16+
// 3. Transform each log lines that doesn't start with a timestamp into a row where everything is in the second column
17+
const untsRegex = /^(?!\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)(.*)(\n)?/gm;
18+
html = html.replace(untsRegex, (match, log) =>
19+
`<tr><td></td><td>${log}</td></tr>`
20+
);
21+
22+
// 3.b Transform each log lines that start with a timestamp in a row with two columns and make the timestamp be a
23+
// self-referencial anchor.
24+
const tsRegex = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z) (.*)(\n)?/gm;
25+
html = html.replace(tsRegex, (match, ts, log) =>
26+
`<tr><td><a id="${ts}" href="#${ts}" class="timestamp" data-pseudo-content="${ts}"></a></td><td>${log}</td></tr>`
27+
);
28+
29+
// 4. Add a anchor around every "##[error]" string
30+
let errorCounter = -1;
31+
html = html.replace(/##\[error\]/g, () =>
32+
`<a id="error-${++errorCounter}" class="error-marker">##[error]</a>`
33+
);
34+
35+
// 4.b Add a span around every "##[warning]" string
36+
html = html.replace(/##\[warning\]/g, () =>
37+
`<span class="warning-marker">##[warning]</span>`
38+
);
39+
40+
// pre-5. Polyfill the recently (2025) added `RegExp.escape` function.
41+
// Inspired by the former MDN section on escaping:
42+
// https://web.archive.org/web/20230806114646/https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions#escaping
43+
const escapeRegExp = RegExp.escape || function(string) {
44+
return string.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
45+
};
46+
47+
// 5. Add anchors around some paths
48+
// Detailed examples of what the regex does is at https://regex101.com/r/vCnx9Y/2
49+
//
50+
// But simply speaking the regex tries to find absolute (with `/checkout` prefix) and
51+
// relative paths, the path must start with one of the repository level-2 directories.
52+
// We also try to retrieve the lines and cols if given (`<path>:line:col`).
53+
//
54+
// Some examples of paths we want to find:
55+
// - src/tools/test-float-parse/src/traits.rs:173:11
56+
// - /checkout/compiler/rustc_macros
57+
// - /checkout/src/doc/rustdoc/src/advanced-features.md
58+
//
59+
// Any other paths, in particular if prefixed by `./` or `obj/` should not taken.
60+
const pathRegex = new RegExp(
61+
"(?<boundary_start>[^a-zA-Z0-9.\\/])"
62+
+ "(?<inner>(?:[\\\/]?(?:checkout[\\\/])?(?<path>(?:"
63+
+ tree_roots.map(p => escapeRegExp(p)).join("|")
64+
+ ")(?:[\\\/][a-zA-Z0-9_$\\\-.\\\/]+)?))"
65+
+ "(?::(?<line>[0-9]+):(?<col>[0-9]+))?)(?<boundary_end>[^a-zA-Z0-9.])",
66+
"g"
67+
);
68+
html = html.replace(pathRegex, (match, boundary_start, inner, path, line, col, boundary_end) => {
69+
const pos = (line !== undefined) ? `#L${line}` : "";
70+
return `${boundary_start}<a href="https://github.com/${owner}/${repo}/blob/${sha}/${path}${pos}" class="path-marker">${inner}</a>${boundary_end}`;
71+
});
72+
73+
// 6. Add the html to the table
74+
logsEl.innerHTML = html;
75+
76+
// 7. If no anchor is given, scroll to the last error
77+
if (location.hash === "" && errorCounter >= 0) {
78+
const hasSmallViewport = window.innerWidth <= 750;
79+
document.getElementById(`error-${errorCounter}`).scrollIntoView({
80+
behavior: 'instant',
81+
block: 'end',
82+
inline: hasSmallViewport ? 'start' : 'center'
83+
});
84+
}
85+
86+
// 8. If a anchor is given, highlight and scroll to the selection
87+
if (location.hash !== "") {
88+
const match = window.location.hash
89+
.match(/L?(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)(?:-L(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z))?/);
90+
91+
if (match) {
92+
const [startId, endId] = [match[1], match[2] || match[1]].map(decodeURIComponent);
93+
const startRow = logsEl.querySelector(`a[id="${startId}"]`)?.closest('tr');
94+
95+
if (startRow) {
96+
startingAnchorId = startId;
97+
highlightTimestampRange(startId, endId);
98+
startRow.scrollIntoView({ block: 'center' });
99+
}
100+
}
101+
}
102+
103+
// 9. Add a copy handler that force plain/text copy
104+
logsEl.addEventListener("copy", function(e) {
105+
var text = window.getSelection().toString();
106+
e.clipboardData.setData('text/plain', text);
107+
e.preventDefault();
108+
});
109+
110+
// 10. Add click event to handle custom hightling
111+
logsEl.addEventListener('click', (e) => {
112+
const rowEl = e.target.closest('tr');
113+
if (!rowEl || !e.target.classList.contains("timestamp")) return;
114+
115+
const ctrlOrMeta = e.ctrlKey || e.metaKey;
116+
const shiftKey = e.shiftKey;
117+
const rowId = getRowId(rowEl);
118+
119+
// Prevent default link behavior
120+
e.preventDefault();
121+
e.stopPropagation();
122+
123+
if (!ctrlOrMeta && !shiftKey) {
124+
// Normal click: select single row, set anchor
125+
startingAnchorId = rowId;
126+
highlightTimestampRange(startingAnchorId, startingAnchorId);
127+
} else if (shiftKey && startingAnchorId !== null) {
128+
// Shift+click: extend selection from anchor
129+
highlightTimestampRange(startingAnchorId, rowId);
130+
} else if (ctrlOrMeta) {
131+
// Ctrl/Cmd+click: new anchor (resets selection)
132+
startingAnchorId = rowId;
133+
highlightTimestampRange(startingAnchorId, startingAnchorId);
134+
}
135+
136+
// Update our URL hash after every selection change
137+
const ids = Array.from(logsEl.querySelectorAll('tr.selected')).map(getRowId).sort();
138+
window.location.hash = ids.length ?
139+
(ids.length === 1 ? `L${ids[0]}` : `L${ids[0]}-L${ids[ids.length-1]}`) : '';
140+
});
141+
142+
// Helper function to get the ID of the given row
143+
function getRowId(rowEl) {
144+
return rowEl.querySelector('a.timestamp').id; // "2025-12-12T21:28:09.6347029Z"
145+
}
146+
147+
// Helper function to highlight (toggle the selected class) on the given timestamp range
148+
function highlightTimestampRange(startId, endId) {
149+
const rows = Array.from(logsEl.querySelectorAll('tr')).filter(r => r.querySelector('.timestamp'));
150+
151+
const startIndex = rows.findIndex(row => getRowId(row) === startId);
152+
const endIndex = rows.findIndex(row => getRowId(row) === endId);
153+
154+
const start = Math.min(startIndex, endIndex);
155+
const end = Math.max(startIndex, endIndex);
156+
157+
rows.forEach((row, index) => row.classList.toggle('selected', index >= start && index <= end));
158+
}

0 commit comments

Comments
 (0)