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:
Sebastian Serfling
2026-04-22 14:07:13 +02:00
parent f2d749ba3f
commit f600cd52f3
4 changed files with 721 additions and 205 deletions
+212 -79
View File
@@ -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: