diff --git a/src/imgtests/web/static/css/style.css b/src/imgtests/web/static/css/style.css
index 85d91a1c..acc5ab61 100644
--- a/src/imgtests/web/static/css/style.css
+++ b/src/imgtests/web/static/css/style.css
@@ -403,3 +403,32 @@ h1 i {
color: rgba(255, 255, 255, 0.9);
margin: 0;
}
+
+.duration-input-group {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-end;
+ gap: 8px;
+ margin-top: 6px;
+}
+
+.duration-input-field {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ color: #666;
+ font-size: 0.85em;
+}
+
+.duration-input-field input {
+ width: 78px;
+ padding: 4px;
+}
+
+.duration-input-field:first-child input {
+ width: 92px;
+}
+
+.duration-input-error {
+ border: 2px solid #c62828;
+}
diff --git a/src/imgtests/web/static/js/duration.js b/src/imgtests/web/static/js/duration.js
new file mode 100644
index 00000000..0eb89537
--- /dev/null
+++ b/src/imgtests/web/static/js/duration.js
@@ -0,0 +1,140 @@
+(function () {
+ "use strict";
+
+ const units = [
+ { key: "days", label: "Days", max: 9999, multiplier: 86400 },
+ { key: "hours", label: "Hours", max: 23, multiplier: 3600 },
+ { key: "minutes", label: "Minutes", max: 59, multiplier: 60 },
+ { key: "seconds", label: "Seconds", max: 59, multiplier: 1 },
+ ];
+
+ const maxTotalSeconds = units.reduce(
+ (total, unit) => total + unit.max * unit.multiplier,
+ 0,
+ );
+
+ function validatePrefix(prefix) {
+ if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(prefix)) {
+ throw new Error(`Invalid duration input prefix: ${prefix}`);
+ }
+ }
+
+ function normalizeTotalSeconds(totalSeconds) {
+ const parsed = Number.parseInt(totalSeconds, 10);
+ if (!Number.isFinite(parsed) || parsed < 0) {
+ return 0;
+ }
+ return Math.min(parsed, maxTotalSeconds);
+ }
+
+ function split(totalSeconds) {
+ let remaining = normalizeTotalSeconds(totalSeconds);
+ const values = {};
+
+ units.forEach((unit) => {
+ values[unit.key] = Math.floor(remaining / unit.multiplier);
+ remaining %= unit.multiplier;
+ });
+
+ return values;
+ }
+
+ function render(prefix, totalSeconds = 0, disabled = false) {
+ validatePrefix(prefix);
+ const values = split(totalSeconds);
+ const disabledAttribute = disabled ? " disabled" : "";
+
+ const inputs = units
+ .map(
+ (unit) => `
+
+ `,
+ )
+ .join("");
+
+ return `
+
+ ${inputs}
+
+ `;
+ }
+
+ function getComponent(prefix, unit) {
+ const input = document.getElementById(`${prefix}_${unit.key}`);
+ if (!input) {
+ throw new Error(`Duration field ${unit.label.toLowerCase()} was not found.`);
+ }
+
+ const rawValue = input.value.trim();
+ const value = rawValue === "" ? 0 : Number(rawValue);
+ const isValid = Number.isInteger(value) && value >= 0 && value <= unit.max;
+
+ input.classList.toggle("duration-input-error", !isValid);
+ if (!isValid) {
+ input.focus();
+ throw new Error(`${unit.label} must be an integer from 0 to ${unit.max}.`);
+ }
+
+ return value;
+ }
+
+ function toSeconds(prefix, fieldLabel = "Duration") {
+ validatePrefix(prefix);
+ const total = units.reduce(
+ (sum, unit) => sum + getComponent(prefix, unit) * unit.multiplier,
+ 0,
+ );
+
+ if (total === 0) {
+ document.getElementById(`${prefix}_seconds`)?.focus();
+ throw new Error(`${fieldLabel} must be greater than zero.`);
+ }
+
+ return total;
+ }
+
+ function setDisabled(prefix, disabled) {
+ validatePrefix(prefix);
+ units.forEach((unit) => {
+ const input = document.getElementById(`${prefix}_${unit.key}`);
+ if (input) {
+ input.disabled = disabled;
+ }
+ });
+ }
+
+ function format(totalSeconds) {
+ const values = split(totalSeconds);
+ const parts = [];
+
+ if (values.days > 0) parts.push(`${values.days}d`);
+ if (values.hours > 0) parts.push(`${values.hours}h`);
+ if (values.minutes > 0) parts.push(`${values.minutes}m`);
+ if (values.seconds > 0 || parts.length === 0) parts.push(`${values.seconds}s`);
+
+ return parts.join(" ");
+ }
+
+ window.DurationInput = Object.freeze({
+ MAX_TOTAL_SECONDS: maxTotalSeconds,
+ format,
+ render,
+ setDisabled,
+ split,
+ toSeconds,
+ });
+})();
diff --git a/src/imgtests/web/static/js/launch_test.js b/src/imgtests/web/static/js/launch_test.js
index 1d1ddfc9..a19a3900 100644
--- a/src/imgtests/web/static/js/launch_test.js
+++ b/src/imgtests/web/static/js/launch_test.js
@@ -22,11 +22,16 @@ function getSelectedSubsystems() {
function collectSingleConfig() {
return {
- durations: { duration_sec: parseInt(document.getElementById("duration_sec").value, 10) },
+ durations: {
+ duration_sec: DurationInput.toSeconds(
+ "profiled_single_duration",
+ "Profile duration",
+ ),
+ },
profile: document.getElementById("profileSelect").value,
pattern: document.getElementById("profiledPatternSelect").value,
subsystems: getSelectedSubsystems(),
- run_matrix: false
+ run_matrix: false,
};
}
@@ -37,10 +42,13 @@ function collectMatrixConfig() {
document.querySelectorAll('#profilesCheckboxes input[type="checkbox"]').forEach(cb => {
if (cb.checked) {
matrixProfiles.push(cb.value);
- const durInput = document.getElementById(`duration_${cb.value}`);
- if (durInput) {
- durations[`duration_${cb.value}`] = parseInt(durInput.value, 10);
- }
+ const durationPrefix =
+ cb.dataset.durationPrefix ||
+ `profiled_${cb.value}_duration`;
+ durations[`duration_${cb.value}`] = DurationInput.toSeconds(
+ durationPrefix,
+ `${cb.value} profile duration`,
+ );
}
});
@@ -66,12 +74,24 @@ document.getElementById("runTestsBtn").addEventListener("click", function () {
: 1;
let config = null;
- if (testing_mode === "profiled") {
- const conf_mode = document.querySelector('input[name="profiledConfigMode"]:checked').value;
- if (conf_mode === "custom") {
- const runMode = document.querySelector('input[name="profiledRunMode"]:checked').value;
- config = runMode === "single" ? collectSingleConfig() : collectMatrixConfig();
+ try {
+ if (testing_mode === "profiled") {
+ const conf_mode = document.querySelector(
+ 'input[name="profiledConfigMode"]:checked',
+ ).value;
+ if (conf_mode === "custom") {
+ const runMode = document.querySelector(
+ 'input[name="profiledRunMode"]:checked',
+ ).value;
+ config =
+ runMode === "single"
+ ? collectSingleConfig()
+ : collectMatrixConfig();
+ }
}
+ } catch (error) {
+ outputContainer.textContent = "Error: " + error.message;
+ return;
}
btn.disabled = true;
diff --git a/src/imgtests/web/static/js/test_config.js b/src/imgtests/web/static/js/test_config.js
index 79dd4971..2e1e23cb 100644
--- a/src/imgtests/web/static/js/test_config.js
+++ b/src/imgtests/web/static/js/test_config.js
@@ -80,46 +80,54 @@ class TestConfigManager {
console.log("Found radio buttons:", radioButtons.length);
radioButtons.forEach((radio) => {
- radio.addEventListener("change", (e) => {
- console.log("Config mode changed to:", e.target.value);
+ radio.addEventListener("change", (event) => {
+ console.log("Config mode changed to:", event.target.value);
const panel = document.getElementById("customConfigPanel");
if (panel) {
panel.style.display =
- e.target.value === "custom" ? "block" : "none";
- if (e.target.value === "custom" && this.availableSuites) {
+ event.target.value === "custom" ? "block" : "none";
+ if (
+ event.target.value === "custom" &&
+ this.availableSuites
+ ) {
this.renderConfigUI();
}
}
});
- document.querySelectorAll('input[name="profiledConfigMode"]').forEach(radio => {
- radio.addEventListener("change", e => {
- const panel = document.getElementById("profiledCustomPanel");
- if (e.target.value === "custom") {
- panel.style.display = "block";
- this.renderProfilesAndDurationsUI();
- this.renderPatternUI();
- this.renderSubsystemsUI();
- } else {
- panel.style.display = "none";
- }
- });
});
- document.querySelectorAll('input[name="profiledRunMode"]').forEach(radio => {
- radio.addEventListener("change", e => {
- const singlePanel = document.getElementById("singleRunPanel");
- const matrixPanel = document.getElementById("matrixRunPanel");
- if (e.target.value === "single") {
- singlePanel.style.display = "block";
- matrixPanel.style.display = "none";
- } else {
- singlePanel.style.display = "none";
- matrixPanel.style.display = "block";
- }
- this.renderProfilesAndDurationsUI();
+
+ document
+ .querySelectorAll('input[name="profiledConfigMode"]')
+ .forEach((radio) => {
+ radio.addEventListener("change", (event) => {
+ const panel =
+ document.getElementById("profiledCustomPanel");
+ if (event.target.value === "custom") {
+ panel.style.display = "block";
+ this.renderProfilesAndDurationsUI();
+ this.renderPatternUI();
+ this.renderSubsystemsUI();
+ } else {
+ panel.style.display = "none";
+ }
+ });
});
- });
- });
+ document
+ .querySelectorAll('input[name="profiledRunMode"]')
+ .forEach((radio) => {
+ radio.addEventListener("change", (event) => {
+ const singlePanel =
+ document.getElementById("singleRunPanel");
+ const matrixPanel =
+ document.getElementById("matrixRunPanel");
+ const isSingle = event.target.value === "single";
+
+ singlePanel.style.display = isSingle ? "block" : "none";
+ matrixPanel.style.display = isSingle ? "none" : "block";
+ this.renderProfilesAndDurationsUI();
+ });
+ });
const saveBtn = document.getElementById("saveConfigBtn");
if (saveBtn) {
@@ -180,8 +188,10 @@ class TestConfigManager {
this.currentConfig.suite_durations?.[suiteName] ||
suiteInfo.default_duration ||
300;
+ const durationPrefix = `basic_${suiteName}_duration`;
const div = document.createElement("div");
+ div.className = "suite-config-card";
div.style.margin = "15px 0";
div.style.padding = "10px";
div.style.border = "1px solid #e0e0e0";
@@ -193,6 +203,7 @@ class TestConfigManager {
@@ -203,22 +214,20 @@ class TestConfigManager {
- Default duration: ${suiteInfo.default_duration}s |
+ Default duration:
+ ${DurationInput.format(suiteInfo.default_duration)} |
Tests: ${suiteInfo.test_count || 0}
-
-
+
+
Custom duration:
+ ${DurationInput.render(
+ durationPrefix,
+ currentDuration,
+ !isChecked,
+ )}