From 70c2850f228c93ab5d2462ac9a39b0bc5dab6cc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Sandvik?= Date: Fri, 8 May 2026 01:42:50 +0200 Subject: [PATCH 1/3] feat: add management UI at /manage for ingestion and sync Implements issue #72. Adds a server-rendered management page at GET /manage that lets operators ingest and sync datasets without needing to know API endpoint details or dataset template IDs. - GET /manage renders a Jinja2 page with an ingest form (template dropdown, start/end dates, extent pre-filled) and a status table with per-dataset Sync buttons; flash messages show success or error after each operation - POST /manage/ingest handles the ingest form and redirects back to /manage - POST /manage/sync handles the sync form and redirects back to /manage - Landing page gains an "Available dataset templates" card listing all registered templates and a Manage link in the Explore section --- src/climate_api/system/routes.py | 87 +++- src/climate_api/system/templates.py | 56 ++- src/climate_api/templates/landing_page.html | 43 ++ src/climate_api/templates/manage.html | 423 ++++++++++++++++++++ 4 files changed, 597 insertions(+), 12 deletions(-) create mode 100644 src/climate_api/templates/manage.html diff --git a/src/climate_api/system/routes.py b/src/climate_api/system/routes.py index 05af11b7..4b9e7fe8 100644 --- a/src/climate_api/system/routes.py +++ b/src/climate_api/system/routes.py @@ -1,13 +1,15 @@ """Root API endpoints.""" import sys +import urllib.parse from importlib.metadata import version as _pkg_version from fastapi import APIRouter, Request from fastapi.responses import HTMLResponse, JSONResponse, Response +from starlette.responses import RedirectResponse from .schemas import AppInfo, HealthStatus, Status -from .templates import ROOT_RESPONSES, app_version, render_landing, render_maps, root_json, wants_json +from .templates import ROOT_RESPONSES, app_version, render_landing, render_manage, render_maps, root_json, wants_json router = APIRouter() @@ -28,6 +30,89 @@ def maps(request: Request) -> HTMLResponse: return HTMLResponse(render_maps(base)) +@router.get("/manage", response_class=HTMLResponse, include_in_schema=False) +def manage( + request: Request, + message: str | None = None, + error: str | None = None, +) -> HTMLResponse: + """Return the management interface for ingestion and sync operations.""" + base = str(request.base_url).rstrip("/") + return HTMLResponse(render_manage(app_version, base, message=message, error=error)) + + +@router.post("/manage/ingest", include_in_schema=False) +async def manage_ingest(request: Request) -> RedirectResponse: + """Handle ingest form submission and redirect to the management page.""" + from fastapi import HTTPException + + from climate_api.data_registry.services.datasets import get_dataset + from climate_api.extents.services import get_extent + from climate_api.ingestions.services import create_artifact + + base = str(request.base_url).rstrip("/") + try: + form = await request.form() + dataset_id = str(form.get("dataset_id", "")) + start = str(form.get("start", "")) + end = str(form.get("end", "")) or None + publish = "publish" in form + overwrite = "overwrite" in form + + template = get_dataset(dataset_id) + if template is None: + msg = urllib.parse.quote(f"Dataset template '{dataset_id}' not found") + return RedirectResponse(f"{base}/manage?error={msg}", status_code=303) + + extent = get_extent() + resolved_bbox = list(extent["bbox"]) if extent else None + extent_id = extent["id"] if extent else None + country_code = extent.get("country_code") if extent else None + + create_artifact( + dataset=template, + start=start, + end=end, + extent_id=extent_id, + bbox=resolved_bbox, + country_code=country_code, + overwrite=overwrite, + prefer_zarr=True, + publish=publish, + ) + name = urllib.parse.quote(template.get("name", dataset_id)) + return RedirectResponse(f"{base}/manage?message=Ingested+{name}", status_code=303) + except HTTPException as exc: + msg = urllib.parse.quote(str(exc.detail)) + return RedirectResponse(f"{base}/manage?error={msg}", status_code=303) + except Exception as exc: + msg = urllib.parse.quote(str(exc)) + return RedirectResponse(f"{base}/manage?error={msg}", status_code=303) + + +@router.post("/manage/sync", include_in_schema=False) +async def manage_sync(request: Request) -> RedirectResponse: + """Handle sync form submission and redirect to the management page.""" + from fastapi import HTTPException + + from climate_api.ingestions.services import sync_dataset + + base = str(request.base_url).rstrip("/") + try: + form = await request.form() + dataset_id = str(form.get("dataset_id", "")) + publish = "publish" in form + + sync_dataset(dataset_id=dataset_id, end=None, prefer_zarr=True, publish=publish) + return RedirectResponse(f"{base}/manage?message=Sync+completed", status_code=303) + except HTTPException as exc: + msg = urllib.parse.quote(str(exc.detail)) + return RedirectResponse(f"{base}/manage?error={msg}", status_code=303) + except Exception as exc: + msg = urllib.parse.quote(str(exc)) + return RedirectResponse(f"{base}/manage?error={msg}", status_code=303) + + @router.get("/health") def health() -> HealthStatus: """Return health status for container health checks.""" diff --git a/src/climate_api/system/templates.py b/src/climate_api/system/templates.py index 2aba0070..5e09d854 100644 --- a/src/climate_api/system/templates.py +++ b/src/climate_api/system/templates.py @@ -2,6 +2,7 @@ import importlib.resources import logging +from datetime import date from importlib.metadata import PackageNotFoundError from importlib.metadata import version as _pkg_version from typing import Any @@ -9,6 +10,7 @@ import jinja2 from fastapi import Request +from climate_api.data_registry.services import datasets as registry_datasets from climate_api.extents.services import get_extent from climate_api.ingestions.services import list_datasets @@ -99,23 +101,55 @@ def render_maps(base: str) -> str: return get_template("maps.html").render(base=base) -def render_landing(version: str, base: str) -> str: - """Render the root landing page with live instance status.""" +def _load_extent() -> dict[str, Any] | None: try: - extent: dict[str, Any] | None = get_extent() + return get_extent() except ValueError: - extent = None + return None except Exception: - _log.exception("Unexpected error loading extent for landing page") - extent = None + _log.exception("Unexpected error loading extent") + return None + + +def _load_templates() -> list[dict[str, Any]]: try: - datasets = list_datasets().items + return registry_datasets.list_datasets() except Exception: - _log.exception("Unexpected error loading datasets for landing page") - datasets = [] + _log.exception("Unexpected error loading dataset templates") + return [] + + +def _load_datasets() -> list[Any]: + try: + return list_datasets().items + except Exception: + _log.exception("Unexpected error loading datasets") + return [] + + +def render_landing(version: str, base: str) -> str: + """Render the root landing page with live instance status.""" return get_template("landing_page.html").render( version=version, base=base, - extent=extent, - datasets=datasets, + extent=_load_extent(), + datasets=_load_datasets(), + templates=_load_templates(), + ) + + +def render_manage(version: str, base: str, message: str | None = None, error: str | None = None) -> str: + """Render the management page.""" + today = date.today().isoformat() + year_ago = date.today().replace(year=date.today().year - 1).isoformat() + return get_template("manage.html").render( + version=version, + base=base, + extent=_load_extent(), + templates=_load_templates(), + datasets=_load_datasets(), + today=today, + year_ago=year_ago, + message=message, + error=error, ) diff --git a/src/climate_api/templates/landing_page.html b/src/climate_api/templates/landing_page.html index 3e4ce5a3..6bd7703b 100644 --- a/src/climate_api/templates/landing_page.html +++ b/src/climate_api/templates/landing_page.html @@ -264,10 +264,53 @@

Datasets {{ datasets | length }}

{% endif %} + +
+

Available dataset templates {{ templates | length }}

+ {% if templates %} + + + + + + + + + + + {% for t in templates %} + + + + + + + {% endfor %} + +
NameVariablePeriodSource
{{ t.name }}{{ t.variable }}{{ t.period_type }} + {% if t.source_url %} + {{ t.source or t.id }} + {% else %} + {{ t.source or '—' }} + {% endif %} +
+ {% else %} +

No dataset templates found.

+ {% endif %} +
+

Explore