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
  '<upload dir>/../..' is refused. This path traversal predates the
  redesign; the import moves the source tree, so it mattered.
This commit is contained in:
cproudlock
2026-10-14 14:48:10 -04:00
parent 2bcc9d8016
commit 2ed2cbe2e9
11 changed files with 61 additions and 15 deletions

View File

@@ -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)

View File

@@ -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: "<upload dir>/../.." 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

View File

@@ -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();
});
});

View File

@@ -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);
});

View File

@@ -107,7 +107,7 @@
<div class="form-field">
<label class="form-label">Key</label>
<input type="text" class="form-control mono" name="key"
pattern="[a-z][a-z0-9-]{1,63}" required
pattern="[a-z][a-z0-9\-]{1,63}" required
placeholder="gea-shopfloor-newtype">
<div class="form-text">Lowercase + hyphens. Used as directory name + URL path. 2-64 chars, must start with a letter.</div>
</div>
@@ -144,7 +144,7 @@
<div class="form-field">
<label class="form-label">New key</label>
<input type="text" class="form-control mono" name="dst_key"
pattern="[a-z][a-z0-9-]{1,63}" required>
pattern="[a-z][a-z0-9\-]{1,63}" required>
</div>
<div class="form-field">
<label class="form-label">Friendly name (optional)</label>

View File

@@ -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();

View File

@@ -23,7 +23,7 @@ function cancelImagingReload() {
{% if sessions %}
{% call ui.more('the imaging floor') %}
<form method="post" action="{{ url_for('imaging_delete_all') }}"
onsubmit="return confirm('Clear all {{ sessions|length }} imaging sessions? Every row is removed; PCs still imaging reappear on their next status push.');">
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.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="del">Clear all sessions</button>
</form>
@@ -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();

View File

@@ -78,7 +78,7 @@
{# ---- 1. ServiceNow ---- #}
<div class="sect" id="sn"><h2>ServiceNow ticks</h2><span class="sub">Tick each asset's Onsite Bin Management RITM (ENT-045 / ENT-159).</span>
<div style="margin-left: auto; display: flex; gap: 6px; flex-wrap: wrap" role="group" aria-label="Period">
{% for d in (7, 30, 90) %}<a class="fbtn" href="{{ url_for('blancco_todo', days=d, all='1' if include_done else None, _anchor='sn') }}"{% if days == d %} aria-current="true"{% endif %}>{{ d }} days</a>{% endfor %}
{% for d in (7, 30, 90, 366) %}<a class="fbtn" href="{{ url_for('blancco_todo', days=d, all='1' if include_done else None, _anchor='sn') }}"{% if days == d %} aria-current="true"{% endif %}>{{ 'Year' if d == 366 else d ~ ' days' }}</a>{% endfor %}
<a class="fbtn" href="{{ url_for('blancco_todo', days=days, all=None if include_done else '1', _anchor='sn') }}"{% if include_done %} aria-current="true"{% endif %}>Show ticked</a>
</div></div>
{% for it in sn_items %}

View File

@@ -490,7 +490,7 @@
<div class="sub">Apply <span class="mono">{{ h.id }}</span>.</div>
</div>
</details>
{% if can %}<div class="act"><a class="ui-btn" href="{{ url_for('dell_updates.rollback_review', apply_id=h.id) }}">Review rollback</a></div>{% endif %}
{% if can %}<div class="act"><a class="ui-btn" href="{{ url_for('dell_updates.rollback_review', apply_id=h.id, back='dell') }}">Review rollback</a></div>{% endif %}
</div>
{% endfor %}
</section>

View File

@@ -54,12 +54,12 @@
</ul>
<div style="display: flex; flex-wrap: wrap; gap: 10px; justify-content: flex-end">
<a class="ui-btn" href="{{ url_for('updates_overview') }}">Cancel</a>
<a class="ui-btn" href="{{ url_for('dell_updates.page') if back == 'dell' else url_for('updates_overview') }}">Cancel</a>
{% if rec.unchanged and not rec.rolled_back %}
<form method="post" action="{{ url_for('dell_updates.rollback') }}" style="margin: 0">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="apply_id" value="{{ rec.id }}">
<input type="hidden" name="next" value="overview">
{% if back != 'dell' %}<input type="hidden" name="next" value="overview">{% endif %}
<button type="submit" class="ui-btn pri">Roll back {{ rec.unchanged }} file{{ 's' if rec.unchanged != 1 }}</button>
</form>
{% endif %}

View File

@@ -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"])