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:
@@ -993,7 +993,11 @@ def imaging_dashboard():
|
|||||||
hidden = 0
|
hidden = 0
|
||||||
if not show_all:
|
if not show_all:
|
||||||
sessions, hidden = imaging_status.hide_stale(sessions)
|
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,
|
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)
|
show_all=show_all, window_hours=imaging_status.ACTIVE_WINDOW_HOURS)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -105,9 +105,12 @@ def import_deploy(src_deploy, dst_deploy, target="", move=False):
|
|||||||
|
|
||||||
def allowed_import_source(source):
|
def allowed_import_source(source):
|
||||||
"""True if source is a USB mount or under the upload dir."""
|
"""True if source is a USB mount or under the upload dir."""
|
||||||
usb = find_usb_mounts()
|
# Compare real paths: "<upload dir>/../.." passes a plain prefix test.
|
||||||
if any(source == m or source.startswith(m + "/") for m in usb):
|
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
|
return True
|
||||||
if source == config.UPLOAD_DIR or source.startswith(config.UPLOAD_DIR + "/"):
|
up = os.path.realpath(config.UPLOAD_DIR)
|
||||||
return os.path.isdir(source)
|
if real == up or real.startswith(up + "/"):
|
||||||
|
return os.path.isdir(real)
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -494,3 +494,19 @@ document.addEventListener('click', function (e) {
|
|||||||
document.body.removeChild(ta);
|
document.body.removeChild(ta);
|
||||||
done(ok);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -244,14 +244,17 @@
|
|||||||
if (btn.classList.contains('una-add')) { addRow(btn.getAttribute('data-list')); return; }
|
if (btn.classList.contains('una-add')) { addRow(btn.getAttribute('data-list')); return; }
|
||||||
var row = btn.closest('.una-row, .una-simple');
|
var row = btn.closest('.una-row, .una-simple');
|
||||||
var name = listName(row);
|
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')) {
|
if (btn.classList.contains('una-remove')) {
|
||||||
row.remove();
|
row.remove();
|
||||||
} else if (btn.classList.contains('una-up') && row.previousElementSibling) {
|
} else if (btn.classList.contains('una-up') && row.previousElementSibling) {
|
||||||
row.parentNode.insertBefore(row, 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) {
|
} else if (btn.classList.contains('una-down') && row.nextElementSibling) {
|
||||||
row.parentNode.insertBefore(row.nextElementSibling, row);
|
row.parentNode.insertBefore(row.nextElementSibling, row);
|
||||||
btn.focus();
|
if (menu) menu.querySelector('summary').focus();
|
||||||
}
|
}
|
||||||
afterChange(name);
|
afterChange(name);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -107,7 +107,7 @@
|
|||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
<label class="form-label">Key</label>
|
<label class="form-label">Key</label>
|
||||||
<input type="text" class="form-control mono" name="key"
|
<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">
|
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 class="form-text">Lowercase + hyphens. Used as directory name + URL path. 2-64 chars, must start with a letter.</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -144,7 +144,7 @@
|
|||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
<label class="form-label">New key</label>
|
<label class="form-label">New key</label>
|
||||||
<input type="text" class="form-control mono" name="dst_key"
|
<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>
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
<label class="form-label">Friendly name (optional)</label>
|
<label class="form-label">Friendly name (optional)</label>
|
||||||
|
|||||||
@@ -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;
|
.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; }
|
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 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 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 { 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); }
|
.dbody .find.cleared li { color: var(--muted); }
|
||||||
@@ -676,6 +676,7 @@
|
|||||||
else if (act === 'moveto') openMove(it);
|
else if (act === 'moveto') openMove(it);
|
||||||
else if (act === 'edit') openEditor(state.items.indexOf(it));
|
else if (act === 'edit') openEditor(state.items.indexOf(it));
|
||||||
else if (act === 'remove') {
|
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);
|
if (it.orig === null) state.items.splice(state.items.indexOf(it), 1);
|
||||||
else { it.removed = true; it.open = false; }
|
else { it.removed = true; it.open = false; }
|
||||||
changed();
|
changed();
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ function cancelImagingReload() {
|
|||||||
{% if sessions %}
|
{% if sessions %}
|
||||||
{% call ui.more('the imaging floor') %}
|
{% call ui.more('the imaging floor') %}
|
||||||
<form method="post" action="{{ url_for('imaging_delete_all') }}"
|
<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() }}">
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||||
<button type="submit" class="del">Clear all sessions</button>
|
<button type="submit" class="del">Clear all sessions</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -363,11 +363,29 @@ document.addEventListener('keydown', function(e) {
|
|||||||
document.querySelectorAll('#floor-filters [data-count]').forEach(function(n) {
|
document.querySelectorAll('#floor-filters [data-count]').forEach(function(n) {
|
||||||
n.textContent = counts[n.getAttribute('data-count')];
|
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');
|
var none = document.getElementById('floor-nomatch');
|
||||||
if (none) none.hidden = visible > 0 || (q === '' && stateFilter === '');
|
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() {
|
window.imagingPostSwapHooks = function() {
|
||||||
|
restoreLogsGroup();
|
||||||
restoreExpandedState();
|
restoreExpandedState();
|
||||||
autoRenderLapsQRs();
|
autoRenderLapsQRs();
|
||||||
renderIntuneQRs();
|
renderIntuneQRs();
|
||||||
|
|||||||
@@ -78,7 +78,7 @@
|
|||||||
{# ---- 1. ServiceNow ---- #}
|
{# ---- 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 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">
|
<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>
|
<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>
|
</div></div>
|
||||||
{% for it in sn_items %}
|
{% for it in sn_items %}
|
||||||
|
|||||||
@@ -490,7 +490,7 @@
|
|||||||
<div class="sub">Apply <span class="mono">{{ h.id }}</span>.</div>
|
<div class="sub">Apply <span class="mono">{{ h.id }}</span>.</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</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>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -54,12 +54,12 @@
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div style="display: flex; flex-wrap: wrap; gap: 10px; justify-content: flex-end">
|
<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 %}
|
{% if rec.unchanged and not rec.rolled_back %}
|
||||||
<form method="post" action="{{ url_for('dell_updates.rollback') }}" style="margin: 0">
|
<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="_csrf_token" value="{{ csrf_token() }}">
|
||||||
<input type="hidden" name="apply_id" value="{{ rec.id }}">
|
<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>
|
<button type="submit" class="ui-btn pri">Roll back {{ rec.unchanged }} file{{ 's' if rec.unchanged != 1 }}</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -166,7 +166,8 @@ def rollback_review(apply_id):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
flash("No apply with that id.", "warning")
|
flash("No apply with that id.", "warning")
|
||||||
return redirect(url_for("updates_overview"))
|
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"])
|
@bp.route("/rollback", methods=["POST"])
|
||||||
|
|||||||
Reference in New Issue
Block a user