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

PaganLinux/pagan-web-v2/blueprints/build_panel.py main

1637 linii Raw ← Powrót
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637
import json, os, sys, subprocess, threading, time, queue as qmod, functools
from collections import deque
from datetime import datetime, timezone
from flask import Blueprint, render_template, request, redirect, jsonify, Response, stream_with_context, session, send_file
from config.settings import PAGBUILD_SYNC, STATE_FILE, CATEGORIES, GIT_WEBHOOK_SECRET, RECIPES_DIR, REPO_BASE
from database import get_db

build_bp = Blueprint("build", __name__)

# ── Build access: tylko admin ──
def build_access_required(f):
    @functools.wraps(f)
    def wrap(*a, **kw):
        if session.get("is_admin"):
            return f(*a, **kw)
        return render_template("build/restricted.html"), 403
    return wrap

# SSE queues
_sse_queues = []

# ── Panel v2: bufor historii konsoli (replay po odświeżeniu strony) ──
_console_lock = threading.Lock()
_console_seq = 0
_console_buf = deque(maxlen=2000)


def _console_record(event, data):
    global _console_seq
    if event != "log":
        return
    with _console_lock:
        _console_seq += 1
        _console_buf.append({
            "id": _console_seq,
            "event": event,
            "msg": data.get("msg", ""),
            "cls": data.get("cls", ""),
        })

def _sse_broadcast(event, data):
    msg = f"event: {event}\ndata: {json.dumps(data)}\n\n"
    _console_record(event, data)
    dead = []
    for q in _sse_queues:
        try:
            q.put_nowait(msg)
        except Exception:
            dead.append(q)
    for q in dead:
        try:
            _sse_queues.remove(q)
        except ValueError:
            pass

def _read_state():
    if os.path.exists(STATE_FILE):
        try:
            return json.load(open(STATE_FILE))
        except Exception:
            pass
    return {"builds": [], "last_sync": "", "current_build": None, "packages": {}, "recipe_index": {}}


def _local(ts):
    """Konwertuje timestamp zapisany w UTC (przez pagsync) na czas lokalny serwera."""
    if not ts:
        return ts
    s = str(ts)
    if s.endswith("Z"):
        s = s[:-1] + "+00:00"
    for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"):
        try:
            dt = datetime.strptime(s[:19].strip(), fmt)
            return dt.replace(tzinfo=timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S")
        except Exception:
            continue
    return ts


def _build_history(builds, days=7):
    counts = {}
    def parse_day(ts):
        if not ts:
            return None
        if isinstance(ts, str):
            loc = _local(ts)
            if loc and loc != ts:
                return loc[:10]
            for fmt in ("%Y-%m-%d", "%d.%m.%Y"):
                try:
                    return datetime.strptime(ts[:10], fmt).date().isoformat()
                except Exception:
                    pass
            if " " in ts:
                return ts.split(" ")[0]
            return ts[:10]
        return None

    for b in builds[-100:]:
        day = parse_day(b.get("time", "")) or "unknown"
        status = b.get("status", "unknown")
        if day not in counts:
            counts[day] = {"ok": 0, "fail": 0, "other": 0}
        if status == "ok":
            counts[day]["ok"] += 1
        elif status == "failed":
            counts[day]["fail"] += 1
        else:
            counts[day]["other"] += 1

    items = []
    for day, stats in sorted(counts.items(), key=lambda x: x[0], reverse=True)[:days]:
        items.append({"day": day, "ok": stats["ok"], "fail": stats["fail"], "other": stats["other"], "total": stats["ok"] + stats["fail"] + stats["other"]})
    if not items and builds:
        items = [{"day": "recent", "ok": sum(1 for b in builds if b.get("status") == "ok"), "fail": sum(1 for b in builds if b.get("status") == "failed"), "other": sum(1 for b in builds if b.get("status") not in ("ok", "failed")), "total": len(builds)}]
    return items

def _run_sync(force=False, mode="sync"):
    """Uruchamia pagsync --once, broadcastuje output przez SSE."""
    args = [PAGBUILD_SYNC, "--once"]
    if force:
        args.append("--force")
    if mode == "missing":
        args.append("--missing")
    try:
        proc = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, errors='replace',
                                start_new_session=True,
                                env={**os.environ, "PYTHONUNBUFFERED": "1"})
        for line in proc.stdout:
            line = line.rstrip()
            cls = ""
            if "✅" in line or "OK" in line:
                cls = "ok"
            elif "❌" in line or "FAIL" in line:
                cls = "error"
            elif "" in line or "" in line or "" in line or "" in line or "" in line:
                cls = "info"
            elif "⚠" in line:
                cls = "warn"
            _sse_broadcast("log", {"msg": line, "cls": cls})
        proc.wait()
        icon = "✅" if proc.returncode == 0 else "❌"
        _sse_broadcast("log", {"msg": f"{icon} Sync zakończony (kod {proc.returncode})", "cls": "ok" if proc.returncode == 0 else "error"})
        _sse_broadcast("status", {"event": "sync_done"})
        # Po udanym syncu dorobienie nieaktualnych/nowych (jeśli włączone w panelu)
        if proc.returncode == 0:
            _auto_update_after_sync()
    except Exception as e:
        _sse_broadcast("log", {"msg": f"❌ Błąd: {e}", "cls": "error"})

def _run_fix_sha():
    """Uruchamia pagsync --once --fix-sha (sprawdzanie/poprawa sha256sums), streamuje przez SSE."""
    args = [PAGBUILD_SYNC, "--once", "--fix-sha"]
    try:
        proc = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, errors='replace',
                                start_new_session=True,
                                env={**os.environ, "PYTHONUNBUFFERED": "1"})
        for line in proc.stdout:
            line = line.rstrip()
            cls = ""
            if "✅" in line or "" in line or "" in line:
                cls = "ok"
            elif "❌" in line:
                cls = "error"
            elif "⚠" in line:
                cls = "warn"
            elif "…" in line or "" in line or "" in line:
                cls = "info"
            _sse_broadcast("log", {"msg": line, "cls": cls})
        proc.wait()
        icon = "✅" if proc.returncode == 0 else "❌"
        _sse_broadcast("log", {"msg": f"{icon} Fix SHA zakończony (kod {proc.returncode})", "cls": "ok" if proc.returncode == 0 else "error"})
        _sse_broadcast("status", {"event": "fix_sha_done"})
    except Exception as e:
        _sse_broadcast("log", {"msg": f"❌ Błąd: {e}", "cls": "error"})

def _run_sync_only():
    """Uruchamia pagsync --once --sync-only (git pull + skan + repo.json, BEZ
    budowania) i strumieniuje output do SSE.

    Bez tego przycisk „ Sync" odpalał subprocess z wyrzuconym stdout – w
    konsoli nic nie było widać, a frontend nigdy nie dostał zdarzenia
    kończącego: buildRunning zostawało na True, przycisk wisiał na
    „⌛ Synchronizuję..." i wszystkie akcje panelu były zablokowane do
    ręcznego odświeżenia strony.
    """
    args = [PAGBUILD_SYNC, "--once", "--sync-only"]
    try:
        proc = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                                text=True, errors='replace', start_new_session=True,
                                env={**os.environ, "PYTHONUNBUFFERED": "1"})
        for line in proc.stdout:
            line = line.rstrip()
            cls = ""
            if "✅" in line or "OK" in line:
                cls = "ok"
            elif "❌" in line or "FAIL" in line:
                cls = "error"
            elif "" in line or "" in line or "" in line or "" in line:
                cls = "info"
            elif "⚠" in line:
                cls = "warn"
            _sse_broadcast("log", {"msg": line, "cls": cls})
        proc.wait()
        icon = "✅" if proc.returncode == 0 else "❌"
        _sse_broadcast("log", {"msg": f"{icon} Sync (bez budowania) zakończony (kod {proc.returncode})",
                               "cls": "ok" if proc.returncode == 0 else "error"})
        _sse_broadcast("status", {"event": "sync_done"})
    except Exception as e:
        _sse_broadcast("log", {"msg": f"❌ Błąd: {e}", "cls": "error"})
        _sse_broadcast("status", {"event": "sync_done"})

def _run_rescan_index():
    """Uruchamia pagsync --rescan: czysty skan receptur -> state.json (BEZ git
    pull, repo.json i budowania) – przycisk „ Odśwież listę" w panelu."""
    args = [PAGBUILD_SYNC, "--once", "--rescan"]
    try:
        proc = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                                text=True, errors='replace', start_new_session=True,
                                env={**os.environ, "PYTHONUNBUFFERED": "1"})
        for line in proc.stdout:
            line = line.rstrip()
            cls = ""
            if "✅" in line or "OK" in line:
                cls = "ok"
            elif "❌" in line:
                cls = "error"
            elif "" in line or "" in line or "" in line:
                cls = "info"
            elif "⚠" in line:
                cls = "warn"
            _sse_broadcast("log", {"msg": line, "cls": cls})
        proc.wait()
        icon = "✅" if proc.returncode == 0 else "❌"
        _sse_broadcast("log", {"msg": f"{icon} Odświeżanie indeksu zakończone (kod {proc.returncode})",
                               "cls": "ok" if proc.returncode == 0 else "error"})
        _sse_broadcast("status", {"event": "sync_done"})
    except Exception as e:
        _sse_broadcast("log", {"msg": f"❌ Błąd: {e}", "cls": "error"})
        _sse_broadcast("status", {"event": "sync_done"})

def _run_gen_repo():
    """Uruchamia pagsync --once --gen-repo: przebudowuje i podpisuje repo.json
    (indeks pakietów) na podstawie plików w repo, BEZ git pull i budowania.
    Przycisk „Zaktualizuj bazę JSON" w panelu.

    Uwaga: operacja skanuje WSZYSTKIE paczki (liczy SHA256), więc przy dużym
    repo trwa kilka minut – postęp leci na konsolę przez SSE. pagsync używa
    globalnego locka, więc jeśli trwa build, zakończy się kodem 3 (pominięte).
    """
    args = [PAGBUILD_SYNC, "--once", "--gen-repo"]
    try:
        proc = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                                text=True, errors='replace', start_new_session=True,
                                env={**os.environ, "PYTHONUNBUFFERED": "1"})
        for line in proc.stdout:
            line = line.rstrip()
            cls = ""
            if "❌" in line or "FAIL" in line:
                cls = "error"
            elif "⚠" in line:
                cls = "warn"
            elif "✅" in line or "📋" in line or "🔏" in line:
                cls = "ok"
            _sse_broadcast("log", {"msg": line, "cls": cls})
        proc.wait()
        if proc.returncode == 3:
            _sse_broadcast("log", {"msg": "⚠ Inna operacja pagsync już trwa (globalny lock) – spróbuj za chwilę.", "cls": "warn"})
        icon = "✅" if proc.returncode == 0 else "❌"
        _sse_broadcast("log", {"msg": f"{icon} Baza JSON (repo.json) zaktualizowana (kod {proc.returncode})",
                               "cls": "ok" if proc.returncode == 0 else "error"})
        _sse_broadcast("status", {"event": "sync_done"})
    except Exception as e:
        _sse_broadcast("log", {"msg": f"❌ Błąd: {e}", "cls": "error"})
        _sse_broadcast("status", {"event": "sync_done"})


# ── Monitor state.json w tle ──
def _monitor_state():
    """Wątek: czeka na zmiany w state.json i broadcastuje przez SSE."""
    last_mtime = 0
    while True:
        try:
            if os.path.exists(STATE_FILE):
                mtime = os.path.getmtime(STATE_FILE)
                if mtime > last_mtime:
                    last_mtime = mtime
                    s = _read_state()
                    cb = s.get("current_build")
                    if cb:
                        _sse_broadcast("log", {
                            "msg": f" [{cb.get('progress','?')}] {cb.get('name')}-{cb.get('version')} ({cb.get('category','?')}) – {cb.get('step_text','')}",
                            "cls": "info"
                        })
            time.sleep(3)
        except Exception:
            time.sleep(3)

threading.Thread(target=_monitor_state, daemon=True).start()


# ── Panel v2: narzędzia (klasyfikacja logu, procesy, statystyki) ──
def _classify_line(line):
    if "✅" in line or "Sukces" in line or "GOTOWE" in line:
        return "ok"
    if "❌" in line or "FAIL" in line or "Błąd" in line or "Traceback" in line:
        return "error"
    if "⚠" in line:
        return "warn"
    if any(s in line for s in ("", "", "", "", "", "", "", "", "")):
        return "info"
    return ""


def _run_pagsync(args, start_msg=None, done_msg=None):
    """Uruchamia pagsync z zadanymi argumentami i strumieniuje wyjście do SSE.
    Proces dostaje własną sesję (start_new_session), żeby „Anuluj” mógł
    wygasić całą grupę procesów (pagsync + pagbuild + make...)."""
    if start_msg:
        _sse_broadcast("log", {"msg": start_msg, "cls": "info"})
    try:
        proc = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                                text=True, errors='replace', start_new_session=True,
                                env={**os.environ, "PYTHONUNBUFFERED": "1"})
        for line in proc.stdout:
            line = line.rstrip()
            if line.strip():
                _sse_broadcast("log", {"msg": line, "cls": _classify_line(line)})
        proc.wait()
        ok = proc.returncode == 0
        icon = "✅" if ok else "❌"
        _sse_broadcast("log", {"msg": f"{icon} {done_msg or 'Zakończono'} (kod {proc.returncode})",
                               "cls": "ok" if ok else "error"})
        _sse_broadcast("status", {"event": "sync_done"})
    except Exception as e:
        _sse_broadcast("log", {"msg": f"❌ Błąd uruchamiania: {e}", "cls": "error"})
        _sse_broadcast("status", {"event": "sync_done"})


def _ps_rows():
    """Parsuje `ps -eo pid,ppid,pgid,sid,etime,cmd` do listy dictów."""
    rows = []
    try:
        r = subprocess.run(["ps", "-eo", "pid,ppid,pgid,sid,etime,cmd"],
                           capture_output=True, text=True, timeout=8)
    except Exception:
        return rows
    for ln in r.stdout.splitlines()[1:]:
        parts = ln.split(None, 5)
        if len(parts) < 6:
            continue
        pid, ppid, pgid, sid, etime, cmd = parts
        cmd = cmd.strip()
        if cmd.startswith(("ps ", "pgrep", "grep")):
            continue
        try:
            rows.append({"pid": int(pid), "ppid": int(ppid), "pgid": int(pgid),
                         "sid": int(sid), "etime": etime, "cmd": cmd})
        except ValueError:
            continue
    return rows


def _build_proc_tree():
    """Procesy builda: pagsync/pagbuild (poza panelem) oraz WSZYSCY ich
    potomkowie (chroot, make, ninja, gcc/cc1plus...).

    To jest sedno anulowania: sam pagsync nie wystarcza, bo build to całe
    drzewo procesów, a dzieci po uśmierceniu rodzica przepina init.
    """
    rows = _ps_rows()
    by_ppid = {}
    for row in rows:
        by_ppid.setdefault(row["ppid"], []).append(row)
    out, seen = [], set()
    stack = [row for row in rows
             if ("pagsync" in row["cmd"] or "pagbuild" in row["cmd"])
             and "app.py" not in row["cmd"]]
    while stack:
        row = stack.pop()
        if row["pid"] in seen:
            continue
        seen.add(row["pid"])
        out.append(row)
        stack.extend(by_ppid.get(row["pid"], []))
    return out


def _running_pagsync():
    """Lista działających procesów builda (pagsync/pagbuild + potomkowie, bez panelu)."""
    return [{"pid": str(p["pid"]), "ppid": str(p["ppid"]),
             "etime": p["etime"], "cmd": p["cmd"][:220]}
            for p in _build_proc_tree()]


def _cancel_pagsync():
    """Zatrzymuje build: SIGTERM do CAŁEJ grupy procesów (pagsync → pagbuild →
    chroot → make/ninja/gcc), po 3 s SIGKILL dla tego, co przetrwało.

    Dlaczego nie wystarczy `kill -9` na pagsync: SIGKILL jest nieprzechwytywalny,
    więc handler pagsync (os.killpg na grupie pagbuild) się NIE uruchamia,
    a kompilatory przepinają się do init i dalej zżerają CPU. Dlatego wygaszamy
    całe GRUPY procesów (killpg), a nie pojedyncze PID-y.
    """
    import signal
    procs = _build_proc_tree()
    if not procs:
        return []
    our_pid = os.getpid()
    try:
        our_pgid = os.getpgid(0)
    except Exception:
        our_pgid = -1

    # Grupy do wygaszenia – NIGDY własna grupa panelu (inaczej ubilibyśmy siebie).
    pgids = sorted({p["pgid"] for p in procs if p["pgid"] != our_pgid})
    lone = [p["pid"] for p in procs if p["pgid"] == our_pgid and p["pid"] != our_pid]

    killed = []
    for pg in pgids:
        try:
            os.killpg(pg, signal.SIGTERM)
            killed.append(f"pgid:{pg}")
        except Exception:
            pass
    for pid in lone:
        try:
            os.kill(pid, signal.SIGTERM)
            killed.append(str(pid))
        except Exception:
            pass

    if killed:
        time.sleep(3)
        # Domykamy to, co przetrwało TERM, oraz procesy powstałe w międzyczasie.
        for p in _build_proc_tree():
            if p["pid"] == our_pid:
                continue
            try:
                if p["pgid"] != our_pgid:
                    os.killpg(p["pgid"], signal.SIGKILL)
                else:
                    os.kill(p["pid"], signal.SIGKILL)
            except Exception:
                pass
    return killed


def _dur_secs(d):
    """'231.6s', '12m 3s', '1h 2m 10s' → sekundy (float) albo None."""
    if not d:
        return None
    import re
    total = 0.0
    for v, u in re.findall(r"([0-9]+(?:\.[0-9]+)?)([smhd])", str(d)):
        total += float(v) * {"s": 1, "m": 60, "h": 3600, "d": 86400}.get(u, 0)
    return total if total else None


def _in_last_days(ts, days):
    if not ts:
        return False
    try:
        s = str(ts)[:19].strip()
        dt = datetime.strptime(s, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc)
        return (time.time() - dt.timestamp()) <= days * 86400
    except Exception:
        try:
            s = str(ts)[:19].strip()
            dt = datetime.strptime(s, "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
            return (time.time() - dt.timestamp()) <= days * 86400
        except Exception:
            return False


def _stats_data():
    s = _read_state()
    builds = s.get("builds", []) or []
    ok_b = [b for b in builds if b.get("status") == "ok"]
    fail_b = [b for b in builds if b.get("status") == "failed"]
    other_b = [b for b in builds if b.get("status") not in ("ok", "failed")]
    dur_ok = [d for d in (_dur_secs(b.get("duration")) for b in ok_b) if d]
    avg_ok = round(sum(dur_ok) / len(dur_ok), 1) if dur_ok else None
    longest = sorted(ok_b, key=lambda b: _dur_secs(b.get("duration")) or 0, reverse=True)[:5]
    fail_cnt = {}
    for b in builds:
        if b.get("status") == "failed":
            n = b.get("name", "?")
            fail_cnt[n] = fail_cnt.get(n, 0) + 1
    top_fail = sorted(fail_cnt.items(), key=lambda kv: kv[1], reverse=True)[:8]
    return {
        "total": len(builds), "ok": len(ok_b), "fail": len(fail_b), "other": len(other_b),
        "ok_rate": round(100.0 * len(ok_b) / len(builds), 1) if builds else None,
        "avg_ok_secs": avg_ok,
        "longest": [{"name": b.get("name"), "version": b.get("version"),
                      "duration": b.get("duration"),
                      "secs": _dur_secs(b.get("duration")),
                      "time": _local(b.get("time"))} for b in longest],
        "top_fail": [{"name": n, "count": c} for n, c in top_fail],
        "last24h": sum(1 for b in builds if _in_last_days(b.get("time"), 1)),
        "last7d": sum(1 for b in builds if _in_last_days(b.get("time"), 7)),
        "last30d": sum(1 for b in builds if _in_last_days(b.get("time"), 30)),
        "history": _build_history(builds, days=14),
    }

# ── Routes ──
@build_bp.route("/")
@build_bp.route("/dashboard")
@build_access_required
def dashboard():
    s = _read_state()
    builds = s.get("builds", [])
    packages = s.get("packages", {})
    ok = sum(1 for b in builds if b.get("status") == "ok")
    fail = sum(1 for b in builds if b.get("status") == "failed")
    history = _build_history(builds)
    max_count = max((item["total"] for item in history), default=1)
    dbuilds = []
    for b in reversed(builds[-50:]):
        b = dict(b)
        b["time"] = _local(b.get("time"))
        dbuilds.append(b)
    return render_template("build/dashboard.html",
        builds=dbuilds,
        total=len(builds), ok_count=ok, fail_count=fail,
        last_sync=_local(s.get("last_sync", "")), current_build=s.get("current_build"),
        categories=CATEGORIES, package_count=len(packages),
        build_history=history, build_history_max=max_count)

@build_bp.route("/api/trigger", methods=["POST"])
@build_access_required
def api_trigger():
    data = request.get_json(silent=True) or {}
    force = data.get("force", False)
    mode = data.get("mode", "sync")
    if mode == "missing":
        _sse_broadcast("log", {"msg": "Build Missing – szukam niezbudowanych...", "cls": "info"})
    elif force:
        _sse_broadcast("log", {"msg": "FORCE Build – przebudowuję wszystko", "cls": "warn"})
    else:
        _sse_broadcast("log", {"msg": "Sync & Build – start", "cls": "info"})
    threading.Thread(target=_run_sync, args=(force, mode), daemon=True).start()
    return jsonify({"status": "started"})

@build_bp.route("/api/sync-only", methods=["POST"])
@build_access_required
def api_sync_only():
    """Synchronizuje recipes z gita + odświeża repo.json, BEZ budowania."""
    _sse_broadcast("log", {"msg": "Sync (bez budowania) – git pull + repo.json...", "cls": "info"})
    threading.Thread(target=_run_sync_only, daemon=True).start()
    return jsonify({"status": "started"})

@build_bp.route("/api/refresh-index", methods=["POST"])
@build_access_required
def api_refresh_index():
    """Odświeża listę pakietów: czysty skan receptur -> state.json (BEZ git pull,
    repo.json i budowania) – przycisk „ Odśwież listę" w panelu."""
    _sse_broadcast("log", {"msg": "Odświeżanie indeksu receptur (skan)...", "cls": "info"})
    threading.Thread(target=_run_rescan_index, daemon=True).start()
    return jsonify({"status": "started"})

@build_bp.route("/api/gen-repo", methods=["POST"])
@build_access_required
def api_gen_repo():
    """Przebudowuje i podpisuje repo.json (indeks pakietów) z plików w repo –
    BEZ git pull i budowania. Przycisk „Zaktualizuj bazę JSON"."""
    _sse_broadcast("log", {"msg": "Aktualizacja bazy JSON (repo.json) – skan paczek + podpis...", "cls": "info"})
    threading.Thread(target=_run_gen_repo, daemon=True).start()
    return jsonify({"status": "started"})

@build_bp.route("/api/fix-sha", methods=["POST"])
@build_access_required
def api_fix_sha():
    """Sprawdza i automatycznie poprawia sha256sums w recepturach (asynchronicznie, przez SSE)."""
    _sse_broadcast("log", {"msg": "Fix SHA – sprawdzanie sum kontrolnych...", "cls": "info"})
    threading.Thread(target=_run_fix_sha, daemon=True).start()
    return jsonify({"status": "started"})

# ── Zapis receptury do gita – odporny na konflikt index.lock ──
# Gdy pagsync w tle robi git pull/commit (timer 03:00, --fix-sha, weekly),
# chwilowy index.lock nie może zepsuć zapisu z panelu: czekamy, usuwamy martwą
# blokadę (brak procesu git) i ponawiamy.
def _clear_stale_git_index_lock():
    lock = os.path.join(RECIPES_DIR, ".git", "index.lock")
    if not os.path.exists(lock):
        return
    try:
        p = subprocess.run(["pgrep", "-f", "git.*recipes"],
                           capture_output=True, text=True, timeout=5)
        if p.stdout.strip():
            return  # proces git działa – nie ruszamy
        os.remove(lock)
    except Exception:
        pass


def _git_save_recipe(rel, msg):
    cmds = [
        ["git", "-C", RECIPES_DIR, "add", "--", rel],
        ["git", "-C", RECIPES_DIR, "commit", "-m", msg],
        # Unifikacja: commit -> pull --rebase -> push. Rebase lokalnych commitów
        # na origin eliminuje rozjazdy (push non-fast-forward) między panelem,
        # weekly i auto-fixem.
        ["git", "-C", RECIPES_DIR, "pull", "--rebase", "origin", "main"],
        ["git", "-C", RECIPES_DIR, "push", "origin", "HEAD"],
    ]
    for attempt in range(6):
        out = []
        conflict = False
        for c in cmds:
            r = subprocess.run(c, capture_output=True, text=True,
                               errors='replace', timeout=30,
                               env={**os.environ})
            text = (r.stdout + r.stderr).strip()
            out.append((r.returncode, text))
            if r.returncode in (0, 1):   # 1 = "nothing to commit" – ok
                continue
            if "index.lock" in text or "Another git process" in text:
                conflict = True
                break
            # Konflikt rebase'a lub inny błąd – zostaw czysty checkout (bez stanu rebase).
            if ("rebase" in text.lower() or "conflict" in text.lower()
                    or "cannot pull" in text.lower() or "diverged" in text.lower()):
                subprocess.run(["git", "-C", RECIPES_DIR, "rebase", "--abort"],
                               capture_output=True, timeout=10)
            return False, text
        if not conflict:
            return True, out
        # Konflikt blokady – wyczyść martwą i ponów (max 5× z przerwą).
        _clear_stale_git_index_lock()
        if attempt < 5:
            time.sleep(2)
        else:
            return False, "git: blokada index.lock nie ustąpiła (inny proces git działa w recipes repo)"
    return False, "git: nie udało się zapisać do gita (index.lock)"


@build_bp.route("/api/recipe/<name>", methods=["GET", "POST"])
@build_access_required
def api_recipe(name):
    """Odczyt (GET) / zapis + push do gita (POST) receptury pakietu."""
    s = _read_state()
    path = _recipe_path_from_index(s.get("recipe_index", {}), name)
    if not path:
        # Fallback: żywy skan katalogu (gdy cache jeszcze nie ma wpisu)
        import glob as _g
        for cand in _g.glob(os.path.join(RECIPES_DIR, "*", name, "PAGBUILD.yaml")):
            if os.path.isfile(cand):
                path = cand
                break
    if not path:
        return jsonify({"error": "receptura nie znaleziona", "name": name}), 404
    rel = os.path.relpath(path, RECIPES_DIR) if path.startswith(RECIPES_DIR) else path

    if request.method == "POST":
        data = request.get_json(silent=True) or {}
        content = data.get("content", "")
        if not content.strip():
            return jsonify({"error": "pusta receptura"}), 400
        # Limit rozmiaru receptury – 16 MB w zupełności wystarcza;
        # chroni przed przypadkowym wklejeniem binarki i przed 413 z nginx.
        if len(content.encode("utf-8", "ignore")) > 16 * 1024 * 1024:
            return jsonify({"error": "receptura za duża (limit 16 MB)", "too_large": True}), 413
        # Zapis pliku
        try:
            with open(path, "w", encoding="utf-8") as f:
                f.write(content)
        except Exception as e:
            return jsonify({"error": f"zapis: {e}"}), 500
        # Commit + push do gita (z checkoutu RECIPES_DIR) – odporny na konflikt
        # index.lock (inny proces git np. pagsync pull / fix-sha w tle).
        msg = data.get("message") or f"Update recipe {name}"
        ok, result = _git_save_recipe(rel, msg)
        if not ok:
            return jsonify({"error": str(result)[:300]}), 500
        return jsonify({"ok": True, "name": name, "message": msg})

    with open(path, "r", encoding="utf-8") as f:
        content = f.read()
    return jsonify({"name": name, "path": path, "rel": rel, "content": content})


@build_bp.route("/api/recipe/new", methods=["POST"])
@build_access_required
def api_recipe_new():
    """Tworzy nową recepturę PAGBUILD.yaml w RECIPES_DIR/<cat>/<name>/
    (commit + push do gita). Kategorie: core / gui / utils / de."""
    import re as _re
    data = request.get_json(silent=True) or {}
    name = (data.get("name") or "").strip().lower()
    cat = (data.get("category") or "").strip().lower()
    if not _re.fullmatch(r"[a-z0-9][a-z0-9+_.-]*", name or ""):
        return jsonify({"error": "niedozwolona nazwa pakietu"}), 400
    if cat not in ("core", "gui", "utils", "de"):
        return jsonify({"error": "kategoria musi być: core, gui, utils lub de"}), 400
    d = os.path.join(RECIPES_DIR, cat, name)
    if os.path.exists(d):
        return jsonify({"error": f"receptura już istnieje: {cat}/{name}"}), 409
    try:
        os.makedirs(d, exist_ok=True)
    except Exception as e:
        return jsonify({"error": f"mkdir: {e}"}), 500
    skeleton = (
        "# PAGBUILD.yaml – nowa receptura. Uzupełnij pkgver, pkgdesc, url,\n"
        "# źródło (source) i skrypty build/package przed budowaniem.\n"
        f"pkgname: {name}\n"
        "pkgver: '0.1'\n"
        "pkgrel: 1\n"
        "# Grupy opcjonalne (kde/gnome/xfce/lxqt...): odkomentuj i uzupełnij\n"
        "# groups: [kde]\n"
        f"pkgdesc: '{name} – opis pakietu'\n"
        "url: 'https://example.com/'\n"
        "arch: x86_64\n"
        "depends: []\n"
        "makedepends: []\n"
        "source:\n"
        "- 'https://example.com/${pkgname}-${pkgver}.tar.gz'\n"
        "sha256sums:\n"
        "- SKIP\n"
        "build: |\n"
        "  cd ${pkgname}-${pkgver}\n"
        "  ./configure --prefix=/usr\n"
        "  make\n"
        "package: |\n"
        "  make DESTDIR=${PKGDIR} install\n"
    )
    fp = os.path.join(d, "PAGBUILD.yaml")
    try:
        with open(fp, "w", encoding="utf-8") as f:
            f.write(skeleton)
    except Exception as e:
        return jsonify({"error": f"zapis: {e}"}), 500
    rel = f"{cat}/{name}/PAGBUILD.yaml"
    ok, result = _git_save_recipe(rel, f"Nowa receptura: {name} ({cat})")
    if not ok:
        return jsonify({"error": f"commit/push: {str(result)[:300]}"}), 500
    return jsonify({"ok": True, "name": name, "category": cat, "rel": rel})


@build_bp.route("/api/status")
def api_status():
    s = _read_state()
    builds = s.get("builds", [])
    return jsonify({
        "builds": builds[-20:],
        "current_build": s.get("current_build"),
        "last_sync": _local(s.get("last_sync")),
        "total": len(builds),
        "ok_count": sum(1 for b in builds if b.get("status") == "ok"),
        "fail_count": sum(1 for b in builds if b.get("status") == "failed"),
        "packages": s.get("packages", {}),
    })

@build_bp.route("/api/status-badge")
def api_status_badge():
    """Lekki endpoint publiczny – stan buildu dla indykatora w nav."""
    s = _read_state()
    cb = s.get("current_build")
    builds = s.get("builds", [])
    last = builds[-1] if builds else None
    return jsonify({
        "active": bool(cb),
        "pkg": f"{cb['name']}-{cb['version']}" if cb else None,
        "category": cb.get("category") if cb else None,
        "progress": cb.get("progress") if cb else None,
        "last_status": last.get("status") if last else None,
        "last_pkg": last.get("name") if last else None,
    })

@build_bp.route("/api/stream")
def api_stream():
    def gen():
        q = qmod.Queue()
        _sse_queues.append(q)
        try:
            yield f"event: connected\ndata: {json.dumps({'ok':True})}\n\n"
            while True:
                try:
                    msg = q.get(timeout=30)
                    yield msg
                except qmod.Empty:
                    yield ": ping\n\n"
        except GeneratorExit:
            pass
        finally:
            try:
                _sse_queues.remove(q)
            except ValueError:
                pass
    return Response(stream_with_context(gen()), mimetype="text/event-stream",
                    headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})

@build_bp.route("/api/builds/clear", methods=["POST"])
@build_access_required
def clear_builds():
    s = _read_state()
    s["builds"] = []
    s["current_build"] = None
    json.dump(s, open(STATE_FILE, "w"), indent=2)
    _sse_broadcast("log", {"msg": "Historia buildów wyczyszczona.", "cls": "info"})
    return jsonify({"ok": True})

@build_bp.route("/api/rebuild-failed", methods=["POST"])
@build_access_required
def rebuild_failed():
    """Buduje tylko pakiety które ostatnio FAILowały."""
    s = _read_state()
    builds = s.get("builds", [])
    failed_names = list(set(b["name"] for b in builds if b.get("status") == "failed"))
    if not failed_names:
        return jsonify({"status": "no_failed", "count": 0})
    _sse_broadcast("log", {"msg": f" Rebuild {len(failed_names)} failed: {', '.join(failed_names[:10])}...", "cls": "warn"})

    def _run_rebuild_failed():
        try:
            proc = subprocess.Popen(
                [PAGBUILD_SYNC, "--once", "--rebuild-failed"],
                stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, errors='replace',
                start_new_session=True,
                env={**os.environ, "PYTHONUNBUFFERED": "1"}
            )
            for line in proc.stdout:
                line = line.rstrip()
                cls = ""
                if "✅" in line or "OK" in line:
                    cls = "ok"
                elif "❌" in line or "FAIL" in line:
                    cls = "error"
                elif "" in line or "" in line or "" in line or "" in line:
                    cls = "info"
                elif "⚠" in line:
                    cls = "warn"
                _sse_broadcast("log", {"msg": line, "cls": cls})
            proc.wait()
            icon = "✅" if proc.returncode == 0 else "❌"
            _sse_broadcast("log", {"msg": f"{icon} Rebuild failed zakończony (kod {proc.returncode})",
                                   "cls": "ok" if proc.returncode == 0 else "error"})
            _sse_broadcast("status", {"event": "build_done"})
        except Exception as e:
            _sse_broadcast("log", {"msg": f"❌ Błąd: {e}", "cls": "error"})
            _sse_broadcast("status", {"event": "build_done"})

    threading.Thread(target=_run_rebuild_failed, daemon=True).start()
    return jsonify({"status": "started", "count": len(failed_names)})

@build_bp.route("/api/purge-and-rebuild", methods=["POST"])
@build_access_required
def purge_and_rebuild():
    """Usuwa wszystkie paczki z repo i buduje od nowa."""
    import glob as gmod
    total = 0
    for cat in CATEGORIES:
        d = os.path.join(REPO_BASE, cat)
        if not os.path.isdir(d):
            continue
        for f in gmod.glob(os.path.join(d, "*.pkg.tar.xz")):
            os.remove(f)
            total += 1
        rj = os.path.join(d, "repo.json")
        if os.path.exists(rj):
            os.remove(rj)
    s = _read_state()
    s["builds"] = []
    s["current_build"] = None
    s["packages"] = {}
    json.dump(s, open(STATE_FILE, "w"), indent=2)
    _sse_broadcast("log", {"msg": f" Usunięto {total} paczek, zaczynam rebuild od zera...", "cls": "warn"})
    threading.Thread(target=_run_sync, args=(True,), daemon=True).start()
    return jsonify({"status": "started", "deleted": total})

@build_bp.route("/api/build-single", methods=["POST"])
@build_access_required
def build_single():
    """Buduje jeden konkretny pakiet przez pagsync --build."""
    data = request.get_json(silent=True) or {}
    pkg_name = data.get("name", "").strip()
    if not pkg_name:
        return jsonify({"error": "missing name"}), 400
        _sse_broadcast("log", {"msg": f" Build single: {pkg_name}", "cls": "info"})

    def _build_one():
        try:
            proc = subprocess.Popen(
                [PAGBUILD_SYNC, "--once", "--build", pkg_name],
                stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, errors='replace',
                start_new_session=True,
                env={**os.environ, "PYTHONUNBUFFERED": "1"}
            )
            for line in proc.stdout:
                line = line.rstrip()
                cls = ""
                if "✅" in line: cls = "ok"
                elif "❌" in line: cls = "error"
                elif "" in line or "" in line or "" in line: cls = "info"
                _sse_broadcast("log", {"msg": line, "cls": cls})
            proc.wait()
            icon = "✅" if proc.returncode == 0 else "❌"
            _sse_broadcast("log", {"msg": f"{icon} Build {pkg_name} zakończony (kod {proc.returncode})", "cls": "ok" if proc.returncode==0 else "error"})
            # Powiadom dashboard, że build (manual) się zakończył – odświeży stronę,
            # żeby zniknął wskaźnik "pagbuild w użyciu" (current_build).
            _sse_broadcast("status", {"event": "build_done"})
        except Exception as e:
            _sse_broadcast("log", {"msg": f"❌ Błąd: {e}", "cls": "error"})
            _sse_broadcast("status", {"event": "build_done"})

    threading.Thread(target=_build_one, daemon=True).start()
    return jsonify({"status": "started", "name": pkg_name})

def _start_build_queue(pkgs, label="Kolejka"):
    """Uruchamia sekwencyjną kolejkę buildów (pagsync --once --build) w tle,
    strumieniując output do konsoli przez SSE. Zwraca liczbę pakietów."""
    pkgs = list(pkgs)

    def _run_many():
        total = len(pkgs)
        try:
            for i, p in enumerate(pkgs, 1):
                _sse_broadcast("log", {"msg": f" {label} [{i}/{total}]  Build: {p}", "cls": "info"})
                try:
                    proc = subprocess.Popen(
                        [PAGBUILD_SYNC, "--once", "--build", p],
                        stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, errors='replace',
                        start_new_session=True,
                        env={**os.environ, "PYTHONUNBUFFERED": "1"}
                    )
                    for line in proc.stdout:
                        line = line.rstrip()
                        cls = ""
                        if "✅" in line:
                            cls = "ok"
                        elif "❌" in line:
                            cls = "error"
                        elif "" in line or "" in line or "" in line or "" in line or "" in line:
                            cls = "info"
                        elif "⚠" in line:
                            cls = "warn"
                        _sse_broadcast("log", {"msg": line, "cls": cls})
                    proc.wait()
                    rc = proc.returncode
                    if rc == 0:
                        _sse_broadcast("log", {"msg": f"✅ {label} [{i}/{total}] {p} – OK", "cls": "ok"})
                    elif rc == 3:
                        # pagsync exit 3 = globalny lock (inny build trwa, np. timer)
                        _sse_broadcast("log", {"msg": f"⏭ {label} [{i}/{total}] {p} – pominięty (inny pagsync już działa)", "cls": "warn"})
                    else:
                        _sse_broadcast("log", {"msg": f"❌ {label} [{i}/{total}] {p} – FAIL (kod {rc})", "cls": "error"})
                except Exception as e:
                    _sse_broadcast("log", {"msg": f"❌ {label} [{i}/{total}] {p} – błąd: {e}", "cls": "error"})
            _sse_broadcast("log", {"msg": f" {label} zakończona: {total} pakietów", "cls": "ok"})
        except Exception as e:
            _sse_broadcast("log", {"msg": f"❌ Błąd kolejki: {e}", "cls": "error"})
        finally:
            _sse_broadcast("status", {"event": "build_done"})

    threading.Thread(target=_run_many, daemon=True).start()
    return len(pkgs)


@build_bp.route("/api/build-many", methods=["POST"])
@build_access_required
def build_many():
    """Buduje zaznaczone pakiety JEDEN PO DRUGIM (sekwencyjna kolejka).
    Każdy pakiet przez osobne pagsync --once --build (jak batch-build.sh),
    output strumieniowany do konsoli przez SSE.
    """
    data = request.get_json(silent=True) or {}
    raw = data.get("packages") or []
    if not isinstance(raw, list):
        return jsonify({"error": "packages musi być listą"}), 400
    pkgs = []
    seen = set()
    for x in raw:
        p = str(x).strip()
        if p and p not in seen:
            seen.add(p)
            pkgs.append(p)
    if not pkgs:
        return jsonify({"error": "Brak zaznaczonych pakietów"}), 400
    if len(pkgs) > 600:
        return jsonify({"error": "Za dużo pakietów (maks. 600)"}), 400

    n = _start_build_queue(pkgs, label="Kolejka")
    return jsonify({"status": "started", "count": n})

@build_bp.route("/download-log")
@build_access_required
def download_log():
    import io
    s = _read_state()
    builds = s.get("builds", [])
    return _make_log_file(builds, "pagan-build")

@build_bp.route("/download-failed-log")
@build_access_required
def download_failed_log():
    """Pobiera log tylko nieudanych / niezbudowanych pakietów."""
    s = _read_state()
    builds = s.get("builds", [])
    # Zbierz ostatni FAIL per pakiet
    failed = {}
    for b in builds:
        if b.get("status") == "failed":
            failed[b["name"]] = b  # ostatni nadpisuje
    # Dodaj pakiety które nigdy nie były budowane (na podstawie aktualnych receptur)
    meta_map = _recipes_from_index(s.get("recipe_index", {}))
    if not meta_map:
        meta_map = _scan_recipes_live()
    packages = s.get("packages", {})
    for name, meta in meta_map.items():
        if name not in packages and name not in failed:
            failed[name] = {"name": name, "version": str(meta.get("version", "?")),
                            "status": "never_built", "category": str(meta.get("category", "?")),
                            "log": "(nigdy nie budowany)", "time": "-", "duration": "-"}
    return _make_log_file(list(failed.values()), "pagan-failed")

def _make_log_file(builds_list, prefix):
    import io
    lines = [f"PaganOS Build Log – {time.strftime('%Y-%m-%d %H:%M:%S')}", "=" * 60,
             f"Liczba wpisów: {len(builds_list)}", ""]
    ok_count = sum(1 for b in builds_list if b.get("status") == "ok")
    fail_count = sum(1 for b in builds_list if b.get("status") != "ok")
    lines.append(f"✅ OK: {ok_count}  ❌ FAIL/niezbudowane: {fail_count}")
    for b in builds_list:
        icon = "OK" if b.get("status") == "ok" else "FAIL"
        lines.append(f"\n[{icon}] {b.get('name')}-{b.get('version','?')} (rel {b.get('release','?')}) [{b.get('category','?')}]")
        lines.append(f"Czas: {b.get('time','?')} | Trwanie: {b.get('duration','?')}")
        lines.append("-" * 40)
        lines.append(b.get("log", "(brak logu)"))
    buf = io.BytesIO("\n".join(lines).encode())
    buf.seek(0)
    return send_file(buf, mimetype="text/plain", as_attachment=True,
                     download_name=f"{prefix}-{time.strftime('%Y%m%d-%H%M%S')}.txt")

def _packages_rows():
    """Lista pakietów (receptury + status + obecność w repo) – wspólna dla
    api_packages oraz wyliczania nieaktualnych/nowych (auto-aktualizacja).

    Źródła listy receptur: state['recipes'] → state['recipe_index'] → żywy skan.
    Status: packages[name] (pagsync) > ostatni build > 'ok' gdy .pag w repo > 'pending'.
    """
    s = _read_state()
    packages = s.get("packages", {}) or {}
    builds = s.get("builds", []) or []

    meta_map = {}
    recipes_state = s.get("recipes") or {}
    for n, m in recipes_state.items():
        if isinstance(m, dict) and m.get("version"):
            meta_map[n] = m
    if not meta_map:
        meta_map = _recipes_from_index(s.get("recipe_index", {}))
    if not meta_map:
        meta_map = _scan_recipes_live()

    last_build = {}
    for b in builds:
        n = b.get("name", "")
        if n:
            last_build[n] = b

    listing = _repo_listing()
    # Mapa nazwa → ścieżka receptury (jedno przejście – bez O(n^2) na pakiet)
    idx = s.get("recipe_index", {}) or {}
    path_by_name = {}
    for k, v in idx.items():
        if not isinstance(v, dict):
            continue
        meta = v.get("meta")
        if isinstance(meta, dict) and meta.get("name") and str(k).startswith("/"):
            path_by_name.setdefault(meta["name"], k)
        elif v.get("path"):
            path_by_name.setdefault(k, v["path"])
    for n, m in recipes_state.items():
        if isinstance(m, dict) and m.get("path"):
            path_by_name.setdefault(n, m["path"])
    result = []
    for name, meta in sorted(meta_map.items()):
        pkg = packages.get(name, {}) or {}
        lb = last_build.get(name, {}) or {}
        in_repo, repo_ver, repo_rel = _repo_match(name, listing)
        status = pkg.get("status") or lb.get("status")
        if not status:
            status = "ok" if in_repo else "pending"
        category = (pkg.get("category") or meta.get("category")
                    or lb.get("category") or "other")
        fp = path_by_name.get(name)
        recipe_mtime = ""
        groups = []
        if fp and os.path.isfile(fp):
            try:
                recipe_mtime = time.strftime("%Y-%m-%d %H:%M",
                                             time.localtime(os.path.getmtime(fp)))
            except Exception:
                recipe_mtime = ""
            groups = _recipe_groups(fp)
        # Nowsza receptura niż to, co leży w repo → kandydat do aktualizacji
        built_ver = repo_ver if in_repo else pkg.get("version")
        needs_update = bool(in_repo and built_ver
                            and meta.get("version") not in (None, "", "?")
                            and str(built_ver) != str(meta.get("version")))
        result.append({
            "name": name,
            "version": meta.get("version", "?"),
            "release": meta.get("release", meta.get("pkgrel", 1)),
            "depends": meta.get("depends", []) or [],
            "makedepends": meta.get("makedepends", []) or [],
            "groups": groups,
            "category": category,
            "status": status,
            "recipe_mtime": recipe_mtime,
            "built_version": repo_ver if in_repo else pkg.get("version"),
            "built_release": repo_rel if in_repo else pkg.get("release"),
            "built_at": pkg.get("built_at"),
            "last_build_time": _local(lb.get("time")),
            "in_repo": in_repo,
            "is_new": (not in_repo),
            "needs_update": needs_update,
        })
    return result


@build_bp.route("/api/packages")
@build_access_required
def api_packages():
    """Zwraca wszystkie receptury z gita + status builda + obecność w repo.

    Pola pomocnicze: `in_repo` (jest .pag), `is_new` (brak w repo – nowy pakiet),
    `needs_update` (w repo leży starsza wersja niż w recepturze).
    """
    result = _packages_rows()
    return jsonify({"packages": result, "total": len(result)})


def _update_candidates(mode="outdated"):
    """Nazwy pakietów do zbudowania/aktualizacji:
    - 'outdated' – w repo jest starsza wersja niż w recepturze,
    - 'new'      – brak pakietu w repo (nowy),
    - 'both'     – jedne i drugie.
    """
    mode = (mode or "outdated").lower()
    names = []
    for p in _packages_rows():
        if mode in ("outdated", "both") and p.get("needs_update"):
            names.append(p["name"])
        elif mode in ("new", "both") and p.get("is_new"):
            names.append(p["name"])
    return names


@build_bp.route("/api/update-outdated", methods=["POST"])
@build_access_required
def update_outdated():
    """Buduje/aktualizuje pakiety na żądanie: nieaktualne (nowsza receptura),
    nowe (brak w repo) albo oba zbiory. Uruchamia sekwencyjną kolejkę."""
    data = request.get_json(silent=True) or {}
    mode = (data.get("mode") or "outdated").lower()
    if mode not in ("outdated", "new", "both"):
        return jsonify({"error": "mode: outdated|new|both"}), 400
    names = _update_candidates(mode)
    if not names:
        return jsonify({"status": "nothing", "count": 0, "mode": mode})
    limit = 600
    if len(names) > limit:
        skipped = len(names) - limit
        names = names[:limit]
        _sse_broadcast("log", {"msg": f"⚠ Limit kolejki {limit} – pominięto {skipped} pakietów", "cls": "warn"})
    label = {"outdated": "Aktualizacja", "new": "Nowe pakiety", "both": "Nowe + aktualizacja"}[mode]
    _sse_broadcast("log", {"msg": f"♻ {label}: {len(names)} pakietów do kolejki", "cls": "info"})
    n = _start_build_queue(names, label=label)
    return jsonify({"status": "started", "count": n, "mode": mode})


# ── Auto-aktualizacja po sync ("" = wyłączona, 'outdated', 'both') ──
_AUTO_UPDATE_KEY = "build_auto_update_mode"


def _auto_update_after_sync():
    """Po udanym syncu: jeśli włączone, dorzuć nieaktualne/nowe do kolejki."""
    from database import get_setting
    mode = (get_setting(_AUTO_UPDATE_KEY, "") or "").lower()
    if mode not in ("outdated", "both"):
        return
    try:
        names = _update_candidates(mode)
    except Exception as e:
        _sse_broadcast("log", {"msg": f"❌ Auto-aktualizacja: {e}", "cls": "error"})
        return
    if not names:
        _sse_broadcast("log", {"msg": "♻ Auto-aktualizacja: wszystko aktualne", "cls": "ok"})
        return
    limit = 600
    if len(names) > limit:
        names = names[:limit]
    _sse_broadcast("log", {"msg": f"♻ Auto-aktualizacja ({mode}): {len(names)} pakietów...", "cls": "warn"})
    _start_build_queue(names, label="Auto")


@build_bp.route("/api/auto-update", methods=["GET", "POST"])
@build_access_required
def auto_update_setting():
    """Odczyt/zapis trybu auto-aktualizacji po sync (DB settings)."""
    from database import get_setting, set_setting
    if request.method == "POST":
        data = request.get_json(silent=True) or {}
        mode = (data.get("mode") or "").lower()
        if mode not in ("", "off", "outdated", "both"):
            return jsonify({"error": "mode: off|outdated|both"}), 400
        if mode == "off":
            mode = ""
        set_setting(_AUTO_UPDATE_KEY, mode)
        _sse_broadcast("log", {"msg": f"♻ Auto-aktualizacja po sync: {mode or 'wyłączona'}", "cls": "info"})
        return jsonify({"ok": True, "mode": mode})
    return jsonify({"mode": get_setting(_AUTO_UPDATE_KEY, "") or ""})


def _recipe_path_from_index(recipe_index, name):
    """Ścieżka do pliku receptury wg nazwy – niezależna od schematu recipe_index:
    ścieżka-klucz {mtime, meta} (zapis pagsync) albo nazwa-klucz {path, ...}.
    Zwraca ścieżkę istniejącego pliku albo None."""
    idx = recipe_index or {}
    # 1) szybka ścieżka: klucz == nazwa (starszy/panelowy schemat)
    v = idx.get(name)
    if isinstance(v, dict) and v.get("path"):
        p = v["path"]
        if os.path.isfile(p):
            return p
    # 2) schemat pagsync: klucz = ścieżka, meta w środku
    for k, val in idx.items():
        if not isinstance(val, dict) or not str(k).startswith("/"):
            continue
        meta = val.get("meta")
        if isinstance(meta, dict) and meta.get("name") == name:
            return k if os.path.isfile(k) else None
    return None


def _recipes_from_index(recipe_index):
    """Wyciąga {name: meta} z recipe_index – obsługuje obie generacje schematu:
    {ścieżka: {mtime, meta}} (aktualna) oraz {nazwa: {path, mtime, version, depends}}
    (starsza). Pomija wpisy, których plik już nie istnieje (usunięte receptury),
    i preferuje świeży schemat kluczowany ścieżką."""
    out = {}
    fp_entries = []   # (name, meta, path) – aktualny schemat
    name_entries = []  # (name, meta, path) – starszy schemat
    for k, v in (recipe_index or {}).items():
        meta = None
        path = None
        if isinstance(v, dict):
            if isinstance(v.get("meta"), dict):
                meta = v["meta"]
                path = str(k) if str(k).startswith("/") else None
            elif isinstance(v.get("version"), str) and v.get("path"):
                meta = v
                path = v.get("path")
        if not meta or not meta.get("version"):
            continue
        name = meta.get("name")
        if not name:
            name = None if str(k).startswith("/") else k
        if not name:
            continue
        meta = dict(meta)
        meta.setdefault("category", _cat_from_path(path or k))
        (fp_entries if str(k).startswith("/") else name_entries).append((name, meta, path))
    # Najpierw świeży schemat (ścieżki), potem starszy – tylko jeśli nazwy brak
    for name, meta, path in fp_entries + name_entries:
        if name in out:
            continue
        if path and not os.path.exists(path):
            continue
        out[name] = meta
    return out


_REPO_CAT_MAP = {"core": "core", "gui": "desktop", "de": "desktop",
                  "utils": "tools", "net": "network", "drivers": "drivers"}


def _cat_from_path(fp):
    for part in str(fp).split("/"):
        if part in _REPO_CAT_MAP:
            return _REPO_CAT_MAP[part]
    return "other"


def _scan_recipes_live():
    """Ostatnia deska ratunku: żywy skan RECIPES_DIR (state bez indeksu)."""
    try:
        import yaml as _yaml
    except Exception:
        return {}
    out = {}
    for root, _dirs, files in os.walk(RECIPES_DIR):
        if "PAGBUILD.yaml" not in files:
            continue
        fp = os.path.join(root, "PAGBUILD.yaml")
        try:
            with open(fp, encoding="utf-8", errors="replace") as fh:
                data = _yaml.safe_load(fh) or {}
            name = data.get("pkgname") or os.path.basename(root)
            if not name:
                continue
            out.setdefault(name, {
                "name": name,
                "version": str(data.get("pkgver", "?")),
                "release": data.get("pkgrel", 1),
                "depends": data.get("depends") or [],
                "category": _cat_from_path(fp),
            })
        except Exception:
            continue
    return out


def _repo_listing():
    try:
        return os.listdir(os.path.join(REPO_BASE, "stable"))
    except Exception:
        return []


def _repo_match(name, listing):
    """Zwraca (in_repo, wersja, release) na podstawie pliku name-ver-rel.pag.
    Przy kilku wersjach w repo wybiera najnowszą."""
    import re as _re
    pat = _re.compile(r"^" + _re.escape(name) + r"-([0-9][^/]*)\.pag$")

    def _numkey(s):
        out = []
        for x in str(s).replace("_", ".").split("."):
            out.append(int(x) if x.isdigit() else sum(ord(c) for c in x))
        return out

    best = None
    for f in listing:
        m = pat.match(f)
        if not m:
            continue
        verrel = m.group(1)
        if "-" in verrel:
            v, r = verrel.rsplit("-", 1)
        else:
            v, r = verrel, "0"
        cur = (_numkey(v), int(r) if r.isdigit() else 0)
        if best is None or cur > best[0]:
            best = (cur, v, r if r != "0" else "")
    if best is None:
        return False, None, None
    return True, best[1], best[2]


_RECIPE_GROUPS_CACHE = {}


def _recipe_groups(fp):
    """Odczytuje opcjonalne pole 'groups:' z PAGBUILD.yaml (np. [kde, gnome]).
    Lekki parser linii + cache wg mtime – bez pełnego YAML na każdą recepturę."""
    try:
        st = os.stat(fp)
        hit = _RECIPE_GROUPS_CACHE.get(fp)
        if hit and hit[0] == st.st_mtime_ns:
            return hit[1]
    except Exception:
        return []
    groups = []
    try:
        with open(fp, encoding="utf-8", errors="replace") as fh:
            for ln in fh:
                s = ln.strip()
                if s.startswith("groups:") or s == "groups":
                    rest = s[len("groups:"):].strip() if ":" in s else ""
                    if rest.startswith("["):
                        inner = rest.split("#")[0].strip().strip("[]")
                        for it in inner.split(","):
                            it = it.strip().strip('"\'')
                            if it:
                                groups.append(it)
                        break
                    elif rest:
                        groups.append(rest)
                        break
                    else:
                        # lista w kolejnych liniach:  - kde
                        for ln2 in fh:
                            s2 = ln2.strip()
                            if s2.startswith("- "):
                                groups.append(s2[2:].strip())
                            elif s2 and not s2.startswith("#"):
                                break
                        break
    except Exception:
        pass
    groups = list(dict.fromkeys(g.lower() for g in groups if g))
    try:
        _RECIPE_GROUPS_CACHE[fp] = (st.st_mtime_ns, groups)
    except Exception:
        pass
    return groups

@build_bp.route("/api/builds")
@build_access_required
def api_builds():
    """Zwraca historię buildów (lekka lista: ostatnie 300, bez logów).

    Logi są duże – tabela pobiera je osobno przez /api/build/<idx> na klik
    (historia trzyma teraz do 5000 wpisów, pełny log tylko dla ostatnich 300).
    """
    s = _read_state()
    builds = s.get("builds", [])
    out = []
    for b in reversed(builds[-300:]):
        row = {k: b.get(k) for k in ("name", "version", "release", "category",
                                     "status", "duration", "time")}
        row["time"] = _local(b.get("time"))
        out.append(row)
    return jsonify({
        "builds": out,
        "total": len(out),
        "stored": len(builds),
        "ok_count": sum(1 for b in builds if b.get("status") == "ok"),
        "fail_count": sum(1 for b in builds if b.get("status") == "failed"),
    })

@build_bp.route("/api/build/<int:build_idx>")
@build_access_required
def api_build_detail(build_idx):
    """Zwraca szczegóły pojedynczego buildu."""
    s = _read_state()
    builds = s.get("builds", [])
    rev = list(reversed(builds))
    if build_idx < 0 or build_idx >= len(rev):
        return jsonify({"error": "not found"}), 404
    b = rev[build_idx]
    # Parsuj log na sekcje
    log_text = b.get("log", "")
    errors = []
    warnings = []
    info_lines = []
    for line in log_text.split("\n"):
        if "❌" in line or "FAIL" in line or "Error" in line or "error:" in line.lower():
            errors.append(line)
        elif "⚠" in line or "WARNING" in line:
            warnings.append(line)
        else:
            info_lines.append(line)
    return jsonify({
        **b,
        "index": build_idx,
        "errors": errors,
        "warnings": warnings,
        "info_count": len(info_lines),
    })

@build_bp.route("/api/recipes")
@build_access_required
def api_recipes():
    """Zwraca listę dostępnych receptur."""
    s = _read_state()
    packages = s.get("packages", {})
    meta_map = _recipes_from_index(s.get("recipe_index", {}))
    if not meta_map:
        meta_map = _scan_recipes_live()

    result = []
    for name, meta in sorted(meta_map.items()):
        pkg = packages.get(name, {}) or {}
        result.append({
            "name": name,
            "version": str(meta.get("version", "?")),
            "depends": meta.get("depends", []) or [],
            "built": name in packages,
            "built_version": pkg.get("version"),
            "built_status": pkg.get("status"),
            "category": str(meta.get("category") or pkg.get("category") or "?"),
        })
    return jsonify({"recipes": result, "total": len(result)})

@build_bp.route("/api/rescan-recipes", methods=["POST"])
@build_access_required
def rescan_recipes():
    """Wymusza ponowne skanowanie receptur."""
    threading.Thread(target=lambda: subprocess.run(
        [PAGBUILD_SYNC, "--once", "--missing"],
        stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, errors='replace'
    ), daemon=True).start()
    return jsonify({"status": "scanning"})

# ── Git webhook – auto-build po push do repo ──
@build_bp.route("/api/git-hook", methods=["POST"])
def git_webhook():
    """Webhook z Gitea/GitHub – po push do recipes → auto sync & build."""
    secret = request.headers.get("X-Gitlab-Token", "") or request.headers.get("X-Hub-Signature-256", "")
    if GIT_WEBHOOK_SECRET not in secret:
        return jsonify({"error": "unauthorized"}), 403

    payload = request.get_json(silent=True) or {}
    repo_name = payload.get("repository", {}).get("name", "")

    _sse_broadcast("log", {"msg": f" Git hook: {repo_name} – uruchamiam sync & build...", "cls": "info"})
    threading.Thread(target=_run_sync, args=(False, "missing"), daemon=True).start()
    return jsonify({"status": "ok"})


# ── Panel v2: nowe API ──
@build_bp.route("/api/console")
@build_access_required
def api_console():
    """Historia konsoli live – replay po odświeżeniu strony (?since=ostatnie_id)."""
    try:
        since = int(request.args.get("since", 0))
    except Exception:
        since = 0
    with _console_lock:
        items = [m for m in _console_buf if m["id"] > since]
        seq = _console_seq
    return jsonify({"seq": seq, "items": items})


@build_bp.route("/api/console/clear", methods=["POST"])
@build_access_required
def api_console_clear():
    with _console_lock:
        _console_buf.clear()
    return jsonify({"ok": True})


@build_bp.route("/api/proc")
@build_access_required
def api_proc():
    procs = _running_pagsync()
    return jsonify({"procs": procs, "count": len(procs)})


@build_bp.route("/api/cancel", methods=["POST"])
@build_access_required
def api_cancel():
    killed = _cancel_pagsync()
    msg = f" Wysłano SIGTERM do: {', '.join(killed) if killed else 'brak aktywnych procesów'}"
    _sse_broadcast("log", {"msg": msg, "cls": "warn"})
    return jsonify({"ok": True, "killed": killed})


@build_bp.route("/api/stats")
@build_access_required
def api_stats():
    return jsonify(_stats_data())


@build_bp.route("/api/repo")
@build_access_required
def api_repo():
    """Lista pakietów .pag w repozytorium (z podpisami .asc)."""
    cats = request.args.get("cats", "stable")
    cat_list = [c for c in cats.split(",") if c]
    q = (request.args.get("q") or "").lower().strip()
    entries = []
    for cat in cat_list:
        d = os.path.join(REPO_BASE, cat)
        if not os.path.isdir(d):
            continue
        try:
            names = sorted(os.listdir(d))
        except Exception:
            continue
        for fn in names:
            if not fn.endswith(".pag"):
                continue
            if q and q not in fn.lower():
                continue
            fp = os.path.join(d, fn)
            try:
                st = os.stat(fp)
                asc = os.path.exists(fp + ".asc")
            except Exception:
                continue
            entries.append({
                "category": cat, "file": fn, "size": st.st_size,
                "mtime": time.strftime("%Y-%m-%d %H:%M", time.localtime(st.st_mtime)),
                "asc": asc, "url": f"https://repo.paganlinux.eu/{cat}/{fn}",
            })
    entries.sort(key=lambda e: e["file"])
    return jsonify({"base": "https://repo.paganlinux.eu", "entries": entries,
                    "total": len(entries)})


@build_bp.route("/api/build-deps", methods=["POST"])
@build_access_required
def api_build_deps():
    """Wymusza przebudowę całego łańcucha zależności pakietu (deps+makedeps+pkg)."""
    data = request.get_json(silent=True) or {}
    name = (data.get("name") or "").strip()
    if not name:
        return jsonify({"error": "brak nazwy pakietu"}), 400
    threading.Thread(target=_run_pagsync, kwargs={
        "args": [PAGBUILD_SYNC, "--rebuild-deps", name],
        "start_msg": f" Rebuild deps: {name} (deps + makedeps + pakiet)",
        "done_msg": f"Rebuild deps zakończony: {name}",
    }, daemon=True).start()
    return jsonify({"status": "started"})


@build_bp.route("/api/check", methods=["POST"])
@build_access_required
def api_check():
    """pagsync --check: weryfikacja receptur (YAML, SHA256, deps, URL) bez budowania."""
    threading.Thread(target=_run_pagsync, kwargs={
        "args": [PAGBUILD_SYNC, "--once", "--check"],
        "start_msg": " pagsync --check – weryfikacja receptur (bez budowania)...",
        "done_msg": "Check receptur zakończony",
    }, daemon=True).start()
    return jsonify({"status": "started"})


@build_bp.route("/api/check-sigs", methods=["POST"])
@build_access_required
def api_check_sigs():
    """pagsync --check-sigs: klucz GPG, repo.json.asc i podpisy wszystkich paczek.
    Bez globalnego locka – można uruchomić także w trakcie budowania."""
    threading.Thread(target=_run_pagsync, kwargs={
        "args": [PAGBUILD_SYNC, "--check-sigs"],
        "start_msg": "🔐 Kontrola podpisów GPG (klucz, repo.json, paczki)...",
        "done_msg": "Kontrola podpisów zakończona",
    }, daemon=True).start()
    return jsonify({"status": "started"})


@build_bp.route("/api/fix-sigs", methods=["POST"])
@build_access_required
def api_fix_sigs():
    """pagsync --check-sigs --fix-sigs: podpisuje ponownie złe/brakujące .asc."""
    threading.Thread(target=_run_pagsync, kwargs={
        "args": [PAGBUILD_SYNC, "--check-sigs", "--fix-sigs"],
        "start_msg": "🔏 Naprawa podpisów GPG (re-sign paczek z złym/brakującym .asc)...",
        "done_msg": "Naprawa podpisów zakończona",
    }, daemon=True).start()
    return jsonify({"status": "started"})