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 = '