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:
|
||||
|
||||
+385
-99
@@ -33,7 +33,7 @@
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* ── Login ── */
|
||||
@@ -130,10 +130,10 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 20px;
|
||||
padding: 10px 20px;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-left: 2px solid transparent;
|
||||
transition: all 0.15s;
|
||||
@@ -170,7 +170,7 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.user-info { flex: 1; min-width: 0; }
|
||||
.user-info .name { font-size: 12px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.user-info .name { font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.user-info .role { font-size: 10px; font-family: var(--mono); color: var(--muted); text-transform: uppercase; letter-spacing: 1px; }
|
||||
.logout-btn {
|
||||
background: none; border: none; color: var(--muted); cursor: pointer;
|
||||
@@ -182,7 +182,7 @@
|
||||
/* ── Main Content ── */
|
||||
.main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.topbar {
|
||||
height: 52px;
|
||||
height: 54px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -192,7 +192,7 @@
|
||||
}
|
||||
.page-title {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
font-family: var(--mono);
|
||||
letter-spacing: 0.5px;
|
||||
@@ -223,7 +223,7 @@
|
||||
}
|
||||
.stat-card .value {
|
||||
font-family: var(--mono);
|
||||
font-size: 28px;
|
||||
font-size: 30px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -261,7 +261,7 @@
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 10px 18px;
|
||||
padding: 9px 18px;
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 1.5px;
|
||||
@@ -270,7 +270,7 @@
|
||||
border-bottom: 1px solid var(--border2);
|
||||
font-weight: 500;
|
||||
}
|
||||
td { padding: 11px 18px; border-bottom: 1px solid var(--border2); vertical-align: middle; }
|
||||
td { padding: 10px 18px; border-bottom: 1px solid var(--border2); vertical-align: middle; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: var(--surface2); }
|
||||
|
||||
@@ -298,11 +298,18 @@
|
||||
.badge.done .badge-dot { background: var(--accent); }
|
||||
.badge.failed { background: rgba(248,81,73,0.15); color: var(--danger); }
|
||||
.badge.failed .badge-dot { background: var(--danger); }
|
||||
.badge.cancelled { background: rgba(210,153,34,0.15); color: var(--warn); }
|
||||
.badge.cancelled .badge-dot { background: var(--warn); }
|
||||
.badge.cancelling { background: rgba(210,153,34,0.15); color: var(--warn); }
|
||||
.badge.cancelling .badge-dot { background: var(--warn); animation: pulse 0.8s infinite; }
|
||||
.badge.admin { background: rgba(56,139,253,0.15); color: var(--blue); }
|
||||
.badge.operator { background: rgba(210,153,34,0.15); color: var(--warn); }
|
||||
.badge.viewer { background: rgba(125,133,144,0.15); color: var(--muted); }
|
||||
.badge.enabled { background: rgba(46,160,67,0.15); color: var(--accent); }
|
||||
.badge.disabled { background: rgba(125,133,144,0.15); color: var(--muted); }
|
||||
.badge.source { background: rgba(56,139,253,0.15); color: var(--blue); }
|
||||
.badge.destination { background: rgba(210,153,34,0.15); color: var(--warn); }
|
||||
.badge.both { background: rgba(46,160,67,0.15); color: var(--accent); }
|
||||
|
||||
/* ── Buttons ── */
|
||||
.btn {
|
||||
@@ -313,7 +320,7 @@
|
||||
border-radius: 5px;
|
||||
border: 1px solid transparent;
|
||||
font-family: var(--sans);
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
@@ -326,14 +333,14 @@
|
||||
.btn-outline:hover { background: var(--surface2); border-color: var(--muted); }
|
||||
.btn-danger { background: transparent; color: var(--danger); border-color: var(--danger-dim); }
|
||||
.btn-danger:hover { background: rgba(248,81,73,0.1); }
|
||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||
.btn-sm { padding: 4px 10px; font-size: 13px; }
|
||||
.btn-icon { padding: 5px 8px; font-size: 14px; }
|
||||
|
||||
/* ── Forms ── */
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.form-group { display: flex; flex-direction: column; gap: 6px; }
|
||||
.form-group.full { grid-column: 1 / -1; }
|
||||
label { font-size: 12px; font-weight: 500; color: var(--muted); font-family: var(--mono); letter-spacing: 0.5px; }
|
||||
label { font-size: 13px; font-weight: 500; color: var(--muted); font-family: var(--mono); letter-spacing: 0.5px; }
|
||||
input, select, textarea {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
@@ -341,7 +348,7 @@
|
||||
color: var(--text);
|
||||
padding: 8px 12px;
|
||||
font-family: var(--sans);
|
||||
font-size: 13px;
|
||||
font-size: 15px;
|
||||
transition: border-color 0.15s;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -350,7 +357,7 @@
|
||||
border-color: var(--blue);
|
||||
}
|
||||
input[type="checkbox"] { width: auto; }
|
||||
textarea { resize: vertical; min-height: 80px; font-family: var(--mono); font-size: 12px; }
|
||||
textarea { resize: vertical; min-height: 80px; font-family: var(--mono); font-size: 14px; }
|
||||
select option { background: var(--surface); }
|
||||
.form-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border2); }
|
||||
.section-divider {
|
||||
@@ -363,6 +370,45 @@
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.section-divider-prominent {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.section-divider-prominent::before,
|
||||
.section-divider-prominent::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.section-divider-prominent.src::before,
|
||||
.section-divider-prominent.src::after { background: var(--blue); }
|
||||
.section-divider-prominent.dst::before,
|
||||
.section-divider-prominent.dst::after { background: var(--danger); }
|
||||
.section-divider-prominent .section-title {
|
||||
padding: 0 14px;
|
||||
font-family: var(--mono);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.section-divider-prominent.src .section-title { color: var(--blue); }
|
||||
.section-divider-prominent.dst .section-title { color: var(--danger); }
|
||||
.server-info {
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border2);
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ── Modal ── */
|
||||
.modal-overlay {
|
||||
@@ -386,7 +432,7 @@
|
||||
border-bottom: 1px solid var(--border2);
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
}
|
||||
.modal-header h3 { flex: 1; font-family: var(--mono); font-size: 14px; }
|
||||
.modal-header h3 { flex: 1; font-family: var(--mono); font-size: 15px; }
|
||||
.modal-body { padding: 22px; }
|
||||
.modal-close { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 18px; padding: 4px; }
|
||||
.modal-close:hover { color: var(--text); }
|
||||
@@ -443,7 +489,7 @@
|
||||
/* ── Misc ── */
|
||||
.mono { font-family: var(--mono); }
|
||||
.text-muted { color: var(--muted); }
|
||||
.text-sm { font-size: 12px; }
|
||||
.text-sm { font-size: 13px; }
|
||||
.flex { display: flex; align-items: center; }
|
||||
.gap-8 { gap: 8px; }
|
||||
.gap-12 { gap: 12px; }
|
||||
@@ -481,8 +527,8 @@
|
||||
<input id="login-pass" type="password" placeholder="••••••••" autocomplete="current-password">
|
||||
</div>
|
||||
<button class="btn btn-primary" style="width:100%;justify-content:center" onclick="doLogin()">Anmelden</button>
|
||||
<div id="login-error" style="color:var(--danger);font-size:12px;text-align:center;margin-top:12px;display:none"></div>
|
||||
<div style="text-align:center;margin-top:20px;font-size:11px;color:var(--muted);font-family:var(--mono)">
|
||||
<div id="login-error" style="color:var(--danger);font-size:13px;text-align:center;margin-top:12px;display:none"></div>
|
||||
<div style="text-align:center;margin-top:20px;font-size:12px;color:var(--muted);font-family:var(--mono)">
|
||||
Standard: admin / admin
|
||||
</div>
|
||||
</div>
|
||||
@@ -501,6 +547,9 @@
|
||||
<span class="icon">◈</span> Dashboard
|
||||
</a>
|
||||
<div class="nav-section">Aufträge</div>
|
||||
<a onclick="navigate('servers')" data-page="servers">
|
||||
<span class="icon">⊞</span> Server
|
||||
</a>
|
||||
<a onclick="navigate('jobs')" data-page="jobs">
|
||||
<span class="icon">⟳</span> Sync-Jobs
|
||||
</a>
|
||||
@@ -538,10 +587,8 @@
|
||||
<div class="toast-container" id="toasts"></div>
|
||||
|
||||
<script>
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
let state = { token: null, user: null, refreshTimer: null };
|
||||
let state = { token: null, user: null, refreshTimer: null, serversCache: null };
|
||||
|
||||
// ── API ────────────────────────────────────────────────────────────────────
|
||||
const api = {
|
||||
async req(method, path, body = null) {
|
||||
const opts = {
|
||||
@@ -562,7 +609,6 @@ const api = {
|
||||
delete: (p) => api.req('DELETE', p),
|
||||
};
|
||||
|
||||
// ── Auth ───────────────────────────────────────────────────────────────────
|
||||
async function doLogin() {
|
||||
const u = document.getElementById('login-user').value.trim();
|
||||
const p = document.getElementById('login-pass').value;
|
||||
@@ -595,21 +641,17 @@ function logout() {
|
||||
function startApp() {
|
||||
document.getElementById('login-screen').style.display = 'none';
|
||||
document.getElementById('app').classList.add('visible');
|
||||
|
||||
// Sidebar user info
|
||||
document.getElementById('sidebar-username').textContent = state.user.username;
|
||||
document.getElementById('sidebar-role').textContent = state.user.role;
|
||||
document.getElementById('user-avatar').textContent = state.user.username[0].toUpperCase();
|
||||
|
||||
if (state.user.role === 'admin') {
|
||||
document.getElementById('admin-nav').style.display = '';
|
||||
document.getElementById('admin-link').style.display = '';
|
||||
}
|
||||
|
||||
navigate('dashboard');
|
||||
const lastPage = localStorage.getItem('ims_page') || 'dashboard';
|
||||
navigate(lastPage);
|
||||
}
|
||||
|
||||
// Check saved token
|
||||
(async () => {
|
||||
const saved = localStorage.getItem('ims_token');
|
||||
if (saved) {
|
||||
@@ -622,22 +664,34 @@ function startApp() {
|
||||
}
|
||||
})();
|
||||
|
||||
// ── Navigation ──────────────────────────────────────────────────────────────
|
||||
function navigate(page) {
|
||||
document.querySelectorAll('nav a').forEach(a => a.classList.remove('active'));
|
||||
const link = document.querySelector(`nav a[data-page="${page}"]`);
|
||||
if (link) link.classList.add('active');
|
||||
if (state.refreshTimer) clearInterval(state.refreshTimer);
|
||||
currentPageInit = {};
|
||||
localStorage.setItem('ims_page', page);
|
||||
|
||||
const titles = { dashboard:'◈ DASHBOARD', jobs:'⟳ SYNC-JOBS', runs:'◎ VERLAUF', users:'◉ BENUTZER' };
|
||||
const titles = {
|
||||
dashboard:'◈ DASHBOARD',
|
||||
servers:'⊞ SERVER',
|
||||
jobs:'⟳ SYNC-JOBS',
|
||||
runs:'◎ VERLAUF',
|
||||
users:'◉ BENUTZER'
|
||||
};
|
||||
document.getElementById('page-title').textContent = titles[page] || page.toUpperCase();
|
||||
document.getElementById('topbar-actions').innerHTML = '';
|
||||
|
||||
const pages = { dashboard: renderDashboard, jobs: renderJobs, runs: renderRuns, users: renderUsers };
|
||||
const pages = {
|
||||
dashboard: renderDashboard,
|
||||
servers: renderServers,
|
||||
jobs: renderJobs,
|
||||
runs: renderRuns,
|
||||
users: renderUsers
|
||||
};
|
||||
if (pages[page]) pages[page]();
|
||||
}
|
||||
|
||||
// ── Toast ──────────────────────────────────────────────────────────────────
|
||||
function toast(msg, type = 'success') {
|
||||
const t = document.createElement('div');
|
||||
t.className = `toast ${type}`;
|
||||
@@ -646,12 +700,16 @@ function toast(msg, type = 'success') {
|
||||
setTimeout(() => t.remove(), 3500);
|
||||
}
|
||||
|
||||
// ── Modal ──────────────────────────────────────────────────────────────────
|
||||
function openModal(html) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal-overlay';
|
||||
overlay.innerHTML = html;
|
||||
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
|
||||
let mouseDownTarget = null;
|
||||
overlay.addEventListener('mousedown', e => { mouseDownTarget = e.target; });
|
||||
overlay.addEventListener('mouseup', e => {
|
||||
if (e.target === overlay && mouseDownTarget === overlay) overlay.remove();
|
||||
mouseDownTarget = null;
|
||||
});
|
||||
document.getElementById('modal-container').appendChild(overlay);
|
||||
return overlay;
|
||||
}
|
||||
@@ -659,10 +717,25 @@ function closeModal() {
|
||||
document.querySelector('.modal-overlay')?.remove();
|
||||
}
|
||||
|
||||
async function loadServers() {
|
||||
if (!state.serversCache) {
|
||||
state.serversCache = await api.get('/servers');
|
||||
}
|
||||
return state.serversCache;
|
||||
}
|
||||
|
||||
function invalidateServersCache() {
|
||||
state.serversCache = null;
|
||||
}
|
||||
|
||||
let currentPageInit = {};
|
||||
|
||||
// ── Dashboard ──────────────────────────────────────────────────────────────
|
||||
async function renderDashboard() {
|
||||
const content = document.getElementById('content');
|
||||
content.innerHTML = '<div class="empty-state"><div class="spinner"></div></div>';
|
||||
const isFirst = !currentPageInit.dashboard;
|
||||
currentPageInit.dashboard = true;
|
||||
if (isFirst) content.innerHTML = '<div class="empty-state"><div class="spinner"></div></div>';
|
||||
try {
|
||||
const s = await api.get('/stats');
|
||||
let dailyChart = '';
|
||||
@@ -670,14 +743,14 @@ async function renderDashboard() {
|
||||
const maxVal = Math.max(...s.daily.map(d => d.synced), 1);
|
||||
dailyChart = s.daily.map(d => {
|
||||
const h = Math.max(4, Math.round((d.synced / maxVal) * 76));
|
||||
const label = d.day.substring(5); // MM-DD
|
||||
const label = d.day.substring(5);
|
||||
return `<div class="bar-col">
|
||||
<div class="bar" style="height:${h}px" title="${d.day}: ${d.synced} msgs, ${d.runs} runs"></div>
|
||||
<div class="bar-label">${label}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} else {
|
||||
dailyChart = '<div style="color:var(--muted);font-size:12px;padding:24px 0">Noch keine Daten</div>';
|
||||
dailyChart = '<div style="color:var(--muted);font-size:13px;padding:24px 0">Noch keine Daten</div>';
|
||||
}
|
||||
|
||||
const recentRows = (s.recent_runs || []).map(r => `
|
||||
@@ -699,6 +772,11 @@ async function renderDashboard() {
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="stat-grid">
|
||||
<div class="stat-card blue">
|
||||
<div class="label">Server</div>
|
||||
<div class="value">${s.servers.total}</div>
|
||||
<div class="sub">Konfiguriert</div>
|
||||
</div>
|
||||
<div class="stat-card blue">
|
||||
<div class="label">Sync-Jobs</div>
|
||||
<div class="value">${s.jobs.total}</div>
|
||||
@@ -741,31 +819,199 @@ async function renderDashboard() {
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Auto-refresh every 30s
|
||||
state.refreshTimer = setInterval(renderDashboard, 30000);
|
||||
state.refreshTimer = setInterval(renderDashboard, 60000);
|
||||
} catch(e) {
|
||||
content.innerHTML = `<div class="empty-state"><div>Fehler: ${e.message}</div></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Servers ────────────────────────────────────────────────────────────────
|
||||
async function renderServers() {
|
||||
const content = document.getElementById('content');
|
||||
const canEdit = state.user.role !== 'viewer';
|
||||
const isFirst = !currentPageInit.servers;
|
||||
currentPageInit.servers = true;
|
||||
|
||||
if (canEdit) {
|
||||
document.getElementById('topbar-actions').innerHTML =
|
||||
`<button class="btn btn-primary" onclick="showServerModal()">+ Neuer Server</button>`;
|
||||
}
|
||||
|
||||
if (isFirst) content.innerHTML = '<div class="empty-state"><div class="spinner"></div></div>';
|
||||
try {
|
||||
const servers = await api.get('/servers');
|
||||
invalidateServersCache();
|
||||
|
||||
if (!servers.length) {
|
||||
content.innerHTML = `<div class="empty-state">
|
||||
<div class="icon">⊞</div>
|
||||
<div>Noch keine Server konfiguriert</div>
|
||||
${canEdit ? '<button class="btn btn-primary" style="margin-top:16px" onclick="showServerModal()">+ Ersten Server anlegen</button>' : ''}
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const dirLabels = { source: 'Eingang', destination: 'Ausgang', both: 'Beide' };
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="panel">
|
||||
<div class="panel-header"><h3>${servers.length} Server</h3></div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Host</th>
|
||||
<th>Port</th>
|
||||
<th>SSL</th>
|
||||
<th>Richtung</th>
|
||||
<th>Verwendung</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${servers.map(s => `
|
||||
<tr>
|
||||
<td>
|
||||
<div style="font-weight:500">${esc(s.name)}</div>
|
||||
<div class="text-muted text-sm mono">#${s.id}</div>
|
||||
</td>
|
||||
<td class="mono text-sm">${esc(s.host)}</td>
|
||||
<td class="mono text-sm">${s.port}</td>
|
||||
<td>${s.ssl ? '<span class="badge enabled">SSL</span>' : '<span class="badge disabled">Kein SSL</span>'}</td>
|
||||
<td><span class="badge ${s.direction}">${dirLabels[s.direction] || s.direction}</span></td>
|
||||
<td class="text-sm text-muted">
|
||||
${s.used_as_src > 0 ? `<span style="color:var(--blue)">${s.used_as_src}× Quelle</span>` : ''}
|
||||
${s.used_as_src > 0 && s.used_as_dst > 0 ? ' · ' : ''}
|
||||
${s.used_as_dst > 0 ? `<span style="color:var(--warn)">${s.used_as_dst}× Ziel</span>` : ''}
|
||||
${s.used_as_src === 0 && s.used_as_dst === 0 ? '<span class="text-muted">—</span>' : ''}
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex gap-8">
|
||||
${canEdit ? `<button class="btn btn-outline btn-sm" onclick="showServerModal(${s.id})">✎</button>` : ''}
|
||||
${canEdit ? `<button class="btn btn-danger btn-sm" onclick="deleteServer(${s.id}, '${esc(s.name)}')">✕</button>` : ''}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
|
||||
state.refreshTimer = setInterval(renderServers, 60000);
|
||||
} catch(e) {
|
||||
content.innerHTML = `<div class="empty-state">Fehler: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function showServerModal(serverId = null) {
|
||||
let server = null;
|
||||
if (serverId) {
|
||||
try { server = await api.get(`/servers`); server = server.find(s => s.id === serverId); } catch(e) { toast(e.message, 'error'); return; }
|
||||
}
|
||||
const title = server ? `Server bearbeiten: ${server.name}` : 'Neuer Server';
|
||||
openModal(`
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>⊞ ${title}</h3>
|
||||
<button class="modal-close" onclick="closeModal()">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-grid">
|
||||
<div class="form-group full">
|
||||
<label>SERVERNAME</label>
|
||||
<input id="sv-name" type="text" value="${esc(server?.name||'')}" placeholder="z.B. Google Workspace, Exchange Online">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>HOST</label>
|
||||
<input id="sv-host" type="text" value="${esc(server?.host||'')}" placeholder="imap.example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>PORT</label>
|
||||
<input id="sv-port" type="number" value="${server?.port||993}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>SSL/TLS</label>
|
||||
<select id="sv-ssl">
|
||||
<option value="1" ${(!server||server.ssl)?'selected':''}>SSL (empfohlen)</option>
|
||||
<option value="0" ${(server&&!server.ssl)?'selected':''}>Kein SSL</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>RICHTUNG</label>
|
||||
<select id="sv-direction">
|
||||
<option value="source" ${(server?.direction==='source')?'selected':''}>Eingang (Quelle)</option>
|
||||
<option value="destination" ${(server?.direction==='destination')?'selected':''}>Ausgang (Ziel)</option>
|
||||
<option value="both" ${(!server||server?.direction==='both')?'selected':''}>Beide Richtungen</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-outline" onclick="closeModal()">Abbrechen</button>
|
||||
<button class="btn btn-primary" onclick="saveServer(${serverId||'null'})">
|
||||
${server ? 'Speichern' : 'Server anlegen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
async function saveServer(serverId) {
|
||||
const data = {
|
||||
name: document.getElementById('sv-name').value.trim(),
|
||||
host: document.getElementById('sv-host').value.trim(),
|
||||
port: parseInt(document.getElementById('sv-port').value),
|
||||
ssl: document.getElementById('sv-ssl').value === '1',
|
||||
direction: document.getElementById('sv-direction').value,
|
||||
};
|
||||
if (!data.name || !data.host) {
|
||||
toast('Name und Host sind Pflichtfelder', 'error'); return;
|
||||
}
|
||||
try {
|
||||
if (serverId) {
|
||||
await api.put(`/servers/${serverId}`, data);
|
||||
toast('Server aktualisiert');
|
||||
} else {
|
||||
await api.post('/servers', data);
|
||||
toast('Server erstellt');
|
||||
}
|
||||
closeModal();
|
||||
invalidateServersCache();
|
||||
renderServers();
|
||||
} catch(e) { toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteServer(id, name) {
|
||||
if (!confirm(`Server "${name}" wirklich löschen?`)) return;
|
||||
try {
|
||||
await api.delete(`/servers/${id}`);
|
||||
toast('Server gelöscht');
|
||||
invalidateServersCache();
|
||||
renderServers();
|
||||
} catch(e) { toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Jobs ───────────────────────────────────────────────────────────────────
|
||||
async function renderJobs() {
|
||||
const content = document.getElementById('content');
|
||||
const canEdit = state.user.role !== 'viewer';
|
||||
const isFirst = !currentPageInit.jobs;
|
||||
currentPageInit.jobs = true;
|
||||
|
||||
if (canEdit) {
|
||||
document.getElementById('topbar-actions').innerHTML =
|
||||
`<button class="btn btn-primary" onclick="showJobModal()">+ Neuer Job</button>`;
|
||||
}
|
||||
|
||||
content.innerHTML = '<div class="empty-state"><div class="spinner"></div></div>';
|
||||
if (isFirst) content.innerHTML = '<div class="empty-state"><div class="spinner"></div></div>';
|
||||
try {
|
||||
const jobs = await api.get('/jobs');
|
||||
if (!jobs.length) {
|
||||
content.innerHTML = `<div class="empty-state">
|
||||
<div class="icon">⟳</div>
|
||||
<div>Noch keine Sync-Jobs konfiguriert</div>
|
||||
${canEdit ? '<button class="btn btn-primary" style="margin-top:16px" onclick="showJobModal()">+ Ersten Job erstellen</button>' : ''}
|
||||
${canEdit ? '<div style="margin-top:12px"><button class="btn btn-primary" onclick="navigate(\'servers\')">Zuerst Server anlegen →</button></div>' : ''}
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
@@ -793,7 +1039,7 @@ async function renderJobs() {
|
||||
<div class="text-muted text-sm mono">#${j.id}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="mono text-sm">${esc(j.src_host)} → ${esc(j.dst_host)}</div>
|
||||
<div class="mono text-sm">${esc(j.src_server_name||'?')} → ${esc(j.dst_server_name||'?')}</div>
|
||||
<div class="text-muted text-sm">${esc(j.src_user)} → ${esc(j.dst_user)}</div>
|
||||
</td>
|
||||
<td>
|
||||
@@ -806,7 +1052,7 @@ async function renderJobs() {
|
||||
<td>
|
||||
<div class="flex gap-8">
|
||||
${canEdit && j.status === 'idle' ? `<button class="btn btn-outline btn-sm" onclick="triggerJob(${j.id})">▶ Start</button>` : ''}
|
||||
${canEdit && j.status === 'queued' ? `<button class="btn btn-outline btn-sm" onclick="stopJob(${j.id})">■ Stop</button>` : ''}
|
||||
${canEdit && (j.status === 'queued' || j.status === 'running') ? `<button class="btn btn-outline btn-sm" onclick="stopJob(${j.id})">■ Stop</button>` : ''}
|
||||
<button class="btn btn-outline btn-sm" onclick="navigate('runs');showRunsForJob(${j.id},'${esc(j.name)}')">Logs</button>
|
||||
${canEdit ? `<button class="btn btn-outline btn-sm" onclick="showJobModal(${j.id})">✎</button>` : ''}
|
||||
${canEdit ? `<button class="btn btn-danger btn-sm" onclick="deleteJob(${j.id}, '${esc(j.name)}')">✕</button>` : ''}
|
||||
@@ -819,8 +1065,7 @@ async function renderJobs() {
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Auto-refresh every 15s
|
||||
state.refreshTimer = setInterval(renderJobs, 15000);
|
||||
state.refreshTimer = setInterval(renderJobs, 30000);
|
||||
} catch(e) {
|
||||
content.innerHTML = `<div class="empty-state">Fehler: ${e.message}</div>`;
|
||||
}
|
||||
@@ -831,8 +1076,35 @@ async function showJobModal(jobId = null) {
|
||||
if (jobId) {
|
||||
try { job = await api.get(`/jobs/${jobId}`); } catch(e) { toast(e.message, 'error'); return; }
|
||||
}
|
||||
|
||||
const servers = await loadServers();
|
||||
const srcServers = servers.filter(s => s.direction === 'source' || s.direction === 'both');
|
||||
const dstServers = servers.filter(s => s.direction === 'destination' || s.direction === 'both');
|
||||
|
||||
if (!srcServers.length && !dstServers.length) {
|
||||
toast('Bitte zuerst Server anlegen', 'error');
|
||||
navigate('servers');
|
||||
return;
|
||||
}
|
||||
|
||||
const title = job ? `Job bearbeiten: ${job.name}` : 'Neuer Sync-Job';
|
||||
const overlay = openModal(`
|
||||
|
||||
function serverOptions(list, selectedId) {
|
||||
if (!list.length) return '<option value="">— Keine Server verfügbar —</option>';
|
||||
return list.map(s =>
|
||||
`<option value="${s.id}" ${s.id === selectedId ? 'selected' : ''}>${esc(s.name)} (${esc(s.host)}:${s.port})</option>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
function serverDetailLine(s) {
|
||||
if (!s) return '';
|
||||
return `${esc(s.host)}:${s.port} ${s.ssl ? 'SSL' : 'Kein SSL'}`;
|
||||
}
|
||||
|
||||
const selectedSrcServer = job ? srcServers.find(s => s.id === job.src_server_id) : null;
|
||||
const selectedDstServer = job ? dstServers.find(s => s.id === job.dst_server_id) : null;
|
||||
|
||||
openModal(`
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>⟳ ${title}</h3>
|
||||
@@ -845,55 +1117,45 @@ async function showJobModal(jobId = null) {
|
||||
<input id="j-name" type="text" value="${esc(job?.name||'')}" placeholder="z.B. Max Mustermann Migration">
|
||||
</div>
|
||||
|
||||
<div class="section-divider">Quell-Server (Source)</div>
|
||||
<div class="form-group">
|
||||
<label>HOST</label>
|
||||
<input id="j-s-host" type="text" value="${esc(job?.src_host||'')}" placeholder="mail.example.com">
|
||||
<div class="section-divider-prominent src"><span class="section-title">▼ Quell-Server (Eingang)</span></div>
|
||||
<div class="form-group full">
|
||||
<label>SERVER</label>
|
||||
<select id="j-src-server" onchange="updateServerInfo('src')">
|
||||
<option value="">— Server auswählen —</option>
|
||||
${serverOptions(srcServers, job?.src_server_id)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group full" id="j-src-server-info" style="display:${selectedSrcServer?'block':'none'}">
|
||||
<div class="server-info" id="j-src-server-detail">${serverDetailLine(selectedSrcServer)}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>PORT</label>
|
||||
<input id="j-s-port" type="number" value="${job?.src_port||993}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>BENUTZER</label>
|
||||
<label>BENUTZER (E-Mail)</label>
|
||||
<input id="j-s-user" type="text" value="${esc(job?.src_user||'')}" placeholder="user@example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>PASSWORT</label>
|
||||
<input id="j-s-pass" type="password" value="${esc(job?.src_password||'')}" placeholder="••••••••">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>SSL/TLS</label>
|
||||
<select id="j-s-ssl">
|
||||
<option value="1" ${(!job||job.src_ssl)?'selected':''}>SSL (empfohlen)</option>
|
||||
<option value="0" ${(job&&!job.src_ssl)?'selected':''}>Kein SSL</option>
|
||||
|
||||
<div class="section-divider-prominent dst"><span class="section-title">▼ Ziel-Server (Ausgang)</span></div>
|
||||
<div class="form-group full">
|
||||
<label>SERVER</label>
|
||||
<select id="j-dst-server" onchange="updateServerInfo('dst')">
|
||||
<option value="">— Server auswählen —</option>
|
||||
${serverOptions(dstServers, job?.dst_server_id)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="section-divider">Ziel-Server (Destination)</div>
|
||||
<div class="form-group">
|
||||
<label>HOST</label>
|
||||
<input id="j-d-host" type="text" value="${esc(job?.dst_host||'')}" placeholder="mail.ziel.com">
|
||||
<div class="form-group full" id="j-dst-server-info" style="display:${selectedDstServer?'block':'none'}">
|
||||
<div class="server-info" id="j-dst-server-detail">${serverDetailLine(selectedDstServer)}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>PORT</label>
|
||||
<input id="j-d-port" type="number" value="${job?.dst_port||993}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>BENUTZER</label>
|
||||
<label>BENUTZER (E-Mail)</label>
|
||||
<input id="j-d-user" type="text" value="${esc(job?.dst_user||'')}" placeholder="user@ziel.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>PASSWORT</label>
|
||||
<input id="j-d-pass" type="password" value="${esc(job?.dst_password||'')}" placeholder="••••••••">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>SSL/TLS</label>
|
||||
<select id="j-d-ssl">
|
||||
<option value="1" ${(!job||job.dst_ssl)?'selected':''}>SSL (empfohlen)</option>
|
||||
<option value="0" ${(job&&!job.dst_ssl)?'selected':''}>Kein SSL</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="section-divider">Zeitplan & Optionen</div>
|
||||
<div class="form-group">
|
||||
@@ -923,24 +1185,45 @@ async function showJobModal(jobId = null) {
|
||||
`);
|
||||
}
|
||||
|
||||
function updateServerInfo(side) {
|
||||
const servers = state.serversCache || [];
|
||||
const selId = side === 'src'
|
||||
? parseInt(document.getElementById('j-src-server').value)
|
||||
: parseInt(document.getElementById('j-dst-server').value);
|
||||
const server = servers.find(s => s.id === selId);
|
||||
|
||||
const infoEl = document.getElementById(`j-${side}-server-info`);
|
||||
const detailEl = document.getElementById(`j-${side}-server-detail`);
|
||||
|
||||
if (server) {
|
||||
detailEl.textContent = `${server.host}:${server.port} ${server.ssl ? 'SSL' : 'Kein SSL'}`;
|
||||
infoEl.style.display = 'block';
|
||||
} else {
|
||||
infoEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveJob(jobId) {
|
||||
const srcServerId = parseInt(document.getElementById('j-src-server').value);
|
||||
const dstServerId = parseInt(document.getElementById('j-dst-server').value);
|
||||
|
||||
if (!srcServerId || !dstServerId) {
|
||||
toast('Bitte Quell- und Ziel-Server auswählen', 'error'); return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
name: document.getElementById('j-name').value.trim(),
|
||||
src_host: document.getElementById('j-s-host').value.trim(),
|
||||
src_port: parseInt(document.getElementById('j-s-port').value),
|
||||
src_ssl: document.getElementById('j-s-ssl').value === '1',
|
||||
src_user: document.getElementById('j-s-user').value.trim(),
|
||||
src_password: document.getElementById('j-s-pass').value,
|
||||
dst_host: document.getElementById('j-d-host').value.trim(),
|
||||
dst_port: parseInt(document.getElementById('j-d-port').value),
|
||||
dst_ssl: document.getElementById('j-d-ssl').value === '1',
|
||||
dst_user: document.getElementById('j-d-user').value.trim(),
|
||||
dst_password: document.getElementById('j-d-pass').value,
|
||||
schedule: document.getElementById('j-schedule').value.trim() || null,
|
||||
enabled: document.getElementById('j-enabled').value === '1',
|
||||
extra_args: document.getElementById('j-extra').value.trim(),
|
||||
name: document.getElementById('j-name').value.trim(),
|
||||
src_server_id: srcServerId,
|
||||
src_user: document.getElementById('j-s-user').value.trim(),
|
||||
src_password: document.getElementById('j-s-pass').value,
|
||||
dst_server_id: dstServerId,
|
||||
dst_user: document.getElementById('j-d-user').value.trim(),
|
||||
dst_password: document.getElementById('j-d-pass').value,
|
||||
schedule: document.getElementById('j-schedule').value.trim() || null,
|
||||
enabled: document.getElementById('j-enabled').value === '1',
|
||||
extra_args: document.getElementById('j-extra').value.trim(),
|
||||
};
|
||||
if (!data.name || !data.src_host || !data.src_user || !data.dst_host || !data.dst_user) {
|
||||
if (!data.name || !data.src_user || !data.dst_user) {
|
||||
toast('Bitte alle Pflichtfelder ausfüllen', 'error'); return;
|
||||
}
|
||||
try {
|
||||
@@ -991,7 +1274,9 @@ function showRunsForJob(jobId, jobName) {
|
||||
|
||||
async function renderRuns() {
|
||||
const content = document.getElementById('content');
|
||||
content.innerHTML = '<div class="empty-state"><div class="spinner"></div></div>';
|
||||
const isFirst = !currentPageInit.runs;
|
||||
currentPageInit.runs = true;
|
||||
if (isFirst) content.innerHTML = '<div class="empty-state"><div class="spinner"></div></div>';
|
||||
|
||||
try {
|
||||
let runs = [];
|
||||
@@ -1001,7 +1286,6 @@ async function renderRuns() {
|
||||
`<span class="text-muted text-sm">Filter: ${esc(activeJobFilter.name)}</span>
|
||||
<button class="btn btn-outline btn-sm" onclick="activeJobFilter=null;renderRuns()">✕ Filter</button>`;
|
||||
} else {
|
||||
// Load all jobs and their runs
|
||||
document.getElementById('topbar-actions').innerHTML = '';
|
||||
const jobs = await api.get('/jobs');
|
||||
for (const j of jobs.slice(0, 20)) {
|
||||
@@ -1094,7 +1378,9 @@ async function renderUsers() {
|
||||
`<button class="btn btn-primary" onclick="showUserModal()">+ Benutzer erstellen</button>`;
|
||||
|
||||
const content = document.getElementById('content');
|
||||
content.innerHTML = '<div class="empty-state"><div class="spinner"></div></div>';
|
||||
const isFirst = !currentPageInit.users;
|
||||
currentPageInit.users = true;
|
||||
if (isFirst) content.innerHTML = '<div class="empty-state"><div class="spinner"></div></div>';
|
||||
|
||||
try {
|
||||
const users = await api.get('/users');
|
||||
@@ -1142,11 +1428,11 @@ async function renderUsers() {
|
||||
<div style="padding:18px;display:grid;grid-template-columns:repeat(3,1fr);gap:16px">
|
||||
<div>
|
||||
<span class="badge admin" style="margin-bottom:8px">admin</span>
|
||||
<div class="text-sm text-muted" style="margin-top:8px">Vollzugriff: Benutzer, Jobs, Logs erstellen/löschen</div>
|
||||
<div class="text-sm text-muted" style="margin-top:8px">Vollzugriff: Benutzer, Server, Jobs, Logs erstellen/löschen</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="badge operator" style="margin-bottom:8px">operator</span>
|
||||
<div class="text-sm text-muted" style="margin-top:8px">Jobs erstellen/starten, Logs einsehen. Keine Benutzerverwaltung.</div>
|
||||
<div class="text-sm text-muted" style="margin-top:8px">Server, Jobs erstellen/starten, Logs einsehen. Keine Benutzerverwaltung.</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="badge viewer" style="margin-bottom:8px">viewer</span>
|
||||
@@ -1246,11 +1532,11 @@ function fmtDuration(sec) {
|
||||
return Math.floor(sec/3600) + 'h ' + Math.floor((sec%3600)/60) + 'm';
|
||||
}
|
||||
function statusBadge(s) {
|
||||
const labels = { idle:'Bereit', queued:'Warteschlange', running:'Läuft', done:'Fertig', failed:'Fehler' };
|
||||
const labels = { idle:'Bereit', queued:'Warteschlange', running:'Läuft', done:'Fertig', failed:'Fehler', cancelling:'Abbruch…', cancelled:'Abgebrochen' };
|
||||
return `<span class="badge ${s}"><span class="badge-dot"></span>${labels[s]||s}</span>`;
|
||||
}
|
||||
function roleDesc(r) {
|
||||
const d = { admin:'Vollzugriff', operator:'Jobs verwalten', viewer:'Nur Lesen' };
|
||||
const d = { admin:'Vollzugriff', operator:'Jobs & Server verwalten', viewer:'Nur Lesen' };
|
||||
return d[r] || r;
|
||||
}
|
||||
</script>
|
||||
|
||||
+42
-3
@@ -1,12 +1,51 @@
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
# Install imapsync and dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
imapsync \
|
||||
wget \
|
||||
git \
|
||||
lsb-release \
|
||||
libauthen-ntlm-perl \
|
||||
libdist-checkconflicts-perl \
|
||||
libpar-packer-perl \
|
||||
libtest-requires-perl \
|
||||
libtest-fatal-perl \
|
||||
libtest-mock-guard-perl \
|
||||
libcgi-pm-perl \
|
||||
libcrypt-openssl-rsa-perl \
|
||||
libdata-uniqid-perl \
|
||||
libencode-imaputf7-perl \
|
||||
libfile-copy-recursive-perl \
|
||||
libfile-tail-perl \
|
||||
libio-socket-inet6-perl \
|
||||
libio-socket-ssl-perl \
|
||||
libio-tee-perl \
|
||||
libhtml-parser-perl \
|
||||
libjson-webtoken-perl \
|
||||
libmail-imapclient-perl \
|
||||
libparse-recdescent-perl \
|
||||
libmodule-scandeps-perl \
|
||||
libreadonly-perl \
|
||||
libregexp-common-perl \
|
||||
libsys-meminfo-perl \
|
||||
libterm-readkey-perl \
|
||||
libtest-mockobject-perl \
|
||||
libtest-pod-perl \
|
||||
libunicode-string-perl \
|
||||
liburi-perl \
|
||||
libwww-perl \
|
||||
libtest-nowarnings-perl \
|
||||
libtest-deep-perl \
|
||||
libtest-warn-perl \
|
||||
make \
|
||||
cpanminus \
|
||||
gcc \
|
||||
python3 \
|
||||
python3-pip \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN wget -O /usr/local/bin/imapsync https://imapsync.lamiral.info/imapsync && \
|
||||
chmod +x /usr/local/bin/imapsync
|
||||
|
||||
WORKDIR /app
|
||||
COPY worker.py .
|
||||
|
||||
|
||||
+82
-24
@@ -36,7 +36,6 @@ def get_db():
|
||||
|
||||
|
||||
def parse_imapsync_output(text: str) -> dict:
|
||||
"""Extract stats from imapsync stdout/stderr."""
|
||||
stats = {"messages_synced": 0, "messages_skipped": 0, "errors": 0}
|
||||
m = re.search(r"Messages transferred:\s+(\d+)", text)
|
||||
if m:
|
||||
@@ -44,17 +43,15 @@ def parse_imapsync_output(text: str) -> dict:
|
||||
m = re.search(r"Messages skipped:\s+(\d+)", text)
|
||||
if m:
|
||||
stats["messages_skipped"] = int(m.group(1))
|
||||
# Count error lines
|
||||
stats["errors"] = len(re.findall(r"(?i)^\s*(error|err)\b", text, re.MULTILINE))
|
||||
return stats
|
||||
|
||||
|
||||
def check_due_schedules():
|
||||
"""Queue jobs whose cron schedule is due (within last POLL_INTERVAL seconds)."""
|
||||
try:
|
||||
from croniter import croniter
|
||||
except ImportError:
|
||||
return # croniter not installed in this image, skip
|
||||
return
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
@@ -70,7 +67,6 @@ def check_due_schedules():
|
||||
cron = croniter(row["schedule"])
|
||||
last_run = datetime.fromisoformat(row["last_run"]) if row["last_run"] else datetime(2000, 1, 1)
|
||||
prev_due = cron.get_prev(datetime)
|
||||
# If last scheduled run is after last actual run, queue it
|
||||
if prev_due > last_run:
|
||||
conn.execute(
|
||||
"UPDATE sync_jobs SET status='queued' WHERE id=?",
|
||||
@@ -103,7 +99,6 @@ def run_job(job: sqlite3.Row):
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Build imapsync command
|
||||
ssl1 = "--ssl1" if job["src_ssl"] else "--nossl1"
|
||||
ssl2 = "--ssl2" if job["dst_ssl"] else "--nossl2"
|
||||
cmd = [
|
||||
@@ -126,27 +121,44 @@ def run_job(job: sqlite3.Row):
|
||||
started = time.time()
|
||||
exit_code = 0
|
||||
output = ""
|
||||
cancelled = False
|
||||
|
||||
try:
|
||||
with open(log_path, "w") as lf:
|
||||
lf.write(f"# ImapSync Job: {job['name']}\n")
|
||||
lf.write(f"# Started: {datetime.utcnow().isoformat()}\n")
|
||||
lf.write(f"# Command: {' '.join(cmd[:20])}...\n\n")
|
||||
result = subprocess.run(
|
||||
lf.flush()
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=lf,
|
||||
stderr=subprocess.STDOUT,
|
||||
timeout=7200, # 2h max
|
||||
)
|
||||
exit_code = result.returncode
|
||||
while proc.poll() is None:
|
||||
conn2 = get_db()
|
||||
row = conn2.execute("SELECT status FROM sync_jobs WHERE id=?", (job_id,)).fetchone()
|
||||
conn2.close()
|
||||
if row and row["status"] == "cancelling":
|
||||
log.info(f"Job {job_id} cancel requested, terminating process")
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
cancelled = True
|
||||
break
|
||||
time.sleep(3)
|
||||
elapsed = time.time() - started
|
||||
if elapsed > 7200:
|
||||
log.error(f"Job {job_id} timed out after 2h")
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
break
|
||||
if not cancelled and proc.returncode is not None:
|
||||
exit_code = proc.returncode
|
||||
with open(log_path, "r", errors="replace") as lf:
|
||||
output = lf.read()
|
||||
except subprocess.TimeoutExpired:
|
||||
log.error(f"Job {job_id} timed out after 2h")
|
||||
exit_code = -1
|
||||
output = "TIMEOUT after 2 hours"
|
||||
with open(log_path, "a") as lf:
|
||||
lf.write("\n\nTIMEOUT: Job exceeded 2 hour limit\n")
|
||||
except Exception as e:
|
||||
log.error(f"Job {job_id} exception: {e}")
|
||||
exit_code = -2
|
||||
@@ -156,20 +168,36 @@ def run_job(job: sqlite3.Row):
|
||||
|
||||
duration = int(time.time() - started)
|
||||
stats = parse_imapsync_output(output)
|
||||
job_status = "done" if exit_code == 0 else "failed"
|
||||
|
||||
conn3 = get_db()
|
||||
current_status = conn3.execute("SELECT status FROM sync_jobs WHERE id=?", (job_id,)).fetchone()
|
||||
conn3.close()
|
||||
if not cancelled and current_status and current_status["status"] == "cancelling":
|
||||
cancelled = True
|
||||
|
||||
if cancelled:
|
||||
job_status = "cancelled"
|
||||
with open(log_path, "a") as lf:
|
||||
lf.write("\n\nCANCELLED: Job was cancelled by user\n")
|
||||
elif exit_code == 0:
|
||||
job_status = "done"
|
||||
else:
|
||||
job_status = "failed"
|
||||
|
||||
conn = get_db()
|
||||
conn.execute("""
|
||||
UPDATE job_runs SET
|
||||
status=?, finished_at=?, messages_synced=?,
|
||||
messages_skipped=?, errors=?, duration_sec=?
|
||||
WHERE id=?
|
||||
WHERE id=? AND status != 'cancelled'
|
||||
""", (job_status, datetime.utcnow().isoformat(),
|
||||
stats["messages_synced"], stats["messages_skipped"],
|
||||
stats["errors"], duration, run_id))
|
||||
if job_status == "cancelled":
|
||||
conn.execute("UPDATE job_runs SET duration_sec=? WHERE id=?", (duration, run_id))
|
||||
conn.execute(
|
||||
"UPDATE sync_jobs SET status=? WHERE id=?",
|
||||
("idle", job_id)
|
||||
"UPDATE sync_jobs SET status='idle' WHERE id=?",
|
||||
(job_id,)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -181,24 +209,54 @@ def run_job(job: sqlite3.Row):
|
||||
)
|
||||
|
||||
|
||||
def recover_stale_jobs():
|
||||
conn = get_db()
|
||||
try:
|
||||
stale = conn.execute(
|
||||
"SELECT id FROM sync_jobs WHERE status IN ('running', 'cancelling')"
|
||||
).fetchall()
|
||||
for row in stale:
|
||||
job_id = row["id"]
|
||||
conn.execute(
|
||||
"UPDATE job_runs SET status='failed', finished_at=? "
|
||||
"WHERE job_id=? AND status='running'",
|
||||
(datetime.utcnow().isoformat(), job_id)
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE sync_jobs SET status='idle' WHERE id=?",
|
||||
(job_id,)
|
||||
)
|
||||
log.warning(f"Recovered stale job {job_id} → idle (worker restart)")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def main():
|
||||
log.info(f"Worker started. DB={DB_PATH} LOG_DIR={LOG_DIR} POLL={POLL_INTERVAL}s")
|
||||
# Wait for DB to be initialized by the web container
|
||||
for i in range(30):
|
||||
if os.path.exists(DB_PATH):
|
||||
break
|
||||
log.info(f"Waiting for DB... ({i+1}/30)")
|
||||
time.sleep(2)
|
||||
|
||||
recover_stale_jobs()
|
||||
|
||||
while True:
|
||||
try:
|
||||
check_due_schedules()
|
||||
|
||||
conn = get_db()
|
||||
job = conn.execute(
|
||||
"SELECT * FROM sync_jobs WHERE status='queued' AND enabled=1 "
|
||||
"ORDER BY created_at ASC LIMIT 1"
|
||||
).fetchone()
|
||||
job = conn.execute("""
|
||||
SELECT j.*,
|
||||
s1.host as src_host, s1.port as src_port, s1.ssl as src_ssl,
|
||||
s2.host as dst_host, s2.port as dst_port, s2.ssl as dst_ssl
|
||||
FROM sync_jobs j
|
||||
JOIN servers s1 ON j.src_server_id = s1.id
|
||||
JOIN servers s2 ON j.dst_server_id = s2.id
|
||||
WHERE j.status='queued' AND j.enabled=1
|
||||
ORDER BY j.created_at ASC LIMIT 1
|
||||
""").fetchone()
|
||||
conn.close()
|
||||
|
||||
if job:
|
||||
|
||||
Reference in New Issue
Block a user