🔒 Repository is read-only – file editing is disabled.
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"})