Spaces:
Sleeping
Sleeping
Commit ·
70d9e6c
1
Parent(s): 66006d5
Add Phase 4 reports: PDF export, browser report page, and notification bell.
Browse filesReportLab PDF download, standalone /dda/reports view, email deep links, and in-app job notifications on dev Space.
Co-authored-by: Cursor <cursoragent@cursor.com>
- Dockerfile +1 -1
- app/dda/bootstrap.py +3 -1
- app/dda/config.py +11 -0
- app/dda/detect_service.py +3 -0
- app/dda/report_pdf.py +135 -0
- app/dda/reports_routes.py +97 -0
- app/main.py +11 -0
- app/notifier.py +17 -1
- requirements.txt +2 -0
- static/css/dda.css +72 -0
- static/js/dda/compare.js +23 -4
- static/js/dda/notifications.js +149 -0
- static/js/dda/report_page.js +115 -0
- static/js/dda/reports.js +26 -8
- templates/ChangeDetection.html +3 -1
- templates/index_dda.html +15 -5
- templates/report_dda.html +67 -0
Dockerfile
CHANGED
|
@@ -21,7 +21,7 @@ WORKDIR /app
|
|
| 21 |
|
| 22 |
# Build-time info + cache-bust:
|
| 23 |
# Changing APP_BUILD forces Docker to re-run subsequent layers (including pip install).
|
| 24 |
-
ARG APP_BUILD=
|
| 25 |
ENV MAX_GEOTIFF_MB=5120
|
| 26 |
ENV APP_BUILD=${APP_BUILD}
|
| 27 |
ENV GDAL_CONFIG=/usr/bin/gdal-config
|
|
|
|
| 21 |
|
| 22 |
# Build-time info + cache-bust:
|
| 23 |
# Changing APP_BUILD forces Docker to re-run subsequent layers (including pip install).
|
| 24 |
+
ARG APP_BUILD=34
|
| 25 |
ENV MAX_GEOTIFF_MB=5120
|
| 26 |
ENV APP_BUILD=${APP_BUILD}
|
| 27 |
ENV GDAL_CONFIG=/usr/bin/gdal-config
|
app/dda/bootstrap.py
CHANGED
|
@@ -8,6 +8,7 @@ from .config import IS_DDA_MODE, ensure_library_dirs, ensure_local_year_folders,
|
|
| 8 |
from .jobs_routes import router as jobs_router
|
| 9 |
from .library_routes import router as library_router
|
| 10 |
from .local_routes import router as local_router
|
|
|
|
| 11 |
from .seed import seed_delhi_hierarchy
|
| 12 |
|
| 13 |
logger = logging.getLogger(__name__)
|
|
@@ -55,5 +56,6 @@ def setup_dda(app: FastAPI) -> None:
|
|
| 55 |
return
|
| 56 |
app.include_router(library_router, prefix="/api/dda", tags=["dda"])
|
| 57 |
app.include_router(jobs_router, prefix="/api/dda", tags=["dda-jobs"])
|
|
|
|
| 58 |
app.include_router(local_router, prefix="/api/dda", tags=["dda-local"])
|
| 59 |
-
logger.info("APP_MODE=dda — DDA routes enabled (library, jobs, local folder)")
|
|
|
|
| 8 |
from .jobs_routes import router as jobs_router
|
| 9 |
from .library_routes import router as library_router
|
| 10 |
from .local_routes import router as local_router
|
| 11 |
+
from .reports_routes import router as reports_router
|
| 12 |
from .seed import seed_delhi_hierarchy
|
| 13 |
|
| 14 |
logger = logging.getLogger(__name__)
|
|
|
|
| 56 |
return
|
| 57 |
app.include_router(library_router, prefix="/api/dda", tags=["dda"])
|
| 58 |
app.include_router(jobs_router, prefix="/api/dda", tags=["dda-jobs"])
|
| 59 |
+
app.include_router(reports_router, prefix="/api/dda", tags=["dda-reports"])
|
| 60 |
app.include_router(local_router, prefix="/api/dda", tags=["dda-local"])
|
| 61 |
+
logger.info("APP_MODE=dda — DDA routes enabled (library, jobs, reports, local folder)")
|
app/dda/config.py
CHANGED
|
@@ -21,9 +21,20 @@ def _is_dda_mode() -> bool:
|
|
| 21 |
return _SPACE_ID.endswith("/satdetect-dev")
|
| 22 |
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
IS_DDA_MODE = _is_dda_mode()
|
| 25 |
APP_MODE = APP_MODE_RAW or ("dda" if IS_DDA_MODE else "legacy")
|
| 26 |
|
|
|
|
| 27 |
# Project root: change_detection_webapp/
|
| 28 |
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
| 29 |
|
|
|
|
| 21 |
return _SPACE_ID.endswith("/satdetect-dev")
|
| 22 |
|
| 23 |
|
| 24 |
+
def get_public_base_url() -> str:
|
| 25 |
+
"""Public URL for deep links (email, share). Override with PUBLIC_BASE_URL."""
|
| 26 |
+
explicit = os.environ.get("PUBLIC_BASE_URL", "").strip().rstrip("/")
|
| 27 |
+
if explicit:
|
| 28 |
+
return explicit
|
| 29 |
+
if _SPACE_ID:
|
| 30 |
+
return f"https://{_SPACE_ID.replace('/', '-')}.hf.space"
|
| 31 |
+
return "http://localhost:7860"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
IS_DDA_MODE = _is_dda_mode()
|
| 35 |
APP_MODE = APP_MODE_RAW or ("dda" if IS_DDA_MODE else "legacy")
|
| 36 |
|
| 37 |
+
|
| 38 |
# Project root: change_detection_webapp/
|
| 39 |
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
| 40 |
|
app/dda/detect_service.py
CHANGED
|
@@ -167,6 +167,8 @@ def run_detection_and_save(
|
|
| 167 |
notification_sent = False
|
| 168 |
notification_error = None
|
| 169 |
if notify_email and notify_email.strip():
|
|
|
|
|
|
|
| 170 |
notification_sent, notification_error = send_notification(
|
| 171 |
recipient=notify_email.strip(),
|
| 172 |
title=title,
|
|
@@ -177,6 +179,7 @@ def run_detection_and_save(
|
|
| 177 |
changed_px=changed_px,
|
| 178 |
total_px=total_px,
|
| 179 |
regions=regions_serializable,
|
|
|
|
| 180 |
)
|
| 181 |
|
| 182 |
return {
|
|
|
|
| 167 |
notification_sent = False
|
| 168 |
notification_error = None
|
| 169 |
if notify_email and notify_email.strip():
|
| 170 |
+
from .config import IS_DDA_MODE, get_public_base_url
|
| 171 |
+
report_url = f"{get_public_base_url()}/dda/reports/{run.id}" if IS_DDA_MODE else ""
|
| 172 |
notification_sent, notification_error = send_notification(
|
| 173 |
recipient=notify_email.strip(),
|
| 174 |
title=title,
|
|
|
|
| 179 |
changed_px=changed_px,
|
| 180 |
total_px=total_px,
|
| 181 |
regions=regions_serializable,
|
| 182 |
+
report_url=report_url,
|
| 183 |
)
|
| 184 |
|
| 185 |
return {
|
app/dda/report_pdf.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PDF export for DDA detection reports (FR-05)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
from io import BytesIO
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any, Dict, List, Optional
|
| 8 |
+
|
| 9 |
+
from ..database import DATA_DIR
|
| 10 |
+
from ..models import DetectionRun
|
| 11 |
+
from .detect_service import _isoformat_ist
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _safe_filename(title: str, run_id: int) -> str:
|
| 15 |
+
base = "".join(c if c.isalnum() or c in " -_" else "_" for c in (title or "report"))
|
| 16 |
+
base = base.strip().replace(" ", "_")[:60] or "report"
|
| 17 |
+
return f"DDA_Report_{run_id}_{base}.pdf"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def build_report_dict(run: DetectionRun, *, include_overlay_b64: bool = False) -> Dict[str, Any]:
|
| 21 |
+
regions: List[dict] = json.loads(run.regions_json or "[]")
|
| 22 |
+
payload: Dict[str, Any] = {
|
| 23 |
+
"id": run.id,
|
| 24 |
+
"title": run.title,
|
| 25 |
+
"method": run.method,
|
| 26 |
+
"zone": run.zone or "",
|
| 27 |
+
"village": run.village or "",
|
| 28 |
+
"statistics": {
|
| 29 |
+
"totalPixels": run.total_pixels,
|
| 30 |
+
"changedPixels": run.changed_pixels,
|
| 31 |
+
"unchangedPixels": run.total_pixels - run.changed_pixels,
|
| 32 |
+
"changePercentage": run.change_percentage,
|
| 33 |
+
},
|
| 34 |
+
"regions": regions,
|
| 35 |
+
"regionsCount": run.regions_count,
|
| 36 |
+
"overlayUrl": f"/api/overlay/{run.overlay_path}" if run.overlay_path else None,
|
| 37 |
+
"beforeFullUrl": f"/api/overlay/{run.before_full_path}" if run.before_full_path else None,
|
| 38 |
+
"beforeThumbUrl": f"/api/overlay/{run.before_thumb_path}" if run.before_thumb_path else None,
|
| 39 |
+
"afterThumbUrl": f"/api/overlay/{run.after_thumb_path}" if run.after_thumb_path else None,
|
| 40 |
+
"createdAt": _isoformat_ist(run.created_at),
|
| 41 |
+
"pdfUrl": f"/api/dda/reports/{run.id}/pdf",
|
| 42 |
+
}
|
| 43 |
+
if include_overlay_b64 and run.overlay_path:
|
| 44 |
+
overlay_file = DATA_DIR / run.overlay_path
|
| 45 |
+
if overlay_file.exists():
|
| 46 |
+
import base64
|
| 47 |
+
payload["overlayBase64Png"] = base64.b64encode(overlay_file.read_bytes()).decode("utf-8")
|
| 48 |
+
return payload
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def generate_report_pdf(run: DetectionRun) -> tuple[bytes, str]:
|
| 52 |
+
"""Build PDF bytes and suggested download filename."""
|
| 53 |
+
from reportlab.lib import colors
|
| 54 |
+
from reportlab.lib.pagesizes import A4
|
| 55 |
+
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
| 56 |
+
from reportlab.lib.units import mm
|
| 57 |
+
from reportlab.platypus import Image as RLImage
|
| 58 |
+
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
|
| 59 |
+
|
| 60 |
+
regions: List[dict] = json.loads(run.regions_json or "[]")
|
| 61 |
+
buf = BytesIO()
|
| 62 |
+
doc = SimpleDocTemplate(buf, pagesize=A4, leftMargin=18 * mm, rightMargin=18 * mm, topMargin=16 * mm, bottomMargin=16 * mm)
|
| 63 |
+
styles = getSampleStyleSheet()
|
| 64 |
+
title_style = ParagraphStyle("ReportTitle", parent=styles["Heading1"], fontSize=16, spaceAfter=8)
|
| 65 |
+
sub_style = ParagraphStyle("ReportSub", parent=styles["Normal"], fontSize=10, textColor=colors.grey)
|
| 66 |
+
body = styles["Normal"]
|
| 67 |
+
|
| 68 |
+
location = ", ".join(filter(None, [run.village, run.zone])) or "—"
|
| 69 |
+
story = [
|
| 70 |
+
Paragraph("DDA Change Detection Report", title_style),
|
| 71 |
+
Paragraph(run.title or f"Run #{run.id}", styles["Heading2"]),
|
| 72 |
+
Paragraph(f"Generated {_isoformat_ist(run.created_at)} IST", sub_style),
|
| 73 |
+
Spacer(1, 8),
|
| 74 |
+
Paragraph(f"<b>Method:</b> {run.method or '—'}", body),
|
| 75 |
+
Paragraph(f"<b>Location:</b> {location}", body),
|
| 76 |
+
Paragraph(f"<b>Change:</b> {run.change_percentage:.2f}% ({run.changed_pixels:,} / {run.total_pixels:,} px)", body),
|
| 77 |
+
Paragraph(f"<b>Regions detected:</b> {run.regions_count}", body),
|
| 78 |
+
Spacer(1, 10),
|
| 79 |
+
]
|
| 80 |
+
|
| 81 |
+
overlay_path = run.overlay_path
|
| 82 |
+
if overlay_path:
|
| 83 |
+
overlay_file = DATA_DIR / overlay_path
|
| 84 |
+
if overlay_file.exists():
|
| 85 |
+
try:
|
| 86 |
+
from PIL import Image as PILImage
|
| 87 |
+
with PILImage.open(overlay_file) as im:
|
| 88 |
+
im = im.convert("RGB")
|
| 89 |
+
max_w = 160 * mm
|
| 90 |
+
ratio = min(1.0, max_w / im.width)
|
| 91 |
+
w, h = int(im.width * ratio), int(im.height * ratio)
|
| 92 |
+
thumb = im.resize((w, h), PILImage.Resampling.LANCZOS)
|
| 93 |
+
img_buf = BytesIO()
|
| 94 |
+
thumb.save(img_buf, format="JPEG", quality=85)
|
| 95 |
+
img_buf.seek(0)
|
| 96 |
+
story.append(Paragraph("Change overlay", styles["Heading3"]))
|
| 97 |
+
story.append(RLImage(img_buf, width=w, height=h))
|
| 98 |
+
story.append(Spacer(1, 10))
|
| 99 |
+
except Exception:
|
| 100 |
+
pass
|
| 101 |
+
|
| 102 |
+
story.append(Paragraph("Detected regions", styles["Heading3"]))
|
| 103 |
+
if regions:
|
| 104 |
+
table_data = [["#", "DDA type", "Internal type", "Conf.", "Area (px)", "Lat", "Lng"]]
|
| 105 |
+
for r in regions[:50]:
|
| 106 |
+
lat = r.get("latitude") or r.get("lat")
|
| 107 |
+
lng = r.get("longitude") or r.get("lng")
|
| 108 |
+
table_data.append([
|
| 109 |
+
str(r.get("id", "")),
|
| 110 |
+
r.get("ddaChangeType") or r.get("objectType") or "—",
|
| 111 |
+
r.get("internalObjectType") or r.get("objectType") or "—",
|
| 112 |
+
f'{(r.get("confidence", 0) or 0) * 100:.0f}%',
|
| 113 |
+
f'{r.get("area", 0):,}',
|
| 114 |
+
f"{lat:.5f}" if lat is not None else "—",
|
| 115 |
+
f"{lng:.5f}" if lng is not None else "—",
|
| 116 |
+
])
|
| 117 |
+
tbl = Table(table_data, repeatRows=1, colWidths=[22, 72, 72, 36, 52, 48, 48])
|
| 118 |
+
tbl.setStyle(TableStyle([
|
| 119 |
+
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#2e33c5")),
|
| 120 |
+
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
|
| 121 |
+
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
| 122 |
+
("FONTSIZE", (0, 0), (-1, -1), 8),
|
| 123 |
+
("GRID", (0, 0), (-1, -1), 0.25, colors.lightgrey),
|
| 124 |
+
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f9f9fb")]),
|
| 125 |
+
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
| 126 |
+
]))
|
| 127 |
+
story.append(tbl)
|
| 128 |
+
if len(regions) > 50:
|
| 129 |
+
story.append(Spacer(1, 6))
|
| 130 |
+
story.append(Paragraph(f"Showing first 50 of {len(regions)} regions.", sub_style))
|
| 131 |
+
else:
|
| 132 |
+
story.append(Paragraph("No change regions detected.", body))
|
| 133 |
+
|
| 134 |
+
doc.build(story)
|
| 135 |
+
return buf.getvalue(), _safe_filename(run.title or "", run.id)
|
app/dda/reports_routes.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""DDA report browser view, PDF export, and email notify (FR-05)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import logging
|
| 5 |
+
from typing import Optional
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 8 |
+
from fastapi.responses import Response
|
| 9 |
+
from pydantic import BaseModel, EmailStr
|
| 10 |
+
from sqlalchemy.orm import Session
|
| 11 |
+
|
| 12 |
+
from ..auth import get_or_create_guest_user
|
| 13 |
+
from ..database import get_db
|
| 14 |
+
from ..models import DetectionRun
|
| 15 |
+
from ..notifier import send_notification
|
| 16 |
+
from .config import get_public_base_url
|
| 17 |
+
from .report_pdf import build_report_dict, generate_report_pdf
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
router = APIRouter()
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _require_dda():
|
| 24 |
+
from .config import IS_DDA_MODE
|
| 25 |
+
if not IS_DDA_MODE:
|
| 26 |
+
raise HTTPException(status_code=404, detail="DDA mode is not enabled")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _get_user_run(db: Session, run_id: int, user_id: int) -> DetectionRun:
|
| 30 |
+
run = db.query(DetectionRun).filter(
|
| 31 |
+
DetectionRun.id == run_id,
|
| 32 |
+
DetectionRun.user_id == user_id,
|
| 33 |
+
).first()
|
| 34 |
+
if not run:
|
| 35 |
+
raise HTTPException(status_code=404, detail="Report not found")
|
| 36 |
+
return run
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@router.get("/reports/{run_id}")
|
| 40 |
+
def get_report(run_id: int, db: Session = Depends(get_db)):
|
| 41 |
+
"""JSON payload for the standalone report page and API clients."""
|
| 42 |
+
_require_dda()
|
| 43 |
+
user = get_or_create_guest_user(db)
|
| 44 |
+
run = _get_user_run(db, run_id, user.id)
|
| 45 |
+
data = build_report_dict(run, include_overlay_b64=True)
|
| 46 |
+
data["reportUrl"] = f"{get_public_base_url()}/dda/reports/{run.id}"
|
| 47 |
+
return data
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@router.get("/reports/{run_id}/pdf")
|
| 51 |
+
def download_report_pdf(run_id: int, db: Session = Depends(get_db)):
|
| 52 |
+
"""Download detection report as PDF."""
|
| 53 |
+
_require_dda()
|
| 54 |
+
user = get_or_create_guest_user(db)
|
| 55 |
+
run = _get_user_run(db, run_id, user.id)
|
| 56 |
+
try:
|
| 57 |
+
pdf_bytes, filename = generate_report_pdf(run)
|
| 58 |
+
except ImportError as exc:
|
| 59 |
+
raise HTTPException(status_code=503, detail="PDF export is not available (reportlab missing)") from exc
|
| 60 |
+
except Exception as exc:
|
| 61 |
+
logger.exception("PDF generation failed for run %s", run_id)
|
| 62 |
+
raise HTTPException(status_code=500, detail=f"PDF generation failed: {exc}") from exc
|
| 63 |
+
return Response(
|
| 64 |
+
content=pdf_bytes,
|
| 65 |
+
media_type="application/pdf",
|
| 66 |
+
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class ReportNotifyBody(BaseModel):
|
| 71 |
+
email: EmailStr
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
@router.post("/reports/{run_id}/notify")
|
| 75 |
+
def notify_report(run_id: int, body: ReportNotifyBody, db: Session = Depends(get_db)):
|
| 76 |
+
"""Email report summary with link to the browser report page."""
|
| 77 |
+
_require_dda()
|
| 78 |
+
user = get_or_create_guest_user(db)
|
| 79 |
+
run = _get_user_run(db, run_id, user.id)
|
| 80 |
+
import json
|
| 81 |
+
regions = json.loads(run.regions_json or "[]")
|
| 82 |
+
report_url = f"{get_public_base_url()}/dda/reports/{run.id}"
|
| 83 |
+
sent, error = send_notification(
|
| 84 |
+
recipient=body.email.strip(),
|
| 85 |
+
title=run.title,
|
| 86 |
+
method=run.method,
|
| 87 |
+
zone=run.zone or "",
|
| 88 |
+
village=run.village or "",
|
| 89 |
+
change_pct=float(run.change_percentage),
|
| 90 |
+
changed_px=int(run.changed_pixels),
|
| 91 |
+
total_px=int(run.total_pixels),
|
| 92 |
+
regions=regions,
|
| 93 |
+
report_url=report_url,
|
| 94 |
+
)
|
| 95 |
+
if not sent:
|
| 96 |
+
raise HTTPException(status_code=400, detail=error or "Failed to send report email")
|
| 97 |
+
return {"ok": True, "message": f"Report link sent to {body.email.strip()}.", "reportUrl": report_url}
|
app/main.py
CHANGED
|
@@ -555,6 +555,17 @@ def delete_run(
|
|
| 555 |
|
| 556 |
|
| 557 |
# --- Serve SPA ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 558 |
@app.get("/", response_class=HTMLResponse)
|
| 559 |
def index():
|
| 560 |
if IS_DDA_MODE:
|
|
|
|
| 555 |
|
| 556 |
|
| 557 |
# --- Serve SPA ---
|
| 558 |
+
@app.get("/dda/reports/{run_id}", response_class=HTMLResponse)
|
| 559 |
+
def dda_report_page(run_id: int):
|
| 560 |
+
"""Standalone browser report (FR-05)."""
|
| 561 |
+
if not IS_DDA_MODE:
|
| 562 |
+
raise HTTPException(status_code=404, detail="Not found")
|
| 563 |
+
report_file = TEMPLATES_DIR / "report_dda.html"
|
| 564 |
+
if not report_file.exists():
|
| 565 |
+
raise HTTPException(status_code=404, detail="Report template missing")
|
| 566 |
+
return FileResponse(report_file)
|
| 567 |
+
|
| 568 |
+
|
| 569 |
@app.get("/", response_class=HTMLResponse)
|
| 570 |
def index():
|
| 571 |
if IS_DDA_MODE:
|
app/notifier.py
CHANGED
|
@@ -71,6 +71,18 @@ def _build_region_rows(regions: list) -> str:
|
|
| 71 |
return "\n".join(rows)
|
| 72 |
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
def build_email_body(
|
| 75 |
title: str,
|
| 76 |
method: str,
|
|
@@ -80,6 +92,7 @@ def build_email_body(
|
|
| 80 |
changed_px: int,
|
| 81 |
total_px: int,
|
| 82 |
regions: list,
|
|
|
|
| 83 |
) -> str:
|
| 84 |
"""Populate the HTML template with detection results."""
|
| 85 |
html = _load_template()
|
|
@@ -97,6 +110,7 @@ def build_email_body(
|
|
| 97 |
"{{regions_count}}": str(len(regions)),
|
| 98 |
"{{region_rows}}": region_rows,
|
| 99 |
"{{timestamp}}": now,
|
|
|
|
| 100 |
}
|
| 101 |
for key, val in replacements.items():
|
| 102 |
html = html.replace(key, val)
|
|
@@ -208,10 +222,12 @@ def send_notification(
|
|
| 208 |
changed_px: int,
|
| 209 |
total_px: int,
|
| 210 |
regions: list,
|
|
|
|
| 211 |
):
|
| 212 |
"""Send a detection report email and return (success, error_message)."""
|
| 213 |
html_body = build_email_body(
|
| 214 |
-
title, method, zone, village, change_pct, changed_px, total_px, regions
|
|
|
|
| 215 |
)
|
| 216 |
subject = f"Change Detection Report — {title or 'Untitled run'}"
|
| 217 |
return _send_html_email(recipient, subject, html_body)
|
|
|
|
| 71 |
return "\n".join(rows)
|
| 72 |
|
| 73 |
|
| 74 |
+
def _report_link_section(report_url: str) -> str:
|
| 75 |
+
if not report_url:
|
| 76 |
+
return ""
|
| 77 |
+
safe = report_url.replace('"', "%22")
|
| 78 |
+
return (
|
| 79 |
+
'<p style="margin:0 0 20px; text-align:center;">'
|
| 80 |
+
f'<a href="{safe}" style="display:inline-block; background:#2e33c5; color:#fff; '
|
| 81 |
+
'text-decoration:none; padding:12px 24px; border-radius:8px; font-size:14px; font-weight:600;">'
|
| 82 |
+
"View full report online</a></p>"
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
def build_email_body(
|
| 87 |
title: str,
|
| 88 |
method: str,
|
|
|
|
| 92 |
changed_px: int,
|
| 93 |
total_px: int,
|
| 94 |
regions: list,
|
| 95 |
+
report_url: str = "",
|
| 96 |
) -> str:
|
| 97 |
"""Populate the HTML template with detection results."""
|
| 98 |
html = _load_template()
|
|
|
|
| 110 |
"{{regions_count}}": str(len(regions)),
|
| 111 |
"{{region_rows}}": region_rows,
|
| 112 |
"{{timestamp}}": now,
|
| 113 |
+
"{{report_link_section}}": _report_link_section(report_url),
|
| 114 |
}
|
| 115 |
for key, val in replacements.items():
|
| 116 |
html = html.replace(key, val)
|
|
|
|
| 222 |
changed_px: int,
|
| 223 |
total_px: int,
|
| 224 |
regions: list,
|
| 225 |
+
report_url: str = "",
|
| 226 |
):
|
| 227 |
"""Send a detection report email and return (success, error_message)."""
|
| 228 |
html_body = build_email_body(
|
| 229 |
+
title, method, zone, village, change_pct, changed_px, total_px, regions,
|
| 230 |
+
report_url=report_url,
|
| 231 |
)
|
| 232 |
subject = f"Change Detection Report — {title or 'Untitled run'}"
|
| 233 |
return _send_html_email(recipient, subject, html_body)
|
requirements.txt
CHANGED
|
@@ -18,3 +18,5 @@ requests>=2.28.0
|
|
| 18 |
protobuf>=5.28.0,<6
|
| 19 |
rasterio>=1.3.0,<1.5
|
| 20 |
pyproj>=3.6.0
|
|
|
|
|
|
|
|
|
| 18 |
protobuf>=5.28.0,<6
|
| 19 |
rasterio>=1.3.0,<1.5
|
| 20 |
pyproj>=3.6.0
|
| 21 |
+
reportlab>=4.0.0
|
| 22 |
+
email-validator>=2.0.0
|
static/css/dda.css
CHANGED
|
@@ -347,3 +347,75 @@
|
|
| 347 |
|
| 348 |
.regions-table tr.region-selected { background: rgba(16, 185, 129, 0.12); }
|
| 349 |
.regions-table tr { cursor: pointer; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
|
| 348 |
.regions-table tr.region-selected { background: rgba(16, 185, 129, 0.12); }
|
| 349 |
.regions-table tr { cursor: pointer; }
|
| 350 |
+
|
| 351 |
+
.dda-notify-wrap { position: relative; margin-left: auto; }
|
| 352 |
+
.dda-notify-btn {
|
| 353 |
+
position: relative;
|
| 354 |
+
background: transparent;
|
| 355 |
+
border: 1px solid var(--border);
|
| 356 |
+
border-radius: 8px;
|
| 357 |
+
padding: 0.4rem 0.55rem;
|
| 358 |
+
cursor: pointer;
|
| 359 |
+
color: inherit;
|
| 360 |
+
display: flex;
|
| 361 |
+
align-items: center;
|
| 362 |
+
}
|
| 363 |
+
.dda-notify-badge {
|
| 364 |
+
position: absolute;
|
| 365 |
+
top: -4px;
|
| 366 |
+
right: -4px;
|
| 367 |
+
min-width: 16px;
|
| 368 |
+
height: 16px;
|
| 369 |
+
padding: 0 4px;
|
| 370 |
+
border-radius: 999px;
|
| 371 |
+
background: var(--danger, #dc2626);
|
| 372 |
+
color: #fff;
|
| 373 |
+
font-size: 10px;
|
| 374 |
+
font-weight: 700;
|
| 375 |
+
line-height: 16px;
|
| 376 |
+
text-align: center;
|
| 377 |
+
}
|
| 378 |
+
.dda-notify-panel {
|
| 379 |
+
position: absolute;
|
| 380 |
+
right: 0;
|
| 381 |
+
top: calc(100% + 6px);
|
| 382 |
+
width: min(320px, 90vw);
|
| 383 |
+
max-height: 360px;
|
| 384 |
+
overflow-y: auto;
|
| 385 |
+
background: var(--card, #fff);
|
| 386 |
+
border: 1px solid var(--border);
|
| 387 |
+
border-radius: 10px;
|
| 388 |
+
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
|
| 389 |
+
z-index: 200;
|
| 390 |
+
padding: 0.35rem;
|
| 391 |
+
}
|
| 392 |
+
.dda-notify-item {
|
| 393 |
+
display: block;
|
| 394 |
+
width: 100%;
|
| 395 |
+
text-align: left;
|
| 396 |
+
border: none;
|
| 397 |
+
background: transparent;
|
| 398 |
+
padding: 0.55rem 0.65rem;
|
| 399 |
+
border-radius: 6px;
|
| 400 |
+
cursor: pointer;
|
| 401 |
+
color: inherit;
|
| 402 |
+
}
|
| 403 |
+
.dda-notify-item:hover { background: rgba(0,0,0,0.04); }
|
| 404 |
+
.dda-notify-item.unread { background: rgba(46, 51, 197, 0.06); }
|
| 405 |
+
.dda-notify-item-title { display: block; font-size: 0.85rem; font-weight: 600; }
|
| 406 |
+
.dda-notify-item-meta { display: block; font-size: 0.75rem; color: var(--text-muted, #888); margin-top: 0.15rem; }
|
| 407 |
+
.dda-notify-empty { padding: 0.75rem; margin: 0; font-size: 0.85rem; }
|
| 408 |
+
|
| 409 |
+
.dda-notify-email { margin-top: 0.35rem; width: 100%; max-width: 280px; }
|
| 410 |
+
.dda-report-actions-cell { white-space: nowrap; }
|
| 411 |
+
.dda-report-actions-cell .btn, .dda-report-actions-cell a.btn { margin-right: 0.25rem; margin-bottom: 0.25rem; }
|
| 412 |
+
|
| 413 |
+
.dda-report-page { max-width: 960px; margin: 0 auto; padding: 1rem; }
|
| 414 |
+
.dda-report-header { justify-content: space-between; }
|
| 415 |
+
.dda-report-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
| 416 |
+
.dda-report-stats { display: flex; flex-wrap: wrap; gap: 1rem 2rem; margin-top: 0.75rem; }
|
| 417 |
+
.dda-stat .label { display: block; font-size: 0.75rem; color: var(--text-muted, #888); }
|
| 418 |
+
.dda-stat .value { font-size: 1.25rem; font-weight: 700; }
|
| 419 |
+
.dda-report-overlay { max-width: 100%; height: auto; border-radius: 8px; border: 1px solid var(--border); }
|
| 420 |
+
.dda-report-email-row { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.5rem; }
|
| 421 |
+
.dda-report-email-row input { flex: 1; min-width: 200px; }
|
static/js/dda/compare.js
CHANGED
|
@@ -279,6 +279,12 @@ function setupCompareInteractions() {
|
|
| 279 |
});
|
| 280 |
document.getElementById('btn-run-job')?.addEventListener('click', runLibraryDetection);
|
| 281 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
document.addEventListener('keydown', (e) => {
|
| 283 |
if (e.key !== 'Escape') return;
|
| 284 |
const picker = document.getElementById('dda-picker-modal');
|
|
@@ -304,7 +310,7 @@ async function runDetectionWithFallback(form, loadingEl) {
|
|
| 304 |
loadingEl.textContent = 'Running detection (sync fallback)…';
|
| 305 |
}
|
| 306 |
}
|
| 307 |
-
return ddaApi('POST', '/api/dda/detect/from-library', { body: form });
|
| 308 |
}
|
| 309 |
|
| 310 |
async function pollJobUntilDone(jobId, loadingEl) {
|
|
@@ -315,7 +321,10 @@ async function pollJobUntilDone(jobId, loadingEl) {
|
|
| 315 |
if (loadingEl) {
|
| 316 |
loadingEl.textContent = `Detection job #${jobId} — ${status}… (${i + 1})`;
|
| 317 |
}
|
| 318 |
-
if (status === 'completed' && job.result)
|
|
|
|
|
|
|
|
|
|
| 319 |
if (status === 'failed') throw new Error(job.errorMessage || 'Detection job failed');
|
| 320 |
await new Promise((r) => setTimeout(r, 2000));
|
| 321 |
}
|
|
@@ -344,11 +353,21 @@ async function runLibraryDetection() {
|
|
| 344 |
form.append('detection_sensitivity', String(Math.max(0, Math.min(1, Number(document.getElementById('dda-detect-sensitivity')?.value ?? 0.45)))));
|
| 345 |
const minArea = Number(document.getElementById('dda-detect-min-area')?.value ?? 150);
|
| 346 |
if (!Number.isNaN(minArea) && minArea >= 50) form.append('min_region_area', String(Math.round(minArea)));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
|
| 348 |
try {
|
| 349 |
-
const data = await runDetectionWithFallback(form, loading);
|
|
|
|
| 350 |
showDetectResult(data);
|
| 351 |
-
if (typeof showDdaSuccess === 'function')
|
|
|
|
|
|
|
|
|
|
|
|
|
| 352 |
if (typeof loadReportsList === 'function') loadReportsList();
|
| 353 |
} catch (err) {
|
| 354 |
if (typeof showDdaError === 'function') showDdaError(err.message || 'Detection failed');
|
|
|
|
| 279 |
});
|
| 280 |
document.getElementById('btn-run-job')?.addEventListener('click', runLibraryDetection);
|
| 281 |
|
| 282 |
+
const notifyCb = document.getElementById('dda-detect-notify');
|
| 283 |
+
const notifyEmail = document.getElementById('dda-detect-notify-email');
|
| 284 |
+
notifyCb?.addEventListener('change', () => {
|
| 285 |
+
if (notifyEmail) notifyEmail.classList.toggle('hidden', !notifyCb.checked);
|
| 286 |
+
});
|
| 287 |
+
|
| 288 |
document.addEventListener('keydown', (e) => {
|
| 289 |
if (e.key !== 'Escape') return;
|
| 290 |
const picker = document.getElementById('dda-picker-modal');
|
|
|
|
| 310 |
loadingEl.textContent = 'Running detection (sync fallback)…';
|
| 311 |
}
|
| 312 |
}
|
| 313 |
+
return ddaApi('POST', '/api/dda/detect/from-library', { body: form }).then((result) => ({ result, jobId: null }));
|
| 314 |
}
|
| 315 |
|
| 316 |
async function pollJobUntilDone(jobId, loadingEl) {
|
|
|
|
| 321 |
if (loadingEl) {
|
| 322 |
loadingEl.textContent = `Detection job #${jobId} — ${status}… (${i + 1})`;
|
| 323 |
}
|
| 324 |
+
if (status === 'completed' && job.result) {
|
| 325 |
+
if (typeof window.refreshDdaNotifications === 'function') window.refreshDdaNotifications();
|
| 326 |
+
return { result: job.result, jobId };
|
| 327 |
+
}
|
| 328 |
if (status === 'failed') throw new Error(job.errorMessage || 'Detection job failed');
|
| 329 |
await new Promise((r) => setTimeout(r, 2000));
|
| 330 |
}
|
|
|
|
| 353 |
form.append('detection_sensitivity', String(Math.max(0, Math.min(1, Number(document.getElementById('dda-detect-sensitivity')?.value ?? 0.45)))));
|
| 354 |
const minArea = Number(document.getElementById('dda-detect-min-area')?.value ?? 150);
|
| 355 |
if (!Number.isNaN(minArea) && minArea >= 50) form.append('min_region_area', String(Math.round(minArea)));
|
| 356 |
+
const notifyCb = document.getElementById('dda-detect-notify');
|
| 357 |
+
const notifyEmail = document.getElementById('dda-detect-notify-email');
|
| 358 |
+
if (notifyCb?.checked && notifyEmail?.value?.trim()) {
|
| 359 |
+
form.append('notify_email', notifyEmail.value.trim());
|
| 360 |
+
}
|
| 361 |
|
| 362 |
try {
|
| 363 |
+
const { result: data, jobId } = await runDetectionWithFallback(form, loading);
|
| 364 |
+
if (jobId && typeof window.markDdaJobSeen === 'function') window.markDdaJobSeen(jobId);
|
| 365 |
showDetectResult(data);
|
| 366 |
+
if (typeof showDdaSuccess === 'function') {
|
| 367 |
+
let msg = 'Detection complete.';
|
| 368 |
+
if (data.notificationSent) msg += ' Report email sent.';
|
| 369 |
+
showDdaSuccess(msg);
|
| 370 |
+
}
|
| 371 |
if (typeof loadReportsList === 'function') loadReportsList();
|
| 372 |
} catch (err) {
|
| 373 |
if (typeof showDdaError === 'function') showDdaError(err.message || 'Detection failed');
|
static/js/dda/notifications.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** In-app notification bell — polls completed detection jobs (FR-05). */
|
| 2 |
+
|
| 3 |
+
const SEEN_JOBS_KEY = 'dda_seen_job_ids';
|
| 4 |
+
const POLL_MS = 30000;
|
| 5 |
+
|
| 6 |
+
function getSeenJobIds() {
|
| 7 |
+
try {
|
| 8 |
+
return new Set(JSON.parse(localStorage.getItem(SEEN_JOBS_KEY) || '[]'));
|
| 9 |
+
} catch (_) {
|
| 10 |
+
return new Set();
|
| 11 |
+
}
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
function saveSeenJobIds(ids) {
|
| 15 |
+
try {
|
| 16 |
+
localStorage.setItem(SEEN_JOBS_KEY, JSON.stringify([...ids].slice(-200)));
|
| 17 |
+
} catch (_) {}
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
function markJobSeen(jobId) {
|
| 21 |
+
const seen = getSeenJobIds();
|
| 22 |
+
seen.add(String(jobId));
|
| 23 |
+
saveSeenJobIds(seen);
|
| 24 |
+
refreshNotifications();
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
function formatNotifyTime(iso) {
|
| 28 |
+
if (!iso) return '';
|
| 29 |
+
try {
|
| 30 |
+
return new Date(iso).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
|
| 31 |
+
} catch (_) {
|
| 32 |
+
return iso;
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
async function fetchNotifyJobs() {
|
| 37 |
+
try {
|
| 38 |
+
const data = await ddaApi('GET', '/api/dda/jobs?limit=25');
|
| 39 |
+
return data.jobs || [];
|
| 40 |
+
} catch (_) {
|
| 41 |
+
return [];
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
function updateNotifyBadge(jobs) {
|
| 46 |
+
const badge = document.getElementById('dda-notify-badge');
|
| 47 |
+
if (!badge) return;
|
| 48 |
+
const seen = getSeenJobIds();
|
| 49 |
+
const unread = jobs.filter((j) => j.status === 'completed' && j.runId && !seen.has(String(j.id)));
|
| 50 |
+
if (unread.length) {
|
| 51 |
+
badge.textContent = unread.length > 9 ? '9+' : String(unread.length);
|
| 52 |
+
badge.classList.remove('hidden');
|
| 53 |
+
} else {
|
| 54 |
+
badge.classList.add('hidden');
|
| 55 |
+
}
|
| 56 |
+
return unread;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
function renderNotifyPanel(jobs) {
|
| 60 |
+
const panel = document.getElementById('dda-notify-panel');
|
| 61 |
+
if (!panel) return;
|
| 62 |
+
const seen = getSeenJobIds();
|
| 63 |
+
const items = jobs.filter((j) => ['completed', 'failed', 'running', 'queued'].includes(j.status));
|
| 64 |
+
if (!items.length) {
|
| 65 |
+
panel.innerHTML = '<p class="dim dda-notify-empty">No recent jobs.</p>';
|
| 66 |
+
return;
|
| 67 |
+
}
|
| 68 |
+
panel.innerHTML = items.slice(0, 12).map((j) => {
|
| 69 |
+
const unread = j.status === 'completed' && !seen.has(String(j.id));
|
| 70 |
+
const title = j.title || `${j.basePath || 'Base'} vs ${j.comparisonPath || 'Comparison'}`;
|
| 71 |
+
const pct = j.report?.changePercentage;
|
| 72 |
+
const meta = j.status === 'completed' && pct != null ? `${pct.toFixed(2)}% change` : j.status;
|
| 73 |
+
return `
|
| 74 |
+
<button type="button" class="dda-notify-item${unread ? ' unread' : ''}" data-job-id="${j.id}" data-run-id="${j.runId || ''}" data-status="${j.status}">
|
| 75 |
+
<span class="dda-notify-item-title">${title}</span>
|
| 76 |
+
<span class="dda-notify-item-meta">${formatNotifyTime(j.completedAt || j.createdAt)} · ${meta}</span>
|
| 77 |
+
</button>`;
|
| 78 |
+
}).join('');
|
| 79 |
+
|
| 80 |
+
panel.querySelectorAll('.dda-notify-item').forEach((btn) => {
|
| 81 |
+
btn.addEventListener('click', async () => {
|
| 82 |
+
markJobSeen(btn.dataset.jobId);
|
| 83 |
+
const status = btn.dataset.status;
|
| 84 |
+
const runId = btn.dataset.runId;
|
| 85 |
+
closeNotifyPanel();
|
| 86 |
+
if (status === 'completed' && runId) {
|
| 87 |
+
if (typeof showDdaResult === 'function') {
|
| 88 |
+
try {
|
| 89 |
+
const data = await ddaApi('GET', `/api/history/${runId}`);
|
| 90 |
+
showDdaResult(data);
|
| 91 |
+
} catch (_) {
|
| 92 |
+
window.open(`/dda/reports/${runId}`, '_blank');
|
| 93 |
+
}
|
| 94 |
+
} else {
|
| 95 |
+
window.open(`/dda/reports/${runId}`, '_blank');
|
| 96 |
+
}
|
| 97 |
+
} else if (status === 'failed') {
|
| 98 |
+
if (typeof showDdaError === 'function') showDdaError('Detection job failed. See Reports tab for details.');
|
| 99 |
+
document.querySelector('.dda-tab[data-tab="reports"]')?.click();
|
| 100 |
+
} else {
|
| 101 |
+
document.querySelector('.dda-tab[data-tab="reports"]')?.click();
|
| 102 |
+
}
|
| 103 |
+
});
|
| 104 |
+
});
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
function closeNotifyPanel() {
|
| 108 |
+
document.getElementById('dda-notify-panel')?.classList.add('hidden');
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
async function refreshNotifications() {
|
| 112 |
+
const jobs = await fetchNotifyJobs();
|
| 113 |
+
updateNotifyBadge(jobs);
|
| 114 |
+
const panel = document.getElementById('dda-notify-panel');
|
| 115 |
+
if (panel && !panel.classList.contains('hidden')) {
|
| 116 |
+
renderNotifyPanel(jobs);
|
| 117 |
+
}
|
| 118 |
+
return jobs;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
function initNotifications() {
|
| 122 |
+
const btn = document.getElementById('dda-notify-btn');
|
| 123 |
+
if (!btn) return;
|
| 124 |
+
|
| 125 |
+
btn.addEventListener('click', async (e) => {
|
| 126 |
+
e.stopPropagation();
|
| 127 |
+
const panel = document.getElementById('dda-notify-panel');
|
| 128 |
+
const wasHidden = panel?.classList.contains('hidden');
|
| 129 |
+
if (wasHidden) {
|
| 130 |
+
const jobs = await fetchNotifyJobs();
|
| 131 |
+
renderNotifyPanel(jobs);
|
| 132 |
+
panel?.classList.remove('hidden');
|
| 133 |
+
} else {
|
| 134 |
+
closeNotifyPanel();
|
| 135 |
+
}
|
| 136 |
+
});
|
| 137 |
+
|
| 138 |
+
document.addEventListener('click', (e) => {
|
| 139 |
+
if (e.target.closest('.dda-notify-wrap')) return;
|
| 140 |
+
closeNotifyPanel();
|
| 141 |
+
});
|
| 142 |
+
|
| 143 |
+
refreshNotifications();
|
| 144 |
+
setInterval(refreshNotifications, POLL_MS);
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
document.addEventListener('DOMContentLoaded', initNotifications);
|
| 148 |
+
window.markDdaJobSeen = markJobSeen;
|
| 149 |
+
window.refreshDdaNotifications = refreshNotifications;
|
static/js/dda/report_page.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** Standalone report page at /dda/reports/{runId} (FR-05). */
|
| 2 |
+
|
| 3 |
+
function parseReportRunId() {
|
| 4 |
+
const m = window.location.pathname.match(/\/dda\/reports\/(\d+)/);
|
| 5 |
+
return m ? parseInt(m[1], 10) : null;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
function showReportError(msg) {
|
| 9 |
+
const el = document.getElementById('report-error');
|
| 10 |
+
if (!el) return;
|
| 11 |
+
el.textContent = msg;
|
| 12 |
+
el.classList.remove('hidden');
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
function formatCoord(v) {
|
| 16 |
+
if (v == null || v === '') return '—';
|
| 17 |
+
const n = Number(v);
|
| 18 |
+
return Number.isFinite(n) ? n.toFixed(5) : '—';
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
async function loadReportPage() {
|
| 22 |
+
const runId = parseReportRunId();
|
| 23 |
+
const loading = document.getElementById('report-loading');
|
| 24 |
+
const content = document.getElementById('report-content');
|
| 25 |
+
if (!runId) {
|
| 26 |
+
if (loading) loading.innerHTML = '<p class="dim">Invalid report URL.</p>';
|
| 27 |
+
return;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
try {
|
| 31 |
+
const data = await ddaApi('GET', `/api/dda/reports/${runId}`);
|
| 32 |
+
if (loading) loading.classList.add('hidden');
|
| 33 |
+
if (content) content.classList.remove('hidden');
|
| 34 |
+
|
| 35 |
+
document.getElementById('report-title').textContent = data.title || `Run #${runId}`;
|
| 36 |
+
const loc = [data.village, data.zone].filter(Boolean).join(', ') || '—';
|
| 37 |
+
document.getElementById('report-meta').textContent =
|
| 38 |
+
`${data.method || '—'} · ${loc} · ${data.createdAt || ''}`;
|
| 39 |
+
|
| 40 |
+
const stats = data.statistics || {};
|
| 41 |
+
document.getElementById('report-stats').innerHTML = `
|
| 42 |
+
<div class="dda-stat"><span class="label">Change</span><span class="value">${(stats.changePercentage ?? 0).toFixed(2)}%</span></div>
|
| 43 |
+
<div class="dda-stat"><span class="label">Changed px</span><span class="value">${(stats.changedPixels ?? 0).toLocaleString()}</span></div>
|
| 44 |
+
<div class="dda-stat"><span class="label">Regions</span><span class="value">${data.regionsCount ?? (data.regions || []).length}</span></div>`;
|
| 45 |
+
|
| 46 |
+
const overlay = document.getElementById('report-overlay');
|
| 47 |
+
if (overlay) {
|
| 48 |
+
if (data.overlayBase64Png) {
|
| 49 |
+
overlay.src = `data:image/png;base64,${data.overlayBase64Png}`;
|
| 50 |
+
} else if (data.overlayUrl) {
|
| 51 |
+
overlay.src = data.overlayUrl;
|
| 52 |
+
} else {
|
| 53 |
+
overlay.alt = 'No overlay available';
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
const tbody = document.getElementById('report-regions-body');
|
| 58 |
+
const regions = data.regions || [];
|
| 59 |
+
if (tbody) {
|
| 60 |
+
tbody.innerHTML = regions.length
|
| 61 |
+
? regions.map((r) => `
|
| 62 |
+
<tr>
|
| 63 |
+
<td>${r.id ?? ''}</td>
|
| 64 |
+
<td>${r.ddaChangeType || r.objectType || '—'}</td>
|
| 65 |
+
<td>${r.internalObjectType || r.objectType || '—'}</td>
|
| 66 |
+
<td>${((r.confidence ?? 0) * 100).toFixed(0)}%</td>
|
| 67 |
+
<td>${(r.area ?? 0).toLocaleString()}</td>
|
| 68 |
+
<td>${formatCoord(r.latitude ?? r.lat)}</td>
|
| 69 |
+
<td>${formatCoord(r.longitude ?? r.lng)}</td>
|
| 70 |
+
</tr>`).join('')
|
| 71 |
+
: '<tr><td colspan="7" class="dim">No regions detected.</td></tr>';
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
const pdfBtn = document.getElementById('report-pdf-btn');
|
| 75 |
+
if (pdfBtn) {
|
| 76 |
+
pdfBtn.disabled = false;
|
| 77 |
+
pdfBtn.onclick = () => { window.location.href = `/api/dda/reports/${runId}/pdf`; };
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
const viewBtn = document.getElementById('report-view-btn');
|
| 81 |
+
if (viewBtn) {
|
| 82 |
+
viewBtn.disabled = false;
|
| 83 |
+
viewBtn.onclick = () => {
|
| 84 |
+
window.location.href = '/?tab=reports';
|
| 85 |
+
try { sessionStorage.setItem('dda_open_run', String(runId)); } catch (_) {}
|
| 86 |
+
};
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
window._reportPageData = data;
|
| 90 |
+
} catch (err) {
|
| 91 |
+
if (loading) loading.classList.add('hidden');
|
| 92 |
+
showReportError(err.message || 'Could not load report.');
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
document.getElementById('report-email-btn')?.addEventListener('click', async () => {
|
| 97 |
+
const runId = parseReportRunId();
|
| 98 |
+
const input = document.getElementById('report-email');
|
| 99 |
+
const msg = document.getElementById('report-email-msg');
|
| 100 |
+
const email = (input?.value || '').trim();
|
| 101 |
+
if (!email) {
|
| 102 |
+
if (msg) msg.textContent = 'Enter an email address.';
|
| 103 |
+
return;
|
| 104 |
+
}
|
| 105 |
+
try {
|
| 106 |
+
const res = await ddaApi('POST', `/api/dda/reports/${runId}/notify`, {
|
| 107 |
+
body: JSON.stringify({ email }),
|
| 108 |
+
});
|
| 109 |
+
if (msg) msg.textContent = res.message || 'Sent.';
|
| 110 |
+
} catch (err) {
|
| 111 |
+
if (msg) msg.textContent = err.message || 'Send failed.';
|
| 112 |
+
}
|
| 113 |
+
});
|
| 114 |
+
|
| 115 |
+
loadReportPage();
|
static/js/dda/reports.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
/** Reports tab — detection jobs
|
| 2 |
|
| 3 |
function formatReportDate(iso) {
|
| 4 |
if (!iso) return '—';
|
|
@@ -68,7 +68,7 @@ async function loadReportsList() {
|
|
| 68 |
<th>Status</th>
|
| 69 |
<th>Change %</th>
|
| 70 |
<th>Regions</th>
|
| 71 |
-
<th></th>
|
| 72 |
</tr>
|
| 73 |
</thead>
|
| 74 |
<tbody>
|
|
@@ -79,10 +79,12 @@ async function loadReportsList() {
|
|
| 79 |
<td><span class="dda-status dda-status-${r.status}">${r.status}</span></td>
|
| 80 |
<td>${r.changePct != null ? r.changePct.toFixed(2) + '%' : '—'}</td>
|
| 81 |
<td>${r.regions ?? '—'}</td>
|
| 82 |
-
<td>
|
| 83 |
${r.status === 'completed' && r.runId
|
| 84 |
-
? `<button type="button" class="btn btn-secondary btn-sm" data-view-run="${r.runId}">View</button>
|
| 85 |
-
|
|
|
|
|
|
|
| 86 |
</td>
|
| 87 |
</tr>`).join('')}
|
| 88 |
</tbody>
|
|
@@ -114,6 +116,22 @@ document.querySelectorAll('.dda-tab').forEach((btn) => {
|
|
| 114 |
}
|
| 115 |
});
|
| 116 |
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** Reports tab — detection jobs, history, PDF export (FR-05). */
|
| 2 |
|
| 3 |
function formatReportDate(iso) {
|
| 4 |
if (!iso) return '—';
|
|
|
|
| 68 |
<th>Status</th>
|
| 69 |
<th>Change %</th>
|
| 70 |
<th>Regions</th>
|
| 71 |
+
<th>Actions</th>
|
| 72 |
</tr>
|
| 73 |
</thead>
|
| 74 |
<tbody>
|
|
|
|
| 79 |
<td><span class="dda-status dda-status-${r.status}">${r.status}</span></td>
|
| 80 |
<td>${r.changePct != null ? r.changePct.toFixed(2) + '%' : '—'}</td>
|
| 81 |
<td>${r.regions ?? '—'}</td>
|
| 82 |
+
<td class="dda-report-actions-cell">
|
| 83 |
${r.status === 'completed' && r.runId
|
| 84 |
+
? `<button type="button" class="btn btn-secondary btn-sm" data-view-run="${r.runId}">View</button>
|
| 85 |
+
<a class="btn btn-secondary btn-sm" href="/dda/reports/${r.runId}" target="_blank" rel="noopener">Report</a>
|
| 86 |
+
<a class="btn btn-secondary btn-sm" href="/api/dda/reports/${r.runId}/pdf" download>PDF</a>`
|
| 87 |
+
: (r.error ? `<span class="dim" title="${String(r.error).replace(/"/g, '')}">Error</span>` : '—')}
|
| 88 |
</td>
|
| 89 |
</tr>`).join('')}
|
| 90 |
</tbody>
|
|
|
|
| 116 |
}
|
| 117 |
});
|
| 118 |
|
| 119 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 120 |
+
try {
|
| 121 |
+
const openRun = sessionStorage.getItem('dda_open_run');
|
| 122 |
+
if (openRun) {
|
| 123 |
+
sessionStorage.removeItem('dda_open_run');
|
| 124 |
+
document.querySelector('.dda-tab[data-tab="reports"]')?.click();
|
| 125 |
+
setTimeout(async () => {
|
| 126 |
+
try {
|
| 127 |
+
const data = await ddaApi('GET', `/api/history/${openRun}`);
|
| 128 |
+
if (typeof showDdaResult === 'function') showDdaResult(data);
|
| 129 |
+
} catch (_) {}
|
| 130 |
+
}, 300);
|
| 131 |
+
}
|
| 132 |
+
} catch (_) {}
|
| 133 |
+
|
| 134 |
+
if (document.getElementById('tab-reports')?.classList.contains('active')) {
|
| 135 |
+
loadReportsList();
|
| 136 |
+
}
|
| 137 |
+
});
|
templates/ChangeDetection.html
CHANGED
|
@@ -80,8 +80,10 @@
|
|
| 80 |
</table>
|
| 81 |
{{/regions}}
|
| 82 |
|
|
|
|
|
|
|
| 83 |
<p style="margin:24px 0 0; color:#333; font-size:15px; line-height:1.6;">
|
| 84 |
-
You can view the full overlay image and detailed results
|
| 85 |
</p>
|
| 86 |
</td>
|
| 87 |
</tr>
|
|
|
|
| 80 |
</table>
|
| 81 |
{{/regions}}
|
| 82 |
|
| 83 |
+
{{report_link_section}}
|
| 84 |
+
|
| 85 |
<p style="margin:24px 0 0; color:#333; font-size:15px; line-height:1.6;">
|
| 86 |
+
You can view the full overlay image and detailed results in the application.
|
| 87 |
</p>
|
| 88 |
</td>
|
| 89 |
</tr>
|
templates/index_dda.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<title>DDA Change Detection</title>
|
| 7 |
<link rel="stylesheet" href="/static/css/style.css?v=30" />
|
| 8 |
-
<link rel="stylesheet" href="/static/css/dda.css?v=
|
| 9 |
</head>
|
| 10 |
<body>
|
| 11 |
<div class="app dda-app">
|
|
@@ -20,6 +20,13 @@
|
|
| 20 |
<button type="button" class="dda-tab" data-tab="detect" role="tab">Change Detection</button>
|
| 21 |
<button type="button" class="dda-tab" data-tab="reports" role="tab">Reports</button>
|
| 22 |
</nav>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
</header>
|
| 24 |
|
| 25 |
<div id="dda-error" class="alert alert-error hidden"></div>
|
|
@@ -140,6 +147,8 @@
|
|
| 140 |
</div>
|
| 141 |
<label class="dda-check"><input type="checkbox" id="dda-detect-registration" checked /> Image registration</label>
|
| 142 |
<label class="dda-check"><input type="checkbox" id="dda-detect-normalization" checked /> Normalization</label>
|
|
|
|
|
|
|
| 143 |
</div>
|
| 144 |
<p class="dim" id="dda-detect-res-hint"></p>
|
| 145 |
<button type="button" class="btn btn-primary" id="btn-run-job" disabled>Run Detection</button>
|
|
@@ -154,7 +163,7 @@
|
|
| 154 |
<h3>Detection Reports</h3>
|
| 155 |
<button type="button" class="btn btn-secondary btn-sm" id="btn-reports-refresh">Refresh</button>
|
| 156 |
</div>
|
| 157 |
-
<p class="sub dim">Completed jobs and detection history. PDF
|
| 158 |
<div id="reports-list"><p class="dim">Open this tab to load reports.</p></div>
|
| 159 |
</div>
|
| 160 |
</section>
|
|
@@ -232,10 +241,11 @@
|
|
| 232 |
</div>
|
| 233 |
</div>
|
| 234 |
|
| 235 |
-
<script src="/static/js/dda/app.js?v=
|
| 236 |
<script src="/static/js/dda/library.js?v=6"></script>
|
| 237 |
<script src="/static/js/dda/result.js?v=4"></script>
|
| 238 |
-
<script src="/static/js/dda/compare.js?v=
|
| 239 |
-
<script src="/static/js/dda/reports.js?v=
|
|
|
|
| 240 |
</body>
|
| 241 |
</html>
|
|
|
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<title>DDA Change Detection</title>
|
| 7 |
<link rel="stylesheet" href="/static/css/style.css?v=30" />
|
| 8 |
+
<link rel="stylesheet" href="/static/css/dda.css?v=9" />
|
| 9 |
</head>
|
| 10 |
<body>
|
| 11 |
<div class="app dda-app">
|
|
|
|
| 20 |
<button type="button" class="dda-tab" data-tab="detect" role="tab">Change Detection</button>
|
| 21 |
<button type="button" class="dda-tab" data-tab="reports" role="tab">Reports</button>
|
| 22 |
</nav>
|
| 23 |
+
<div class="dda-notify-wrap">
|
| 24 |
+
<button type="button" id="dda-notify-btn" class="dda-notify-btn" title="Job notifications" aria-label="Notifications">
|
| 25 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 01-3.46 0"/></svg>
|
| 26 |
+
<span id="dda-notify-badge" class="dda-notify-badge hidden">0</span>
|
| 27 |
+
</button>
|
| 28 |
+
<div id="dda-notify-panel" class="dda-notify-panel hidden" role="menu"></div>
|
| 29 |
+
</div>
|
| 30 |
</header>
|
| 31 |
|
| 32 |
<div id="dda-error" class="alert alert-error hidden"></div>
|
|
|
|
| 147 |
</div>
|
| 148 |
<label class="dda-check"><input type="checkbox" id="dda-detect-registration" checked /> Image registration</label>
|
| 149 |
<label class="dda-check"><input type="checkbox" id="dda-detect-normalization" checked /> Normalization</label>
|
| 150 |
+
<label class="dda-check"><input type="checkbox" id="dda-detect-notify" /> Email report when done</label>
|
| 151 |
+
<input type="email" id="dda-detect-notify-email" class="dda-notify-email hidden" placeholder="recipient@example.com" />
|
| 152 |
</div>
|
| 153 |
<p class="dim" id="dda-detect-res-hint"></p>
|
| 154 |
<button type="button" class="btn btn-primary" id="btn-run-job" disabled>Run Detection</button>
|
|
|
|
| 163 |
<h3>Detection Reports</h3>
|
| 164 |
<button type="button" class="btn btn-secondary btn-sm" id="btn-reports-refresh">Refresh</button>
|
| 165 |
</div>
|
| 166 |
+
<p class="sub dim">Completed jobs and detection history. Download PDF or open the browser report.</p>
|
| 167 |
<div id="reports-list"><p class="dim">Open this tab to load reports.</p></div>
|
| 168 |
</div>
|
| 169 |
</section>
|
|
|
|
| 241 |
</div>
|
| 242 |
</div>
|
| 243 |
|
| 244 |
+
<script src="/static/js/dda/app.js?v=11"></script>
|
| 245 |
<script src="/static/js/dda/library.js?v=6"></script>
|
| 246 |
<script src="/static/js/dda/result.js?v=4"></script>
|
| 247 |
+
<script src="/static/js/dda/compare.js?v=8"></script>
|
| 248 |
+
<script src="/static/js/dda/reports.js?v=3"></script>
|
| 249 |
+
<script src="/static/js/dda/notifications.js?v=1"></script>
|
| 250 |
</body>
|
| 251 |
</html>
|
templates/report_dda.html
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>DDA Detection Report</title>
|
| 7 |
+
<link rel="stylesheet" href="/static/css/style.css?v=30" />
|
| 8 |
+
<link rel="stylesheet" href="/static/css/dda.css?v=9" />
|
| 9 |
+
</head>
|
| 10 |
+
<body>
|
| 11 |
+
<div class="app dda-app dda-report-page">
|
| 12 |
+
<header class="dda-header dda-report-header">
|
| 13 |
+
<div class="app-brand">
|
| 14 |
+
<span>DDA Change Detection — Report</span>
|
| 15 |
+
</div>
|
| 16 |
+
<div class="dda-report-actions">
|
| 17 |
+
<a href="/" class="btn btn-secondary btn-sm">← Back to app</a>
|
| 18 |
+
<button type="button" class="btn btn-secondary btn-sm" id="report-pdf-btn" disabled>Download PDF</button>
|
| 19 |
+
<button type="button" class="btn btn-primary btn-sm" id="report-view-btn" disabled>Interactive view</button>
|
| 20 |
+
</div>
|
| 21 |
+
</header>
|
| 22 |
+
|
| 23 |
+
<div id="report-error" class="alert alert-error hidden"></div>
|
| 24 |
+
<div id="report-loading" class="card"><p class="dim">Loading report…</p></div>
|
| 25 |
+
<div id="report-content" class="hidden">
|
| 26 |
+
<div class="card dda-report-summary">
|
| 27 |
+
<h1 id="report-title"></h1>
|
| 28 |
+
<p class="dim" id="report-meta"></p>
|
| 29 |
+
<div class="dda-report-stats" id="report-stats"></div>
|
| 30 |
+
</div>
|
| 31 |
+
<div class="card dda-report-overlay-wrap">
|
| 32 |
+
<h3>Change overlay</h3>
|
| 33 |
+
<img id="report-overlay" alt="Change overlay" class="dda-report-overlay" />
|
| 34 |
+
</div>
|
| 35 |
+
<div class="card">
|
| 36 |
+
<h3>Detected regions</h3>
|
| 37 |
+
<div class="table-wrap">
|
| 38 |
+
<table class="dda-reports-table dda-report-regions" id="report-regions-table">
|
| 39 |
+
<thead>
|
| 40 |
+
<tr>
|
| 41 |
+
<th>#</th>
|
| 42 |
+
<th>DDA type</th>
|
| 43 |
+
<th>Internal type</th>
|
| 44 |
+
<th>Confidence</th>
|
| 45 |
+
<th>Area (px)</th>
|
| 46 |
+
<th>Lat</th>
|
| 47 |
+
<th>Lng</th>
|
| 48 |
+
</tr>
|
| 49 |
+
</thead>
|
| 50 |
+
<tbody id="report-regions-body"></tbody>
|
| 51 |
+
</table>
|
| 52 |
+
</div>
|
| 53 |
+
</div>
|
| 54 |
+
<div class="card dda-report-email">
|
| 55 |
+
<h3>Email this report</h3>
|
| 56 |
+
<div class="dda-report-email-row">
|
| 57 |
+
<input type="email" id="report-email" placeholder="recipient@example.com" />
|
| 58 |
+
<button type="button" class="btn btn-secondary" id="report-email-btn">Send link</button>
|
| 59 |
+
</div>
|
| 60 |
+
<p class="dim" id="report-email-msg"></p>
|
| 61 |
+
</div>
|
| 62 |
+
</div>
|
| 63 |
+
</div>
|
| 64 |
+
<script src="/static/js/dda/app.js?v=11"></script>
|
| 65 |
+
<script src="/static/js/dda/report_page.js?v=1"></script>
|
| 66 |
+
</body>
|
| 67 |
+
</html>
|