Files
zeekayandHanzo Dev 64c8e40f76 the face is Zen, and it ships from @hanzo/font
One family. Font stacks name Zen and fall back to the native faces, tokens
name the role rather than a face, and the packages that used to supply a
face now point at @hanzo/font.

Self-hosted @font-face blocks are removed rather than renamed: rewriting the
family in one would leave a src pointing at a file this repo no longer ships.
@hanzo/font carries the binaries and declares the faces, so the local copies
go with the declaration that used them.

Comments naming a face we no longer use are deleted, not reworded.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-08 10:25:22 -07:00

2528 lines
96 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>praktika report</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="" rel="stylesheet">
<style>
/* Default (Day Theme) */
:root {
--background-color: #FBFCFF;
--text-color: #151515;
--tile-background: #F9F9F9;
--info-background: #DFDFDF;
--footer-background: #151515;
--footer-text-color: #F9F9F9;
--status-width: 300px;
--pending-bar-color: #FAFF69;
--skipped-bar-color: #C0C0C0;
}
.hidden {
transform: translateX(-100%); /* Moves status-container out of view */
}
.expanded {
left: 10px !important; /* Move result-container to the left */
}
.button-moved {
left: 0 !important; /* Move button to the left when sidebar is hidden */
}
body {
background-color: var(--background-color);
color: var(--text-color);
height: 100%;
margin: 0;
display: flex;
flex-direction: column;
font-family: Zen, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
letter-spacing: -0.2px;
--header-background-color: #F9F9F9;
--link-color: #2563eb;
--link-hover-color: #1d4ed8;
--job-name-color: var(--text-color);
--file-link-color: #2563eb;
--file-link-hover-color: #1d4ed8;
--table-border-color: #DFDFDF;
}
body.night-theme {
--background-color: #151515;
--text-color: #DFDFDF;
--tile-background: #1D1D1D;
--info-background: #414141;
--header-background-color: #151515;
--pending-bar-color: #FAFF69;
--skipped-bar-color: #414141;
--table-border-color: #414141;
--job-name-color: var(--text-color);
--link-color: #FAFF69;
--link-hover-color: #e6eb5e;
--file-link-color: #FAFF69;
--file-link-hover-color: #e6eb5e;
--footer-background: #151515;
--footer-text-color: #F9F9F9;
}
#status-container {
position: fixed;
top: 0;
bottom: 40px;
left: 0;
width: var(--status-width);
background-color: var(--tile-background);
padding: 0;
box-sizing: border-box;
/*font-size: 18px;*/
margin: 0;
overflow-y: auto;
}
.status-widget {
border: 1px solid var(--table-border-color, #ccc); /* fallback to #ccc */
padding: 10px;
margin-bottom: 5px;
text-align: right;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: space-between;
word-wrap: break-word;
overflow-wrap: break-word;
}
#toggle-status-bar {
position: fixed;
top: 40%;
left: calc(var(--status-width));
background-color: var(--pending-bar-color);
color: var(--tile-background);
border: none;
padding: 20px 4px 20px 4px;
cursor: pointer;
border-top-right-radius: 8px;
border-bottom-right-radius: 8px;
}
@media (min-width: 1200px) {
#toggle-status-bar {
display: none;
}
}
.file-link {
color: var(--file-link-color);
font-weight: bold;
font-size: 0.7em;
font-family: Zen, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-stretch: ultra-condensed;
cursor: pointer;
display: inline-block;
margin-top: 5px;
text-decoration: none;
word-break: break-word; /* allows long strings to wrap mid-word */
white-space: normal; /* enables wrapping */
max-width: 100%; /* make sure it doesn’t overflow parent */
}
.file-link a {
color: var(--file-link-color);
text-decoration: none;
}
.file-link a:hover, .file-link:hover {
color: var(--file-link-hover-color);
text-decoration: none;
}
.external-link {
color: var(--file-link-color);
font-family: Zen, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
cursor: pointer;
display: inline-block;
text-decoration: none;
}
.external-link:hover {
color: var(--file-link-hover-color);
text-decoration: underline;
}
.dropdown-value {
width: 100px;
font-weight: normal;
font-family: Zen, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: transparent;
color: inherit;
/*border: none;*/
/*outline: none;*/
/*cursor: pointer;*/
}
#result-container {
background-color: var(--tile-background);
position: fixed;
top: 0;
bottom: 40px;
left: calc(var(--status-width) + 10px); /* Align with status container */
right: 0; /* Stretch to the right edge */
padding: 0;
box-sizing: border-box;
font-weight: normal;
overflow: auto;
white-space: nowrap;
}
.info-text {
white-space: normal; /* Allow text to wrap */
word-wrap: break-word;
overflow-wrap: break-word;
max-width: 100%; /* Prevent overflow */
background-color: var(--info-background);
font-family: Zen Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
}
.info-text pre {
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
}
#info-content {
text-align: left;
padding: 10px;
}
#footer {
padding: 10px;
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: var(--footer-background);
color: var(--footer-text-color);
font-size: 14px;
display: flex;
justify-content: space-between; /* Ensure the .left expands, and .right and .settings are aligned to the right */
align-items: center;
}
#footer a {
color: var(--footer-text-color);
text-decoration: none;
}
#footer .left {
flex-grow: 1; /* Takes up all the available space */
}
#footer .left span.separator {
margin-left: 5px;
margin-right: 5px;
}
#footer .right, #footer .settings {
display: flex;
align-items: center;
margin-right: 20px;
}
#footer .right a::before {
content: "#";
margin-left: 10px;
color: #606060;
}
#footer .right::before, #footer .settings::before {
content: "|"; /* Add separator before right and settings sections */
margin-left: 0px;
margin-right: 10px;
color: #606060;
}
#theme-toggle, #status-filter-toggle, #sort-toggle, #proxy-toggle {
cursor: pointer;
color: var(--footer-text-color);
margin-left: 10px;
}
#theme-toggle:hover, #sort-toggle:hover, #proxy-toggle:hover {
color: #FAFF69;
}
tr.downstream-row {
background-color: color-mix(in srgb, var(--tile-background) 50%, #e0d8f0);
}
.night-theme tr.downstream-row {
background-color: #2a2820;
}
.downstream-info {
padding: 8px 16px;
margin: 8px 0;
font-size: 13px;
border-radius: 4px;
background-color: var(--info-background);
color: var(--text-color);
opacity: 0.8;
}
#footer a:hover {
text-decoration: underline;
}
table {
table-layout: fixed; /* Ensures columns behave properly */
width: auto; /* Table shrinks to fit content */
max-width: 100%; /* Prevents overflow */
border-collapse: collapse;
}
th, td {
white-space: nowrap; /* Prevent text wrapping */
}
th.name-column, td.name-column,
td.dur-column, th.dur-column {
width: 1px;
max-width: fit-content;
overflow: hidden;
text-overflow: ellipsis;
}
th.status-column, td.status-column {
width: 1px;
max-width: fit-content;
text-align: center;
}
td.dur-column {
text-align: right;
font-family: Zen Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
}
td.time-column {
font-family: Zen Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
}
th.time-column, td.time-column {
width: auto; /* This column takes remaining space */
text-align: left;
}
.name-column a {
color: var(--job-name-color);
text-decoration: underline;
display: inline-block;
}
/*.name-column a:hover {*/
/* color: var(--link-hover-color);*/
/*}*/
/* Non-clickable text should look similar but without underline */
.name-column .disabled {
color: var(--job-name-color); /* Same color as links */
cursor: default; /* No pointer cursor */
text-decoration: none; /* No underline */
}
.disabled {
white-space: pre;
}
.status-column span[style*="pointer"] {
text-decoration: underline;
}
.status-column a {
text-decoration: underline;
cursor: pointer;
}
th, td {
padding: 8px;
border: 1px solid var(--table-border-color);
text-align: left;
}
.status-success {
color: #16a34a;
font-weight: 600;
}
.status-fail {
color: #dc2626;
font-weight: 600;
}
.status-pending {
color: #ca8a04;
font-weight: 600;
}
.status-broken {
color: #9333ea;
font-weight: 600;
}
.status-run {
color: #2563eb;
font-weight: 600;
}
.status-error {
color: #b91c1c;
font-weight: 600;
}
.status-dropped {
color: #7c3aed;
font-weight: 600;
}
.status-other {
color: #808080;
font-weight: 600;
}
.notification-panel {
padding: 10px 14px;
margin: 8px 0;
border-radius: 6px;
border-left: 4px solid;
font-size: 13px;
line-height: 1.5;
}
.notification-panel .notification-title {
font-weight: 600;
margin-bottom: 4px;
}
.notification-panel .notification-item {
margin: 4px 0;
}
.notification-panel .notification-jobs {
font-size: 11px;
opacity: 0.75;
margin-left: 4px;
}
.notification-errors {
background-color: #fef2f2;
border-left-color: #dc2626;
color: #991b1b;
}
.notification-warnings {
background-color: #fffbeb;
border-left-color: #d97706;
color: #92400e;
}
.notification-notes {
background-color: #eff6ff;
border-left-color: #2563eb;
color: #1e40af;
}
.night-theme .notification-errors {
background-color: #2a1515;
border-left-color: #f87171;
color: #fca5a5;
}
.night-theme .notification-warnings {
background-color: #2a2515;
border-left-color: #fbbf24;
color: #fde68a;
}
.night-theme .notification-notes {
background-color: #15202a;
border-left-color: #60a5fa;
color: #93c5fd;
}
</style>
</head>
<body>
<div id="status-container"></div>
<div id="result-container"></div>
<button id="toggle-status-bar" title="Toggle sidebar">‖</button>
<footer id="footer">
<div class="left"></div>
<div class="right"></div>
<div class="settings">
<span id="proxy-toggle" title="Configure downstream proxy URL">🔗</span>
<span id="status-filter-toggle" title="Filter by status"></span>
<span id="sort-toggle" title="Sort by status">🔼</span>
<span id="theme-toggle" title="Toggle day/night theme">🔆️</span>
</div>
</footer>
<script>
// Track all interval timers for cleanup
const activeTimers = {
intervals: [],
addInterval: function(timer) {
this.intervals.push(timer);
return timer;
},
clearAll: function() {
this.intervals.forEach(clearInterval);
this.intervals = [];
}
};
// Track event listeners for cleanup
const activeListeners = {
listeners: [],
add: function(element, type, listener) {
element.addEventListener(type, listener);
this.listeners.push({ element, type, listener });
return listener;
},
removeAll: function() {
this.listeners.forEach(({ element, type, listener }) => {
if (element && element.removeEventListener) {
element.removeEventListener(type, listener);
}
});
this.listeners = [];
}
};
function toggleTheme() {
document.body.classList.toggle('night-theme');
if (document.body.classList.contains('night-theme')) {
localStorage.setItem('theme', 'night');
} else {
localStorage.setItem('theme', 'day');
}
}
function filterRowsByStatus() {
const filter = localStorage.getItem('filter');
const showOnlyFailures = filter === 'failures';
const showOnlyWarnings = filter === 'warnings';
const rows = document.querySelectorAll('tr[data-status]');
if (!showOnlyFailures && !showOnlyWarnings) {
rows.forEach(row => row.style.display = '');
return;
}
rows.forEach(row => {
row.style.display = shouldShowRow(filter, row.dataset.status) ? '' : 'none';
});
}
function shouldShowRow(filter, status) {
const status_ = status.toLowerCase();
const isFailure =
(status_.includes('fail') || status_.includes('error') || status_.includes('dropped') || status_ === 'xpass' || status_ === 'slower')
&& status_ !== 'xfail';
const isWarning =
status_.includes('pending') || status_.includes('running') || status_ === 'unstable';
if (filter === 'failures') {
return isFailure;
}
if (filter === 'warnings') {
// Show both warnings and failures under 'warnings' filter
return isWarning || isFailure;
}
// If no filter is set, show all
return true;
}
function toggleStatusFilter(setValue=false) {
if (setValue instanceof Event) {
setValue = false;
}
let current = localStorage.getItem('filter');
if (!['all', 'failures', 'warnings'].includes(current)) {
current = 'all';
}
let newFilter;
if (setValue) {
newFilter = current;
} else {
if (current === 'all') {
newFilter = 'warnings';
} else if (current === 'warnings') {
newFilter = 'failures';
} else {
newFilter = 'all';
}
}
localStorage.setItem('filter', newFilter);
const toggle = document.getElementById('status-filter-toggle');
if (toggle) {
const greenTick = `<img alt="all" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMiIgaGVpZ2h0PSIxMiIgdmlld0JveD0iMCAwIDIwIDIwIj4KICA8cGF0aCBmaWxsPSJncmVlbiIgZD0iTTAgMTEgTDcgMTggTDIwIDIgTDE4IDAgTDcgMTQgTDIgOSBaIi8+Cjwvc3ZnPgoK">`;
const redCross = `<img alt="failures" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMiIgaGVpZ2h0PSIxMiIgdmlld0JveD0iMCAwIDIwIDIwIj4KICA8bGluZSB4MT0iMiIgeTE9IjIiIHgyPSIxOCIgeTI9IjE4IiBzdHJva2U9InJlZCIgc3Ryb2tlLXdpZHRoPSIyIi8+CiAgPGxpbmUgeDE9IjIiIHkxPSIxOCIgeDI9IjE4IiB5Mj0iMiIgc3Ryb2tlPSJyZWQiIHN0cm9rZS13aWR0aD0iMiIvPgo8L3N2Zz4KCg==">`;
const yellowExclamationMark = `<img alt="warnings" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMiIgaGVpZ2h0PSIxMiIgdmlld0JveD0iMCAwIDIwIDIwIj4KICA8cG9seWdvbiBwb2ludHM9IjEwLDAgMCwyMCAyMCwyMCIgZmlsbD0ieWVsbG93Ii8+CiAgPHJlY3QgeD0iOSIgeT0iNiIgd2lkdGg9IjIiIGhlaWdodD0iNyIgZmlsbD0iYmxhY2siLz4KICA8Y2lyY2xlIGN4PSIxMCIgY3k9IjE2IiByPSIxIiBmaWxsPSJibGFjayIvPgo8L3N2Zz4KCg==">`;
toggle.innerHTML =
newFilter === 'failures' ? redCross :
newFilter === 'warnings' ? yellowExclamationMark :
greenTick;
}
filterRowsByStatus();
}
function toggleStatusBar() {
var statusContainer = document.getElementById("status-container");
var resultContainer = document.getElementById("result-container");
var toggleButton = document.getElementById("toggle-status-bar");
if (statusContainer.classList.contains("hidden")) {
statusContainer.classList.remove("hidden");
resultContainer.classList.remove("expanded");
toggleButton.classList.remove("button-moved");
} else {
statusContainer.classList.add("hidden");
resultContainer.classList.add("expanded");
toggleButton.classList.add("button-moved");
}
}
function updateUrlParameter(paramName, paramValue) {
const url = new URL(window.location.href);
url.searchParams.set(paramName, paramValue);
window.location.href = url.toString();
}
document.getElementById('theme-toggle').addEventListener('click', toggleTheme);
document.getElementById('status-filter-toggle').addEventListener('click', toggleStatusFilter);
document.getElementById('sort-toggle').addEventListener('click', toggleSortByStatus);
document.getElementById('toggle-status-bar').addEventListener('click', toggleStatusBar);
document.getElementById('proxy-toggle').addEventListener('click', toggleProxy);
function toggleProxy() {
const current = localStorage.getItem('privateBaseUrl') || '';
const value = prompt('Downstream proxy base URL (leave empty to disable):', current);
if (value === null) return; // cancelled
if (value.trim()) {
try {
new URL(value.trim());
} catch {
alert('Invalid URL. Please enter a full URL starting with https://');
return;
}
localStorage.setItem('privateBaseUrl', value.trim().replace(/\/+$/, ''));
} else {
localStorage.removeItem('privateBaseUrl');
}
updateProxyToggleIcon();
location.reload();
}
function updateProxyToggleIcon() {
const toggle = document.getElementById('proxy-toggle');
const hasProxy = !!localStorage.getItem('privateBaseUrl');
if (toggle) {
toggle.style.opacity = hasProxy ? '1' : '0.4';
toggle.title = hasProxy
? 'Downstream proxy: ' + localStorage.getItem('privateBaseUrl')
: 'Configure downstream proxy URL';
}
}
function toggleSortByStatus() {
const current = localStorage.getItem('sortByStatus');
const newValue = current === 'true' ? 'false' : 'true';
localStorage.setItem('sortByStatus', newValue);
columnSortActive = false;
// Update the visual indicator
updateSortToggleIcon();
// Re-render all tables on the page
const tables = document.querySelectorAll('table');
tables.forEach(table => {
const tbody = table.querySelector('tbody');
if (tbody) {
// Get the original results order from a stored copy
const resultsData = tbody.resultsData;
const nestLevel = tbody.nestLevel;
const existingColumnsList = tbody.existingColumnsList;
if (resultsData && existingColumnsList) {
populateTableRows(tbody, resultsData, nestLevel, existingColumnsList);
}
}
});
}
function updateSortToggleIcon() {
const toggle = document.getElementById('sort-toggle');
const sortEnabled = localStorage.getItem('sortByStatus') === 'true';
if (toggle) {
toggle.style.opacity = sortEnabled ? '1' : '0.5';
toggle.title = sortEnabled ? 'Status sorting enabled' : 'Status sorting disabled';
}
}
function getStatusPriority(status) {
const statusLower = (status || '').toLowerCase();
if (statusLower.includes('error')) return 0;
if (statusLower.includes('fail') && statusLower !== 'xfail') return 1;
// Perf-comparison per-query statuses (compare.sh emits slower/unstable/success).
if (statusLower === 'slower') return 1;
if (statusLower === 'unstable') return 2;
// Keep UNKNOWN right under the errors/failures: these are tests whose real
// result was lost (e.g. demoted when the run aborted), so they are the rows
// worth looking at first, not buried below all the OK rows.
if (statusLower.includes('unknown')) return 2;
if (statusLower === 'xpass') return 3;
if (statusLower.includes('running')) return 4;
if (statusLower.includes('dropped')) return 5;
if (statusLower.includes('pending')) return 6;
if (statusLower === 'ok' || statusLower.includes('success')) return 7;
if (statusLower === 'xfail') return 8;
if (statusLower.includes('skipped')) return 9;
return 10;
}
function formatTimestamp(timestamp, showDate = true) {
const date = new Date(timestamp * 1000);
const day = String(date.getDate()).padStart(2, '0');
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const month = monthNames[date.getMonth()];
//const year = date.getFullYear();
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
//const milliseconds = String(date.getMilliseconds()).padStart(2, '0');
return showDate
? `${day}'${month} ${hours}:${minutes}:${seconds}`
: `${hours}:${minutes}:${seconds}`;
}
function formatDuration(durationInSeconds, detailed = false) {
// Treat null/undefined/non-numeric as "unknown"; a literal 0 is a
// real measurement and flows through the precision ladder below
// (otherwise sub-millisecond perf-comparison rows showed up as the
// misleading placeholder "00s").
if (durationInSeconds === null || durationInSeconds === undefined) {
return '00s';
}
const duration = parseFloat(durationInSeconds);
if (isNaN(duration)) {
return '00s';
}
if (detailed && duration >= 1) {
// H/M/S format for job-level totals (e.g. "1h 23m 45s").
const hours = Math.floor(duration / 3600);
const minutes = Math.floor((duration % 3600) / 60);
const seconds = Math.floor(duration % 60);
const formattedHours = hours > 0 ? `${hours}h ` : '';
const formattedMinutes = minutes > 0 ? `${minutes}m ` : '';
const formattedSeconds = `${String(seconds).padStart(2, '0')}s`;
return `${formattedHours}${formattedMinutes}${formattedSeconds}`.trim();
}
// Sub-second precision ladder: drop to a smaller unit when the
// next-coarser one would round to zero. Covers true zero too.
if (duration < 0.00001) {
// < 0.01ms — integer nanoseconds.
return `${Math.round(duration * 1e9)}ns`;
}
if (duration < 0.001) {
// < 1ms but >= 0.01ms — two decimals in ms.
return `${(duration * 1000).toFixed(2)}ms`;
}
if (duration < 1) {
// Sub-second: integer milliseconds.
return `${Math.floor(duration * 1000)}ms`;
}
// >= 1s and not detailed: seconds with two-digit milliseconds.
const seconds = Math.floor(duration);
const milliseconds = Math.floor((duration % 1) * 1000);
const formattedSeconds = String(seconds);
const formattedMilliseconds = String(milliseconds).padStart(2, 0).slice(-2);
return `${formattedSeconds}.${formattedMilliseconds}`;
}
// Function to determine status class based on value
function getStatusClass(status) {
const lowerStatus = status.toLowerCase();
if (lowerStatus === 'xfail') return 'status-other'; // expected failure: gray (known, not alarming)
if (lowerStatus === 'xpass') return 'status-fail'; // unexpected pass: red
if (lowerStatus.includes('success') || lowerStatus === 'ok') return 'status-success';
if (lowerStatus.includes('fail')) return 'status-fail';
if (lowerStatus.includes('pending')) return 'status-pending';
if (lowerStatus.includes('broken')) return 'status-broken';
if (lowerStatus.includes('run')) return 'status-run';
if (lowerStatus.includes('error')) return 'status-error';
if (lowerStatus.includes('dropped')) return 'status-dropped';
return 'status-other';
}
// Color utility
function stringToColor(str, dark = false) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
const hue = Math.abs(hash % 360);
const saturation = dark ? 90 : 60; // Higher = more vivid
const lightness = dark ? 45 : 60; // Slightly darker = more contrast
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
}
// Normalize ext.labels (and legacy ext.hlabels) into a list of
// {name, link?, hint?} entries. Accepts the unified dict format as well as
// legacy formats: a bare string in ext.labels, or [name, link] in ext.hlabels.
function normalizeLabels(labels, hlabels) {
const out = [];
const indexByName = new Map();
const upsert = (entry) => {
const existingIdx = indexByName.get(entry.name);
if (existingIdx === undefined) {
indexByName.set(entry.name, out.length);
out.push(entry);
} else {
out[existingIdx] = {...out[existingIdx], ...entry};
}
};
if (Array.isArray(labels)) {
labels.forEach(item => {
if (typeof item === 'string') {
upsert({name: item});
} else if (item && typeof item === 'object' && item.name) {
upsert({
name: item.name,
...(item.link ? {link: item.link} : {}),
...(item.hint ? {hint: item.hint} : {}),
});
}
});
}
if (Array.isArray(hlabels)) {
hlabels.forEach(item => {
if (Array.isArray(item) && item[0]) {
upsert({name: item[0], ...(item[1] ? {link: item[1]} : {})});
}
});
}
return out;
}
// Apply common pill styling to a label element.
function applyLabelStyles(element, text) {
element.textContent = text;
element.style.marginLeft = '6px';
element.style.padding = '2px 8px';
element.style.backgroundColor = stringToColor(text, true);
element.style.color = 'white';
element.style.fontSize = '12px';
element.style.borderRadius = '8px';
element.style.fontWeight = 'bold';
element.style.display = 'inline-block';
element.style.verticalAlign = 'middle';
}
// Render the current Result's own labels in the sidebar. Without this the
// labels of a drilled-into job would only be visible in the parent
// overview row, never on the job's own page.
function addLabelsWidget(ext) {
const labels = normalizeLabels(ext?.labels, ext?.hlabels);
if (labels.length === 0) return;
const statusContainer = document.getElementById('status-container');
const widget = document.createElement('div');
widget.className = 'status-widget';
widget.style.textAlign = 'left';
labels.forEach(({name, link, hint}) => {
const element = link ? document.createElement('a') : document.createElement('span');
if (link) {
element.href = link;
element.target = '_blank';
element.rel = 'noopener noreferrer';
}
applyLabelStyles(element, name);
element.style.marginLeft = '0';
if (hint) {
element.title = hint;
element.style.cursor = link ? 'pointer' : 'help';
}
const line = document.createElement('div');
line.style.margin = '4px 0';
line.appendChild(element);
widget.appendChild(line);
});
statusContainer.appendChild(widget);
}
function addFileLinksWidget(links) {
if (!Array.isArray(links) || links.length === 0) return;
const statusContainer = document.getElementById('status-container');
const widget = document.createElement('div');
widget.className = 'status-widget';
widget.style.textAlign = 'left';
const base_url = window.location.origin + window.location.pathname;
links.forEach(link => {
const fileLink = document.createElement('a');
fileLink.href = link;
fileLink.target = '_blank';
fileLink.className = 'file-link';
fileLink.textContent = link.includes(base_url) ? "Report" : link.split('/').pop();
// Wrap each link in its own line for spacing
const line = document.createElement('div');
line.appendChild(fileLink);
widget.appendChild(line);
});
statusContainer.appendChild(widget);
}
function addDetailsWidget(runtimeCache, sha, PR) {
const statusContainer = document.getElementById('status-container');
// Widget container
const widget = document.createElement('div');
widget.className = 'status-widget'
function addLine(text, options = {}) {
const el = document.createElement(options.tag || 'div');
el.textContent = text || 'N/A';
if (options.bold) el.style.fontWeight = "bold";
if (options.small) el.style.fontSize = "12px";
if (options.link) {
el.href = options.link;
el.target = "_blank";
el.className = "external-link";
}
widget.appendChild(el);
}
function createDropdown(selected, options, optionValues, key) {
const select = document.createElement('select');
select.className = 'dropdown-value';
options.forEach((label, i) => {
const opt = document.createElement('option');
opt.textContent = label;
opt.value = optionValues[i];
if (optionValues[i] === selected) opt.selected = true;
select.appendChild(opt);
});
select.addEventListener('change', (event) => {
const selectedValue = event.target.value;
updateUrlParameter(key, selectedValue);
});
return select;
}
// === PR + SHA ROW ===
const topRow = document.createElement("div");
topRow.style.display = "flex";
topRow.style.justifyContent = "space-between";
topRow.style.alignItems = "center";
topRow.style.margin = "8px 0";
// PR on the left
const prLabel = document.createElement("div");
if (PR > 0 && runtimeCache.prLink) {
const prLink = document.createElement("a");
prLink.textContent = `PR ${PR}`;
prLink.href = runtimeCache.prLink;
prLink.target = "_blank";
prLink.style.fontWeight = "bold";
prLink.className = "external-link";
prLabel.appendChild(prLink);
}
topRow.appendChild(prLabel);
// SHA dropdown on the right
const commits = runtimeCache.commits || [];
const shaSelectorText = commits.map(({ sha, message }) => {
const shortSha = (sha || "").substring(0, 8);
return message ? `${shortSha}: ${message}` : shortSha;
});
const shaSelectorValues = commits.map(c => c.sha);
shaSelectorText.push("latest");
shaSelectorValues.push("latest");
const shaDropdown = createDropdown(sha, shaSelectorText, shaSelectorValues, "sha");
topRow.appendChild(shaDropdown);
widget.appendChild(topRow);
// PR title / branch
if (PR > 0) {
const ext = runtimeCache.json0?.ext || {};
if (ext.pr_title) addLine(ext.pr_title, { small: true });
if (ext.git_branch) addLine(ext.git_branch, { small: true });
}
// Current SHA (full), small
const real_sha = sha === "latest" ? commits.at(-1)?.sha : sha
addLine(real_sha, { small: true });
addLine(": " + commits.find(c => c.sha === real_sha)?.message || "", { small: true });
statusContainer.appendChild(widget);
}
function addStatisticsWidget(jobName, duration) {
const statusContainer = document.getElementById("status-container");
const chartWrapper = document.createElement("div");
chartWrapper.className = 'status-widget';
const stats = persistentStorage.statisticsObject?.[jobName]?.["3d"];
if (!stats) {
console.warn(`No stats found for job: ${jobName}`);
return;
}
const quantiles = stats.quantiles;
const keys = Object.keys(quantiles).map(Number).sort((a, b) => a - b);
const values = keys.map(k => quantiles[k]);
values[0] = Math.min(duration, values[0]);
values[values.length - 1] = Math.max(duration, values[values.length - 1]);
const minValue = values[0];
const maxValue = values[values.length - 1];
const range = maxValue - minValue;
// --- Density Segments
const densitySegments = [];
for (let i = 0; i < keys.length - 1; i++) {
const q1 = keys[i], q2 = keys[i + 1];
const v1 = quantiles[q1], v2 = quantiles[q2];
const x1 = ((v1 - minValue) / range) * 100;
const x2 = ((v2 - minValue) / range) * 100;
const dq = q2 - q1;
const dv = v2 - v1 || 1e-6;
const density = dq / dv;
densitySegments.push({ x1, x2, density });
}
// --- Normalize densities
const allDensities = densitySegments.map(seg => seg.density);
const maxDensity = Math.max(...allDensities);
const minDensity = Math.min(...allDensities);
const gradientStops = densitySegments.flatMap((seg, i) => {
const next = densitySegments[i + 1] ?? seg;
const norm1 = (seg.density - minDensity) / (maxDensity - minDensity || 1);
const norm2 = (next.density - minDensity) / (maxDensity - minDensity || 1);
const curved1 = Math.pow(norm1, 0.5);
const curved2 = Math.pow(norm2, 0.5);
const alpha1 = (0.3 + 0.7 * curved1).toFixed(2);
const alpha2 = (0.3 + 0.7 * curved2).toFixed(2);
const color1 = `rgba(0, 123, 255, ${alpha1})`;
const color2 = `rgba(0, 123, 255, ${alpha2})`;
return [
`${color1} ${seg.x1.toFixed(2)}%`,
`${color2} ${seg.x2.toFixed(2)}%`
];
});
const gradient = `linear-gradient(to right, ${gradientStops.join(', ')})`;
const clampedDuration = Math.max(minValue, Math.min(duration, maxValue));
const durationLeft = ((clampedDuration - minValue) / range) * 100;
const topLabelBox = document.createElement("div");
//topLabelBox.style.fontSize = "10px";
topLabelBox.style.marginBottom = "4px";
const currentLabel = document.createElement("div");
currentLabel.style.textAlign = "left";
currentLabel.textContent = `duration: ${formatDurationMs(duration)}`;
topLabelBox.appendChild(currentLabel);
chartWrapper.appendChild(topLabelBox);
// --- Bar
const bar = document.createElement("div");
bar.style.position = "relative";
bar.style.height = "24px";
bar.style.background = gradient;
const durationLine = document.createElement("div");
durationLine.style.position = "absolute";
durationLine.style.left = `${durationLeft}%`;
durationLine.style.top = "0";
durationLine.style.bottom = "0";
durationLine.style.width = "2px";
durationLine.style.background = "red";
bar.appendChild(durationLine);
chartWrapper.appendChild(bar);
// --- Min / Max labels below bar
const bottomLabels = document.createElement("div");
bottomLabels.style.display = "flex";
bottomLabels.style.justifyContent = "space-between";
bottomLabels.style.fontSize = "10px";
bottomLabels.style.color = "#444";
const minLabel = document.createElement("div");
minLabel.textContent = formatDurationMs(minValue);
const maxLabel = document.createElement("div");
maxLabel.textContent = formatDurationMs(maxValue);
bottomLabels.appendChild(minLabel);
bottomLabels.appendChild(maxLabel);
chartWrapper.appendChild(bottomLabels);
// --- Final assemble
statusContainer.appendChild(chartWrapper);
function formatDurationMs(ms) {
const s = Math.floor(ms / 1000);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
const parts = [];
if (h > 0) parts.push(`${h}h`);
parts.push(`${m}m`);
parts.push(`${sec}s`);
return parts.join(' ');
}
}
function addStorageUsageChartToStatus(usage_data) {
const statusContainer = document.getElementById("status-container");
// Note: We don't need the extra style element anymore as we now handle margins directly
if (!usage_data || !usage_data.uploaded || !usage_data.uploaded_details) {
console.error("Invalid or missing storage usage data.");
return;
}
const uploaded = usage_data.uploaded;
const originalDetails = usage_data.uploaded_details;
// Calculate total size in MB - define it at this scope level so it's available throughout the function
const uploadedMB = (uploaded / (1024 * 1024)).toFixed(2);
// Group small files and sort
let sortedEntries = Object.entries(originalDetails)
.map(([name, size]) => [name, size])
.sort((a, b) => b[1] - a[1]);
// Take top 20, rest go to "other"
let grouped = sortedEntries.slice(0, 20);
const rest = sortedEntries.slice(20);
const otherTotal = rest.reduce((sum, [, value]) => sum + value, 0);
if (otherTotal > 0) grouped.push(["other", otherTotal]);
const labels = grouped.map(([label]) => label);
const values = grouped.map(([, value]) => value);
const chartWrapper = document.createElement("div");
chartWrapper.className = 'status-widget';
// Create fixed-height info container (2 lines)
const infoContainer = document.createElement("div");
infoContainer.style.height = "2.2em";
infoContainer.style.display = "flex";
infoContainer.style.flexDirection = "column";
infoContainer.style.fontSize = "0.8em";
// First line: Item name or "total uploaded"
const nameRow = document.createElement("div");
nameRow.style.height = "1.3em";
nameRow.style.lineHeight = "1.3em";
nameRow.style.overflow = "hidden";
nameRow.style.whiteSpace = "nowrap";
nameRow.style.textOverflow = "ellipsis";
nameRow.style.letterSpacing = "-0.02em"; // Slightly condensed letter spacing
nameRow.id = "storage-usage-name";
nameRow.textContent = "uploaded:";
// Second line: XX% ...padding... YYYMB
const detailsRow = document.createElement("div");
detailsRow.style.display = "flex";
detailsRow.style.justifyContent = "space-between";
detailsRow.style.height = "1.3em";
detailsRow.style.lineHeight = "1.3em";
detailsRow.id = "storage-usage-details";
detailsRow.style.fontSize = "0.85em";
detailsRow.style.color = "#666";
// Combined size and percentage info (right-aligned)
const sizeAndPercentEl = document.createElement("span");
sizeAndPercentEl.id = "storage-usage-size-percent";
sizeAndPercentEl.style.marginLeft = "auto"; // Push to the right
sizeAndPercentEl.textContent = `${uploadedMB} MB (100%)`; // Both size and percentage
detailsRow.appendChild(sizeAndPercentEl);
infoContainer.appendChild(nameRow);
infoContainer.appendChild(detailsRow);
chartWrapper.appendChild(infoContainer);
// Fixed dimensions for the horizontal bar chart
const barHeight = 24; // Thick bar
// Bar chart container - use 100% width to fit the widget perfectly
const chartContainer = document.createElement("div");
chartContainer.style.height = `${barHeight}px`;
chartContainer.style.position = "relative";
chartContainer.style.margin = "10px 0 0 0";
chartContainer.style.cursor = "pointer";
// Create bars for each entry
const barData = [];
// Use percentage-based widths instead of fixed pixels
labels.forEach((label, index) => {
const value = values[index];
const percent = value / uploaded;
// Convert to percentage width with minimum visibility
const percentWidth = Math.max(percent * 100, 0.5);
const color = stringToColor(label);
const bar = document.createElement("div");
bar.style.position = "absolute";
bar.style.left = barData.length > 0 ? `${barData.reduce((sum, b) => sum + b.percentWidth, 0)}%` : "0";
bar.style.width = `${percentWidth}%`;
bar.style.height = `${barHeight}px`;
bar.style.backgroundColor = color;
bar.style.transition = "opacity 0.2s";
bar.dataset.label = label;
// Create tooltip data for this bar
const sizeMB = (value / (1024 * 1024)).toFixed(2);
const percentValue = ((value / uploaded) * 100).toFixed(1);
bar.dataset.tooltip = `${label}: ${sizeMB} MB (${percentValue}%)`;
chartContainer.appendChild(bar);
barData.push({
label,
element: bar,
percentWidth,
color,
value,
tooltip: `${label}: ${sizeMB} MB (${percentValue}%)`
});
});
chartWrapper.appendChild(chartContainer);
statusContainer.appendChild(chartWrapper);
// Helper function to update display elements
const updateDisplay = (name, sizeMB, percent) => {
const nameEl = document.getElementById("storage-usage-name");
const sizePercentEl = document.getElementById("storage-usage-size-percent");
if (nameEl && sizePercentEl) {
nameEl.textContent = name;
sizePercentEl.textContent = `${sizeMB} MB (${percent}%)`;
}
};
// Show the total uploaded by default
const showTotalUploaded = () => {
updateDisplay("uploaded:", uploadedMB, 100);
};
// Show details for specific segment
const showSegmentDetails = (segment) => {
const sizeMB = (segment.value / (1024 * 1024)).toFixed(2);
const percent = ((segment.value / uploaded) * 100).toFixed(1);
updateDisplay(segment.label, sizeMB, percent);
};
// Highlight bar function
function highlightBar(hoveredLabel) {
let hoveredSegment = null;
barData.forEach(bar => {
if (bar.label === hoveredLabel) {
bar.element.style.opacity = "1";
//bar.element.style.boxShadow = "0 0 0 2px #000";
hoveredSegment = bar;
} else {
bar.element.style.opacity = "0.5";
}
});
// Show details for hovered segment
if (hoveredSegment) {
showSegmentDetails(hoveredSegment);
}
}
// Reset bars function
function resetBars() {
barData.forEach(bar => {
bar.element.style.opacity = "1";
bar.element.style.boxShadow = "none";
});
// Reset to show total uploaded
showTotalUploaded();
}
// Add hover effects to the bar segments
barData.forEach(bar => {
bar.element.addEventListener("mouseenter", () => highlightBar(bar.label));
bar.element.addEventListener("mouseleave", () => resetBars());
});
// Reset wrapper padding to default for text, but adjust for the bar
chartWrapper.style.padding = "10px"; // Restore the standard padding
chartWrapper.style.paddingBottom = "10px"; // Add extra bottom padding
// Only adjust the chart container (bar) to have full width
chartContainer.style.marginLeft = "-10px"; // Offset the parent padding
chartContainer.style.marginRight = "-10px"; // Offset the parent padding
chartContainer.style.width = "calc(100% + 20px)"; // Compensate for the negative margins
}
function addTimeTraceWidget(results, statusWidget, originalTask) {
const statusContainer = document.getElementById("status-container");
if (!results || results.length === 0) {
console.error("No results to visualize.");
return;
}
const lineWidth = 1;
const padding = 1;
const totalHeight = (lineWidth + padding) * results.length;
const widget = document.createElement("div");
widget.className = 'status-widget'
widget.style.height = `${totalHeight}px`;
widget.style.padding = "0";
statusContainer.appendChild(widget);
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.style.width = "100%";
canvas.style.height = `${totalHeight}px`;
canvas.style.display = "block";
widget.appendChild(canvas);
// Move taskRects to outer scope so event handlers can access it
let taskRects = [];
let hoveredIndex = -1;
function resizeCanvas() {
canvas.width = widget.clientWidth;
canvas.height = totalHeight;
drawTimeline();
}
resizeCanvas();
// Use our event tracker instead of direct attachment
activeListeners.add(window, "resize", resizeCanvas);
function getColorFromName(name) {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
const hue = Math.abs(hash % 360);
return `hsl(${hue}, 70%, 60%)`;
}
function drawTimeline() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
taskRects = []; // Reset the array instead of declaring a new one
const failedMarkers = [];
const minStartTime = runtimeCache.json0.start_time;
const currentUtcTimestamp = Date.now() / 1000;
const maxTime = runtimeCache.json0?.duration ?? (currentUtcTimestamp - minStartTime);
const scaleX = canvas.width / maxTime;
// Draw highlight background for hovered row
if (hoveredIndex >= 0) {
const hy = hoveredIndex * (lineWidth + padding) - padding;
const isDark = document.body.classList.contains('night-theme');
ctx.fillStyle = isDark ? 'rgba(255, 255, 255, 0.15)' : 'rgba(0, 0, 0, 0.1)';
ctx.fillRect(0, hy, canvas.width, lineWidth + 2 * padding);
}
results.forEach((task, index) => {
const y = index * (lineWidth + padding);
const height = lineWidth;
let x, width, color;
if (task.start_time === null) {
x = 0;
width = canvas.width;
const getCss = (name) => getComputedStyle(document.body).getPropertyValue(name).trim();
const sl = (task.status || '').toLowerCase();
color = sl === "pending" ? getCss('--pending-bar-color') : getCss('--skipped-bar-color');
} else if (task.duration === null) {
x = (task.start_time - minStartTime) * scaleX;
width = (maxTime - (task.start_time - minStartTime)) * scaleX;
color = getColorFromName(task.name);
} else {
x = (task.start_time - minStartTime) * scaleX;
width = task.duration * scaleX;
color = getColorFromName(task.name);
}
ctx.fillStyle = color;
ctx.fillRect(x, y, width, height);
taskRects.push({ x, y, width, height, task });
// Save failure marker position for second pass
const tsl = (task.status || '').toLowerCase();
if (tsl === "fail" || tsl === "failure" || tsl === "error" || tsl === "dropped") {
failedMarkers.push({ x: x + width, y: y + height / 2 });
}
});
// Draw failure markers on top
failedMarkers.forEach(({ x, y }) => {
ctx.fillStyle = "#ff3333";
ctx.beginPath();
const s = 3;
ctx.moveTo(x - s, y - s); ctx.lineTo(x + s, y + s);
ctx.moveTo(x + s, y - s); ctx.lineTo(x - s, y + s);
ctx.lineWidth = 1.5;
ctx.strokeStyle = "#ff3333";
ctx.stroke();
});
}
activeListeners.add(canvas, "mousemove", (event) => {
const rect = canvas.getBoundingClientRect();
const mouseY = event.clientY - rect.top;
let hoveredTask = null;
let newHoveredIndex = -1;
for (let i = 0; i < taskRects.length; i++) {
const { y, height, task } = taskRects[i];
if (mouseY >= y && mouseY <= y + height) {
hoveredTask = task;
newHoveredIndex = i;
break;
}
}
if (newHoveredIndex !== hoveredIndex) {
hoveredIndex = newHoveredIndex;
drawTimeline();
}
if (hoveredTask && statusWidget?.update) {
statusWidget.update(
hoveredTask.name,
hoveredTask.status,
hoveredTask.start_time,
hoveredTask.duration
);
}
});
activeListeners.add(canvas, "mouseleave", () => {
hoveredIndex = -1;
drawTimeline();
if (statusWidget?.update && originalTask) {
statusWidget.update(
originalTask.name,
originalTask.status,
originalTask.start_time,
originalTask.duration
);
}
});
drawTimeline();
activeTimers.addInterval(setInterval(drawTimeline, 5000));
}
function addStatusWidget(name, status, start_time, duration, container = null) {
const statusContainer = container || document.getElementById('status-container');
// Widget container
const widget = document.createElement('div');
widget.className = 'status-widget'
// Helper to create a line
function addLine(value, className = '') {
const line = document.createElement('div');
line.textContent = value;
if (className) line.classList.add(className);
widget.appendChild(line);
}
// Make widget re-usable by external updates
widget.update = function (name, status, start_time, duration) {
widget.innerHTML = '';
// Create name line with fixed height for two lines
const nameLine = document.createElement('div');
nameLine.textContent = name;
nameLine.style.whiteSpace = "normal";
nameLine.style.overflow = "hidden";
nameLine.style.maxWidth = "100%";
nameLine.style.height = "2.6em"; // Reserve space for 2 lines
nameLine.style.lineHeight = "1.3em";
nameLine.style.textAlign = "left";
widget.appendChild(nameLine);
addLine(status, getStatusClass(status));
addLine(start_time ? formatTimestamp(start_time) : "0:00:00");
if (duration != null) {
addLine(formatDuration(duration, true));
} else if (start_time != null) {
const durationLine = document.createElement('div');
let seconds = Math.floor(Date.now() / 1000 - start_time);
durationLine.textContent = formatDuration(seconds, true);
activeTimers.addInterval(setInterval(() => {
seconds++;
durationLine.textContent = formatDuration(seconds, true);
}, 1000));
widget.appendChild(durationLine);
} else {
addLine("--s");
}
};
// Initial fill
widget.update(name, status, start_time, duration);
statusContainer.appendChild(widget);
return widget;
}
const columns = ['status', 'name', 'duration', 'start_time'];
const columnSymbols = {
// name: '🗂️',
// status: '✅',
start_time: 'start time',
duration: 'duration',
// info: '📝',
files: '📎'
};
function createResultsTable(results, nest_level) {
if (results && Array.isArray(results) && results.length > 0) {
const table = document.createElement('table');
const thead = document.createElement('thead');
const tbody = document.createElement('tbody');
let existing_columns = {};
columns.forEach(column => {
existing_columns[column] = results.some(result => result[column]);
});
// Build an ordered list of columns that actually exist
const existingColumnsList = columns.filter(column => existing_columns[column]);
const lastColumn = existingColumnsList[existingColumnsList.length - 1];
// Create table headers based on the fixed columns
const headerRow = document.createElement('tr');
existingColumnsList.forEach(column => {
const th = document.createElement('th');
th.textContent = columnSymbols[column] || column;
th.style.cursor = 'pointer';
th.style.textDecoration = 'underline';
th.setAttribute('data-sort-direction', 'asc'); // Default sort direction
if (column === lastColumn) th.classList.add('time-column');
th.addEventListener('click', () => sortTable(results, column, columnSymbols[column] || column, tbody, nest_level, existingColumnsList));
headerRow.appendChild(th);
});
thead.appendChild(headerRow);
// Create table rows
populateTableRows(tbody, results, nest_level, existingColumnsList);
table.appendChild(thead);
table.appendChild(tbody);
return table;
}
return null;
}
function populateTableRows(tbody, results, nest_level, existingColumnsList) {
const currentUrl = new URL(window.location.href);
// Store the original data in the tbody for later access
// Make a deep copy to preserve the original order
if (!tbody.resultsData) {
tbody.resultsData = JSON.parse(JSON.stringify(results));
}
tbody.nestLevel = nest_level;
tbody.existingColumnsList = existingColumnsList;
// Clear existing rows
tbody.innerHTML = '';
const filter = localStorage.getItem('filter');
const lastColumn = existingColumnsList[existingColumnsList.length - 1];
// Sort results by status if the feature is enabled.
// Skip if user clicked a column header (columnSortActive) — reset on next fresh render.
const sortByStatus = !columnSortActive && localStorage.getItem('sortByStatus') === 'true';
// Work with a copy so we don't mutate the original
let displayResults = [...results];
if (sortByStatus) {
displayResults.sort((a, b) => {
const statusA = a.status || '';
const statusB = b.status || '';
return getStatusPriority(statusA) - getStatusPriority(statusB);
});
}
displayResults.forEach((result, _index) => {
const row = document.createElement('tr');
if (result._downstreamHref) row.classList.add('downstream-row');
// Unified labels: list of {name, link?, hint?} (or legacy strings).
// Legacy ext.hlabels (list of [name, link]) is merged in for backward compat.
const labels = normalizeLabels(result?.ext?.labels, result?.ext?.hlabels);
existingColumnsList.forEach(column => {
const td = document.createElement('td');
const value = result[column];
// Make the last column the expanding one regardless of its name
if (column === lastColumn) td.classList.add('time-column');
if (column === 'status') {
const value_ = value ? value.toLowerCase() : "";
const clickable = (result.info && result.info.trim() !== "") || (Array.isArray(result.links) && result.links.length > 0) || (Array.isArray(result.results) && result.results.length > 0);
const statusEl = clickable ? document.createElement('a') : document.createElement('span');
statusEl.className = getStatusClass(value_);
statusEl.textContent = value_;
td.classList.add('status-column');
row.dataset.status = value_;
if (clickable) {
statusEl.href = '#';
statusEl.addEventListener('click', (e) => {
e.preventDefault();
toggleInfoRow(row, result.info, result.results, result.links);
});
}
td.appendChild(statusEl);
row.style.display = shouldShowRow(filter, row.dataset.status) ? '' : 'none';
} else if (column === 'name') {
const statusValue = result["status"] ? result["status"].toLowerCase() : "";
if (result._downstreamHref) {
// Downstream result: link to private json.html via proxy
const link = document.createElement('a');
link.href = result._downstreamHref;
link.textContent = '🔗 ' + value;
link.target = '_blank';
td.classList.add('name-column');
td.appendChild(link);
} else if (
(nest_level === 1 && ["pending", "skipped", "running"].includes(statusValue)) ||
(nest_level > 1 && (!Array.isArray(result.results) || result.results.length === 0) ||
nest_level === 0)
) {
const span = document.createElement('span');
span.textContent = value;
span.classList.add('disabled');
td.classList.add('name-column');
td.appendChild(span);
} else {
const newUrl = new URL(currentUrl);
newUrl.searchParams.set(`name_${nest_level}`, value);
const link = document.createElement('a');
link.href = newUrl.toString();
link.textContent = value;
td.classList.add('name-column');
td.appendChild(link);
}
// Render unified labels: plain span, or <a> if link present.
// The hint (when set) becomes the title attribute -> hover tooltip.
if (labels.length > 0) {
td.appendChild(document.createTextNode(' '));
labels.forEach(({name, link, hint}) => {
const element = link ? document.createElement('a') : document.createElement('span');
if (link) {
element.href = link;
element.target = '_blank';
element.rel = 'noopener noreferrer';
}
applyLabelStyles(element, name);
if (hint) {
element.title = hint;
element.style.cursor = link ? 'pointer' : 'help';
}
td.appendChild(element);
});
}
} else if (column === 'start_time') {
td.textContent = value ? formatTimestamp(value, false) : '';
} else if (column === 'duration') {
td.classList.add('dur-column');
td.textContent = value ? formatDuration(value, true) : '';
}
row.appendChild(td);
});
tbody.appendChild(row);
// unfold all for preview mode (nest_level = 0 in this case)
if (nest_level === 0 && result.results.length > 0) {
toggleInfoRow(row, result.info, result.results, result.links);
}
});
}
/**
* Toggles an additional row below the clicked row to display the "info" text.
*/
function toggleInfoRow(row, infoText, sub_results = [], links = []) {
let nextRow = row.nextElementSibling;
// Check if the next row is already an "info" row
if (nextRow && nextRow.classList.contains('info-row')) {
nextRow.remove(); // If it's there, remove it
closedInfoCnt++;
console.log(`Info row closed. Count: ${openedInfoCnt - closedInfoCnt}. Auto-refresh ${openedInfoCnt - closedInfoCnt > 0 ? 'disabled' : 'enabled'}.`);
} else {
// Otherwise, create a new row for the info text
openedInfoCnt++;
console.log(`Info row opened. Count: ${openedInfoCnt - closedInfoCnt}. Auto-refresh ${openedInfoCnt - closedInfoCnt > 0 ? 'disabled' : 'enabled'}.`);
const infoRow = document.createElement('tr');
infoRow.classList.add('info-row');
const infoCell = document.createElement('td');
infoCell.colSpan = row.children.length; // Make it span the full width
infoCell.classList.add('info-text');
if (infoText) {
const pre = document.createElement('pre');
pre.textContent = infoText;
infoCell.appendChild(pre);
}
// Handle subResults
if (sub_results) {
if (Array.isArray(sub_results) && sub_results.length > 0) {
const table = createResultsTable(sub_results, 0);
if (table) {
infoCell.appendChild(table); // Appends table correctly
}
}
}
// If there are links, create a list of links
if (Array.isArray(links) && links.length > 0) {
const linksContainer = document.createElement('div');
linksContainer.classList.add('file-link');
let base_url = window.location.origin + window.location.pathname;
links.forEach(link => {
let linkElement = document.createElement('a');
linkElement.href = link;
linkElement.target = "_blank"; // Open in a new tab
linkElement.style.display = "block"; // Each link on a new line
if (!link.includes(base_url)) {
// External link: Show file name
linkElement.textContent = link.split('/').pop();
} else {
linkElement.textContent = "Report";
}
linksContainer.appendChild(linkElement);
});
infoCell.appendChild(linksContainer);
}
infoRow.appendChild(infoCell);
row.parentNode.insertBefore(infoRow, row.nextSibling); // Insert below the clicked row
}
}
// When true, populateTableRows skips its own sort-by-status (column header sort takes over)
let columnSortActive = false;
function sortTable(results, column, key, tbody, nest_level, existingColumnsList) {
// Find the table header element for the given key
const tableHeaders = document.querySelectorAll('th');
let th = Array.from(tableHeaders).find(header => header.textContent === key);
if (!th) {
console.error(`No table header found for key: ${key}`);
return;
}
const ascending = th.getAttribute('data-sort-direction') === 'asc';
th.setAttribute('data-sort-direction', ascending ? 'desc' : 'asc');
// Suppress populateTableRows auto-sort until next re-render
columnSortActive = true;
results.sort((a, b) => {
let va = a[column], vb = b[column];
if (column === 'status') {
va = getStatusPriority(va);
vb = getStatusPriority(vb);
}
if (va < vb) return ascending ? -1 : 1;
if (va > vb) return ascending ? 1 : -1;
return 0;
});
// Clear the existing rows in tbody
tbody.innerHTML = '';
// Re-populate the table with sorted data
populateTableRows(tbody, results, nest_level, existingColumnsList);
}
async function fetchJson(path, lastModifiedTime = null, eTag = null) {
try {
// Set headers if lastModifiedTime or eTag are available
const headers = {};
if (lastModifiedTime) headers["If-Modified-Since"] = lastModifiedTime;
if (eTag) headers["If-None-Match"] = eTag;
// Fetch with conditional headers
const response = await fetch(path, {
cache: "no-cache",
headers
});
// If the response is 304 (Not Modified), return existing metadata without fetching new data
if (response.status === 304) {
return { data: null, lastModifiedTime, eTag, updated: false, status: 304 };
} else if (response.status === 403) {
return { data: null, lastModifiedTime: null, eTag: null, updated: true, status: 403 };
}
// Extract headers
const newLastModifiedTime = response.headers.get('Last-Modified');
const newETag = response.headers.get('ETag');
const jsonData = await response.json();
return { data: jsonData, lastModifiedTime: newLastModifiedTime, eTag: newETag, updated: true, status: 200 };
} catch (error) {
console.error('Error loading Results:', error);
return { data: null, lastModifiedTime: null, eTag: null, updated: true, status: 0 };
}
}
const runtimeCache = {
commits: null,
json0: null,
json1: null,
prLink: null,
commitLink: null,
runLink: null,
sha: null,
downstreamResults: null,
downstreamSha: null,
downstreamError: null
};
function storeLink(link) {
if (link.includes('/pull/')) runtimeCache.prLink = link;
else if (link.includes('/commit/')) runtimeCache.commitLink = link;
else if (link.includes('/actions/runs/')) runtimeCache.runLink = link;
}
function getTargetResults(nameParams) {
let resolvedNames = [];
let data = runtimeCache.json0;
if (data !== null) {
resolvedNames.push(nameParams[0]); // Initialize resolvedNames with the first parameter
} else {
return null; // Return an object containing data and resolvedNames
}
// Check if we have more than one name to resolve
if (nameParams.length > 1) {
if (runtimeCache.json1) {
data = runtimeCache.json1; // If json1 exists, use it directly
} else {
let found = false;
// Iterate through data.results to find the target name in json0
for (let result of data.results) {
if (result.name === nameParams[1]) {
resolvedNames.push(nameParams[1]);
data = result;
found = true;
break;
}
}
if (!found) {
console.log("No data for", nameParams[1]);
return data; // Return current data and resolvedNames if no match is found
}
}
// Now resolve remaining names starting from index 2
for (let i = 2; i < nameParams.length; i++) {
let name = nameParams[i];
let found = false;
// Iterate through data.results to find the target name
for (let result of data.results) {
if (result.name === name) {
resolvedNames.push(name);
data = result;
found = true;
break;
}
}
if (!found) {
console.log("No data for", name);
return data; // Return current data and resolvedNames if no match is found
}
}
}
return data; // Return the resolved data and names at the end
}
function fillInNavigationBar(nameArray) {
let baseParams = new URLSearchParams(window.location.search);
let keysToDelete = [];
baseParams.forEach((value, key) => {
if (key.startsWith('name_')) {
keysToDelete.push(key);
}
});
keysToDelete.forEach((key) => baseParams.delete(key));
let pathNames = [];
let pathLinks = [];
for (const [index, name] of nameArray.entries()) {
baseParams.set(`name_${index}`, name);
pathNames.push(name);
pathLinks.push(`<span class="separator">/</span><a href="${window.location.pathname}?${baseParams.toString()}" class="nav-link">${name}</a>`);
}
const footerLeft = document.querySelector('#footer .left');
footerLeft.innerHTML = pathLinks.join('');
}
function renderFooter(nameParams) {
const footerRight = document.querySelector('#footer .right');
footerRight.innerHTML = '';
function createLinkElement(href, textContent, footer) {
const a = document.createElement('a');
a.href = href;
a.textContent = textContent;
a.target = '_blank';
footer.appendChild(a);
}
const result = getTargetResults(nameParams);
if (runtimeCache.prLink) createLinkElement(runtimeCache.prLink, 'PR', footerRight);
if (runtimeCache.commitLink) createLinkElement(runtimeCache.commitLink, 'Commit', footerRight);
const runLink = result?.ext?.run_url || runtimeCache.runLink;
if (runLink) createLinkElement(runLink, 'Run', footerRight);
let i = 1;
for (const name of nameParams) {
if (name === result.name) {
break;
}
i++;
}
fillInNavigationBar(nameParams.slice(0, i + 1))
}
function renderNotificationPanels(container, ext, resultName) {
const kinds = [
{ key: 'errors', cssClass: 'notification-errors', title: 'Errors' },
{ key: 'warnings', cssClass: 'notification-warnings', title: 'Warnings' },
{ key: 'notes', cssClass: 'notification-notes', title: 'Notes' },
];
for (const { key, cssClass, title } of kinds) {
const items = ext?.[key];
if (!Array.isArray(items) || items.length === 0) continue;
// Group entries by message text, collecting unique "from" sources
const grouped = new Map();
for (const item of items) {
const text = item.message || '';
const source = item.from || '';
if (!grouped.has(text)) {
grouped.set(text, []);
}
const sources = grouped.get(text);
if (source && !sources.includes(source)) {
sources.push(source);
}
}
const panel = document.createElement('div');
panel.className = `notification-panel ${cssClass}`;
const titleEl = document.createElement('div');
titleEl.className = 'notification-title';
titleEl.textContent = title;
panel.appendChild(titleEl);
for (const [message, sources] of grouped) {
const row = document.createElement('div');
row.className = 'notification-item';
row.textContent = message;
// Hide source when all sources match the current result name
const externalSources = sources.filter(s => s !== resultName);
if (externalSources.length === 0) {
// all from this result — no label needed
} else if (externalSources.length === 1) {
const span = document.createElement('span');
span.className = 'notification-jobs';
span.textContent = `(${externalSources[0]})`;
row.appendChild(span);
} else {
const span = document.createElement('span');
span.className = 'notification-jobs';
span.textContent = `(${externalSources.length} jobs)`;
span.title = externalSources.join('\n');
row.appendChild(span);
}
panel.appendChild(row);
}
container.appendChild(panel);
}
}
async function renderResults(PR, sha, nameParams) {
const result = getTargetResults(nameParams)
let i = 1;
for (const name of nameParams) {
if (name === result.name) {
break;
}
i++;
}
const resultsDiv = document.getElementById('result-container');
renderNotificationPanels(resultsDiv, result.ext, result.name);
if (result.info) {
const textDiv = document.createElement('div');
textDiv.id = 'info-content';
const info = (result.info || '').replace(/\n/g, '<br>');
textDiv.innerHTML = info.replace('ERROR: ', '<span class="status-error">ERROR:</span> ');
resultsDiv.appendChild(textDiv);
}
// Merge cached downstream results if available and matching current SHA
let resultsData = result.results;
const resolvedSha = (sha === 'latest' && runtimeCache.commits)
? runtimeCache.commits[runtimeCache.commits.length - 1]?.sha
: sha;
if (nameParams.length === 1 && runtimeCache.downstreamResults
&& runtimeCache.downstreamSha === resolvedSha) {
resultsData = [...resultsData, ...runtimeCache.downstreamResults];
}
if (Array.isArray(resultsData) && resultsData.length > 0) {
const table = createResultsTable(resultsData, i);
if (table) {
resultsDiv.appendChild(table);
}
}
addDetailsWidget(runtimeCache, sha, PR)
//addInfoLineToStatus(current_sha);
const originalWorkflowDetails = {
name: result.name,
status: result.status,
start_time: result.start_time,
duration: result.duration
};
let statusWidget = addStatusWidget(result.name, result.status, result.start_time, result.duration);
addLabelsWidget(result.ext);
if (nameParams.length > 1) {
addFileLinksWidget(result.links);
if (nameParams.length === 2) {
addStatisticsWidget(nameParams[1], result.duration * 1000)
}
} else {
addTimeTraceWidget(result.results, statusWidget, originalWorkflowDetails);
}
addStorageUsageChartToStatus(result.ext?.storage_usage)
}
const persistentStorage = {
get updatedAt() {
const value = localStorage.getItem('updatedAt');
return value ? parseInt(value, 10) : null;
},
set updatedAt(timestamp) {
localStorage.setItem('updatedAt', timestamp.toString());
},
get statisticsObject() {
const value = localStorage.getItem('statisticsObject');
return value ? JSON.parse(value) : null;
},
set statisticsObject(obj) {
localStorage.setItem('statisticsObject', JSON.stringify(obj));
},
};
async function fetchStatistics(name) {
const HALF_DAY = 12 * 60 * 60 * 1000;
const now = Date.now();
// Get the first path segment after the domain
const pathParts = window.location.pathname.split('/');
const bucketName = pathParts.length >= 2 ? pathParts[1] : 'default-bucket';
if (
!persistentStorage.updatedAt ||
now > persistentStorage.updatedAt + HALF_DAY
) {
const url = `https://s3.amazonaws.com/${bucketName}/statistics/statistics.json.gz`;
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
persistentStorage.statisticsObject = await response.json();
persistentStorage.updatedAt = now;
} catch (error) {
console.error('Error fetching statistics:', error);
}
} else {
console.log('statistics updated at:', new Date(persistentStorage.updatedAt).toISOString());
}
}
async function fetchData(PR, REF, sha, nameParams, base_url) {
function normalizeTaskName(name) {
return name.toLowerCase()
.replace(/[^a-z0-9]/g, '_')
.replace(/_+/g, '_')
.replace(/_+$/, '');
}
// Use base_url from param if provided, else fallback to old logic
let baseUrl = base_url;
if (!baseUrl) {
baseUrl = window.location.origin + window.location.pathname.split('/').slice(0, -1).join('/');
}
let suffix = '';
if (PR) {
suffix = `PRs/${encodeURIComponent(PR)}`;
} else if (REF) {
suffix = `REFs/${encodeURIComponent(REF)}`;
}
let hasNewData = false;
if (!runtimeCache.commits || sha === 'latest') {
const { data, updated, status } = await fetchJson(`${baseUrl}/${suffix}/commits.json`);
if (status === 403) {
return { error: 403, message: 'Report does not exist or expired' };
}
if (updated) {
runtimeCache.commits = data ? data : [];
hasNewData = true;
}
}
const shaToLoad = (sha === 'latest') ? runtimeCache.commits[runtimeCache.commits.length - 1]?.sha : sha;
const fetchTasks = [];
if (nameParams.length > 1) {
const task1 = normalizeTaskName(nameParams[1]);
const path1 = `${baseUrl}/${suffix}/${encodeURIComponent(shaToLoad)}/result_${task1}.json`;
fetchTasks.push(fetchJson(path1));
}
const task0 = normalizeTaskName(nameParams[0]);
const path0 = `${baseUrl}/${suffix}/${encodeURIComponent(shaToLoad)}/result_${task0}.json`;
fetchTasks.push(fetchJson(path0));
// Run fetchJson calls in parallel
const results = await Promise.all(fetchTasks);
if (nameParams.length > 1) {
const { data, updated, status } = results[0]; // First request result
if (status === 403) {
return { error: 403, message: 'Report does not exist or expired' };
}
if (updated) {
runtimeCache.json1 = data;
hasNewData = true;
}
}
const { data, updated, status } = results[fetchTasks.length - 1]; // Last request result (always task0)
if (status === 403) {
return { error: 403, message: 'Report does not exist or expired' };
}
if (updated) {
runtimeCache.json0 = data;
hasNewData = true;
if (data && data.ext) {
if (data.ext.change_url) storeLink(data.ext.change_url);
if (data.ext.run_url) storeLink(data.ext.run_url);
}
}
return hasNewData;
}
// Fetch downstream (private sync) results via proxy.
// Returns { data: [...results], error: null } or { data: null, error: "message" }
async function fetchDownstreamData(sha, privateBaseUrl) {
// Step 1: Fetch sync metadata
let metaResult;
try {
metaResult = await fetchJson(`${privateBaseUrl}/sync_metadata/${sha}.json`);
} catch (e) {
return { data: null, error: 'Downstream: proxy unreachable' };
}
if (metaResult.status === 0) {
return { data: null, error: 'Downstream: proxy unreachable' };
}
if (metaResult.status === 403 || !metaResult.data) {
return { data: null, error: 'Downstream: no sync metadata for this commit' };
}
const { private_pr_number, private_sha } = metaResult.data;
if (!private_pr_number || !private_sha) {
return { data: null, error: 'Downstream: invalid sync metadata' };
}
// Step 2: Fetch private PR result
let resultResult;
try {
resultResult = await fetchJson(
`${privateBaseUrl}/PRs/${encodeURIComponent(private_pr_number)}/${encodeURIComponent(private_sha)}/result_pr.json`
);
} catch (e) {
return { data: null, error: 'Downstream: proxy unreachable' };
}
if (resultResult.status === 0) {
return { data: null, error: 'Downstream: proxy unreachable' };
}
if (resultResult.status === 403 || !resultResult.data) {
return { data: null, error: 'Downstream: sync PR results not available yet' };
}
const privateResults = resultResult.data.results || [];
// Step 3: Transform results — prefix names, add downstream href, rewrite links
const transformed = privateResults.map(r => {
const originalName = r.name;
const copy = Object.assign({}, r);
copy.name = originalName;
copy._downstreamHref = `${privateBaseUrl}/json.html?PR=${encodeURIComponent(private_pr_number)}&sha=${encodeURIComponent(private_sha)}&name_0=PR&name_1=${encodeURIComponent(originalName)}`;
// Rewrite links: replace origin+bucket prefix with proxy base URL.
// privateBaseUrl is e.g. https://proxy/bucket-name, extract /bucket-name
const bucketPath = new URL(privateBaseUrl).pathname;
if (Array.isArray(copy.links)) {
copy.links = copy.links.map(link => {
const re = new RegExp('^https?://[^/]+' + bucketPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
return link.replace(re, privateBaseUrl);
});
}
return copy;
});
return { data: transformed, error: null };
}
function showDownstreamInfo(message) {
const container = document.getElementById('result-container');
// Remove any existing downstream info bar
const existing = container.querySelector('.downstream-info');
if (existing) existing.remove();
const bar = document.createElement('div');
bar.className = 'downstream-info';
bar.textContent = message;
container.insertBefore(bar, container.firstChild);
}
// Track for autorefresh toggle
let openedInfoCnt = 0;
let closedInfoCnt = 0;
async function renderResultsAuto(PR, REF, sha, nameParams, base_url, privateBaseUrl) {
function clearPage() {
// First, clean up any existing timers and event listeners
activeTimers.clearAll();
activeListeners.removeAll();
// Reset column sort override so toggle takes effect again
columnSortActive = false;
// Then clear the DOM content
document.getElementById('result-container').innerHTML = '';
document.getElementById('status-container').innerHTML = '';
}
async function checkForUpdatesAndRender(force = false) {
// Stop autoreload if info panels are open
if (openedInfoCnt - closedInfoCnt > 0) {
return;
}
// If not forced, only continue autoreload when status is "running" or "pending"
if (!force) {
let shouldContinue = false;
if (nameParams?.length === 1) {
// For single-level results, check json0 status
shouldContinue = ["running", "pending"].includes((runtimeCache?.json0?.status || '').toLowerCase());
} else if (nameParams?.length === 2) {
// For two-level results, check json1 status (or json0 if json1 is null)
if (runtimeCache?.json1) {
shouldContinue = ["running", "pending"].includes((runtimeCache?.json1?.status || '').toLowerCase());
} else {
// If json1 is null, we might still be loading, so continue
shouldContinue = true;
}
}
if (!shouldContinue) {
console.log(`Auto-reload stopped: status is ${runtimeCache?.json0?.status || runtimeCache?.json1?.status || 'unknown'}`);
return;
}
}
const statusContainer = document.getElementById('status-container');
const scrollTop = statusContainer ? statusContainer.scrollTop : 0;
const fetchResult = await fetchData(PR, REF, sha, nameParams, base_url);
// Check if fetchData returned an error
if (fetchResult && typeof fetchResult === 'object' && fetchResult.error === 403) {
const resultsDiv = document.getElementById('result-container');
const textDiv = document.createElement('div');
textDiv.id = 'info-content';
textDiv.style.textAlign = 'center';
textDiv.textContent = fetchResult.message;
resultsDiv.appendChild(textDiv);
return;
}
// Render upstream immediately (+ cached downstream if available)
if (fetchResult) {
clearPage();
await renderResults(PR, sha, nameParams);
if (statusContainer) {
statusContainer.scrollTop = scrollTop;
}
if (runtimeCache.downstreamError) {
showDownstreamInfo(runtimeCache.downstreamError);
}
console.log(`Data refreshed at ${new Date().toLocaleTimeString()}`);
}
// Fetch downstream asynchronously. Only for PRs to main trunk.
// On first load this adds downstream after a brief delay.
// On subsequent refreshes, cached data is already merged above.
if (nameParams.length === 1 && nameParams[0] === 'PR' && privateBaseUrl) {
const resolvedSha = runtimeCache.commits
? ((sha === 'latest')
? runtimeCache.commits[runtimeCache.commits.length - 1]?.sha
: sha)
: sha;
if (resolvedSha) {
fetchDownstreamData(resolvedSha, privateBaseUrl).then(result => {
if (result.error) {
const hadDownstream = runtimeCache.downstreamResults !== null;
runtimeCache.downstreamResults = null;
runtimeCache.downstreamSha = null;
runtimeCache.downstreamError = result.error;
// Re-render to clear stale downstream rows
if (hadDownstream) {
clearPage();
renderResults(PR, sha, nameParams);
}
showDownstreamInfo(result.error);
} else if (result.data && result.data.length > 0) {
runtimeCache.downstreamResults = result.data;
runtimeCache.downstreamSha = resolvedSha;
runtimeCache.downstreamError = null;
// Re-render with merged data (only if not already merged)
if (!document.querySelector('.downstream-row')) {
clearPage();
renderResults(PR, sha, nameParams);
}
} else if (result.data && result.data.length === 0) {
// Empty results — clear any stale cached downstream rows
const hadDownstream = runtimeCache.downstreamResults !== null;
runtimeCache.downstreamResults = null;
runtimeCache.downstreamSha = null;
runtimeCache.downstreamError = null;
if (hadDownstream) {
clearPage();
renderResults(PR, sha, nameParams);
}
}
});
}
}
}
// Initial render
await checkForUpdatesAndRender(true);
// Setup periodic refresh using our timer management
activeTimers.addInterval(setInterval(() => {
checkForUpdatesAndRender(false);
}, 30000));
console.log("Auto-reload started");
}
function updatePageTitle(PR, REF, sha, nameParams) {
let title = "";
if (Array.isArray(nameParams) && nameParams.length > 0) {
title += ` ${nameParams[0]}`;
}
if (Array.isArray(nameParams) && nameParams.length > 1) {
title += `/${nameParams[1]}`;
}
if (PR > 0) {
title += `, PR#${PR}`;
} else {
title += `, sha#${sha.substring(0, 8)}`;
}
document.title = title.trim();
}
async function init() {
const urlParams = new URLSearchParams(window.location.search);
const PR = urlParams.get('PR');
const REF = urlParams.get('REF');
const sha = urlParams.get('sha');
const base_url = urlParams.get('base_url');
// Proxy URL: from URL param (persisted) or localStorage
const proxyParam = urlParams.get('proxy');
if (proxyParam) {
localStorage.setItem('privateBaseUrl', proxyParam.replace(/\/+$/, ''));
}
const privateBaseUrl = localStorage.getItem('privateBaseUrl') || null;
const nameParams = [];
urlParams.forEach((value, key) => {
if (key.startsWith('name_')) {
const index = parseInt(key.split('_')[1], 10);
nameParams[index] = value;
}
});
await renderResultsAuto(PR, REF, sha, nameParams, base_url, privateBaseUrl);
renderFooter(nameParams)
updatePageTitle(PR, REF, sha, nameParams)
fetchStatistics(nameParams[0])
}
document.addEventListener('DOMContentLoaded', async () => {
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'night') {
document.body.classList.add('night-theme');
}
// Set sortByStatus to true by default if not set
if (localStorage.getItem('sortByStatus') === null) {
localStorage.setItem('sortByStatus', 'true');
}
await init();
toggleStatusFilter(true);
updateSortToggleIcon();
updateProxyToggleIcon();
});
function createFavicon() {
const width = 32;
const height = 32;
const lineColor = "rgb(0, 0, 0)";
const lineWidth = 4;
const spaceWidth = 3;
const lineNumber = 4;
// Create a canvas
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
// Set transparent background
ctx.clearRect(0, 0, width, height);
// Draw vertical lines
let xStart = spaceWidth;
for (let i = 0; i < lineNumber; i++) {
let yStart = Math.floor(Math.random() * height); // Random Y start position
ctx.fillStyle = lineColor;
for (let y = 0; y < height - spaceWidth; y++) {
let yPos = (y + yStart) % height;
ctx.fillRect(xStart, yPos, lineWidth, 1); // Draw a 1-pixel high line segment
}
xStart += lineWidth + spaceWidth;
}
// Convert canvas to Data URL (PNG format)
const faviconURL = canvas.toDataURL("image/png");
// Set the favicon dynamically
let link = document.querySelector("link[rel='icon']");
if (!link) {
link = document.createElement("link");
link.rel = "icon";
document.head.appendChild(link);
}
link.href = faviconURL;
}
createFavicon();
</script>
</body>
</html>