diff --git a/backend/app/api/v1/admin_scrape.py b/backend/app/api/v1/admin_scrape.py
index 3badc6e4..30e2b6ae 100644
--- a/backend/app/api/v1/admin_scrape.py
+++ b/backend/app/api/v1/admin_scrape.py
@@ -69,17 +69,65 @@ def trigger_kn_sweep(
@router.get("/queue")
def queue_status(
+ db: Annotated[Session, Depends(get_db)],
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
) -> dict[str, Any]:
- """Snapshot of Celery worker(s) state: active tasks, reserved (queued on
- worker, not yet started), scheduled (with ETA), beat-schedule, and queue
- depth in Redis. If no worker is running, all worker-side fields are empty.
+ """Snapshot of Celery state, optimised for UI poll latency.
+
+ - active: from kn_scrape_runs WHERE status='running' (instant, richer info
+ than celery inspect — includes objects_count/flats_count/requests_count).
+ - reserved + workers: celery inspect with short timeout (500 ms), called in
+ a thread so a missing worker can't block the request.
+ - queue_depth: Redis LLEN on default queue (single round-trip).
+ - scheduled: omitted — we don't use ETA tasks.
+
+ Worst-case latency ≈ 600 ms even if no worker is reachable, vs ~8 s with
+ the previous 4×2 s blocking inspect calls.
"""
_check_token(x_admin_token)
+ import concurrent.futures
+ import time
+
from app.workers.celery_app import celery_app
- inspect = celery_app.control.inspect(timeout=2.0)
+ # 1) Active runs straight from DB — no broker round-trip.
+ active_rows = (
+ db.execute(
+ text(
+ """
+ SELECT run_id, started_at, region_codes, developer_ids,
+ objects_count, flats_count, requests_count
+ FROM kn_scrape_runs
+ WHERE status = 'running'
+ ORDER BY started_at DESC
+ LIMIT 20
+ """
+ )
+ )
+ .mappings()
+ .all()
+ )
+ active: list[dict[str, Any]] = [
+ {
+ "worker": "db",
+ "task_id": f"run-{r['run_id']}",
+ "name": "scrape_kn_region",
+ "args": [list(r["region_codes"] or []), list(r["developer_ids"] or [])],
+ "kwargs": {
+ "objects": r["objects_count"],
+ "flats": r["flats_count"],
+ "requests": r["requests_count"],
+ },
+ "time_start": (int(r["started_at"].timestamp()) if r["started_at"] else None),
+ "eta": None,
+ }
+ for r in active_rows
+ ]
+
+ # 2) Run reserved + ping in parallel with a tight timeout so the endpoint
+ # never hangs when the broker is unreachable or worker is gone.
+ inspect = celery_app.control.inspect(timeout=0.5)
def _flatten(per_worker: dict[str, list[dict[str, Any]]] | None) -> list[dict[str, Any]]:
if not per_worker:
@@ -100,42 +148,43 @@ def queue_status(
)
return out
- try:
- active = _flatten(inspect.active())
- except Exception:
- active = []
- try:
- reserved = _flatten(inspect.reserved())
- except Exception:
- reserved = []
- try:
- scheduled = _flatten(inspect.scheduled())
- except Exception:
- scheduled = []
+ def _safe(fn):
+ try:
+ return fn()
+ except Exception:
+ return None
- # Pending in broker queue (not yet picked up by any worker).
+ deadline = time.monotonic() + 0.8
+ with concurrent.futures.ThreadPoolExecutor(max_workers=2) as ex:
+ f_reserved = ex.submit(_safe, inspect.reserved)
+ f_ping = ex.submit(_safe, inspect.ping)
+ try:
+ reserved_raw = f_reserved.result(timeout=max(0.1, deadline - time.monotonic()))
+ except concurrent.futures.TimeoutError:
+ reserved_raw = None
+ try:
+ ping_resp = f_ping.result(timeout=max(0.1, deadline - time.monotonic()))
+ except concurrent.futures.TimeoutError:
+ ping_resp = None
+
+ reserved = _flatten(reserved_raw)
+ workers = list((ping_resp or {}).keys())
+
+ # 3) Pending in broker queue (not yet picked up by any worker).
queue_depth: int | None = None
try:
with celery_app.connection_or_acquire() as conn:
with conn.channel() as channel:
- # Default queue name is 'celery'.
queue_depth = channel.client.llen("celery")
except Exception:
queue_depth = None
- workers: list[str] = []
- try:
- ping_resp = inspect.ping() or {}
- workers = list(ping_resp.keys())
- except Exception:
- pass
-
return {
"workers": workers,
"queue_depth": queue_depth,
"active": active,
"reserved": reserved,
- "scheduled": scheduled,
+ "scheduled": [],
}
diff --git a/backend/app/api/v1/photos.py b/backend/app/api/v1/photos.py
new file mode 100644
index 00000000..ab06f637
--- /dev/null
+++ b/backend/app/api/v1/photos.py
@@ -0,0 +1,87 @@
+"""Serve cached DOM.РФ photos.
+
+GET /api/v1/photos/{obj_id}/{file_id}?size=thumb|full
+
+- size=thumb (default): WebP 320×240 from local cache. If thumb is missing
+ but the original is present locally, generate one on the fly. If neither
+ is present, redirect to the upstream DOM.РФ URL.
+- size=full: original PNG/JPG if cached locally, else redirect upstream.
+
+This isolates the frontend grid from upstream latency and from Next.js dev-mode
+optimizer cold-hit cost.
+"""
+
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+from typing import Annotated, Literal
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from fastapi.responses import FileResponse, RedirectResponse, Response
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+from app.core.db import get_db
+from app.services.photos.thumbs import make_thumbnail
+
+logger = logging.getLogger(__name__)
+router = APIRouter()
+
+
+_PHOTO_LOOKUP_SQL = text(
+ """
+ SELECT local_path, thumb_path, photo_url
+ FROM domrf_kn_photos
+ WHERE obj_id = :obj AND obj_file_id = :fid
+ LIMIT 1
+ """
+)
+
+
+@router.get("/{obj_id}/{file_id}")
+def get_photo(
+ db: Annotated[Session, Depends(get_db)],
+ obj_id: int,
+ file_id: str,
+ size: Annotated[Literal["thumb", "full"], Query()] = "thumb",
+) -> Response:
+ row = db.execute(_PHOTO_LOOKUP_SQL, {"obj": obj_id, "fid": file_id}).mappings().first()
+ if not row:
+ raise HTTPException(status_code=404, detail="photo not registered")
+
+ local_path = row["local_path"]
+ thumb_path = row["thumb_path"]
+ upstream = row["photo_url"]
+
+ headers = {"Cache-Control": "public, max-age=604800, immutable"}
+
+ if size == "full":
+ if local_path and Path(local_path).exists():
+ return FileResponse(local_path, headers=headers)
+ if upstream:
+ return RedirectResponse(upstream, status_code=302)
+ raise HTTPException(status_code=404, detail="full photo unavailable")
+
+ # size=thumb
+ if thumb_path and Path(thumb_path).exists():
+ return FileResponse(thumb_path, media_type="image/webp", headers=headers)
+
+ # Generate thumb on the fly from cached original
+ if local_path and Path(local_path).exists():
+ generated = make_thumbnail(Path(local_path))
+ if generated and generated.exists():
+ db.execute(
+ text(
+ "UPDATE domrf_kn_photos SET thumb_path = :tp"
+ " WHERE obj_id = :o AND obj_file_id = :f"
+ ),
+ {"tp": str(generated), "o": obj_id, "f": file_id},
+ )
+ db.commit()
+ return FileResponse(str(generated), media_type="image/webp", headers=headers)
+
+ # Last resort — redirect to upstream so the page still renders
+ if upstream:
+ return RedirectResponse(upstream, status_code=302)
+ raise HTTPException(status_code=404, detail="photo unavailable")
diff --git a/backend/app/main.py b/backend/app/main.py
index 8203b642..768d31ac 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -5,7 +5,7 @@ import sentry_sdk
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
-from app.api.v1 import admin_leads, admin_scrape, analytics, concepts, parcels
+from app.api.v1 import admin_leads, admin_scrape, analytics, concepts, parcels, photos
from app.core.config import settings
@@ -35,6 +35,7 @@ app.include_router(parcels.router, prefix="/api/v1/parcels", tags=["parcels"])
app.include_router(analytics.router, prefix="/api/v1/analytics", tags=["analytics"])
app.include_router(admin_scrape.router, prefix="/api/v1/admin/scrape", tags=["admin"])
app.include_router(admin_leads.router, prefix="/api/v1/admin/leads", tags=["admin"])
+app.include_router(photos.router, prefix="/api/v1/photos", tags=["photos"])
@app.get("/health")
diff --git a/backend/app/services/analytics_queries.py b/backend/app/services/analytics_queries.py
index 1dddf81e..2ceaabb2 100644
--- a/backend/app/services/analytics_queries.py
+++ b/backend/app/services/analytics_queries.py
@@ -787,6 +787,10 @@ def object_photos(db: Session, obj_id: int, limit: int = 100) -> list[dict[str,
"obj_file_id": r["obj_file_id"],
"ord_num": r["ord_num"],
"photo_url": r["photo_url"],
+ # Always serve thumbs through our backend — cached WebP, no upstream
+ # latency, no Next.js dev-mode optimizer cold-hit cost.
+ "thumb_url": f"/api/v1/photos/{obj_id}/{r['obj_file_id']}?size=thumb",
+ "full_url": f"/api/v1/photos/{obj_id}/{r['obj_file_id']}?size=full",
"photo_dttm": r["photo_dttm"].isoformat() if r["photo_dttm"] else None,
"period_dt": r["period_dt"].isoformat() if r["period_dt"] else None,
"size_bytes": r["size_bytes"],
diff --git a/backend/app/services/photos/__init__.py b/backend/app/services/photos/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/backend/app/services/photos/thumbs.py b/backend/app/services/photos/thumbs.py
new file mode 100644
index 00000000..5b79e22e
--- /dev/null
+++ b/backend/app/services/photos/thumbs.py
@@ -0,0 +1,57 @@
+"""Thumbnail generation for DOM.РФ photos.
+
+Strategy: original PNG/JPG ~1-3 MB downloaded by scraper to
+data/raw/domrf_photos/
SCRAPE_KN_CRON).
+