vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Tail run logs in the browser and make steps accordions
34d6fd7a9210fca265ea4aea1d5733b3eeeb2569 · Keenan Nemetz · 10d ago · parent 5ece70a5
Tail run logs in the browser and make steps accordions The log endpoint now serves from a byte offset and marks an attempt's log final, so the run page can poll only while the job runs. The job output accordion follows the stream; each step is its own accordion with its slice or a clear empty state. The steps table is gone; the logs card is the whole viewer.
server/web/static/app.js +70
| @@ -564,3 +564,73 @@ const copyText = (value, done) => { | |||
| 564 | 564 | }; | |
| 565 | 565 | document.head.appendChild(script); | |
| 566 | 566 | })(); | |
| 567 | |||
| 568 | // Live CI job output. The server serves the attempt's file from a byte offset and | ||
| 569 | // says when the log is final; the page asks again only while the job is running. | ||
| 570 | // Chunks are decoded with a streaming TextDecoder because a multi-byte character | ||
| 571 | // can straddle two byte offsets. | ||
| 572 | (() => { | ||
| 573 | const follow = (pre) => { | ||
| 574 | if (pre.dataset.logStreaming) return; | ||
| 575 | pre.dataset.logStreaming = '1'; | ||
| 576 | const url = pre.dataset.logUrl; | ||
| 577 | if (!url) return; | ||
| 578 | const decoder = new TextDecoder(); | ||
| 579 | let offset = 0; | ||
| 580 | let stopped = false; | ||
| 581 | const finalOn404 = pre.dataset.logFinal === '1'; | ||
| 582 | const schedule = () => { | ||
| 583 | if (!stopped) setTimeout(tick, 1000); | ||
| 584 | }; | ||
| 585 | const tick = async () => { | ||
| 586 | let res; | ||
| 587 | try { | ||
| 588 | res = await fetch(url + (url.includes('?') ? '&' : '?') + 'after=' + offset, { | ||
| 589 | cache: 'no-store', | ||
| 590 | credentials: 'same-origin', | ||
| 591 | }); | ||
| 592 | } catch (_) { | ||
| 593 | schedule(); | ||
| 594 | return; | ||
| 595 | } | ||
| 596 | if (res.status === 404) { | ||
| 597 | if (finalOn404 || res.headers.get('X-Log-Final') === '1') return; | ||
| 598 | schedule(); | ||
| 599 | return; | ||
| 600 | } | ||
| 601 | if (!res.ok) { | ||
| 602 | schedule(); | ||
| 603 | return; | ||
| 604 | } | ||
| 605 | let buf; | ||
| 606 | try { | ||
| 607 | buf = await res.arrayBuffer(); | ||
| 608 | } catch (_) { | ||
| 609 | schedule(); | ||
| 610 | return; | ||
| 611 | } | ||
| 612 | if (buf.byteLength) { | ||
| 613 | const stick = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 12; | ||
| 614 | pre.appendChild(document.createTextNode(decoder.decode(buf, { stream: true }))); | ||
| 615 | offset += buf.byteLength; | ||
| 616 | if (stick) pre.scrollTop = pre.scrollHeight; | ||
| 617 | } | ||
| 618 | if (res.headers.get('X-Log-Final') === '1') { | ||
| 619 | pre.appendChild(document.createTextNode(decoder.decode())); | ||
| 620 | pre.dataset.logStreaming = 'done'; | ||
| 621 | return; | ||
| 622 | } | ||
| 623 | schedule(); | ||
| 624 | }; | ||
| 625 | tick(); | ||
| 626 | }; | ||
| 627 | |||
| 628 | document.querySelectorAll('details').forEach((details) => { | ||
| 629 | const pre = details.querySelector('pre[data-log-stream]'); | ||
| 630 | if (!pre) return; | ||
| 631 | if (details.open) follow(pre); | ||
| 632 | details.addEventListener('toggle', () => { | ||
| 633 | if (details.open) follow(pre); | ||
| 634 | }); | ||
| 635 | }); | ||
| 636 | })(); | ||