diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8822121 --- /dev/null +++ b/.env.example @@ -0,0 +1,62 @@ +# Porta della dashboard web (es: 8080) +WEB_PORT=8080 + +# Credenziali AWS/S3. +# Esempio: copia/incolla i valori del tuo utente IAM. +AWS_ACCESS_KEY_ID=INSERISCI_ACCESS_KEY +AWS_SECRET_ACCESS_KEY=INSERISCI_SECRET_KEY +AWS_REGION=us-east-1 +# Opzionale: endpoint per provider compatibili S3 (MinIO, Wasabi, ecc.) +# Per AWS lascia vuoto. +S3_ENDPOINT_URL= + +# Alternativa: monta ~/.aws nel container e lascia vuote le chiavi qui sopra. + +# Se true, esegue una sync immediata all'avvio del container +RUN_ON_STARTUP=true + +# Valore di default usato dai job se non specificano delete_local_extras +MIRROR_DELETE_LOCAL_EXTRAS=true + +# Pianificazione di fallback (cron UTC) quando il job non ha schedule +# Esempio: */30 * * * * = ogni 30 minuti +DEFAULT_SCHEDULE_CRON=*/30 * * * * + +# Notifiche Gotify (opzionale) +# Lascia vuoto se non lo usi +GOTIFY_URL=http://gotify:80 +GOTIFY_TOKEN=INSERISCI_TOKEN_GOTIFY +GOTIFY_PRIORITY_START=5 +GOTIFY_PRIORITY_END=5 + +# ------------------------------ +# CONFIGURAZIONE JOB SEMPLICE +# ------------------------------ +# Numero di job attivi (uno per ogni bucket) +JOB_COUNT=2 + +# JOB 1 +JOB_1_NAME=foto +JOB_1_BUCKET=bucket-foto +JOB_1_LOCAL_DIR=/sync/foto +JOB_1_PREFIX= +JOB_1_SCHEDULE=*/15 * * * * +JOB_1_DELETE_LOCAL_EXTRAS=true +JOB_1_REGION= +JOB_1_ENDPOINT_URL= + +# JOB 2 +JOB_2_NAME=documenti +JOB_2_BUCKET=bucket-documenti +JOB_2_LOCAL_DIR=/sync/documenti +JOB_2_PREFIX=archivio/ +JOB_2_SCHEDULE=0 * * * * +JOB_2_DELETE_LOCAL_EXTRAS=true +JOB_2_REGION= +JOB_2_ENDPOINT_URL= + +# ------------------------------ +# FORMATO JSON (opzionale) +# ------------------------------ +# Se usi JOB_COUNT, puoi ignorare questa riga. +# SYNC_JOBS_JSON=[{"name":"foto","bucket":"bucket-foto","local_dir":"/sync/foto","prefix":"","schedule":"*/15 * * * *","delete_local_extras":true}] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..620f996 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +.env +.local/ +.vscode/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1017b15 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app + +EXPOSE 8080 + +CMD ["python", "app/main.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..6aa69e4 --- /dev/null +++ b/README.md @@ -0,0 +1,175 @@ +# Mirror S3 su Cartelle Locali + +Servizio Docker che copia (in mirroring) uno o piu bucket S3 dentro cartelle locali gia esistenti, con: + +- pianificazione automatica (cron, timezone UTC), +- dashboard web con stato e log in tempo reale, +- avvio manuale delle sync dalla UI, +- notifiche Gotify all'inizio e alla fine di ogni sync. + +## A cosa serve + +Esempio pratico: + +- hai 2 bucket S3, +- hai 2 cartelle sul PC o server, +- vuoi che le cartelle siano sempre allineate ai bucket, +- vuoi vedere stato, errori e storico da una pagina web. + +## 1) Configura il file .env (guida semplice) + +Copia `.env.example` in `.env` e modifica i valori. + +Variabili principali: + +- `WEB_PORT`: porta della dashboard web. +- `JOB_COUNT` + blocchi `JOB_1_*`, `JOB_2_*`, ecc.: elenco job in formato semplice. +- `AWS_ACCESS_KEY_ID` e `AWS_SECRET_ACCESS_KEY`: credenziali S3. +- `S3_ENDPOINT_URL`: opzionale, utile per storage compatibili S3 (MinIO, Wasabi, ecc.). +- `GOTIFY_URL` e `GOTIFY_TOKEN`: opzionali, per ricevere notifiche push. + +Esempio completo di `.env` semplice per 2 bucket: + +```env +WEB_PORT=8080 + +AWS_ACCESS_KEY_ID=LA_TUA_ACCESS_KEY +AWS_SECRET_ACCESS_KEY=LA_TUA_SECRET_KEY +AWS_REGION=eu-west-1 +S3_ENDPOINT_URL= + +RUN_ON_STARTUP=true +MIRROR_DELETE_LOCAL_EXTRAS=true +DEFAULT_SCHEDULE_CRON=*/30 * * * * + +GOTIFY_URL=http://192.168.1.50:8088 +GOTIFY_TOKEN=IL_TUO_TOKEN_GOTIFY +GOTIFY_PRIORITY_START=5 +GOTIFY_PRIORITY_END=5 + +JOB_COUNT=2 + +JOB_1_NAME=foto +JOB_1_BUCKET=bucket-foto +JOB_1_LOCAL_DIR=/sync/foto +JOB_1_PREFIX= +JOB_1_SCHEDULE=*/15 * * * * +JOB_1_DELETE_LOCAL_EXTRAS=true + +JOB_2_NAME=documenti +JOB_2_BUCKET=bucket-documenti +JOB_2_LOCAL_DIR=/sync/documenti +JOB_2_PREFIX=archivio/ +JOB_2_SCHEDULE=0 * * * * +JOB_2_DELETE_LOCAL_EXTRAS=true +``` + +## 2) Come aggiungere un terzo job (facile) + +Se vuoi aggiungere un altro bucket: + +- cambia `JOB_COUNT=3` +- aggiungi le righe `JOB_3_*` + +Esempio: + +```env +JOB_3_NAME=video +JOB_3_BUCKET=bucket-video +JOB_3_LOCAL_DIR=/sync/video +JOB_3_PREFIX= +JOB_3_SCHEDULE=0 2 * * * +JOB_3_DELETE_LOCAL_EXTRAS=true +``` + +## 3) Significato campi JOB_n_* + +- `JOB_n_NAME`: nome visualizzato in dashboard. +- `JOB_n_BUCKET`: nome bucket sorgente. +- `JOB_n_LOCAL_DIR`: cartella di destinazione nel container. +- `JOB_n_PREFIX`: opzionale, limita la sync a una sottocartella del bucket. +- `JOB_n_SCHEDULE`: frequenza con sintassi cron a 5 campi. +- `JOB_n_DELETE_LOCAL_EXTRAS`: se `true`, cancella in locale file non piu presenti su S3. +- `JOB_n_REGION`: opzionale, se vuoi forzare una regione diversa da AWS_REGION. +- `JOB_n_ENDPOINT_URL`: opzionale, endpoint S3 custom per quel job. + +## 4) Esempio sintassi JSON (opzionale avanzato) + +Se preferisci, puoi ancora usare il vecchio formato JSON con `SYNC_JOBS_JSON`. +La modalita semplice con `JOB_COUNT` resta consigliata per utenti non tecnici. + +Ogni oggetto dell'array rappresenta un job. + +```json +[ + { + "name": "foto", + "bucket": "bucket-foto", + "local_dir": "/sync/foto", + "endpoint_url": "", + "prefix": "", + "schedule": "*/15 * * * *", + "delete_local_extras": true + } +] +``` + +Significato campi: + +- `name`: nome visualizzato in dashboard. +- `bucket`: nome bucket sorgente. +- `local_dir`: cartella di destinazione nel container. +- `endpoint_url`: lascia vuoto per AWS; compila per S3 compatibile. +- `prefix`: opzionale, limita la sync a una sottocartella del bucket. +- `schedule`: frequenza con sintassi cron a 5 campi. +- `delete_local_extras`: se `true`, cancella in locale file non piu presenti su S3. + +## 5) Esempi semplici di pianificazione cron + +Formato: `minuto ora giorno mese giorno_settimana` + +Esempi utili: + +- `*/15 * * * *` ogni 15 minuti +- `0 * * * *` a ogni ora (minuto 0) +- `0 2 * * *` ogni notte alle 02:00 +- `0 8 * * 1` ogni lunedi alle 08:00 + +## 6) Collega le cartelle locali in docker-compose + +In [docker-compose.yml](docker-compose.yml) devi mappare le cartelle reali del tuo PC/server verso i percorsi usati in `local_dir`. + +Esempio Windows: + +```yaml +volumes: + - D:/Backup/Foto:/sync/foto + - D:/Backup/Documenti:/sync/documenti +``` + +## 7) Avvio + +```bash +docker compose up --build -d +``` + +Dashboard: + +- `http://localhost:8080` (oppure la porta impostata in `WEB_PORT`) + +## Comportamento del mirror + +- Direzione: **S3 -> cartella locale**. +- Scarica file nuovi o modificati. +- Se `delete_local_extras=true`, elimina file locali non presenti nel bucket. +- Invia notifica Gotify a inizio e fine sync (anche in caso di errore). + +## API disponibili + +- `GET /api/status` stato completo e log. +- `POST /api/run/` avvio manuale di un job. +- `POST /api/run-all` avvio manuale di tutti i job. + +## Nota sicurezza + +Non salvare credenziali reali su repository Git pubblici. Il file `.env` e gia ignorato da Git. diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..ffb2e47 --- /dev/null +++ b/app/main.py @@ -0,0 +1,477 @@ +import json +import logging +import os +import threading +from collections import deque +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import boto3 +import requests +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from botocore.exceptions import BotoCoreError, ClientError +from flask import Flask, jsonify, render_template + + +logging.basicConfig( + level=os.getenv("LOG_LEVEL", "INFO"), + format="%(asctime)s | %(levelname)s | %(message)s", +) +logger = logging.getLogger("s3-folder-sync") + +app = Flask(__name__) +scheduler = BackgroundScheduler(timezone="UTC") +log_buffer = deque(maxlen=int(os.getenv("LOG_BUFFER_SIZE", "500"))) +status_lock = threading.Lock() +job_locks: dict[str, threading.Lock] = {} + + +@dataclass +class JobConfig: + name: str + bucket: str + local_dir: str + prefix: str = "" + schedule: str = "*/30 * * * *" + delete_local_extras: bool = True + region: str | None = None + endpoint_url: str | None = None + + +@dataclass +class JobState: + name: str + bucket: str + local_dir: str + last_start: str | None = None + last_end: str | None = None + last_status: str = "never" + last_summary: dict[str, Any] = field(default_factory=dict) + running: bool = False + + +job_configs: list[JobConfig] = [] +job_states: dict[str, JobState] = {} + + +def utc_now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def parse_bool(value: str | None, default: bool = False) -> bool: + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "y", "on"} + + +def add_log(level: str, message: str, job_name: str | None = None, details: dict[str, Any] | None = None) -> None: + event = { + "time": utc_now_iso(), + "level": level.upper(), + "job": job_name, + "message": message, + "details": details or {}, + } + log_buffer.appendleft(event) + text = f"[{event['level']}]" + if job_name: + text += f"[{job_name}]" + text += f" {message}" + if event["details"]: + text += f" | {event['details']}" + + if level.lower() == "error": + logger.error(text) + elif level.lower() == "warning": + logger.warning(text) + else: + logger.info(text) + + +def notify_gotify(title: str, message: str, priority: int = 5) -> None: + gotify_url = os.getenv("GOTIFY_URL", "").strip().rstrip("/") + gotify_token = os.getenv("GOTIFY_TOKEN", "").strip() + + if not gotify_url or not gotify_token: + return + + try: + requests.post( + f"{gotify_url}/message", + json={"title": title, "message": message, "priority": priority}, + params={"token": gotify_token}, + timeout=10, + ) + except requests.RequestException as exc: + add_log("warning", "Gotify notification failed", details={"error": str(exc)}) + + +def get_s3_client(region: str | None = None, endpoint_url: str | None = None): + kwargs: dict[str, Any] = {} + if region: + kwargs["region_name"] = region + if endpoint_url: + kwargs["endpoint_url"] = endpoint_url + return boto3.client("s3", **kwargs) + + +def is_within_directory(base_dir: Path, candidate: Path) -> bool: + try: + candidate.resolve().relative_to(base_dir.resolve()) + return True + except ValueError: + return False + + +def should_download(local_path: Path, s3_size: int, s3_mtime_ts: float) -> bool: + if not local_path.exists(): + return True + + stat = local_path.stat() + size_mismatch = stat.st_size != s3_size + mtime_mismatch = abs(stat.st_mtime - s3_mtime_ts) > 1 + return size_mismatch or mtime_mismatch + + +def build_job_config_from_item( + item: dict[str, Any], + idx: int, + default_cron: str, + default_delete: bool, + default_region: str | None, + default_endpoint: str | None, +) -> JobConfig: + bucket = str(item.get("bucket", "")).strip() + local_dir = str(item.get("local_dir", "")).strip() + if not bucket or not local_dir: + raise ValueError(f"Job #{idx} must define 'bucket' and 'local_dir'") + + name = str(item.get("name", f"job-{idx}")) + schedule = str(item.get("schedule", default_cron)).strip() or default_cron + prefix = str(item.get("prefix", "")).lstrip("/") + if prefix and not prefix.endswith("/"): + prefix += "/" + + delete_local_extras = item.get("delete_local_extras") + if delete_local_extras is None: + delete_local_extras = default_delete + elif isinstance(delete_local_extras, str): + delete_local_extras = parse_bool(delete_local_extras, default_delete) + else: + delete_local_extras = bool(delete_local_extras) + + return JobConfig( + name=name, + bucket=bucket, + local_dir=local_dir, + prefix=prefix, + schedule=schedule, + delete_local_extras=delete_local_extras, + region=str(item.get("region", "")).strip() or default_region, + endpoint_url=str(item.get("endpoint_url", "")).strip() or default_endpoint, + ) + + +def load_jobs_from_indexed_env( + default_cron: str, + default_delete: bool, + default_region: str | None, + default_endpoint: str | None, +) -> list[JobConfig]: + job_count_raw = os.getenv("JOB_COUNT", "").strip() + if not job_count_raw: + return [] + + try: + job_count = int(job_count_raw) + except ValueError as exc: + raise ValueError("JOB_COUNT must be an integer value") from exc + + if job_count <= 0: + raise ValueError("JOB_COUNT must be greater than zero") + + parsed_jobs: list[JobConfig] = [] + for idx in range(1, job_count + 1): + var_prefix = f"JOB_{idx}_" + + item = { + "name": os.getenv(f"{var_prefix}NAME", f"job-{idx}").strip(), + "bucket": os.getenv(f"{var_prefix}BUCKET", "").strip(), + "local_dir": os.getenv(f"{var_prefix}LOCAL_DIR", "").strip(), + "prefix": os.getenv(f"{var_prefix}PREFIX", "").strip(), + "schedule": os.getenv(f"{var_prefix}SCHEDULE", default_cron).strip() or default_cron, + "delete_local_extras": os.getenv(f"{var_prefix}DELETE_LOCAL_EXTRAS"), + "region": os.getenv(f"{var_prefix}REGION", "").strip() or default_region, + "endpoint_url": os.getenv(f"{var_prefix}ENDPOINT_URL", "").strip() or default_endpoint, + } + + parsed_jobs.append( + build_job_config_from_item( + item=item, + idx=idx, + default_cron=default_cron, + default_delete=default_delete, + default_region=default_region, + default_endpoint=default_endpoint, + ) + ) + + return parsed_jobs + + +def load_jobs_from_env() -> list[JobConfig]: + default_cron = os.getenv("DEFAULT_SCHEDULE_CRON", "*/30 * * * *") + default_delete = parse_bool(os.getenv("MIRROR_DELETE_LOCAL_EXTRAS"), True) + default_region = os.getenv("AWS_REGION", "").strip() or None + default_endpoint = os.getenv("S3_ENDPOINT_URL", "").strip() or None + + indexed_jobs = load_jobs_from_indexed_env( + default_cron=default_cron, + default_delete=default_delete, + default_region=default_region, + default_endpoint=default_endpoint, + ) + if indexed_jobs: + return indexed_jobs + + jobs_json = os.getenv("SYNC_JOBS_JSON", "").strip() + if not jobs_json: + raise ValueError("Define jobs with JOB_COUNT/JOB_1_* variables or SYNC_JOBS_JSON") + + try: + raw_jobs = json.loads(jobs_json) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid SYNC_JOBS_JSON: {exc}") from exc + + if not isinstance(raw_jobs, list) or not raw_jobs: + raise ValueError("SYNC_JOBS_JSON must be a non-empty JSON array") + + parsed_jobs: list[JobConfig] = [] + for idx, item in enumerate(raw_jobs, start=1): + if not isinstance(item, dict): + raise ValueError(f"SYNC_JOBS_JSON item #{idx} must be an object") + + parsed_jobs.append( + build_job_config_from_item( + item=item, + idx=idx, + default_cron=default_cron, + default_delete=default_delete, + default_region=default_region, + default_endpoint=default_endpoint, + ) + ) + + return parsed_jobs + + +def sync_job(job: JobConfig, trigger: str = "scheduled") -> None: + lock = job_locks[job.name] + if not lock.acquire(blocking=False): + add_log("warning", "Sync skipped because previous run is still active", job.name) + return + + started_at = utc_now_iso() + with status_lock: + state = job_states[job.name] + state.running = True + state.last_start = started_at + + notify_gotify( + title=f"Sync started: {job.name}", + message=f"Bucket '{job.bucket}' -> '{job.local_dir}' ({trigger})", + priority=int(os.getenv("GOTIFY_PRIORITY_START", "5")), + ) + add_log("info", "Sync started", job.name, {"trigger": trigger}) + + downloaded = 0 + kept = 0 + deleted = 0 + errors = 0 + local_dir = Path(job.local_dir) + + try: + local_dir.mkdir(parents=True, exist_ok=True) + s3_client = get_s3_client(job.region, job.endpoint_url) + paginator = s3_client.get_paginator("list_objects_v2") + + expected_rel_paths: set[str] = set() + + for page in paginator.paginate(Bucket=job.bucket, Prefix=job.prefix): + contents = page.get("Contents", []) + for obj in contents: + key = obj["Key"] + if key.endswith("/"): + continue + + rel_key = key[len(job.prefix) :] if job.prefix else key + rel_key = rel_key.lstrip("/") + if not rel_key: + continue + + local_path = local_dir / rel_key + if not is_within_directory(local_dir, local_path): + add_log("warning", "Skipped key due to unsafe path", job.name, {"key": key}) + continue + + expected_rel_paths.add(rel_key.replace("\\", "/")) + local_path.parent.mkdir(parents=True, exist_ok=True) + + s3_size = int(obj.get("Size", 0)) + s3_ts = obj["LastModified"].timestamp() + + if should_download(local_path, s3_size, s3_ts): + s3_client.download_file(job.bucket, key, str(local_path)) + os.utime(local_path, (s3_ts, s3_ts)) + downloaded += 1 + else: + kept += 1 + + if job.delete_local_extras: + for file_path in local_dir.rglob("*"): + if not file_path.is_file(): + continue + rel_path = file_path.relative_to(local_dir).as_posix() + if rel_path not in expected_rel_paths: + file_path.unlink(missing_ok=True) + deleted += 1 + + ended_at = utc_now_iso() + summary = { + "downloaded": downloaded, + "kept": kept, + "deleted": deleted, + "errors": errors, + "trigger": trigger, + } + + with status_lock: + state = job_states[job.name] + state.running = False + state.last_end = ended_at + state.last_status = "success" + state.last_summary = summary + + add_log("info", "Sync completed", job.name, summary) + notify_gotify( + title=f"Sync completed: {job.name}", + message=( + f"ok | downloaded={downloaded}, kept={kept}, " + f"deleted={deleted}, errors={errors}" + ), + priority=int(os.getenv("GOTIFY_PRIORITY_END", "5")), + ) + + except (BotoCoreError, ClientError, OSError, requests.RequestException) as exc: + errors += 1 + ended_at = utc_now_iso() + summary = { + "downloaded": downloaded, + "kept": kept, + "deleted": deleted, + "errors": errors, + "trigger": trigger, + "error": str(exc), + } + + with status_lock: + state = job_states[job.name] + state.running = False + state.last_end = ended_at + state.last_status = "failed" + state.last_summary = summary + + add_log("error", "Sync failed", job.name, {"error": str(exc)}) + notify_gotify( + title=f"Sync failed: {job.name}", + message=f"error: {exc}", + priority=int(os.getenv("GOTIFY_PRIORITY_END", "8")), + ) + + finally: + lock.release() + + +def schedule_jobs() -> None: + for job in job_configs: + trigger = CronTrigger.from_crontab(job.schedule, timezone="UTC") + scheduler.add_job( + func=sync_job, + trigger=trigger, + args=[job, "scheduled"], + id=job.name, + max_instances=1, + replace_existing=True, + coalesce=True, + ) + add_log( + "info", + "Job scheduled", + job.name, + {"cron": job.schedule, "bucket": job.bucket, "local_dir": job.local_dir}, + ) + + +def initialize() -> None: + global job_configs + + job_configs = load_jobs_from_env() + for job in job_configs: + job_locks[job.name] = threading.Lock() + job_states[job.name] = JobState(name=job.name, bucket=job.bucket, local_dir=job.local_dir) + + schedule_jobs() + scheduler.start() + + if parse_bool(os.getenv("RUN_ON_STARTUP"), True): + for job in job_configs: + thread = threading.Thread(target=sync_job, args=(job, "startup"), daemon=True) + thread.start() + + +@app.route("/") +def index(): + return render_template("index.html") + + +@app.route("/api/status") +def api_status(): + with status_lock: + jobs = [vars(state) for state in job_states.values()] + + return jsonify( + { + "server_time": utc_now_iso(), + "timezone": "UTC", + "jobs": jobs, + "logs": list(log_buffer), + } + ) + + +@app.route("/api/run/", methods=["POST"]) +def api_run_job(job_name: str): + found = next((job for job in job_configs if job.name == job_name), None) + if not found: + return jsonify({"ok": False, "error": f"Unknown job '{job_name}'"}), 404 + + thread = threading.Thread(target=sync_job, args=(found, "manual"), daemon=True) + thread.start() + return jsonify({"ok": True, "message": f"Sync started for {job_name}"}) + + +@app.route("/api/run-all", methods=["POST"]) +def api_run_all(): + for job in job_configs: + thread = threading.Thread(target=sync_job, args=(job, "manual-all"), daemon=True) + thread.start() + return jsonify({"ok": True, "message": "Sync started for all jobs"}) + + +if __name__ == "__main__": + initialize() + web_port = int(os.getenv("WEB_PORT", "8080")) + app.run(host="0.0.0.0", port=web_port) diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..aa65efc --- /dev/null +++ b/app/static/style.css @@ -0,0 +1,200 @@ +:root { + --bg-1: #0b1e2d; + --bg-2: #113a4a; + --accent: #ffb703; + --accent-2: #fb8500; + --ok: #2ec4b6; + --fail: #ff4d6d; + --idle: #8ecae6; + --panel: rgba(9, 24, 33, 0.75); + --card: rgba(17, 39, 52, 0.82); + --border: rgba(255, 255, 255, 0.14); +} + +* { + box-sizing: border-box; +} + +body { + font-family: "Space Grotesk", sans-serif; + min-height: 100vh; + margin: 0; + background: radial-gradient(circle at 20% 20%, var(--bg-2), transparent 42%), + radial-gradient(circle at 80% 30%, #215f6b, transparent 35%), + linear-gradient(140deg, var(--bg-1), #07131d 70%); + color: #f6f7f9; + overflow-x: hidden; +} + +.aurora { + position: fixed; + width: 42vmax; + height: 42vmax; + border-radius: 50%; + filter: blur(60px); + opacity: 0.22; + z-index: 0; + animation: drift 18s ease-in-out infinite alternate; +} + +.aurora-a { + background: #ffb703; + top: -8vmax; + left: -8vmax; +} + +.aurora-b { + background: #2ec4b6; + right: -10vmax; + bottom: -10vmax; + animation-delay: 4s; +} + +@keyframes drift { + from { + transform: translateY(0) translateX(0) scale(1); + } + to { + transform: translateY(20px) translateX(30px) scale(1.1); + } +} + +.hero-panel, +.log-panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 20px; + backdrop-filter: blur(10px); +} + +.hero-panel h1 { + font-weight: 700; + letter-spacing: 0.4px; +} + +.eyebrow { + text-transform: uppercase; + font-size: 0.78rem; + letter-spacing: 1.6px; + color: #ffdd99; + margin-bottom: 0.35rem; +} + +.btn-accent { + background: linear-gradient(130deg, var(--accent), var(--accent-2)); + color: #092235; + border: 0; + font-weight: 700; +} + +.btn-accent:hover, +.btn-accent:focus { + color: #07131d; + transform: translateY(-1px); +} + +.job-card { + background: var(--card); + border: 1px solid var(--border); + border-radius: 16px; + height: 100%; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); + animation: reveal 0.45s ease both; +} + +@keyframes reveal { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.mono { + font-family: "IBM Plex Mono", monospace; +} + +.summary-box { + background: rgba(3, 12, 18, 0.5); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + padding: 0.55rem 0.7rem; + min-height: 42px; + word-break: break-word; +} + +.status-chip { + border-radius: 999px; + padding: 0.3rem 0.75rem; + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.status-running { + background: rgba(255, 183, 3, 0.22); + color: #ffe2a3; +} + +.status-ok { + background: rgba(46, 196, 182, 0.2); + color: #a9f3eb; +} + +.status-fail { + background: rgba(255, 77, 109, 0.2); + color: #ffc8d2; +} + +.status-idle { + background: rgba(142, 202, 230, 0.2); + color: #cbefff; +} + +.pill { + display: inline-block; + padding: 0.2rem 0.55rem; + border-radius: 999px; + border: 1px solid var(--border); + font-size: 0.72rem; +} + +.level-info { + background: rgba(72, 149, 239, 0.2); +} + +.level-warning { + background: rgba(255, 183, 3, 0.2); +} + +.level-error { + background: rgba(255, 77, 109, 0.2); +} + +.table { + --bs-table-bg: transparent; + --bs-table-color: #f6f7f9; +} + +.table thead { + border-bottom: 1px solid var(--border); +} + +.table tbody tr { + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +@media (max-width: 768px) { + .hero-panel, + .log-panel { + border-radius: 16px; + } + + .job-card { + border-radius: 14px; + } +} diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..4cb21c8 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,181 @@ + + + + + + Controllo Mirror S3 + + + + + + + +
+
+ +
+
+
+
+

Mirror S3 su Locale

+

Dashboard Sync

+

Controlla tutti i job, avvia sync manuali e consulta i log in tempo reale.

+
+
+ +

loading...

+
+
+
+ +
+
+
+ +
+
+

Attivita Recenti

+ UTC +
+
+ + + + + + + + + + + +
OraLivelloJobMessaggioDettagli
+
+
+
+ + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..41edd53 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +services: + s3-folder-sync: + build: . + container_name: s3-folder-sync + env_file: + - .env + ports: + - "${WEB_PORT:-8080}:${WEB_PORT:-8080}" + volumes: + # Mappa cartelle locali esistenti verso i percorsi usati in SYNC_JOBS_JSON + - ./backups/foto:/sync/foto + - ./backups/documenti:/sync/documenti + # Opzionale: monta il profilo AWS del sistema host + # - ${USERPROFILE}/.aws:/root/.aws:ro + restart: unless-stopped diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..733e558 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +APScheduler==3.10.4 +boto3==1.35.15 +Flask==3.0.3 +requests==2.32.3