Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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