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/", 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/// (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/ 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/") @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"})