++ Add Readme + Multiple S3 Configs
This commit is contained in:
153
web/app.py
153
web/app.py
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user