From 76942898fac753561b89be55d602f09b03813e8e Mon Sep 17 00:00:00 2001 From: mhmdk0 Date: Mon, 27 Jul 2026 02:36:01 +0300 Subject: [PATCH 1/9] enhance Automatic Grant Access allow multiple models/models-benchmarks combinations to run at the same time --- cli/medperf/web_ui/app.py | 11 +- cli/medperf/web_ui/containers/routes.py | 69 ++++++---- .../static/js/containers/container_access.js | 124 ++++++++++++++++-- .../templates/container/container_access.html | 32 ++--- 4 files changed, 170 insertions(+), 66 deletions(-) diff --git a/cli/medperf/web_ui/app.py b/cli/medperf/web_ui/app.py index 3a5f40b92..58fd0f7d9 100644 --- a/cli/medperf/web_ui/app.py +++ b/cli/medperf/web_ui/app.py @@ -91,15 +91,8 @@ def startup_event(): # List of [schemas.Notification] will appear in the notifications tab web_app.state.notifications = [] - # Container auto grant access initial values - web_app.state.model_auto_give_access = { - "running": False, - "worker": None, - "benchmark": 0, - "model": 0, - "emails": "", - "interval": 0, - } + # Dictionary for tracking auto give access status for each model and benchmark combination + web_app.state.model_auto_give_access = {} # Set default UI mode to evaluation on startup, will be updated by NavModeMiddleware on each request based on cookie web_app.state.ui_mode = UI_MODE_EVALUATION diff --git a/cli/medperf/web_ui/containers/routes.py b/cli/medperf/web_ui/containers/routes.py index 7e835a194..8d6f1437f 100644 --- a/cli/medperf/web_ui/containers/routes.py +++ b/cli/medperf/web_ui/containers/routes.py @@ -30,6 +30,27 @@ logger = logging.getLogger(__name__) +def _auto_access_key(model_id: int, benchmark_id: int) -> str: + return f"{model_id}-{benchmark_id}" + + +def _running_auto_access_for_container( + model_auto_give_access: dict, container_id: int +) -> dict: + running = {} + for key, state in model_auto_give_access.items(): + try: + model_id_str, benchmark_id_str = key.split("-", 1) + if int(model_id_str) == container_id: + running[int(benchmark_id_str)] = { + "emails": state["emails"], + "interval": state["interval"], + } + except (ValueError, IndexError): + continue + return running + + @router.get("/ui", response_class=HTMLResponse) def containers_ui( request: Request, @@ -232,6 +253,9 @@ def container_access_ui( "is_owner": is_owner, "benchmarks": benchmarks, "keys": existing_keys, + "running_auto_access": _running_auto_access_for_container( + request.app.state.model_auto_give_access, container_id + ), }, ) @@ -296,12 +320,12 @@ def start_auto_access( emails: str = Form(...), current_user: bool = Depends(check_user_api), ): - if request.app.state.model_auto_give_access["running"]: - bmk = request.app.state.model_auto_give_access["benchmark"] - model = request.app.state.model_auto_give_access["model"] + model_auto_give_access = request.app.state.model_auto_give_access + key = _auto_access_key(model_id, benchmark_id) + if key in model_auto_give_access: return { "status": "failed", - "error": f"Auto give access is already running for benchmark: {bmk}, model: {model}", + "error": "Auto give access is already running for the selected container and benchmark.", } return_response = {"status": "", "error": ""} @@ -313,6 +337,12 @@ def start_auto_access( daemon=True, ) auto_access_worker.start() + model_auto_give_access[key] = { + "worker": auto_access_worker, + "event": event, + "emails": emails, + "interval": interval, + } return_response["status"] = "success" notification_message = "Successfully started automatic grant access." except Exception as exp: @@ -326,16 +356,6 @@ def start_auto_access( return_response=return_response, url=f"/containers/ui/display/{model_id}/access", ) - - request.app.state.model_auto_give_access = { - "running": True, - "worker": auto_access_worker, - "event": event, - "benchmark": benchmark_id, - "model": model_id, - "emails": emails, - "interval": interval, - } return return_response @@ -343,9 +363,12 @@ def start_auto_access( def stop_auto_access( request: Request, model_id: int = Form(...), + benchmark_id: int = Form(...), current_user: bool = Depends(check_user_api), ): - if not request.app.state.model_auto_give_access["running"]: + model_auto_give_access = request.app.state.model_auto_give_access + key = _auto_access_key(model_id, benchmark_id) + if key not in model_auto_give_access: return { "status": "failed", "error": "Auto give access is not started, nothing to stop.", @@ -353,8 +376,9 @@ def stop_auto_access( return_response = {"status": "", "error": ""} try: - request.app.state.model_auto_give_access["event"].set() - request.app.state.model_auto_give_access["worker"].join() + model_auto_give_access[key]["event"].set() + model_auto_give_access[key]["worker"].join() + del model_auto_give_access[key] return_response["status"] = "success" notification_message = "Successfully stopped automatic grant access." except Exception as exp: @@ -368,17 +392,6 @@ def stop_auto_access( return_response=return_response, url=f"/containers/ui/display/{model_id}/access", ) - - request.app.state.model_auto_give_access = { - "running": False, - "worker": None, - "event": None, - "benchmark": 0, - "model": 0, - "emails": "", - "interval": 0, - } - return return_response diff --git a/cli/medperf/web_ui/static/js/containers/container_access.js b/cli/medperf/web_ui/static/js/containers/container_access.js index 27ef5f4ee..8faf96c3e 100644 --- a/cli/medperf/web_ui/static/js/containers/container_access.js +++ b/cli/medperf/web_ui/static/js/containers/container_access.js @@ -19,16 +19,114 @@ function createEmailChip(email, inputElement) { if (inputElement && inputElement.parentNode) inputElement.parentNode.insertBefore(chip, inputElement); } +function clearEmailChips(container) { + if (!container) return; + container.querySelectorAll(".email-chip").forEach(function (chip) { chip.remove(); }); +} + +function setEmailChips(container, emails) { + clearEmailChips(container); + var inputEl = container ? container.querySelector("input") : null; + (emails || []).forEach(function (email) { + email = (email || "").trim(); + if (email) createEmailChip(email, inputEl); + }); +} + function parseEmails(element) { if (!element || !element.getAttribute) return; var raw = element.getAttribute("data-allowed-list") || "[]"; try { var jsonList = JSON.parse(raw); - var inputEl = element.querySelector("input"); - jsonList.forEach(function (email) { createEmailChip(email, inputEl); }); + setEmailChips(element, jsonList); } catch (_) {} } +function parseRunningAutoAccess(panel) { + if (!panel) return {}; + try { + return JSON.parse(panel.getAttribute("data-running-auto-access") || "{}"); + } catch (_) { + return {}; + } +} + +function getSelectedBenchmarkId() { + var benchmarkEl = document.getElementById("benchmark-auto"); + return benchmarkEl && benchmarkEl.value ? benchmarkEl.value : ""; +} + +function getRunningStateForBenchmark(runningAutoAccess, benchmarkId) { + if (!benchmarkId) return null; + return runningAutoAccess[benchmarkId] || null; +} + +function parseStoredEmails(emails) { + if (!emails) return []; + return String(emails).trim().split(/\s+/).filter(Boolean); +} + +function setElementVisible(element, visible) { + if (!element) return; + element.style.display = visible ? "" : "none"; + element.classList.toggle("hidden", !visible); +} + +function updateAutoAccessUI() { + var panel = document.getElementById("auto-access-panel"); + var actionsEl = document.getElementById("auto-access-actions"); + var startBtn = document.getElementById("start-auto-access-btn"); + var stopBtn = document.getElementById("stop-auto-access-btn"); + var runningBadge = document.getElementById("running-badge"); + var benchmarkEl = document.getElementById("benchmark-auto"); + var intervalEl = document.getElementById("interval-auto"); + var emailContainer = document.getElementById("allowed-email-list-auto"); + var emailInput = document.getElementById("email-input-auto"); + var benchmarkId = getSelectedBenchmarkId(); + var runningAutoAccess = parseRunningAutoAccess(panel); + var runningState = getRunningStateForBenchmark(runningAutoAccess, benchmarkId); + var isRunning = Boolean(runningState); + + setElementVisible(startBtn, false); + setElementVisible(stopBtn, false); + setElementVisible(runningBadge, false); + + if (!benchmarkId) { + setElementVisible(actionsEl, false); + if (intervalEl) { + intervalEl.value = "5"; + intervalEl.disabled = true; + } + if (emailInput) emailInput.disabled = true; + if (benchmarkEl) benchmarkEl.disabled = false; + clearEmailChips(emailContainer); + return; + } + + setElementVisible(actionsEl, true); + + if (isRunning) { + if (intervalEl) { + intervalEl.value = runningState.interval || 5; + intervalEl.disabled = true; + } + if (emailInput) emailInput.disabled = true; + if (benchmarkEl) benchmarkEl.disabled = true; + setEmailChips(emailContainer, parseStoredEmails(runningState.emails)); + setElementVisible(stopBtn, true); + setElementVisible(runningBadge, true); + } else { + if (intervalEl) { + intervalEl.value = "5"; + intervalEl.disabled = false; + } + if (emailInput) emailInput.disabled = false; + if (benchmarkEl) benchmarkEl.disabled = false; + clearEmailChips(emailContainer); + setElementVisible(startBtn, true); + } +} + function checkAccessForm() { var allowListArr = getEmailsList(document.getElementById("allowed-email-list")); if (!document.getElementById("benchmark") || !document.getElementById("benchmark").value) { @@ -44,7 +142,7 @@ function checkAccessForm() { function checkAutoAccessForm() { var allowListArr = getEmailsList(document.getElementById("allowed-email-list-auto")); - if (!document.getElementById("benchmark-auto") || !document.getElementById("benchmark-auto").value) { + if (!getSelectedBenchmarkId()) { showErrorToast("Make sure that you've selected a benchmark"); return false; } @@ -63,10 +161,11 @@ function checkAutoAccessForm() { function startAutoGrant(startBtn) { disableElements(".card button, .card input, .card select"); + var panel = document.getElementById("auto-access-panel"); var allowListArr = getEmailsList(document.getElementById("allowed-email-list-auto")); var formData = new FormData(); - formData.append("benchmark_id", document.getElementById("benchmark-auto").value); - formData.append("model_id", startBtn.getAttribute("data-model-id")); + formData.append("benchmark_id", getSelectedBenchmarkId()); + formData.append("model_id", panel ? panel.getAttribute("data-model-id") : ""); formData.append("interval", document.getElementById("interval-auto").value); formData.append("emails", allowListArr.join(" ")); ajaxRequest("/containers/start_auto_access", "POST", formData, function (response) { @@ -77,8 +176,10 @@ function startAutoGrant(startBtn) { function stopAutoGrant(stopBtn) { disableElements(".card button, .card input, .card select"); + var panel = document.getElementById("auto-access-panel"); var formData = new FormData(); - formData.append("model_id", stopBtn.getAttribute("data-model-id")); + formData.append("model_id", panel ? panel.getAttribute("data-model-id") : ""); + formData.append("benchmark_id", getSelectedBenchmarkId()); ajaxRequest("/containers/stop_auto_access", "POST", formData, function (response) { if (response && response.status === "success") showReloadModal({ title: "Successfully Stopped Auto Grant Access", seconds: 2 }); else showErrorModal("Failed to Stop Auto Grant Access", response); @@ -95,7 +196,6 @@ function isValidEmail(email) { function init() { parseEmails(document.getElementById("allowed-email-list")); - parseEmails(document.getElementById("allowed-email-list-auto")); document.querySelectorAll(".email-input").forEach(function (input) { input.addEventListener("keydown", function (e) { if (e.key === "Enter" || e.key === " " || e.key === ",") { @@ -129,12 +229,20 @@ function init() { } }); }); + + var benchmarkAutoEl = document.getElementById("benchmark-auto"); + if (benchmarkAutoEl) benchmarkAutoEl.addEventListener("change", updateAutoAccessUI); + var startBtn = document.getElementById("start-auto-access-btn"); if (startBtn) startBtn.addEventListener("click", function (e) { if (checkAutoAccessForm()) showConfirmModal(e.currentTarget, startAutoGrant, "start automatic grant access for the selected benchmark?"); }); var stopBtn = document.getElementById("stop-auto-access-btn"); - if (stopBtn) stopBtn.addEventListener("click", function (e) { showConfirmModal(e.currentTarget, stopAutoGrant, "stop automatic grant access?"); }); + if (stopBtn) stopBtn.addEventListener("click", function (e) { + showConfirmModal(e.currentTarget, stopAutoGrant, "stop automatic grant access?"); + }); + + updateAutoAccessUI(); } if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init); else init(); diff --git a/cli/medperf/web_ui/templates/container/container_access.html b/cli/medperf/web_ui/templates/container/container_access.html index e9322882c..3d7a17a0f 100644 --- a/cli/medperf/web_ui/templates/container/container_access.html +++ b/cli/medperf/web_ui/templates/container/container_access.html @@ -3,14 +3,6 @@ {% block title %}Manage Container Access{% endblock %} {% block detail_panel %} -{% set auto_access_running = request.app.state.model_auto_give_access["running"] %} -{% set auto_access_for_this_model = auto_access_running and request.app.state.model_auto_give_access["model"] == entity.id %} -{% if auto_access_for_this_model %} - {% set selected_benchmark = request.app.state.model_auto_give_access["benchmark"] %} - {% set selected_interval = request.app.state.model_auto_give_access["interval"] %} - {% set selected_emails = request.app.state.model_auto_give_access["emails"] %} -{% endif %} -
← Back to Container Details

Manage Access | {{ entity_name }}

@@ -46,17 +38,18 @@

Grant Access

-
+

Automatic Access

Automatically grant access to new data owners associated to a benchmark.

+
- {% if benchmarks %} {% for benchmark in benchmarks %} - + {% endfor %} {% else %} @@ -65,22 +58,19 @@

Automatic Access

- +
- -
- {% if auto_access_for_this_model %} - - Running - {% else %} - - {% endif %} +
From 203732664395d78f307c57dce4ebb18954b52e8f Mon Sep 17 00:00:00 2001 From: mhmdk0 Date: Mon, 27 Jul 2026 09:52:31 +0300 Subject: [PATCH 2/9] fix Auto grant access worker --- cli/medperf/web_ui/containers/routes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/medperf/web_ui/containers/routes.py b/cli/medperf/web_ui/containers/routes.py index 8d6f1437f..ace4cf051 100644 --- a/cli/medperf/web_ui/containers/routes.py +++ b/cli/medperf/web_ui/containers/routes.py @@ -302,8 +302,8 @@ def grant_access_worker( GrantAccess.run( benchmark_id=benchmark_id, model_id=model_id, - emails=emails, approved=True, + allowed_emails=emails, ) except Exception: pass From 5904b420cf8b48a0a552ca23fd773e697efc9a01 Mon Sep 17 00:00:00 2001 From: mhmdk0 Date: Thu, 6 Aug 2026 23:29:18 +0300 Subject: [PATCH 3/9] Allow empty allowlist for association policy, grant access, and committee members --- .../benchmark/update_associations_poilcy.py | 16 ++++++++++++ cli/medperf/commands/mlcube/grant_access.py | 7 ++++- .../commands/mlcube/test_grant_access.py | 8 +++--- cli/medperf/web_ui/benchmarks/routes.py | 14 +++++++--- cli/medperf/web_ui/containers/routes.py | 4 +-- .../static/js/benchmarks/benchmark_detail.js | 26 +++++++++---------- .../static/js/containers/container_access.js | 21 +++++++-------- 7 files changed, 63 insertions(+), 33 deletions(-) diff --git a/cli/medperf/commands/benchmark/update_associations_poilcy.py b/cli/medperf/commands/benchmark/update_associations_poilcy.py index 37108334d..8ac1e8211 100644 --- a/cli/medperf/commands/benchmark/update_associations_poilcy.py +++ b/cli/medperf/commands/benchmark/update_associations_poilcy.py @@ -112,6 +112,21 @@ def validate_emails(self): if self.model_emails is not None: self.model_emails = validate_and_normalize_emails(self.model_emails) + def warn_if_empty_allow_list(self): + # self.dataset_emails is None means the field wasn't part of this + # submission at all (existing allow list, if any, stays untouched); + # only an explicitly-submitted empty list ([]) warrants a warning. + if self.dataset_mode == "ALLOWLIST" and self.dataset_emails == []: + config.ui.print_warning( + "Dataset allow list is empty: no dataset associations will be " + "auto-approved until you add emails to the allow list." + ) + if self.model_mode == "ALLOWLIST" and self.model_emails == []: + config.ui.print_warning( + "Model allow list is empty: no model associations will be " + "auto-approved until you add emails to the allow list." + ) + def update(self): if all( [ @@ -122,6 +137,7 @@ def update(self): ] ): return + self.warn_if_empty_allow_list() body = {} if self.dataset_emails is not None: body["dataset_auto_approval_allow_list"] = self.dataset_emails diff --git a/cli/medperf/commands/mlcube/grant_access.py b/cli/medperf/commands/mlcube/grant_access.py index 131b60db7..46696ab61 100644 --- a/cli/medperf/commands/mlcube/grant_access.py +++ b/cli/medperf/commands/mlcube/grant_access.py @@ -61,6 +61,11 @@ def get_approval(self): f"registered in Benchmark (UID: {self.benchmark_id}) access to " "your Model.\n" ) + if not self.allowed_emails: + msg += ( + "No allow list was provided, so ALL eligible Data Owners will " + "be granted access (no email filtering).\n" + ) if not self.approved and not approval_prompt(msg): raise CleanExit("Access granting operation cancelled") @@ -104,7 +109,7 @@ def prepare_certificates_list(self): self.cert_user_info = cert_user_info def filter_certificates(self): - if self.allowed_emails is None: + if not self.allowed_emails: return logging.debug("Filtering certificates based on allowed emails list") filtered_certificates = [] diff --git a/cli/medperf/tests/commands/mlcube/test_grant_access.py b/cli/medperf/tests/commands/mlcube/test_grant_access.py index bb6367d72..c62aaa876 100644 --- a/cli/medperf/tests/commands/mlcube/test_grant_access.py +++ b/cli/medperf/tests/commands/mlcube/test_grant_access.py @@ -217,9 +217,11 @@ def test_filter_certificates_when_empty(grantaccess): } grantaccess.allowed_emails = [] - # Act & Assert - with pytest.raises(CleanExit): - grantaccess.filter_certificates() + # Act + grantaccess.filter_certificates() + + # Assert + assert [cert.id for cert in grantaccess.certificates] == [1, 2] def test_verify_certificates_filters_invalid_certs(mocker, grantaccess): diff --git a/cli/medperf/web_ui/benchmarks/routes.py b/cli/medperf/web_ui/benchmarks/routes.py index 8b18f292a..87c7bc041 100644 --- a/cli/medperf/web_ui/benchmarks/routes.py +++ b/cli/medperf/web_ui/benchmarks/routes.py @@ -1,5 +1,7 @@ import logging +import anyio + from fastapi import APIRouter, Depends, Form from fastapi.responses import HTMLResponse, JSONResponse from fastapi import Request @@ -326,11 +328,17 @@ def update_associations_policy( request: Request, benchmark_id: int = Form(...), dataset_mode: Optional[str] = Form(None), - dataset_emails: Optional[str] = Form(None), model_mode: Optional[str] = Form(None), - model_emails: Optional[str] = Form(None), current_user: bool = Depends(check_user_api), ): + # dataset_emails/model_emails are read from the raw form instead of via + # FastAPI's Form(None): an empty-string value there is indistinguishable + # from the field being absent, which breaks "explicitly clear the allow + # list" (as opposed to "the field wasn't part of this submission at all"). + form_data = anyio.from_thread.run(lambda: request.form()) + dataset_emails = form_data.get("dataset_emails") + model_emails = form_data.get("model_emails") + initialize_state_task(request, task_name="update_associations_policy") return_response = {"status": "", "error": ""} try: @@ -363,7 +371,7 @@ def update_associations_policy( def update_committee_members( request: Request, benchmark_id: int = Form(...), - committee_emails: Optional[str] = Form(None), + committee_emails: str = Form(""), current_user: bool = Depends(check_user_api), ): initialize_state_task(request, task_name="update_committee_members") diff --git a/cli/medperf/web_ui/containers/routes.py b/cli/medperf/web_ui/containers/routes.py index ace4cf051..7494e06ea 100644 --- a/cli/medperf/web_ui/containers/routes.py +++ b/cli/medperf/web_ui/containers/routes.py @@ -265,7 +265,7 @@ def grant_access( request: Request, benchmark_id: int = Form(...), model_id: int = Form(...), - emails: str = Form(...), + emails: str = Form(""), current_user: bool = Depends(check_user_api), ): @@ -317,7 +317,7 @@ def start_auto_access( benchmark_id: int = Form(...), model_id: int = Form(...), interval: int = Form(...), - emails: str = Form(...), + emails: str = Form(""), current_user: bool = Depends(check_user_api), ): model_auto_give_access = request.app.state.model_auto_give_access diff --git a/cli/medperf/web_ui/static/js/benchmarks/benchmark_detail.js b/cli/medperf/web_ui/static/js/benchmarks/benchmark_detail.js index 73919b752..095da2f1d 100644 --- a/cli/medperf/web_ui/static/js/benchmarks/benchmark_detail.js +++ b/cli/medperf/web_ui/static/js/benchmarks/benchmark_detail.js @@ -48,24 +48,23 @@ function showErrorToast(message) { showToast("Validation Error", message, "text-bg-danger"); } -function checkUpdateAssociationsPolicyForm() { +function buildAssociationsPolicyConfirmMessage(message) { var datasetModeEl = document.getElementById("dataset-auto-approve-mode"); var modelModeEl = document.getElementById("model-auto-approve-mode"); - var datasetApproveMode = datasetModeEl ? datasetModeEl.value : "NEVER"; - var modelApproveMode = modelModeEl ? modelModeEl.value : "NEVER"; - var isDatasetValid = (datasetApproveMode === "NEVER" || datasetApproveMode === "ALWAYS"); - var isModelValid = (modelApproveMode === "NEVER" || modelApproveMode === "ALWAYS"); - if (!isDatasetValid) { + var warnings = []; + if (datasetModeEl && datasetModeEl.value === "ALLOWLIST") { var datasetAllowListArr = getEmailsList(document.getElementById("dataset-allow-list-emails")); - if (datasetAllowListArr.length) isDatasetValid = true; - else showErrorToast("Make sure that the dataset allow list is not empty"); + if (!datasetAllowListArr.length) warnings.push("dataset"); } - if (!isModelValid) { + if (modelModeEl && modelModeEl.value === "ALLOWLIST") { var modelAllowListArr = getEmailsList(document.getElementById("model-allow-list-emails")); - if (modelAllowListArr.length) isModelValid = true; - else showErrorToast("Make sure that the model allow list is not empty"); + if (!modelAllowListArr.length) warnings.push("model"); } - return isDatasetValid && isModelValid; + if (!warnings.length) return message; + var listWord = warnings.length > 1 ? "allow lists are" : "allow list is"; + return message + " Note: the " + warnings.join(" and ") + " " + listWord + " empty " + + "- no " + warnings.join("/") + " associations will be auto-approved until " + + "you add emails."; } function onUpdateAssociationsPolicySuccess(response) { @@ -168,7 +167,8 @@ function initBenchmarkDetail() { var savePolicyBtn = document.getElementById("save-policy-btn"); if (savePolicyBtn) savePolicyBtn.addEventListener("click", function (e) { - if (checkUpdateAssociationsPolicyForm()) showConfirmModal(e.currentTarget, updateAssociationsPolicy, "update benchmark associations policy?"); + var message = buildAssociationsPolicyConfirmMessage("update benchmark associations policy?"); + showConfirmModal(e.currentTarget, updateAssociationsPolicy, message); }); var saveCommitteeBtn = document.getElementById("save-committee-members-btn"); diff --git a/cli/medperf/web_ui/static/js/containers/container_access.js b/cli/medperf/web_ui/static/js/containers/container_access.js index 8faf96c3e..21dd07a11 100644 --- a/cli/medperf/web_ui/static/js/containers/container_access.js +++ b/cli/medperf/web_ui/static/js/containers/container_access.js @@ -128,20 +128,14 @@ function updateAutoAccessUI() { } function checkAccessForm() { - var allowListArr = getEmailsList(document.getElementById("allowed-email-list")); if (!document.getElementById("benchmark") || !document.getElementById("benchmark").value) { showErrorToast("Make sure that you've selected a benchmark"); return false; } - if (!allowListArr.length) { - showErrorToast("Make sure that the email allow list is not empty"); - return false; - } return true; } function checkAutoAccessForm() { - var allowListArr = getEmailsList(document.getElementById("allowed-email-list-auto")); if (!getSelectedBenchmarkId()) { showErrorToast("Make sure that you've selected a benchmark"); return false; @@ -152,13 +146,15 @@ function checkAutoAccessForm() { showErrorToast("Make sure that the time interval is between 5 and 60 (inclusive)"); return false; } - if (!allowListArr.length) { - showErrorToast("Make sure that the email allow list is not empty"); - return false; - } return true; } +function emptyAllowListWarning(allowListArr, message) { + if (allowListArr.length) return message; + return message + " Note: no emails were added - this will grant access to ALL " + + "eligible data owners, with no email filtering."; +} + function startAutoGrant(startBtn) { disableElements(".card button, .card input, .card select"); var panel = document.getElementById("auto-access-panel"); @@ -235,7 +231,10 @@ function init() { var startBtn = document.getElementById("start-auto-access-btn"); if (startBtn) startBtn.addEventListener("click", function (e) { - if (checkAutoAccessForm()) showConfirmModal(e.currentTarget, startAutoGrant, "start automatic grant access for the selected benchmark?"); + if (!checkAutoAccessForm()) return; + var allowListArr = getEmailsList(document.getElementById("allowed-email-list-auto")); + var message = emptyAllowListWarning(allowListArr, "start automatic grant access for the selected benchmark?"); + showConfirmModal(e.currentTarget, startAutoGrant, message); }); var stopBtn = document.getElementById("stop-auto-access-btn"); if (stopBtn) stopBtn.addEventListener("click", function (e) { From ff88633f9a219c839ece23437937eea3823e0562 Mon Sep 17 00:00:00 2001 From: mhmdk0 Date: Thu, 6 Aug 2026 23:49:45 +0300 Subject: [PATCH 4/9] fixes after merging main --- cli/medperf/web_ui/containers/routes.py | 12 +++++++++--- .../web_ui/static/js/containers/container_access.js | 4 ---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/cli/medperf/web_ui/containers/routes.py b/cli/medperf/web_ui/containers/routes.py index 0d868eb0d..93dc128cf 100644 --- a/cli/medperf/web_ui/containers/routes.py +++ b/cli/medperf/web_ui/containers/routes.py @@ -240,6 +240,13 @@ def container_access_ui( if cert_id in certs_mapping: existing_keys[key_id] = certs_mapping[cert_id] + running_auto_access = _running_auto_access_for_container( + request.app.state.model_auto_give_access, container_id + ) + running_benchmark_names = [ + Benchmark.get(benchmark_id).name for benchmark_id in running_auto_access + ] + return templates.TemplateResponse( "container/container_access.html", { @@ -249,9 +256,8 @@ def container_access_ui( "is_owner": is_owner, "benchmark_allowed_ids": benchmark_allowed_ids, "keys": existing_keys, - "running_auto_access": _running_auto_access_for_container( - request.app.state.model_auto_give_access, container_id - ), + "running_auto_access": running_auto_access, + "running_benchmark_names": running_benchmark_names, }, ) diff --git a/cli/medperf/web_ui/static/js/containers/container_access.js b/cli/medperf/web_ui/static/js/containers/container_access.js index 21dd07a11..5af2cf611 100644 --- a/cli/medperf/web_ui/static/js/containers/container_access.js +++ b/cli/medperf/web_ui/static/js/containers/container_access.js @@ -78,7 +78,6 @@ function updateAutoAccessUI() { var startBtn = document.getElementById("start-auto-access-btn"); var stopBtn = document.getElementById("stop-auto-access-btn"); var runningBadge = document.getElementById("running-badge"); - var benchmarkEl = document.getElementById("benchmark-auto"); var intervalEl = document.getElementById("interval-auto"); var emailContainer = document.getElementById("allowed-email-list-auto"); var emailInput = document.getElementById("email-input-auto"); @@ -98,7 +97,6 @@ function updateAutoAccessUI() { intervalEl.disabled = true; } if (emailInput) emailInput.disabled = true; - if (benchmarkEl) benchmarkEl.disabled = false; clearEmailChips(emailContainer); return; } @@ -111,7 +109,6 @@ function updateAutoAccessUI() { intervalEl.disabled = true; } if (emailInput) emailInput.disabled = true; - if (benchmarkEl) benchmarkEl.disabled = true; setEmailChips(emailContainer, parseStoredEmails(runningState.emails)); setElementVisible(stopBtn, true); setElementVisible(runningBadge, true); @@ -121,7 +118,6 @@ function updateAutoAccessUI() { intervalEl.disabled = false; } if (emailInput) emailInput.disabled = false; - if (benchmarkEl) benchmarkEl.disabled = false; clearEmailChips(emailContainer); setElementVisible(startBtn, true); } From 46e88d49e8800ed546f0c32bebd465ec45bfbcde Mon Sep 17 00:00:00 2001 From: mhmdk0 Date: Mon, 24 Aug 2026 01:57:40 +0300 Subject: [PATCH 5/9] make warnings in bold --- cli/medperf/web_ui/static/js/benchmarks/benchmark_detail.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/medperf/web_ui/static/js/benchmarks/benchmark_detail.js b/cli/medperf/web_ui/static/js/benchmarks/benchmark_detail.js index 095da2f1d..47f0d7a7c 100644 --- a/cli/medperf/web_ui/static/js/benchmarks/benchmark_detail.js +++ b/cli/medperf/web_ui/static/js/benchmarks/benchmark_detail.js @@ -62,9 +62,9 @@ function buildAssociationsPolicyConfirmMessage(message) { } if (!warnings.length) return message; var listWord = warnings.length > 1 ? "allow lists are" : "allow list is"; - return message + " Note: the " + warnings.join(" and ") + " " + listWord + " empty " + + return message + " Note: the " + warnings.join(" and ") + " " + listWord + " empty " + "- no " + warnings.join("/") + " associations will be auto-approved until " + - "you add emails."; + "you add emails."; } function onUpdateAssociationsPolicySuccess(response) { From ce5b8e653c3a73ff4a80c1e9698cd66805ae1cc2 Mon Sep 17 00:00:00 2001 From: mhmdk0 Date: Mon, 24 Aug 2026 02:03:53 +0300 Subject: [PATCH 6/9] implement silenced mode in webui task logs, save auto grant access logs with no interfere with foreground task logs add view logs and make bold warnings in container_access --- cli/medperf/ui/web_ui.py | 51 ++++++++++++++- cli/medperf/web_ui/common.py | 1 + cli/medperf/web_ui/containers/routes.py | 62 +++++++++++++++---- .../static/js/containers/container_access.js | 26 +++++++- .../templates/container/container_access.html | 9 ++- 5 files changed, 131 insertions(+), 18 deletions(-) diff --git a/cli/medperf/ui/web_ui.py b/cli/medperf/ui/web_ui.py index d5ee40578..dd7f8038a 100644 --- a/cli/medperf/ui/web_ui.py +++ b/cli/medperf/ui/web_ui.py @@ -1,3 +1,4 @@ +import threading from queue import Queue from contextlib import contextmanager from yaspin import yaspin @@ -16,6 +17,39 @@ def __init__(self): self.task_id = None self.events_manager = EventsManager() self.global_events_manager = GlobalEventsManager() + self._silenced = threading.local() + + @property + def _is_silenced(self) -> bool: + return getattr(self._silenced, "value", False) + + @property + def _captured_messages(self): + return getattr(self._silenced, "captured", None) + + @contextmanager + def capture(self): + """Run a block without emitting task-log events or touching the + shared spinner/interactive state, scoped to the calling thread only, + collecting any messages that would have been emitted instead. + + Used by background workers (e.g. the auto grant access worker) that + call into commands reporting progress via this UI, so they don't + get attributed to - or interrupt - whichever foreground task + currently owns the task-log stream, while still keeping a record of + what happened. Callers not interested in the messages can simply + ignore the yielded list. + + Yields: + list[str]: messages emitted during the block, in arrival order. + """ + self._silenced.value = True + self._silenced.captured = [] + try: + yield self._silenced.captured + finally: + self._silenced.captured = None + self._silenced.value = False def print_error(self, msg: str): """Display an error message on the command line @@ -37,6 +71,12 @@ def print_warning(self, msg: str): self._print(msg, "warning") def _print(self, msg: str = "", type: str = "print"): + if self._is_silenced: + captured = self._captured_messages + if captured is not None: + captured.append(msg) + return + if self.is_interactive: self.spinner.write(msg) else: @@ -70,7 +110,10 @@ def interactive(self): Yields: CLI: Yields the current CLI instance with an interactive session initialized """ - if self.is_interactive: + if self._is_silenced: + # don't touch the shared spinner/interactive state + yield self + elif self.is_interactive: # if already interactive, do nothing yield self else: @@ -97,6 +140,12 @@ def text(self, msg: str = ""): # if not self.is_interactive: # self.print(msg) + if self._is_silenced: + captured = self._captured_messages + if captured is not None: + captured.append(msg) + return + self.set_event( Event( task_id=self.task_id, diff --git a/cli/medperf/web_ui/common.py b/cli/medperf/web_ui/common.py index 6b0dd53d5..9cfa81e12 100644 --- a/cli/medperf/web_ui/common.py +++ b/cli/medperf/web_ui/common.py @@ -39,6 +39,7 @@ "/current_task", "/api/running_tasks", "/api/stop_task", + "/containers/auto_access_logs", ] diff --git a/cli/medperf/web_ui/containers/routes.py b/cli/medperf/web_ui/containers/routes.py index 93dc128cf..11fc8260d 100644 --- a/cli/medperf/web_ui/containers/routes.py +++ b/cli/medperf/web_ui/containers/routes.py @@ -1,5 +1,7 @@ import logging import threading +from collections import deque +from datetime import datetime from typing import Optional from fastapi import APIRouter, Depends, Form, Request @@ -35,6 +37,11 @@ def _auto_access_key(model_id: int, benchmark_id: int) -> str: return f"{model_id}-{benchmark_id}" +def _format_auto_access_log_line(message: str) -> str: + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + return f"[{timestamp}] {message}" + + def _running_auto_access_for_container( model_auto_give_access: dict, container_id: int ) -> dict: @@ -44,6 +51,7 @@ def _running_auto_access_for_container( model_id_str, benchmark_id_str = key.split("-", 1) if int(model_id_str) == container_id: running[int(benchmark_id_str)] = { + "name": state["name"], "emails": state["emails"], "interval": state["interval"], } @@ -243,8 +251,9 @@ def container_access_ui( running_auto_access = _running_auto_access_for_container( request.app.state.model_auto_give_access, container_id ) - running_benchmark_names = [ - Benchmark.get(benchmark_id).name for benchmark_id in running_auto_access + running_benchmarks = [ + {"id": benchmark_id, "name": state["name"]} + for benchmark_id, state in running_auto_access.items() ] return templates.TemplateResponse( @@ -257,7 +266,7 @@ def container_access_ui( "benchmark_allowed_ids": benchmark_allowed_ids, "keys": existing_keys, "running_auto_access": running_auto_access, - "running_benchmark_names": running_benchmark_names, + "running_benchmarks": running_benchmarks, }, ) @@ -296,19 +305,23 @@ def grant_access( def grant_access_worker( - benchmark_id, model_id, emails, interval, stop_event: threading.Event + benchmark_id, model_id, emails, interval, stop_event: threading.Event, logs: deque ): interval_in_seconds = interval * 60 while not stop_event.is_set(): try: - GrantAccess.run( - benchmark_id=benchmark_id, - model_id=model_id, - approved=True, - allowed_emails=emails, - ) - except Exception: - pass + with config.ui.capture() as messages: + GrantAccess.run( + benchmark_id=benchmark_id, + model_id=model_id, + approved=True, + allowed_emails=emails, + ) + except Exception as exp: + messages.append(f"Error: {exp}") + logger.exception(exp) + for message in messages: + logs.append(_format_auto_access_log_line(message)) if stop_event.wait(interval_in_seconds): break @@ -332,18 +345,22 @@ def start_auto_access( return_response = {"status": "", "error": ""} try: + benchmark_name = Benchmark.get(benchmark_id).name event = threading.Event() + logs = deque(maxlen=config.webui_max_log_messages) auto_access_worker = threading.Thread( target=grant_access_worker, - args=(benchmark_id, model_id, emails, interval, event), + args=(benchmark_id, model_id, emails, interval, event, logs), daemon=True, ) auto_access_worker.start() model_auto_give_access[key] = { "worker": auto_access_worker, "event": event, + "name": benchmark_name, "emails": emails, "interval": interval, + "logs": logs, } return_response["status"] = "success" notification_message = "Successfully started automatic grant access." @@ -397,6 +414,25 @@ def stop_auto_access( return return_response +@router.get("/auto_access_logs", response_class=JSONResponse) +def auto_access_logs( + request: Request, + model_id: int, + benchmark_id: int, + current_user: bool = Depends(check_user_api), +): + model_auto_give_access = request.app.state.model_auto_give_access + key = _auto_access_key(model_id, benchmark_id) + if key not in model_auto_give_access: + return { + "status": "failed", + "error": "Auto give access is not running for the selected container and benchmark.", + "logs": [], + } + + return {"status": "success", "error": "", "logs": list(model_auto_give_access[key]["logs"])} + + @router.post("/revoke_user_access", response_class=JSONResponse) def revoke_user_access( request: Request, diff --git a/cli/medperf/web_ui/static/js/containers/container_access.js b/cli/medperf/web_ui/static/js/containers/container_access.js index 5af2cf611..c71d50e35 100644 --- a/cli/medperf/web_ui/static/js/containers/container_access.js +++ b/cli/medperf/web_ui/static/js/containers/container_access.js @@ -77,6 +77,7 @@ function updateAutoAccessUI() { var actionsEl = document.getElementById("auto-access-actions"); var startBtn = document.getElementById("start-auto-access-btn"); var stopBtn = document.getElementById("stop-auto-access-btn"); + var viewLogsBtn = document.getElementById("view-auto-access-logs-btn"); var runningBadge = document.getElementById("running-badge"); var intervalEl = document.getElementById("interval-auto"); var emailContainer = document.getElementById("allowed-email-list-auto"); @@ -88,6 +89,7 @@ function updateAutoAccessUI() { setElementVisible(startBtn, false); setElementVisible(stopBtn, false); + setElementVisible(viewLogsBtn, false); setElementVisible(runningBadge, false); if (!benchmarkId) { @@ -111,6 +113,7 @@ function updateAutoAccessUI() { if (emailInput) emailInput.disabled = true; setEmailChips(emailContainer, parseStoredEmails(runningState.emails)); setElementVisible(stopBtn, true); + setElementVisible(viewLogsBtn, true); setElementVisible(runningBadge, true); } else { if (intervalEl) { @@ -147,8 +150,8 @@ function checkAutoAccessForm() { function emptyAllowListWarning(allowListArr, message) { if (allowListArr.length) return message; - return message + " Note: no emails were added - this will grant access to ALL " + - "eligible data owners, with no email filtering."; + return message + " Note: no emails were added - this will grant access to ALL " + + "eligible data owners, with no email filtering."; } function startAutoGrant(startBtn) { @@ -178,6 +181,23 @@ function stopAutoGrant(stopBtn) { }, "Failed to stop auto grant access"); } +function viewAutoAccessLogs() { + var panel = document.getElementById("auto-access-panel"); + var modelId = panel ? panel.getAttribute("data-model-id") : ""; + var benchmarkId = getSelectedBenchmarkId(); + var url = "/containers/auto_access_logs?model_id=" + encodeURIComponent(modelId) + + "&benchmark_id=" + encodeURIComponent(benchmarkId); + ajaxRequest(url, "GET", null, function (response) { + var logs = (response && response.logs) || []; + var body = logs.length + ? "
" +
+                logs.map(function (line) { return escapeHtml(cleanMsg(line)); }).join("\n") + "
" + : "

No logs recorded yet.

"; + var footer = ""; + showModal({ title: "Automatic Grant Access Logs", body: body, footer: footer, modalClasses: "max-w-2xl" }); + }, "Failed to fetch automatic grant access logs"); +} + function showErrorToast(message) { showToast("Validation Error", message, "text-bg-danger"); } @@ -236,6 +256,8 @@ function init() { if (stopBtn) stopBtn.addEventListener("click", function (e) { showConfirmModal(e.currentTarget, stopAutoGrant, "stop automatic grant access?"); }); + var viewLogsBtn = document.getElementById("view-auto-access-logs-btn"); + if (viewLogsBtn) viewLogsBtn.addEventListener("click", viewAutoAccessLogs); updateAutoAccessUI(); } diff --git a/cli/medperf/web_ui/templates/container/container_access.html b/cli/medperf/web_ui/templates/container/container_access.html index 13e571e95..8d1b513bf 100644 --- a/cli/medperf/web_ui/templates/container/container_access.html +++ b/cli/medperf/web_ui/templates/container/container_access.html @@ -39,8 +39,12 @@

Grant Access

Automatic Access

Automatically grant access to new data owners associated to a benchmark.

- {% if running_benchmark_names %} -

Currently running for: {{ running_benchmark_names | join(", ") }}

+ {% if running_benchmarks %} +

Currently running for: + {% for benchmark in running_benchmarks %} + {{ benchmark.name }}{% if not loop.last %}, {% endif %} + {% endfor %} +

{% endif %}
@@ -69,6 +73,7 @@

Automatic Access

From bd196f31c270cf839f4e260c40e788a624b95bbc Mon Sep 17 00:00:00 2001 From: mhmdk0 Date: Mon, 24 Aug 2026 05:47:08 +0300 Subject: [PATCH 7/9] add a note in contianer_access page for benchmark selection --- cli/medperf/web_ui/templates/container/container_access.html | 1 + 1 file changed, 1 insertion(+) diff --git a/cli/medperf/web_ui/templates/container/container_access.html b/cli/medperf/web_ui/templates/container/container_access.html index 8d1b513bf..1f460a036 100644 --- a/cli/medperf/web_ui/templates/container/container_access.html +++ b/cli/medperf/web_ui/templates/container/container_access.html @@ -45,6 +45,7 @@

Automatic Access

{{ benchmark.name }}{% if not loop.last %}, {% endif %} {% endfor %}

+

Note: select the benchmark below to stop it or view its logs.

{% endif %}
From b9bb8e98e690ee0041554714058197ccbe456c55 Mon Sep 17 00:00:00 2001 From: mhmdk0 Date: Mon, 24 Aug 2026 05:50:29 +0300 Subject: [PATCH 8/9] prevent logging out if auto grant access is running - webui --- cli/medperf/web_ui/medperf_login.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cli/medperf/web_ui/medperf_login.py b/cli/medperf/web_ui/medperf_login.py index ba448a550..ed9403b45 100644 --- a/cli/medperf/web_ui/medperf_login.py +++ b/cli/medperf/web_ui/medperf_login.py @@ -102,6 +102,12 @@ def logout( request: Request, current_user: bool = Depends(check_user_api), ): + if request.app.state.model_auto_give_access: + return { + "status": "failed", + "error": "Automatic grant access is currently running. Stop it before logging out.", + } + initialize_state_task(request, task_name="medperf_logout") return_response = {"status": "", "error": ""} From 5af5d83c4d7ebcd79bf77b1d5f8ad1ff37bc1ec9 Mon Sep 17 00:00:00 2001 From: mhmdk0 Date: Mon, 24 Aug 2026 06:01:59 +0300 Subject: [PATCH 9/9] handle requests errors - webui --- cli/medperf/web_ui/app.py | 25 +++++++++++++++++++++++-- cli/medperf/web_ui/static/js/common.js | 1 + 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/cli/medperf/web_ui/app.py b/cli/medperf/web_ui/app.py index 58fd0f7d9..3b6f1c607 100644 --- a/cli/medperf/web_ui/app.py +++ b/cli/medperf/web_ui/app.py @@ -6,8 +6,9 @@ import typer from starlette.middleware.base import BaseHTTPMiddleware -from fastapi import FastAPI, Request -from fastapi.responses import RedirectResponse +from fastapi import FastAPI, HTTPException, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from medperf import config @@ -122,6 +123,26 @@ def not_authenticated_exception_handler( return RedirectResponse(url=exc.redirect_url) +@web_app.exception_handler(HTTPException) +def http_exception_handler(request: Request, exc: HTTPException): + # Reshape into the {"status", "error"} format for consistency with other error responses, + # instead of FastAPI's default {"detail": ...} body. + return JSONResponse( + status_code=exc.status_code, + content={"status": "failed", "error": exc.detail}, + ) + + +@web_app.exception_handler(RequestValidationError) +def validation_exception_handler(request: Request, exc: RequestValidationError): + messages = [] + for error in exc.errors(): + field = ".".join(str(p) for p in error.get("loc", []) if p != "body") + messages.append(f"{field}: {error['msg']}" if field else error["msg"]) + message = "\n".join(messages) if messages else "Invalid request data." + return JSONResponse(status_code=422, content={"status": "failed", "error": message}) + + @web_app.get("/", include_in_schema=False) def read_root(request: Request): if request.app.state.ui_mode == UI_MODE_TRAINING: diff --git a/cli/medperf/web_ui/static/js/common.js b/cli/medperf/web_ui/static/js/common.js index 5f37e7139..e04e24ae0 100644 --- a/cli/medperf/web_ui/static/js/common.js +++ b/cli/medperf/web_ui/static/js/common.js @@ -199,6 +199,7 @@ function showErrorModal(errorTitle, response) { var responseError = (response && response.error) || ""; var responseStatus = (response && response.status) || ""; var errorText = (responseError + (responseError ? "
" : "") + responseStatus).replace(/\n/g, "
"); + if (!errorText) errorText = "Something went wrong. Please try again."; var modalBody = "

" + errorText + "

"; var modalFooter = ""; showModal({ title: errorTitle, body: modalBody, footer: modalFooter });