fix(989): correct four live build-log viewer bugs in logs.js

- live detection: use !h.status (null while running) not === 'running'
- SSE: replace named addEventListener('chunk'/'done') with onmessage +
  frame.done check — backend sends generic message events, no named type
- SSE payload: use frame.stdout_append/stderr_append not frame.text
- timestamps: started_at/finished_at are unix seconds, multiply by 1000
  was already done implicitly — actually use unix arithmetic directly
  to avoid new Date() confusion entirely

Also move the misplaced .flow-main-slim rule from the logs section to
the flow-shell section of dashboard.css.
This commit is contained in:
iris 2026-06-01 19:18:30 +02:00
commit ee61eac663
2 changed files with 43 additions and 27 deletions

View file

@ -1805,6 +1805,11 @@ body.flow-shell .tabbar .tab.active.tab-link {
bottom: 0;
overflow: hidden;
}
/* flow.html now has a slim header (back link only) instead of the full
tabbar reduce the top padding to match the smaller header height. */
body.flow-shell .flow-main-slim {
padding-top: calc(var(--flow-header-h) + 0.4em);
}
.flow-main .terminal-wrap {
position: absolute;
inset: 0;
@ -1864,9 +1869,6 @@ body.logs-shell {
margin: 0;
padding: 0;
}
body.flow-shell .flow-main-slim {
padding-top: calc(var(--flow-header-h) + 0.4em);
}
.logs-header {
position: sticky;

View file

@ -40,13 +40,11 @@ import {
// ─── helpers ──────────────────────────────────────────────────────────
function fmtTs(isoStr) {
if (!isoStr) return '';
try {
const d = new Date(isoStr);
const age = Math.floor((Date.now() - d.getTime()) / 1000);
return fmtAgeSecs(age) + ' ago';
} catch { return isoStr; }
// started_at / finished_at are unix seconds (i64), not milliseconds.
function fmtTs(unixSecs) {
if (!unixSecs) return '';
const age = Math.floor(Date.now() / 1000) - unixSecs;
return fmtAgeSecs(Math.max(0, age)) + ' ago';
}
function fmtDuration(secs) {
@ -76,10 +74,11 @@ import {
const ul = el('ul', { class: 'build-logs-list' });
for (const h of rows) {
const li = el('li', { class: 'build-logs-item' });
// status is null while running, 'ok'/'fail' when finished.
const live = !h.status;
const ok = h.status === 'ok';
const live = h.status === 'running';
const statusClass = ok ? 'build-logs-ok' : live ? 'build-logs-live-badge badge badge-running' : 'build-logs-fail';
const statusLabel = ok ? '✓' : live ? 'live' : '✗';
const statusClass = live ? 'badge badge-running' : ok ? 'badge badge-ok' : 'badge badge-fail';
const statusLabel = live ? 'live' : ok ? 'ok' : 'fail';
const age = h.finished_at ? fmtTs(h.finished_at) : (live ? '' : fmtTs(h.started_at));
const runtime = h.runtime_secs != null
? el('span', { class: 'build-logs-runtime meta' }, fmtDuration(Math.max(0, h.runtime_secs)))
@ -125,20 +124,35 @@ import {
const badge = el('span', { class: 'build-logs-live-badge badge badge-running' }, 'live');
detail.append(badge, pre);
streamEs = new EventSource('/api/build-logs/id/' + h.id + '/stream');
streamEs.addEventListener('chunk', (e) => {
try { pre.textContent += JSON.parse(e.data).text; } catch { /**/ }
});
streamEs.addEventListener('done', () => {
badge.textContent = 'done';
badge.className = 'build-logs-live-badge badge';
streamEs.close(); streamEs = null;
detail.dataset.loaded = '1';
});
streamEs.addEventListener('error', () => {
badge.textContent = 'stream error';
badge.className = 'build-logs-live-badge badge';
streamEs.close(); streamEs = null;
});
let stdoutLen = 0, stderrLen = 0;
streamEs.onmessage = (e) => {
let frame;
try { frame = JSON.parse(e.data); } catch { return; }
if (frame.stdout_append) {
pre.textContent += frame.stdout_append;
stdoutLen += frame.stdout_append.length;
}
if (frame.stderr_append) {
if (stderrLen === 0) pre.textContent += '\n--- stderr ---\n';
pre.textContent += frame.stderr_append;
stderrLen += frame.stderr_append.length;
}
if (frame.done) {
badge.className = frame.status === 'ok' ? 'badge badge-ok' : 'badge badge-fail';
badge.textContent = frame.status || 'done';
streamEs.close(); streamEs = null;
detail.dataset.loaded = '1';
}
};
streamEs.onerror = () => {
if (streamEs && streamEs.readyState === EventSource.CONNECTING) {
pre.textContent = ''; stdoutLen = 0; stderrLen = 0;
} else {
badge.textContent = 'stream error';
badge.className = 'badge badge-fail';
if (streamEs) { streamEs.close(); streamEs = null; }
}
};
} else {
const pre = el('pre', { class: 'build-logs-output' }, 'fetching…');
detail.append(pre);