🔒 Repository is read-only – file editing is disabled.

PaganLinux/pagan-web-v2/templates/build/dashboard.html main

1082 linii Raw ← Powrót
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082
{% extends "base.html" %}{% block title %}Build Panel{% endblock %}
{% block head %}
<style>
/* ============================================================
 PaganOS Build Panel v2 – layout czytelny, karty, siatki
 ============================================================ */
.bld-nav { position: sticky; top: 0; z-index: 60; display: flex; flex-wrap: wrap; gap: 0.45rem;
 align-items: center; background: color-mix(in srgb, var(--bg) 88%, transparent);
 backdrop-filter: blur(6px); padding: 0.5rem 0; margin-bottom: 1rem;
 border-bottom: 1px solid var(--border); }
.bld-nav.grow { flex: 1; }
.bld-nav a.pill { padding: 0.32rem 0.8rem; border-radius: 999px; font-size: 0.78rem;
 text-decoration: none; color: var(--fg); border: 1px solid var(--border);
 background: var(--bg-card); transition: border-color.15s, color.15s; }
.bld-nav a.pill:hover { border-color: var(--accent); color: var(--accent); }
.live-pill { display: inline-flex; align-items: center; gap: 0.45rem; font-size: 0.76rem;
 padding: 0.28rem 0.7rem; border-radius: 999px; border: 1px solid var(--border);
 color: var(--muted); background: var(--bg-card); white-space: nowrap; }
.live-dot { width: 9px; height: 9px; border-radius: 50%; background: #666; flex: none; }
.live-dot.on { background: #3fb950; box-shadow: 0 0 8px #3fb950; animation: pulse 1.2s infinite; }
.live-dot.busy { background: var(--yellow); box-shadow: 0 0 8px var(--yellow); animation: pulse 0.8s infinite; }
@keyframes pulse { 50% { opacity: 0.25; } }

/* karty metryk */
.bld-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.7rem; margin-bottom: 1rem; }
.bld-stat { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius);
 padding: 0.85rem 1rem; display: flex; flex-direction: column; gap: 0.25rem; }
.bld-stat.val { font-size: 1.7rem; font-weight: 800; line-height: 1.15; letter-spacing: -0.02em; }
.bld-stat.val small { font-size: 0.85rem; font-weight: 600; opacity: .75; }
.bld-stat.lbl { font-size: 0.7rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.07em; }
.bld-stat.ok.val { color: #3fb950; } .bld-stat.fail.val { color: var(--red); }
.bld-stat.acc.val { color: var(--accent); } .bld-stat.warn.val { color: var(--yellow); }

/* pasek statystyk (JS /api/stats) */
#stats-strip { display: none; margin-bottom: 1rem; }
#stats-strip .inner { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.6rem; }
.mini { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 0.5rem 0.75rem; display: flex; flex-direction: column; gap: 0.15rem; min-width: 0; }
.mini .mv { font-size: 1.02rem; font-weight: 800; color: var(--fg); font-variant-numeric: tabular-nums; line-height: 1.25; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mini .mv .g { color: #3fb950; } .mini .mv .y { color: var(--yellow); } .mini .mv .r { color: var(--red); } .mini .mv .a { color: var(--accent); }
.mini .mv .dim { color: var(--muted); font-weight: 600; }
.mini .ml { font-size: 0.64rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); }

/* karty / sekcje */
.bld-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 1rem; }
.bld-card > .hd { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;
 flex-wrap: wrap; padding: 0.55rem 0.9rem; border-bottom: 1px solid var(--border); }
.bld-card > .hd h3 { margin: 0; font-size: 0.86rem; letter-spacing: 0.02em; }
.bld-card > .hd.sub { font-size: 0.72rem; color: var(--muted); font-weight: 400; }
.bld-card > .bd { padding: 0.6rem 0.9rem; }
.bld-grid-2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; align-items: start; }
@media (max-width: 1150px) { .bld-grid-2 { grid-template-columns: 1fr; } }

/* przyciski akcji – grupowane */
.bld-actions { display: flex; flex-wrap: wrap; gap: 0.45rem; }
.bld-actions.grp { display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center;
 padding-right: 0.6rem; margin-right: 0.25rem; border-right: 1px solid var(--border); }
.bld-actions.grp:last-child { border-right: none; margin-right: 0; padding-right: 0; }
.bld-actions.grp.glbl { font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.08em;
 color: var(--muted); margin-right: 0.15rem; }
.bld-btn { padding: 0.42rem 0.85rem; border: 1px solid var(--border); border-radius: var(--radius-sm);
 font-weight: 600; cursor: pointer; font-size: 0.76rem; background: var(--bg);
 color: var(--fg); transition: opacity.15s, border-color.15s; white-space: nowrap; }
.bld-btn:hover:not(:disabled) { border-color: var(--accent); }
.bld-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.bld-btn.pri { background: var(--accent); border-color: var(--accent); color: #000; }
.bld-btn.warn2 { background: #f0883e; border-color: #f0883e; color: #000; }
.bld-btn.danger { background: var(--red); border-color: var(--red); color: #fff; }
.bld-btn.ghost { background: transparent; }

/* konsola */
.bld-console { background: #07090d; border: 1px solid #161b22; border-radius: var(--radius-sm);
 padding: 0.6rem 0.7rem; max-height: 460px; overflow-y: auto; font-family: ui-monospace, 'JetBrains Mono', 'Fira Code', monospace;
 font-size: 0.76rem; line-height: 1.65; }
.bld-console.bld-row { display: flex; align-items: baseline; gap: 0.5rem; padding: 0.14rem 0;
 border-bottom: 1px solid #0c0f14; }
.bld-console.icon { width: 1.15rem; text-align: center; flex-shrink: 0; }
.bld-console.pkg { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.bld-console.ok { color: #3fb950; } .bld-console.error { color: var(--red); }
.bld-console.info { color: var(--accent); } .bld-console.warn { color: var(--yellow); }
.bld-console.dim { color: #4a5260; font-size: 0.68rem; flex: none; }
.console-tools { display: flex; gap: 0.35rem; }
.chk-lbl { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.72rem;
 color: var(--muted); cursor: pointer; padding: 0.2rem 0.45rem; border: 1px solid var(--border);
 border-radius: var(--radius-sm); background: var(--bg); }
.chk-lbl input { accent-color: var(--accent); }

/* tabele */
.bld-table { width: 100%; font-size: 0.76rem; border-collapse: collapse; }
.bld-table th { position: sticky; top: 0; z-index: 1; background: #10151c; color: var(--muted);
 text-align: left; padding: 0.4rem 0.55rem; border-bottom: 2px solid var(--border);
 font-weight: 600; font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.05em; }
.bld-table td { padding: 0.32rem 0.55rem; border-bottom: 1px solid #0f141b; vertical-align: middle; }
.bld-table tbody tr:hover { background: #121820; }
.bld-table tr.clickable { cursor: pointer; }
.bld-table.num { text-align: right; font-variant-numeric: tabular-nums; }
.bld-table.mono { font-family: ui-monospace, monospace; }
.tblwrap { overflow-y: auto; }

/* chips / badge */
.chip { display: inline-block; padding: 0.06rem 0.5rem; border-radius: 999px; font-size: 0.66rem;
 border: 1px solid var(--border); color: var(--muted); white-space: nowrap; }
.chip-ok { color: #3fb950; border-color: #3fb950; }
.chip-fail { color: #f85149; border-color: #f85149; }
.chip-warn { color: #d29922; border-color: #d29922; }
.chip-acc { color: var(--accent); border-color: var(--accent); }
.repo-badge { display: inline-block; font-size: 0.6rem; padding: 0 0.4rem; border: 1px solid #3fb950;
 color: #3fb950; border-radius: 3px; margin-right: 0.35rem; vertical-align: 0.08em; }
.repo-missing { display: inline-block; font-size: 0.6rem; padding: 0 0.4rem; border: 1px solid var(--border);
 color: var(--muted); border-radius: 3px; margin-right: 0.35rem; }

/* procesy */
.proc-list { display: flex; flex-direction: column; }
.proc-row { display: flex; align-items: center; gap: 0.6rem; padding: 0.4rem 0.5rem;
 border-bottom: 1px solid #0f141b; font-size: 0.75rem; }
.proc-cmd { font-family: ui-monospace, monospace; color: var(--accent); overflow: hidden;
 text-overflow: ellipsis; white-space: nowrap; flex: 1; direction: rtl; text-align: left; }
.proc-meta { color: var(--muted); font-family: ui-monospace, monospace; font-size: 0.68rem; flex: none; }
.empty { color: var(--muted); font-size: 0.78rem; padding: 0.9rem 0.3rem; text-align: center; }

/* filtr / input */
.bld-input, .bld-select { padding: 0.4rem 0.55rem; background: #0b0f14; border: 1px solid var(--border);
 color: var(--fg); font-size: 0.78rem; border-radius: var(--radius-sm); }
.bld-input:focus, .bld-select:focus { outline: none; border-color: var(--accent); }
.filterbar { display: flex; gap: 0.4rem; flex-wrap: wrap; padding: 0.5rem 0.9rem;
 border-bottom: 1px solid var(--border); }

/* modal */
.bld-modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.78);
 z-index: 9999; justify-content: center; align-items: flex-start; padding: 2rem 1rem; overflow-y: auto; }
.bld-modal-overlay.active { display: flex; }
.bld-modal { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius);
 width: 100%; max-width: 960px; max-height: 88vh; display: flex; flex-direction: column; }
.bld-modal-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem;
 padding: 0.9rem 1.1rem; border-bottom: 1px solid var(--border); }
.bld-modal-header h3 { margin: 0; font-size: 1rem; }
.bld-modal-header.meta { font-size: 0.74rem; color: var(--muted); margin-top: 0.3rem; }
.bld-modal-close { background: none; border: none; color: var(--muted); font-size: 1.6rem;
 cursor: pointer; line-height: 1; padding: 0 0.25rem; }
.bld-modal-close:hover { color: var(--fg); }
.bld-modal-body { padding: 1rem 1.1rem; overflow-y: auto; }
.bld-log-full { font-family: ui-monospace, 'JetBrains Mono', monospace; font-size: 0.74rem;
 line-height: 1.55; white-space: pre-wrap; word-break: break-word; background: #07090d;
 padding: 0.8rem; border-radius: var(--radius-sm); max-height: 55vh; overflow-y: auto; }
.bld-log-full.err { color: var(--red); } .bld-log-full.wrn { color: var(--yellow); }
.bld-log-full.dim { color: #555; }
.recipe-area { width: 100%; min-height: 46vh; font-family: ui-monospace, monospace; font-size: 0.76rem;
 background: #07090d; color: #e6edf3; border: 1px solid var(--border);
 border-radius: var(--radius-sm); padding: 0.7rem; resize: vertical; white-space: pre; overflow: auto; }
.deps-row>td{background:var(--bg2);padding:0.4rem 0.9rem;border-bottom:1px solid var(--border)}
.deps-wrap{display:flex;flex-wrap:wrap;gap:0.35rem;align-items:center}
.deps-lbl{font-size:0.62rem;text-transform:uppercase;letter-spacing:0.06em;color:var(--muted);margin-right:0.15rem;font-weight:700}
.dchip{font-family:var(--mono);font-size:0.72rem;background:var(--bg);border:1px solid var(--border);padding:0.08rem 0.5rem;border-radius:999px;color:var(--text)}
.dchip.ok{border-color:#3fb950;color:#3fb950}
.dchip.fail{border-color:var(--red);color:var(--red)}
.dchip.warn{border-color:var(--yellow);color:var(--yellow)}
.dchip.dep-chip{cursor:pointer;transition:var(--transition)}
.dchip.dep-chip:hover{border-color:var(--accent);color:var(--accent);transform:translateY(-1px)}
.deps-empty{color:var(--muted);font-size:0.78rem}
.dep-info{margin-top:0.45rem;padding:0.45rem 0.7rem;background:var(--bg);border:1px dashed var(--border);border-radius:var(--radius-sm);display:flex;flex-wrap:wrap;gap:0.3rem 1rem;align-items:center;font-size:0.76rem}
.dep-name{font-weight:800;color:var(--text-bright);font-family:var(--mono)}
.dep-status{font-weight:700;text-transform:uppercase;font-size:0.66rem;letter-spacing:0.04em}
.dep-ok{color:#3fb950}.dep-fail{color:var(--red)}.dep-pend{color:var(--yellow)}
.dep-away{color:var(--muted)}
.dep-info b{color:var(--text-bright);font-variant-numeric:tabular-nums}
.gline{margin-top:0.18rem}
.gchip{display:inline-block;font-size:0.62rem;color:var(--accent);border:1px solid rgba(63,185,80,0.35);border-radius:99px;padding:0 0.45rem;margin-right:0.25rem;background:rgba(63,185,80,0.08);font-weight:600}
/* Jasny motyw: powierzchnie panelu dostosowane (konsola/edytor zostają ciemne) */
html[data-theme="light"].bld-input, html[data-theme="light"].bld-select { background: #ffffff; }
html[data-theme="light"].bld-table th { background: #eef1f6; }
html[data-theme="light"].bld-table td { border-bottom-color: rgba(15,23,42,0.06); }
html[data-theme="light"].bld-table tbody tr:hover, html[data-theme="light"].repo-table tr:hover { background: rgba(15,23,42,0.04); }
html[data-theme="light"].proc-row { border-bottom-color: rgba(15,23,42,0.06); }
html[data-theme="light"].bld-nav { background: rgba(255,255,255,0.85); }
html[data-theme="light"].bld-console, html[data-theme="light"].bld-log-full { background: #0c1220; color: #c9d4e3; }
html[data-theme="light"].bld-console.dim, html[data-theme="light"].bld-log-full.dim { color: #5d6b7e; }
html[data-theme="light"].bld-row { border-bottom-color: #141c2b; }
html[data-theme="light"].chk-lbl { background: #ffffff; }
html[data-theme="light"].filterbar { background: #eef1f6; }
</style>
<script>
// ═══ Stan i SSE ═══
let buildRunning = false;
let buildRunningQueued = false;
let allBuilds = [];
let pkgData = {};
let pkgMeta = {};
let consoleSeq = 0;
let consoleEl = null;
let consoleStick = true;
const MAX_CONSOLE_ROWS = 800;
const $ = (id) => document.getElementById(id);

const evtSource = new EventSource('/build/api/stream');
evtSource.addEventListener('log', e => {
 let d; try { d = JSON.parse(e.data); } catch(_) { return; }
 appendLogRow(d.msg || '', d.cls || '');
 const m = d.msg || '';
 if (m.includes('Zakończono') || m.includes('SYNC DONE') || m.includes('Gotowe!')) {
 buildRunning = false; updateBusyUI();
 setTimeout(() => { loadPackages(); loadStats(); }, 1500);
 }
});
evtSource.addEventListener('status', e => {
 let d; try { d = JSON.parse(e.data); } catch(_) { d = {}; }
 if (d.event === 'build_done' || d.event === 'sync_done') {
 buildRunning = false; updateBusyUI();
 // Nie przeładowuj strony, gdy otwarty jest modal (reload anulowałby
 // fetch czytania/edycji receptury → AbortError). Odśwież dane po cichu.
 const modalOpen = document.querySelector('.bld-modal-overlay.active');
 if (modalOpen) {
 setTimeout(() => { loadPackages(); loadStats(); }, 800);
 } else {
 setTimeout(() => { location.reload(); }, 1200);
 }
 }
});
evtSource.onerror = () => {};

function setLive(mode) { // 'on' | 'busy' | 'off'
 const dot = $('live-dot');
 if (!dot) return;
 dot.className = 'live-dot ' + (mode === 'on' ? 'on' : mode === 'busy' ? 'busy' : '');
 const tx = $('live-txt');
 if (tx) tx.textContent = mode === 'on' ? 'build aktywny'
 : mode === 'busy' ? 'kolejka…' : 'bezczynny';
}

// ═══ Konsola ═══
function getConsoleEl() { if (!consoleEl) consoleEl = $('console'); return consoleEl; }
function appendLogRow(msg, cls, time) {
 const c = getConsoleEl(); if (!c) return;
 const row = document.createElement('div');
 row.className = 'bld-row';
 const icon = document.createElement('span');
 icon.className = 'icon';
 icon.textContent = cls === 'ok' ? '✅' : cls === 'error' ? '❌'
 : cls === 'warn' ? '⚠' : '⏳';
 const pkg = document.createElement('span');
 pkg.className = 'pkg ' + (cls || '');
 pkg.textContent = msg;
 const dim = document.createElement('span');
 dim.className = 'dim';
 dim.textContent = time || new Date().toLocaleTimeString();
 row.appendChild(icon); row.appendChild(pkg); row.appendChild(dim);
 c.appendChild(row);
 while (c.childElementCount > MAX_CONSOLE_ROWS) c.removeChild(c.firstElementChild);
 if (consoleStick) c.scrollTop = c.scrollHeight;
}
function replayConsole() {
 fetch('/build/api/console?since=0')
 .then(r => r.json())
 .then(d => {
 const c = getConsoleEl(); if (!c) return;
 c.textContent = '';
 (d.items || []).forEach(it => appendLogRow(it.msg || '', it.cls || '', ''));
 consoleSeq = d.seq || 0;
 if (!d.items || !d.items.length) appendLogRow('> Ready.', 'dim', '');
 const last = d.items && d.items.length? d.items[d.items.length - 1]: null;
 if (last && last.msg) {
 const mm = last.msg;
 if (mm.includes('Zakończono') || mm.includes('SYNC DONE') || mm.includes('Gotowe!')) {
 buildRunning = false; updateBusyUI();
 }
 }
 })
 .catch(() => {});
}
function clearConsole() {
 fetch('/build/api/console/clear', { method: 'POST' })
 .then(r => r.json())
 .then(() => { const c = getConsoleEl(); if (c) { c.textContent = ''; appendLogRow('> Konsola wyczyszczona.', 'dim'); } })
 .catch(() => {});
}

// ═══ Wskaźnik aktywności + procesy ═══
function updateBusyUI() {
 ['sync-btn','sync-only-btn','refresh-btn','fix-sha-btn','check-btn','force-btn',
 'missing-btn','rebuild-failed-btn','purge-btn','build-sel-btn'].forEach(id => {
 const el = $(id); if (el) el.disabled = buildRunning;
 });
 setLive(buildRunning? (buildRunningQueued? 'busy' : 'on'): 'off');
}

function loadProcs() {
 fetch('/build/api/proc').then(r => r.json()).then(d => {
 const box = $('procs-box'); if (!box) return;
 const procs = d.procs || [];
 if (!procs.length) {
 box.innerHTML = '<div class="empty"> Brak aktywnych procesów builda.</div>';
 if ($('proc-count')) $('proc-count').textContent = '';
 if ($('cancel-btn')) $('cancel-btn').style.display = 'none';
 if (buildRunning && !buildRunningQueued) { buildRunning = false; updateBusyUI(); }
 return;
 }
 buildRunning = true; updateBusyUI();
 if ($('cancel-btn')) $('cancel-btn').style.display = '';
 if ($('proc-count')) $('proc-count').textContent = d.count + ' proces(ów)';
 let html = '';
 procs.forEach(p => {
 html += '<div class="proc-row">'
 + '<span class="live-dot on"></span>'
 + '<span class="proc-cmd" title="' + esc(p.cmd || '') + '">' + esc(p.cmd || '') + '</span>'
 + '<span class="proc-meta">pid ' + esc(p.pid || '?') + ' · ' + esc(p.etime || '') + '</span>'
 + '</div>';
 });
 box.innerHTML = html;
 }).catch(() => {}).finally(() => setTimeout(loadProcs, 5000));
}
function cancelBuilds() {
 if (!confirm(' Wysłać SIGTERM (po 3 s SIGKILL) do wszystkich procesów pagsync/pagbuild?')) return;
 fetch('/build/api/cancel', { method: 'POST' }).then(r => r.json()).then(d => {
 const c = getConsoleEl();
 if (c && d.killed && d.killed.length) appendLogRow(' Anulowano procesy: ' + d.killed.join(', '), 'warn');
 buildRunning = false; updateBusyUI();
 }).catch(() => {});
}

// ═══ Statystyki (/api/stats) ═══
function loadStats() {
 fetch('/build/api/stats').then(r => r.json()).then(d => {
 const strip = $('stats-strip'); if (!strip) return;
 if (!d || typeof d.total === 'undefined') return;
 const avg = d.avg_ok_secs!= null? fmtDur(d.avg_ok_secs): '—';
 const longest = d.longest && d.longest[0]
 ? esc(d.longest[0].name) + ' <span class="dim">(' + fmtDur(d.longest[0].secs || 0) + ')</span>'
 : '—';
 const tf = d.top_fail && d.top_fail[0]
 ? esc(d.top_fail[0].name) + ' <span class="r">×' + d.top_fail[0].count + '</span>'
 : '—';
 strip.innerHTML =
 '<div class="inner">'
 + '<div class="mini"><div class="mv a">' + (d.ok_rate != null ? d.ok_rate + '%' : '—') + '</div><div class="ml">Skuteczność OK</div></div>'
 + '<div class="mini"><div class="mv">' + avg + '</div><div class="ml">Śr. czas OK</div></div>'
 + '<div class="mini"><div class="mv"><span class="g">' + (d.last24h || 0) + '</span> / '
 + '<span class="y">' + (d.last7d || 0) + '</span> / ' + (d.last30d || 0) + '</div><div class="ml">Buildy 24h / 7d / 30d</div></div>'
 + '<div class="mini"><div class="mv y">' + longest + '</div><div class="ml">Najdłuższy OK</div></div>'
 + '<div class="mini"><div class="mv r">' + tf + '</div><div class="ml">Najczęstszy fail</div></div>'
 + '</div>';
 strip.style.display = 'block';
 }).catch(() => {});
}

// ═══ Pakiety ═══
function loadAll() { loadPackages(); loadBuilds(); }
function loadPackages() {
 fetch('/build/api/packages').then(r => r.json()).then(d => {
 const tb = $('pkg-tbody'); if (!tb || !d.packages) return;
 const f = ($('pkg-filter').value || '').toLowerCase();
 const sf = $('status-filter').value;
 // wypełnij filtr grup (zachowaj wybór) – pole groups: w recepturach
 const gsel = $('group-filter');
 const gprev = gsel ? gsel.value : '';
 const gset = {};
 d.packages.forEach(p => { (p.groups || []).forEach(g => { gset[g] = 1; }); });
 if (gsel) {
 const gnames = Object.keys(gset).sort();
 let ghtml = '<option value="">Grupa: wszystkie</option>';
 gnames.forEach(g => {
 ghtml += '<option value="' + escAttr(g) + '"' + (g === gprev ? ' selected' : '') + '>' + esc(g) + '</option>';
 });
 gsel.innerHTML = ghtml;
 gsel.value = (gprev && gset[gprev]) ? gprev : '';
 }
 const gf = gsel ? gsel.value : '';
 const vf = $('ver-filter') ? $('ver-filter').value : '';
 // Liczniki zbiorów (z całej listy, nie tylko z widocznych)
 let cntUpd = 0, cntNew = 0;
 d.packages.forEach(p => { if (p.needs_update) cntUpd++; if (p.is_new) cntNew++; });
 if ($('upd-cnt')) $('upd-cnt').textContent = cntUpd;
 if ($('new-cnt')) $('new-cnt').textContent = cntNew;
 if ($('upd-btn')) $('upd-btn').disabled = cntUpd === 0 || buildRunning;
 if ($('new-btn')) $('new-btn').disabled = cntNew === 0 || buildRunning;
 let rows = '', ok = 0, fail = 0, pending = 0;
 pkgData = {};
 pkgMeta = {};
 d.packages.forEach(p => {
 const groups = p.groups || [];
 if (f && !p.name.toLowerCase().includes(f) && !p.category.toLowerCase().includes(f)) return;
 if (sf && p.status!== sf) return;
 if (gf && groups.indexOf(gf) === -1) return;
 if (vf === 'outdated' && !p.needs_update) return;
 if (vf === 'new' && !p.is_new) return;
 if (p.status === 'ok') { ok++; }
 else if (p.status === 'failed') { fail++; }
 else pending++;
 pkgData[p.name] = { depends: p.depends || [], makedepends: p.makedepends || [] };
 pkgMeta[p.name] = {
 status: p.status || '', in_repo: !!p.in_repo, category: p.category || '',
 version: p.version || '', release: p.release, built_version: p.built_version || '',
 built_release: p.built_release || ''
 };
 const st = p.status === 'ok' ? '✅' : p.status === 'failed' ? '❌' : '⏳';
 const repoBadge = p.in_repo
 ? '<span class="repo-badge" title="Pakiet jest w repozytorium">repo</span>'
 : '<span class="repo-missing" title="Brak w repozytorium">—</span>';
 const ver = (p.built_version || p.version || '?');
 const verChip = p.needs_update
 ? ' <span class="chip chip-warn" title="W repo jest starsza wersja – do aktualizacji">nowa</span>' : '';
 const newChip = p.is_new
 ? ' <span class="chip chip-warn" title="Brak pakietu w repo – nowy">nowy</span>' : '';
 const edited = p.recipe_mtime ? String(p.recipe_mtime).slice(0, 10) : '—';
 const editTitle = p.recipe_mtime ? 'Edytowano: ' + esc(p.recipe_mtime) : 'Brak pliku receptury';
 const gline = groups.length
 ? '<div class="gline">' + groups.map(g => '<span class="gchip">' + esc(g) + '</span>').join('') + '</div>'
 : '';
 rows += '<tr>'
 + '<td><input type="checkbox" class="pkg-sel" value="' + escAttr(p.name) + '" title="Zaznacz do kolejki"></td>'
 + '<td>' + st + '</td>'
 + '<td>' + repoBadge
 + '<a href="javascript:void(0)" data-recipe="' + escAttr(p.name) + '" class="pkg-link" style="color:var(--link)">' + esc(p.name) + '</a>'
 + gline + '</td>'
 + '<td class="mono">' + esc(ver) + verChip + newChip + '</td>'
 + '<td><span class="chip">' + esc(p.category) + '</span></td>'
 + '<td class="dim mono" style="font-size:0.72rem" title="' + editTitle + '">' + edited + '</td>'
 + '<td class="num" style="white-space:nowrap">'
 + '<button class="bld-btn ghost" style="padding:0.1rem 0.4rem" data-act="deps-view" data-name="' + escAttr(p.name) + '" title="Zależności ' + escAttr(p.name) + '"><svg class="i16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg></button> '
 + '<button class="bld-btn ghost" style="padding:0.1rem 0.4rem" data-act="build" data-name="' + escAttr(p.name) + '" title="Zbuduj ' + escAttr(p.name) + '"><svg class="i16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg></button> '
 + '<button class="bld-btn ghost" style="padding:0.1rem 0.4rem" data-act="deps" data-name="' + escAttr(p.name) + '" title="Przebuduj łańcuch zależności ' + escAttr(p.name) + '"><svg class="i16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></button>'
 + '</td></tr>';
 });
 tb.innerHTML = rows || '<tr><td colspan="7" class="empty">Brak pakietów dla filtra.</td></tr>';
 if ($('pkg-stats')) $('pkg-stats').textContent = '✅ ' + ok + ' ❌ ' + fail + ' ⏳ ' + pending;
 updateSelCount();
 });
}
function loadBuilds() {
 fetch('/build/api/builds').then(r => r.json()).then(d => {
 allBuilds = d.builds || [];
 const tb = $('build-tbody'); if (!tb) return;
 const bf = ($('build-filter').value || '').toLowerCase();
 let rows = '', shown = 0;
 allBuilds.forEach((b, i) => {
 if (bf && !b.name.toLowerCase().includes(bf) && !(b.status || '').toLowerCase().includes(bf)) return;
 const st = b.status === 'ok' ? '✅' : '❌';
 const chip = b.status === 'ok' ? 'chip-ok' : 'chip-fail';
 rows += '<tr class="clickable" data-idx="' + i + '">'
 + '<td class="dim mono">' + i + '</td>'
 + '<td><span class="chip ' + chip + '">' + st + ' ' + esc(b.status || '') + '</span></td>'
 + '<td>' + esc(b.name) + '</td>'
 + '<td class="mono">' + esc(b.version || '?') + '</td>'
 + '<td class="num">' + esc(b.duration || '?') + '</td>'
 + '<td class="dim">' + esc(b.time || '') + '</td></tr>';
 shown++;
 });
 tb.innerHTML = rows || '<tr><td colspan="6" class="empty">{{ t('build_no_entries') }}</td></tr>';
 if ($('build-count')) $('build-count').textContent = shown + ' / ' + allBuilds.length;
 });
}

// ═══ Repo browser (/api/repo) ═══
let repoDebounce = null;
function loadRepo() {
 const q = ($('repo-q').value || '').trim();
 const cat = ($('repo-cat').value || 'stable');
 clearTimeout(repoDebounce);
 repoDebounce = setTimeout(() => {
 fetch('/build/api/repo?cats=' + encodeURIComponent(cat) + '&q=' + encodeURIComponent(q))
 .then(r => r.json()).then(d => {
 const tb = $('repo-tbody'); if (!tb) return;
 if ($('repo-count')) $('repo-count').textContent = d.total + ' plików';
 let rows = '';
 (d.entries || []).forEach(e => {
 rows += '<tr>'
 + '<td class="mono"><a href="' + escAttr(e.url) + '" target="_blank" rel="noopener" style="color:var(--link)">' + esc(e.file) + '</a></td>'
 + '<td><span class="chip">' + esc(e.category) + '</span></td>'
 + '<td class="num">' + fmtSize(e.size) + '</td>'
 + '<td class="dim">' + esc(e.mtime || '') + '</td>'
 + '<td>' + (e.asc? '<span class="chip chip-ok" title="Podpis GPG obecny">asc</span>'
 : '<span class="chip chip-fail" title="Brak podpisu!">brak asc</span>') + '</td>'
 + '</tr>';
 });
 tb.innerHTML = rows || '<tr><td colspan="5" class="empty">Brak pakietów.</td></tr>';
 }).catch(() => {});
 }, 250);
}

// ═══ Delegacja zdarzeń tabeli pakietów ═══
function depChip(name) {
 const m = pkgMeta[name];
 const cls = m ? (m.status === 'ok' ? 'ok' : m.status === 'failed' ? 'fail' : 'warn') : '';
 return '<button type="button" class="dchip dep-chip ' + cls + '" data-dep="' + escAttr(name) + '" title="Zależność: kliknij po szczegóły">' + esc(name) + '</button>';
}
function depsChips(arr) {
 if (!arr || !arr.length) return '';
 return arr.map(depChip).join('');
}
function toggleDeps(name, btn) {
 const tbl = $('pkg-table');
 const old = tbl ? tbl.querySelector('tr.deps-row') : null;
 if (old) { old.remove(); if (old.dataset.for === name) return; }
 const info = pkgData[name] || { depends: [], makedepends: [] };
 const rdep = depsChips(info.depends), mdep = depsChips(info.makedepends);
 const row = btn.closest('tr');
 if (!row) return;
 let html = '';
 if (rdep) html += '<span class="deps-lbl">Runtime:</span>' + rdep;
 if (mdep) html += '<span class="deps-lbl">Build:</span>' + mdep;
 if (!html) html = '<span class="deps-empty">brak zależności</span>';
 const tr = document.createElement('tr');
 tr.className = 'deps-row';
 tr.dataset.for = name;
 tr.innerHTML = '<td colspan="7"><div class="deps-wrap">' + html + '</div></td>';
 row.insertAdjacentElement('afterend', tr);
}
function showDepInfo(name, chip) {
 const row = chip.closest('tr.deps-row');
 if (!row) return;
 let info = row.querySelector('.dep-info');
 if (info) { info.remove(); return; }
 const m = pkgMeta[name];
 info = document.createElement('div');
 info.className = 'dep-info';
 info.dataset.dep = name;
 const ok = m && m.status === 'ok' && m.in_repo;
 let html = '<span class="dep-name">' + esc(name) + '</span>';
 html += '<span class="dep-status ' + (!m ? 'dep-away' : ok ? 'dep-ok' : m.status === 'failed' ? 'dep-fail' : 'dep-pend') + '">'
 + (!m ? 'brak receptury' : ok ? 'Zbudowany i w repo' : m.status === 'failed' ? 'Ostatni build: FAIL' : 'Niezbudowany') + '</span>';
 if (m) {
 html += '<span>receptura: <b>' + esc(m.version || '?') + '</b></span>';
 html += m.in_repo
 ? '<span>w repo: <b>' + esc(m.built_version || '?') + (m.built_release ? '-' + esc(m.built_release) : '') + '</b></span>'
 : '<span class="dep-away">brak w repozytorium</span>';
 html += '<span>' + esc(m.category || '') + '</span>';
 if (!ok) html += '<button type="button" class="bld-btn ghost" style="padding:0.1rem 0.55rem" data-act="build" data-name="' + escAttr(name) + '" title="Zbuduj ' + escAttr(name) + '">Zbuduj</button>';
 } else {
 // Brak receptury w drzewie – zaproponuj utworzenie nowej (wybór kategorii)
 html += '<span class="dep-away" style="font-size:0.72rem">dodać nową recepturę:</span>';
 html += '<select class="bld-select rcat" style="padding:0.2rem 0.5rem;font-size:0.75rem;width:auto">'
 + '<option value="utils">utils</option><option value="gui">gui</option><option value="core">core</option></select>';
 html += '<button type="button" class="bld-btn" style="padding:0.2rem 0.7rem;font-size:0.75rem" data-act="recipe-new" data-name="' + escAttr(name) + '" title="Utwórz PAGBUILD.yaml dla ' + escAttr(name) + '">Dodaj recepturę</button>';
 }
 info.innerHTML = html;
 row.querySelector('.deps-wrap').insertAdjacentElement('afterend', info);
}
function addRecipe(name, btn) {
 const row = btn.closest('tr.deps-row');
 const sel = row ? row.querySelector('select.rcat') : null;
 const cat = sel ? sel.value : 'utils';
 if (!confirm('Utworzyć nową recepturę „' + name + '” w kategorii ' + cat + '? (plik + commit do gita)')) return;
 btn.disabled = true; btn.textContent = 'Tworzę…';
 fetch('/build/api/recipe/new', {
 method: 'POST', headers: { 'Content-Type': 'application/json' },
 body: JSON.stringify({ name: name, category: cat })
 }).then(r => r.json()).then(d => {
 if (d.ok) {
 appendLogRow('Nowa receptura: ' + name + ' (' + cat + ') – commit wykonany', 'ok');
 loadPackages();
 openRecipe(name);
 } else {
 alert(d.error || 'Błąd tworzenia receptury');
 btn.disabled = false; btn.textContent = 'Dodaj recepturę';
 }
 }).catch(() => { alert('Błąd sieci'); btn.disabled = false; btn.textContent = 'Dodaj recepturę'; });
}
document.addEventListener('DOMContentLoaded', () => {
 const c = getConsoleEl();
 if (c) c.addEventListener('scroll', () => {
 consoleStick = (c.scrollHeight - c.scrollTop - c.clientHeight) < 60;
 });
 loadAutoUpdate();
 $('pkg-table').addEventListener('click', ev => {
 const b = ev.target.closest('button[data-act]');
 if (b) {
 const name = b.getAttribute('data-name');
 if (b.dataset.act === 'build') buildSingle(name);
 else if (b.dataset.act === 'deps') rebuildDeps(name);
 else if (b.dataset.act === 'deps-view') toggleDeps(name, b);
 else if (b.dataset.act === 'recipe-new') addRecipe(name, b);
 return;
 }
 const a = ev.target.closest('a.pkg-link');
 if (a) openRecipe(a.getAttribute('data-recipe'));
 const dc = ev.target.closest('button.dep-chip');
 if (dc) showDepInfo(dc.getAttribute('data-dep'), dc);
 });
 $('build-table').addEventListener('click', ev => {
 const tr = ev.target.closest('tr.clickable');
 if (tr) showBuildDetail(parseInt(tr.dataset.idx, 10));
 });
 // Checkboxy pakietów: odśwież licznik/przycisk po zaznaczeniu (delegacja –
 // checkboxy są przebudowywane co odświeżenie listy)
 $('pkg-table').addEventListener('change', ev => {
 if (ev.target.classList && ev.target.classList.contains('pkg-sel')) updateSelCount();
 });
 if ($('console-clear')) $('console-clear').addEventListener('click', clearConsole);
 if ($('console-stick-chk')) $('console-stick-chk').addEventListener('change', e => { consoleStick = e.target.checked; });
 if ($('cancel-btn')) $('cancel-btn').addEventListener('click', cancelBuilds);
 if ($('repo-q')) $('repo-q').addEventListener('input', loadRepo);
 if ($('repo-cat')) $('repo-cat').addEventListener('change', loadRepo);

 loadAll();
 loadProcs();
 loadStats();
 replayConsole();
 setInterval(loadPackages, 15000);
 setInterval(loadBuilds, 20000);
 setInterval(loadStats, 30000);
});

// ═══ Akcje builda ═══
function _startBuildUi(ids) {
 buildRunning = true; updateBusyUI();
 ids.forEach(id => { const el = $(id); if (el) el.disabled = true; });
}
function _endBuildUi() {
 buildRunning = false; buildRunningQueued = false; updateBusyUI();
}
function triggerSync() {
 if (buildRunning) return;
 _startBuildUi(['sync-btn','force-btn','missing-btn']);
 fetch('/build/api/trigger', { method: 'POST', headers: { 'Content-Type': 'application/json' },
 body: JSON.stringify({ force: false }) })
 .catch(() => _endBuildUi());
 setTimeout(() => { if (buildRunning) { _endBuildUi(); } }, 2500); // realny koniec przyjdzie przez SSE
}
function forceBuild() {
 if (buildRunning) return;
 if (!confirm(' {{ t('build_confirm_force') }}')) return;
 _startBuildUi(['force-btn','sync-btn','missing-btn']);
 fetch('/build/api/trigger', { method: 'POST', headers: { 'Content-Type': 'application/json' },
 body: JSON.stringify({ force: true }) })
 .catch(() => _endBuildUi());
 setTimeout(() => { if (buildRunning) { _endBuildUi(); } }, 2500);
}
function rebuildFailed() {
 if (buildRunning) return;
 _startBuildUi(['sync-btn','force-btn','missing-btn']);
 fetch('/build/api/rebuild-failed', { method: 'POST' })
 .then(r => r.json()).then(d => { if (d.status === 'no_failed') { alert('Brak FAILowanych paczek!'); _endBuildUi(); } })
 .catch(() => _endBuildUi());
}
function buildMissing() {
 if (buildRunning) return;
 buildRunningQueued = true;
 _startBuildUi(['missing-btn','sync-btn','force-btn']);
 fetch('/build/api/trigger', { method: 'POST', headers: { 'Content-Type': 'application/json' },
 body: JSON.stringify({ mode: 'missing' }) })
 .catch(() => _endBuildUi());
}
function syncOnly() {
 if (buildRunning) return;
 _startBuildUi(['sync-only-btn']);
 fetch('/build/api/sync-only', { method: 'POST' }).catch(() => _endBuildUi());
 setTimeout(() => _endBuildUi(), 2500);
}
function refreshIndex() {
 if (buildRunning) return;
 _startBuildUi(['refresh-btn']);
 fetch('/build/api/refresh-index', { method: 'POST' }).then(r => r.json()).then(d => {
 if (d.error) alert(d.error);
 }).catch(() => {}).finally(() => _endBuildUi());
}
function genRepo() {
 if (buildRunning) return;
 _startBuildUi(['gen-repo-btn']);
 fetch('/build/api/gen-repo', { method: 'POST' }).catch(() => _endBuildUi());
 setTimeout(() => _endBuildUi(), 2500);
}
function fixSha() {
 if (buildRunning) return;
 _startBuildUi(['fix-sha-btn']);
 fetch('/build/api/fix-sha', { method: 'POST' }).catch(() => _endBuildUi());
 setTimeout(() => _endBuildUi(), 2500);
}
function runCheck() {
 if (buildRunning) return;
 _startBuildUi(['check-btn']);
 fetch('/build/api/check', { method: 'POST' }).catch(() => _endBuildUi());
 setTimeout(() => _endBuildUi(), 2500);
}
function checkSigs() {
 if (buildRunning) return;
 _startBuildUi(['check-sigs-btn']);
 fetch('/build/api/check-sigs', { method: 'POST' }).catch(() => _endBuildUi());
 setTimeout(() => _endBuildUi(), 2500);
}
function fixSigs() {
 if (buildRunning) return;
 if (!confirm('Podpisać ponownie paczki z nieprawidłowym lub brakującym podpisem GPG?')) return;
 _startBuildUi(['fix-sigs-btn']);
 fetch('/build/api/fix-sigs', { method: 'POST' }).catch(() => _endBuildUi());
 setTimeout(() => _endBuildUi(), 2500);
}
function buildSingle(name) {
 if (buildRunning) { alert('Build już leci – poczekaj lub anuluj w sekcji Procesy.'); return; }
 const m = pkgMeta[name];
 const built = isBuiltCurrent(m);
 if (built) {
 if (!confirm(' Pakiet ' + name + ' jest już w repo w wersji zgodnej z recepturą.\nZbudować mimo to (wymuszony rebuild)?')) return;
 } else if (!confirm(' {{ t('build_confirm_single') }} ' + name + '?')) return;
 buildRunning = true; updateBusyUI();
 fetch('/build/api/build-single', { method: 'POST', headers: { 'Content-Type': 'application/json' },
 body: JSON.stringify({ name: name }) })
 .catch(() => { buildRunning = false; updateBusyUI(); });
 setTimeout(() => { if (buildRunning) { buildRunning = false; updateBusyUI(); } }, 2500);
}
function rebuildDeps(name) {
 if (buildRunning) { alert('Build już leci – poczekaj lub anuluj w sekcji Procesy.'); return; }
 if (!confirm(' Przebudować cały łańcuch zależności pakietu ' + name + ' (deps + makedeps + pakiet)?')) return;
 buildRunning = true; updateBusyUI();
 fetch('/build/api/build-deps', { method: 'POST', headers: { 'Content-Type': 'application/json' },
 body: JSON.stringify({ name: name }) })
 .catch(() => { buildRunning = false; updateBusyUI(); });
 setTimeout(() => { if (buildRunning) { buildRunning = false; updateBusyUI(); } }, 2500);
}
function isBuiltCurrent(m) {
 // Czy pakiet jest już w repo w wersji zgodnej z recepturą (nie wymaga przebudowy)
 if (!m) return false;
 if (m.status !== 'ok' || !m.in_repo) return false;
 const a = (m.built_version || '') === (m.version || '');
 const ra = String(m.built_release == null ? '' : m.built_release).replace(/^0$/, '');
 const rb = String(m.release == null ? '' : m.release).replace(/^0$/, '');
 return a && ra === rb;
}
function buildSelected() {
 if (buildRunning) { alert('Build już leci!'); return; }
 const names = selectedNames();
 if (!names.length) { alert('Zaznacz pakiety do zbudowania (checkboxy w tabeli).'); return; }
 // Pomiń pakiety już zbudowane w repo w wersji zgodnej z recepturą
 const todo = names.filter(n => !isBuiltCurrent(pkgMeta[n]));
 const skipped = names.length - todo.length;
 if (!todo.length) {
 alert('Wszystkie zaznaczone pakiety są już zbudowane w repo (wersja zgodna z recepturą).');
 return;
 }
 // Limit kolejki: maksymalnie 600 pakietów na jedno uruchomienie
 const MAX_BUILD = 600;
 let sel = todo;
 if (sel.length > MAX_BUILD) {
 sel = sel.slice(0, MAX_BUILD);
 appendLogRow('Limit kolejki: 600 pakietów. Buduję pierwsze 600 z ' + todo.length + ' do zbudowania.', 'warn');
 }
 if (skipped) {
 appendLogRow('Pominięto ' + skipped + ' pakietów już zbudowanych (wersja zgodna z recepturą).', 'warn');
 }
 if (!confirm(' Zbudować KOLEJNO (jeden po drugim) ' + sel.length + ' pakietów?'
 + (skipped ? ' (pominięto ' + skipped + ' już zbudowanych)' : '')
 + (sel.length < todo.length ? ' (limit 600)' : '') + '\n\n'
 + sel.slice(0, 30).join(', ') + (sel.length > 30? '\n…' : ''))) return;
 buildRunning = true; updateBusyUI();
 fetch('/build/api/build-many', { method: 'POST', headers: { 'Content-Type': 'application/json' },
 body: JSON.stringify({ packages: sel }) })
 .then(r => r.json()).then(d => { if (d.error) { alert(d.error); buildRunning = false; updateBusyUI(); } })
 .catch(() => { buildRunning = false; updateBusyUI(); });
}
function updatePkgs(mode) {
 if (buildRunning) { alert('Build już leci!'); return; }
 const label = { outdated: 'nieaktualne (nowsza receptura)', new: 'nowe (brak w repo)', both: 'nieaktualne + nowe' }[mode] || mode;
 const cnt = mode === 'new' ? ($('new-cnt') ? $('new-cnt').textContent : '?') : ($('upd-cnt') ? $('upd-cnt').textContent : '?');
 if (!confirm(' Zbudować pakiety: ' + label + ' (' + cnt + ')?\nKolejka leci po jednym pakiecie (maks. 600).')) return;
 buildRunning = true; updateBusyUI();
 fetch('/build/api/update-outdated', { method: 'POST', headers: { 'Content-Type': 'application/json' },
 body: JSON.stringify({ mode: mode }) })
 .then(r => r.json()).then(d => {
  if (d.error) { alert(d.error); buildRunning = false; updateBusyUI(); }
  else if (d.status === 'nothing') { alert('Brak pakietów w tej grupie.'); buildRunning = false; updateBusyUI(); }
 })
 .catch(() => { buildRunning = false; updateBusyUI(); });
}
function loadAutoUpdate() {
 const sel = $('auto-upd'); if (!sel) return;
 fetch('/build/api/auto-update').then(r => r.json()).then(d => { sel.value = d.mode || ''; }).catch(() => {});
}
function saveAutoUpdate() {
 const sel = $('auto-upd'); if (!sel) return;
 fetch('/build/api/auto-update', { method: 'POST', headers: { 'Content-Type': 'application/json' },
 body: JSON.stringify({ mode: sel.value }) })
 .then(r => r.json()).then(d => { if (d.error) alert(d.error); }).catch(() => {});
}
function selectedNames() {
 return Array.from(document.querySelectorAll('#pkg-table .pkg-sel:checked')).map(c => c.value);
}
function updateSelCount() {
 const b = $('build-sel-btn'); if (!b) return;
 const n = selectedNames().length;
 b.disabled = n === 0 || buildRunning;
 b.textContent = ' Zbuduj zaznaczone (' + n + ')';
}
function toggleSelVisible() {
 const boxes = document.querySelectorAll('#pkg-table .pkg-sel');
 if (!boxes.length) return;
 const allOn = Array.from(boxes).every(x => x.checked);
 boxes.forEach(x => { x.checked = !allOn; });
 updateSelCount();
}
function clearSelection() {
 document.querySelectorAll('#pkg-table .pkg-sel').forEach(x => { x.checked = false; });
 updateSelCount();
}

// ═══ Modal: szczegóły buildu / receptura ═══
function showBuildDetail(idx) {
 const b = allBuilds[idx]; if (!b) return;
 $('modal-title').textContent = (b.status === 'ok' ? '✅ ' : '❌ ') + b.name + '-' + (b.version || '?')
 + ' [' + (b.category || '?') + ']';
 $('modal-status').textContent = 'Status: ' + b.status + ' · ⏱ ' + (b.duration || '?') + ' · ' + (b.time || '');
 const log = $('modal-log');
 log.textContent = '⏳ Ładowanie logu…';
 $('modal-overlay').classList.add('active');
 // Log pobieramy osobno – lista /api/builds jest lekka (bez logów).
 fetch('/build/api/build/' + idx).then(r => r.json()).then(d => {
 log.innerHTML = linkifyLog(esc(d.log || '(brak logu)'));
 const nerr = (d.errors || []).length, nwrn = (d.warnings || []).length;
 if (nerr || nwrn) {
 log.insertAdjacentHTML('beforeend', '<div class="dim" style="margin-top:0.6rem;border-top:1px solid #1c2028;padding-top:0.4rem">'
 + (nerr? '<span class="err">' + nerr + ' linii z błędami</span>' : '') + (nerr && nwrn? ' · ' : '')
 + (nwrn? '<span class="wrn">' + nwrn + ' ostrzeżeń</span>' : '') + '</div>');
 }
 }).catch(() => { log.textContent = '(błąd pobierania logu)' });
}
function closeModal() { $('modal-overlay').classList.remove('active'); }
let currentRecipe = null;
function openRecipe(name) {
 fetch('/build/api/recipe/' + encodeURIComponent(name)).then(r => r.json()).then(d => {
 if (d.error) { alert(d.error); return; }
 currentRecipe = d.name;
 $('recipe-title').textContent = ' ' + d.name;
 $('recipe-path').textContent = d.rel;
 $('recipe-content').value = d.content;
 $('recipe-message').value = 'Update recipe ' + d.name;
 $('recipe-status').textContent = '';
 $('recipe-overlay').classList.add('active');
 }).catch(e => {
 // AbortError = żądanie anulowane przez przeładowanie strony/akcję – nie strasz użytkownika
 if (e && e.name === 'AbortError') return;
 alert('Błąd odczytu receptury: ' + e);
 });
}
function saveRecipe() {
 if (!currentRecipe) return;
 const content = $('recipe-content').value;
 const message = $('recipe-message').value || ('Update recipe ' + currentRecipe);
 const st = $('recipe-status');
 st.textContent = '⏳ Zapisywanie i push do gita...';
 fetch('/build/api/recipe/' + encodeURIComponent(currentRecipe), {
 method: 'POST', headers: { 'Content-Type': 'application/json' },
 body: JSON.stringify({ content: content, message: message })
 }).then(r => r.text().then(t => {
  let d = null;
  try { d = JSON.parse(t); } catch (e) {}
  if (!d) {
   if (r.status === 413) throw new Error('receptura zbyt duża dla serwera (HTTP 413)');
   throw new Error('HTTP ' + r.status + (t ? ': ' + t.slice(0, 200) : ''));
  }
  if (!r.ok || d.error) throw new Error(d.error || ('HTTP ' + r.status));
  return d;
 })).then(d => {
  st.textContent = '✅ Zapisano i wypchnięto do gita.';
 }).catch(e => { st.textContent = '❌ ' + (e && e.message ? e.message : e); });
}
function closeRecipeModal() { $('recipe-overlay').classList.remove('active'); }
document.addEventListener('keydown', e => { if (e.key === 'Escape') { closeModal(); closeRecipeModal(); } });

// ═══ Pomocnicze ═══
function esc(s) {
 return String(s == null? '' : s).replace(/[&<>"']/g, c =>
 ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function escAttr(s) { return esc(s); }
function fmtSize(n) {
 if (n == null) return '—';
 const u = ['B','KB','MB','GB']; let i = 0; let v = n;
 while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
 return (i === 0? v: v.toFixed(1)) + ' ' + u[i];
}
function fmtDur(secs) {
 if (secs == null) return '—';
 if (secs < 60) return Math.round(secs) + 's';
 if (secs < 3600) return Math.floor(secs / 60) + 'm ' + Math.round(secs % 60) + 's';
 return Math.floor(secs / 3600) + 'h ' + Math.floor((secs % 3600) / 60) + 'm';
}
function linkifyLog(s) {
 return s.replace(/(https?:\/\/[^\s"'<>]+)/g, '<a href="$1" target="_blank" rel="noopener" style="color:var(--link)">$1</a>');
}
</script>
{% endblock %}
{% block content %}

<nav class="bld-nav">
 <span class="live-pill"><span class="live-dot" id="live-dot"></span><span id="live-txt">bezczynny</span></span>
 <span class="grow"></span>
 <a class="pill" href="#sekcja-konsola"> Konsola</a>
 <a class="pill" href="#sekcja-pakiety"> Pakiety</a>
 <a class="pill" href="#sekcja-repo"> Repo</a>
 <a class="pill" href="#sekcja-procesy"> Procesy</a>
 <a class="pill" href="#sekcja-historia"> Historia</a>
</nav>

<!-- ═══ Metryki (server) + statystyki (JS) ═══ -->
<div class="bld-stats">
 <div class="bld-stat acc"><div class="val">{{ total }}</div><div class="lbl">{{ t('build_total') }}</div></div>
 <div class="bld-stat ok"><div class="val">{{ ok_count }}</div><div class="lbl">✅ {{ t('build_success') }}</div></div>
 <div class="bld-stat fail"><div class="val">{{ fail_count }}</div><div class="lbl">❌ {{ t('build_failed') }}</div></div>
 <div class="bld-stat {% if current_build %}warn{% endif %}">
 <div class="val">{% if current_build %} {{ current_build.name }}{% else %}—{% endif %}</div>
 <div class="lbl">{{ t('build_active') }}</div>
 </div>
 <div class="bld-stat"><div class="val" style="font-size:1.05rem">{{ last_sync or t('admin_none') }}</div><div class="lbl">{{ t('build_last_sync') }}</div></div>
</div>
<div id="stats-strip" aria-live="polite"></div>

{% if current_build %}
<div class="bld-card" style="border-color:var(--accent)">
 <div class="hd"><h3> {{ current_build.name }}-{{ current_build.version }}</h3>
 <span class="chip chip-acc">{{ current_build.category }} · {{ current_build.get('progress','') }} · {{ current_build.get('step_text','-') }}</span></div>
</div>
{% endif %}

<!-- ═══ Akcje ═══ -->
<div class="bld-card" id="sekcja-akcje">
 <div class="hd"><h3> Akcje</h3><span class="sub">operacje na całym drzewie receptur</span></div>
 <div class="bd">
 <div class="bld-actions">
 <div class="grp"><span class="glbl">Build</span>
 <button class="bld-btn pri" id="sync-btn" onclick="triggerSync()"> {{ t('build_sync_btn') }}</button>
 <button class="bld-btn" id="missing-btn" onclick="buildMissing()"> {{ t('build_missing') }}</button>
 <button class="bld-btn" id="rebuild-failed-btn" onclick="rebuildFailed()"> {{ t('build_rebuild_failed') }}</button>
 <button class="bld-btn warn2" id="force-btn" onclick="forceBuild()"> {{ t('build_force_btn') }}</button>
 </div>
 <div class="grp"><span class="glbl">Narzędzia</span>
 <button class="bld-btn" id="sync-only-btn" onclick="syncOnly()"> {{ t('build_sync_only_btn') }}</button>
 <button class="bld-btn" id="refresh-btn" onclick="refreshIndex()" title="Przeskanuj receptury (bez git pull i budowania)"> Odśwież listę</button>
 <button class="bld-btn" id="gen-repo-btn" onclick="genRepo()" title="Przebuduj i podpisz repo.json z plików w repo (bez budowania)"> Zaktualizuj bazę JSON</button>
 <button class="bld-btn" id="fix-sha-btn" onclick="fixSha()"> {{ t('build_fix_sha') }}</button>
 <button class="bld-btn" id="check-btn" onclick="runCheck()" title="pagsync --check: YAML, SHA256, deps, URL – bez budowania"> Check receptur</button>
 <button class="bld-btn" id="check-sigs-btn" onclick="checkSigs()" title="Sprawdź klucz GPG oraz podpisy repo.json i wszystkich paczek (bez budowania)"> 🔐 Sprawdź podpisy</button>
 <button class="bld-btn warn2" id="fix-sigs-btn" onclick="fixSigs()" title="Podpisz ponownie paczki z brakującym/nieprawidłowym .asc"> 🔏 Napraw podpisy</button>
 </div>
 <div class="grp"><span class="glbl">Logi</span>
 <button class="bld-btn" onclick="window.open('/build/download-log')"> {{ t('build_download_log') }}</button>
 <button class="bld-btn" onclick="window.open('/build/download-failed-log')" title="Tylko FAIL i niezbudowane"> ❌ Failed log</button>
 <button class="bld-btn" onclick="if(confirm('{{ t('build_confirm_clear') }}'))fetch('/build/api/builds/clear',{method:'POST'}).then(r=>r.json()).then(()=>location.reload())"> {{ t('build_clear_logs') }}</button>
 </div>
 <div class="grp"><span class="glbl">Ryzyko</span>
 <button class="bld-btn danger" id="purge-btn" onclick="if(confirm('{{ t('build_confirm_purge') }}')){fetch('/build/api/purge-and-rebuild',{method:'POST'}).then(()=>setTimeout(()=>location.reload(),2000))}"> {{ t('build_purge_rebuild') }}</button>
 </div>
 </div>
 </div>
</div>

<!-- ═══ Konsola + Pakiety ═══ -->
<div class="bld-grid-2">
 <div class="bld-card" id="sekcja-konsola">
 <div class="hd">
 <h3> Live Console</h3>
 <div class="console-tools">
 <label class="chk-lbl" title="Automatyczne przewijanie"><input type="checkbox" id="console-stick-chk" checked> auto-scroll</label>
 <button class="bld-btn ghost" id="console-clear" title="Wyczyść bufor konsoli (serwer)"> Wyczyść</button>
 </div>
 </div>
 <div class="bd">
 <div class="bld-console" id="console"><div class="bld-row"><span class="pkg dim">&gt; Ładowanie…</span></div></div>
 </div>
 </div>

 <div class="bld-card" id="sekcja-pakiety">
 <div class="hd"><h3> Pakiety</h3>
 <span class="sub" id="pkg-stats">Ładowanie…</span></div>
 <div class="filterbar">
 <input class="bld-input" id="pkg-filter" placeholder=" Filtruj nazwę / kategorię…" style="flex:1;min-width:160px" oninput="loadPackages()">
 <select class="bld-select" id="status-filter" onchange="loadPackages()">
 <option value="">{{ t('build_all_status') }}</option>
 <option value="ok">✅ {{ t('build_status_ok') }}</option>
 <option value="failed">❌ {{ t('build_status_failed') }}</option>
 <option value="pending">⏳ {{ t('build_status_pending') }}</option>
 </select>
 <select class="bld-select" id="group-filter" onchange="loadPackages()" title="Filtruj po grupie – pole groups: w PAGBUILD.yaml (np. kde, gnome)">
 <option value="">Grupa: wszystkie</option>
 </select>
 <select class="bld-select" id="ver-filter" onchange="loadPackages()" title="Nieaktualne = w repo leży starsza wersja niż w recepturze; nowe = brak pakietu w repo">
 <option value="">Wersje: wszystkie</option>
 <option value="outdated">Tylko nieaktualne</option>
 <option value="new">Tylko nowe</option>
 </select>
 <button class="bld-btn ghost" onclick="toggleSelVisible()" title="Zaznacz wszystkie widoczne"> Zaznacz widoczne</button>
 <button class="bld-btn pri" id="build-sel-btn" onclick="buildSelected()" disabled> Zbuduj zaznaczone (0)</button>
 <button class="bld-btn ghost" onclick="clearSelection()"> Odznacz</button>
 <button class="bld-btn ghost" id="upd-btn" onclick="updatePkgs('outdated')" title="Przebuduj pakiety, dla których receptura ma nowszą wersję niż pakiet w repo">Aktualizuj nieaktualne (<span id="upd-cnt">0</span>)</button>
 <button class="bld-btn ghost" id="new-btn" onclick="updatePkgs('new')" title="Zbuduj pakiety, których nie ma jeszcze w repo">Zbuduj nowe (<span id="new-cnt">0</span>)</button>
 <select class="bld-select" id="auto-upd" onchange="saveAutoUpdate()" title="Po każdym udanym syncu automatycznie dorzuć pakiety do kolejki budowania">
 <option value="">Auto: wyłączona</option>
 <option value="outdated">Auto: nieaktualne</option>
 <option value="both">Auto: nieaktualne + nowe</option>
 </select>
 </div>
 <div class="tblwrap" style="max-height:440px">
 <table class="bld-table" id="pkg-table">
 <thead><tr><th style="width:26px"></th><th style="width:30px">St.</th><th>{{ t('build_th_pkg') }}</th>
 <th>{{ t('build_th_ver') }}</th><th>{{ t('build_th_cat') }}</th><th style="width:70px">Edycja</th><th style="width:118px" class="num">Akcje</th></tr></thead>
 <tbody id="pkg-tbody"></tbody>
 </table>
 </div>
 </div>
</div>

<!-- ═══ Repo + Procesy ═══ -->
<div class="bld-grid-2">
 <div class="bld-card" id="sekcja-repo">
 <div class="hd"><h3> Repozytorium</h3><span class="sub" id="repo-count"></span></div>
 <div class="filterbar">
 <select class="bld-select" id="repo-cat">
 <option value="stable">stable</option>
 <option value="core">core</option>
 <option value="desktop">desktop</option>
 <option value="tools">tools</option>
 <option value="network">network</option>
 </select>
 <input class="bld-input" id="repo-q" placeholder=" Filtruj.pag…" style="flex:1;min-width:150px">
 </div>
 <div class="tblwrap" style="max-height:260px">
 <table class="bld-table">
 <thead><tr><th>Pakiet</th><th>Kat.</th><th class="num">Rozmiar</th><th>Data</th><th>Podpis</th></tr></thead>
 <tbody id="repo-tbody"><tr><td colspan="5" class="empty">Ładowanie…</td></tr></tbody>
 </table>
 </div>
 </div>

 <div class="bld-card" id="sekcja-procesy">
 <div class="hd"><h3> Aktywne procesy builda</h3>
 <span class="sub" id="proc-count"></span></div>
 <div class="bd">
 <div class="proc-list" id="procs-box"><div class="empty">Sprawdzam…</div></div>
 <div style="margin-top:0.6rem;display:flex;justify-content:flex-end">
 <button class="bld-btn danger" id="cancel-btn" style="display:none"> Zatrzymaj buildy (SIGTERM/SIGKILL)</button>
 </div>
 <p class="dim" style="font-size:0.7rem;margin:0.5rem 0 0;line-height:1.5">
 w tabeli <b>Pakiety</b> buduje pojedynczo; kolejka (Sync, Missing, Rebuild failed,
 zaznaczone) leci sekwencyjnie – procesy widać tutaj na żywo.
 </p>
 </div>
 </div>
</div>

<!-- ═══ Historia: tabela ═══ -->
<div class="bld-card" id="sekcja-historia">
 <div class="hd"><h3> Historia buildów</h3>
 <span class="sub" id="build-count">0 wpisów</span></div>
 <div class="filterbar">
 <input class="bld-input" id="build-filter" placeholder=" Filtruj…" style="flex:1" oninput="loadBuilds()">
 </div>
 <div class="tblwrap" style="max-height:380px">
 <table class="bld-table" id="build-table">
 <thead><tr><th>#</th><th>{{ t('build_th_status') }}</th><th>{{ t('build_th_pkg') }}</th>
 <th>{{ t('build_th_version') }}</th><th class="num"></th><th>{{ t('build_th_time') }}</th></tr></thead>
 <tbody id="build-tbody"></tbody>
 </table>
 </div>
</div>

<!-- Modal: szczegóły buildu -->
<div class="bld-modal-overlay" id="modal-overlay" onclick="if(event.target===this)closeModal()">
 <div class="bld-modal">
 <div class="bld-modal-header">
 <div><h3 id="modal-title"></h3><div class="meta" id="modal-status"></div></div>
 <button class="bld-modal-close" onclick="closeModal()">&times;</button>
 </div>
 <div class="bld-modal-body"><div class="bld-log-full" id="modal-log"></div></div>
 </div>
</div>

<!-- Modal: edycja receptury -->
<div class="bld-modal-overlay" id="recipe-overlay" onclick="if(event.target===this)closeRecipeModal()">
 <div class="bld-modal">
 <div class="bld-modal-header">
 <div><h3 id="recipe-title"></h3><div class="meta" id="recipe-path"></div></div>
 <button class="bld-modal-close" onclick="closeRecipeModal()">&times;</button>
 </div>
 <div class="bld-modal-body">
 <div style="margin-bottom:0.5rem;display:flex;justify-content:space-between;align-items:center">
 <label style="font-size:0.72rem;color:var(--muted)">{{ t('recipe_label') }}</label>
 <span id="recipe-status" style="font-size:0.75rem;color:var(--muted)"></span>
 </div>
 <textarea class="recipe-area" id="recipe-content" spellcheck="false" wrap="off"></textarea>
 <div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;margin-top:0.6rem">
 <input class="bld-input" id="recipe-message" placeholder="{{ t('recipe_commit_msg') }}" style="flex:1;min-width:200px">
 <button class="bld-btn pri" onclick="saveRecipe()"> {{ t('recipe_save_git') }}</button>
 <button class="bld-btn" onclick="closeRecipeModal()"> {{ t('recipe_close') }}</button>
 </div>
 </div>
 </div>
</div>
{% endblock %}