release: bump version to 0.49.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -28,7 +28,7 @@ async def login(
|
||||
):
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT id, username, email, password_hash, role, is_active FROM users WHERE username = :username"
|
||||
"SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups FROM users WHERE username = :username"
|
||||
),
|
||||
{"username": form_data.username},
|
||||
)
|
||||
@@ -46,6 +46,7 @@ async def login(
|
||||
user.password_hash = row[3]
|
||||
user.role = row[4]
|
||||
user.is_active = row[5]
|
||||
user.gatekeeper_groups = row[6] or []
|
||||
|
||||
if not verify_password(form_data.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
@@ -73,6 +74,7 @@ async def login(
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"role": user.role,
|
||||
"gatekeeper_groups": user.gatekeeper_groups or [],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -95,6 +97,7 @@ async def refresh_token(
|
||||
"id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"role": current_user.role,
|
||||
"gatekeeper_groups": current_user.gatekeeper_groups or [],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -111,6 +114,7 @@ async def get_me(current_user: User = Depends(get_current_user)):
|
||||
"username": current_user.username,
|
||||
"email": current_user.email,
|
||||
"role": current_user.role,
|
||||
"gatekeeper_groups": current_user.gatekeeper_groups or [],
|
||||
"is_active": current_user.is_active,
|
||||
"created_at": current_user.created_at,
|
||||
}
|
||||
|
||||
@@ -5,12 +5,18 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.security import get_current_user
|
||||
from app.db.session import get_db
|
||||
from app.models.bgp_anomaly import BGPAnomaly
|
||||
from app.models.bgp_incident import BGPIncident
|
||||
from app.models.bgp_observation import BGPObservation
|
||||
from app.models.user import User
|
||||
from app.services.bgp_collector_locations import (
|
||||
collect_bgp_collector_location_candidates,
|
||||
get_bgp_collector_location_dict,
|
||||
)
|
||||
from app.services.bgp_collectors import build_bgp_collector_coverage
|
||||
|
||||
router = APIRouter()
|
||||
@@ -264,6 +270,77 @@ async def get_bgp_collector_summary(
|
||||
}
|
||||
|
||||
|
||||
class CollectBGPCollectorLocationRequest(BaseModel):
|
||||
city: Optional[str] = None
|
||||
country: Optional[str] = None
|
||||
site: Optional[str] = None
|
||||
operator: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/collectors/{collector_id}/collect-location")
|
||||
async def collect_bgp_collector_location(
|
||||
collector_id: str,
|
||||
payload: CollectBGPCollectorLocationRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Run the shared location pipeline for a BGP route collector.
|
||||
|
||||
Mirrors ``POST /api/v1/visualization/compute-centers/{source_id}/collect-location``.
|
||||
Returns ranked candidates from source coordinates and Nominatim queries
|
||||
built around the collector's stored context (IXP / city / country). Stored
|
||||
collector locations provide context only; they are not emitted as
|
||||
candidates.
|
||||
"""
|
||||
if not collector_id or not collector_id.strip():
|
||||
raise HTTPException(status_code=400, detail="collector_id is required")
|
||||
|
||||
legacy = get_bgp_collector_location_dict(collector_id) or {}
|
||||
site = payload.site or legacy.get("matched_location_name")
|
||||
city = payload.city or legacy.get("city")
|
||||
country = payload.country or legacy.get("country")
|
||||
operator = payload.operator or "RIPE NCC"
|
||||
|
||||
candidates, attempted_queries = collect_bgp_collector_location_candidates(
|
||||
collector=collector_id,
|
||||
site=site,
|
||||
city=city,
|
||||
country=country,
|
||||
operator=operator,
|
||||
)
|
||||
|
||||
context = {
|
||||
"collector": collector_id,
|
||||
"site": site,
|
||||
"city": city,
|
||||
"country": country,
|
||||
"operator": operator,
|
||||
}
|
||||
|
||||
if not candidates:
|
||||
return {
|
||||
"collector_id": collector_id,
|
||||
"name": collector_id,
|
||||
"success": False,
|
||||
"failure_reason": (
|
||||
"No source coordinates or online geocoding result reached"
|
||||
" city-level precision for this collector."
|
||||
),
|
||||
"candidates": [],
|
||||
"attempted_queries": list(attempted_queries),
|
||||
"context": context,
|
||||
}
|
||||
|
||||
return {
|
||||
"collector_id": collector_id,
|
||||
"name": collector_id,
|
||||
"success": True,
|
||||
"candidates": [candidate.to_dict() for candidate in candidates],
|
||||
"best_candidate": candidates[0].to_dict(),
|
||||
"attempted_queries": list(attempted_queries),
|
||||
"context": context,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/overview/summary")
|
||||
async def get_bgp_overview_summary(
|
||||
current_user: User = Depends(get_current_user),
|
||||
|
||||
102
backend/app/api/v1/docs.py
Normal file
102
backend/app/api/v1/docs.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Authenticated documentation APIs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.security import decode_token
|
||||
from app.db.session import async_session_factory
|
||||
from app.models.user import User
|
||||
from app.services.docs_gatekeeper import (
|
||||
DOCS_BY_SLUG,
|
||||
VALID_DOCS_LANGS,
|
||||
can_read_doc,
|
||||
catalog_for_user,
|
||||
doc_path_for,
|
||||
title_for,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
optional_bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
async def get_optional_current_user(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(optional_bearer),
|
||||
) -> User | None:
|
||||
if credentials is None:
|
||||
return None
|
||||
|
||||
payload = decode_token(credentials.credentials)
|
||||
if payload is None or payload.get("type") != "access" or payload.get("sub") is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token",
|
||||
)
|
||||
|
||||
async with async_session_factory() as db:
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups FROM users WHERE id = :id"
|
||||
),
|
||||
{"id": int(payload["sub"])},
|
||||
)
|
||||
row = result.fetchone()
|
||||
if row is None or not row[5]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found or inactive",
|
||||
)
|
||||
|
||||
user = User()
|
||||
user.id = row[0]
|
||||
user.username = row[1]
|
||||
user.email = row[2]
|
||||
user.password_hash = row[3]
|
||||
user.role = row[4]
|
||||
user.is_active = row[5]
|
||||
user.gatekeeper_groups = row[6] or []
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/catalog")
|
||||
async def get_docs_catalog(current_user: User | None = Depends(get_optional_current_user)):
|
||||
return {
|
||||
"items": catalog_for_user(current_user),
|
||||
"authenticated": current_user is not None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{lang}/{slug}")
|
||||
async def get_doc_content(
|
||||
lang: str,
|
||||
slug: str,
|
||||
current_user: User | None = Depends(get_optional_current_user),
|
||||
):
|
||||
if lang not in VALID_DOCS_LANGS:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||
|
||||
entry = DOCS_BY_SLUG.get(slug)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||
|
||||
path = doc_path_for(entry, lang)
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||
|
||||
if not can_read_doc(entry, current_user):
|
||||
if current_user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient Docs permissions")
|
||||
|
||||
return {
|
||||
"slug": entry.slug,
|
||||
"filename": entry.filename,
|
||||
"lang": lang,
|
||||
"title": title_for(entry, lang),
|
||||
"group": entry.group,
|
||||
"order": entry.order,
|
||||
"access": entry.access,
|
||||
"markdown": path.read_text(encoding="utf-8"),
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
@@ -7,10 +8,12 @@ from sqlalchemy import text
|
||||
from app.core.security import get_current_user, get_password_hash
|
||||
from app.db.session import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.user import UserCreate, UserResponse, UserUpdate
|
||||
from app.schemas.user import UserCreate, UserUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
VALID_GATEKEEPER_GROUPS = {"docs_user", "docs_developer", "docs_admin"}
|
||||
|
||||
|
||||
def check_permission(current_user: User, required_roles: List[str]) -> bool:
|
||||
user_role_value = (
|
||||
@@ -52,7 +55,7 @@ async def list_users(
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
query = text(
|
||||
f"SELECT id, username, email, role, is_active, last_login_at, created_at FROM users WHERE {where_sql} ORDER BY created_at DESC LIMIT {page_size} OFFSET {offset}"
|
||||
f"SELECT id, username, email, role, is_active, last_login_at, created_at, gatekeeper_groups FROM users WHERE {where_sql} ORDER BY created_at DESC LIMIT {page_size} OFFSET {offset}"
|
||||
)
|
||||
count_query = text(f"SELECT COUNT(*) FROM users WHERE {where_sql}")
|
||||
|
||||
@@ -75,6 +78,7 @@ async def list_users(
|
||||
"is_active": u[4],
|
||||
"last_login_at": u[5],
|
||||
"created_at": u[6],
|
||||
"gatekeeper_groups": u[7] or [],
|
||||
}
|
||||
for u in users
|
||||
],
|
||||
@@ -95,7 +99,7 @@ async def get_user(
|
||||
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT id, username, email, role, is_active, last_login_at, created_at FROM users WHERE id = :id"
|
||||
"SELECT id, username, email, role, is_active, last_login_at, created_at, gatekeeper_groups FROM users WHERE id = :id"
|
||||
),
|
||||
{"id": user_id},
|
||||
)
|
||||
@@ -114,6 +118,7 @@ async def get_user(
|
||||
"is_active": user[4],
|
||||
"last_login_at": user[5],
|
||||
"created_at": user[6],
|
||||
"gatekeeper_groups": user[7] or [],
|
||||
}
|
||||
|
||||
|
||||
@@ -128,6 +133,12 @@ async def create_user(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only super_admin can create users",
|
||||
)
|
||||
invalid_groups = sorted(set(user_data.gatekeeper_groups) - VALID_GATEKEEPER_GROUPS)
|
||||
if invalid_groups:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported Gatekeeper groups: {', '.join(invalid_groups)}",
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
text("SELECT id FROM users WHERE username = :username OR email = :email"),
|
||||
@@ -142,13 +153,14 @@ async def create_user(
|
||||
hashed_password = get_password_hash(user_data.password)
|
||||
|
||||
await db.execute(
|
||||
text("""INSERT INTO users (username, email, password_hash, role, is_active, created_at, updated_at)
|
||||
VALUES (:username, :email, :password_hash, :role, :is_active, NOW(), NOW())"""),
|
||||
text("""INSERT INTO users (username, email, password_hash, role, gatekeeper_groups, is_active, created_at, updated_at)
|
||||
VALUES (:username, :email, :password_hash, :role, CAST(:gatekeeper_groups AS jsonb), :is_active, NOW(), NOW())"""),
|
||||
{
|
||||
"username": user_data.username,
|
||||
"email": user_data.email,
|
||||
"password_hash": hashed_password,
|
||||
"role": user_data.role,
|
||||
"gatekeeper_groups": json.dumps(user_data.gatekeeper_groups),
|
||||
"is_active": True,
|
||||
},
|
||||
)
|
||||
@@ -172,6 +184,7 @@ async def create_user(
|
||||
"username": user_data.username,
|
||||
"email": user_data.email,
|
||||
"role": user_data.role,
|
||||
"gatekeeper_groups": user_data.gatekeeper_groups,
|
||||
"is_active": True,
|
||||
}
|
||||
|
||||
@@ -194,6 +207,18 @@ async def update_user(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only super_admin can change user role",
|
||||
)
|
||||
if not check_permission(current_user, ["super_admin"]) and user_data.gatekeeper_groups is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only super_admin can change Gatekeeper groups",
|
||||
)
|
||||
if user_data.gatekeeper_groups is not None:
|
||||
invalid_groups = sorted(set(user_data.gatekeeper_groups) - VALID_GATEKEEPER_GROUPS)
|
||||
if invalid_groups:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported Gatekeeper groups: {', '.join(invalid_groups)}",
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
text("SELECT id FROM users WHERE id = :id"),
|
||||
@@ -213,6 +238,9 @@ async def update_user(
|
||||
if user_data.role is not None:
|
||||
update_fields.append("role = :role")
|
||||
params["role"] = user_data.role
|
||||
if user_data.gatekeeper_groups is not None:
|
||||
update_fields.append("gatekeeper_groups = CAST(:gatekeeper_groups AS jsonb)")
|
||||
params["gatekeeper_groups"] = json.dumps(user_data.gatekeeper_groups)
|
||||
if user_data.is_active is not None:
|
||||
update_fields.append("is_active = :is_active")
|
||||
params["is_active"] = user_data.is_active
|
||||
|
||||
@@ -4,17 +4,20 @@ Unified API for all visualization data sources.
|
||||
Returns GeoJSON format compatible with Three.js, CesiumJS, and Unreal Cesium.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
from collections import OrderedDict
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import math
|
||||
import re
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query, Response
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
from app.core.collected_data_fields import get_record_field
|
||||
from app.core.countries import get_country_centroid
|
||||
from app.core.satellite_tle import build_tle_lines_from_elements
|
||||
from app.core.time import to_iso8601_utc
|
||||
from app.db.session import get_db
|
||||
@@ -25,6 +28,14 @@ from app.models.collected_data import CollectedData
|
||||
from app.models.vessel import AISSourceHealth, VesselPosition, VesselStatic
|
||||
from app.services.bgp_collectors import build_bgp_collector_coverage
|
||||
from app.services.cable_graph import build_graph_from_data, CableGraph, haversine_distance
|
||||
from app.services.compute_center_locations import (
|
||||
RENDERABLE_PRECISIONS,
|
||||
ResolutionDiagnostic,
|
||||
collect_location_candidates,
|
||||
refresh_compute_center_location_cache,
|
||||
resolve_compute_center_location_full,
|
||||
upsert_compute_center_location,
|
||||
)
|
||||
from app.services.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS
|
||||
from app.services.persistent_logs import record_system_log
|
||||
from app.services.vessel_ais_aggregation import (
|
||||
@@ -43,9 +54,23 @@ logger = get_logger(__name__, service="api")
|
||||
TERRAIN_TILE_URL_TEMPLATE = (
|
||||
"https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png"
|
||||
)
|
||||
TERRAIN_TILE_CACHE_MAX_ITEMS = 512
|
||||
TERRAIN_TILE_BATCH_MAX_ITEMS = 128
|
||||
TERRAIN_TILE_BATCH_CONCURRENCY = 16
|
||||
_terrain_tile_cache: OrderedDict[tuple[int, int, int], tuple[bytes, str, dict[str, str]]] = OrderedDict()
|
||||
VESSEL_NAME_FALLBACK_PATTERN = re.compile(r"^mmsi\s*\d+$", re.IGNORECASE)
|
||||
|
||||
|
||||
class TerrariumTileRequest(BaseModel):
|
||||
z: int = Field(ge=0, le=14)
|
||||
x: int = Field(ge=0)
|
||||
y: int = Field(ge=0)
|
||||
|
||||
|
||||
class TerrariumTileBatchRequest(BaseModel):
|
||||
tiles: List[TerrariumTileRequest] = Field(min_length=1, max_length=TERRAIN_TILE_BATCH_MAX_ITEMS)
|
||||
|
||||
|
||||
# ============== Converter Functions ==============
|
||||
|
||||
|
||||
@@ -536,100 +561,6 @@ def _parse_float(value: Any) -> Optional[float]:
|
||||
return None
|
||||
|
||||
|
||||
COMPUTE_CENTER_COORDINATE_HINTS = (
|
||||
("el capitan", 37.6819, -121.7681),
|
||||
("livermore", 37.6819, -121.7681),
|
||||
("llnl", 37.6819, -121.7681),
|
||||
("lawrence livermore", 37.6819, -121.7681),
|
||||
("frontier", 35.9319, -84.3107),
|
||||
("oak ridge", 35.9319, -84.3107),
|
||||
("ornl", 35.9319, -84.3107),
|
||||
("aurora", 41.7130, -87.9820),
|
||||
("argonne", 41.7130, -87.9820),
|
||||
("anl", 41.7130, -87.9820),
|
||||
("fugaku", 34.6953, 135.1974),
|
||||
("kobe", 34.6953, 135.1974),
|
||||
("riken", 34.6953, 135.1974),
|
||||
("summit", 35.9319, -84.3107),
|
||||
("leonardo", 44.4949, 11.3426),
|
||||
("bologna", 44.4949, 11.3426),
|
||||
("alps", 46.0037, 8.9511),
|
||||
("lugano", 46.0037, 8.9511),
|
||||
("sunway taihulight", 31.4912, 120.3119),
|
||||
("wuxi", 31.4912, 120.3119),
|
||||
("tianhe-2", 23.1291, 113.2644),
|
||||
("tianhe-2a", 23.1291, 113.2644),
|
||||
("guangzhou", 23.1291, 113.2644),
|
||||
("colossus", 35.1495, -90.0490),
|
||||
("memphis", 35.1495, -90.0490),
|
||||
("xai", 35.1495, -90.0490),
|
||||
)
|
||||
|
||||
|
||||
def _normalize_hint_text(*parts: Any) -> str:
|
||||
return " ".join(
|
||||
str(part).strip().lower()
|
||||
for part in parts
|
||||
if part not in (None, "")
|
||||
)
|
||||
|
||||
|
||||
def _resolve_compute_center_coordinates(
|
||||
record: CollectedData,
|
||||
metadata: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
latitude = _parse_float(get_record_field(record, "latitude"))
|
||||
longitude = _parse_float(get_record_field(record, "longitude"))
|
||||
if latitude not in (None, 0.0) and longitude not in (None, 0.0):
|
||||
return {
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"location_precision": "precise",
|
||||
"geography_mode": "source_coordinates",
|
||||
"is_estimated": False,
|
||||
"estimated_reason": None,
|
||||
}
|
||||
|
||||
hint_text = _normalize_hint_text(
|
||||
record.name,
|
||||
get_record_field(record, "city"),
|
||||
get_record_field(record, "country"),
|
||||
metadata.get("site"),
|
||||
metadata.get("organization"),
|
||||
metadata.get("operator"),
|
||||
)
|
||||
for needle, resolved_latitude, resolved_longitude in COMPUTE_CENTER_COORDINATE_HINTS:
|
||||
if needle in hint_text:
|
||||
return {
|
||||
"latitude": resolved_latitude,
|
||||
"longitude": resolved_longitude,
|
||||
"location_precision": "estimated_site",
|
||||
"geography_mode": "site_hint",
|
||||
"is_estimated": True,
|
||||
"estimated_reason": f"Matched known site hint: {needle}",
|
||||
}
|
||||
|
||||
centroid = get_country_centroid(get_record_field(record, "country"))
|
||||
if centroid:
|
||||
return {
|
||||
"latitude": centroid.get("latitude"),
|
||||
"longitude": centroid.get("longitude"),
|
||||
"location_precision": "estimated_country",
|
||||
"geography_mode": "country_centroid",
|
||||
"is_estimated": True,
|
||||
"estimated_reason": "Estimated from country centroid",
|
||||
}
|
||||
|
||||
return {
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"location_precision": "unknown",
|
||||
"geography_mode": "unknown",
|
||||
"is_estimated": True,
|
||||
"estimated_reason": "No resolvable location hints",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_capacity_band(capacity_value: Optional[float], capacity_unit: str) -> str:
|
||||
if capacity_value is None:
|
||||
return "unknown"
|
||||
@@ -654,22 +585,49 @@ def _normalize_capacity_band(capacity_value: Optional[float], capacity_unit: str
|
||||
|
||||
|
||||
def convert_compute_centers_to_geojson(records: List[CollectedData]) -> Dict[str, Any]:
|
||||
"""Convert compute infrastructure records into a unified GeoJSON layer."""
|
||||
features = []
|
||||
"""Convert compute infrastructure records into a unified GeoJSON layer.
|
||||
|
||||
Records that cannot be resolved to at least city-level precision are NOT
|
||||
silently dropped: they are returned in ``unresolved`` so the UI can offer
|
||||
the click-to-collect coordinate flow. The features list never contains
|
||||
``[0, 0]`` placeholders or country/region/unknown precision points.
|
||||
"""
|
||||
features: List[Dict[str, Any]] = []
|
||||
unresolved: List[Dict[str, Any]] = []
|
||||
|
||||
for record in records:
|
||||
metadata = record.extra_data or {}
|
||||
coordinate_info = _resolve_compute_center_coordinates(record, metadata)
|
||||
latitude = coordinate_info.get("latitude")
|
||||
longitude = coordinate_info.get("longitude")
|
||||
result = resolve_compute_center_location_full(record, metadata)
|
||||
site_type = (
|
||||
"supercomputer"
|
||||
if record.source == "top500" or record.data_type == "supercomputer"
|
||||
else "gpu_cluster"
|
||||
)
|
||||
if latitude in (None, 0.0) or longitude in (None, 0.0):
|
||||
|
||||
if not result.is_resolved:
|
||||
diagnostic = result.diagnostic or ResolutionDiagnostic(
|
||||
failure_reason="Unknown resolver failure",
|
||||
attempted_queries=(),
|
||||
record_id=getattr(record, "id", None),
|
||||
source=getattr(record, "source", None),
|
||||
source_id=getattr(record, "source_id", None),
|
||||
name=getattr(record, "name", None),
|
||||
)
|
||||
unresolved.append({
|
||||
**diagnostic.to_dict(),
|
||||
"site_type": site_type,
|
||||
})
|
||||
continue
|
||||
|
||||
location = result.location
|
||||
if location is None or not location.is_renderable:
|
||||
# Defensive: should not happen because is_resolved guards this.
|
||||
continue
|
||||
|
||||
location_props = location.to_geojson_properties()
|
||||
latitude = location.latitude
|
||||
longitude = location.longitude
|
||||
|
||||
if site_type == "supercomputer":
|
||||
capacity_value = _parse_float(get_record_field(record, "rmax"))
|
||||
capacity_unit = "GFlops"
|
||||
@@ -699,15 +657,16 @@ def convert_compute_centers_to_geojson(records: List[CollectedData]) -> Dict[str
|
||||
"id": record.id,
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [longitude or 0, latitude or 0],
|
||||
"coordinates": [longitude, latitude],
|
||||
},
|
||||
"properties": {
|
||||
"id": record.id,
|
||||
"source_id": record.source_id,
|
||||
"name": record.name,
|
||||
"site_type": site_type,
|
||||
"country": get_record_field(record, "country"),
|
||||
"city": get_record_field(record, "city"),
|
||||
"country": get_record_field(record, "country") or location.country,
|
||||
"city": get_record_field(record, "city") or location.city,
|
||||
"region": location.region,
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"operator": operator,
|
||||
@@ -723,17 +682,14 @@ def convert_compute_centers_to_geojson(records: List[CollectedData]) -> Dict[str
|
||||
"source": record.source,
|
||||
"updated_at": updated_at,
|
||||
"status": "observed",
|
||||
"location_precision": coordinate_info.get("location_precision"),
|
||||
"geography_mode": coordinate_info.get("geography_mode"),
|
||||
"is_estimated": coordinate_info.get("is_estimated", False),
|
||||
"estimated_reason": coordinate_info.get("estimated_reason"),
|
||||
**location_props,
|
||||
"data_type": "compute_center",
|
||||
"metadata": metadata,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return {"type": "FeatureCollection", "features": features}
|
||||
return {"type": "FeatureCollection", "features": features, "unresolved": unresolved}
|
||||
|
||||
|
||||
VESSEL_TYPE_FILTERS = {
|
||||
@@ -1486,18 +1442,12 @@ async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)):
|
||||
@router.get("/terrain/terrarium/{z}/{x}/{y}.png")
|
||||
async def get_terrarium_tile(z: int, x: int, y: int):
|
||||
"""Proxy Terrarium elevation tiles through the backend to avoid browser CORS issues."""
|
||||
if z < 0 or x < 0 or y < 0:
|
||||
if not _is_valid_terrain_tile(z, x, y):
|
||||
raise HTTPException(status_code=400, detail="Invalid terrain tile coordinates")
|
||||
|
||||
url = TERRAIN_TILE_URL_TEMPLATE.format(z=z, x=x, y=y)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=20.0,
|
||||
follow_redirects=True,
|
||||
) as client:
|
||||
upstream = await client.get(url)
|
||||
upstream.raise_for_status()
|
||||
async with httpx.AsyncClient(timeout=20.0, follow_redirects=True) as client:
|
||||
content, content_type, headers = await _fetch_terrain_tile(client, z, x, y)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise HTTPException(
|
||||
status_code=exc.response.status_code,
|
||||
@@ -1509,22 +1459,140 @@ async def get_terrarium_tile(z: int, x: int, y: int):
|
||||
detail=f"Terrain tile fetch failed: {exc}",
|
||||
) from exc
|
||||
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=content_type,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
def _is_valid_terrain_tile(z: int, x: int, y: int) -> bool:
|
||||
if z < 0 or x < 0 or y < 0:
|
||||
return False
|
||||
max_tile = 2 ** z
|
||||
return x < max_tile and y < max_tile
|
||||
|
||||
|
||||
def _get_cached_terrain_tile(z: int, x: int, y: int) -> tuple[bytes, str, dict[str, str]] | None:
|
||||
key = (z, x, y)
|
||||
cached = _terrain_tile_cache.get(key)
|
||||
if cached is None:
|
||||
return None
|
||||
_terrain_tile_cache.move_to_end(key)
|
||||
content, content_type, headers = cached
|
||||
return content, content_type, dict(headers)
|
||||
|
||||
|
||||
def _cache_terrain_tile(
|
||||
z: int,
|
||||
x: int,
|
||||
y: int,
|
||||
content: bytes,
|
||||
content_type: str,
|
||||
headers: dict[str, str],
|
||||
) -> None:
|
||||
key = (z, x, y)
|
||||
_terrain_tile_cache[key] = (content, content_type, dict(headers))
|
||||
_terrain_tile_cache.move_to_end(key)
|
||||
while len(_terrain_tile_cache) > TERRAIN_TILE_CACHE_MAX_ITEMS:
|
||||
_terrain_tile_cache.popitem(last=False)
|
||||
|
||||
|
||||
async def _fetch_terrain_tile(
|
||||
client: httpx.AsyncClient,
|
||||
z: int,
|
||||
x: int,
|
||||
y: int,
|
||||
) -> tuple[bytes, str, dict[str, str]]:
|
||||
cached = _get_cached_terrain_tile(z, x, y)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
url = TERRAIN_TILE_URL_TEMPLATE.format(z=z, x=x, y=y)
|
||||
upstream = await client.get(url)
|
||||
upstream.raise_for_status()
|
||||
|
||||
cache_control = upstream.headers.get("cache-control") or "public, max-age=86400"
|
||||
etag = upstream.headers.get("etag")
|
||||
last_modified = upstream.headers.get("last-modified")
|
||||
headers = {
|
||||
"Cache-Control": cache_control,
|
||||
}
|
||||
etag = upstream.headers.get("etag")
|
||||
last_modified = upstream.headers.get("last-modified")
|
||||
if etag:
|
||||
headers["ETag"] = etag
|
||||
if last_modified:
|
||||
headers["Last-Modified"] = last_modified
|
||||
|
||||
return Response(
|
||||
content=upstream.content,
|
||||
media_type=upstream.headers.get("content-type", "image/png"),
|
||||
headers=headers,
|
||||
)
|
||||
content_type = upstream.headers.get("content-type", "image/png")
|
||||
content = upstream.content
|
||||
_cache_terrain_tile(z, x, y, content, content_type, headers)
|
||||
return content, content_type, dict(headers)
|
||||
|
||||
|
||||
@router.post("/terrain/terrarium/batch")
|
||||
async def get_terrarium_tile_batch(payload: TerrariumTileBatchRequest):
|
||||
"""Fetch Terrarium elevation tiles in batches so the browser avoids many tiny requests."""
|
||||
unique_tiles: list[TerrariumTileRequest] = []
|
||||
seen: set[tuple[int, int, int]] = set()
|
||||
for tile in payload.tiles:
|
||||
key = (tile.z, tile.x, tile.y)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
if not _is_valid_terrain_tile(tile.z, tile.x, tile.y):
|
||||
raise HTTPException(status_code=400, detail="Invalid terrain tile coordinates")
|
||||
unique_tiles.append(tile)
|
||||
|
||||
semaphore = asyncio.Semaphore(TERRAIN_TILE_BATCH_CONCURRENCY)
|
||||
results: list[dict[str, Any]] = []
|
||||
errors: list[dict[str, Any]] = []
|
||||
|
||||
async with httpx.AsyncClient(timeout=20.0, follow_redirects=True) as client:
|
||||
async def fetch_one(tile: TerrariumTileRequest) -> None:
|
||||
async with semaphore:
|
||||
try:
|
||||
content, content_type, _headers = await _fetch_terrain_tile(
|
||||
client,
|
||||
tile.z,
|
||||
tile.x,
|
||||
tile.y,
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"z": tile.z,
|
||||
"x": tile.x,
|
||||
"y": tile.y,
|
||||
"content_type": content_type,
|
||||
"data": base64.b64encode(content).decode("ascii"),
|
||||
},
|
||||
)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
errors.append(
|
||||
{
|
||||
"z": tile.z,
|
||||
"x": tile.x,
|
||||
"y": tile.y,
|
||||
"status_code": exc.response.status_code,
|
||||
"message": f"upstream error: {exc.response.status_code}",
|
||||
},
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
errors.append(
|
||||
{
|
||||
"z": tile.z,
|
||||
"x": tile.x,
|
||||
"y": tile.y,
|
||||
"status_code": 502,
|
||||
"message": str(exc),
|
||||
},
|
||||
)
|
||||
|
||||
await asyncio.gather(*(fetch_one(tile) for tile in unique_tiles))
|
||||
|
||||
return {
|
||||
"tiles": results,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/geo/all")
|
||||
@@ -1659,33 +1727,253 @@ async def get_compute_centers_geojson(
|
||||
return {
|
||||
"type": "FeatureCollection",
|
||||
"features": [],
|
||||
"unresolved": [],
|
||||
"count": 0,
|
||||
"stats": {
|
||||
"total": 0,
|
||||
"supercomputers": 0,
|
||||
"gpu_clusters": 0,
|
||||
"unresolved": 0,
|
||||
},
|
||||
}
|
||||
|
||||
await refresh_compute_center_location_cache(db)
|
||||
geojson = convert_compute_centers_to_geojson(records)
|
||||
features = geojson.get("features", [])
|
||||
unresolved = geojson.get("unresolved", [])
|
||||
# Belt-and-suspenders: ensure no Feature ever sneaks through without
|
||||
# city-or-better precision and finite, non-zero coordinates.
|
||||
sanitized_features: List[Dict[str, Any]] = []
|
||||
for feature in features:
|
||||
coords = feature.get("geometry", {}).get("coordinates") or []
|
||||
precision = feature.get("properties", {}).get("location_precision")
|
||||
if precision not in RENDERABLE_PRECISIONS:
|
||||
unresolved.append({
|
||||
"failure_reason": f"Rejected non-renderable precision '{precision}'",
|
||||
"record_id": feature.get("id"),
|
||||
"source_id": feature.get("properties", {}).get("source_id"),
|
||||
"name": feature.get("properties", {}).get("name"),
|
||||
})
|
||||
continue
|
||||
if (
|
||||
len(coords) != 2
|
||||
or coords[0] in (None, 0, 0.0)
|
||||
or coords[1] in (None, 0, 0.0)
|
||||
):
|
||||
unresolved.append({
|
||||
"failure_reason": "Rejected feature with [0,0] or invalid coordinates",
|
||||
"record_id": feature.get("id"),
|
||||
"source_id": feature.get("properties", {}).get("source_id"),
|
||||
"name": feature.get("properties", {}).get("name"),
|
||||
})
|
||||
continue
|
||||
sanitized_features.append(feature)
|
||||
return {
|
||||
**geojson,
|
||||
"count": len(features),
|
||||
"type": "FeatureCollection",
|
||||
"features": sanitized_features,
|
||||
"unresolved": unresolved,
|
||||
"count": len(sanitized_features),
|
||||
"stats": {
|
||||
"total": len(features),
|
||||
"total": len(sanitized_features),
|
||||
"supercomputers": sum(
|
||||
1 for feature in features
|
||||
1 for feature in sanitized_features
|
||||
if feature.get("properties", {}).get("site_type") == "supercomputer"
|
||||
),
|
||||
"gpu_clusters": sum(
|
||||
1 for feature in features
|
||||
1 for feature in sanitized_features
|
||||
if feature.get("properties", {}).get("site_type") == "gpu_cluster"
|
||||
),
|
||||
"unresolved": len(unresolved),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class CollectComputeCenterLocationRequest(BaseModel):
|
||||
name: Optional[str] = None
|
||||
source: Optional[str] = None
|
||||
operator: Optional[str] = None
|
||||
site: Optional[str] = None
|
||||
organization: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
country: Optional[str] = None
|
||||
record_id: Optional[int] = Field(default=None, alias="id")
|
||||
|
||||
model_config = {"populate_by_name": True}
|
||||
|
||||
|
||||
class SaveComputeCenterLocationRequest(BaseModel):
|
||||
source: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
operator: Optional[str] = None
|
||||
site: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
country: Optional[str] = None
|
||||
latitude: float
|
||||
longitude: float
|
||||
precision: str = "city"
|
||||
confidence: Optional[float] = None
|
||||
location_source: Optional[str] = None
|
||||
source_url: Optional[str] = None
|
||||
source_note: Optional[str] = None
|
||||
raw_payload: Dict[str, Any] = Field(default_factory=dict)
|
||||
needs_confirmation: bool = False
|
||||
verification_status: Optional[str] = None
|
||||
|
||||
model_config = {"populate_by_name": True}
|
||||
|
||||
|
||||
@router.post("/compute-centers/{source_id}/collect-location")
|
||||
async def collect_compute_center_location(
|
||||
source_id: str,
|
||||
payload: CollectComputeCenterLocationRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Run the full multi-query location collection pipeline for a record.
|
||||
|
||||
The endpoint accepts the source_id of a compute center plus contextual
|
||||
fields (name/operator/site/city/country/...) and returns ranked candidate
|
||||
locations from source coordinates, open organization lookups, and online
|
||||
geocoding combinations. The caller never has to type coordinates by hand:
|
||||
if any candidate is accepted it can be applied directly. If no candidate
|
||||
can reach city-level precision the response includes an explicit
|
||||
``failure_reason`` and the list of attempted queries.
|
||||
"""
|
||||
if not source_id or not source_id.strip():
|
||||
raise HTTPException(status_code=400, detail="source_id is required")
|
||||
|
||||
record = await _load_compute_center_record(db, source_id)
|
||||
name = payload.name or (record.name if record else None)
|
||||
metadata = (record.extra_data or {}) if record else {}
|
||||
|
||||
operator = payload.operator or metadata.get("operator") or metadata.get("organization") or metadata.get("owner")
|
||||
site = payload.site or metadata.get("site")
|
||||
organization = payload.organization or metadata.get("organization")
|
||||
city = payload.city or get_record_field(record, "city") if record else payload.city
|
||||
country = payload.country or (get_record_field(record, "country") if record else None)
|
||||
source = payload.source or (record.source if record else None)
|
||||
record_id = payload.record_id or (record.id if record else None)
|
||||
|
||||
candidates, attempted_queries = collect_location_candidates(
|
||||
name=name,
|
||||
source=source,
|
||||
source_id=source_id,
|
||||
operator=operator,
|
||||
site=site,
|
||||
organization=organization,
|
||||
city=city,
|
||||
country=country,
|
||||
record_id=record_id,
|
||||
)
|
||||
|
||||
if not candidates:
|
||||
return {
|
||||
"source_id": source_id,
|
||||
"record_id": record_id,
|
||||
"name": name,
|
||||
"success": False,
|
||||
"failure_reason": (
|
||||
"No source coordinates, organization lookup, or online geocoding"
|
||||
" result reached city-level precision."
|
||||
),
|
||||
"candidates": [],
|
||||
"attempted_queries": list(attempted_queries),
|
||||
"context": {
|
||||
"name": name,
|
||||
"operator": operator,
|
||||
"site": site,
|
||||
"city": city,
|
||||
"country": country,
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
"source_id": source_id,
|
||||
"record_id": record_id,
|
||||
"name": name,
|
||||
"success": True,
|
||||
"candidates": [candidate.to_dict() for candidate in candidates],
|
||||
"best_candidate": candidates[0].to_dict(),
|
||||
"attempted_queries": list(attempted_queries),
|
||||
"context": {
|
||||
"name": name,
|
||||
"operator": operator,
|
||||
"site": site,
|
||||
"city": city,
|
||||
"country": country,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/compute-centers/{source_id}/location")
|
||||
async def save_compute_center_location(
|
||||
source_id: str,
|
||||
payload: SaveComputeCenterLocationRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Persist the user-selected compute-center location candidate."""
|
||||
if not source_id or not source_id.strip():
|
||||
raise HTTPException(status_code=400, detail="source_id is required")
|
||||
if payload.latitude in (0.0, None) or payload.longitude in (0.0, None):
|
||||
raise HTTPException(status_code=400, detail="latitude/longitude are required")
|
||||
if payload.precision not in RENDERABLE_PRECISIONS:
|
||||
raise HTTPException(status_code=400, detail="precision must be precise, site, or city")
|
||||
|
||||
record = await _load_compute_center_record(db, source_id)
|
||||
metadata = (record.extra_data or {}) if record else {}
|
||||
record_source = payload.source or (record.source if record else None)
|
||||
if not record_source:
|
||||
raise HTTPException(status_code=400, detail="source is required for unknown compute center")
|
||||
|
||||
operator = (
|
||||
payload.operator
|
||||
or metadata.get("operator")
|
||||
or metadata.get("organization")
|
||||
or metadata.get("owner")
|
||||
or metadata.get("manufacturer")
|
||||
)
|
||||
site = payload.site or metadata.get("site") or metadata.get("organization")
|
||||
saved = await upsert_compute_center_location(
|
||||
db,
|
||||
source=record_source,
|
||||
source_id=source_id,
|
||||
name=payload.name or (record.name if record else None),
|
||||
operator=operator,
|
||||
site=site,
|
||||
city=payload.city or (get_record_field(record, "city") if record else None),
|
||||
country=payload.country or (get_record_field(record, "country") if record else None),
|
||||
latitude=payload.latitude,
|
||||
longitude=payload.longitude,
|
||||
precision=payload.precision,
|
||||
confidence=payload.confidence,
|
||||
location_source=payload.location_source or "manual_selection",
|
||||
source_url=payload.source_url,
|
||||
source_note=payload.source_note,
|
||||
raw_payload=payload.raw_payload,
|
||||
needs_confirmation=payload.needs_confirmation,
|
||||
verification_status=payload.verification_status
|
||||
or ("unverified" if payload.needs_confirmation else "verified"),
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"source": saved.source,
|
||||
"source_id": saved.source_id,
|
||||
"location": saved.to_location_dict(),
|
||||
}
|
||||
|
||||
|
||||
async def _load_compute_center_record(db: AsyncSession, source_id: str) -> CollectedData | None:
|
||||
stmt = (
|
||||
select(CollectedData)
|
||||
.where(CollectedData.source_id == source_id)
|
||||
.where(CollectedData.source.in_(["top500", "epoch_ai_gpu"]))
|
||||
.order_by(CollectedData.is_current.desc(), CollectedData.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
@router.get("/geo/vessels")
|
||||
async def get_vessels_geojson(
|
||||
bbox: Optional[str] = Query(
|
||||
|
||||
Reference in New Issue
Block a user