release: bump version to 0.61.0
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.v1.visualization import (
|
||||
@@ -110,6 +110,7 @@ async def get_vessel_layer_snapshot(
|
||||
vessel_type: Optional[str] = Query(None, alias="type"),
|
||||
since_minutes: int = Query(60, ge=1, le=1440),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
response: Response = None,
|
||||
):
|
||||
parsed_bbox = _parse_layer_bbox(bbox)
|
||||
return await build_vessel_snapshot_response(
|
||||
@@ -119,6 +120,7 @@ async def get_vessel_layer_snapshot(
|
||||
limit=limit,
|
||||
type_filter=vessel_type,
|
||||
since_minutes=since_minutes,
|
||||
response=response,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ from app.services.system_logs import (
|
||||
normalize_log_level,
|
||||
read_log_snapshot,
|
||||
)
|
||||
from app.services.earth_layer_cache import earth_layer_cache
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -112,6 +113,17 @@ class EarthClientLogEventResponse(BaseModel):
|
||||
level: str
|
||||
|
||||
|
||||
class EarthLayerCacheStatusResponse(BaseModel):
|
||||
prefix: str
|
||||
key_count: int
|
||||
memory_bytes: int
|
||||
layers: dict[str, dict[str, int]]
|
||||
|
||||
|
||||
class EarthLayerCacheClearResponse(BaseModel):
|
||||
deleted: int
|
||||
|
||||
|
||||
def ensure_super_admin(current_user: User) -> None:
|
||||
if not require_super_admin(current_user.role):
|
||||
raise HTTPException(
|
||||
@@ -132,6 +144,34 @@ def validate_log_date(raw_value: str | None, field_name: str) -> str | None:
|
||||
) from exc
|
||||
|
||||
|
||||
@router.get("/cache/earth-layers", response_model=EarthLayerCacheStatusResponse)
|
||||
async def get_earth_layer_cache_status(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
ensure_super_admin(current_user)
|
||||
try:
|
||||
return earth_layer_cache.status()
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"Unable to read Earth layer cache status: {exc}",
|
||||
) from exc
|
||||
|
||||
|
||||
@router.delete("/cache/earth-layers", response_model=EarthLayerCacheClearResponse)
|
||||
async def clear_earth_layer_cache(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
ensure_super_admin(current_user)
|
||||
try:
|
||||
return {"deleted": earth_layer_cache.delete_pattern()}
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"Unable to clear Earth layer cache: {exc}",
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/restart-tasks", response_model=RestartTaskResponse)
|
||||
async def create_restart_task(
|
||||
payload: RestartTaskCreate,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.v1.visualization import _parse_bbox, build_vessel_snapshot_response
|
||||
@@ -23,6 +23,7 @@ async def get_vessel_snapshot(
|
||||
limit: int = Query(1000, ge=1, le=MAX_SNAPSHOT_LIMIT),
|
||||
since_minutes: int = Query(60, ge=1, le=1440),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
response: Response = None,
|
||||
):
|
||||
if not bbox:
|
||||
raise HTTPException(status_code=400, detail="bbox is required")
|
||||
@@ -36,4 +37,5 @@ async def get_vessel_snapshot(
|
||||
type_filter=type,
|
||||
limit=limit,
|
||||
since_minutes=since_minutes,
|
||||
response=response,
|
||||
)
|
||||
|
||||
@@ -56,6 +56,13 @@ from app.services.vessel_ais_aggregation import (
|
||||
get_vessel_raw_observations,
|
||||
MAX_SNAPSHOT_LIMIT,
|
||||
)
|
||||
from app.services.earth_layer_cache import (
|
||||
EarthLayerCachePolicy,
|
||||
earth_layer_cache,
|
||||
format_bbox_key,
|
||||
get_or_build_layer_payload,
|
||||
quantize_bbox,
|
||||
)
|
||||
from app.core.logging import get_logger
|
||||
|
||||
router = APIRouter()
|
||||
@@ -69,6 +76,68 @@ 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)
|
||||
VESSEL_SNAPSHOT_LEGACY_FALLBACK_ENABLED = True
|
||||
SECONDS_PER_MINUTE = 60
|
||||
BYTES_PER_MIB = 1024 * 1024
|
||||
CABLE_CACHE_FRESH_SECONDS = 6 * 60 * SECONDS_PER_MINUTE
|
||||
CABLE_CACHE_STALE_SECONDS = 24 * 60 * SECONDS_PER_MINUTE
|
||||
SATELLITE_CACHE_FRESH_SECONDS = 15 * SECONDS_PER_MINUTE
|
||||
SATELLITE_CACHE_STALE_SECONDS = 2 * 60 * SECONDS_PER_MINUTE
|
||||
COMPUTE_CENTER_CACHE_FRESH_SECONDS = 10 * SECONDS_PER_MINUTE
|
||||
COMPUTE_CENTER_CACHE_STALE_SECONDS = 60 * SECONDS_PER_MINUTE
|
||||
BGP_CACHE_FRESH_SECONDS = 60
|
||||
BGP_EVENT_CACHE_FRESH_SECONDS = 30
|
||||
BGP_CACHE_STALE_SECONDS = 10 * SECONDS_PER_MINUTE
|
||||
VESSEL_SNAPSHOT_CACHE_FRESH_SECONDS = 5
|
||||
VESSEL_SNAPSHOT_CACHE_STALE_SECONDS = 30
|
||||
|
||||
CABLE_CACHE_POLICY = EarthLayerCachePolicy(
|
||||
CABLE_CACHE_FRESH_SECONDS,
|
||||
CABLE_CACHE_STALE_SECONDS,
|
||||
max_features=6000,
|
||||
max_bytes=10 * BYTES_PER_MIB,
|
||||
)
|
||||
LANDING_POINT_CACHE_POLICY = EarthLayerCachePolicy(
|
||||
CABLE_CACHE_FRESH_SECONDS,
|
||||
CABLE_CACHE_STALE_SECONDS,
|
||||
max_features=6000,
|
||||
max_bytes=8 * BYTES_PER_MIB,
|
||||
)
|
||||
SATELLITE_CACHE_POLICY = EarthLayerCachePolicy(
|
||||
SATELLITE_CACHE_FRESH_SECONDS,
|
||||
SATELLITE_CACHE_STALE_SECONDS,
|
||||
max_features=8000,
|
||||
max_bytes=10 * BYTES_PER_MIB,
|
||||
)
|
||||
COMPUTE_CENTER_CACHE_POLICY = EarthLayerCachePolicy(
|
||||
COMPUTE_CENTER_CACHE_FRESH_SECONDS,
|
||||
COMPUTE_CENTER_CACHE_STALE_SECONDS,
|
||||
max_features=1000,
|
||||
max_bytes=4 * BYTES_PER_MIB,
|
||||
)
|
||||
BGP_CACHE_POLICY = EarthLayerCachePolicy(
|
||||
BGP_CACHE_FRESH_SECONDS,
|
||||
BGP_CACHE_STALE_SECONDS,
|
||||
max_features=1000,
|
||||
max_bytes=3 * BYTES_PER_MIB,
|
||||
)
|
||||
BGP_EVENT_CACHE_POLICY = EarthLayerCachePolicy(
|
||||
BGP_EVENT_CACHE_FRESH_SECONDS,
|
||||
BGP_CACHE_STALE_SECONDS,
|
||||
max_features=1000,
|
||||
max_bytes=3 * BYTES_PER_MIB,
|
||||
)
|
||||
SUMMARY_CACHE_POLICY = EarthLayerCachePolicy(
|
||||
BGP_EVENT_CACHE_FRESH_SECONDS,
|
||||
BGP_CACHE_STALE_SECONDS,
|
||||
max_features=0,
|
||||
max_bytes=512 * 1024,
|
||||
)
|
||||
VESSEL_SNAPSHOT_CACHE_POLICY = EarthLayerCachePolicy(
|
||||
VESSEL_SNAPSHOT_CACHE_FRESH_SECONDS,
|
||||
VESSEL_SNAPSHOT_CACHE_STALE_SECONDS,
|
||||
max_features=1500,
|
||||
max_bytes=3 * BYTES_PER_MIB,
|
||||
)
|
||||
|
||||
|
||||
class TerrariumTileRequest(BaseModel):
|
||||
@@ -1010,7 +1079,40 @@ async def build_vessel_snapshot_response(
|
||||
type_filter: str | None,
|
||||
limit: int | None,
|
||||
since_minutes: int = 60,
|
||||
response: Response | None = None,
|
||||
use_cache: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
if use_cache and bbox is not None:
|
||||
safe_limit_for_key = _safe_vessel_limit(limit)
|
||||
safe_since_for_key = min(max(int(since_minutes or 60), 1), 1440)
|
||||
cache_key = earth_layer_cache.key(
|
||||
"vessels-snapshot",
|
||||
bbox=format_bbox_key(quantize_bbox(bbox)),
|
||||
zoom=zoom or "none",
|
||||
type=type_filter or "all",
|
||||
limit=safe_limit_for_key,
|
||||
since=safe_since_for_key,
|
||||
)
|
||||
|
||||
async def build_uncached() -> dict[str, Any]:
|
||||
return await build_vessel_snapshot_response(
|
||||
db,
|
||||
bbox=bbox,
|
||||
zoom=zoom,
|
||||
type_filter=type_filter,
|
||||
limit=limit,
|
||||
since_minutes=since_minutes,
|
||||
response=None,
|
||||
use_cache=False,
|
||||
)
|
||||
|
||||
return await get_or_build_layer_payload(
|
||||
key=cache_key,
|
||||
policy=VESSEL_SNAPSHOT_CACHE_POLICY,
|
||||
builder=build_uncached,
|
||||
response=response,
|
||||
)
|
||||
|
||||
requested_types = _requested_vessel_types(type_filter)
|
||||
safe_limit = _safe_vessel_limit(limit)
|
||||
safe_since_minutes = min(max(int(since_minutes or 60), 1), 1440)
|
||||
@@ -1444,8 +1546,20 @@ def convert_bgp_incidents_to_geojson(
|
||||
|
||||
|
||||
@router.get("/geo/cables")
|
||||
async def get_cables_geojson(db: AsyncSession = Depends(get_db)):
|
||||
async def get_cables_geojson(db: AsyncSession = Depends(get_db), response: Response = None):
|
||||
"""获取海底电缆 GeoJSON 数据 (LineString)"""
|
||||
async def build_payload() -> dict[str, Any]:
|
||||
return await _build_cables_geojson(db)
|
||||
|
||||
return await get_or_build_layer_payload(
|
||||
key=earth_layer_cache.key("cables"),
|
||||
policy=CABLE_CACHE_POLICY,
|
||||
builder=build_payload,
|
||||
response=response,
|
||||
)
|
||||
|
||||
|
||||
async def _build_cables_geojson(db: AsyncSession) -> dict[str, Any]:
|
||||
try:
|
||||
records = await _load_current_collected_data(db, "arcgis_cables")
|
||||
|
||||
@@ -1478,7 +1592,19 @@ async def get_cables_geojson(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/geo/landing-points")
|
||||
async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)):
|
||||
async def get_landing_points_geojson(db: AsyncSession = Depends(get_db), response: Response = None):
|
||||
async def build_payload() -> dict[str, Any]:
|
||||
return await _build_landing_points_geojson(db)
|
||||
|
||||
return await get_or_build_layer_payload(
|
||||
key=earth_layer_cache.key("landing-points"),
|
||||
policy=LANDING_POINT_CACHE_POLICY,
|
||||
builder=build_payload,
|
||||
response=response,
|
||||
)
|
||||
|
||||
|
||||
async def _build_landing_points_geojson(db: AsyncSession) -> dict[str, Any]:
|
||||
try:
|
||||
records_by_source = await _load_current_collected_data_by_sources(
|
||||
db,
|
||||
@@ -1731,8 +1857,25 @@ async def get_satellites_geojson(
|
||||
description="Maximum number of satellites to return. Omit for no limit.",
|
||||
),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
response: Response = None,
|
||||
):
|
||||
"""获取卫星 TLE GeoJSON 数据"""
|
||||
async def build_payload() -> dict[str, Any]:
|
||||
return await _build_satellites_geojson(limit=limit, db=db)
|
||||
|
||||
return await get_or_build_layer_payload(
|
||||
key=earth_layer_cache.key("satellites", limit=limit or "all"),
|
||||
policy=SATELLITE_CACHE_POLICY,
|
||||
builder=build_payload,
|
||||
response=response,
|
||||
)
|
||||
|
||||
|
||||
async def _build_satellites_geojson(
|
||||
*,
|
||||
limit: int | None,
|
||||
db: AsyncSession,
|
||||
) -> dict[str, Any]:
|
||||
records = await _load_current_or_latest_task_data(
|
||||
db,
|
||||
"celestrak_tle",
|
||||
@@ -1800,8 +1943,25 @@ async def get_gpu_clusters_geojson(
|
||||
async def get_compute_centers_geojson(
|
||||
limit: int = Query(200, ge=1, le=1000),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
response: Response = None,
|
||||
):
|
||||
"""获取统一算力中心 GeoJSON 数据"""
|
||||
async def build_payload() -> dict[str, Any]:
|
||||
return await _build_compute_centers_geojson(limit=limit, db=db)
|
||||
|
||||
return await get_or_build_layer_payload(
|
||||
key=earth_layer_cache.key("compute-centers", limit=limit),
|
||||
policy=COMPUTE_CENTER_CACHE_POLICY,
|
||||
builder=build_payload,
|
||||
response=response,
|
||||
)
|
||||
|
||||
|
||||
async def _build_compute_centers_geojson(
|
||||
*,
|
||||
limit: int,
|
||||
db: AsyncSession,
|
||||
) -> dict[str, Any]:
|
||||
records_by_source = await _load_current_collected_data_by_sources(
|
||||
db,
|
||||
["top500", "epoch_ai_gpu"],
|
||||
@@ -2397,7 +2557,31 @@ async def get_bgp_anomalies_geojson(
|
||||
status: Optional[str] = Query("active"),
|
||||
limit: int = Query(200, ge=1, le=1000),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
response: Response = None,
|
||||
):
|
||||
async def build_payload() -> dict[str, Any]:
|
||||
return await _build_bgp_anomalies_geojson(
|
||||
severity=severity,
|
||||
status=status,
|
||||
limit=limit,
|
||||
db=db,
|
||||
)
|
||||
|
||||
return await get_or_build_layer_payload(
|
||||
key=earth_layer_cache.key("bgp-anomalies", severity=severity or "all", status=status or "all", limit=limit),
|
||||
policy=BGP_EVENT_CACHE_POLICY,
|
||||
builder=build_payload,
|
||||
response=response,
|
||||
)
|
||||
|
||||
|
||||
async def _build_bgp_anomalies_geojson(
|
||||
*,
|
||||
severity: str | None,
|
||||
status: str | None,
|
||||
limit: int,
|
||||
db: AsyncSession,
|
||||
) -> dict[str, Any]:
|
||||
stmt = select(BGPAnomaly).order_by(BGPAnomaly.created_at.desc()).limit(limit)
|
||||
if severity:
|
||||
stmt = stmt.where(BGPAnomaly.severity == severity)
|
||||
@@ -2417,7 +2601,31 @@ async def get_bgp_incidents_geojson(
|
||||
status: Optional[str] = Query("active"),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
response: Response = None,
|
||||
):
|
||||
async def build_payload() -> dict[str, Any]:
|
||||
return await _build_bgp_incidents_geojson(
|
||||
severity=severity,
|
||||
status=status,
|
||||
limit=limit,
|
||||
db=db,
|
||||
)
|
||||
|
||||
return await get_or_build_layer_payload(
|
||||
key=earth_layer_cache.key("bgp-incidents", severity=severity or "all", status=status or "all", limit=limit),
|
||||
policy=BGP_EVENT_CACHE_POLICY,
|
||||
builder=build_payload,
|
||||
response=response,
|
||||
)
|
||||
|
||||
|
||||
async def _build_bgp_incidents_geojson(
|
||||
*,
|
||||
severity: str | None,
|
||||
status: str | None,
|
||||
limit: int,
|
||||
db: AsyncSession,
|
||||
) -> dict[str, Any]:
|
||||
stmt = select(BGPIncident).order_by(BGPIncident.created_at.desc()).limit(limit)
|
||||
if severity:
|
||||
stmt = stmt.where(BGPIncident.severity == severity)
|
||||
@@ -2432,7 +2640,19 @@ async def get_bgp_incidents_geojson(
|
||||
|
||||
|
||||
@router.get("/geo/bgp-collectors")
|
||||
async def get_bgp_collectors_geojson(db: AsyncSession = Depends(get_db)):
|
||||
async def get_bgp_collectors_geojson(db: AsyncSession = Depends(get_db), response: Response = None):
|
||||
async def build_payload() -> dict[str, Any]:
|
||||
return await _build_bgp_collectors_geojson(db)
|
||||
|
||||
return await get_or_build_layer_payload(
|
||||
key=earth_layer_cache.key("bgp-collectors"),
|
||||
policy=BGP_CACHE_POLICY,
|
||||
builder=build_payload,
|
||||
response=response,
|
||||
)
|
||||
|
||||
|
||||
async def _build_bgp_collectors_geojson(db: AsyncSession) -> dict[str, Any]:
|
||||
coverage = await build_bgp_collector_coverage(
|
||||
db,
|
||||
source_filter=("ris_live_bgp", "bgpstream_bgp"),
|
||||
@@ -2447,8 +2667,20 @@ async def get_bgp_collectors_geojson(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/geo/summary")
|
||||
async def get_visualization_geo_summary(db: AsyncSession = Depends(get_db)):
|
||||
async def get_visualization_geo_summary(db: AsyncSession = Depends(get_db), response: Response = None):
|
||||
"""Return lightweight Earth HUD counts without loading layer GeoJSON payloads."""
|
||||
async def build_payload() -> dict[str, Any]:
|
||||
return await _build_visualization_geo_summary(db)
|
||||
|
||||
return await get_or_build_layer_payload(
|
||||
key=earth_layer_cache.key("summary"),
|
||||
policy=SUMMARY_CACHE_POLICY,
|
||||
builder=build_payload,
|
||||
response=response,
|
||||
)
|
||||
|
||||
|
||||
async def _build_visualization_geo_summary(db: AsyncSession) -> dict[str, Any]:
|
||||
cable_count = await _count_current_or_latest_task_data(db, "arcgis_cables")
|
||||
landing_point_count = await _count_current_or_latest_task_data(db, "arcgis_landing_points")
|
||||
satellite_count = await _count_current_or_latest_task_data(
|
||||
|
||||
Reference in New Issue
Block a user