mirror of
https://github.com/hanzoai/datastore.git
synced 2026-09-26 13:55:07 +00:00
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]>
2528 lines
96 KiB
HTML
2528 lines
96 KiB
HTML
<!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>
|