Initial commit
This commit is contained in:
commit
b22231c8b6
40 changed files with 2443 additions and 0 deletions
57
frontend/api_client.py
Normal file
57
frontend/api_client.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# api_client.py - httpx async
|
||||
|
||||
import httpx
|
||||
from config import BACKEND_URL
|
||||
|
||||
TIMEOUT_HEALTH = 5
|
||||
TIMEOUT_UPLOAD = 3600
|
||||
TIMEOUT_DEFAULT = 15
|
||||
|
||||
|
||||
async def check_health() -> dict:
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.get(f"{BACKEND_URL}/health", timeout=TIMEOUT_HEALTH)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
async def upload_file(filename: str, data: bytes) -> dict:
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.post(
|
||||
f"{BACKEND_URL}/upload",
|
||||
files={"file": (filename, data)},
|
||||
timeout=TIMEOUT_UPLOAD,
|
||||
)
|
||||
if r.status_code != 200:
|
||||
raise RuntimeError(f"Erreur backend ({r.status_code}) : {r.text}")
|
||||
return r.json()
|
||||
|
||||
|
||||
async def get_debug(pc_id: str) -> dict:
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.get(
|
||||
f"{BACKEND_URL}/debug/{pc_id}", timeout=TIMEOUT_DEFAULT
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
async def delete_pointcloud(pc_id: str) -> dict:
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.delete(
|
||||
f"{BACKEND_URL}/delete/{pc_id}", timeout=TIMEOUT_DEFAULT
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
async def crop_pointcloud(pc_id: str, payload: dict) -> dict:
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.post(
|
||||
f"{BACKEND_URL}/crop/{pc_id}",
|
||||
json=payload,
|
||||
timeout=TIMEOUT_UPLOAD,
|
||||
)
|
||||
if r.status_code != 200:
|
||||
raise RuntimeError(f"Erreur crop ({r.status_code}) : {r.text}")
|
||||
return r.json()
|
||||
14
frontend/components/Button.html
Normal file
14
frontend/components/Button.html
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<button
|
||||
type="{{ type or 'button' }}"
|
||||
class="btn {{ class_extra or '' }}"
|
||||
{% if onclick %}onclick="{{ onclick }}"{% endif %}
|
||||
{% if hx_get %}hx-get="{{ hx_get }}"{% endif %}
|
||||
{% if hx_post %}hx-post="{{ hx_post }}"{% endif %}
|
||||
{% if hx_put %}hx-put="{{ hx_put }}"{% endif %}
|
||||
{% if hx_delete %}hx-delete="{{ hx_delete }}"{% endif %}
|
||||
{% if hx_target %}hx-target="{{ hx_target }}"{% endif %}
|
||||
{% if hx_swap %}hx-swap="{{ hx_swap }}"{% endif %}
|
||||
{% if disabled %}disabled{% endif %}
|
||||
>
|
||||
{{ label }}
|
||||
</button>
|
||||
8
frontend/components/Card.html
Normal file
8
frontend/components/Card.html
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<div class="card bg-base-100 shadow {{ class_extra }}">
|
||||
<div class="card-body">
|
||||
{% if title %}
|
||||
<h2 class="card-title text-base mb-2">{{ title }}</h2>
|
||||
{% endif %}
|
||||
{{ content }}
|
||||
</div>
|
||||
</div>
|
||||
25
frontend/components/CloudRow.html
Normal file
25
frontend/components/CloudRow.html
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="avatar">
|
||||
<div class="w-10 rounded-full">
|
||||
<img src="{{ cloud_url }}" alt="{{ cloud_name }}">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium">{{ cloud_name }}</h3>
|
||||
<p class="text-sm text-base-content/60">{{ cloud_path }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ cloud_url }}" class="btn btn-sm btn-ghost" target="_blank">
|
||||
📂 Ouvrir
|
||||
</a>
|
||||
<a href="{{ cloud_url }}/viewer" class="btn btn-sm">
|
||||
👁️ Visualiser
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
17
frontend/components/Layout.html
Normal file
17
frontend/components/Layout.html
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ title }}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.10/dist/full.min.css" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||
</head>
|
||||
<body class="bg-base-200 min-h-screen">
|
||||
<Navbar active_tab="{{ active_tab }}" />
|
||||
<div class="container mx-auto px-4 mt-6 max-w-7xl pb-10">
|
||||
{{ content }}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
35
frontend/components/Navbar.html
Normal file
35
frontend/components/Navbar.html
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<div class="navbar bg-base-100 shadow-md px-6 mb-2">
|
||||
<div class="flex-1">
|
||||
<span class="text-xl font-bold tracking-tight">☁️ PointCloud Viewer</span>
|
||||
</div>
|
||||
<div class="flex-none gap-4 items-center">
|
||||
<div
|
||||
id="health-indicator"
|
||||
hx-get="/health-check"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML"
|
||||
class="text-sm text-base-content/50"
|
||||
>vérification…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div role="tablist" class="tabs tabs-boxed mb-6 w-fit ml-4">
|
||||
<a
|
||||
role="tab"
|
||||
class="tab {% if active_tab == 'upload' %}tab-active{% endif %}"
|
||||
hx-get="/"
|
||||
hx-target="#main-content"
|
||||
hx-push-url="/"
|
||||
>📤 Upload</a>
|
||||
<a
|
||||
role="tab"
|
||||
class="tab {% if active_tab == 'admin' %}tab-active{% endif %}"
|
||||
hx-get="/viewer/list"
|
||||
hx-target="#main-content"
|
||||
hx-push-url="/viewer"
|
||||
>🗂️ Admin</a>
|
||||
</div>
|
||||
|
||||
<div id="main-content" class="px-4">
|
||||
{{ content }}
|
||||
</div>
|
||||
1
frontend/components/Spinner.html
Normal file
1
frontend/components/Spinner.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<div id="{{ id }}" class="loading loading-spinner loading-sm"></div>
|
||||
31
frontend/config.py
Normal file
31
frontend/config.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# URL du backend - configurée uniquement via variable d'environnement BACKEND_URL
|
||||
# Pour le développement local : BACKEND_URL=http://localhost:8091
|
||||
# Pour la production : BACKEND_URL=http://backend_entwine:8000
|
||||
|
||||
# URL Potree - configurée via variable d'environnement POTREE_URL
|
||||
# Pour le développement local : POTREE_URL=http://localhost:8090
|
||||
# Pour la production : POTREE_URL=http://potree_server:8090
|
||||
|
||||
def load_backend_config():
|
||||
"""Charge la configuration du backend depuis les variables d'environnement."""
|
||||
return os.getenv(
|
||||
"BACKEND_URL", "http://localhost:8091"
|
||||
).strip().rstrip("/")
|
||||
|
||||
def load_potree_config():
|
||||
"""Charge la configuration Potree depuis les variables d'environnement."""
|
||||
return os.getenv(
|
||||
"POTREE_URL", "http://localhost:8090"
|
||||
).strip().rstrip("/")
|
||||
|
||||
BACKEND_URL = load_backend_config()
|
||||
POTREE_URL = load_potree_config()
|
||||
|
||||
SUPPORTED_FORMATS = [".las", ".laz", ".ply", ".xyz", ".pts"]
|
||||
SUPPORTED_EXTENSIONS = SUPPORTED_FORMATS # Alias pour compatibilité
|
||||
|
||||
# Chemin du dossier EPT (nuages de points convertis)
|
||||
EPT_DIR = Path("/app/backend/data/ept")
|
||||
54
frontend/main.py
Normal file
54
frontend/main.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# main.py - FastAPI + Jinja2
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
from routes import upload, viewer, admin, crop
|
||||
|
||||
# -- Middleware ------------------------------------------------
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
|
||||
app = FastAPI(title="PointCloud Frontend")
|
||||
|
||||
MAX_UPLOAD_BYTES = 10 * 1024 * 1024 * 1024 # 10 GB à ajuster
|
||||
|
||||
class LimitUploadSize(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
if request.method == "POST":
|
||||
content_length = request.headers.get("content-length")
|
||||
if content_length and int(content_length) > MAX_UPLOAD_BYTES:
|
||||
return Response(
|
||||
content=f"Fichier trop volumineux. Maximum : {MAX_UPLOAD_BYTES // (1024**3)} GB",
|
||||
status_code=413,
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
app.add_middleware(LimitUploadSize)
|
||||
|
||||
# ── Fichiers statiques ────────────────────────────────────────────────────────
|
||||
app.mount(
|
||||
"/static",
|
||||
StaticFiles(directory=Path(__file__).parent / "static"),
|
||||
name="static",
|
||||
)
|
||||
|
||||
# ── Jinja2 pour les pages et partials ────────────────────────────────────────
|
||||
templates = Jinja2Templates(directory=Path(__file__).parent / "templates")
|
||||
templates.env.filters["datetimeformat"] = lambda ts: (
|
||||
datetime.fromtimestamp(int(ts)).strftime("%Y-%m-%d %H:%M") if ts else "—"
|
||||
)
|
||||
|
||||
# Rend les templates accessibles aux routes via app.state
|
||||
app.state.templates = templates
|
||||
|
||||
# ── Routes ────────────────────────────────────────────────────────────────────
|
||||
app.include_router(upload.router)
|
||||
app.include_router(viewer.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(crop.router)
|
||||
4
frontend/routes/__init__.py
Normal file
4
frontend/routes/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from . import upload
|
||||
from . import viewer
|
||||
from . import admin
|
||||
from . import crop
|
||||
131
frontend/routes/admin.py
Normal file
131
frontend/routes/admin.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
from fastapi import APIRouter, Request, HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
import api_client
|
||||
import config
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/backend-config", response_class=HTMLResponse)
|
||||
async def backend_config(request: Request):
|
||||
"""Affiche le formulaire de configuration du backend"""
|
||||
current_backend_url = config.BACKEND_URL
|
||||
current_potree_url = config.POTREE_URL
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
"partials/backend_config.html",
|
||||
{"request": request, "current_backend_url": current_backend_url, "current_potree_url": current_potree_url},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/backend-config", response_class=HTMLResponse)
|
||||
async def save_backend_config(request: Request):
|
||||
"""Sauvegarde les nouvelles URLs du backend et Potree"""
|
||||
form_data = await request.form()
|
||||
new_backend_url = form_data.get("backend_url", "").strip().rstrip("/")
|
||||
new_potree_url = form_data.get("potree_url", "").strip().rstrip("/")
|
||||
|
||||
if not new_backend_url:
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
"partials/backend_config.html",
|
||||
{"request": request, "current_backend_url": config.BACKEND_URL, "current_potree_url": config.POTREE_URL, "error": "URL du backend vide"},
|
||||
)
|
||||
|
||||
# Sauvegarder dans le fichier JSON
|
||||
config_file = Path(__file__).parent.parent / "config" / "backend.json"
|
||||
config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
config_data = {}
|
||||
if new_backend_url:
|
||||
config_data["backend_url"] = new_backend_url
|
||||
if new_potree_url:
|
||||
config_data["potree_url"] = new_potree_url
|
||||
|
||||
with open(config_file, "w") as f:
|
||||
json.dump(config_data, f, indent=2)
|
||||
|
||||
# Mettre à jour les variables module
|
||||
config.BACKEND_URL = new_backend_url
|
||||
config.POTREE_URL = new_potree_url
|
||||
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
"partials/backend_config.html",
|
||||
{"request": request, "current_backend_url": new_backend_url, "current_potree_url": new_potree_url, "success": True},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/list", response_class=HTMLResponse)
|
||||
async def admin_list(request: Request):
|
||||
"""Affiche la liste de tous les nuages de points"""
|
||||
pointclouds = await api_client.list_pointclouds()
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
"partials/cloud_list.html",
|
||||
{"request": request, "pointclouds": pointclouds},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/debug/{pc_id}", response_class=HTMLResponse)
|
||||
async def admin_debug(request: Request, pc_id: str):
|
||||
"""Affiche les informations de debug pour un nuage"""
|
||||
debug_info = await api_client.get_debug(pc_id)
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
"partials/debug_panel.html",
|
||||
{"request": request, "pc_id": pc_id, "data": debug_info},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/delete/{pc_id}", response_class=HTMLResponse)
|
||||
async def admin_delete(request: Request, pc_id: str):
|
||||
"""Supprime un nuage de points"""
|
||||
try:
|
||||
result = await api_client.delete_pointcloud(pc_id)
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
"partials/upload_result.html",
|
||||
{"request": request, "result": {
|
||||
"id": pc_id,
|
||||
"filename": f"{pc_id}.las",
|
||||
"size_mb": 0,
|
||||
"conversion_time_seconds": 0,
|
||||
}, "error": None},
|
||||
)
|
||||
except Exception as e:
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
"partials/upload_result.html",
|
||||
{"request": request, "error": str(e), "result": None},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/crop/{pc_id}", response_class=HTMLResponse)
|
||||
async def admin_crop(request: Request, pc_id: str, box: dict):
|
||||
"""
|
||||
Crop le nuage de points avec PDAL.
|
||||
|
||||
Args:
|
||||
pc_id: ID du nuage de points à cropper
|
||||
box: dict avec les coordonnées de la box 3D
|
||||
{"minX", "minY", "minZ", "maxX", "maxY", "maxZ"}
|
||||
"""
|
||||
try:
|
||||
result = await api_client.crop_pointcloud(pc_id, box)
|
||||
|
||||
if result.get("ok"):
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
"partials/upload_result.html",
|
||||
{"request": request, "result": {
|
||||
"id": result.get("id"),
|
||||
"filename": f"{pc_id}_cropped.las",
|
||||
"size_mb": result.get("size_mb", 0),
|
||||
"conversion_time_seconds": result.get("conversion_time_seconds", 0),
|
||||
}, "error": None},
|
||||
)
|
||||
else:
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
"partials/upload_result.html",
|
||||
{"request": request, "error": result.get("detail", "Erreur inconnue"), "result": None},
|
||||
)
|
||||
except Exception as e:
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
"partials/upload_result.html",
|
||||
{"request": request, "error": str(e), "result": None},
|
||||
)
|
||||
51
frontend/routes/crop.py
Normal file
51
frontend/routes/crop.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
from fastapi import APIRouter, Request, HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
import api_client
|
||||
import config
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/crop", response_class=HTMLResponse)
|
||||
async def crop_ui(request: Request, pc_id: str):
|
||||
"""Interface utilisateur pour le crop du nuage de points"""
|
||||
embed_url = f"{config.BACKEND_URL}/viewer-embed/{pc_id}"
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
"partials/crop.html",
|
||||
{"request": request, "pc_id": pc_id, "embed_url": embed_url},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/crop", response_class=HTMLResponse)
|
||||
async def crop(request: Request, pc_id: str, box: dict):
|
||||
"""
|
||||
Traite la requête de crop :
|
||||
1. Envoie la box 3D au backend
|
||||
2. Le backend utilise PDAL pour cropper le nuage
|
||||
3. Retourne le nouveau pc_id pour l'affichage
|
||||
"""
|
||||
try:
|
||||
# Envoi de la requête de crop au backend
|
||||
result = await api_client.crop_pointcloud(pc_id, box)
|
||||
|
||||
if result.get("ok"):
|
||||
new_pc_id = result.get("id")
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
"partials/upload_result.html",
|
||||
{"request": request, "result": {
|
||||
"id": new_pc_id,
|
||||
"filename": f"{pc_id}_cropped.las",
|
||||
"size_mb": result.get("size_mb", 0),
|
||||
"conversion_time_seconds": result.get("conversion_time_seconds", 0),
|
||||
}, "error": None},
|
||||
)
|
||||
else:
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
"partials/upload_result.html",
|
||||
{"request": request, "error": result.get("detail", "Erreur inconnue"), "result": None},
|
||||
)
|
||||
except Exception as e:
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
"partials/upload_result.html",
|
||||
{"request": request, "error": str(e), "result": None},
|
||||
)
|
||||
59
frontend/routes/upload.py
Normal file
59
frontend/routes/upload.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# routes/upload.py
|
||||
|
||||
from fastapi import APIRouter, Request, UploadFile, File, HTTPException
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from pathlib import Path
|
||||
import api_client
|
||||
import config
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_class=RedirectResponse)
|
||||
async def root():
|
||||
"""Redirige / vers /upload"""
|
||||
return RedirectResponse(url="/upload")
|
||||
|
||||
|
||||
@router.get("/upload", response_class=HTMLResponse)
|
||||
async def index(request: Request):
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
"index.html",
|
||||
{"request": request, "active_tab": "upload"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/health-check", response_class=HTMLResponse)
|
||||
async def health_check(request: Request):
|
||||
try:
|
||||
data = await api_client.check_health()
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
"partials/health_status.html",
|
||||
{"request": request, "ok": True, "entwine_available": data.get("entwine_available", False), "disk_free_gb": data.get("disk_free_gb", "?")},
|
||||
)
|
||||
except Exception as e:
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
"partials/health_status.html",
|
||||
{"request": request, "ok": False, "error": str(e)},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/upload", response_class=HTMLResponse)
|
||||
async def upload(request: Request, file: UploadFile = File(...)):
|
||||
suffix = Path(file.filename).suffix.lower()
|
||||
if suffix not in config.SUPPORTED_EXTENSIONS:
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
"partials/upload_result.html",
|
||||
{"request": request, "error": f"Format non supporté : {suffix}. Formats acceptés : {', '.join(config.SUPPORTED_EXTENSIONS)}", "result": None},
|
||||
)
|
||||
try:
|
||||
data = await api_client.upload_file(file.filename, await file.read())
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
"partials/upload_result.html",
|
||||
{"request": request, "result": data, "error": None},
|
||||
)
|
||||
except Exception as e:
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
"partials/upload_result.html",
|
||||
{"request": request, "error": str(e), "result": None},
|
||||
)
|
||||
61
frontend/routes/viewer.py
Normal file
61
frontend/routes/viewer.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
import config
|
||||
import api_client
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/viewer/list", response_class=HTMLResponse)
|
||||
async def viewer_list(request: Request):
|
||||
"""Liste les nuages de points disponibles - Endpoint autonome frontend"""
|
||||
pointclouds = []
|
||||
|
||||
# Récupérer la liste des nuages directement depuis le système de fichiers
|
||||
ept_dir = Path(config.EPT_DIR)
|
||||
if ept_dir.exists():
|
||||
for item in sorted(ept_dir.iterdir(), key=lambda x: x.stat().st_ctime, reverse=True):
|
||||
if item.is_dir():
|
||||
# Lire le manifeste
|
||||
manifest_path = item / "manifest.json"
|
||||
manifest = {}
|
||||
if manifest_path.exists():
|
||||
try:
|
||||
manifest = {"ept_dir": item.name}
|
||||
except:
|
||||
pass
|
||||
|
||||
# Calculer la taille et le nombre de fichiers
|
||||
total_size = 0
|
||||
file_count = 0
|
||||
for f in item.rglob("*"):
|
||||
if f.is_file():
|
||||
total_size += f.stat().st_size
|
||||
file_count += 1
|
||||
|
||||
if file_count > 0:
|
||||
pointclouds.append({
|
||||
"id": item.name,
|
||||
"size_mb": round(total_size / (1024 * 1024), 2),
|
||||
"file_count": file_count,
|
||||
"manifest": manifest,
|
||||
"created": item.stat().st_ctime,
|
||||
})
|
||||
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
"partials/cloud_list.html",
|
||||
{"request": request, "pointclouds": pointclouds},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/viewer/{pc_id}", response_class=HTMLResponse)
|
||||
async def viewer(request: Request, pc_id: str):
|
||||
embed_url = f"{config.POTREE_URL}/viewer-embed/{pc_id}"
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
"partials/viewer.html",
|
||||
{"request": request, "pc_id": pc_id, "embed_url": embed_url},
|
||||
)
|
||||
119
frontend/templates/index.html
Normal file
119
frontend/templates/index.html
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PointCloud Viewer</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.10/dist/full.min.css" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js"></script>
|
||||
</head>
|
||||
<body class="bg-base-200 min-h-screen">
|
||||
<!-- Navbar -->
|
||||
<div class="navbar bg-base-100 shadow-md px-6 mb-2">
|
||||
<div class="flex-1">
|
||||
<span class="text-xl font-bold tracking-tight">☁️ PointCloud Viewer</span>
|
||||
</div>
|
||||
<div class="flex-none gap-4 items-center">
|
||||
<div
|
||||
id="health-indicator"
|
||||
hx-get="/health-check"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML"
|
||||
class="text-sm text-base-content/50"
|
||||
>vérification…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div role="tablist" class="tabs tabs-boxed mb-6 w-fit ml-4">
|
||||
<a
|
||||
role="tab"
|
||||
class="tab {% if active_tab == 'upload' %}tab-active{% endif %}"
|
||||
hx-get="/upload"
|
||||
hx-target="#main-content"
|
||||
hx-push-url="/"
|
||||
>📤 Upload</a>
|
||||
<a
|
||||
role="tab"
|
||||
class="tab {% if active_tab == 'admin' %}tab-active{% endif %}"
|
||||
hx-get="/viewer/list"
|
||||
hx-target="#main-content"
|
||||
hx-push-url="/viewer"
|
||||
>🗂️ Admin</a>
|
||||
</div>
|
||||
|
||||
<div id="main-content" class="container mx-auto px-4 mt-6 max-w-7xl pb-10">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
<!-- Colonne gauche : upload -->
|
||||
<div class="lg:col-span-1 flex flex-col gap-4">
|
||||
<!-- Configuration Backend -->
|
||||
<div id="backend-config-panel" hx-get="/admin/backend-config" hx-target="#backend-config-panel" hx-swap="innerHTML">
|
||||
{% include "partials/backend_config.html" %}
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base mb-2">📤 Upload</h2>
|
||||
<p class="text-sm text-base-content/60 mb-3">
|
||||
Formats acceptés : LAS, LAZ, PLY, XYZ, PTS
|
||||
</p>
|
||||
<form
|
||||
hx-post="/upload"
|
||||
hx-target="#upload-result"
|
||||
hx-swap="innerHTML"
|
||||
hx-encoding="multipart/form-data"
|
||||
hx-indicator="#upload-spinner"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
name="file"
|
||||
accept=".las,.laz,.ply,.xyz,.pts"
|
||||
class="file-input file-input-bordered w-full mb-4"
|
||||
required
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn"
|
||||
hx_indicator="#upload-spinner"
|
||||
>
|
||||
📤 Uploader & convertir
|
||||
</button>
|
||||
<div id="upload-spinner" class="loading loading-spinner loading-sm"></div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="upload-result"></div>
|
||||
</div>
|
||||
|
||||
<!-- Colonne droite : viewer -->
|
||||
<div class="lg:col-span-2">
|
||||
<div
|
||||
id="viewer-container"
|
||||
class="card bg-base-100 shadow min-h-[600px] flex items-center justify-center"
|
||||
>
|
||||
<p class="text-base-content/40 text-sm">
|
||||
Uploadez un fichier pour lancer la visualisation
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
id="viewer-panel"
|
||||
class="card bg-base-100 shadow mt-4"
|
||||
hx-get="/viewer/list"
|
||||
hx-target="#viewer-panel"
|
||||
hx-swap="innerHTML"
|
||||
hx-trigger="load"
|
||||
>
|
||||
{% include "partials/cloud_list.html" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
71
frontend/templates/partials/backend_config.html
Normal file
71
frontend/templates/partials/backend_config.html
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<div class="card bg-base-200 shadow">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title">
|
||||
<span class="badge badge-ghost">⚙️</span>
|
||||
Configuration Backend
|
||||
</h3>
|
||||
|
||||
{% if success %}
|
||||
<div class="alert alert-success mb-4">
|
||||
<span>✓ Configuration sauvegardée !</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error mb-4">
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- Configuration Backend URL -->
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text">URL du Backend</span>
|
||||
<span class="label-text-alt text-xs">
|
||||
Pour les appels API
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="backend_url"
|
||||
value="{{ current_backend_url or 'http://localhost:8091' }}"
|
||||
class="input input-bordered"
|
||||
placeholder="http://localhost:8091"
|
||||
>
|
||||
<span class="label-text-alt text-xs">
|
||||
Ex: http://localhost:8091 ou http://backend_entwine:8000
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Configuration Potree URL -->
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text">URL Potree</span>
|
||||
<span class="label-text-alt text-xs">
|
||||
Pour charger le viewer 3D
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="potree_url"
|
||||
value="{{ current_potree_url or 'http://localhost:8090' }}"
|
||||
class="input input-bordered"
|
||||
placeholder="http://localhost:8090"
|
||||
>
|
||||
<span class="label-text-alt text-xs">
|
||||
Ex: http://localhost:8090 ou http://potree_server:8090
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-end mt-4">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary btn-sm"
|
||||
>
|
||||
💾 Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
60
frontend/templates/partials/cloud_list.html
Normal file
60
frontend/templates/partials/cloud_list.html
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base mb-2">🗂️ Nuages de points</h2>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost"
|
||||
hx-get="/viewer/list"
|
||||
hx-target="#viewer-panel"
|
||||
hx-trigger="click"
|
||||
>
|
||||
🔄 Actualiser
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error">
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
{% elif not pointclouds %}
|
||||
<p class="text-base-content/40 text-sm text-center py-8">
|
||||
Aucun nuage disponible sur le serveur.
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="text-xs text-base-content/40 mb-3">{{ pointclouds|length }} nuage(s)</p>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Taille</th>
|
||||
<th>Fichiers</th>
|
||||
<th>Créé le</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="cloud-table-body">
|
||||
{% for pc in pointclouds %}
|
||||
<tr>
|
||||
<td>{{ pc.id }}</td>
|
||||
<td>{{ pc.size_mb }} MB</td>
|
||||
<td>{{ pc.file_count }}</td>
|
||||
<td>{{ pc.created|datetimeformat }}</td>
|
||||
<td>
|
||||
<a href="/admin/debug/{{ pc.id }}" class="btn btn-sm btn-ghost">🔍</a>
|
||||
<a href="/viewer/{{ pc.id }}" class="btn btn-sm" target="_blank">👁️</a>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-error"
|
||||
onclick="if(confirm('Supprimer ce nuage ?')) window.location.href='/admin/delete/{{ pc.id }}'"
|
||||
>🗑️</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
135
frontend/templates/partials/crop.html
Normal file
135
frontend/templates/partials/crop.html
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crop — {{ pc_id }}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.10/dist/full.min.css" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||
</head>
|
||||
<body class="bg-base-200 min-h-screen">
|
||||
<!-- Navbar -->
|
||||
<div class="navbar bg-base-100 shadow-md px-6 mb-2">
|
||||
<div class="flex-1">
|
||||
<span class="text-xl font-bold tracking-tight">☁️ PointCloud Viewer</span>
|
||||
</div>
|
||||
<div class="flex-none gap-4 items-center">
|
||||
<div
|
||||
id="health-indicator"
|
||||
hx-get="/health-check"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML"
|
||||
class="text-sm text-base-content/50"
|
||||
>vérification…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div role="tablist" class="tabs tabs-boxed mb-6 w-fit ml-4">
|
||||
<a
|
||||
role="tab"
|
||||
class="tab {% if active_tab == 'upload' %}tab-active{% endif %}"
|
||||
hx-get="/"
|
||||
hx-target="#main-content"
|
||||
hx-push-url="/"
|
||||
>📤 Upload</a>
|
||||
<a
|
||||
role="tab"
|
||||
class="tab {% if active_tab == 'admin' %}tab-active{% endif %}"
|
||||
hx-get="/viewer/list"
|
||||
hx-target="#main-content"
|
||||
hx-push-url="/viewer"
|
||||
>🗂️ Admin</a>
|
||||
</div>
|
||||
|
||||
<div id="main-content" class="container mx-auto px-4 mt-6 max-w-7xl pb-10">
|
||||
<div class="w-full max-w-4xl mx-auto space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-2xl font-bold">📐 Crop du Nuage de Points</h2>
|
||||
<a href="/viewer/list" class="btn btn-ghost btn-sm">← Retour</a>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="badge badge-info">Nuage : {{ pc_id }}</span>
|
||||
<span class="text-sm text-base-content/60">
|
||||
Sélectionnez une zone dans le viewer pour cropper le nuage
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<a href="/viewer/{{ pc_id }}" class="btn btn-primary" hx-get="/viewer/{{ pc_id }}" hx-target="#viewer-container">
|
||||
👁️ Ouvrir Viewer
|
||||
</a>
|
||||
<a href="{{ embed_url }}" target="_blank" class="btn btn-ghost btn-sm">
|
||||
↗ Plein écran
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="viewer-container" class="w-full rounded-lg border border-base-300">
|
||||
<iframe
|
||||
src="{{ embed_url }}"
|
||||
class="w-full"
|
||||
style="height: 680px;"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h3 class="font-semibold">📦 Coordonnées de la Box 3D</h3>
|
||||
<div class="grid grid-cols-3 gap-2 text-sm">
|
||||
<div>
|
||||
<label class="text-base-content/60">Min X</label>
|
||||
<input type="number" step="0.001" id="minX" class="input input-bordered input-sm w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-base-content/60">Min Y</label>
|
||||
<input type="number" step="0.001" id="minY" class="input input-bordered input-sm w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-base-content/60">Min Z</label>
|
||||
<input type="number" step="0.001" id="minZ" class="input input-bordered input-sm w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-base-content/60">Max X</label>
|
||||
<input type="number" step="0.001" id="maxX" class="input input-bordered input-sm w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-base-content/60">Max Y</label>
|
||||
<input type="number" step="0.001" id="maxY" class="input input-bordered input-sm w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-base-content/60">Max Z</label>
|
||||
<input type="number" step="0.001" id="maxZ" class="input input-bordered input-sm w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
hx-post="/crop/{{ pc_id }}"
|
||||
hx-target="#crop-result"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
✂️ Cropper
|
||||
</button>
|
||||
<span class="text-sm text-base-content/60">
|
||||
Le traitement peut prendre plusieurs minutes selon la taille du nuage
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="crop-result" class="w-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
71
frontend/templates/partials/debug_panel.html
Normal file
71
frontend/templates/partials/debug_panel.html
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Debug — {{ pc_id }}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.10/dist/full.min.css" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||
</head>
|
||||
<body class="bg-base-200 min-h-screen">
|
||||
<!-- Navbar -->
|
||||
<div class="navbar bg-base-100 shadow-md px-6 mb-2">
|
||||
<div class="flex-1">
|
||||
<span class="text-xl font-bold tracking-tight">☁️ PointCloud Viewer</span>
|
||||
</div>
|
||||
<div class="flex-none gap-4 items-center">
|
||||
<div
|
||||
id="health-indicator"
|
||||
hx-get="/health-check"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML"
|
||||
class="text-sm text-base-content/50"
|
||||
>vérification…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div role="tablist" class="tabs tabs-boxed mb-6 w-fit ml-4">
|
||||
<a
|
||||
role="tab"
|
||||
class="tab {% if active_tab == 'upload' %}tab-active{% endif %}"
|
||||
hx-get="/"
|
||||
hx-target="#main-content"
|
||||
hx-push-url="/"
|
||||
>📤 Upload</a>
|
||||
<a
|
||||
role="tab"
|
||||
class="tab {% if active_tab == 'admin' %}tab-active{% endif %}"
|
||||
hx-get="/viewer/list"
|
||||
hx-target="#main-content"
|
||||
hx-push-url="/viewer"
|
||||
>🗂️ Admin</a>
|
||||
</div>
|
||||
|
||||
<div id="main-content" class="container mx-auto px-4 mt-6 max-w-7xl pb-10">
|
||||
<div class="card bg-base-100 shadow mt-4">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="font-semibold text-sm">Debug : <code>{{ pc_id }}</code></h3>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm"
|
||||
hx-get="/viewer/list"
|
||||
hx-target="#main-content"
|
||||
>
|
||||
✕ Fermer
|
||||
</button>
|
||||
</div>
|
||||
{% if error %}
|
||||
<div class="alert alert-error">
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<pre class="bg-base-200 rounded p-3 text-xs overflow-auto max-h-80">{{ data | tojson(indent=2) }}</pre>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
14
frontend/templates/partials/health_status.html
Normal file
14
frontend/templates/partials/health_status.html
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{% if ok %}
|
||||
<div class="flex items-center gap-2">
|
||||
{% if entwine_available %}
|
||||
<span class="badge badge-success">backend ✓</span>
|
||||
<span class="badge badge-success">entwine ✓</span>
|
||||
{% else %}
|
||||
<span class="badge badge-success">backend ✓</span>
|
||||
<span class="badge badge-warning">entwine absent</span>
|
||||
{% endif %}
|
||||
<span class="text-xs text-base-content/40">{{ disk_free_gb }} GB libres</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="badge badge-error">backend inaccessible</span>
|
||||
{% endif %}
|
||||
27
frontend/templates/partials/upload_result.html
Normal file
27
frontend/templates/partials/upload_result.html
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{% if error %}
|
||||
<div class="alert alert-error">
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="badge badge-success">✓ Conversion EPT terminée</span>
|
||||
</div>
|
||||
<div class="text-sm space-y-1 mb-4">
|
||||
<div><span class="text-base-content/50">ID</span><code class="ml-2">{{ result.id }}</code></div>
|
||||
<div><span class="text-base-content/50">Fichier</span><span class="ml-2">{{ result.filename }}</span></div>
|
||||
<div><span class="text-base-content/50">Taille</span><span class="ml-2">{{ result.size_mb }} MB</span></div>
|
||||
<div><span class="text-base-content/50">Conversion</span><span class="ml-2">{{ result.conversion_time_seconds }}s</span></div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="/viewer/{{ result.id }}" class="btn btn-primary" hx-get="/viewer/{{ result.id }}" hx-target="#viewer-container">
|
||||
👁️ Visualiser
|
||||
</a>
|
||||
<a href="/viewer/{{ result.id }}" target="_blank" class="btn btn-ghost btn-sm">
|
||||
↗ Nouvel onglet
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
14
frontend/templates/partials/viewer.html
Normal file
14
frontend/templates/partials/viewer.html
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<div class="w-full flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between px-1">
|
||||
<span class="text-sm text-base-content/50">
|
||||
Nuage actif : <code>{{ pc_id }}</code>
|
||||
</span>
|
||||
<a href="{{ embed_url }}" target="_blank" class="btn btn-ghost btn-xs">↗ Plein écran</a>
|
||||
</div>
|
||||
<iframe
|
||||
src="{{ embed_url }}"
|
||||
class="w-full rounded-lg border border-base-300"
|
||||
style="height: 680px;"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
Loading…
Add table
Add a link
Reference in a new issue