diff --git a/src/gha_logs.rs b/src/gha_logs.rs
index 482170e11..7b7608339 100644
--- a/src/gha_logs.rs
+++ b/src/gha_logs.rs
@@ -13,6 +13,7 @@ use std::collections::VecDeque;
use std::sync::Arc;
use uuid::Uuid;
+pub const GHA_LOGS_JS: &str = include_str!("gha_logs/gha_logs.js");
pub const ANSI_UP_URL: &str = "/gha_logs/ansi_up@0.0.1-custom.js";
pub const SUCCESS_URL: &str = "/gha_logs/success@1.svg";
pub const FAILURE_URL: &str = "/gha_logs/failure@1.svg";
@@ -224,6 +225,11 @@ table {{
white-space: pre;
table-layout: fixed;
width: 100%;
+ border-spacing: 0;
+ border-collapse: collapse;
+}}
+tr.selected {{
+ background: #1d1a16; /* similar to GitHub’s yellow-ish */
}}
.timestamp {{
color: #848484;
@@ -271,92 +277,11 @@ table {{
const logs = {logs};
const tree_roots = {tree_roots};
- const ansi_up = new AnsiUp();
- ansi_up.use_classes = true;
-
- // 1. Tranform the ANSI escape codes to HTML
- var html = ansi_up.ansi_to_html(logs);
-
- // 2. Remove UTF-8 useless BOM and Windows Carriage Return
- html = html.replace(/^\uFEFF/gm, "");
- html = html.replace(/\r\n/g, "\n");
-
- // 3. Transform each log lines that doesn't start with a timestamp into a row where everything is in the second column
- const untsRegex = /^(?!\d{{4}}-\d{{2}}-\d{{2}}T\d{{2}}:\d{{2}}:\d{{2}}\.\d+Z)(.*)(\n)?/gm;
- html = html.replace(untsRegex, (match, log) =>
- `
| ${{log}} |
`
- );
-
- // 3.b Transform each log lines that start with a timestamp in a row with two columns and make the timestamp be a
- // self-referencial anchor.
- const tsRegex = /^(\d{{4}}-\d{{2}}-\d{{2}}T\d{{2}}:\d{{2}}:\d{{2}}\.\d+Z) (.*)(\n)?/gm;
- html = html.replace(tsRegex, (match, ts, log) =>
- ` | ${{log}} |
`
- );
-
- // 4. Add a anchor around every "##[error]" string
- let errorCounter = -1;
- html = html.replace(/##\[error\]/g, () =>
- `##[error]`
- );
-
- // 4.b Add a span around every "##[warning]" string
- html = html.replace(/##\[warning\]/g, () =>
- `##[warning]`
- );
-
- // pre-5. Polyfill the recently (2025) added `RegExp.escape` function.
- // Inspired by the former MDN section on escaping:
- // https://web.archive.org/web/20230806114646/https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions#escaping
- const escapeRegExp = RegExp.escape || function(string) {{
- return string.replace(/[.*+?^${{}}()|[\]\\/]/g, "\\$&");
- }};
-
- // 5. Add anchors around some paths
- // Detailed examples of what the regex does is at https://regex101.com/r/vCnx9Y/2
- //
- // But simply speaking the regex tries to find absolute (with `/checkout` prefix) and
- // relative paths, the path must start with one of the repository level-2 directories.
- // We also try to retrieve the lines and cols if given (`:line:col`).
- //
- // Some examples of paths we want to find:
- // - src/tools/test-float-parse/src/traits.rs:173:11
- // - /checkout/compiler/rustc_macros
- // - /checkout/src/doc/rustdoc/src/advanced-features.md
- //
- // Any other paths, in particular if prefixed by `./` or `obj/` should not taken.
- const pathRegex = new RegExp(
- "(?[^a-zA-Z0-9.\\/])"
- + "(?(?:[\\\/]?(?:checkout[\\\/])?(?(?:"
- + tree_roots.map(p => escapeRegExp(p)).join("|")
- + ")(?:[\\\/][a-zA-Z0-9_$\\\-.\\\/]+)?))"
- + "(?::(?[0-9]+):(?[0-9]+))?)(?[^a-zA-Z0-9.])",
- "g"
- );
- html = html.replace(pathRegex, (match, boundary_start, inner, path, line, col, boundary_end) => {{
- const pos = (line !== undefined) ? `#L${{line}}` : "";
- return `${{boundary_start}}${{inner}}${{boundary_end}}`;
- }});
-
- // 6. Add the html to the table
- document.getElementById("logs").innerHTML = html;
-
- // 7. If no anchor is given, scroll to the last error
- if (location.hash === "" && errorCounter >= 0) {{
- const hasSmallViewport = window.innerWidth <= 750;
- document.getElementById(`error-${{errorCounter}}`).scrollIntoView({{
- behavior: 'instant',
- block: 'end',
- inline: hasSmallViewport ? 'start' : 'center'
- }});
- }}
+ const owner = "{owner}";
+ const repo = "{repo}";
+ const sha = "{sha}";
- // 8. Add a copy handler that force plain/text copy
- document.addEventListener("copy", function(e) {{
- var text = window.getSelection().toString();
- e.clipboardData.setData('text/plain', text);
- e.preventDefault();
- }});
+ {GHA_LOGS_JS}
}} catch (e) {{
console.error(e);
diff --git a/src/gha_logs/gha_logs.js b/src/gha_logs/gha_logs.js
new file mode 100644
index 000000000..fb10f5e91
--- /dev/null
+++ b/src/gha_logs/gha_logs.js
@@ -0,0 +1,158 @@
+// Logic for triagebot GitHub Actions logs viewer
+
+const logsEl = document.getElementById("logs");
+const ansi_up = new AnsiUp();
+ansi_up.use_classes = true;
+
+let startingAnchorId = null;
+
+// 1. Tranform the ANSI escape codes to HTML
+var html = ansi_up.ansi_to_html(logs);
+
+// 2. Remove UTF-8 useless BOM and Windows Carriage Return
+html = html.replace(/^\uFEFF/gm, "");
+html = html.replace(/\r\n/g, "\n");
+
+// 3. Transform each log lines that doesn't start with a timestamp into a row where everything is in the second column
+const untsRegex = /^(?!\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)(.*)(\n)?/gm;
+html = html.replace(untsRegex, (match, log) =>
+ ` | ${log} |
`
+);
+
+// 3.b Transform each log lines that start with a timestamp in a row with two columns and make the timestamp be a
+// self-referencial anchor.
+const tsRegex = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z) (.*)(\n)?/gm;
+html = html.replace(tsRegex, (match, ts, log) =>
+ ` | ${log} |
`
+);
+
+// 4. Add a anchor around every "##[error]" string
+let errorCounter = -1;
+html = html.replace(/##\[error\]/g, () =>
+ `##[error]`
+);
+
+// 4.b Add a span around every "##[warning]" string
+html = html.replace(/##\[warning\]/g, () =>
+ `##[warning]`
+);
+
+// pre-5. Polyfill the recently (2025) added `RegExp.escape` function.
+// Inspired by the former MDN section on escaping:
+// https://web.archive.org/web/20230806114646/https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions#escaping
+const escapeRegExp = RegExp.escape || function(string) {
+ return string.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
+};
+
+// 5. Add anchors around some paths
+// Detailed examples of what the regex does is at https://regex101.com/r/vCnx9Y/2
+//
+// But simply speaking the regex tries to find absolute (with `/checkout` prefix) and
+// relative paths, the path must start with one of the repository level-2 directories.
+// We also try to retrieve the lines and cols if given (`:line:col`).
+//
+// Some examples of paths we want to find:
+// - src/tools/test-float-parse/src/traits.rs:173:11
+// - /checkout/compiler/rustc_macros
+// - /checkout/src/doc/rustdoc/src/advanced-features.md
+//
+// Any other paths, in particular if prefixed by `./` or `obj/` should not taken.
+const pathRegex = new RegExp(
+ "(?[^a-zA-Z0-9.\\/])"
+ + "(?(?:[\\\/]?(?:checkout[\\\/])?(?(?:"
+ + tree_roots.map(p => escapeRegExp(p)).join("|")
+ + ")(?:[\\\/][a-zA-Z0-9_$\\\-.\\\/]+)?))"
+ + "(?::(?[0-9]+):(?[0-9]+))?)(?[^a-zA-Z0-9.])",
+ "g"
+);
+html = html.replace(pathRegex, (match, boundary_start, inner, path, line, col, boundary_end) => {
+ const pos = (line !== undefined) ? `#L${line}` : "";
+ return `${boundary_start}${inner}${boundary_end}`;
+});
+
+// 6. Add the html to the table
+logsEl.innerHTML = html;
+
+// 7. If no anchor is given, scroll to the last error
+if (location.hash === "" && errorCounter >= 0) {
+ const hasSmallViewport = window.innerWidth <= 750;
+ document.getElementById(`error-${errorCounter}`).scrollIntoView({
+ behavior: 'instant',
+ block: 'end',
+ inline: hasSmallViewport ? 'start' : 'center'
+ });
+}
+
+// 8. If a anchor is given, highlight and scroll to the selection
+if (location.hash !== "") {
+ const match = window.location.hash
+ .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))?/);
+
+ if (match) {
+ const [startId, endId] = [match[1], match[2] || match[1]].map(decodeURIComponent);
+ const startRow = logsEl.querySelector(`a[id="${startId}"]`)?.closest('tr');
+
+ if (startRow) {
+ startingAnchorId = startId;
+ highlightTimestampRange(startId, endId);
+ startRow.scrollIntoView({ block: 'center' });
+ }
+ }
+}
+
+// 9. Add a copy handler that force plain/text copy
+logsEl.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');
+ if (!rowEl || !e.target.classList.contains("timestamp")) return;
+
+ const ctrlOrMeta = e.ctrlKey || e.metaKey;
+ const shiftKey = e.shiftKey;
+ const rowId = getRowId(rowEl);
+
+ // Prevent default link behavior
+ e.preventDefault();
+ e.stopPropagation();
+
+ if (!ctrlOrMeta && !shiftKey) {
+ // Normal click: select single row, set anchor
+ startingAnchorId = rowId;
+ highlightTimestampRange(startingAnchorId, startingAnchorId);
+ } else if (shiftKey && startingAnchorId !== null) {
+ // Shift+click: extend selection from anchor
+ highlightTimestampRange(startingAnchorId, rowId);
+ } else if (ctrlOrMeta) {
+ // Ctrl/Cmd+click: new anchor (resets selection)
+ startingAnchorId = rowId;
+ highlightTimestampRange(startingAnchorId, startingAnchorId);
+ }
+
+ // Update our URL hash after every selection change
+ const ids = Array.from(logsEl.querySelectorAll('tr.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"
+}
+
+// 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 startIndex = rows.findIndex(row => getRowId(row) === startId);
+ const endIndex = rows.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));
+}