From 2ed2cbe2e92ec23496955f9867b423c786e5f2b8 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Wed, 14 Oct 2026 14:48:10 -0400 Subject: [PATCH] Webapp redesign: fixes from the functional regression test - Unattend: a command's '...' menu stayed open after Move up/down and covered the next row's menu, so the next click could stage removing the wrong command. The menu now closes and focus returns to its button. - '...' menus close on an outside click, on Escape and when another opens (static/app.js), for every page. - Drivers: Remove asks to confirm again, as before the redesign. Long zip paths in a row body wrap (the page scrolled sideways at 390 px). - Rollback review returns to the page it was opened from (Dell page or Updates overview), for both Cancel and the rollback itself. - To-do: the ServiceNow period offers a year again (366 days). - Imaging floor: a search that matches a log-only bay opens that group; its open state survives the live refresh. 'Clear all' states the number of session files it deletes, hidden finished ones included. - Dashboard: the image key pattern is valid under the browser's v flag (client-side validation was silently skipped). - Import: allowed_import_source() compares real paths, so '/../..' is refused. This path traversal predates the redesign; the import moves the source tree, so it mattered. --- webapp/app.py | 4 ++++ webapp/services/deploy.py | 11 +++++++---- webapp/static/app.js | 16 ++++++++++++++++ webapp/static/unattend-editor.js | 7 +++++-- webapp/templates/dashboard.html | 4 ++-- webapp/templates/image_drivers.html | 3 ++- webapp/templates/imaging.html | 20 +++++++++++++++++++- webapp/templates/reports_todo.html | 2 +- webapp/templates/updates_dell.html | 2 +- webapp/templates/updates_rollback.html | 4 ++-- webapp/views/dell_updates.py | 3 ++- 11 files changed, 61 insertions(+), 15 deletions(-) diff --git a/webapp/app.py b/webapp/app.py index 5c7ee4c..e602f69 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -993,7 +993,11 @@ def imaging_dashboard(): hidden = 0 if not show_all: sessions, hidden = imaging_status.hide_stale(sessions) + # Clear all deletes every session file, hidden finished ones included; + # log-only bays have no file. The confirm states that number. + clear_count = len(imaging_status.list_sessions(include_inferred=False)) return render_template("imaging.html", sessions=imaging_status.floor_order(sessions), hidden=hidden, + clear_count=clear_count, show_all=show_all, window_hours=imaging_status.ACTIVE_WINDOW_HOURS) diff --git a/webapp/services/deploy.py b/webapp/services/deploy.py index d7166de..87faf3f 100644 --- a/webapp/services/deploy.py +++ b/webapp/services/deploy.py @@ -105,9 +105,12 @@ def import_deploy(src_deploy, dst_deploy, target="", move=False): def allowed_import_source(source): """True if source is a USB mount or under the upload dir.""" - usb = find_usb_mounts() - if any(source == m or source.startswith(m + "/") for m in usb): + # Compare real paths: "/../.." passes a plain prefix test. + real = os.path.realpath(source) + usb = [os.path.realpath(m) for m in find_usb_mounts()] + if any(real == m or real.startswith(m + "/") for m in usb): return True - if source == config.UPLOAD_DIR or source.startswith(config.UPLOAD_DIR + "/"): - return os.path.isdir(source) + up = os.path.realpath(config.UPLOAD_DIR) + if real == up or real.startswith(up + "/"): + return os.path.isdir(real) return False diff --git a/webapp/static/app.js b/webapp/static/app.js index 1173978..97bee14 100644 --- a/webapp/static/app.js +++ b/webapp/static/app.js @@ -494,3 +494,19 @@ document.addEventListener('click', function (e) { document.body.removeChild(ta); done(ok); }); + +// "..." menus (details.more): close on a click outside, on Escape, and when +// another one opens, so an open menu never covers the next row's controls. +document.addEventListener('click', function (e) { + document.querySelectorAll('details.more[open]').forEach(function (d) { + if (!d.contains(e.target)) d.open = false; + }); +}); +document.addEventListener('keydown', function (e) { + if (e.key !== 'Escape') return; + document.querySelectorAll('details.more[open]').forEach(function (d) { + d.open = false; + var s = d.querySelector('summary'); + if (s && d.contains(document.activeElement)) s.focus(); + }); +}); diff --git a/webapp/static/unattend-editor.js b/webapp/static/unattend-editor.js index a7ec94d..aef35bc 100644 --- a/webapp/static/unattend-editor.js +++ b/webapp/static/unattend-editor.js @@ -244,14 +244,17 @@ if (btn.classList.contains('una-add')) { addRow(btn.getAttribute('data-list')); return; } var row = btn.closest('.una-row, .una-simple'); var name = listName(row); + // Close the row's menu first: left open it covers the next row's menu. + var menu = btn.closest('details.more'); + if (menu) menu.open = false; if (btn.classList.contains('una-remove')) { row.remove(); } else if (btn.classList.contains('una-up') && row.previousElementSibling) { row.parentNode.insertBefore(row, row.previousElementSibling); - btn.focus(); + if (menu) menu.querySelector('summary').focus(); } else if (btn.classList.contains('una-down') && row.nextElementSibling) { row.parentNode.insertBefore(row.nextElementSibling, row); - btn.focus(); + if (menu) menu.querySelector('summary').focus(); } afterChange(name); }); diff --git a/webapp/templates/dashboard.html b/webapp/templates/dashboard.html index 45536cb..e0cc0cc 100644 --- a/webapp/templates/dashboard.html +++ b/webapp/templates/dashboard.html @@ -107,7 +107,7 @@
Lowercase + hyphens. Used as directory name + URL path. 2-64 chars, must start with a letter.
@@ -144,7 +144,7 @@
+ pattern="[a-z][a-z0-9\-]{1,63}" required>
diff --git a/webapp/templates/image_drivers.html b/webapp/templates/image_drivers.html index a27630a..4a14260 100644 --- a/webapp/templates/image_drivers.html +++ b/webapp/templates/image_drivers.html @@ -33,7 +33,7 @@ .dbody { padding: 2px 24px 16px 68px; display: grid; grid-template-columns: minmax(300px, .9fr) minmax(0, 1.6fr); gap: 12px 32px; font-size: 13px; align-items: start; } .dbody dl { margin: 0; display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 4px 14px; align-content: start; align-items: baseline; } - .dbody dt { color: var(--muted); font-weight: 400; } .dbody dd { margin: 0; } + .dbody dt { color: var(--muted); font-weight: 400; } .dbody dd { margin: 0; overflow-wrap: anywhere; min-width: 0; } .dbody h4 { margin: 0 0 6px; font-size: 12px; font-weight: 600; color: var(--muted); letter-spacing: .03em; text-transform: uppercase; } .dbody .find { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; line-height: 1.5; } .dbody .find.cleared li { color: var(--muted); } @@ -676,6 +676,7 @@ else if (act === 'moveto') openMove(it); else if (act === 'edit') openEditor(state.items.indexOf(it)); else if (act === 'remove') { + if (!confirm('Remove #' + livePos(it) + ' ' + label(it) + ' from the map? The zip stays on disk. Nothing is written until Review and save, and Undo remove puts it back.')) return; if (it.orig === null) state.items.splice(state.items.indexOf(it), 1); else { it.removed = true; it.open = false; } changed(); diff --git a/webapp/templates/imaging.html b/webapp/templates/imaging.html index 2a572e3..be22d19 100644 --- a/webapp/templates/imaging.html +++ b/webapp/templates/imaging.html @@ -23,7 +23,7 @@ function cancelImagingReload() { {% if sessions %} {% call ui.more('the imaging floor') %}
+ onsubmit="return confirm('Clear all {{ clear_count }} imaging session files{{ (', including ' ~ hidden ~ ' hidden finished') if hidden else '' }}? Every row is removed; PCs still imaging reappear on their next status push.');">
@@ -363,11 +363,29 @@ document.addEventListener('keydown', function(e) { document.querySelectorAll('#floor-filters [data-count]').forEach(function(n) { n.textContent = counts[n.getAttribute('data-count')]; }); + // A log-only bay that matches a search is shown: open its group. + var logs = document.querySelector('details.floor-logs'); + if (logs) { + var hit = Array.prototype.some.call(logs.querySelectorAll('.imaging-card'), function (c) { return !c.hidden; }); + if (q !== '' && hit) logs.open = true; + if (!hit) logs.hidden = true; else logs.hidden = false; + } var none = document.getElementById('floor-nomatch'); if (none) none.hidden = visible > 0 || (q === '' && stateFilter === ''); } + function restoreLogsGroup() { + var g = document.querySelector('details.floor-logs'); + if (!g) return; + try { if (sessionStorage.getItem('floor-logs-open') === '1') g.open = true; } catch (e) {} + if (!g._bound) { + g.addEventListener('toggle', function () { try { sessionStorage.setItem('floor-logs-open', g.open ? '1' : '0'); } catch (e) {} }); + g._bound = true; + } + } + window.imagingPostSwapHooks = function() { + restoreLogsGroup(); restoreExpandedState(); autoRenderLapsQRs(); renderIntuneQRs(); diff --git a/webapp/templates/reports_todo.html b/webapp/templates/reports_todo.html index 9a95896..d506fc5 100644 --- a/webapp/templates/reports_todo.html +++ b/webapp/templates/reports_todo.html @@ -78,7 +78,7 @@ {# ---- 1. ServiceNow ---- #}

ServiceNow ticks

Tick each asset's Onsite Bin Management RITM (ENT-045 / ENT-159).
- {% for d in (7, 30, 90) %}{{ d }} days{% endfor %} + {% for d in (7, 30, 90, 366) %}{{ 'Year' if d == 366 else d ~ ' days' }}{% endfor %} Show ticked
{% for it in sn_items %} diff --git a/webapp/templates/updates_dell.html b/webapp/templates/updates_dell.html index c609098..9522bf0 100644 --- a/webapp/templates/updates_dell.html +++ b/webapp/templates/updates_dell.html @@ -490,7 +490,7 @@
Apply {{ h.id }}.
- {% if can %}{% endif %} + {% if can %}{% endif %} {% endfor %} diff --git a/webapp/templates/updates_rollback.html b/webapp/templates/updates_rollback.html index c783cdd..638d3c8 100644 --- a/webapp/templates/updates_rollback.html +++ b/webapp/templates/updates_rollback.html @@ -54,12 +54,12 @@
- Cancel + Cancel {% if rec.unchanged and not rec.rolled_back %}
- + {% if back != 'dell' %}{% endif %}
{% endif %} diff --git a/webapp/views/dell_updates.py b/webapp/views/dell_updates.py index afe5208..4d52f79 100644 --- a/webapp/views/dell_updates.py +++ b/webapp/views/dell_updates.py @@ -166,7 +166,8 @@ def rollback_review(apply_id): except ValueError: flash("No apply with that id.", "warning") return redirect(url_for("updates_overview")) - return render_template("updates_rollback.html", rec=rec) + back = "dell" if request.args.get("back") == "dell" else "overview" + return render_template("updates_rollback.html", rec=rec, back=back) @bp.route("/rollback", methods=["POST"])