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:
+385 -99
View File
@@ -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
View File
@@ -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
View File
@@ -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: