This is an automated email from the ASF dual-hosted git repository.

sbp pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git


The following commit(s) were added to refs/heads/main by this push:
     new 9368da3  Add progress bars to indicate the status of uploads
9368da3 is described below

commit 9368da30bc548ef60a51ff5c4324964b2ff7d1ca
Author: Sean B. Palmer <[email protected]>
AuthorDate: Thu Dec 18 21:16:53 2025 +0000

    Add progress bars to indicate the status of uploads
---
 atr/get/upload.py                       |  22 ++-
 atr/post/upload.py                      | 106 +++++++++++++-
 atr/static/js/src/upload-progress-ui.js | 147 +++++++++++++++++++
 atr/static/js/src/upload-progress.js    | 243 ++++++++++++++++++++++++++++++++
 atr/util.py                             |   6 +
 5 files changed, 521 insertions(+), 3 deletions(-)

diff --git a/atr/get/upload.py b/atr/get/upload.py
index b60c6ef..4a86c1b 100644
--- a/atr/get/upload.py
+++ b/atr/get/upload.py
@@ -15,8 +15,11 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import secrets
 from collections.abc import Sequence
 
+import htpy
+
 import atr.blueprints.get as get
 import atr.db as db
 import atr.form as form
@@ -63,6 +66,18 @@ async def selected(session: web.Committer, project_name: 
str, version_name: str)
     block.h2(id="file-upload")["File upload"]
     block.p["Use this form to add files to this candidate draft."]
 
+    upload_session_token = secrets.token_hex(16)
+    stage_url = 
f"/upload/stage/{project_name}/{version_name}/{upload_session_token}"
+    finalise_url = 
f"/upload/finalise/{project_name}/{version_name}/{upload_session_token}"
+
+    block.append(
+        htpy.div(
+            "#upload-config.atr-hide",
+            data_stage_url=stage_url,
+            data_finalise_url=finalise_url,
+        )
+    )
+
     form.render_block(
         block,
         model_cls=shared.upload.AddFilesForm,
@@ -70,6 +85,8 @@ async def selected(session: web.Committer, project_name: str, 
version_name: str)
         form_classes=".atr-canary.py-4.px-5",
     )
 
+    block.append(htpy.div("#upload-progress-container.d-none"))
+
     block.h2(id="svn-upload")["SVN upload"]
     block.p["Import files from this project's ASF Subversion repository into 
this draft."]
     block.p[
@@ -114,6 +131,7 @@ async def selected(session: web.Committer, project_name: 
str, version_name: str)
     return await template.blank(
         f"Upload files to {release.short_display_name}",
         content=block.collect(),
+        javascripts=["upload-progress-ui", "upload-progress"],
     )
 
 
@@ -136,7 +154,7 @@ def _render_ssh_keys_info(block: htm.Block, user_ssh_keys: 
Sequence[sql.SSHKey])
     if key_count == 1:
         key = user_ssh_keys[0]
         key_parts = key.key.split(" ", 2)
-        key_comment = key_parts[2].strip() if len(key_parts) > 2 else "key"
+        key_comment = key_parts[2].strip() if (len(key_parts) > 2) else "key"
         block.p[
             "We have the SSH key ",
             htm.a(
@@ -152,7 +170,7 @@ def _render_ssh_keys_info(block: htm.Block, user_ssh_keys: 
Sequence[sql.SSHKey])
         key_items = []
         for key in user_ssh_keys:
             key_parts = key.key.split(" ", 2)
-            key_comment = key_parts[2].strip() if len(key_parts) > 2 else "key"
+            key_comment = key_parts[2].strip() if (len(key_parts) > 2) else 
"key"
             key_items.append(
                 htm.li[
                     htm.a(
diff --git a/atr/post/upload.py b/atr/post/upload.py
index f0dc590..26cfb88 100644
--- a/atr/post/upload.py
+++ b/atr/post/upload.py
@@ -15,10 +15,16 @@
 # specific language governing permissions and limitations
 # under the License.
 
-
+import asyncio
+import json
+import pathlib
 from typing import Final
 
+import aiofiles
+import aiofiles.os
+import aioshutil
 import quart
+import werkzeug.wrappers.response as response
 
 import atr.blueprints.post as post
 import atr.db as db
@@ -26,11 +32,61 @@ import atr.get as get
 import atr.log as log
 import atr.shared as shared
 import atr.storage as storage
+import atr.util as util
 import atr.web as web
 
 _SVN_BASE_URL: Final[str] = "https://dist.apache.org/repos/dist";
 
 
[email protected]("/upload/finalise/<project_name>/<version_name>/<upload_session>")
+async def finalise(
+    session: web.Committer, project_name: str, version_name: str, 
upload_session: str
+) -> web.WerkzeugResponse:
+    await session.check_access(project_name)
+
+    try:
+        staging_dir = util.get_upload_staging_dir(upload_session)
+    except ValueError:
+        return _json_error("Invalid session token", 400)
+
+    if not await aiofiles.os.path.isdir(staging_dir):
+        return _json_error("No staged files found", 400)
+
+    try:
+        staged_files = await aiofiles.os.listdir(staging_dir)
+    except OSError:
+        return _json_error("Error reading staging directory", 500)
+
+    staged_files = [f for f in staged_files if f not in (".", "..")]
+    if not staged_files:
+        return _json_error("No staged files found", 400)
+
+    try:
+        async with storage.write(session) as write:
+            wacp = await write.as_project_committee_participant(project_name)
+            number_of_files = len(staged_files)
+            plural = "s" if number_of_files != 1 else ""
+            description = f"Upload of {number_of_files} file{plural} through 
web interface"
+
+            async with wacp.release.create_and_manage_revision(project_name, 
version_name, description) as creating:
+                for filename in staged_files:
+                    src = staging_dir / filename
+                    dst = creating.interim_path / filename
+                    await aioshutil.move(str(src), str(dst))
+
+        await aioshutil.rmtree(staging_dir)
+
+        return await session.redirect(
+            get.compose.selected,
+            success=f"{number_of_files} file{plural} added successfully",
+            project_name=project_name,
+            version_name=version_name,
+        )
+    except Exception as e:
+        log.exception("Error finalising upload:")
+        return _json_error(f"Error finalising upload: {e!s}", 500)
+
+
 @post.committer("/upload/<project_name>/<version_name>")
 @post.form(shared.upload.UploadForm)
 async def selected(
@@ -46,6 +102,46 @@ async def selected(
             return await _svn_import(session, svn_form, project_name, 
version_name)
 
 
[email protected]("/upload/stage/<project_name>/<version_name>/<upload_session>")
+async def stage(
+    session: web.Committer, project_name: str, version_name: str, 
upload_session: str
+) -> web.WerkzeugResponse:
+    await session.check_access(project_name)
+
+    try:
+        staging_dir = util.get_upload_staging_dir(upload_session)
+    except ValueError:
+        return _json_error("Invalid session token", 400)
+
+    files = await quart.request.files
+    file = files.get("file")
+    if (not file) or (not file.filename):
+        return _json_error("No file provided", 400)
+
+    filename = pathlib.Path(file.filename).name
+    if (not filename) or (filename in (".", "..")):
+        return _json_error("Invalid filename", 400)
+
+    await aiofiles.os.makedirs(staging_dir, exist_ok=True)
+
+    target_path = staging_dir / filename
+    if await aiofiles.os.path.exists(target_path):
+        return _json_error("File already exists in staging", 409)
+
+    try:
+        async with aiofiles.open(target_path, "wb") as f:
+            # 1 MiB chunks
+            while chunk := await asyncio.to_thread(file.stream.read, 1024 * 
1024):
+                await f.write(chunk)
+    except Exception as e:
+        log.exception("Error staging file:")
+        if await aiofiles.os.path.exists(target_path):
+            await aiofiles.os.remove(target_path)
+        return _json_error(f"Error staging file: {e!s}", 500)
+
+    return _json_success({"status": "staged", "filename": filename})
+
+
 async def _add_files(
     session: web.Committer, add_form: shared.upload.AddFilesForm, 
project_name: str, version_name: str
 ) -> web.WerkzeugResponse:
@@ -80,6 +176,14 @@ def _construct_svn_url(project_name: str, area: 
shared.upload.SvnArea, path: str
     return f"{_SVN_BASE_URL}/{area.value}/{project_name}/{path}"
 
 
+def _json_error(message: str, status: int) -> web.WerkzeugResponse:
+    return response.Response(json.dumps({"error": message}), status=status, 
mimetype="application/json")
+
+
+def _json_success(data: dict[str, str], status: int = 200) -> 
web.WerkzeugResponse:
+    return response.Response(json.dumps(data), status=status, 
mimetype="application/json")
+
+
 async def _svn_import(
     session: web.Committer, svn_form: shared.upload.SvnImportForm, 
project_name: str, version_name: str
 ) -> web.WerkzeugResponse:
diff --git a/atr/static/js/src/upload-progress-ui.js 
b/atr/static/js/src/upload-progress-ui.js
new file mode 100644
index 0000000..7648e80
--- /dev/null
+++ b/atr/static/js/src/upload-progress-ui.js
@@ -0,0 +1,147 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+
+window.UploadUI = {
+       formatBytes(b) {
+               if (b < 1024) return `${b} B`;
+               if (b < 1048576) return `${(b / 1024).toFixed(1)} KB`;
+               if (b < 1073741824) return `${(b / 1048576).toFixed(1)} MB`;
+               return `${(b / 1073741824).toFixed(2)} GB`;
+       },
+
+       createFileProgressUI(file, index, activeUploads) {
+               const div = document.createElement("div");
+               div.className = "card mb-3";
+               div.id = `upload-file-${index}`;
+               div.innerHTML = `<div class="card-body">
+                       <div class="d-flex justify-content-between 
align-items-start mb-2">
+                               <div><strong class="file-name"></strong>
+                                       <small class="text-muted 
ms-2">(${this.formatBytes(file.size)})</small></div>
+                               <button type="button" class="btn btn-sm 
btn-outline-secondary cancel-btn">Cancel</button>
+                       </div>
+                       <progress class="w-100 mb-1" value="0" 
max="100"></progress>
+                       <div class="d-flex justify-content-between">
+                               <small class="upload-status 
text-muted">Preparing...</small>
+                               <small class="upload-percent">0%</small>
+                       </div></div>`;
+               div.querySelector(".file-name").textContent = file.name;
+               div.querySelector(".cancel-btn").addEventListener("click", () 
=> {
+                       const xhr = activeUploads.get(index);
+                       if (xhr) xhr.abort();
+               });
+               return div;
+       },
+
+       markUploadDone(ui, success, msg) {
+               const statusEl = ui.querySelector(".upload-status");
+               statusEl.textContent = msg;
+               statusEl.className = `upload-status text-${success ? "success" 
: "danger"}`;
+               const border = success ? "border-success" : "border-danger";
+               ui.querySelector(".card-body").classList.add(
+                       "border-start",
+                       border,
+                       "border-3",
+               );
+               if (success) {
+                       ui.querySelector("progress").value = 100;
+                       ui.querySelector(".upload-percent").textContent = 
"100%";
+               }
+               ui.querySelector(".cancel-btn").classList.add("d-none");
+       },
+
+       resetUploadUI(ui) {
+               ui.querySelector(".card-body").classList.remove(
+                       "border-start",
+                       "border-danger",
+                       "border-3",
+               );
+               const statusEl = ui.querySelector(".upload-status");
+               statusEl.textContent = "Preparing...";
+               statusEl.className = "upload-status text-muted";
+               ui.querySelector("progress").value = 0;
+               ui.querySelector(".upload-percent").textContent = "0%";
+               ui.querySelector(".cancel-btn").classList.remove("d-none");
+       },
+
+       showAllFailedSummary(container) {
+               const summary = document.createElement("div");
+               summary.className = "alert alert-danger mt-3";
+               summary.innerHTML = `<strong>All uploads failed.</strong>
+                       <button type="button" class="btn btn-outline-primary 
ms-3" id="retry-all-btn">Try again</button>`;
+               container.append(summary);
+               document
+                       .getElementById("retry-all-btn")
+                       .addEventListener("click", () => location.reload());
+       },
+
+       showPartialSuccessSummary(container, completedCount, totalFiles, 
onFinalise) {
+               const summary = document.createElement("div");
+               summary.className = "alert alert-warning mt-3";
+               summary.id = "upload-summary";
+               const totalWord = totalFiles === 1 ? "file" : "files";
+               const stagedWord = completedCount === 1 ? "file" : "files";
+               summary.innerHTML = `<strong>${completedCount} of ${totalFiles} 
${totalWord} staged.</strong>
+                       <span class="ms-2">You can finalise with the staged 
files or retry failed uploads.</span>
+                       <button type="button" class="btn btn-primary ms-3" 
id="finalise-btn">Finalise ${completedCount} ${stagedWord}</button>`;
+               container.append(summary);
+               document
+                       .getElementById("finalise-btn")
+                       .addEventListener("click", onFinalise);
+       },
+
+       doFinalise(container, finaliseUrl, csrfToken) {
+               const existingSummary = 
document.getElementById("upload-summary");
+               if (existingSummary) existingSummary.remove();
+               const summary = document.createElement("div");
+               summary.className = "alert alert-info mt-3";
+               summary.innerHTML = `<strong>Finalising upload...</strong>`;
+               container.append(summary);
+               const form = document.createElement("form");
+               form.method = "POST";
+               form.action = finaliseUrl;
+               form.style.display = "none";
+               const csrfInput = document.createElement("input");
+               csrfInput.type = "hidden";
+               csrfInput.name = "csrf_token";
+               csrfInput.value = csrfToken;
+               form.append(csrfInput);
+               document.body.append(form);
+               form.submit();
+       },
+
+       handleUploadProgress(e, progressBar, statusEl, percentEl, cancelBtn) {
+               if (e.lengthComputable) {
+                       const pct = Math.round((e.loaded / e.total) * 100);
+                       progressBar.value = pct;
+                       delete progressBar.dataset.indeterminate;
+                       percentEl.textContent = `${pct}%`;
+                       if (pct >= 100) {
+                               statusEl.textContent = "Processing...";
+                               cancelBtn.classList.add("d-none");
+                       } else {
+                               statusEl.textContent = 
`${this.formatBytes(e.loaded)} / ${this.formatBytes(e.total)}`;
+                       }
+               } else {
+                       progressBar.removeAttribute("value");
+                       progressBar.dataset.indeterminate = "true";
+                       percentEl.textContent = "";
+                       statusEl.textContent = `${this.formatBytes(e.loaded)} 
uploaded`;
+               }
+       },
+};
diff --git a/atr/static/js/src/upload-progress.js 
b/atr/static/js/src/upload-progress.js
new file mode 100644
index 0000000..a9ef76a
--- /dev/null
+++ b/atr/static/js/src/upload-progress.js
@@ -0,0 +1,243 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+
+function handleXhrLoad(xhr, ctx) {
+       ctx.activeUploads.delete(ctx.index);
+       if (xhr.status >= 200 && xhr.status < 300) {
+               UploadUI.markUploadDone(ctx.ui, true, "Staged");
+               ctx.state.completedCount++;
+       } else {
+               let msg = "Upload failed";
+               try {
+                       msg = JSON.parse(xhr.responseText).error || msg;
+               } catch {
+                       /* ignore */
+               }
+               UploadUI.markUploadDone(ctx.ui, false, `Failed: ${msg}`);
+               ctx.state.failedCount++;
+               ctx.addRetryButton(ctx.ui, ctx.index);
+       }
+       ctx.checkAllComplete();
+}
+
+function handleXhrFailure(ctx, reason, isCancelled) {
+       ctx.activeUploads.delete(ctx.index);
+       if (isCancelled) {
+               ctx.statusEl.textContent = "Cancelled";
+               ctx.statusEl.className = "upload-status text-secondary";
+               ctx.cancelBtn.classList.add("d-none");
+       } else {
+               UploadUI.markUploadDone(ctx.ui, false, `Failed: ${reason}`);
+               ctx.addRetryButton(ctx.ui, ctx.index);
+       }
+       ctx.state.failedCount++;
+       ctx.checkAllComplete();
+}
+
+function setupXhrHandlers(
+       xhr,
+       ui,
+       index,
+       state,
+       addRetryButton,
+       checkAllComplete,
+) {
+       const ctx = {
+               ui,
+               index,
+               state,
+               addRetryButton,
+               checkAllComplete,
+               activeUploads: state.activeUploads,
+               statusEl: ui.querySelector(".upload-status"),
+               cancelBtn: ui.querySelector(".cancel-btn"),
+       };
+       const progressBar = ui.querySelector("progress");
+       const percentEl = ui.querySelector(".upload-percent");
+
+       xhr.upload.addEventListener("progress", (e) =>
+               UploadUI.handleUploadProgress(
+                       e,
+                       progressBar,
+                       ctx.statusEl,
+                       percentEl,
+                       ctx.cancelBtn,
+               ),
+       );
+       xhr.addEventListener("load", () => handleXhrLoad(xhr, ctx));
+       xhr.addEventListener("error", () =>
+               handleXhrFailure(ctx, "Network error", false),
+       );
+       xhr.addEventListener("abort", () => handleXhrFailure(ctx, null, true));
+}
+
+function checkAllComplete(state, container, finaliseUrl, csrfToken) {
+       if (state.activeUploads.size > 0) return;
+       if (state.completedCount === 0) {
+               UploadUI.showAllFailedSummary(container);
+       } else if (state.failedCount > 0) {
+               UploadUI.showPartialSuccessSummary(
+                       container,
+                       state.completedCount,
+                       state.totalFiles,
+                       () => UploadUI.doFinalise(container, finaliseUrl, 
csrfToken),
+               );
+       } else {
+               UploadUI.doFinalise(container, finaliseUrl, csrfToken);
+       }
+}
+
+function startUpload(
+       file,
+       index,
+       state,
+       stageUrl,
+       csrfToken,
+       addRetryButton,
+       checkComplete,
+) {
+       const ui = document.getElementById(`upload-file-${index}`);
+       const xhr = new XMLHttpRequest();
+       state.activeUploads.set(index, xhr);
+       setupXhrHandlers(xhr, ui, index, state, addRetryButton, checkComplete);
+       const formData = new FormData();
+       formData.append("csrf_token", csrfToken);
+       formData.append("file", file, file.name);
+       xhr.open("POST", stageUrl, true);
+       xhr.send(formData);
+       ui.querySelector(".upload-status").textContent = "Uploading...";
+}
+
+function addRetryButton(
+       ui,
+       index,
+       state,
+       fileInput,
+       stageUrl,
+       csrfToken,
+       checkComplete,
+) {
+       const retryBtn = document.createElement("button");
+       retryBtn.type = "button";
+       retryBtn.className = "btn btn-sm btn-outline-primary retry-btn ms-2";
+       retryBtn.textContent = "Retry";
+       retryBtn.addEventListener("click", () => {
+               UploadUI.resetUploadUI(ui);
+               retryBtn.remove();
+               state.failedCount--;
+               const file = Array.from(fileInput.files || [])[index];
+               startUpload(
+                       file,
+                       index,
+                       state,
+                       stageUrl,
+                       csrfToken,
+                       addRetryButton,
+                       checkComplete,
+               );
+       });
+       ui.querySelector(".upload-status").parentNode.append(retryBtn);
+}
+
+function handleFormSubmit(
+       e,
+       form,
+       fileInput,
+       container,
+       state,
+       stageUrl,
+       csrfToken,
+       checkComplete,
+) {
+       e.preventDefault();
+       const files = Array.from(fileInput.files || []);
+       if (files.length === 0) {
+               alert("Please select files to upload.");
+               return;
+       }
+       state.totalFiles = files.length;
+       state.completedCount = 0;
+       state.failedCount = 0;
+       form.classList.add("d-none");
+       container.classList.remove("d-none");
+       container.innerHTML = "";
+       files.forEach((file, index) => {
+               container.append(
+                       UploadUI.createFileProgressUI(file, index, 
state.activeUploads),
+               );
+               startUpload(
+                       file,
+                       index,
+                       state,
+                       stageUrl,
+                       csrfToken,
+                       (ui, idx) =>
+                               addRetryButton(
+                                       ui,
+                                       idx,
+                                       state,
+                                       fileInput,
+                                       stageUrl,
+                                       csrfToken,
+                                       checkComplete,
+                               ),
+                       checkComplete,
+               );
+       });
+}
+
+(() => {
+       const config = document.getElementById("upload-config");
+       if (!config) return;
+
+       const stageUrl = config.dataset.stageUrl;
+       const finaliseUrl = config.dataset.finaliseUrl;
+       if (!stageUrl || !finaliseUrl) return;
+
+       const form = document
+               .querySelector('input[name="variant"][value="add_files"]')
+               ?.closest("form");
+       if (!form) return;
+
+       const fileInput = form.querySelector('input[type="file"]');
+       const container = document.getElementById("upload-progress-container");
+       const csrfToken = form.querySelector('input[name="csrf_token"]')?.value;
+       const state = {
+               activeUploads: new Map(),
+               completedCount: 0,
+               failedCount: 0,
+               totalFiles: 0,
+       };
+
+       const checkComplete = () =>
+               checkAllComplete(state, container, finaliseUrl, csrfToken);
+
+       form.addEventListener("submit", (e) =>
+               handleFormSubmit(
+                       e,
+                       form,
+                       fileInput,
+                       container,
+                       state,
+                       stageUrl,
+                       csrfToken,
+                       checkComplete,
+               ),
+       );
+})();
diff --git a/atr/util.py b/atr/util.py
index 0c6ebff..e3fbdf0 100644
--- a/atr/util.py
+++ b/atr/util.py
@@ -482,6 +482,12 @@ def get_unfinished_dir() -> pathlib.Path:
     return pathlib.Path(config.get().UNFINISHED_STORAGE_DIR)
 
 
+def get_upload_staging_dir(session_token: str) -> pathlib.Path:
+    if not session_token.isalnum():
+        raise ValueError("Invalid session token")
+    return get_tmp_dir() / "upload-staging" / session_token
+
+
 async def get_urls_as_completed(urls: Sequence[str]) -> 
AsyncGenerator[tuple[str, int | str | None, bytes]]:
     """GET a list of URLs in parallel and yield (url, status, content_bytes) 
as they become available."""
     async with aiohttp.ClientSession() as session:


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to