feat: initialize microservice architecture with auth, api, realtime, copernicus, ml, and console modules
This commit is contained in:
0
copernicus/Dockerfile
Normal file
0
copernicus/Dockerfile
Normal file
125
copernicus/core/cache.py
Normal file
125
copernicus/core/cache.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
Redis Keys:
|
||||
- marine:catalog:full → lista dei dataset completo (TTL 1h)
|
||||
- marine:catalog:search:{hash} → risultati ricerca (TTL 30min)
|
||||
- marine:job:{session_id} → stato job download (TTL 48h)
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
import redis
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Configurazione Redis da variabili ambiente
|
||||
REDIS_HOST = os.getenv("REDIS_HOST", "meb-redis")
|
||||
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
|
||||
|
||||
# Pool di connessioni condiviso (thread-safe, riutilizzabile)
|
||||
_pool: Optional[redis.ConnectionPool] = None
|
||||
_client: Optional[redis.Redis] = None
|
||||
|
||||
|
||||
def _get_client() -> Optional[redis.Redis]:
|
||||
"""Restituisce il client Redis singleton con connection pool.
|
||||
Ritorna None se Redis non è raggiungibile."""
|
||||
global _pool, _client
|
||||
|
||||
if _client is not None:
|
||||
return _client
|
||||
|
||||
try:
|
||||
_pool = redis.ConnectionPool(
|
||||
host=REDIS_HOST,
|
||||
port=REDIS_PORT,
|
||||
# Decodifica automatica delle risposte in stringhe UTF-8
|
||||
decode_responses=True,
|
||||
# Massimo 5 connessioni nel pool (VPS 1-core, non serve di più)
|
||||
max_connections=5,
|
||||
# Timeout connessione e socket per evitare blocchi
|
||||
socket_connect_timeout=3,
|
||||
socket_timeout=3,
|
||||
# Riprova automaticamente se la connessione viene interrotta
|
||||
retry_on_timeout=True,
|
||||
)
|
||||
_client = redis.Redis(connection_pool=_pool)
|
||||
# Test connessione
|
||||
_client.ping()
|
||||
logger.info("[Redis] Connessione stabilita per il servizio Marine")
|
||||
return _client
|
||||
except Exception as e:
|
||||
logger.warning(f"[Redis] Non disponibile, la cache è disabilitata: {e}")
|
||||
_client = None
|
||||
return None
|
||||
|
||||
|
||||
def cache_get(key: str) -> Optional[Any]:
|
||||
"""Legge un valore dalla cache Redis.
|
||||
|
||||
Args:
|
||||
key: Chiave Redis (es. 'marine:catalog:full')
|
||||
|
||||
Returns:
|
||||
Il valore deserializzato da JSON, oppure None se non trovato o errore
|
||||
"""
|
||||
try:
|
||||
client = _get_client()
|
||||
if client is None:
|
||||
return None
|
||||
|
||||
data = client.get(key)
|
||||
if data is None:
|
||||
return None
|
||||
|
||||
return json.loads(data)
|
||||
except Exception as e:
|
||||
logger.warning(f"[Redis] Errore lettura chiave '{key}': {e}")
|
||||
return None
|
||||
|
||||
|
||||
def cache_set(key: str, value: Any, ttl: int = 3600) -> bool:
|
||||
"""Scrive un valore nella cache Redis con TTL.
|
||||
|
||||
Args:
|
||||
key: Chiave Redis
|
||||
value: Valore da serializzare in JSON
|
||||
ttl: Tempo di vita in secondi (default: 1 ora)
|
||||
|
||||
Returns:
|
||||
True se scritto con successo, False altrimenti
|
||||
"""
|
||||
try:
|
||||
client = _get_client()
|
||||
if client is None:
|
||||
return False
|
||||
|
||||
serialized = json.dumps(value)
|
||||
client.setex(key, ttl, serialized)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"[Redis] Errore scrittura chiave '{key}': {e}")
|
||||
return False
|
||||
|
||||
|
||||
def cache_delete(key: str) -> bool:
|
||||
"""Elimina una chiave dalla cache Redis.
|
||||
|
||||
Args:
|
||||
key: Chiave Redis da eliminare
|
||||
|
||||
Returns:
|
||||
True se eliminata, False altrimenti
|
||||
"""
|
||||
try:
|
||||
client = _get_client()
|
||||
if client is None:
|
||||
return False
|
||||
|
||||
client.delete(key)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"[Redis] Errore eliminazione chiave '{key}': {e}")
|
||||
return False
|
||||
310
copernicus/core/copernicus.py
Normal file
310
copernicus/core/copernicus.py
Normal file
@@ -0,0 +1,310 @@
|
||||
import hashlib
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from core.cache import cache_get, cache_set
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Chiavi Redis e TTL ────────────────────────────────────────────────
|
||||
# Chiave per il catalogo completo Copernicus
|
||||
_CATALOG_KEY = "marine:catalog:full"
|
||||
# TTL del catalogo: 1 ora (il catalogo Copernicus cambia raramente)
|
||||
_CATALOG_TTL = 3600
|
||||
# TTL per i risultati di ricerca: 30 minuti
|
||||
_SEARCH_TTL = 1800
|
||||
|
||||
|
||||
def _fmt_description(name: Optional[str]) -> Optional[str]:
|
||||
"""Formatta meglio il titolo del dataset"""
|
||||
if not name:
|
||||
return None
|
||||
return name.replace("_", " ").title()
|
||||
|
||||
|
||||
def _get_raw_catalog() -> dict:
|
||||
"""Interroga le API di Copernicus per ottenere la lista completa dei dataset.
|
||||
|
||||
Strategia cache Redis:
|
||||
1. Cerca in Redis (chiave marine:catalog:full)
|
||||
2. Se non trovato → chiama Copernicus SDK → salva in Redis con TTL 1h
|
||||
3. Se Redis non disponibile → chiama sempre l'SDK (nessuna cache)
|
||||
|
||||
Il catalogo in Redis sopravvive al restart del servizio grazie
|
||||
alla persistenza RDB+AOF configurata in redis.conf.
|
||||
"""
|
||||
# Cerca in Redis prima di chiamare l'SDK Copernicus
|
||||
cached = cache_get(_CATALOG_KEY)
|
||||
if cached is not None:
|
||||
logger.debug("[Catalogo] Servito da cache Redis")
|
||||
return cached
|
||||
|
||||
# Cache miss: interroga Copernicus SDK (operazione lenta, ~10-30s)
|
||||
logger.info("[Catalogo] Cache miss, scaricamento da Copernicus SDK...")
|
||||
import copernicusmarine
|
||||
catalog = copernicusmarine.describe(disable_progress_bar=True)
|
||||
|
||||
# Serializza la risposta SDK in un dizionario standard
|
||||
if hasattr(catalog, "model_dump"):
|
||||
result = catalog.model_dump()
|
||||
elif hasattr(catalog, "__dict__"):
|
||||
result = catalog.__dict__
|
||||
else:
|
||||
result = catalog
|
||||
|
||||
# Salva in Redis per le prossime richieste (TTL 1 ora)
|
||||
cache_set(_CATALOG_KEY, result, _CATALOG_TTL)
|
||||
logger.info("[Catalogo] Salvato in cache Redis")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _get_dataset_reqs(ds: dict) -> tuple:
|
||||
"""
|
||||
Ottieni dalla risposta del dataset le variabili disponibili e le coordinate dell'area disponibile.
|
||||
|
||||
Attualmente è implementato Copernicus SDK v2, le variabili sono in::
|
||||
dataset -> versions[-1] -> parts[] -> services[] -> variables[]
|
||||
|
||||
Le coordinate sono disponibili in variable.bbox = [min_lon, min_lat, max_lon, max_lat].
|
||||
La finestra temporale disponibile è nel servizio "arco-time-series"
|
||||
dove coordinate_id == 'time' (i valori sono in millisecondi, usando Unix epoch).
|
||||
"""
|
||||
variables = []
|
||||
seen: set = set()
|
||||
bounds = {
|
||||
"min_longitude": None, "max_longitude": None,
|
||||
"min_latitude": None, "max_latitude": None,
|
||||
"start_datetime": None, "end_datetime": None,
|
||||
}
|
||||
|
||||
versions = ds.get("versions", [])
|
||||
if not versions:
|
||||
return variables, bounds
|
||||
|
||||
for part in versions[-1].get("parts", []):
|
||||
for service in part.get("services", []):
|
||||
service_name = service.get("service_name", "")
|
||||
for var in service.get("variables", []):
|
||||
short_name = var.get("short_name", "")
|
||||
if not short_name or short_name in seen:
|
||||
continue
|
||||
seen.add(short_name)
|
||||
std = var.get("standard_name")
|
||||
variables.append({
|
||||
"short_name": short_name,
|
||||
"standard_name": std,
|
||||
"units": var.get("units"),
|
||||
"description": _fmt_description(std),
|
||||
})
|
||||
|
||||
# Ottieni la box delle coordinate
|
||||
if bounds["min_longitude"] is None:
|
||||
bbox = var.get("bbox")
|
||||
if bbox and len(bbox) >= 4:
|
||||
# [min_lon, min_lat, max_lon, max_lat]
|
||||
bounds["min_longitude"] = bbox[0]
|
||||
bounds["min_latitude"] = bbox[1]
|
||||
bounds["max_longitude"] = bbox[2]
|
||||
bounds["max_latitude"] = bbox[3]
|
||||
|
||||
# Ottieni la finestra temporale del dataset dal servizio "arco-time-series"
|
||||
if bounds["start_datetime"] is None and "arco-time" in service_name:
|
||||
for coord in var.get("coordinates", []):
|
||||
if coord.get("coordinate_id") == "time":
|
||||
min_ms = coord.get("minimum_value")
|
||||
max_ms = coord.get("maximum_value")
|
||||
if min_ms is not None:
|
||||
bounds["start_datetime"] = datetime.fromtimestamp(
|
||||
min_ms / 1000, tz=timezone.utc
|
||||
).strftime("%Y-%m-%d")
|
||||
if max_ms is not None:
|
||||
bounds["end_datetime"] = datetime.fromtimestamp(
|
||||
max_ms / 1000, tz=timezone.utc
|
||||
).strftime("%Y-%m-%d")
|
||||
break
|
||||
|
||||
return variables, bounds
|
||||
|
||||
|
||||
def get_catalog(search: Optional[str] = None, limit: int = 50, offset: int = 0) -> dict:
|
||||
"""Ottieni dataset dal catalogo Copernicus Marine, filtrabili per nome o ID.
|
||||
|
||||
Cache Redis per le ricerche:
|
||||
- Chiave: marine:catalog:search:{md5(search|limit|offset)}
|
||||
- TTL: 30 minuti
|
||||
- La cache ricerca viene invalidata quando il catalogo scade (1h)
|
||||
"""
|
||||
# Genera chiave cache unica per questa combinazione di parametri
|
||||
cache_key = None
|
||||
if search:
|
||||
query_hash = hashlib.md5(f"{search}|{limit}|{offset}".encode()).hexdigest()[:12]
|
||||
cache_key = f"marine:catalog:search:{query_hash}"
|
||||
|
||||
# Cerca risultato in cache Redis
|
||||
cached_result = cache_get(cache_key)
|
||||
if cached_result is not None:
|
||||
logger.debug(f"[Catalogo] Ricerca '{search}' servita da cache Redis")
|
||||
return cached_result
|
||||
|
||||
raw = _get_raw_catalog()
|
||||
# Gestisce formati diversi della risposta SDK (lista o dizionario)
|
||||
if isinstance(raw, list):
|
||||
products = raw
|
||||
else:
|
||||
products = raw.get("products", [])
|
||||
|
||||
results = []
|
||||
for product in products:
|
||||
title = product.get("title", "")
|
||||
description = product.get("description", "")
|
||||
|
||||
for ds in product.get("datasets", []):
|
||||
dataset_id = ds.get("dataset_id", "")
|
||||
|
||||
if search:
|
||||
needle = search.lower()
|
||||
if needle not in dataset_id.lower() and needle not in title.lower():
|
||||
continue
|
||||
|
||||
variables, bounds = _get_dataset_reqs(ds)
|
||||
results.append({
|
||||
"dataset_id": dataset_id,
|
||||
"title": title,
|
||||
"description": description[:200] if description else "",
|
||||
"variables": variables,
|
||||
**bounds,
|
||||
})
|
||||
|
||||
total = len(results)
|
||||
page = results[offset: offset + limit]
|
||||
response = {"total": total, "offset": offset, "limit": limit, "datasets": page}
|
||||
|
||||
# Salva risultato ricerca in cache Redis (solo se c'è un filtro di ricerca)
|
||||
if cache_key:
|
||||
cache_set(cache_key, response, _SEARCH_TTL)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def get_dataset_info(dataset_id: str) -> Optional[dict]:
|
||||
"""Return detailed info for a single dataset (variables, bounds, time range)."""
|
||||
raw = _get_raw_catalog()
|
||||
if isinstance(raw, list):
|
||||
products = raw
|
||||
else:
|
||||
products = raw.get("products", [])
|
||||
|
||||
for product in products:
|
||||
for ds in product.get("datasets", []):
|
||||
if ds.get("dataset_id") == dataset_id:
|
||||
variables, bounds = _get_dataset_reqs(ds)
|
||||
return {
|
||||
"dataset_id": dataset_id,
|
||||
"title": product.get("title", ""),
|
||||
"description": product.get("description", ""),
|
||||
"variables": variables,
|
||||
**bounds,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def download_dataset(
|
||||
dataset_id: str,
|
||||
variables: List[str],
|
||||
min_longitude: float,
|
||||
max_longitude: float,
|
||||
min_latitude: float,
|
||||
max_latitude: float,
|
||||
start_datetime: str,
|
||||
end_datetime: str,
|
||||
progress_callback: Optional[Callable[[int, str], None]] = None
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
Scarica i dati di un dataset da Copernicus Marine. L'SDK ufficiale di Copernicus,
|
||||
restituisce i dati del download sotto forma di pandas Dataframe.
|
||||
"""
|
||||
import tempfile
|
||||
|
||||
import copernicusmarine
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(5, "Avvio dowload...")
|
||||
|
||||
# l'SDK di copernicus richiede l'autenticazione di un utente
|
||||
if not os.getenv("COPERNICUS_USERNAME") or not os.getenv("COPERNICUS_PASSWORD"):
|
||||
raise ValueError("non sono presenti username e password per copernicus.")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
try:
|
||||
copernicusmarine.subset(
|
||||
dataset_id=dataset_id,
|
||||
variables=variables,
|
||||
minimum_longitude=min_longitude,
|
||||
maximum_longitude=max_longitude,
|
||||
minimum_latitude=min_latitude,
|
||||
maximum_latitude=max_latitude,
|
||||
start_datetime=start_datetime,
|
||||
end_datetime=end_datetime,
|
||||
username=os.getenv("COPERNICUS_USERNAME"),
|
||||
password=os.getenv("COPERNICUS_PASSWORD"),
|
||||
output_directory=tmpdir,
|
||||
output_filename="data.nc",
|
||||
force_download=True,
|
||||
overwrite_output_data=True,
|
||||
disable_progress_bar=True,
|
||||
)
|
||||
except TypeError:
|
||||
# Fallback for older versions of copernicusmarine
|
||||
copernicusmarine.subset(
|
||||
dataset_id=dataset_id,
|
||||
variables=variables,
|
||||
minimum_longitude=min_longitude,
|
||||
maximum_longitude=max_longitude,
|
||||
minimum_latitude=min_latitude,
|
||||
maximum_latitude=max_latitude,
|
||||
start_datetime=start_datetime,
|
||||
end_datetime=end_datetime,
|
||||
username=os.getenv("COPERNICUS_USERNAME"),
|
||||
password=os.getenv("COPERNICUS_PASSWORD"),
|
||||
output_directory=tmpdir,
|
||||
output_filename="data.nc",
|
||||
overwrite=True,
|
||||
disable_progress_bar=True,
|
||||
)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(50, "Download completato, elaboro i dati...")
|
||||
|
||||
import xarray as xr
|
||||
ds = xr.open_dataset(os.path.join(tmpdir, "data.nc"))
|
||||
df = ds.to_dataframe().reset_index()
|
||||
ds.close()
|
||||
|
||||
if df is None or df.empty:
|
||||
raise ValueError("Nessun dato disponibile. errore nel download")
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(75, "Elaborazione completata, formatto i dati...")
|
||||
|
||||
return df
|
||||
|
||||
|
||||
def dataframe_to_bytes(df: pd.DataFrame, fmt: str, variable_renames: dict = None) -> tuple:
|
||||
"""
|
||||
Converte i dati in memorie sottoforma di DataFrame scaircati da Copernicus in byte per migliorarne l'elaborazione e la formattazione in file CSV o JSON."""
|
||||
if variable_renames:
|
||||
df = df.rename(columns=variable_renames)
|
||||
if fmt == "csv":
|
||||
buf = io.StringIO()
|
||||
df.to_csv(buf, index=True)
|
||||
return buf.getvalue().encode("utf-8"), "text/csv"
|
||||
else:
|
||||
buf = io.StringIO()
|
||||
df.to_json(buf, orient="records", date_format="iso", indent=2)
|
||||
return buf.getvalue().encode("utf-8"), "application/json"
|
||||
112
copernicus/core/storage.py
Normal file
112
copernicus/core/storage.py
Normal file
@@ -0,0 +1,112 @@
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Optional
|
||||
|
||||
from minio.error import S3Error
|
||||
|
||||
from minio import Minio
|
||||
|
||||
_minio_host = os.getenv("MINIO_ENDPOINT", "minio")
|
||||
_minio_port = os.getenv("MINIO_PORT", "9000")
|
||||
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "meb-admin")
|
||||
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "meb-cloud")
|
||||
MINIO_SECURE = os.getenv("MINIO_SECURE", "false").lower() == "true"
|
||||
|
||||
DATASETS_BUCKET = "datasets"
|
||||
METADATA_FILE = "metadata.json"
|
||||
|
||||
_client: Optional[Minio] = None
|
||||
|
||||
|
||||
def get_client() -> Minio:
|
||||
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = Minio(
|
||||
f"{_minio_host}:{_minio_port}",
|
||||
access_key=MINIO_ACCESS_KEY,
|
||||
secret_key=MINIO_SECRET_KEY,
|
||||
secure=MINIO_SECURE
|
||||
)
|
||||
return _client
|
||||
|
||||
|
||||
def bucket_exists(bucket: str = DATASETS_BUCKET) -> bool:
|
||||
try:
|
||||
client = get_client()
|
||||
if not client.bucket_exists(bucket):
|
||||
client.make_bucket(bucket)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[Storage] Error in '{bucket}': {e}")
|
||||
return False
|
||||
|
||||
|
||||
def fetch_metadata() -> dict:
|
||||
"""Il bucket datasets contiene un file JSON di metadata valido per tutti i file dataset salvati, che questi siano JSON, csv o
|
||||
un altro formato. I metadata per ogni file sono salvati come oggetti nel file metadata.json. """
|
||||
try:
|
||||
client = get_client()
|
||||
response = client.get_object(DATASETS_BUCKET, METADATA_FILE)
|
||||
data = json.loads(response.read().decode("utf-8"))
|
||||
response.close()
|
||||
return data
|
||||
except S3Error as e:
|
||||
if e.code == "NoSuchKey":
|
||||
return {"datasets": []}
|
||||
raise
|
||||
except Exception:
|
||||
return {"datasets": []}
|
||||
|
||||
|
||||
def write_metadata(data: dict) -> None:
|
||||
"""Aggiunge al file metadata.json un nuovo oggetto con l'id del nuovo file caricato dall'utente"""
|
||||
client = get_client()
|
||||
raw = json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8")
|
||||
client.put_object(
|
||||
DATASETS_BUCKET,
|
||||
METADATA_FILE,
|
||||
io.BytesIO(raw),
|
||||
length=len(raw),
|
||||
content_type="application/json"
|
||||
)
|
||||
|
||||
|
||||
def upload_file(data: bytes, filename: str, content_type: str) -> None:
|
||||
"""Carica un nuovo file di qualsiasi formato nel bucket dataset."""
|
||||
client = get_client()
|
||||
client.put_object(
|
||||
DATASETS_BUCKET,
|
||||
filename,
|
||||
io.BytesIO(data),
|
||||
length=len(data),
|
||||
content_type=content_type
|
||||
)
|
||||
|
||||
|
||||
def delete_file(filename: str) -> None:
|
||||
"""Elimina un file dal bucket dataset."""
|
||||
client = get_client()
|
||||
client.remove_object(DATASETS_BUCKET, filename)
|
||||
|
||||
|
||||
def get_presigned_url(filename: str, expires_hours: int = 1) -> str:
|
||||
"""Genera un URL temporaneo per scaricare un file dal bucket dataset"""
|
||||
from datetime import timedelta
|
||||
client = get_client()
|
||||
return client.presigned_get_object(
|
||||
DATASETS_BUCKET,
|
||||
filename,
|
||||
expires=timedelta(hours=expires_hours)
|
||||
)
|
||||
|
||||
|
||||
def file_exists(filename: str) -> bool:
|
||||
"""Verifica se un file esiste."""
|
||||
try:
|
||||
client = get_client()
|
||||
client.stat_object(DATASETS_BUCKET, filename)
|
||||
return True
|
||||
except S3Error:
|
||||
return False
|
||||
53
copernicus/main.py
Normal file
53
copernicus/main.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
Servizio reindirizzato: api.{domain}/marine/* (Traefik strips /marine prefix)
|
||||
"""
|
||||
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
load_dotenv()
|
||||
|
||||
from routers import catalog, datasets, jobs
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
api_url = os.getenv("API_SERVICE_URL", "http://api:3003")
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="MEB Marine Service",
|
||||
description="Copernicus Marine data download and dataset management for the MEB platform",
|
||||
version="1.0.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
root_path=""
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origin_regex=r"https?://.*\.(localhost|mebboat\.it)(:\d+)?$",
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(catalog.router)
|
||||
app.include_router(jobs.router)
|
||||
app.include_router(datasets.router)
|
||||
|
||||
|
||||
@app.get("/", tags=["health"])
|
||||
async def root():
|
||||
return {"service": "MEB Marine Service", "version": "1.0.0", "docs": "/docs"}
|
||||
|
||||
|
||||
@app.get("/health", tags=["health"])
|
||||
async def health():
|
||||
return {"status": "healthy"}
|
||||
13
copernicus/requirements.txt
Normal file
13
copernicus/requirements.txt
Normal file
@@ -0,0 +1,13 @@
|
||||
fastapi>=0.115.0
|
||||
uvicorn[standard]>=0.32.0
|
||||
httpx>=0.27.0
|
||||
python-dotenv>=1.0.0
|
||||
copernicusmarine>=2.0.0
|
||||
xarray>=2024.0.0
|
||||
pandas>=2.2.0
|
||||
numpy>=1.26.0
|
||||
pydantic>=2.6.0
|
||||
redis>=5.0.0
|
||||
python-multipart>=0.0.9
|
||||
h5py
|
||||
h5netcdf
|
||||
46
copernicus/routers/catalog.py
Normal file
46
copernicus/routers/catalog.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from typing import Optional
|
||||
from middleware.auth import require_auth
|
||||
from core import copernicus
|
||||
|
||||
"""
|
||||
api.mebboat.it/marine/...
|
||||
"""
|
||||
|
||||
router = APIRouter(prefix="/catalog", tags=["Copernicus Marine Database"])
|
||||
|
||||
@router.get("")
|
||||
async def list_catalog(
|
||||
search: Optional[str] = Query(None, description="Cerca per nome o ID"),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
user=Depends(require_auth)
|
||||
):
|
||||
"""
|
||||
[API] Ottieni una lista di dataset corrispondeti alla query di ricerca, con paginazione.
|
||||
Ogni dataset include ID, titolo, descrizione, variabili, posizione e finestra di tempo.
|
||||
I risultati rimangono salvati nella cache del server per un ora.
|
||||
"""
|
||||
try:
|
||||
return copernicus.get_catalog(search=search, limit=limit, offset=offset)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"Catalog unavailable: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/{dataset_id}")
|
||||
async def get_dataset(
|
||||
dataset_id: str,
|
||||
user=Depends(require_auth)
|
||||
):
|
||||
"""
|
||||
[API] Ottieni i dati di un dataset dal catalogo di Copernics Marine.
|
||||
"""
|
||||
try:
|
||||
info = copernicus.get_dataset_info(dataset_id)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"Catalog unavailable: {str(e)}")
|
||||
|
||||
if info is None:
|
||||
raise HTTPException(status_code=404, detail=f"Dataset '{dataset_id}' not found in catalog")
|
||||
|
||||
return info
|
||||
57
copernicus/routers/datasets.py
Normal file
57
copernicus/routers/datasets.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
api.mebboat.it/marine/datasets/*
|
||||
"""
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from middleware.auth import require_auth
|
||||
|
||||
router = APIRouter(prefix="/datasets", tags=["datasets"])
|
||||
|
||||
API_URL = os.getenv("API_SERVICE_URL", "http://api-service:3003")
|
||||
|
||||
|
||||
def _auth_headers(user: dict) -> dict:
|
||||
return {"Authorization": f"Bearer {user['token']}"}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_datasets(
|
||||
tags: Optional[str] = Query(None),
|
||||
user=Depends(require_auth)
|
||||
):
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
r = await client.get(
|
||||
f"{API_URL}/marine/datasets",
|
||||
params={"tags": tags} if tags else {},
|
||||
headers=_auth_headers(user),
|
||||
)
|
||||
if not r.is_success:
|
||||
raise HTTPException(status_code=r.status_code, detail=r.json().get("error", "Upstream error"))
|
||||
return r.json()
|
||||
|
||||
|
||||
@router.get("/{dataset_id}/download")
|
||||
async def download_dataset(dataset_id: str, user=Depends(require_auth)):
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
r = await client.get(
|
||||
f"{API_URL}/marine/datasets/{dataset_id}/download",
|
||||
headers=_auth_headers(user),
|
||||
)
|
||||
if not r.is_success:
|
||||
raise HTTPException(status_code=r.status_code, detail=r.json().get("error", "Upstream error"))
|
||||
return r.json()
|
||||
|
||||
|
||||
@router.delete("/{dataset_id}")
|
||||
async def delete_dataset(dataset_id: str, user=Depends(require_auth)):
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
r = await client.delete(
|
||||
f"{API_URL}/marine/datasets/{dataset_id}",
|
||||
headers=_auth_headers(user),
|
||||
)
|
||||
if not r.is_success:
|
||||
raise HTTPException(status_code=r.status_code, detail=r.json().get("error", "Upstream error"))
|
||||
return r.json()
|
||||
145
copernicus/routers/jobs.py
Normal file
145
copernicus/routers/jobs.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
Flusso:
|
||||
1. POST /jobs → crea job in Redis con stato "pending"
|
||||
2. Background task: scarica dati → aggiorna stato in Redis
|
||||
3. GET /jobs/{id} → legge stato da Redis
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any, Dict
|
||||
|
||||
import httpx
|
||||
from core import copernicus
|
||||
from core.cache import cache_get, cache_set, cache_delete
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from middleware.auth import require_auth
|
||||
from schemas import DownloadJobRequest, JobStatus
|
||||
|
||||
router = APIRouter(prefix="/jobs", tags=["sessions"])
|
||||
|
||||
API_URL = os.getenv("API_SERVICE_URL", "http://api:3003")
|
||||
|
||||
# TTL per lo stato dei job: 48 ore (i job completati vengono puliti automaticamente)
|
||||
_JOB_TTL = 48 * 3600
|
||||
|
||||
|
||||
def _job_key(session_id: str) -> str:
|
||||
"""Genera la chiave Redis per un job."""
|
||||
return f"marine:job:{session_id}"
|
||||
|
||||
|
||||
def _get_job(session_id: str) -> Dict[str, Any] | None:
|
||||
"""Legge lo stato di un job da Redis."""
|
||||
return cache_get(_job_key(session_id))
|
||||
|
||||
|
||||
def _set_job(session_id: str, **kwargs):
|
||||
"""Aggiorna lo stato di un job in Redis.
|
||||
Legge lo stato corrente, applica le modifiche, e riscrive."""
|
||||
job = cache_get(_job_key(session_id))
|
||||
if job is None:
|
||||
return
|
||||
job.update(kwargs)
|
||||
cache_set(_job_key(session_id), job, _JOB_TTL)
|
||||
|
||||
|
||||
def _run_download(session_id: str, req: DownloadJobRequest, username: str, user_token: str):
|
||||
"""Download in background: Copernicus → conversione → upload via API service.
|
||||
|
||||
Ad ogni cambio di fase, lo stato viene aggiornato in Redis
|
||||
così il frontend può fare polling su GET /jobs/{id}.
|
||||
"""
|
||||
def progress(pct: int, msg: str):
|
||||
_set_job(session_id, progress=pct, message=msg)
|
||||
|
||||
try:
|
||||
_set_job(session_id, status="downloading", progress=5, message="Scarico da Copernicus Marine...")
|
||||
|
||||
# Scarica dati dal catalogo Copernicus
|
||||
df = copernicus.download_dataset(
|
||||
dataset_id=req.dataset_id,
|
||||
variables=req.variables,
|
||||
min_longitude=req.min_longitude,
|
||||
max_longitude=req.max_longitude,
|
||||
min_latitude=req.min_latitude,
|
||||
max_latitude=req.max_latitude,
|
||||
start_datetime=req.start_date,
|
||||
end_datetime=req.end_date,
|
||||
progress_callback=progress,
|
||||
)
|
||||
|
||||
_set_job(session_id, status="converting", progress=80, message="Creo il file...")
|
||||
|
||||
# Converte il DataFrame in bytes (CSV o JSON)
|
||||
data_bytes, content_type = copernicus.dataframe_to_bytes(df, req.format, req.variable_renames)
|
||||
filename = f"upload.{req.format}"
|
||||
|
||||
_set_job(session_id, status="saving", progress=90, message="Carico su storage...")
|
||||
|
||||
# Metadati del dataset per l'API service
|
||||
metadata = {
|
||||
"nome": req.nome,
|
||||
"tags": req.tags,
|
||||
"created_by": username,
|
||||
"type": req.format,
|
||||
"notes": req.notes,
|
||||
"copernicus_dataset_id": req.dataset_id,
|
||||
"variables": req.variables,
|
||||
"variable_renames": req.variable_renames,
|
||||
"bbox": [req.min_longitude, req.min_latitude, req.max_longitude, req.max_latitude],
|
||||
"start_date": req.start_date,
|
||||
"end_date": req.end_date,
|
||||
}
|
||||
|
||||
# Upload al servizio API che gestisce MinIO
|
||||
with httpx.Client(timeout=None) as client:
|
||||
r = client.post(
|
||||
f"{API_URL}/marine/datasets/upload",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
files={"file": (filename, data_bytes, content_type)},
|
||||
data={"metadata": json.dumps(metadata)},
|
||||
)
|
||||
|
||||
if not r.is_success:
|
||||
raise RuntimeError(f"API upload failed ({r.status_code}): {r.text}")
|
||||
|
||||
entry = r.json()
|
||||
_set_job(session_id, status="done", progress=100, message="Dataset salvato.", dataset_id=entry["id"])
|
||||
|
||||
except Exception as e:
|
||||
_set_job(session_id, status="error", progress=0, message=str(e))
|
||||
|
||||
|
||||
@router.post("", response_model=JobStatus, status_code=202)
|
||||
async def new_download_session(
|
||||
req: DownloadJobRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
user=Depends(require_auth)
|
||||
):
|
||||
"""Crea un nuovo job di download e lo avvia in background."""
|
||||
session_id = str(uuid.uuid4())
|
||||
|
||||
# Stato iniziale del job salvato in Redis
|
||||
initial_state = {
|
||||
"job_id": session_id,
|
||||
"status": "pending",
|
||||
"progress": 0,
|
||||
"message": "In coda",
|
||||
"dataset_id": None,
|
||||
}
|
||||
cache_set(_job_key(session_id), initial_state, _JOB_TTL)
|
||||
|
||||
# Avvia il download in background
|
||||
background_tasks.add_task(_run_download, session_id, req, user["username"], user["token"])
|
||||
return initial_state
|
||||
|
||||
|
||||
@router.get("/{session_id}", response_model=JobStatus)
|
||||
async def get_download_session(session_id: str, user=Depends(require_auth)):
|
||||
"""Legge lo stato di un job di download da Redis."""
|
||||
session = _get_job(session_id)
|
||||
if session is None:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
return session
|
||||
77
copernicus/schemas.py
Normal file
77
copernicus/schemas.py
Normal file
@@ -0,0 +1,77 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Dict, List, Optional, Any
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# ── Catalog ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class DatasetVariable(BaseModel):
|
||||
short_name: str
|
||||
standard_name: Optional[str] = None
|
||||
units: Optional[str] = None
|
||||
description: Optional[str] = None # human-readable label derived from standard_name
|
||||
|
||||
|
||||
class CatalogDataset(BaseModel):
|
||||
dataset_id: str
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
variables: List[DatasetVariable] = []
|
||||
min_longitude: Optional[float] = None
|
||||
max_longitude: Optional[float] = None
|
||||
min_latitude: Optional[float] = None
|
||||
max_latitude: Optional[float] = None
|
||||
start_datetime: Optional[str] = None
|
||||
end_datetime: Optional[str] = None
|
||||
|
||||
|
||||
# ── Jobs ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
class DownloadJobRequest(BaseModel):
|
||||
dataset_id: str
|
||||
variables: List[str] = Field(..., min_length=1)
|
||||
min_longitude: float
|
||||
max_longitude: float
|
||||
min_latitude: float
|
||||
max_latitude: float
|
||||
start_date: str # YYYY-MM-DD
|
||||
end_date: str # YYYY-MM-DD
|
||||
format: str = Field("json", pattern="^(json|csv)$")
|
||||
nome: str = Field(..., min_length=1)
|
||||
tags: List[str] = Field(default_factory=lambda: ["marine"])
|
||||
notes: str = ""
|
||||
variable_renames: Dict[str, str] = Field(default_factory=dict) # {original: custom}
|
||||
|
||||
|
||||
class JobStatus(BaseModel):
|
||||
job_id: str
|
||||
status: str # pending | downloading | converting | saving | done | error
|
||||
progress: int = 0 # 0-100
|
||||
message: str = ""
|
||||
dataset_id: Optional[str] = None # filled on done
|
||||
|
||||
|
||||
# ── Saved Datasets ────────────────────────────────────────────────────────────
|
||||
|
||||
class DatasetMeta(BaseModel):
|
||||
id: str
|
||||
nome: str
|
||||
tags: List[str] = []
|
||||
created_date: str
|
||||
created_by: str
|
||||
used_last_date: Optional[str] = None
|
||||
type: str # json | csv
|
||||
size: int
|
||||
notes: str = ""
|
||||
version: int = 1
|
||||
filename: str
|
||||
copernicus_dataset_id: str
|
||||
variables: List[str] = []
|
||||
bbox: List[float] = [] # [min_lon, min_lat, max_lon, max_lat]
|
||||
start_date: str
|
||||
end_date: str
|
||||
|
||||
|
||||
class DatasetListResponse(BaseModel):
|
||||
datasets: List[DatasetMeta]
|
||||
count: int
|
||||
581
copernicus/static/script.js
Normal file
581
copernicus/static/script.js
Normal file
@@ -0,0 +1,581 @@
|
||||
const MARINE_API = API_URL + '/marine';
|
||||
const MAPBOX_TOKEN = 'pk.eyJ1Ijoic2VzZWUzIiwiYSI6ImNtZ2dydndkMDBsNjUya3NjeW91dW41MzcifQ.M2qxj0wL1W7plRzIataojQ';
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
let selectedDatasetId = null;
|
||||
let selectedVariables = new Set();
|
||||
let datasetDateRange = { min: null, max: null };
|
||||
let tags = ['marine'];
|
||||
let currentBbox = null;
|
||||
let currentStep = 0;
|
||||
let map = null;
|
||||
let isDrawMode = false;
|
||||
let isDrawing = false;
|
||||
let drawStart = null;
|
||||
let pollInterval = null;
|
||||
const TOTAL_STEPS = 6;
|
||||
|
||||
// Variable renames: { originalName: customName }
|
||||
let variableRenames = {};
|
||||
let _currentRenaming = null;
|
||||
|
||||
// ── Init ───────────────────────────────────────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initMap();
|
||||
renderTags();
|
||||
setupTagInput();
|
||||
renderDots();
|
||||
showStep(0, false);
|
||||
|
||||
document.getElementById('catalogSearch').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') searchCatalog();
|
||||
});
|
||||
|
||||
document.getElementById('renameInput').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); saveRename(); }
|
||||
if (e.key === 'Escape') closeRenameModal();
|
||||
});
|
||||
|
||||
['startDate','endDate','datasetName','outputFormat'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.addEventListener('change', () => { markDone(); updateSummary(); refreshNext(); });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Stepper ────────────────────────────────────────────────────────────────
|
||||
function showStep(i, smooth = true) {
|
||||
const steps = Array.from(document.querySelectorAll('.step'));
|
||||
currentStep = Math.max(0, Math.min(i, TOTAL_STEPS - 1));
|
||||
|
||||
steps.forEach((el, idx) => {
|
||||
const active = idx === currentStep;
|
||||
el.classList.toggle('active', active);
|
||||
if (active) el.removeAttribute('disabled');
|
||||
else el.setAttribute('disabled', '');
|
||||
});
|
||||
|
||||
document.getElementById('prevBtn').disabled = currentStep === 0;
|
||||
document.getElementById('nextBtn').textContent = currentStep === TOTAL_STEPS - 1 ? 'Fine' : 'Prossimo';
|
||||
refreshNext();
|
||||
updateDots();
|
||||
updateSummary();
|
||||
markDone();
|
||||
|
||||
if (smooth) {
|
||||
const active = steps[currentStep];
|
||||
if (active) setTimeout(() => active.scrollIntoView({ behavior: 'smooth', block: 'center' }), 60);
|
||||
}
|
||||
}
|
||||
|
||||
function refreshNext() {
|
||||
document.getElementById('nextBtn').disabled = !canAdvance(currentStep);
|
||||
}
|
||||
|
||||
function nextStep() {
|
||||
if (!canAdvance(currentStep)) { showToast('Completa questo passo per continuare', 'error'); return; }
|
||||
if (currentStep < TOTAL_STEPS - 1) showStep(currentStep + 1);
|
||||
}
|
||||
|
||||
function prevStep() {
|
||||
if (currentStep > 0) showStep(currentStep - 1);
|
||||
}
|
||||
|
||||
function canAdvance(i) {
|
||||
switch (i) {
|
||||
case 0: return !!selectedDatasetId;
|
||||
case 1: return selectedVariables.size > 0;
|
||||
case 2: return !!currentBbox;
|
||||
case 3: {
|
||||
const s = document.getElementById('startDate').value;
|
||||
const e = document.getElementById('endDate').value;
|
||||
return !!(s && e && s <= e);
|
||||
}
|
||||
case 4: return !!document.getElementById('datasetName').value.trim();
|
||||
default: return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dots ───────────────────────────────────────────────────────────────────
|
||||
function renderDots() {
|
||||
const c = document.getElementById('progressDots');
|
||||
c.innerHTML = '';
|
||||
for (let i = 0; i < TOTAL_STEPS; i++) {
|
||||
const d = document.createElement('button');
|
||||
d.className = 'progress-dot';
|
||||
d.setAttribute('aria-label', `Passo ${i + 1}`);
|
||||
d.addEventListener('click', () => { if (i <= currentStep) showStep(i); });
|
||||
c.appendChild(d);
|
||||
}
|
||||
updateDots();
|
||||
}
|
||||
|
||||
function updateDots() {
|
||||
Array.from(document.getElementById('progressDots').children)
|
||||
.forEach((d, i) => d.classList.toggle('active', i === currentStep));
|
||||
}
|
||||
|
||||
// ── Done badges ────────────────────────────────────────────────────────────
|
||||
function markDone() {
|
||||
const steps = document.querySelectorAll('.step');
|
||||
const checks = [
|
||||
!!selectedDatasetId,
|
||||
selectedVariables.size > 0,
|
||||
!!currentBbox,
|
||||
canAdvance(3),
|
||||
!!document.getElementById('datasetName').value.trim(),
|
||||
false,
|
||||
];
|
||||
steps.forEach((el, i) => el.classList.toggle('done', checks[i] === true));
|
||||
}
|
||||
|
||||
// ── Catalog ────────────────────────────────────────────────────────────────
|
||||
async function searchCatalog() {
|
||||
const q = document.getElementById('catalogSearch').value.trim();
|
||||
const btn = document.getElementById('searchBtn');
|
||||
const box = document.getElementById('catalogResults');
|
||||
|
||||
box.innerHTML = '<div class="catalog-empty"><span class="spin"></span>Ricerca in corso...</div>';
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
const params = q ? `?search=${encodeURIComponent(q)}&limit=30` : '?limit=30';
|
||||
const res = await MEB_AUTH.fetch(`${MARINE_API}/catalog${params}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) throw new Error(data.detail || 'Errore catalogo');
|
||||
|
||||
if (!data.datasets?.length) {
|
||||
box.innerHTML = '<div class="catalog-empty">Nessun dataset trovato</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
box.innerHTML = '';
|
||||
data.datasets.forEach(ds => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'catalog-item';
|
||||
item.innerHTML = `<div class="ds-id">${ds.dataset_id}</div><div class="ds-title">${ds.title || ''}</div>`;
|
||||
item.addEventListener('click', () => selectDataset(ds, item));
|
||||
box.appendChild(item);
|
||||
});
|
||||
} catch (e) {
|
||||
box.innerHTML = `<div class="catalog-empty" style="color:var(--danger)">Errore: ${e.message}</div>`;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectDataset(ds, itemEl) {
|
||||
document.querySelectorAll('.catalog-item').forEach(i => i.classList.remove('selected'));
|
||||
itemEl.classList.add('selected');
|
||||
selectedDatasetId = ds.dataset_id;
|
||||
|
||||
const badge = document.getElementById('selectedDsBadge');
|
||||
badge.textContent = ds.dataset_id;
|
||||
badge.style.display = 'block';
|
||||
|
||||
// Reset dependent steps
|
||||
selectedVariables.clear();
|
||||
const vBox = document.getElementById('variablesContainer');
|
||||
vBox.innerHTML = '<span class="spin"></span><span style="color:var(--text-secondary);font-size:0.85rem;">Caricamento variabili...</span>';
|
||||
|
||||
try {
|
||||
const res = await MEB_AUTH.fetch(`${MARINE_API}/catalog/${encodeURIComponent(ds.dataset_id)}`);
|
||||
const info = await res.json();
|
||||
|
||||
renderVariables(info.variables || ds.variables || []);
|
||||
|
||||
if (info.min_longitude != null) {
|
||||
setBboxAndFit(info.min_longitude, info.max_longitude, info.min_latitude, info.max_latitude);
|
||||
}
|
||||
|
||||
if (info.start_datetime) {
|
||||
prefillDates(info.start_datetime, info.end_datetime);
|
||||
}
|
||||
} catch {
|
||||
renderVariables(ds.variables || []);
|
||||
}
|
||||
|
||||
markDone();
|
||||
refreshNext();
|
||||
// Auto-advance to variables step
|
||||
setTimeout(() => showStep(1), 700);
|
||||
}
|
||||
|
||||
// ── Variables ──────────────────────────────────────────────────────────────
|
||||
function renderVariables(vars) {
|
||||
const c = document.getElementById('variablesContainer');
|
||||
if (!vars?.length) {
|
||||
c.innerHTML = '<span style="color:var(--text-secondary);font-size:0.85rem;">Nessuna variabile disponibile</span>';
|
||||
updateVarCount();
|
||||
return;
|
||||
}
|
||||
|
||||
c.innerHTML = '';
|
||||
vars.forEach(v => {
|
||||
const name = typeof v === 'string' ? v : v.short_name;
|
||||
const desc = typeof v === 'object' ? (v.description || v.standard_name || '') : '';
|
||||
const units = typeof v === 'object' && v.units ? v.units : '';
|
||||
|
||||
const chip = document.createElement('div');
|
||||
chip.className = 'var-chip';
|
||||
chip.dataset.name = name;
|
||||
chip.innerHTML = `
|
||||
<span class="var-name">${esc(name)}</span>
|
||||
${desc ? `<span class="var-desc" title="${esc(desc)}">${esc(desc)}</span>` : ''}
|
||||
${units ? `<span class="var-units">[${esc(units)}]</span>` : ''}
|
||||
<button class="rename-btn" onclick="event.stopPropagation(); openRenameModal(this.closest('.var-chip').dataset.name)">✏ Rinomina</button>
|
||||
<span class="rename-badge">${variableRenames[name] ? '→ ' + esc(variableRenames[name]) : ''}</span>
|
||||
`;
|
||||
chip.addEventListener('click', () => toggleVar(chip, name));
|
||||
c.appendChild(chip);
|
||||
});
|
||||
|
||||
updateVarCount();
|
||||
}
|
||||
|
||||
function toggleVar(chip, name) {
|
||||
if (selectedVariables.has(name)) { selectedVariables.delete(name); chip.classList.remove('selected'); }
|
||||
else { selectedVariables.add(name); chip.classList.add('selected'); }
|
||||
updateVarCount(); markDone(); refreshNext();
|
||||
}
|
||||
|
||||
function updateVarCount() {
|
||||
const n = selectedVariables.size;
|
||||
document.getElementById('varCount').textContent =
|
||||
n === 0 ? 'Nessuna selezionata' : `${n} selezionat${n === 1 ? 'a' : 'e'}`;
|
||||
}
|
||||
|
||||
function selectAllVars() {
|
||||
document.querySelectorAll('.var-chip').forEach(chip => {
|
||||
chip.classList.add('selected');
|
||||
selectedVariables.add(chip.dataset.name);
|
||||
});
|
||||
updateVarCount(); markDone(); refreshNext();
|
||||
}
|
||||
|
||||
function deselectAllVars() {
|
||||
document.querySelectorAll('.var-chip').forEach(chip => chip.classList.remove('selected'));
|
||||
selectedVariables.clear();
|
||||
updateVarCount(); markDone(); refreshNext();
|
||||
}
|
||||
|
||||
// ── Dates ──────────────────────────────────────────────────────────────────
|
||||
function prefillDates(minDate, maxDate) {
|
||||
datasetDateRange = { min: minDate, max: maxDate };
|
||||
const s = document.getElementById('startDate');
|
||||
const e = document.getElementById('endDate');
|
||||
|
||||
if (minDate) { s.min = minDate; s.value = minDate; e.min = minDate; }
|
||||
if (maxDate) { e.max = maxDate; e.value = maxDate; s.max = maxDate; }
|
||||
|
||||
const hint = document.getElementById('dateRangeHint');
|
||||
if (minDate && maxDate) hint.textContent = `Dati disponibili: ${minDate} → ${maxDate}`;
|
||||
|
||||
markDone(); updateSummary();
|
||||
}
|
||||
|
||||
// ── Map ────────────────────────────────────────────────────────────────────
|
||||
function initMap() {
|
||||
mapboxgl.accessToken = MAPBOX_TOKEN;
|
||||
map = new mapboxgl.Map({
|
||||
container: 'mapContainer',
|
||||
style: 'mapbox://styles/mapbox/dark-v11',
|
||||
center: [14, 42], zoom: 3.5,
|
||||
attributionControl: false,
|
||||
});
|
||||
map.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right');
|
||||
map.addControl(new mapboxgl.AttributionControl({ compact: true }), 'bottom-right');
|
||||
|
||||
map.on('load', () => {
|
||||
map.addSource('bbox-rect', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
|
||||
map.addLayer({ id: 'bbox-fill', type: 'fill', source: 'bbox-rect', paint: { 'fill-color': '#00a7f5', 'fill-opacity': 0.13 } });
|
||||
map.addLayer({ id: 'bbox-line', type: 'line', source: 'bbox-rect', paint: { 'line-color': '#00a7f5', 'line-width': 2, 'line-dasharray': [4, 2] } });
|
||||
});
|
||||
|
||||
map.getCanvas().addEventListener('mousedown', onCanvasMouseDown, true);
|
||||
window.addEventListener('mousemove', onWindowMouseMove);
|
||||
window.addEventListener('mouseup', onWindowMouseUp);
|
||||
}
|
||||
|
||||
function startDraw() {
|
||||
if (!map) return;
|
||||
isDrawMode = true;
|
||||
map.dragPan.disable(); map.boxZoom.disable();
|
||||
document.getElementById('mapContainer').classList.add('draw-mode');
|
||||
const btn = document.getElementById('drawBtn');
|
||||
btn.textContent = 'Clicca e trascina…';
|
||||
btn.classList.replace('secondary', 'primary');
|
||||
}
|
||||
|
||||
function exitDrawMode() {
|
||||
isDrawMode = isDrawing = false; drawStart = null;
|
||||
map.dragPan.enable(); map.boxZoom.enable();
|
||||
document.getElementById('mapContainer').classList.remove('draw-mode', 'drawing');
|
||||
const btn = document.getElementById('drawBtn');
|
||||
btn.textContent = 'Disegna area';
|
||||
btn.classList.replace('primary', 'secondary');
|
||||
}
|
||||
|
||||
function onCanvasMouseDown(e) {
|
||||
if (!isDrawMode) return;
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
isDrawing = true;
|
||||
drawStart = map.unproject([e.offsetX, e.offsetY]);
|
||||
document.getElementById('mapContainer').classList.add('drawing');
|
||||
}
|
||||
|
||||
function onWindowMouseMove(e) {
|
||||
if (!isDrawing || !drawStart) return;
|
||||
const rc = map.getCanvas().getBoundingClientRect();
|
||||
_drawRect(drawStart, map.unproject([e.clientX - rc.left, e.clientY - rc.top]));
|
||||
}
|
||||
|
||||
function onWindowMouseUp(e) {
|
||||
if (!isDrawing || !drawStart) return;
|
||||
const rc = map.getCanvas().getBoundingClientRect();
|
||||
const end = map.unproject([e.clientX - rc.left, e.clientY - rc.top]);
|
||||
setBbox(
|
||||
Math.min(drawStart.lng, end.lng), Math.max(drawStart.lng, end.lng),
|
||||
Math.min(drawStart.lat, end.lat), Math.max(drawStart.lat, end.lat)
|
||||
);
|
||||
exitDrawMode();
|
||||
}
|
||||
|
||||
function _drawRect(a, b) {
|
||||
if (!map.getSource('bbox-rect')) return;
|
||||
map.getSource('bbox-rect').setData({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Polygon', coordinates: [[[a.lng,a.lat],[b.lng,a.lat],[b.lng,b.lat],[a.lng,b.lat],[a.lng,a.lat]]] },
|
||||
});
|
||||
}
|
||||
|
||||
function setBbox(minLon, maxLon, minLat, maxLat) {
|
||||
currentBbox = { minLon, maxLon, minLat, maxLat };
|
||||
document.getElementById('minLon').value = minLon.toFixed(4);
|
||||
document.getElementById('maxLon').value = maxLon.toFixed(4);
|
||||
document.getElementById('minLat').value = minLat.toFixed(4);
|
||||
document.getElementById('maxLat').value = maxLat.toFixed(4);
|
||||
document.getElementById('bboxReadout').textContent =
|
||||
`${minLon.toFixed(2)}°/${minLat.toFixed(2)}° → ${maxLon.toFixed(2)}°/${maxLat.toFixed(2)}°`;
|
||||
_drawRect({ lng: minLon, lat: minLat }, { lng: maxLon, lat: maxLat });
|
||||
markDone(); refreshNext();
|
||||
}
|
||||
|
||||
function setBboxAndFit(minLon, maxLon, minLat, maxLat) {
|
||||
const doIt = () => {
|
||||
setBbox(minLon, maxLon, minLat, maxLat);
|
||||
map.fitBounds([[minLon, minLat], [maxLon, maxLat]], { padding: 40, maxZoom: 10, duration: 600 });
|
||||
};
|
||||
if (!map) return;
|
||||
if (map.isStyleLoaded()) doIt(); else map.once('load', doIt);
|
||||
}
|
||||
|
||||
function clearBbox() {
|
||||
currentBbox = null;
|
||||
['minLon','maxLon','minLat','maxLat'].forEach(id => document.getElementById(id).value = '');
|
||||
document.getElementById('bboxReadout').textContent = '';
|
||||
if (map?.getSource('bbox-rect')) map.getSource('bbox-rect').setData({ type: 'FeatureCollection', features: [] });
|
||||
markDone(); refreshNext();
|
||||
}
|
||||
|
||||
// ── Tags ───────────────────────────────────────────────────────────────────
|
||||
function setupTagInput() {
|
||||
const inp = document.getElementById('tagInput');
|
||||
inp.addEventListener('keydown', e => {
|
||||
if ((e.key === 'Enter' || e.key === ',') && inp.value.trim()) {
|
||||
e.preventDefault();
|
||||
const t = inp.value.trim().replace(/,/g,'').toLowerCase();
|
||||
if (t && !tags.includes(t)) { tags.push(t); renderTags(); }
|
||||
inp.value = '';
|
||||
} else if (e.key === 'Backspace' && !inp.value && tags.length) {
|
||||
tags.pop(); renderTags();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderTags() {
|
||||
const wrap = document.getElementById('tagsWrap');
|
||||
const inp = document.getElementById('tagInput');
|
||||
wrap.innerHTML = '';
|
||||
tags.forEach(t => {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'tag-chip';
|
||||
chip.innerHTML = `${t} <span class="rm" onclick="removeTag('${t}')">×</span>`;
|
||||
wrap.appendChild(chip);
|
||||
});
|
||||
wrap.appendChild(inp);
|
||||
}
|
||||
|
||||
function removeTag(t) { tags = tags.filter(x => x !== t); renderTags(); }
|
||||
|
||||
// ── Download ───────────────────────────────────────────────────────────────
|
||||
async function startDownload() {
|
||||
if (!selectedDatasetId) return showToast('Seleziona un dataset', 'error');
|
||||
if (!selectedVariables.size) return showToast('Seleziona almeno una variabile', 'error');
|
||||
if (!currentBbox) return showToast("Disegna un'area sulla mappa", 'error');
|
||||
if (!document.getElementById('startDate').value ||
|
||||
!document.getElementById('endDate').value) return showToast('Inserisci le date', 'error');
|
||||
if (!document.getElementById('datasetName').value.trim()) return showToast('Inserisci un nome', 'error');
|
||||
|
||||
const body = {
|
||||
dataset_id: selectedDatasetId,
|
||||
variables: Array.from(selectedVariables),
|
||||
min_longitude: parseFloat(document.getElementById('minLon').value),
|
||||
max_longitude: parseFloat(document.getElementById('maxLon').value),
|
||||
min_latitude: parseFloat(document.getElementById('minLat').value),
|
||||
max_latitude: parseFloat(document.getElementById('maxLat').value),
|
||||
start_date: document.getElementById('startDate').value,
|
||||
end_date: document.getElementById('endDate').value,
|
||||
format: document.getElementById('outputFormat').value,
|
||||
nome: document.getElementById('datasetName').value.trim(),
|
||||
tags: [...tags],
|
||||
notes: document.getElementById('datasetNotes').value.trim(),
|
||||
variable_renames: { ...variableRenames },
|
||||
};
|
||||
|
||||
const btn = document.getElementById('downloadBtn');
|
||||
const prog = document.getElementById('downloadProgress');
|
||||
btn.disabled = true;
|
||||
prog.style.display = 'block';
|
||||
setProgress(0, 'Avvio download...');
|
||||
|
||||
try {
|
||||
const res = await MEB_AUTH.fetch(`${MARINE_API}/jobs`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || 'Errore avvio job');
|
||||
pollJob(data.job_id, btn, prog);
|
||||
} catch (e) {
|
||||
showToast(`Errore: ${e.message}`, 'error');
|
||||
btn.disabled = false;
|
||||
prog.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function pollJob(jobId, btn, prog) {
|
||||
clearInterval(pollInterval);
|
||||
pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const res = await MEB_AUTH.fetch(`${MARINE_API}/jobs/${jobId}`);
|
||||
const job = await res.json();
|
||||
setProgress(job.progress, job.message);
|
||||
|
||||
if (job.status === 'done') {
|
||||
clearInterval(pollInterval);
|
||||
btn.disabled = false;
|
||||
showToast('Dataset salvato con successo!', 'success');
|
||||
setTimeout(resetAll, 1800);
|
||||
} else if (job.status === 'error') {
|
||||
clearInterval(pollInterval);
|
||||
btn.disabled = false;
|
||||
showToast(`Errore: ${job.message}`, 'error');
|
||||
prog.style.display = 'none';
|
||||
}
|
||||
} catch { /* transient */ }
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function setProgress(pct, msg) {
|
||||
document.getElementById('progressFill').style.width = pct + '%';
|
||||
document.getElementById('progressMsg').textContent = msg;
|
||||
}
|
||||
|
||||
// ── Reset ──────────────────────────────────────────────────────────────────
|
||||
function resetAll() {
|
||||
selectedDatasetId = null;
|
||||
selectedVariables.clear();
|
||||
datasetDateRange = { min: null, max: null };
|
||||
variableRenames = {};
|
||||
tags = ['marine'];
|
||||
|
||||
['startDate','endDate','datasetName','datasetNotes'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) { el.value = ''; el.min = ''; el.max = ''; }
|
||||
});
|
||||
|
||||
document.getElementById('catalogResults').innerHTML =
|
||||
'<div class="catalog-empty">Cerca un dataset Copernicus per iniziare</div>';
|
||||
document.getElementById('selectedDsBadge').style.display = 'none';
|
||||
document.getElementById('variablesContainer').innerHTML =
|
||||
'<span style="color:var(--text-secondary);font-size:0.85rem;">Seleziona un dataset per vedere le variabili</span>';
|
||||
document.getElementById('dateRangeHint').textContent = '';
|
||||
document.getElementById('downloadProgress').style.display = 'none';
|
||||
|
||||
clearBbox();
|
||||
renderTags();
|
||||
showStep(0);
|
||||
}
|
||||
|
||||
// ── Summary ────────────────────────────────────────────────────────────────
|
||||
function updateSummary() {
|
||||
if (currentStep !== 5) return;
|
||||
const s = document.getElementById('startDate').value || '-';
|
||||
const e = document.getElementById('endDate').value || '-';
|
||||
const name = document.getElementById('datasetName').value || '-';
|
||||
const fmt = document.getElementById('outputFormat').value || '-';
|
||||
const vars = Array.from(selectedVariables).join(', ') || '-';
|
||||
const bbox = currentBbox
|
||||
? `${currentBbox.minLon.toFixed(2)},${currentBbox.minLat.toFixed(2)} → ${currentBbox.maxLon.toFixed(2)},${currentBbox.maxLat.toFixed(2)}`
|
||||
: '-';
|
||||
document.getElementById('summaryContent').innerHTML = `
|
||||
<div><strong>Dataset:</strong> ${esc(selectedDatasetId || '-')}</div>
|
||||
<div><strong>Variabili (${selectedVariables.size}):</strong> ${esc(vars)}</div>
|
||||
<div><strong>Area:</strong> ${esc(bbox)}</div>
|
||||
<div><strong>Periodo:</strong> ${esc(s)} → ${esc(e)}</div>
|
||||
<div><strong>Formato:</strong> ${esc(fmt)}</div>
|
||||
<div><strong>Nome:</strong> ${esc(name)}</div>
|
||||
<div><strong>Tags:</strong> ${esc(tags.join(', '))}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Rename modal ───────────────────────────────────────────────────────────
|
||||
function openRenameModal(varName) {
|
||||
_currentRenaming = varName;
|
||||
document.getElementById('renameVarLabel').textContent = varName;
|
||||
document.getElementById('renameInput').value = variableRenames[varName] || '';
|
||||
document.getElementById('renameDeleteBtn').style.display = variableRenames[varName] ? 'inline-flex' : 'none';
|
||||
document.getElementById('renameModal').classList.add('visible');
|
||||
setTimeout(() => document.getElementById('renameInput').select(), 50);
|
||||
}
|
||||
|
||||
function saveRename() {
|
||||
if (!_currentRenaming) return;
|
||||
const val = document.getElementById('renameInput').value.trim();
|
||||
if (!val) { deleteRename(); return; }
|
||||
variableRenames[_currentRenaming] = val;
|
||||
_updateRenameBadge(_currentRenaming, val);
|
||||
closeRenameModal();
|
||||
}
|
||||
|
||||
function deleteRename() {
|
||||
if (!_currentRenaming) return;
|
||||
delete variableRenames[_currentRenaming];
|
||||
_updateRenameBadge(_currentRenaming, '');
|
||||
closeRenameModal();
|
||||
}
|
||||
|
||||
function closeRenameModal() {
|
||||
document.getElementById('renameModal').classList.remove('visible');
|
||||
_currentRenaming = null;
|
||||
}
|
||||
|
||||
function _updateRenameBadge(varName, text) {
|
||||
const chip = [...document.querySelectorAll('.var-chip')].find(c => c.dataset.name === varName);
|
||||
if (!chip) return;
|
||||
chip.querySelector('.rename-badge').textContent = text ? '→ ' + text : '';
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
function esc(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
function showToast(msg, type = 'success') {
|
||||
const t = document.getElementById('toast');
|
||||
t.className = `${type} show`;
|
||||
t.textContent = msg;
|
||||
setTimeout(() => t.classList.remove('show'), 3500);
|
||||
}
|
||||
0
copernicus/static/style.css
Normal file
0
copernicus/static/style.css
Normal file
163
copernicus/templates/coprncs.html
Normal file
163
copernicus/templates/coprncs.html
Normal file
@@ -0,0 +1,163 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<title>Copernicus Marine</title>
|
||||
<link href="https://api.mapbox.com/mapbox-gl-js/v3.3.0/mapbox-gl.css" rel="stylesheet">
|
||||
<script src="https://api.mapbox.com/mapbox-gl-js/v3.3.0/mapbox-gl.js"></script>
|
||||
|
||||
<link rel="stylesheet" href="../static/style.css">
|
||||
<script src="../static/script.js"></script>
|
||||
</head>
|
||||
|
||||
|
||||
<body>
|
||||
<div class="marine-body">
|
||||
|
||||
<div class="page-actions">
|
||||
<a href="/datasets">Dataset salvati →</a>
|
||||
</div>
|
||||
|
||||
<!-- Step 1 -->
|
||||
<div class="step" id="step-1">
|
||||
<div class="step-header">
|
||||
<span class="step-num">1</span>
|
||||
<h2 class="step-title">Cerca un dataset</h2>
|
||||
<span class="step-done-badge">✓ Selezionato</span>
|
||||
</div>
|
||||
<div class="search-bar">
|
||||
<input type="text" id="catalogSearch" placeholder="Es. Mediterranean Sea, Physical Oceanography ...">
|
||||
<button class="btn secondary" id="searchBtn" onclick="searchCatalog()">Cerca</button>
|
||||
</div>
|
||||
<div id="catalogResults">
|
||||
<div class="catalog-empty"></div>
|
||||
</div>
|
||||
<div class="selected-ds-badge" id="selectedDsBadge"></div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2 -->
|
||||
<div class="step" id="step-2">
|
||||
<div class="step-header">
|
||||
<span class="step-num">2</span>
|
||||
<h2 class="step-title">Variabili</h2>
|
||||
<span class="step-done-badge">✓ Selezionate</span>
|
||||
</div>
|
||||
<div class="var-toolbar">
|
||||
<span id="varCount">Nessuna selezionata</span>
|
||||
<!-- <button onclick="selectAllVars()">Seleziona tutte</button>
|
||||
<button onclick="deselectAllVars()">Deseleziona tutte</button> -->
|
||||
</div>
|
||||
<div id="variablesContainer">
|
||||
<span style="color:var(--text-secondary);font-size:0.85rem;"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3 -->
|
||||
<div class="step" id="step-3">
|
||||
<div class="step-header">
|
||||
<span class="step-num">3</span>
|
||||
<h2 class="step-title">Area</h2>
|
||||
<span class="step-done-badge">✓ Impostata</span>
|
||||
</div>
|
||||
<div id="mapContainer"></div>
|
||||
<div class="map-toolbar">
|
||||
<button type="button" class="btn secondary small" id="drawBtn" onclick="startDraw()">Disegna
|
||||
area</button>
|
||||
<button type="button" class="btn secondary small" onclick="clearBbox()">Cancella</button>
|
||||
<span id="bboxReadout" class="bbox-readout"></span>
|
||||
</div>
|
||||
<input type="hidden" id="minLon">
|
||||
<input type="hidden" id="maxLon">
|
||||
<input type="hidden" id="minLat">
|
||||
<input type="hidden" id="maxLat">
|
||||
</div>
|
||||
|
||||
<!-- Step 4 -->
|
||||
<div class="step" id="step-4">
|
||||
<div class="step-header">
|
||||
<span class="step-num">4</span>
|
||||
<h2 class="step-title">Finestra temporale</h2>
|
||||
<span class="step-done-badge"></span>
|
||||
</div>
|
||||
<div class="form-row form-group">
|
||||
<input type="date" id="startDate">
|
||||
<input type="date" id="endDate">
|
||||
</div>
|
||||
<div class="field-hint" id="dateRangeHint"></div>
|
||||
</div>
|
||||
|
||||
<!-- Step 5 -->
|
||||
<div class="step" id="step-5">
|
||||
<div class="step-header">
|
||||
<span class="step-num">5</span>
|
||||
<h2 class="step-title">Dettagli</h2>
|
||||
<span class="step-done-badge">Completo</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<select id="outputFormat">
|
||||
<option value="json">JSON</option>
|
||||
<option value="csv">CSV</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="text" id="datasetName" placeholder="Nome">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="tags-input-wrap" id="tagsWrap" onclick="document.getElementById('tagInput').focus()">
|
||||
<input type="text" id="tagInput" placeholder="Tag (Invio per aggiungere)">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<textarea id="datasetNotes" rows="2" placeholder="Aggiungi ulteriori dettagli (opzionale)"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 6 -->
|
||||
<div class="step" id="step-6">
|
||||
<div class="step-header">
|
||||
<span class="step-num">6</span>
|
||||
<h2 class="step-title">Scarica</h2>
|
||||
</div>
|
||||
<div id="summaryContent"></div>
|
||||
<div style="display:flex;gap:0.75rem;align-items:center;">
|
||||
<button class="btn primary" id="downloadBtn" onclick="startDownload()">Avvia download</button>
|
||||
<button class="btn secondary" onclick="resetAll()">Reset</button>
|
||||
</div>
|
||||
<div id="downloadProgress">
|
||||
<div class="progress-bar-wrap">
|
||||
<div class="progress-bar-fill" id="progressFill"></div>
|
||||
</div>
|
||||
<div class="progress-msg" id="progressMsg"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Toolbar fissa -->
|
||||
<div class="step-toolbar">
|
||||
<div class="progress-dots" id="progressDots"></div>
|
||||
<div class="step-actions">
|
||||
<button class="btn secondary" id="prevBtn" onclick="prevStep()">Indietro</button>
|
||||
<button class="btn primary" id="nextBtn" onclick="nextStep()">Prossimo</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast"></div>
|
||||
|
||||
<!-- Rename variable modal -->
|
||||
<div id="renameModal">
|
||||
<div id="renameBackdrop" onclick="closeRenameModal()"></div>
|
||||
<div id="renameDialog">
|
||||
<h4>Rinomina variabile</h4>
|
||||
<div id="renameVarLabel"></div>
|
||||
<input type="text" id="renameInput" placeholder="Nome personalizzato">
|
||||
<div class="rename-actions">
|
||||
<button class="btn primary" style="flex:1;" onclick="saveRename()">Salva</button>
|
||||
<button class="btn secondary" id="renameDeleteBtn" onclick="deleteRename()">Elimina</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Reference in New Issue
Block a user