diff --git a/src/server/app.js b/src/server/app.js index 0712d82..68ab3aa 100644 --- a/src/server/app.js +++ b/src/server/app.js @@ -249,7 +249,10 @@ export function createApp(ctx) { const contributors = store.all(`SELECT u.name, COUNT(*) AS merged FROM attempts a JOIN users u ON u.id = a.user_id WHERE a.state = 'merged' GROUP BY u.id ORDER BY merged DESC LIMIT 20`); const confirmed = store.get(`SELECT COUNT(*) AS n, COALESCE(SUM(a.size), 0) AS bytes FROM func_status fs JOIN symbol_addresses a ON a.symbol_id = fs.symbol_id JOIN versions v ON v.id = a.version_id AND v.is_current = 1 WHERE fs.confirmed = 1 AND fs.status NOT IN ('exact', 'match_unresolved')`); const peerQueue = store.get("SELECT COUNT(*) AS n FROM attempts WHERE state = 'peer_review'").n; - return { functions: total.n, bytes: total.bytes, byStatus, confirmed, peerQueue, byTaskState, contributors }; + // Functions merged into main per hour: the last hour, and the average over the last day. + const mergedSince = (ms) => store.get(`SELECT COALESCE(SUM(json_extract(data, '$.functions')), 0) AS n FROM events WHERE type = 'merged' AND at > ?`, Date.now() - ms).n; + const merges = { lastHour: mergedSince(3600_000), perHourDay: mergedSince(24 * 3600_000) / 24 }; + return { functions: total.n, bytes: total.bytes, byStatus, confirmed, peerQueue, byTaskState, contributors, merges }; })); // --- accounts diff --git a/src/server/web/app.js b/src/server/web/app.js index 55ff22b..7e27eca 100644 --- a/src/server/web/app.js +++ b/src/server/web/app.js @@ -61,46 +61,68 @@ function formData(form) { // --- public -let lastPct = 0; // the gauge moves from the previous reading, not from zero, when it refreshes +const lastBars = {}; // bars move from the previous reading, not from zero, when they refresh async function loadProgress() { const p = await api('/api/progress'); const body = $('progress-body'); body.replaceChildren(); body.className = ''; - // Byte for byte: exact, and identical bytes whose referenced data has no name yet. - const exact = p.byStatus.filter((s) => s.status === 'exact' || s.status === 'match_unresolved').reduce((t, s) => ({ n: t.n + s.n, bytes: t.bytes + s.bytes }), { n: 0, bytes: 0 }); - const pct = p.bytes ? (exact.bytes / p.bytes) * 100 : 0; - - const gauge = el('div', null, 'gauge'); - gauge.append(el('div', `${pct.toFixed(2).padStart(6, '0')}%`, 'readout')); - const meter = el('div', null, 'meter'); - meter.setAttribute('role', 'progressbar'); - meter.setAttribute('aria-valuemin', '0'); - meter.setAttribute('aria-valuemax', '100'); - meter.setAttribute('aria-valuenow', pct.toFixed(2)); - const fill = el('div', null, 'fill'); - fill.style.setProperty('width', `${Math.min(100, lastPct)}%`); - lastPct = pct; - meter.append(fill); - gauge.append(meter); - const conf = p.confirmed?.n ?? 0; - if (!p.functions) gauge.append(el('p', 'No target loaded yet. The machines are warming up.', 'gauge-caption')); - else { - // Both measures: functions (many small ones are done) and bytes of code (how much of the binary). - const share = (a, b) => `${b ? ((a / b) * 100).toFixed(2) : '0.00'}%`; - const kb = (n) => `${(n / 1024).toLocaleString(undefined, { maximumFractionDigits: 0 })} KB`; - const counts = el('div', null, 'gauge-counts'); - counts.append( - el('div', `Functions: ${exact.n.toLocaleString()} of ${p.functions.toLocaleString()} byte-identical (${share(exact.n, p.functions)})`), - el('div', `Code: ${kb(exact.bytes)} of ${kb(p.bytes)} byte-identical (${share(exact.bytes, p.bytes)})`), - ); - gauge.append(counts); - const extra = [conf ? `${conf.toLocaleString()} more confirmed by peer review` : '', p.peerQueue ? `${p.peerQueue.toLocaleString()} waiting for peer review` : ''].filter(Boolean); - if (extra.length) gauge.append(el('p', extra.join(' · '), 'gauge-caption')); + // Done = merged into main: byte-identical, plus peer-confirmed (same logic, other bytes). The bars + // show the total; the byte-identical part has its own colour. + const identical = p.byStatus.filter((s) => s.status === 'exact' || s.status === 'match_unresolved').reduce((t, s) => ({ n: t.n + s.n, bytes: t.bytes + s.bytes }), { n: 0, bytes: 0 }); + const confirmed = { n: p.confirmed?.n ?? 0, bytes: p.confirmed?.bytes ?? 0 }; + if (!p.functions) { + body.append(el('p', 'No target loaded yet. The machines are warming up.', 'muted')); + return; } - body.append(gauge); - requestAnimationFrame(() => fill.style.setProperty('width', `${Math.min(100, pct)}%`)); + const pct = (a, b) => (b ? (a / b) * 100 : 0); + const kb = (n) => `${(n / 1024).toLocaleString(undefined, { maximumFractionDigits: 0 })} KB`; + const bars = el('div', null, 'progress-bars'); + const bar = (key, label, done, of, fmt) => { + const total = done.identical + done.confirmed; + const row = el('div', null, 'pbar'); + row.append(el('div', `${pct(total, of).toFixed(2)}%`, 'readout')); + const right = el('div', null, 'pbar-body'); + right.append(el('div', `${label}: ${fmt(total)} of ${fmt(of)} done`, 'pbar-label')); + const meter = el('div', null, 'meter'); + meter.setAttribute('role', 'progressbar'); + meter.setAttribute('aria-label', label); + meter.setAttribute('aria-valuemin', '0'); + meter.setAttribute('aria-valuemax', '100'); + meter.setAttribute('aria-valuenow', pct(total, of).toFixed(2)); + const segs = [ + ['identical', pct(done.identical, of), `byte-identical: ${fmt(done.identical)}`], + ['confirmed', pct(done.confirmed, of), `confirmed by peer review: ${fmt(done.confirmed)}`], + ]; + for (const [cls, w, title] of segs) { + const seg = el('div', null, `fill ${cls}`); + seg.title = title; + // Grows from the previous reading, not from zero, when the page refreshes. + seg.style.setProperty('width', `${Math.min(100, lastBars[key + cls] ?? 0)}%`); + lastBars[key + cls] = w; + meter.append(seg); + requestAnimationFrame(() => seg.style.setProperty('width', `${Math.min(100, w)}%`)); + } + right.append(meter, el('div', `byte-identical ${fmt(done.identical)} (${pct(done.identical, of).toFixed(2)}%) · peer-confirmed ${fmt(done.confirmed)} (${pct(done.confirmed, of).toFixed(2)}%)`, 'pbar-detail')); + row.append(right); + bars.append(row); + }; + bar('fn', 'Functions', { identical: identical.n, confirmed: confirmed.n }, p.functions, (n) => n.toLocaleString()); + bar('by', 'Code', { identical: identical.bytes, confirmed: confirmed.bytes }, p.bytes, kb); + const legend = el('div', null, 'pbar-legend'); + legend.append(el('span', 'byte-identical', 'swatch identical'), el('span', 'confirmed by peer review', 'swatch confirmed')); + if (p.peerQueue) legend.append(el('span', `${p.peerQueue.toLocaleString()} waiting for peer review`, 'muted')); + if (p.merges) { + const rate = el('div', null, 'merge-rate'); + rate.append( + el('span', `${p.merges.lastHour.toLocaleString()}`, 'merge-rate-n'), + el('span', ` functions merged in the last hour · ${p.merges.perHourDay.toFixed(1)}/h on average over 24 h`, 'muted'), + ); + bars.append(rate); + } + bars.append(legend); + body.append(bars); const stats = el('div', null, 'stats'); if (p.byTaskState.length) stats.append(table(['Task state', 'Count'], p.byTaskState.map((s) => [withInfo(s.state, TASK_STATES[s.state]), s.n]))); diff --git a/src/server/web/style.css b/src/server/web/style.css index dc71fa2..7bdbc6d 100644 --- a/src/server/web/style.css +++ b/src/server/web/style.css @@ -533,3 +533,21 @@ details.fold[open] > summary { margin-bottom: 6px; } /* --- progress: both measures */ .gauge-counts { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 4px 24px; margin: 2px 0; font: 14px var(--font-mono); } + +/* --- progress bars: done = byte-identical (stripes) + peer-confirmed (solid) */ +.progress-bars { display: flex; flex-direction: column; gap: 16px; } +.pbar { display: grid; grid-template-columns: 9.5em 1fr; gap: 6px 18px; align-items: center; } +.pbar .readout { font-size: 26px; text-align: right; } +.pbar-body { min-width: 0; } +.pbar-label { font: 600 14px var(--font-body); margin-bottom: 5px; } +.pbar-detail { font: 12px var(--font-mono); color: var(--muted); margin-top: 4px; } +.meter { display: flex; } +.meter .fill.confirmed { background: #3b8fd4; animation: none; min-width: 0; border-left: 1px solid rgba(0, 0, 0, 0.35); } +.meter .fill.identical { min-width: 0; } +.pbar-legend { display: flex; flex-wrap: wrap; gap: 6px 18px; font-size: 13px; } +.swatch::before { content: ""; display: inline-block; width: 12px; height: 12px; margin-right: 6px; border-radius: 2px; vertical-align: -1px; } +.swatch.identical::before { background: repeating-linear-gradient(-45deg, var(--accent) 0 4px, #d99a00 4px 8px); } +.swatch.confirmed::before { background: #3b8fd4; } +@media (max-width: 600px) { .pbar { grid-template-columns: 1fr; } .pbar .readout { text-align: left; } } +.merge-rate { font-size: 14px; } +.merge-rate-n { font: 700 18px var(--font-mono); color: var(--accent-ink); }