Initial commit: Pangolin Sentinel Docker & Unraid Hub

This commit is contained in:
jschantli committed 2026-09-04 19:46:04 -07:00
1 parent 178b40ba37
commit b4095b82ea
24 files changed
+4355

No files matched your search

+215
View File
@@ -0,0 +1,215 @@
import asyncio
import os
import json
import time
import secrets
from typing import Dict, List, Optional, Any, Set
from fastapi import WebSocket
from backend.config import JOBS_FILE
class JobManager:
def __init__(self):
self.active_jobs: Dict[str, Dict[str, Any]] = {}
self.subscribers: Dict[str, Set[WebSocket]] = {}
self.global_subscribers: Set[WebSocket] = set()
self.lock = asyncio.Lock()
self.type_locks: Dict[str, asyncio.Lock] = {
"backup": asyncio.Lock(),
"restore": asyncio.Lock(),
"update_pangolin": asyncio.Lock(),
"update_system": asyncio.Lock(),
}
def _load_history(self) -> List[Dict[str, Any]]:
if not JOBS_FILE.exists():
return []
try:
with open(JOBS_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return []
def _save_history(self, history: List[Dict[str, Any]]) -> None:
try:
with open(JOBS_FILE, "w", encoding="utf-8") as f:
json.dump(history[:50], f, indent=2)
except Exception as e:
print(f"Error saving jobs history: {e}")
def list_jobs(self, limit: int = 25) -> List[Dict[str, Any]]:
history = self._load_history()
# Merge active jobs at the beginning
active_list = list(self.active_jobs.values())
seen_ids = {j["id"] for j in active_list}
merged = active_list + [j for j in history if j["id"] not in seen_ids]
merged.sort(key=lambda x: x.get("start_time", 0), reverse=True)
return merged[:limit]
def get_job(self, job_id: str) -> Optional[Dict[str, Any]]:
if job_id in self.active_jobs:
return self.active_jobs[job_id]
history = self._load_history()
for j in history:
if j["id"] == job_id:
return j
return None
async def register_subscriber(self, job_id: str, ws: WebSocket):
if job_id not in self.subscribers:
self.subscribers[job_id] = set()
self.subscribers[job_id].add(ws)
def unregister_subscriber(self, job_id: str, ws: WebSocket):
if job_id in self.subscribers:
self.subscribers[job_id].discard(ws)
if not self.subscribers[job_id]:
del self.subscribers[job_id]
async def register_global(self, ws: WebSocket):
self.global_subscribers.add(ws)
def unregister_global(self, ws: WebSocket):
self.global_subscribers.discard(ws)
async def broadcast(self, job_id: str, message: Dict[str, Any]):
# Broadcast to specific job subscribers
if job_id in self.subscribers:
dead = set()
for ws in list(self.subscribers[job_id]):
try:
await ws.send_json(message)
except Exception:
dead.add(ws)
for ws in dead:
self.subscribers[job_id].discard(ws)
# Broadcast to global subscribers
dead_global = set()
for ws in list(self.global_subscribers):
try:
await ws.send_json(message)
except Exception:
dead_global.add(ws)
for ws in dead_global:
self.global_subscribers.discard(ws)
async def run_command_job(
self,
name: str,
cmd: List[str] | str,
job_type: str = "general",
env: Optional[Dict[str, str]] = None,
cwd: Optional[str] = None,
description: str = ""
) -> Dict[str, Any]:
lock = self.type_locks.get(job_type)
if lock and lock.locked():
return {
"success": False,
"error": f"A {job_type} task is already running in the background. Please wait for it to complete."
}
job_id = f"job_{int(time.time())}_{secrets.token_hex(4)}"
job = {
"id": job_id,
"name": name,
"type": job_type,
"description": description,
"cmd": cmd if isinstance(cmd, str) else " ".join(cmd),
"status": "running",
"start_time": time.time(),
"end_time": None,
"duration": None,
"exit_code": None,
"logs": []
}
self.active_jobs[job_id] = job
await self.broadcast(job_id, {"type": "job_started", "job": job})
# Run in background asyncio task
asyncio.create_task(self._execute_process(job_id, cmd, env, cwd, lock))
return {"success": True, "job_id": job_id, "job": job}
async def _execute_process(
self,
job_id: str,
cmd: List[str] | str,
env: Optional[Dict[str, str]],
cwd: Optional[str],
lock: Optional[asyncio.Lock]
):
if lock:
await lock.acquire()
job = self.active_jobs[job_id]
run_env = os.environ.copy()
run_env["DEBIAN_FRONTEND"] = "noninteractive"
run_env["TERM"] = "xterm-256color"
run_env["COLUMNS"] = "120"
if env:
run_env.update(env)
try:
if isinstance(cmd, list):
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
env=run_env,
cwd=cwd
)
else:
proc = await asyncio.create_subprocess_shell(
cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
env=run_env,
cwd=cwd,
executable="/bin/bash" if os.path.exists("/bin/bash") else None
)
while True:
line = await proc.stdout.readline()
if not line:
break
text = line.decode('utf-8', errors='replace').rstrip('\r\n')
timestamp = time.strftime("%H:%M:%S")
log_entry = f"[{timestamp}] {text}"
job["logs"].append(log_entry)
# Keep up to 2000 lines
if len(job["logs"]) > 2000:
job["logs"] = job["logs"][-2000:]
await self.broadcast(job_id, {"type": "log", "job_id": job_id, "data": log_entry})
await proc.wait()
exit_code = proc.returncode
job["exit_code"] = exit_code
job["status"] = "completed" if exit_code == 0 else "failed"
except Exception as e:
err_msg = f"Execution error: {str(e)}"
job["logs"].append(f"[{time.strftime('%H:%M:%S')}] ERROR: {err_msg}")
job["status"] = "failed"
job["exit_code"] = -1
await self.broadcast(job_id, {"type": "log", "job_id": job_id, "data": err_msg})
finally:
job["end_time"] = time.time()
job["duration"] = round(job["end_time"] - job["start_time"], 1)
await self.broadcast(job_id, {"type": "job_finished", "job": job})
# Save to history file
history = self._load_history()
history.insert(0, job.copy())
self._save_history(history)
# Clean from active_jobs after a short delay so clients can finish reading
await asyncio.sleep(5)
if job_id in self.active_jobs:
del self.active_jobs[job_id]
if lock:
lock.release()
runner = JobManager()