++ Add Readme + Multiple S3 Configs

This commit is contained in:
2026-03-18 14:11:45 +01:00
parent d2080c936f
commit cb6536f656
9 changed files with 856 additions and 93 deletions

View File

@@ -1,10 +1,10 @@
#!/usr/bin/env python3
# ============================================================
# app.py - Dashboard Web per S3-to-Local Sync
# app.py - Dashboard Web per S3-to-Local Sync (Multi-Sync)
# ============================================================
# Server Flask leggero che espone una dashboard in tempo reale
# con Server-Sent Events (SSE) per lo streaming dei log e dello
# stato della sincronizzazione. Ispirato all'UI di Syncthing.
# con supporto per MULTIPLE associazioni bucket:cartella (1:1).
# Usa Server-Sent Events (SSE) per lo streaming dei log.
# ============================================================
import json
@@ -14,7 +14,7 @@ import time
import threading
from datetime import datetime
from pathlib import Path
from flask import Flask, render_template, Response, jsonify, stream_with_context
from flask import Flask, render_template, Response, jsonify, stream_with_context, request
# Aggiungi la directory web al path per importare cron_helper
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@@ -25,25 +25,54 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# Directory condivisa con sync.sh per lo scambio di stato
STATE_DIR = Path(os.environ.get("STATE_DIR", "/data/state"))
# Porta del web server (configurabile da .env)
WEB_PORT = int(os.environ.get("WEB_PORT", 8080))
# File di stato scritti da sync.sh
STATUS_FILE = STATE_DIR / "status.json" # Stato corrente della sync
HISTORY_FILE = STATE_DIR / "history.json" # Storico delle sincronizzazioni
LOG_FILE = STATE_DIR / "sync.log" # Log in tempo reale
CHANGES_FILE = STATE_DIR / "recent_changes.json" # Ultime modifiche ai file
app = Flask(__name__)
# ============================================================
# Gestione Multi-Sync
# ============================================================
def load_sync_configs():
"""Carica la configurazione delle sync da SYNC_CONFIGS o fallback a singola sync."""
sync_configs_json = os.environ.get("SYNC_CONFIGS", "").strip()
if sync_configs_json:
try:
configs = json.loads(sync_configs_json)
return {cfg.get("id", f"sync{i}"): cfg for i, cfg in enumerate(configs)}
except json.JSONDecodeError:
pass
# Fallback: singola sync (compatibilità)
return {
"sync1": {
"id": "sync1",
"bucket": os.environ.get("S3_BUCKET", "default"),
"state_dir": STATE_DIR / "sync1"
}
}
SYNC_CONFIGS = load_sync_configs()
def get_sync_state_dir(sync_id):
"""Ritorna la directory di stato per una specifica sync."""
cfg = SYNC_CONFIGS.get(sync_id)
if cfg and "state_dir" in cfg:
return Path(cfg["state_dir"])
return STATE_DIR / sync_id
def get_sync_bucket(sync_id):
"""Ritorna il nome del bucket per una specifica sync."""
cfg = SYNC_CONFIGS.get(sync_id)
return cfg.get("bucket", sync_id) if cfg else sync_id
# ============================================================
# Funzioni di utilità
# ============================================================
def read_json_safe(filepath, default=None):
"""Legge un file JSON in modo sicuro, ritorna default se non esiste o è corrotto."""
"""Legge un file JSON in modo sicuro."""
try:
if filepath.exists():
text = filepath.read_text().strip()
@@ -55,7 +84,7 @@ def read_json_safe(filepath, default=None):
def get_folder_stats(path="/data/local"):
"""Calcola statistiche sulla cartella sincronizzata (numero file, dimensione totale)."""
"""Calcola statistiche sulla cartella sincronizzata."""
total_size = 0
file_count = 0
dir_count = 0
@@ -77,7 +106,7 @@ def get_folder_stats(path="/data/local"):
def format_size(size_bytes):
"""Converte bytes in formato leggibile (es. 1.5 GB)."""
"""Converte bytes in formato leggibile."""
for unit in ["B", "KB", "MB", "GB", "TB"]:
if size_bytes < 1024:
return f"{size_bytes:.1f} {unit}"
@@ -91,14 +120,11 @@ def format_size(size_bytes):
@app.route("/")
def index():
"""Pagina principale della dashboard."""
# Passa la configurazione al template per il riepilogo
# Determina la modalità di pianificazione
sync_schedule = os.environ.get("SYNC_SCHEDULE", "")
"""Pagina principale della dashboard con tab untuk multi-sync."""
sync_interval = int(os.environ.get("SYNC_INTERVAL", 300))
sync_schedule = os.environ.get("SYNC_SCHEDULE", "")
if sync_schedule:
# Modalità cron: mostra l'espressione e la descrizione leggibile
from cron_helper import human_readable
schedule_display = human_readable(sync_schedule)
schedule_mode = "cron"
@@ -108,8 +134,6 @@ def index():
config = {
"endpoint": os.environ.get("S3_ENDPOINT", "N/A"),
"bucket": os.environ.get("S3_BUCKET", "N/A"),
"prefix": os.environ.get("S3_PATH_PREFIX", "") or "(tutto il bucket)",
"sync_mode": os.environ.get("SYNC_MODE", "mirror"),
"sync_interval": sync_interval,
"sync_schedule": sync_schedule,
@@ -118,69 +142,90 @@ def index():
"sync_on_start": os.environ.get("SYNC_ON_START", "true"),
"transfers": os.environ.get("SYNC_TRANSFERS", "4"),
"bandwidth": os.environ.get("SYNC_BANDWIDTH", "0") or "illimitata",
"sync_ids": list(SYNC_CONFIGS.keys()),
"sync_buckets": {sid: get_sync_bucket(sid) for sid in SYNC_CONFIGS.keys()},
}
return render_template("index.html", config=config)
@app.route("/api/status")
def api_status():
"""API: restituisce lo stato corrente della sincronizzazione."""
status = read_json_safe(STATUS_FILE, {
@app.route("/api/syncs")
def api_syncs():
"""API: lista di tutte le sync configurate."""
return jsonify({
"syncs": [
{"id": sid, "bucket": get_sync_bucket(sid)}
for sid in SYNC_CONFIGS.keys()
]
})
@app.route("/api/status/<sync_id>")
def api_status(sync_id):
"""API: restituisce lo stato di una specifica sync."""
state_dir = get_sync_state_dir(sync_id)
status_file = state_dir / "status.json"
status = read_json_safe(status_file, {
"state": "starting",
"last_sync": None,
"next_sync": None,
"message": "In avvio...",
})
# Aggiungi statistiche della cartella locale
status["bucket"] = get_sync_bucket(sync_id)
status["folder_stats"] = get_folder_stats()
return jsonify(status)
@app.route("/api/history")
def api_history():
"""API: restituisce lo storico delle ultime sincronizzazioni."""
history = read_json_safe(HISTORY_FILE, [])
return jsonify(history)
@app.route("/api/changes")
def api_changes():
"""API: restituisce le ultime modifiche ai file."""
changes = read_json_safe(CHANGES_FILE, [])
@app.route("/api/changes/<sync_id>")
def api_changes(sync_id):
"""API: restituisce le ultime modifiche ai file per una sync."""
state_dir = get_sync_state_dir(sync_id)
changes_file = state_dir / "recent_changes.json"
changes = read_json_safe(changes_file, [])
return jsonify(changes)
@app.route("/api/stream")
def api_stream():
@app.route("/api/history/<sync_id>")
def api_history(sync_id):
"""API: restituisce lo storico delle sincronizzazioni."""
state_dir = get_sync_state_dir(sync_id)
history_file = state_dir / "history.json"
history = read_json_safe(history_file, [])
return jsonify(history)
@app.route("/api/stream/<sync_id>")
def api_stream(sync_id):
"""SSE: stream in tempo reale dei log della sincronizzazione."""
state_dir = get_sync_state_dir(sync_id)
log_file = state_dir / "sync.log"
def generate():
"""Generatore SSE: legge il file di log e invia nuove righe al client."""
# Inizia dalla fine del file se esiste
try:
if LOG_FILE.exists():
if log_file.exists():
# Invia le ultime 50 righe come contesto iniziale
with open(LOG_FILE, "r") as f:
with open(log_file, "r") as f:
lines = f.readlines()
for line in lines[-50:]:
yield f"data: {json.dumps({'type': 'log', 'message': line.strip()})}\n\n"
# Poi segui il file (tail -f style)
with open(LOG_FILE, "r") as f:
f.seek(0, 2) # Vai alla fine del file
with open(log_file, "r") as f:
f.seek(0, 2)
while True:
line = f.readline()
if line:
yield f"data: {json.dumps({'type': 'log', 'message': line.strip()})}\n\n"
else:
# Nessuna nuova riga: invia heartbeat per mantenere la connessione
yield f"data: {json.dumps({'type': 'heartbeat'})}\n\n"
time.sleep(1)
else:
# File non ancora creato: attendi
while not LOG_FILE.exists():
while not log_file.exists():
yield f"data: {json.dumps({'type': 'log', 'message': 'In attesa del primo avvio sync...'})}\n\n"
time.sleep(2)
# Ricomincia leggendo il file appena creato
yield from generate()
except GeneratorExit:
pass
@@ -190,7 +235,7 @@ def api_stream():
mimetype="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no", # Disabilita buffering nginx (se presente)
"X-Accel-Buffering": "no",
},
)
@@ -200,13 +245,17 @@ def api_stream():
# ============================================================
if __name__ == "__main__":
# Assicura che la directory di stato esista
STATE_DIR.mkdir(parents=True, exist_ok=True)
# Crea directory di stato per ogni sync se necessarie
for sync_id in SYNC_CONFIGS.keys():
sync_state_dir = get_sync_state_dir(sync_id)
sync_state_dir.mkdir(parents=True, exist_ok=True)
app.run(
host="0.0.0.0",
port=WEB_PORT,
debug=False,
# threaded=True per gestire più client SSE contemporaneamente
threaded=True,
)

View File

@@ -1,5 +1,6 @@
<!DOCTYPE html>
<html lang="it">
<html lang="it" style="height: 100%;">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">