From 62a838ebdde60b5287ae1e99aa0c4612d43f2454 Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Thu, 6 Aug 2026 22:00:44 -0400 Subject: [PATCH] Clicking a recent error opens the log on that line and acks it, so acknowledged means seen --- Plugin/unraid/css/varaverk.css | 8 +++ Plugin/unraid/pages/scheduler.php | 99 ++++++++++++++++++++++++++++++- 2 files changed, 104 insertions(+), 3 deletions(-) diff --git a/Plugin/unraid/css/varaverk.css b/Plugin/unraid/css/varaverk.css index 7c2f0f0..9b7b464 100644 --- a/Plugin/unraid/css/varaverk.css +++ b/Plugin/unraid/css/varaverk.css @@ -701,6 +701,14 @@ mark { background: #5d4037; color: #ffcc80; border-radius: 2px; } color: #555; border-color: #333; flex-shrink: 0; } .vv-ack-btn:hover { color: #aaa; border-color: #555; } .vv-err-line { color: #888; font-size: 11px; word-break: break-all; line-height: 1.4; } +/* The error text opens the log at that line. Underlined on hover only — a permanently decorated + line of log output is harder to read, and this block exists to be read first. */ +.vv-err-line-open { cursor: pointer; } +.vv-err-line-open:hover { color: #ccc; text-decoration: underline; text-decoration-color: #4a4a4a; } +/* The line the operator was sent to. Left bar rather than a full highlight: the log is dense and + a filled background over one wrapped line reads as a block of colour, not a pointer. */ +.vv-log-jump { background: #23303a; color: #dfe8ee; border-left: 3px solid #4a7ea0; + padding-left: 5px; margin-left: -8px; display: inline-block; width: calc(100% + 3px); } /* Locks */ .vv-locks-list { display: flex; flex-direction: column; } diff --git a/Plugin/unraid/pages/scheduler.php b/Plugin/unraid/pages/scheduler.php index 814e82d..2970178 100644 --- a/Plugin/unraid/pages/scheduler.php +++ b/Plugin/unraid/pages/scheduler.php @@ -1356,7 +1356,14 @@ function vvBackToSuggestions() { requestAnimationFrame(vvFitRight); } +// Text of the log line to keep marked, and whether the next render should scroll to it. Cleared +// by anything that changes what is on screen — a different job, a search — so a mark can never +// outlive the thing it was pointing at. +let vvLogMark = null; +let vvLogMarkScroll = false; + function vvOpenRight(id) { + vvLogMark = null; vvLogMarkScroll = false; if (vvActiveId) { const old = document.querySelector('[data-id="' + CSS.escape(vvActiveId) + '"]'); if (old) old.querySelector('.vv-job-row').classList.remove('vv-row-selected'); @@ -1390,8 +1397,18 @@ function vvFetchRight() { const display = content.trim() ? (invert ? content.split('\n').reverse().join('\n') : content) : '(no log yet)'; pre._raw = display; const term = document.getElementById('vv-log-search')?.value.trim() ?? ''; - if (term) vvFilterLog(term); else pre.textContent = display; - if (autoScroll) { + // Captured before the call, which consumes the flag: this render is the one that centres. + const willCentre = !!vvLogMark && vvLogMarkScroll; + let marked = false; + if (term) vvFilterLog(term); + else if (vvLogMark) { marked = vvMarkLogLine(pre, display, invert); if (!marked) pre.textContent = display; } + else pre.textContent = display; + // A jump that has just placed the line mid-view must not be immediately undone by the + // auto-scroll-to-bottom every other render wants; later polls keep wherever the operator + // has scrolled to while reading it. + if (marked) { + if (!willCentre) requestAnimationFrame(() => { pre.scrollTop = savedScroll; }); + } else if (autoScroll) { pre.scrollTop = invert ? 0 : pre.scrollHeight; } else { requestAnimationFrame(() => { pre.scrollTop = savedScroll; }); @@ -2671,6 +2688,71 @@ function vvErrIsAcked(script, ts) { return parseInt(localStorage.getItem('vv-ack-' + script) || '0') >= ts; } +// Opens the offending line in the log, and acknowledges it in the same click. +// +// Acking here is safe in a way a bare Ack button is not: the operator cannot dismiss this without +// the line being put in front of them, so "acknowledged" means it was seen rather than cleared. +// That is the only reason the two actions belong on one click. +// +// The line is located by its text rather than by a line number. board.php scans the last 200 +// lines and log.php serves the last 200, so an index would agree only while the log sat still — +// and these are logs of things that are still running. Matching the text survives the window +// moving underneath it, and when the line has genuinely scrolled out of the tail the log still +// opens, just without the mark. +function vvErrOpenAtLine(script, ts, lineText, el) { + const search = document.getElementById('vv-log-search'); + // A filter would hide the surrounding lines, which are the reason for opening the log at all. + if (search && search.value.trim()) search.value = ''; + vvOpenRight(script + '.sh'); + vvLogMark = lineText; + vvLogMarkScroll = true; + vvAckError(script, ts, el); +} + +// Marks one line in the rendered log and, the first time, scrolls it to the middle. Re-applied on +// every poll so a live log does not blink the mark away while it is being read. +function vvMarkLogLine(pre, display, invert) { + const want = (vvLogMark || '').trim(); + if (!want) return false; + const lines = display.split('\n'); + + // Scanned in file order from the end, because that is the occurrence board.php reported: it + // walks the log backwards and stops at the first hit. Taking the first match instead would mark + // an earlier copy of a line that repeats — "Findings: 1", "✗ failed" — and quietly point at a + // previous run in the same file. Inverting the view reverses file order, so the direction has + // to follow it. + const n = lines.length; + const seek = test => { + for (let k = 0; k < n; k++) { + const i = invert ? k : n - 1 - k; + if (test(lines[i])) return i; + } + return -1; + }; + + let idx = seek(l => l.trim() === want); + // board.php truncates the line at 220 characters, so an exact match is not guaranteed. + if (idx < 0) idx = seek(l => l.includes(want)); + if (idx < 0 && want.length > 40) idx = seek(l => l.includes(want.slice(0, 40))); + if (idx < 0) return false; + + const esc = s => s.replace(/&/g, '&').replace(//g, '>'); + pre.innerHTML = lines.map((l, i) => i === idx + ? '' + esc(l) + '' + : esc(l)).join('\n'); + + if (vvLogMarkScroll) { + vvLogMarkScroll = false; + const hit = document.getElementById('vv-log-jump-hit'); + if (hit) requestAnimationFrame(() => { + // Measured against the pre rather than scrollIntoView(), which would also scroll the page. + const rel = hit.getBoundingClientRect().top - pre.getBoundingClientRect().top + pre.scrollTop; + pre.scrollTop = Math.max(0, rel - pre.clientHeight / 2); + }); + } + return true; +} + function vvAckError(script, ts, btn) { localStorage.setItem('vv-ack-' + script, ts); const row = btn.closest('.vv-err-row'); @@ -2706,6 +2788,11 @@ function vvUpdateErrors(errors) { let html = '
'; for (const e of unacked) { const jobId = "'" + (e.script + '.sh').replace(/\\/g,"\\\\").replace(/'/g,"\\'") + "'"; + // JSON.stringify rather than the hand-rolled quote escaping above: this one is embedded in an + // attribute alongside the error text, which is arbitrary log output and not to be trusted to + // contain no quotes. + const scriptJs = vvEscHtml(JSON.stringify(e.script)); + const lineJs = vvEscHtml(JSON.stringify(e.line)); const label = e.script.split('/').pop().replace(/_/g, ' '); html += '
' + '
' @@ -2713,7 +2800,10 @@ function vvUpdateErrors(errors) { + '' + vvFmtAge(now - e.ts) + '' + '' + '
' - + '
' + vvEscHtml(e.line) + '
' + + '
' + + vvEscHtml(e.line) + '
' + '
'; } body.innerHTML = html + '
'; @@ -2775,6 +2865,9 @@ function vvValidateCronExpr(expr) { function vvFilterLog(term) { const pre = document.getElementById('vv-log-pre'); const raw = pre._raw || ''; + // Searching replaces the jumped-to line as the thing being looked for; leaving the mark set + // would put it back on the next poll and fight the filter. + if (term.trim()) { vvLogMark = null; vvLogMarkScroll = false; } if (!term.trim()) { pre.textContent = raw; return; } const esc = s => s.replace(/&/g,'&').replace(//g,'>'); const escRe = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');