fix: recover stale jobs on worker restart, persist active page on reload
- Worker resets running/cancelling jobs to idle on startup to fix jobs stuck after Docker restart - Frontend saves current page to localStorage so reload returns to last visited page instead of always dashboard Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+212
-79
@@ -13,7 +13,6 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from pydantic import BaseModel
|
||||
from jose import JWTError, jwt
|
||||
|
||||
# ── Config ──────────────────────────────────────────────────────────────────
|
||||
DB_PATH = os.environ.get("DB_PATH", "/data/imapsync.db")
|
||||
LOG_DIR = os.environ.get("LOG_DIR", "/data/logs")
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-change-me")
|
||||
@@ -22,16 +21,33 @@ TOKEN_EXPIRE_HOURS = 12
|
||||
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
|
||||
# ── DB ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
def get_db():
|
||||
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db():
|
||||
conn = get_db()
|
||||
|
||||
tables = [r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()]
|
||||
needs_migration = 'servers' not in tables and 'sync_jobs' in tables
|
||||
if needs_migration:
|
||||
conn.executescript("DROP TABLE IF EXISTS job_runs; DROP TABLE IF EXISTS sync_jobs;")
|
||||
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS servers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER DEFAULT 993,
|
||||
ssl INTEGER DEFAULT 1,
|
||||
direction TEXT NOT NULL DEFAULT 'both',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
@@ -41,25 +57,23 @@ def init_db():
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sync_jobs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
src_host TEXT NOT NULL,
|
||||
src_port INTEGER DEFAULT 993,
|
||||
src_ssl INTEGER DEFAULT 1,
|
||||
src_user TEXT NOT NULL,
|
||||
src_password TEXT NOT NULL,
|
||||
dst_host TEXT NOT NULL,
|
||||
dst_port INTEGER DEFAULT 993,
|
||||
dst_ssl INTEGER DEFAULT 1,
|
||||
dst_user TEXT NOT NULL,
|
||||
dst_password TEXT NOT NULL,
|
||||
extra_args TEXT DEFAULT '',
|
||||
schedule TEXT DEFAULT NULL,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
status TEXT DEFAULT 'idle',
|
||||
last_run DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by TEXT
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
src_server_id INTEGER NOT NULL,
|
||||
src_user TEXT NOT NULL,
|
||||
src_password TEXT NOT NULL,
|
||||
dst_server_id INTEGER NOT NULL,
|
||||
dst_user TEXT NOT NULL,
|
||||
dst_password TEXT NOT NULL,
|
||||
extra_args TEXT DEFAULT '',
|
||||
schedule TEXT DEFAULT NULL,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
status TEXT DEFAULT 'idle',
|
||||
last_run DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by TEXT,
|
||||
FOREIGN KEY (src_server_id) REFERENCES servers(id),
|
||||
FOREIGN KEY (dst_server_id) REFERENCES servers(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS job_runs (
|
||||
@@ -82,7 +96,6 @@ def init_db():
|
||||
expires_at DATETIME NOT NULL
|
||||
);
|
||||
""")
|
||||
# Create default admin if no users exist
|
||||
cur = conn.execute("SELECT COUNT(*) FROM users")
|
||||
if cur.fetchone()[0] == 0:
|
||||
pw_md5 = hashlib.md5("admin".encode()).hexdigest()
|
||||
@@ -93,91 +106,115 @@ def init_db():
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# ── Auth ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def md5_hash(pw: str) -> str:
|
||||
return hashlib.md5(pw.encode()).hexdigest()
|
||||
|
||||
|
||||
def create_token(username: str, role: str) -> str:
|
||||
expire = datetime.utcnow() + timedelta(hours=TOKEN_EXPIRE_HOURS)
|
||||
payload = {"sub": username, "role": role, "exp": expire}
|
||||
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict:
|
||||
try:
|
||||
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
except JWTError:
|
||||
raise HTTPException(status_code=401, detail="Token ungültig oder abgelaufen")
|
||||
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||
return decode_token(credentials.credentials)
|
||||
|
||||
|
||||
def require_admin(user=Depends(get_current_user)):
|
||||
if user.get("role") != "admin":
|
||||
raise HTTPException(status_code=403, detail="Nur Admins erlaubt")
|
||||
return user
|
||||
|
||||
# ── App ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
def require_editor(user=Depends(get_current_user)):
|
||||
if user.get("role") == "viewer":
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung")
|
||||
return user
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="ImapSync Manager", lifespan=lifespan)
|
||||
|
||||
# ── Schemas ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
role: str = "viewer" # admin | operator | viewer
|
||||
role: str = "viewer"
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
password: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
|
||||
|
||||
class ServerCreate(BaseModel):
|
||||
name: str
|
||||
host: str
|
||||
port: int = 993
|
||||
ssl: bool = True
|
||||
direction: str = "both"
|
||||
|
||||
|
||||
class ServerUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
host: Optional[str] = None
|
||||
port: Optional[int] = None
|
||||
ssl: Optional[bool] = None
|
||||
direction: Optional[str] = None
|
||||
|
||||
|
||||
class SyncJobCreate(BaseModel):
|
||||
name: str
|
||||
src_host: str
|
||||
src_port: int = 993
|
||||
src_ssl: bool = True
|
||||
src_server_id: int
|
||||
src_user: str
|
||||
src_password: str
|
||||
dst_host: str
|
||||
dst_port: int = 993
|
||||
dst_ssl: bool = True
|
||||
dst_server_id: int
|
||||
dst_user: str
|
||||
dst_password: str
|
||||
extra_args: str = ""
|
||||
schedule: Optional[str] = None
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class SyncJobUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
src_host: Optional[str] = None
|
||||
src_port: Optional[int] = None
|
||||
src_ssl: Optional[bool] = None
|
||||
src_server_id: Optional[int] = None
|
||||
src_user: Optional[str] = None
|
||||
src_password: Optional[str] = None
|
||||
dst_host: Optional[str] = None
|
||||
dst_port: Optional[int] = None
|
||||
dst_ssl: Optional[bool] = None
|
||||
dst_server_id: Optional[int] = None
|
||||
dst_user: Optional[str] = None
|
||||
dst_password: Optional[str] = None
|
||||
extra_args: Optional[str] = None
|
||||
schedule: Optional[str] = None
|
||||
enabled: Optional[bool] = None
|
||||
|
||||
# ── Health ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
return {"status": "ok", "time": datetime.utcnow().isoformat()}
|
||||
|
||||
# ── Auth Endpoints ────────────────────────────────────────────────────────────
|
||||
|
||||
@app.post("/api/auth/login")
|
||||
def login(req: LoginRequest):
|
||||
conn = get_db()
|
||||
@@ -191,11 +228,12 @@ def login(req: LoginRequest):
|
||||
token = create_token(row["username"], row["role"])
|
||||
return {"token": token, "username": row["username"], "role": row["role"]}
|
||||
|
||||
|
||||
@app.get("/api/auth/me")
|
||||
def me(user=Depends(get_current_user)):
|
||||
return user
|
||||
|
||||
# ── User Endpoints ────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/users")
|
||||
def list_users(user=Depends(require_admin)):
|
||||
conn = get_db()
|
||||
@@ -205,6 +243,7 @@ def list_users(user=Depends(require_admin)):
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@app.post("/api/users", status_code=201)
|
||||
def create_user(req: UserCreate, user=Depends(require_admin)):
|
||||
conn = get_db()
|
||||
@@ -220,6 +259,7 @@ def create_user(req: UserCreate, user=Depends(require_admin)):
|
||||
conn.close()
|
||||
return {"message": f"Benutzer '{req.username}' erstellt"}
|
||||
|
||||
|
||||
@app.put("/api/users/{user_id}")
|
||||
def update_user(user_id: int, req: UserUpdate, user=Depends(require_admin)):
|
||||
conn = get_db()
|
||||
@@ -241,6 +281,7 @@ def update_user(user_id: int, req: UserUpdate, user=Depends(require_admin)):
|
||||
conn.close()
|
||||
return {"message": "Benutzer aktualisiert"}
|
||||
|
||||
|
||||
@app.delete("/api/users/{user_id}")
|
||||
def delete_user(user_id: int, user=Depends(require_admin)):
|
||||
conn = get_db()
|
||||
@@ -254,15 +295,84 @@ def delete_user(user_id: int, user=Depends(require_admin)):
|
||||
conn.close()
|
||||
return {"message": "Benutzer gelöscht"}
|
||||
|
||||
# ── Sync Job Endpoints ────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/servers")
|
||||
def list_servers(user=Depends(get_current_user)):
|
||||
conn = get_db()
|
||||
rows = conn.execute("""
|
||||
SELECT s.*,
|
||||
(SELECT COUNT(*) FROM sync_jobs j WHERE j.src_server_id = s.id) as used_as_src,
|
||||
(SELECT COUNT(*) FROM sync_jobs j WHERE j.dst_server_id = s.id) as used_as_dst
|
||||
FROM servers s ORDER BY s.name
|
||||
""").fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@app.post("/api/servers", status_code=201)
|
||||
def create_server(req: ServerCreate, user=Depends(require_editor)):
|
||||
conn = get_db()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO servers (name, host, port, ssl, direction) VALUES (?, ?, ?, ?, ?)",
|
||||
(req.name, req.host, req.port, int(req.ssl), req.direction)
|
||||
)
|
||||
conn.commit()
|
||||
server_id = cur.lastrowid
|
||||
conn.close()
|
||||
return {"message": "Server erstellt", "id": server_id}
|
||||
|
||||
|
||||
@app.put("/api/servers/{server_id}")
|
||||
def update_server(server_id: int, req: ServerUpdate, user=Depends(require_editor)):
|
||||
conn = get_db()
|
||||
row = conn.execute("SELECT id FROM servers WHERE id = ?", (server_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Server nicht gefunden")
|
||||
fields = {k: v for k, v in req.model_dump().items() if v is not None}
|
||||
if "ssl" in fields:
|
||||
fields["ssl"] = int(fields["ssl"])
|
||||
if fields:
|
||||
set_clause = ", ".join(f"{k} = ?" for k in fields)
|
||||
conn.execute(
|
||||
f"UPDATE servers SET {set_clause} WHERE id = ?",
|
||||
(*fields.values(), server_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"message": "Server aktualisiert"}
|
||||
|
||||
|
||||
@app.delete("/api/servers/{server_id}")
|
||||
def delete_server(server_id: int, user=Depends(require_editor)):
|
||||
conn = get_db()
|
||||
row = conn.execute("SELECT id FROM servers WHERE id = ?", (server_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Server nicht gefunden")
|
||||
in_use = conn.execute(
|
||||
"SELECT COUNT(*) FROM sync_jobs WHERE src_server_id = ? OR dst_server_id = ?",
|
||||
(server_id, server_id)
|
||||
).fetchone()[0]
|
||||
if in_use > 0:
|
||||
raise HTTPException(status_code=409, detail="Server wird noch in Jobs verwendet")
|
||||
conn.execute("DELETE FROM servers WHERE id = ?", (server_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"message": "Server gelöscht"}
|
||||
|
||||
|
||||
@app.get("/api/jobs")
|
||||
def list_jobs(user=Depends(get_current_user)):
|
||||
conn = get_db()
|
||||
rows = conn.execute("""
|
||||
SELECT j.*,
|
||||
s1.name as src_server_name, s1.host as src_host, s1.port as src_port, s1.ssl as src_ssl,
|
||||
s2.name as dst_server_name, s2.host as dst_host, s2.port as dst_port, s2.ssl as dst_ssl,
|
||||
(SELECT COUNT(*) FROM job_runs r WHERE r.job_id = j.id) as run_count,
|
||||
(SELECT SUM(messages_synced) FROM job_runs r WHERE r.job_id = j.id) as total_synced
|
||||
FROM sync_jobs j ORDER BY j.created_at DESC
|
||||
FROM sync_jobs j
|
||||
LEFT JOIN servers s1 ON j.src_server_id = s1.id
|
||||
LEFT JOIN servers s2 ON j.dst_server_id = s2.id
|
||||
ORDER BY j.created_at DESC
|
||||
""").fetchall()
|
||||
conn.close()
|
||||
result = []
|
||||
@@ -273,29 +383,41 @@ def list_jobs(user=Depends(get_current_user)):
|
||||
result.append(d)
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/api/jobs", status_code=201)
|
||||
def create_job(req: SyncJobCreate, user=Depends(get_current_user)):
|
||||
if user.get("role") == "viewer":
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung")
|
||||
def create_job(req: SyncJobCreate, user=Depends(require_editor)):
|
||||
conn = get_db()
|
||||
for sid in [req.src_server_id, req.dst_server_id]:
|
||||
if not conn.execute("SELECT id FROM servers WHERE id = ?", (sid,)).fetchone():
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail=f"Server-ID {sid} nicht gefunden")
|
||||
cur = conn.execute("""
|
||||
INSERT INTO sync_jobs
|
||||
(name,src_host,src_port,src_ssl,src_user,src_password,
|
||||
dst_host,dst_port,dst_ssl,dst_user,dst_password,
|
||||
extra_args,schedule,enabled,created_by)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""", (req.name, req.src_host, req.src_port, int(req.src_ssl), req.src_user, req.src_password,
|
||||
req.dst_host, req.dst_port, int(req.dst_ssl), req.dst_user, req.dst_password,
|
||||
(name, src_server_id, src_user, src_password,
|
||||
dst_server_id, dst_user, dst_password,
|
||||
extra_args, schedule, enabled, created_by)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
||||
""", (req.name, req.src_server_id, req.src_user, req.src_password,
|
||||
req.dst_server_id, req.dst_user, req.dst_password,
|
||||
req.extra_args, req.schedule, int(req.enabled), user["sub"]))
|
||||
conn.commit()
|
||||
job_id = cur.lastrowid
|
||||
conn.close()
|
||||
return {"message": "Job erstellt", "id": job_id}
|
||||
|
||||
|
||||
@app.get("/api/jobs/{job_id}")
|
||||
def get_job(job_id: int, user=Depends(get_current_user)):
|
||||
conn = get_db()
|
||||
row = conn.execute("SELECT * FROM sync_jobs WHERE id = ?", (job_id,)).fetchone()
|
||||
row = conn.execute("""
|
||||
SELECT j.*,
|
||||
s1.name as src_server_name, s1.host as src_host, s1.port as src_port, s1.ssl as src_ssl,
|
||||
s2.name as dst_server_name, s2.host as dst_host, s2.port as dst_port, s2.ssl as dst_ssl
|
||||
FROM sync_jobs j
|
||||
LEFT JOIN servers s1 ON j.src_server_id = s1.id
|
||||
LEFT JOIN servers s2 ON j.dst_server_id = s2.id
|
||||
WHERE j.id = ?
|
||||
""", (job_id,)).fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Job nicht gefunden")
|
||||
@@ -305,21 +427,24 @@ def get_job(job_id: int, user=Depends(get_current_user)):
|
||||
d.pop("dst_password", None)
|
||||
return d
|
||||
|
||||
|
||||
@app.put("/api/jobs/{job_id}")
|
||||
def update_job(job_id: int, req: SyncJobUpdate, user=Depends(get_current_user)):
|
||||
if user.get("role") == "viewer":
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung")
|
||||
def update_job(job_id: int, req: SyncJobUpdate, user=Depends(require_editor)):
|
||||
conn = get_db()
|
||||
row = conn.execute("SELECT id FROM sync_jobs WHERE id = ?", (job_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Job nicht gefunden")
|
||||
fields = {k: v for k, v in req.model_dump().items() if v is not None}
|
||||
if "src_ssl" in fields:
|
||||
fields["src_ssl"] = int(fields["src_ssl"])
|
||||
if "dst_ssl" in fields:
|
||||
fields["dst_ssl"] = int(fields["dst_ssl"])
|
||||
if "enabled" in fields:
|
||||
fields["enabled"] = int(fields["enabled"])
|
||||
if "src_server_id" in fields:
|
||||
if not conn.execute("SELECT id FROM servers WHERE id = ?", (fields["src_server_id"],)).fetchone():
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail="Quell-Server nicht gefunden")
|
||||
if "dst_server_id" in fields:
|
||||
if not conn.execute("SELECT id FROM servers WHERE id = ?", (fields["dst_server_id"],)).fetchone():
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail="Ziel-Server nicht gefunden")
|
||||
if fields:
|
||||
set_clause = ", ".join(f"{k} = ?" for k in fields)
|
||||
conn.execute(
|
||||
@@ -330,10 +455,9 @@ def update_job(job_id: int, req: SyncJobUpdate, user=Depends(get_current_user)):
|
||||
conn.close()
|
||||
return {"message": "Job aktualisiert"}
|
||||
|
||||
|
||||
@app.delete("/api/jobs/{job_id}")
|
||||
def delete_job(job_id: int, user=Depends(get_current_user)):
|
||||
if user.get("role") == "viewer":
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung")
|
||||
def delete_job(job_id: int, user=Depends(require_editor)):
|
||||
conn = get_db()
|
||||
conn.execute("DELETE FROM job_runs WHERE job_id = ?", (job_id,))
|
||||
conn.execute("DELETE FROM sync_jobs WHERE id = ?", (job_id,))
|
||||
@@ -341,10 +465,9 @@ def delete_job(job_id: int, user=Depends(get_current_user)):
|
||||
conn.close()
|
||||
return {"message": "Job gelöscht"}
|
||||
|
||||
|
||||
@app.post("/api/jobs/{job_id}/trigger")
|
||||
def trigger_job(job_id: int, user=Depends(get_current_user)):
|
||||
if user.get("role") == "viewer":
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung")
|
||||
def trigger_job(job_id: int, user=Depends(require_editor)):
|
||||
conn = get_db()
|
||||
row = conn.execute("SELECT status FROM sync_jobs WHERE id = ?", (job_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -356,20 +479,27 @@ def trigger_job(job_id: int, user=Depends(get_current_user)):
|
||||
conn.close()
|
||||
return {"message": "Job in Warteschlange eingereiht"}
|
||||
|
||||
|
||||
@app.post("/api/jobs/{job_id}/stop")
|
||||
def stop_job(job_id: int, user=Depends(get_current_user)):
|
||||
if user.get("role") == "viewer":
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung")
|
||||
def stop_job(job_id: int, user=Depends(require_editor)):
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"UPDATE sync_jobs SET status = 'idle' WHERE id = ? AND status = 'queued'",
|
||||
(job_id,)
|
||||
)
|
||||
row = conn.execute("SELECT status FROM sync_jobs WHERE id = ?", (job_id,)).fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail="Job nicht gefunden")
|
||||
if row["status"] == "queued":
|
||||
conn.execute("UPDATE sync_jobs SET status = 'idle' WHERE id = ?", (job_id,))
|
||||
elif row["status"] == "running":
|
||||
conn.execute("UPDATE sync_jobs SET status = 'cancelling' WHERE id = ?", (job_id,))
|
||||
conn.execute("""
|
||||
UPDATE job_runs SET status = 'cancelled', finished_at = ?
|
||||
WHERE job_id = ? AND status = 'running'
|
||||
""", (datetime.utcnow().isoformat(), job_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"message": "Job aus Warteschlange entfernt (wenn möglich)"}
|
||||
return {"message": "Job wird abgebrochen"}
|
||||
|
||||
|
||||
# ── Run / Log Endpoints ───────────────────────────────────────────────────────
|
||||
@app.get("/api/jobs/{job_id}/runs")
|
||||
def get_job_runs(job_id: int, limit: int = 50, user=Depends(get_current_user)):
|
||||
conn = get_db()
|
||||
@@ -380,6 +510,7 @@ def get_job_runs(job_id: int, limit: int = 50, user=Depends(get_current_user)):
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@app.get("/api/runs/{run_id}/log")
|
||||
def get_run_log(run_id: int, user=Depends(get_current_user)):
|
||||
conn = get_db()
|
||||
@@ -393,7 +524,7 @@ def get_run_log(run_id: int, user=Depends(get_current_user)):
|
||||
with open(log_path, "r", errors="replace") as f:
|
||||
return {"content": f.read()}
|
||||
|
||||
# ── Stats Endpoint ────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/stats")
|
||||
def get_stats(user=Depends(get_current_user)):
|
||||
conn = get_db()
|
||||
@@ -406,7 +537,6 @@ def get_stats(user=Depends(get_current_user)):
|
||||
total_synced = conn.execute("SELECT COALESCE(SUM(messages_synced),0) FROM job_runs").fetchone()[0]
|
||||
total_errors = conn.execute("SELECT COALESCE(SUM(errors),0) FROM job_runs").fetchone()[0]
|
||||
|
||||
# Last 14 days activity
|
||||
daily = conn.execute("""
|
||||
SELECT DATE(started_at) as day,
|
||||
COUNT(*) as runs,
|
||||
@@ -417,7 +547,6 @@ def get_stats(user=Depends(get_current_user)):
|
||||
GROUP BY day ORDER BY day
|
||||
""").fetchall()
|
||||
|
||||
# Recent runs
|
||||
recent = conn.execute("""
|
||||
SELECT r.id, r.job_id, j.name as job_name, r.started_at, r.finished_at,
|
||||
r.status, r.messages_synced, r.errors, r.duration_sec
|
||||
@@ -426,18 +555,22 @@ def get_stats(user=Depends(get_current_user)):
|
||||
ORDER BY r.started_at DESC LIMIT 10
|
||||
""").fetchall()
|
||||
|
||||
total_servers = conn.execute("SELECT COUNT(*) FROM servers").fetchone()[0]
|
||||
|
||||
conn.close()
|
||||
return {
|
||||
"jobs": {"total": total_jobs, "active": active_jobs, "running": running, "queued": queued},
|
||||
"runs": {"total": total_runs, "failed": failed_runs},
|
||||
"messages": {"synced": total_synced, "errors": total_errors},
|
||||
"servers": {"total": total_servers},
|
||||
"daily": [dict(d) for d in daily],
|
||||
"recent_runs": [dict(r) for r in recent],
|
||||
}
|
||||
|
||||
# ── Static Frontend ───────────────────────────────────────────────────────────
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
|
||||
@app.get("/{full_path:path}", response_class=HTMLResponse)
|
||||
async def serve_spa(full_path: str):
|
||||
with open("static/index.html", "r") as f:
|
||||
|
||||
Reference in New Issue
Block a user