From e1984c7a356fab1a507faee91d55cf23e00a41b4 Mon Sep 17 00:00:00 2001 From: linkong Date: Fri, 8 May 2026 17:42:27 +0800 Subject: [PATCH] release: bump version to 0.49.0 Co-Authored-By: Claude Sonnet 4.6 --- VERSION | 2 +- backend/app/api/main.py | 2 + backend/app/api/v1/auth.py | 6 +- backend/app/api/v1/bgp.py | 77 ++ backend/app/api/v1/docs.py | 102 +++ backend/app/api/v1/users.py | 38 +- backend/app/api/v1/visualization.py | 548 ++++++++--- backend/app/core/collected_data_fields.py | 51 +- backend/app/core/security.py | 6 +- .../ripe_ris_collector_locations_seed.json | 328 +++++++ backend/app/db/session.py | 19 + backend/app/models/__init__.py | 4 + backend/app/models/bgp_collector_location.py | 52 ++ backend/app/models/compute_center_location.py | 60 ++ backend/app/models/user.py | 3 +- backend/app/schemas/user.py | 4 + .../app/services/bgp_collector_locations.py | 306 +++++++ backend/app/services/bgp_event_locations.py | 155 ++++ backend/app/services/collectors/bgp_common.py | 56 +- .../app/services/compute_center_locations.py | 863 ++++++++++++++++++ backend/app/services/docs_gatekeeper.py | 119 +++ backend/app/services/location/__init__.py | 57 ++ backend/app/services/location/models.py | 126 +++ backend/app/services/location/pipeline.py | 126 +++ .../services/location/resolvers/__init__.py | 20 + .../services/location/resolvers/inherit.py | 31 + .../services/location/resolvers/nominatim.py | 292 ++++++ .../services/location/resolvers/registry.py | 323 +++++++ .../location/resolvers/source_coordinates.py | 42 + backend/app/services/location/text.py | 41 + backend/tests/conftest.py | 41 +- backend/tests/test_bgp.py | 111 ++- backend/tests/test_bgp_collector_locations.py | 149 +++ backend/tests/test_docs_gatekeeper.py | 115 +++ backend/tests/test_location_pipeline.py | 429 +++++++++ .../test_visualization_compute_centers.py | 688 +++++++++++++- docs/CHANGELOG.md | 13 + docs/plans/README.md | 2 + docs/plans/docs-gatekeeper-auth-plan.md | 92 ++ ...th-mobile-center-country-highlight-plan.md | 252 +++++ .../location-resolver-shared-pipeline-plan.md | 127 +++ docs/technical/en/README.md | 4 + docs/technical/en/backend-collectors.md | 137 ++- .../en/backend-datasources-api-performance.md | 99 ++ ...asource-collector-settings-connectivity.md | 99 +- .../en/docs-gatekeeper-development.md | 116 +++ docs/technical/en/earth-frontend-context.md | 8 +- .../en/earth-toolbar-overlay-coordination.md | 86 ++ .../en/frontend-admin-frontend-context.md | 4 +- .../en/location-pipeline-development.md | 200 ++++ docs/technical/en/location-pipeline-user.md | 127 +++ docs/technical/en/manual.md | 50 +- docs/technical/en/ops-planet-sh-startup.md | 205 +++++ docs/technical/en/quickstart.md | 33 +- docs/technical/zh/README.md | 3 + docs/technical/zh/backend-collectors.md | 60 +- ...asource-collector-settings-connectivity.md | 99 +- .../zh/docs-gatekeeper-development.md | 116 +++ docs/technical/zh/earth-frontend-context.md | 8 +- .../zh/frontend-admin-frontend-context.md | 4 +- .../zh/location-pipeline-development.md | 200 ++++ docs/technical/zh/location-pipeline-user.md | 127 +++ docs/technical/zh/manual.md | 50 +- docs/technical/zh/ops-planet-sh-startup.md | 7 + docs/technical/zh/quickstart.md | 33 +- docs/version-history.md | 3 +- frontend/package.json | 2 +- frontend/public/earth/css/info-panel.css | 181 ++++ frontend/public/earth/css/layer-panel.css | 37 + frontend/public/earth/js/bgp.js | 28 +- frontend/public/earth/js/compute-centers.js | 138 ++- frontend/public/earth/js/constants.js | 2 + frontend/public/earth/js/controls.js | 50 +- frontend/public/earth/js/info-card.js | 512 +++++++++++ frontend/public/earth/js/interactable.js | 39 +- frontend/public/earth/js/main.js | 211 ++++- .../js/mobile-center-country-highlight.js | 128 +++ frontend/public/earth/js/terrain.js | 154 +++- frontend/src/pages/Docs/Docs.tsx | 141 ++- frontend/src/pages/Docs/docs-content.ts | 97 +- frontend/src/pages/Docs/docs-search.ts | 7 +- frontend/src/pages/Users/Users.tsx | 33 + frontend/src/stores/auth.ts | 1 + planet.sh | 56 +- pyproject.toml | 2 +- uv.lock | 2 +- 86 files changed, 9165 insertions(+), 412 deletions(-) create mode 100644 backend/app/api/v1/docs.py create mode 100644 backend/app/data/seeds/ripe_ris_collector_locations_seed.json create mode 100644 backend/app/models/bgp_collector_location.py create mode 100644 backend/app/models/compute_center_location.py create mode 100644 backend/app/services/bgp_collector_locations.py create mode 100644 backend/app/services/bgp_event_locations.py create mode 100644 backend/app/services/compute_center_locations.py create mode 100644 backend/app/services/docs_gatekeeper.py create mode 100644 backend/app/services/location/__init__.py create mode 100644 backend/app/services/location/models.py create mode 100644 backend/app/services/location/pipeline.py create mode 100644 backend/app/services/location/resolvers/__init__.py create mode 100644 backend/app/services/location/resolvers/inherit.py create mode 100644 backend/app/services/location/resolvers/nominatim.py create mode 100644 backend/app/services/location/resolvers/registry.py create mode 100644 backend/app/services/location/resolvers/source_coordinates.py create mode 100644 backend/app/services/location/text.py create mode 100644 backend/tests/test_bgp_collector_locations.py create mode 100644 backend/tests/test_docs_gatekeeper.py create mode 100644 backend/tests/test_location_pipeline.py create mode 100644 docs/plans/docs-gatekeeper-auth-plan.md create mode 100644 docs/plans/earth-mobile-center-country-highlight-plan.md create mode 100644 docs/plans/location-resolver-shared-pipeline-plan.md create mode 100644 docs/technical/en/backend-datasources-api-performance.md create mode 100644 docs/technical/en/docs-gatekeeper-development.md create mode 100644 docs/technical/en/earth-toolbar-overlay-coordination.md create mode 100644 docs/technical/en/location-pipeline-development.md create mode 100644 docs/technical/en/location-pipeline-user.md create mode 100644 docs/technical/en/ops-planet-sh-startup.md create mode 100644 docs/technical/zh/docs-gatekeeper-development.md create mode 100644 docs/technical/zh/location-pipeline-development.md create mode 100644 docs/technical/zh/location-pipeline-user.md create mode 100644 frontend/public/earth/js/mobile-center-country-highlight.js diff --git a/VERSION b/VERSION index a758a09a..5c4503b7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.48.0 +0.49.0 diff --git a/backend/app/api/main.py b/backend/app/api/main.py index ae47610b..26fb73b4 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -5,6 +5,7 @@ from app.api.v1 import ( users, datasource_config, datasources, + docs, tasks, dashboard, websocket, @@ -29,6 +30,7 @@ api_router.include_router( ) api_router.include_router(datasources.router, prefix="/datasources", tags=["datasources"]) api_router.include_router(collected_data.router, prefix="/collected", tags=["collected-data"]) +api_router.include_router(docs.router, prefix="/docs", tags=["docs"]) api_router.include_router(tasks.router, prefix="/tasks", tags=["tasks"]) api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"]) api_router.include_router(alerts.router, prefix="/alerts", tags=["alerts"]) diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index 804b244c..379e4853 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -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, } diff --git a/backend/app/api/v1/bgp.py b/backend/app/api/v1/bgp.py index ff32dd83..fea3d59b 100644 --- a/backend/app/api/v1/bgp.py +++ b/backend/app/api/v1/bgp.py @@ -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), diff --git a/backend/app/api/v1/docs.py b/backend/app/api/v1/docs.py new file mode 100644 index 00000000..e26dea21 --- /dev/null +++ b/backend/app/api/v1/docs.py @@ -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"), + } diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py index bc547f65..fdcfca95 100644 --- a/backend/app/api/v1/users.py +++ b/backend/app/api/v1/users.py @@ -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 diff --git a/backend/app/api/v1/visualization.py b/backend/app/api/v1/visualization.py index 11fc0a72..5eb07822 100644 --- a/backend/app/api/v1/visualization.py +++ b/backend/app/api/v1/visualization.py @@ -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( diff --git a/backend/app/core/collected_data_fields.py b/backend/app/core/collected_data_fields.py index 5574605c..04c7bcb0 100644 --- a/backend/app/core/collected_data_fields.py +++ b/backend/app/core/collected_data_fields.py @@ -4,8 +4,8 @@ from typing import Any, Dict, Optional FIELD_ALIASES = { "country": ("country",), "city": ("city",), - "latitude": ("latitude",), - "longitude": ("longitude",), + "latitude": ("latitude", "lat"), + "longitude": ("longitude", "lon", "lng"), "value": ("value",), "unit": ("unit",), "cores": ("cores",), @@ -14,6 +14,28 @@ FIELD_ALIASES = { "power": ("power",), } +NESTED_FIELD_ALIASES = { + "latitude": ( + ("location", "latitude"), + ("location", "lat"), + ("geo", "latitude"), + ("geo", "lat"), + ("coordinates", "latitude"), + ("coordinates", "lat"), + ), + "longitude": ( + ("location", "longitude"), + ("location", "lon"), + ("location", "lng"), + ("geo", "longitude"), + ("geo", "lon"), + ("geo", "lng"), + ("coordinates", "longitude"), + ("coordinates", "lon"), + ("coordinates", "lng"), + ), +} + def get_metadata_field(metadata: Optional[Dict[str, Any]], field: str, fallback: Any = None) -> Any: if isinstance(metadata, dict): @@ -21,9 +43,34 @@ def get_metadata_field(metadata: Optional[Dict[str, Any]], field: str, fallback: value = metadata.get(key) if value not in (None, ""): return value + for path in NESTED_FIELD_ALIASES.get(field, ()): + current: Any = metadata + for key in path: + if not isinstance(current, dict): + current = None + break + current = current.get(key) + if current not in (None, ""): + return current + if field in {"latitude", "longitude"}: + value = _get_coordinate_sequence_value(metadata, field) + if value not in (None, ""): + return value return fallback +def _get_coordinate_sequence_value(metadata: Dict[str, Any], field: str) -> Any: + for key in ("coordinates", "coord", "coords"): + value = metadata.get(key) + if not isinstance(value, (list, tuple)) or len(value) < 2: + continue + # GeoJSON uses [longitude, latitude]. Most raw collector tuples in this + # codebase use explicit field names, so only sequence aliases are treated + # as GeoJSON-shaped to avoid guessing. + return value[1] if field == "latitude" else value[0] + return None + + def build_dynamic_metadata( metadata: Optional[Dict[str, Any]], *, diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 4b0bdcc5..4812424f 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -105,7 +105,7 @@ async def get_current_user( ) result = await db.execute( text( - "SELECT id, username, email, password_hash, role, is_active FROM users WHERE id = :id" + "SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups FROM users WHERE id = :id" ), {"id": int(user_id)}, ) @@ -122,6 +122,7 @@ async def get_current_user( user.password_hash = row[3] user.role = row[4] user.is_active = row[5] + user.gatekeeper_groups = row[6] or [] return user @@ -144,7 +145,7 @@ async def get_current_user_refresh( ) result = await db.execute( text( - "SELECT id, username, email, password_hash, role, is_active FROM users WHERE id = :id" + "SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups FROM users WHERE id = :id" ), {"id": int(user_id)}, ) @@ -161,6 +162,7 @@ async def get_current_user_refresh( user.password_hash = row[3] user.role = row[4] user.is_active = row[5] + user.gatekeeper_groups = row[6] or [] return user diff --git a/backend/app/data/seeds/ripe_ris_collector_locations_seed.json b/backend/app/data/seeds/ripe_ris_collector_locations_seed.json new file mode 100644 index 00000000..9689c869 --- /dev/null +++ b/backend/app/data/seeds/ripe_ris_collector_locations_seed.json @@ -0,0 +1,328 @@ +{ + "_comment": "Seed payload for the bgp_collector_locations DB table. Coordinates were migrated from the legacy RIPE_RIS_COLLECTOR_COORDS table and default to city-center; seeded rows are unverified and should be upgraded in the database with source evidence when known.", + "locations": [ + { + "canonical_name": "RIPE RIS rrc00", + "aliases": ["rrc00", "RIPE RIS rrc00", "AMS-IX"], + "operator": "RIPE NCC", + "site": "AMS-IX", + "city": "Amsterdam", + "country": "Netherlands", + "latitude": 52.3676, + "longitude": 4.9041, + "precision": "city", + "confidence": 0.85, + "source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table", + "verified_at": null + }, + { + "canonical_name": "RIPE RIS rrc01", + "aliases": ["rrc01", "RIPE RIS rrc01", "LINX"], + "operator": "RIPE NCC", + "site": "LINX", + "city": "London", + "country": "United Kingdom", + "latitude": 51.5072, + "longitude": -0.1276, + "precision": "city", + "confidence": 0.85, + "source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table", + "verified_at": null + }, + { + "canonical_name": "RIPE RIS rrc03", + "aliases": ["rrc03", "RIPE RIS rrc03", "AMS-IX"], + "operator": "RIPE NCC", + "site": "AMS-IX", + "city": "Amsterdam", + "country": "Netherlands", + "latitude": 52.3676, + "longitude": 4.9041, + "precision": "city", + "confidence": 0.85, + "source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table", + "verified_at": null + }, + { + "canonical_name": "RIPE RIS rrc04", + "aliases": ["rrc04", "RIPE RIS rrc04", "CIXP", "CERN Internet Exchange Point"], + "operator": "RIPE NCC", + "site": "CIXP", + "city": "Geneva", + "country": "Switzerland", + "latitude": 46.2044, + "longitude": 6.1432, + "precision": "city", + "confidence": 0.85, + "source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table", + "verified_at": null + }, + { + "canonical_name": "RIPE RIS rrc05", + "aliases": ["rrc05", "RIPE RIS rrc05", "VIX", "Vienna Internet Exchange"], + "operator": "RIPE NCC", + "site": "VIX", + "city": "Vienna", + "country": "Austria", + "latitude": 48.2082, + "longitude": 16.3738, + "precision": "city", + "confidence": 0.85, + "source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table", + "verified_at": null + }, + { + "canonical_name": "RIPE RIS rrc06", + "aliases": ["rrc06", "RIPE RIS rrc06", "JPIX", "Otemachi"], + "operator": "RIPE NCC", + "site": "JPIX", + "city": "Otemachi", + "country": "Japan", + "latitude": 35.686, + "longitude": 139.7671, + "precision": "city", + "confidence": 0.85, + "source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table", + "verified_at": null + }, + { + "canonical_name": "RIPE RIS rrc07", + "aliases": ["rrc07", "RIPE RIS rrc07", "Netnod", "Netnod Stockholm"], + "operator": "RIPE NCC", + "site": "Netnod Stockholm", + "city": "Stockholm", + "country": "Sweden", + "latitude": 59.3293, + "longitude": 18.0686, + "precision": "city", + "confidence": 0.85, + "source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table", + "verified_at": null + }, + { + "canonical_name": "RIPE RIS rrc10", + "aliases": ["rrc10", "RIPE RIS rrc10", "MIX", "Milan Internet Exchange"], + "operator": "RIPE NCC", + "site": "MIX", + "city": "Milan", + "country": "Italy", + "latitude": 45.4642, + "longitude": 9.19, + "precision": "city", + "confidence": 0.85, + "source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table", + "verified_at": null + }, + { + "canonical_name": "RIPE RIS rrc11", + "aliases": ["rrc11", "RIPE RIS rrc11", "NYIIX", "New York International Internet Exchange"], + "operator": "RIPE NCC", + "site": "NYIIX", + "city": "New York", + "country": "United States", + "latitude": 40.7128, + "longitude": -74.006, + "precision": "city", + "confidence": 0.85, + "source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table", + "verified_at": null + }, + { + "canonical_name": "RIPE RIS rrc12", + "aliases": ["rrc12", "RIPE RIS rrc12", "DE-CIX", "DE-CIX Frankfurt"], + "operator": "RIPE NCC", + "site": "DE-CIX Frankfurt", + "city": "Frankfurt", + "country": "Germany", + "latitude": 50.1109, + "longitude": 8.6821, + "precision": "city", + "confidence": 0.85, + "source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table", + "verified_at": null + }, + { + "canonical_name": "RIPE RIS rrc13", + "aliases": ["rrc13", "RIPE RIS rrc13", "MSK-IX"], + "operator": "RIPE NCC", + "site": "MSK-IX", + "city": "Moscow", + "country": "Russia", + "latitude": 55.7558, + "longitude": 37.6173, + "precision": "city", + "confidence": 0.85, + "source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table", + "verified_at": null + }, + { + "canonical_name": "RIPE RIS rrc14", + "aliases": ["rrc14", "RIPE RIS rrc14", "PAIX", "Palo Alto Internet Exchange"], + "operator": "RIPE NCC", + "site": "PAIX", + "city": "Palo Alto", + "country": "United States", + "latitude": 37.4419, + "longitude": -122.143, + "precision": "city", + "confidence": 0.85, + "source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table", + "verified_at": null + }, + { + "canonical_name": "RIPE RIS rrc15", + "aliases": ["rrc15", "RIPE RIS rrc15", "PTT.br Sao Paulo", "PTTMetro Sao Paulo"], + "operator": "RIPE NCC", + "site": "PTT.br", + "city": "Sao Paulo", + "country": "Brazil", + "latitude": -23.5558, + "longitude": -46.6396, + "precision": "city", + "confidence": 0.85, + "source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table", + "verified_at": null + }, + { + "canonical_name": "RIPE RIS rrc16", + "aliases": ["rrc16", "RIPE RIS rrc16", "Equinix Miami", "NOTA Miami"], + "operator": "RIPE NCC", + "site": "Equinix Miami", + "city": "Miami", + "country": "United States", + "latitude": 25.7617, + "longitude": -80.1918, + "precision": "city", + "confidence": 0.85, + "source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table", + "verified_at": null + }, + { + "canonical_name": "RIPE RIS rrc18", + "aliases": ["rrc18", "RIPE RIS rrc18", "CATNIX"], + "operator": "RIPE NCC", + "site": "CATNIX", + "city": "Barcelona", + "country": "Spain", + "latitude": 41.3874, + "longitude": 2.1686, + "precision": "city", + "confidence": 0.85, + "source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table", + "verified_at": null + }, + { + "canonical_name": "RIPE RIS rrc19", + "aliases": ["rrc19", "RIPE RIS rrc19", "NAPAfrica", "JINX", "NAPAfrica Johannesburg"], + "operator": "RIPE NCC", + "site": "NAPAfrica Johannesburg", + "city": "Johannesburg", + "country": "South Africa", + "latitude": -26.2041, + "longitude": 28.0473, + "precision": "city", + "confidence": 0.85, + "source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table", + "verified_at": null + }, + { + "canonical_name": "RIPE RIS rrc20", + "aliases": ["rrc20", "RIPE RIS rrc20", "SwissIX"], + "operator": "RIPE NCC", + "site": "SwissIX", + "city": "Zurich", + "country": "Switzerland", + "latitude": 47.3769, + "longitude": 8.5417, + "precision": "city", + "confidence": 0.85, + "source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table", + "verified_at": null + }, + { + "canonical_name": "RIPE RIS rrc21", + "aliases": ["rrc21", "RIPE RIS rrc21", "France-IX Paris"], + "operator": "RIPE NCC", + "site": "France-IX Paris", + "city": "Paris", + "country": "France", + "latitude": 48.8566, + "longitude": 2.3522, + "precision": "city", + "confidence": 0.85, + "source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table", + "verified_at": null + }, + { + "canonical_name": "RIPE RIS rrc22", + "aliases": ["rrc22", "RIPE RIS rrc22", "InterLAN Bucharest"], + "operator": "RIPE NCC", + "site": "InterLAN Bucharest", + "city": "Bucharest", + "country": "Romania", + "latitude": 44.4268, + "longitude": 26.1025, + "precision": "city", + "confidence": 0.85, + "source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table", + "verified_at": null + }, + { + "canonical_name": "RIPE RIS rrc23", + "aliases": ["rrc23", "RIPE RIS rrc23", "Equinix Singapore"], + "operator": "RIPE NCC", + "site": "Equinix Singapore", + "city": "Singapore", + "country": "Singapore", + "latitude": 1.3521, + "longitude": 103.8198, + "precision": "city", + "confidence": 0.85, + "source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table", + "verified_at": null + }, + { + "canonical_name": "RIPE RIS rrc24", + "aliases": ["rrc24", "RIPE RIS rrc24", "LACNIC Montevideo"], + "operator": "RIPE NCC", + "site": "LACNIC Montevideo", + "city": "Montevideo", + "country": "Uruguay", + "latitude": -34.9011, + "longitude": -56.1645, + "precision": "city", + "confidence": 0.85, + "source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table", + "verified_at": null + }, + { + "canonical_name": "RIPE RIS rrc25", + "aliases": ["rrc25", "RIPE RIS rrc25", "AMS-IX"], + "operator": "RIPE NCC", + "site": "AMS-IX", + "city": "Amsterdam", + "country": "Netherlands", + "latitude": 52.3676, + "longitude": 4.9041, + "precision": "city", + "confidence": 0.85, + "source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table", + "verified_at": null + }, + { + "canonical_name": "RIPE RIS rrc26", + "aliases": ["rrc26", "RIPE RIS rrc26", "UAE-IX"], + "operator": "RIPE NCC", + "site": "UAE-IX", + "city": "Dubai", + "country": "United Arab Emirates", + "latitude": 25.2048, + "longitude": 55.2708, + "precision": "city", + "confidence": 0.85, + "source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table", + "verified_at": null + } + ], + "city_fallbacks": [] +} diff --git a/backend/app/db/session.py b/backend/app/db/session.py index 2483f803..4f0e0404 100644 --- a/backend/app/db/session.py +++ b/backend/app/db/session.py @@ -103,9 +103,11 @@ async def init_db(): import app.models.datasource_config # noqa: F401 import app.models.alert # noqa: F401 import app.models.bgp_anomaly # noqa: F401 + import app.models.bgp_collector_location # noqa: F401 import app.models.bgp_incident # noqa: F401 import app.models.bgp_observation # noqa: F401 import app.models.collected_data # noqa: F401 + import app.models.compute_center_location # noqa: F401 import app.models.system_setting # noqa: F401 import app.models.playground_session # noqa: F401 import app.models.playground_message # noqa: F401 @@ -128,6 +130,14 @@ async def init_db(): async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) + await conn.execute( + text( + """ + ALTER TABLE users + ADD COLUMN IF NOT EXISTS gatekeeper_groups JSONB DEFAULT '[]'::jsonb + """ + ) + ) await conn.execute( text( """ @@ -208,5 +218,14 @@ async def init_db(): ) async with async_session_factory() as session: + from app.services.bgp_collector_locations import ( + seed_default_bgp_collector_locations, + ) + from app.services.compute_center_locations import ( + seed_compute_center_locations_from_source_coords, + ) + + await seed_default_bgp_collector_locations(session) + await seed_compute_center_locations_from_source_coords(session) await seed_default_datasources(session) await ensure_default_admin_user(session) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 9ded1306..4e9bc11c 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -6,8 +6,10 @@ from app.models.datasource import DataSource from app.models.datasource_config import DataSourceConfig from app.models.alert import Alert, AlertSeverity, AlertStatus from app.models.bgp_anomaly import BGPAnomaly +from app.models.bgp_collector_location import BGPCollectorLocation from app.models.bgp_incident import BGPIncident from app.models.bgp_observation import BGPObservation +from app.models.compute_center_location import ComputeCenterLocationRecord from app.models.system_setting import SystemSetting from app.models.playground_session import PlaygroundSession from app.models.playground_message import PlaygroundMessage @@ -27,8 +29,10 @@ __all__ = [ "AlertSeverity", "AlertStatus", "BGPAnomaly", + "BGPCollectorLocation", "BGPIncident", "BGPObservation", + "ComputeCenterLocationRecord", "SystemLog", "AuditLog", "PlaygroundSession", diff --git a/backend/app/models/bgp_collector_location.py b/backend/app/models/bgp_collector_location.py new file mode 100644 index 00000000..e9b5ce76 --- /dev/null +++ b/backend/app/models/bgp_collector_location.py @@ -0,0 +1,52 @@ +"""Stored BGP route-collector locations.""" + +from sqlalchemy import Boolean, Column, DateTime, Float, Integer, JSON, String, Text +from sqlalchemy.sql import func + +from app.core.time import to_iso8601_utc +from app.db.session import Base + + +class BGPCollectorLocation(Base): + """Current known location for a BGP route collector.""" + + __tablename__ = "bgp_collector_locations" + + id = Column(Integer, primary_key=True, autoincrement=True) + collector_id = Column(String(100), nullable=False, unique=True, index=True) + operator = Column(String(255), nullable=True) + site = Column(String(255), nullable=True) + city = Column(String(255), nullable=True) + country = Column(String(255), nullable=True) + latitude = Column(Float, nullable=True) + longitude = Column(Float, nullable=True) + precision = Column(String(30), nullable=False, default="city") + confidence = Column(Float, nullable=True) + source = Column(String(80), nullable=False, default="legacy_seed", index=True) + source_url = Column(String(500), nullable=True) + source_note = Column(Text, nullable=True) + raw_payload = Column(JSON, nullable=False, default=dict) + needs_confirmation = Column(Boolean, nullable=False, default=True, index=True) + verification_status = Column(String(30), nullable=False, default="unverified", index=True) + verified_at = Column(DateTime(timezone=True), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + def to_location_dict(self) -> dict: + return { + "city": self.city, + "country": self.country, + "latitude": self.latitude, + "longitude": self.longitude, + "precision": self.precision, + "source": self.source, + "needs_confirmation": self.needs_confirmation, + "matched_location_name": self.site or self.collector_id, + "verified_at": to_iso8601_utc(self.verified_at), + "confidence": self.confidence, + "operator": self.operator, + "site": self.site, + "verification_status": self.verification_status, + "source_note": self.source_note, + "source_url": self.source_url, + } diff --git a/backend/app/models/compute_center_location.py b/backend/app/models/compute_center_location.py new file mode 100644 index 00000000..5466e7cb --- /dev/null +++ b/backend/app/models/compute_center_location.py @@ -0,0 +1,60 @@ +"""Stored compute-center locations.""" + +from sqlalchemy import Boolean, Column, DateTime, Float, Integer, JSON, String, Text, UniqueConstraint +from sqlalchemy.sql import func + +from app.core.time import to_iso8601_utc +from app.db.session import Base + + +class ComputeCenterLocationRecord(Base): + """Current known location for a compute-center record.""" + + __tablename__ = "compute_center_locations" + __table_args__ = ( + UniqueConstraint("source", "source_id", name="uq_compute_center_location_source_id"), + ) + + id = Column(Integer, primary_key=True, autoincrement=True) + source = Column(String(100), nullable=False, index=True) + source_id = Column(String(255), nullable=False, index=True) + name = Column(String(500), nullable=True) + operator = Column(String(255), nullable=True) + site = Column(String(255), nullable=True) + city = Column(String(255), nullable=True) + country = Column(String(255), nullable=True) + latitude = Column(Float, nullable=True) + longitude = Column(Float, nullable=True) + precision = Column(String(30), nullable=False, default="city") + confidence = Column(Float, nullable=True) + location_source = Column(String(80), nullable=False, default="stored_compute_center_location", index=True) + source_url = Column(String(500), nullable=True) + source_note = Column(Text, nullable=True) + raw_payload = Column(JSON, nullable=False, default=dict) + needs_confirmation = Column(Boolean, nullable=False, default=False, index=True) + verification_status = Column(String(30), nullable=False, default="verified", index=True) + verified_at = Column(DateTime(timezone=True), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + def to_location_dict(self) -> dict: + return { + "source": self.source, + "source_id": self.source_id, + "name": self.name, + "operator": self.operator, + "site": self.site, + "city": self.city, + "country": self.country, + "latitude": self.latitude, + "longitude": self.longitude, + "precision": self.precision, + "confidence": self.confidence, + "location_source": self.location_source, + "source_url": self.source_url, + "source_note": self.source_note, + "raw_payload": self.raw_payload or {}, + "needs_confirmation": self.needs_confirmation, + "verification_status": self.verification_status, + "verified_at": to_iso8601_utc(self.verified_at), + } diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 5e588539..b16f4c49 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,4 +1,4 @@ -from sqlalchemy import Boolean, Column, Integer, String, DateTime +from sqlalchemy import Boolean, Column, DateTime, Integer, JSON, String from sqlalchemy.sql import func from app.db.session import Base @@ -12,6 +12,7 @@ class User(Base): email = Column(String(255), unique=True, index=True, nullable=False) password_hash = Column(String(255), nullable=False) role = Column(String(20), default="viewer") + gatekeeper_groups = Column(JSON, default=list) is_active = Column(Boolean, default=True) last_login_at = Column(DateTime(timezone=True)) created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 13742b4a..9148d561 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -12,17 +12,20 @@ class UserBase(BaseModel): class UserCreate(UserBase): password: str = Field(..., min_length=8) role: str = "viewer" + gatekeeper_groups: list[str] = Field(default_factory=list) class UserUpdate(BaseModel): email: Optional[EmailStr] = None role: Optional[str] = None + gatekeeper_groups: Optional[list[str]] = None is_active: Optional[bool] = None class UserInDB(UserBase): id: int role: str + gatekeeper_groups: list[str] = Field(default_factory=list) is_active: bool last_login_at: Optional[datetime] created_at: datetime @@ -34,6 +37,7 @@ class UserInDB(UserBase): class UserResponse(UserBase): id: int role: str + gatekeeper_groups: list[str] = Field(default_factory=list) is_active: bool created_at: datetime diff --git a/backend/app/services/bgp_collector_locations.py b/backend/app/services/bgp_collector_locations.py new file mode 100644 index 00000000..5798514a --- /dev/null +++ b/backend/app/services/bgp_collector_locations.py @@ -0,0 +1,306 @@ +"""BGP route-collector location resolver. + +Collector positions are stored in the ``bgp_collector_locations`` database +table. The old JSON registry is now only a seed payload used during database +initialization, not a runtime resolver or candidate source. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Iterator + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.bgp_collector_location import BGPCollectorLocation +from app.services.location import ( + LocationCandidate, + LocationPipeline, + LocationQuery, + NominatimResolver, + ResolutionResult, + ResolverOutput, + SourceCoordinatesResolver, + build_default_nominatim_geocoder, + coerce_str, + normalize_text, +) + +SEED_PATH = ( + Path(__file__).resolve().parents[1] + / "data" + / "seeds" + / "ripe_ris_collector_locations_seed.json" +) + +# ── Geocoder (kept at module level for monkeypatching + cache_clear) ── + +_geocode_online = build_default_nominatim_geocoder() + + +# ── In-process compatibility cache ────────────────────────────────── + + +RIPE_RIS_COLLECTOR_COORDS: dict[str, dict[str, Any]] = {} + + +def _collector_record_to_dict(record: BGPCollectorLocation) -> dict[str, Any]: + return record.to_location_dict() + + +def set_bgp_collector_location_cache( + locations: dict[str, dict[str, Any]], +) -> None: + """Replace the legacy compatibility cache in-place.""" + RIPE_RIS_COLLECTOR_COORDS.clear() + RIPE_RIS_COLLECTOR_COORDS.update( + {coerce_str(key): dict(value) for key, value in locations.items()} + ) + + +async def refresh_bgp_collector_location_cache( + session: AsyncSession, +) -> dict[str, dict[str, Any]]: + result = await session.execute(select(BGPCollectorLocation)) + records = result.scalars().all() + cache = { + record.collector_id: _collector_record_to_dict(record) + for record in records + if record.collector_id + } + set_bgp_collector_location_cache(cache) + return cache + + +def _load_seed_payload() -> dict[str, Any]: + with SEED_PATH.open("r", encoding="utf-8") as handle: + return json.load(handle) + + +def _seed_entry_to_record_kwargs(entry: dict[str, Any], collector_id: str) -> dict[str, Any]: + return { + "collector_id": collector_id, + "operator": entry.get("operator") or "RIPE NCC", + "site": entry.get("site"), + "city": entry.get("city"), + "country": entry.get("country"), + "latitude": entry.get("latitude"), + "longitude": entry.get("longitude"), + "precision": entry.get("precision") or "city", + "confidence": entry.get("confidence"), + "source": "legacy_seed", + "source_url": None, + "source_note": entry.get("source_note") + or "Seeded from legacy RIPE RIS collector coordinates", + "raw_payload": entry, + "needs_confirmation": True, + "verification_status": "unverified", + "verified_at": None, + } + + +async def seed_default_bgp_collector_locations(session: AsyncSession) -> None: + """Seed default RIPE RIS collector locations without overwriting users.""" + payload = _load_seed_payload() + for entry in payload.get("locations", []): + aliases = entry.get("aliases") or [] + collector_ids = [ + coerce_str(alias) + for alias in aliases + if coerce_str(alias).startswith("rrc") + ] + if not collector_ids: + continue + collector_id = collector_ids[0] + existing = await session.scalar( + select(BGPCollectorLocation).where( + BGPCollectorLocation.collector_id == collector_id + ) + ) + if existing: + continue + session.add( + BGPCollectorLocation( + **_seed_entry_to_record_kwargs(entry, collector_id) + ) + ) + await session.commit() + await refresh_bgp_collector_location_cache(session) + + +def get_bgp_collector_location_dict(collector_name: str) -> dict[str, Any]: + """Return the current cached collector location dict, or ``{}`` if unknown.""" + return dict(RIPE_RIS_COLLECTOR_COORDS.get(coerce_str(collector_name), {})) + + +def iter_known_collector_names() -> Iterator[str]: + """Yield every collector technical name (rrcXX) known in the cache.""" + return iter(sorted(RIPE_RIS_COLLECTOR_COORDS.keys())) + + +# ── Pipeline construction ────────────────────────────────────────── + + +class StoredCollectorLocationResolver: + """Resolve a collector through the DB-backed compatibility cache.""" + + name = "stored_collector_location" + + def resolve(self, query: LocationQuery) -> ResolverOutput: + collector = coerce_str(query.name) + if not collector: + for alias in query.aliases: + collector = coerce_str(alias) + if collector: + break + if not collector: + return ResolverOutput() + location = get_bgp_collector_location_dict(collector) + if not location: + return ResolverOutput() + latitude = location.get("latitude") + longitude = location.get("longitude") + if latitude in (None, 0.0) or longitude in (None, 0.0): + return ResolverOutput() + return ResolverOutput( + candidates=( + LocationCandidate( + latitude=float(latitude), + longitude=float(longitude), + display_name=location.get("matched_location_name") or collector, + precision=location.get("precision") or "city", + confidence=float(location.get("confidence") or 0.85), + query=f"stored_collector_location::{collector}", + source=location.get("source") or self.name, + source_note=location.get("source_note"), + matched_fields=("collector",), + needs_confirmation=bool(location.get("needs_confirmation")), + city=location.get("city"), + region=None, + country=location.get("country"), + matched_location_name=( + location.get("matched_location_name") or collector + ), + location_verified_at=location.get("verified_at"), + suggested_registry_entry=None, + ), + ) + ) + + +def _bgp_collector_query_plan( + query: LocationQuery, +) -> list[tuple[str, tuple[str, ...]]]: + """Build the Nominatim query plan for a BGP collector.""" + extra = query.extra or {} + site = str(extra.get("site") or "") + operator = str(extra.get("operator") or "") + city = query.city or "" + country = query.country or "" + + plan: list[tuple[str, tuple[str, ...]]] = [] + + def add(parts: list[tuple[str, str]]) -> None: + non_empty = [(field, value) for field, value in parts if value] + if not non_empty: + return + seen: set[str] = set() + cleaned: list[str] = [] + fields: list[str] = [] + for field, value in non_empty: + key = normalize_text(value) + if not key or key in seen: + continue + seen.add(key) + cleaned.append(value) + fields.append(field) + if not cleaned: + return + composed = ", ".join(cleaned) + if not any(composed == existing for existing, _ in plan): + plan.append((composed, tuple(fields))) + + add([("site", site), ("city", city), ("country", country)]) + add([("site", site), ("country", country)]) + add([("operator", operator), ("city", city), ("country", country)]) + add([("city", city), ("country", country)]) + return plan + + +BGP_COLLECTOR_PIPELINE = LocationPipeline( + [ + SourceCoordinatesResolver(), + StoredCollectorLocationResolver(), + ], + failure_reason=( + "Could not resolve BGP collector to renderable coordinates from" + " source coordinates or stored collector location." + ), +) + +BGP_COLLECTOR_COLLECTION_PIPELINE = LocationPipeline( + [ + SourceCoordinatesResolver(), + NominatimResolver( + query_plan_builder=_bgp_collector_query_plan, + # Late-binding so tests can monkeypatch ``_geocode_online``. + geocoder=lambda q: _geocode_online(q), + ), + ], + failure_reason=( + "Could not resolve BGP collector to renderable coordinates from" + " source coordinates or online geocoding." + ), +) + + +# ── Public API ───────────────────────────────────────────────────── + + +def resolve_bgp_collector_location( + collector_name: str, + *, + city: str | None = None, + country: str | None = None, + site: str | None = None, + operator: str | None = None, +) -> ResolutionResult: + """Resolve a BGP collector to its best-known stored location.""" + stored = get_bgp_collector_location_dict(collector_name) + name = coerce_str(collector_name) or None + query = LocationQuery( + name=name, + aliases=tuple(filter(None, (collector_name,))), + city=coerce_str(city or stored.get("city")) or None, + country=coerce_str(country or stored.get("country")) or None, + extra={ + "site": coerce_str(site or stored.get("site")), + "operator": coerce_str(operator or stored.get("operator")) or "RIPE NCC", + }, + ) + return BGP_COLLECTOR_PIPELINE.resolve_best(query) + + +def collect_bgp_collector_location_candidates( + *, + collector: str | None = None, + city: str | None = None, + country: str | None = None, + site: str | None = None, + operator: str | None = None, +) -> tuple[list[LocationCandidate], list[str]]: + stored = get_bgp_collector_location_dict(collector or "") + name = coerce_str(collector) or None + query = LocationQuery( + name=name, + aliases=tuple(filter(None, (collector,))), + city=coerce_str(city or stored.get("city")) or None, + country=coerce_str(country or stored.get("country")) or None, + extra={ + "site": coerce_str(site or stored.get("site")), + "operator": coerce_str(operator or stored.get("operator")) or "RIPE NCC", + }, + ) + return BGP_COLLECTOR_COLLECTION_PIPELINE.collect_candidates(query) diff --git a/backend/app/services/bgp_event_locations.py b/backend/app/services/bgp_event_locations.py new file mode 100644 index 00000000..a647f258 --- /dev/null +++ b/backend/app/services/bgp_event_locations.py @@ -0,0 +1,155 @@ +"""BGP event location resolver. + +A BGP event (announcement / withdrawal / RIB entry) is geographically tied to +the route collector that observed it. This module defines the pipeline that +turns an event payload into renderable coordinates. + +Current resolver chain: + + SourceCoordinates → event payload itself carries lat/lon (rare; some + enriched feeds do). + InheritFromCollector → look up the owning collector via + :func:`resolve_bgp_collector_location`. + +Future plug-ins (no consumer changes required, just append to the list): + + ASNFacilityResolver — origin/peer ASN → peeringdb facility. + PrefixGeoResolver — prefix → IP range geo lookup (iptoasn / opengeofeed). +""" + +from __future__ import annotations + +from typing import Any + +from app.services.bgp_collector_locations import ( + get_bgp_collector_location_dict, +) +from app.services.location import ( + InheritFromAnotherEntityResolver, + LocationCandidate, + LocationPipeline, + LocationQuery, + ResolutionResult, + SourceCoordinatesResolver, + coerce_str, +) + + +def _inherit_from_owning_collector( + query: LocationQuery, +) -> LocationCandidate | None: + """Look up the event's owning collector by exact name in the DB-backed cache.""" + extra = query.extra or {} + collector_name = coerce_str(extra.get("collector")) + if not collector_name: + return None + legacy = get_bgp_collector_location_dict(collector_name) + if not legacy: + return None + latitude = legacy.get("latitude") + longitude = legacy.get("longitude") + if latitude in (None, 0.0) or longitude in (None, 0.0): + return None + return LocationCandidate( + latitude=float(latitude), + longitude=float(longitude), + display_name=legacy.get("matched_location_name") or collector_name, + precision=legacy.get("precision") or "city", + confidence=float(legacy.get("confidence") or 0.85), + query=f"inherit_from_collector::{collector_name}", + source="inherited_from_collector", + source_note=( + f"Inherited from owning collector {collector_name}" + ), + matched_fields=("collector",), + needs_confirmation=bool(legacy.get("needs_confirmation")), + city=legacy.get("city"), + region=None, + country=legacy.get("country"), + matched_location_name=legacy.get("matched_location_name"), + location_verified_at=legacy.get("verified_at"), + suggested_registry_entry=None, + ) + + +BGP_EVENT_PIPELINE = LocationPipeline( + [ + SourceCoordinatesResolver(), + InheritFromAnotherEntityResolver( + source_lookup=_inherit_from_owning_collector, + name="inherited_from_collector", + ), + # Plug new resolvers (peeringdb / ASN facility / prefix-geo) here. + ], + failure_reason=( + "Could not resolve BGP event coordinates: no source coords, owning" + " collector unknown, and no fallback resolver matched." + ), +) + + +def resolve_bgp_event_location( + *, + collector: str, + source_latitude: float | None = None, + source_longitude: float | None = None, + site: str | None = None, + operator: str | None = None, + peer_asn: int | None = None, + origin_asn: int | None = None, + prefix: str | None = None, +) -> ResolutionResult: + """Resolve a BGP event to its renderable coordinates. + + The ``peer_asn`` / ``origin_asn`` / ``prefix`` arguments are accepted + today so future resolvers (ASN→facility, prefix→geo) can consume them + without callers needing to change. + """ + query = LocationQuery( + name=collector or None, + aliases=tuple(filter(None, (collector,))), + source_latitude=source_latitude, + source_longitude=source_longitude, + extra={ + "collector": collector or "", + "site": coerce_str(site), + "operator": coerce_str(operator), + "peer_asn": peer_asn, + "origin_asn": origin_asn, + "prefix": coerce_str(prefix), + }, + ) + return BGP_EVENT_PIPELINE.resolve_best(query) + + +def resolve_bgp_event_geo_dict( + collector: str, + *, + source_latitude: float | None = None, + source_longitude: float | None = None, +) -> dict[str, Any]: + """Convenience wrapper returning the legacy ``collector_geo`` dict shape. + + Preserves ``city``/``country``/``latitude``/``longitude`` keys (consumed + by existing detectors / enrichment / DB serialization) and adds + ``precision``/``source``/``needs_confirmation`` for richer downstream use. + """ + result = resolve_bgp_event_location( + collector=collector, + source_latitude=source_latitude, + source_longitude=source_longitude, + ) + candidate = result.location + if candidate is None: + return {} + return { + "city": candidate.city, + "country": candidate.country, + "latitude": candidate.latitude, + "longitude": candidate.longitude, + "precision": candidate.precision, + "source": candidate.source, + "needs_confirmation": candidate.needs_confirmation, + "matched_location_name": candidate.matched_location_name, + "confidence": candidate.confidence, + } diff --git a/backend/app/services/collectors/bgp_common.py b/backend/app/services/collectors/bgp_common.py index ec54a73c..b4cbf413 100644 --- a/backend/app/services/collectors/bgp_common.py +++ b/backend/app/services/collectors/bgp_common.py @@ -13,6 +13,11 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.models.bgp_anomaly import BGPAnomaly from app.models.bgp_observation import BGPObservation from app.models.collected_data import CollectedData +from app.services.bgp_collector_locations import ( + RIPE_RIS_COLLECTOR_COORDS, + get_bgp_collector_location_dict, +) +from app.services.bgp_event_locations import resolve_bgp_event_geo_dict from app.services.bgp_incidents import create_bgp_incidents_for_anomalies from app.services.bgp_detectors import ( detect_mass_withdrawal_anomalies, @@ -23,32 +28,17 @@ from app.services.bgp_detectors import ( ) from app.services.bgp_enrichment import enrich_bgp_events_for_batch, extract_bgp_network_fields - -RIPE_RIS_COLLECTOR_COORDS: dict[str, dict[str, Any]] = { - "rrc00": {"city": "Amsterdam", "country": "Netherlands", "latitude": 52.3676, "longitude": 4.9041}, - "rrc01": {"city": "London", "country": "United Kingdom", "latitude": 51.5072, "longitude": -0.1276}, - "rrc03": {"city": "Amsterdam", "country": "Netherlands", "latitude": 52.3676, "longitude": 4.9041}, - "rrc04": {"city": "Geneva", "country": "Switzerland", "latitude": 46.2044, "longitude": 6.1432}, - "rrc05": {"city": "Vienna", "country": "Austria", "latitude": 48.2082, "longitude": 16.3738}, - "rrc06": {"city": "Otemachi", "country": "Japan", "latitude": 35.686, "longitude": 139.7671}, - "rrc07": {"city": "Stockholm", "country": "Sweden", "latitude": 59.3293, "longitude": 18.0686}, - "rrc10": {"city": "Milan", "country": "Italy", "latitude": 45.4642, "longitude": 9.19}, - "rrc11": {"city": "New York", "country": "United States", "latitude": 40.7128, "longitude": -74.006}, - "rrc12": {"city": "Frankfurt", "country": "Germany", "latitude": 50.1109, "longitude": 8.6821}, - "rrc13": {"city": "Moscow", "country": "Russia", "latitude": 55.7558, "longitude": 37.6173}, - "rrc14": {"city": "Palo Alto", "country": "United States", "latitude": 37.4419, "longitude": -122.143}, - "rrc15": {"city": "Sao Paulo", "country": "Brazil", "latitude": -23.5558, "longitude": -46.6396}, - "rrc16": {"city": "Miami", "country": "United States", "latitude": 25.7617, "longitude": -80.1918}, - "rrc18": {"city": "Barcelona", "country": "Spain", "latitude": 41.3874, "longitude": 2.1686}, - "rrc19": {"city": "Johannesburg", "country": "South Africa", "latitude": -26.2041, "longitude": 28.0473}, - "rrc20": {"city": "Zurich", "country": "Switzerland", "latitude": 47.3769, "longitude": 8.5417}, - "rrc21": {"city": "Paris", "country": "France", "latitude": 48.8566, "longitude": 2.3522}, - "rrc22": {"city": "Bucharest", "country": "Romania", "latitude": 44.4268, "longitude": 26.1025}, - "rrc23": {"city": "Singapore", "country": "Singapore", "latitude": 1.3521, "longitude": 103.8198}, - "rrc24": {"city": "Montevideo", "country": "Uruguay", "latitude": -34.9011, "longitude": -56.1645}, - "rrc25": {"city": "Amsterdam", "country": "Netherlands", "latitude": 52.3676, "longitude": 4.9041}, - "rrc26": {"city": "Dubai", "country": "United Arab Emirates", "latitude": 25.2048, "longitude": 55.2708}, -} +# Re-exported for backward compatibility with anything that imports +# ``RIPE_RIS_COLLECTOR_COORDS`` from this module. New code should call +# ``app.services.bgp_collector_locations.get_bgp_collector_location_dict()`` +# or ``resolve_bgp_collector_location()`` instead — those use the DB-backed +# collector-location cache. +__all__ = [ + "RIPE_RIS_COLLECTOR_COORDS", + "normalize_bgp_event", + "save_bgp_observations_for_batch", + "create_bgp_anomalies_for_batch", +] def _safe_int(value: Any) -> int | None: @@ -131,7 +121,19 @@ def normalize_bgp_event(payload: dict[str, Any], *, project: str) -> dict[str, A ) source_id = hashlib.sha1(source_material.encode("utf-8")).hexdigest()[:24] - collector_location = RIPE_RIS_COLLECTOR_COORDS.get(collector, {}) + # Routes through the BGP event pipeline: source coords (if any) → + # collector inheritance. Returned dict keeps the legacy + # {city, country, latitude, longitude} keys plus richer + # {precision, source, needs_confirmation, matched_location_name, confidence}. + collector_location = resolve_bgp_event_geo_dict( + collector, + source_latitude=payload.get("latitude"), + source_longitude=payload.get("longitude"), + ) + # Empty result (unknown collector & no source coords) — keep the + # downstream-expected dict shape so detectors / serializers don't crash. + if not collector_location: + collector_location = get_bgp_collector_location_dict(collector) network_fields = extract_bgp_network_fields(prefix) metadata = { "project": project, diff --git a/backend/app/services/compute_center_locations.py b/backend/app/services/compute_center_locations.py new file mode 100644 index 00000000..5bf47dbf --- /dev/null +++ b/backend/app/services/compute_center_locations.py @@ -0,0 +1,863 @@ +"""Compute-center location resolver, built on the shared location pipeline. + +This module is a thin domain wrapper that wires up +:mod:`app.services.location` for compute centers: + + SourceCoordinates + +The online Nominatim step is intentionally reserved for the user-triggered +``collect-location`` flow. The regular GeoJSON endpoint runs during Earth +startup, so it must stay local and deterministic. + +For the full design and the reason behind the abstraction (compute centers, +BGP collectors, BGP events, and future entities all share one pipeline), +see ``docs/plans/location-resolver-shared-pipeline-plan.md``. + +The ``ComputeCenterLocation`` dataclass and the public function signatures are +preserved verbatim so existing callers and tests do not need to change. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +from functools import lru_cache +from typing import Any + +import httpx +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.collected_data_fields import get_record_field +from app.models.collected_data import CollectedData +from app.models.compute_center_location import ComputeCenterLocationRecord + +from app.services.location import ( + LocationCandidate, + LocationPipeline, + LocationQuery, + NominatimResolver, + ResolverOutput, + SourceCoordinatesResolver, + build_default_nominatim_geocoder, + coerce_str, + normalize_country_text, + normalize_text, + parse_float, +) + +ROR_SEARCH_URL = "https://api.ror.org/v2/organizations" +DEFAULT_ROR_USER_AGENT = "planet-earth-location-resolver/1.0" +DEFAULT_ROR_TIMEOUT_SECONDS = 8.0 +RENDERABLE_PRECISIONS: tuple[str, ...] = ("precise", "site", "city") +FORBIDDEN_PRECISIONS: tuple[str, ...] = ( + "country", + "estimated_country", + "country_major_compute_city", + "region", + "unknown", +) + +# ── Public dataclasses ────────────────────────────────────────────── + + +@dataclass(frozen=True) +class ComputeCenterLocation: + latitude: float | None + longitude: float | None + location_precision: str + geography_mode: str + is_estimated: bool + estimated_reason: str | None = None + location_confidence: float | None = None + location_source: str | None = None + location_source_note: str | None = None + location_verified_at: str | None = None + matched_location_name: str | None = None + needs_confirmation: bool = False + city: str | None = None + region: str | None = None + country: str | None = None + + @property + def is_renderable(self) -> bool: + if self.latitude in (None, 0.0) or self.longitude in (None, 0.0): + return False + return self.location_precision in RENDERABLE_PRECISIONS + + def to_geojson_properties(self) -> dict[str, Any]: + return { + "latitude": self.latitude, + "longitude": self.longitude, + "location_precision": self.location_precision, + "geography_mode": self.geography_mode, + "is_estimated": self.is_estimated, + "estimated_reason": self.estimated_reason, + "location_confidence": self.location_confidence, + "location_source": self.location_source, + "location_source_note": self.location_source_note, + "location_verified_at": self.location_verified_at, + "matched_location_name": self.matched_location_name, + "needs_confirmation": self.needs_confirmation, + } + + +@dataclass(frozen=True) +class ResolutionDiagnostic: + failure_reason: str + attempted_queries: tuple[str, ...] = () + record_id: int | None = None + source: str | None = None + source_id: str | None = None + name: str | None = None + country: str | None = None + city: str | None = None + site: str | None = None + operator: str | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "failure_reason": self.failure_reason, + "attempted_queries": list(self.attempted_queries), + "record_id": self.record_id, + "source": self.source, + "source_id": self.source_id, + "name": self.name, + "country": self.country, + "city": self.city, + "site": self.site, + "operator": self.operator, + } + + +@dataclass(frozen=True) +class ResolutionResult: + location: ComputeCenterLocation | None + diagnostic: ResolutionDiagnostic | None + + @property + def is_resolved(self) -> bool: + return bool(self.location and self.location.is_renderable) + + +# ── Geocoder (kept at module level so tests can monkeypatch + cache_clear) ── + +_geocode_online = build_default_nominatim_geocoder() + + +# ── Stored location cache ─────────────────────────────────────────── + + +COMPUTE_CENTER_LOCATION_CACHE: dict[str, dict[str, Any]] = {} + + +def _cache_key(source: str | None, source_id: str | None) -> str: + return f"{coerce_str(source)}:{coerce_str(source_id)}" + + +def set_compute_center_location_cache( + locations: dict[str, dict[str, Any]], +) -> None: + COMPUTE_CENTER_LOCATION_CACHE.clear() + COMPUTE_CENTER_LOCATION_CACHE.update( + {coerce_str(key): dict(value) for key, value in locations.items()} + ) + + +async def refresh_compute_center_location_cache( + session: AsyncSession, +) -> dict[str, dict[str, Any]]: + result = await session.execute(select(ComputeCenterLocationRecord)) + records = result.scalars().all() + cache = {} + for record in records: + if not hasattr(record, "to_location_dict"): + continue + if not record.source or not record.source_id: + continue + cache[_cache_key(record.source, record.source_id)] = record.to_location_dict() + set_compute_center_location_cache(cache) + return cache + + +def get_compute_center_location_dict( + source: str | None, + source_id: str | None, +) -> dict[str, Any]: + return dict(COMPUTE_CENTER_LOCATION_CACHE.get(_cache_key(source, source_id), {})) + + +# ── Pipeline construction ────────────────────────────────────────── + + +@lru_cache(maxsize=512) +def _lookup_ror_organization(query: str) -> dict[str, Any] | None: + """Lookup a research organization in ROR for user-triggered candidates.""" + if not query: + return None + response = httpx.get( + ROR_SEARCH_URL, + params={"query": query}, + headers={"User-Agent": DEFAULT_ROR_USER_AGENT}, + timeout=DEFAULT_ROR_TIMEOUT_SECONDS, + ) + response.raise_for_status() + payload = response.json() + items = payload.get("items") if isinstance(payload, dict) else None + if not isinstance(items, list) or not items: + return None + first = items[0] + if not isinstance(first, dict): + return None + organization = first.get("organization") + if isinstance(organization, dict): + return organization + return first + + +def _compute_center_ror_query_plan( + query: LocationQuery, +) -> list[tuple[str, tuple[str, ...]]]: + extra = query.extra or {} + raw_parts: list[tuple[str, str]] = [ + ("site", coerce_str(extra.get("site"))), + ("operator", coerce_str(extra.get("operator"))), + ("organization", coerce_str(extra.get("organization"))), + ] + for field, value in tuple(raw_parts): + if "/" not in value: + continue + raw_parts.extend( + (field, part.strip()) + for part in value.split("/") + if len(part.strip()) >= 3 + ) + + plan: list[tuple[str, tuple[str, ...]]] = [] + seen: set[str] = set() + for field, value in raw_parts: + key = normalize_text(value) + if not key or key in seen: + continue + seen.add(key) + plan.append((value, (field,))) + return plan + + +def _organization_label(organization: dict[str, Any], fallback: str) -> str: + names = organization.get("names") + if isinstance(names, list): + for name in names: + if not isinstance(name, dict): + continue + types = name.get("types") + if isinstance(types, list) and "ror_display" in types: + value = coerce_str(name.get("value")) + if value: + return value + for name in names: + if isinstance(name, dict): + value = coerce_str(name.get("value")) + if value: + return value + return fallback + + +class ROROrganizationResolver: + """Resolve source-provided organization/site text through the open ROR API.""" + + name = "ror_organization_registry" + + def __init__( + self, + *, + query_plan_builder=_compute_center_ror_query_plan, + lookup=lambda q: _lookup_ror_organization(q), + confidence: float = 0.68, + ) -> None: + self._query_plan_builder = query_plan_builder + self._lookup = lookup + self._confidence = confidence + + def resolve(self, query: LocationQuery): + from app.services.location import ResolverOutput + from app.services.location.text import parse_float + + attempted: list[str] = [] + candidates: list[LocationCandidate] = [] + context_country = normalize_text(normalize_country_text(query.country)) + + for ror_query, matched_fields in self._query_plan_builder(query): + attempted.append(f"ror:{ror_query}") + try: + organization = self._lookup(ror_query) + except Exception: + continue + if not isinstance(organization, dict): + continue + locations = organization.get("locations") + if not isinstance(locations, list) or not locations: + continue + location = locations[0] + if not isinstance(location, dict): + continue + details = location.get("geonames_details") + if not isinstance(details, dict): + continue + latitude = parse_float(details.get("lat")) + longitude = parse_float(details.get("lng")) + if latitude in (None, 0.0) or longitude in (None, 0.0): + continue + + country = normalize_country_text(details.get("country_name")) + if context_country and normalize_text(country) != context_country: + continue + + city = coerce_str(details.get("name")) or None + region = coerce_str(details.get("country_subdivision_name")) or None + display_name = _organization_label(organization, ror_query) + ror_id = coerce_str(organization.get("id")) + geonames_id = location.get("geonames_id") + source_note = ( + f"ROR organization match: {display_name}" + + (f" ({ror_id})" if ror_id else "") + + (f"; GeoNames {geonames_id}" if geonames_id else "") + ) + candidates.append( + LocationCandidate( + latitude=latitude, + longitude=longitude, + display_name=display_name, + precision="city", + confidence=self._confidence, + query=ror_query, + source=self.name, + source_note=source_note, + matched_fields=matched_fields, + needs_confirmation=True, + city=city, + region=region, + country=country or query.country, + matched_location_name=display_name, + location_verified_at=None, + suggested_registry_entry=None, + ) + ) + + return ResolverOutput( + candidates=tuple(candidates), + attempted_queries=tuple(attempted), + ) + + +class StoredComputeCenterLocationResolver: + """Resolve a compute center through the DB-backed current-location cache.""" + + name = "stored_compute_center_location" + + def resolve(self, query: LocationQuery) -> ResolverOutput: + extra = query.extra or {} + stored = get_compute_center_location_dict( + coerce_str(extra.get("source")), + coerce_str(extra.get("source_id")), + ) + if not stored: + return ResolverOutput() + latitude = parse_float(stored.get("latitude")) + longitude = parse_float(stored.get("longitude")) + if latitude in (None, 0.0) or longitude in (None, 0.0): + return ResolverOutput() + return ResolverOutput( + candidates=( + LocationCandidate( + latitude=latitude, + longitude=longitude, + display_name=stored.get("name") or query.name or "Compute center", + precision=stored.get("precision") or "city", + confidence=float(stored.get("confidence") or 0.85), + query=f"stored_compute_center_location::{stored.get('source')}:{stored.get('source_id')}", + source=self.name, + source_note=stored.get("source_note"), + matched_fields=("source", "source_id"), + needs_confirmation=bool(stored.get("needs_confirmation")), + city=stored.get("city") or query.city, + region=None, + country=stored.get("country") or query.country, + matched_location_name=stored.get("site") or stored.get("name") or query.name, + location_verified_at=stored.get("verified_at"), + suggested_registry_entry=None, + ), + ) + ) + + +def _short_system_name(name: Any) -> str: + """Strip vendor/system suffix from TOP500 names like ``"El Capitan - HPE Cray ..."``.""" + text = coerce_str(name) + if not text: + return "" + head = text.split(" - ", 1)[0].strip() + return head or text + + +def _record_context(record: Any, metadata: dict[str, Any]) -> dict[str, str]: + name = coerce_str(getattr(record, "name", None)) + return { + "source": coerce_str(getattr(record, "source", None)), + "source_id": coerce_str(getattr(record, "source_id", None)), + "name": name, + "name_short": _short_system_name(name), + "city": coerce_str(get_record_field(record, "city")), + "country": coerce_str(get_record_field(record, "country")), + "site": coerce_str(metadata.get("site") or metadata.get("organization")), + "operator": coerce_str( + metadata.get("operator") + or metadata.get("organization") + or metadata.get("owner") + or metadata.get("manufacturer") + ), + "organization": coerce_str(metadata.get("organization")), + } + + +def _context_to_query( + context: dict[str, str], + *, + source_lat: float | None = None, + source_lon: float | None = None, +) -> LocationQuery: + name = context.get("name") or None + name_short = context.get("name_short") or "" + aliases: tuple[str, ...] = () + if name_short and name_short != name: + aliases = (name_short,) + return LocationQuery( + name=name, + aliases=aliases, + city=context.get("city") or None, + country=context.get("country") or None, + source_latitude=source_lat, + source_longitude=source_lon, + extra={ + "source": context.get("source") or "", + "source_id": context.get("source_id") or "", + "site": context.get("site") or "", + "operator": context.get("operator") or "", + "organization": context.get("organization") or "", + }, + ) + + +def _compute_center_query_plan( + query: LocationQuery, +) -> list[tuple[str, tuple[str, ...]]]: + """Build the Nominatim query plan for a compute-center query. + + Mirrors the legacy ``_build_online_query_plan`` ordering exactly. + """ + name = query.name or "" + name_short = (query.aliases[0] if query.aliases else "") or name + extra = query.extra or {} + site = str(extra.get("site") or "") + operator = str(extra.get("operator") or "") + city = query.city or "" + country = query.country or "" + + plan: list[tuple[str, tuple[str, ...]]] = [] + + def add(parts: list[tuple[str, str]]) -> None: + non_empty = [(field, value) for field, value in parts if value] + if not non_empty: + return + seen: set[str] = set() + cleaned: list[str] = [] + fields: list[str] = [] + for field, value in non_empty: + key = normalize_text(value) + if not key or key in seen: + continue + seen.add(key) + cleaned.append(value) + fields.append(field) + if not cleaned: + return + composed = ", ".join(cleaned) + if not any(composed == existing for existing, _ in plan): + plan.append((composed, tuple(fields))) + + add([("site", site), ("country", country)]) + add([("operator", operator), ("city", city), ("country", country)]) + add([("name", name_short), ("operator", operator), ("country", country)]) + add([("name", name_short), ("site", site)]) + add([("name", name_short), ("country", country)]) + add([("name", name_short), ("city", city), ("country", country)]) + add([("city", city), ("country", country)]) + if name and name != name_short: + add([("name", name), ("country", country)]) + return plan + + +COMPUTE_CENTER_PIPELINE = LocationPipeline( + [ + SourceCoordinatesResolver(), + StoredComputeCenterLocationResolver(), + ], + failure_reason=( + "Could not resolve to city-level coordinates from source coords" + " or stored compute-center location." + ), +) + +COMPUTE_CENTER_COLLECTION_PIPELINE = LocationPipeline( + [ + SourceCoordinatesResolver(), + ROROrganizationResolver(), + NominatimResolver( + query_plan_builder=_compute_center_query_plan, + # Late-binding so test monkeypatching of ``_geocode_online`` works. + geocoder=lambda q: _geocode_online(q), + ), + ], + failure_reason=( + "Could not resolve to city-level coordinates from source coords" + ", ROR organization lookup, or online geocoding." + ), +) + + +# ── Candidate → ComputeCenterLocation conversion ─────────────────── + + +_GEOGRAPHY_MODE_BY_SOURCE = { + "source_coordinates": "source_coordinates", + "stored_compute_center_location": "stored_compute_center_location", + "ror_organization_registry": "ror_organization", + "nominatim_online_geocode": "online_geocode", +} + + +def _candidate_to_location( + candidate: LocationCandidate, + *, + context: dict[str, str], +) -> ComputeCenterLocation: + geography_mode = _GEOGRAPHY_MODE_BY_SOURCE.get(candidate.source, "online_geocode") + is_estimated = candidate.needs_confirmation or candidate.source.startswith( + "nominatim" + ) + estimated_reason: str | None + if candidate.source == "source_coordinates": + estimated_reason = None + elif candidate.source == "stored_compute_center_location": + estimated_reason = candidate.source_note + elif candidate.source == "ror_organization_registry": + fields_summary = ", ".join(candidate.matched_fields) or "organization" + estimated_reason = ( + f"Resolved by ROR organization lookup '{candidate.query}' " + f"(matched fields: {fields_summary})" + ) + elif candidate.source == "nominatim_online_geocode": + fields_summary = ", ".join(candidate.matched_fields) or "name" + estimated_reason = ( + f"Resolved by online geocoding query '{candidate.query}' " + f"(matched fields: {fields_summary})" + ) + else: + estimated_reason = candidate.source_note + + country = ( + candidate.country + or normalize_country_text(context.get("country")) + or context.get("country") + or None + ) + return ComputeCenterLocation( + latitude=candidate.latitude, + longitude=candidate.longitude, + location_precision=candidate.precision, + geography_mode=geography_mode, + is_estimated=is_estimated, + estimated_reason=estimated_reason, + location_confidence=candidate.confidence, + location_source=candidate.source, + location_source_note=candidate.source_note, + location_verified_at=candidate.location_verified_at, + matched_location_name=candidate.matched_location_name + or context.get("name") + or None, + needs_confirmation=candidate.needs_confirmation, + city=candidate.city or context.get("city") or None, + region=candidate.region, + country=country, + ) + + +def _diagnostic_for( + record: Any, + context: dict[str, str], + *, + failure_reason: str, + attempted_queries: tuple[str, ...] = (), +) -> ResolutionDiagnostic: + return ResolutionDiagnostic( + failure_reason=failure_reason, + attempted_queries=attempted_queries, + record_id=getattr(record, "id", None), + source=getattr(record, "source", None), + source_id=getattr(record, "source_id", None), + name=context.get("name") or getattr(record, "name", None), + country=context.get("country") or None, + city=context.get("city") or None, + site=context.get("site") or None, + operator=context.get("operator") or None, + ) + + +# ── Public API ───────────────────────────────────────────────────── + + +def resolve_compute_center_location( + record: Any, + metadata: dict[str, Any] | None = None, +) -> ComputeCenterLocation: + """Backwards-compatible thin wrapper returning the renderable location only. + + Records that cannot be resolved to city-level get a placeholder + :class:`ComputeCenterLocation` with ``location_precision='unknown'``. + Callers should generally prefer :func:`resolve_compute_center_location_full`. + """ + full = resolve_compute_center_location_full(record, metadata) + return full.location or ComputeCenterLocation( + latitude=None, + longitude=None, + location_precision="unknown", + geography_mode="unresolved", + is_estimated=True, + estimated_reason="No resolvable location hints", + location_confidence=0.0, + location_source="unknown", + location_source_note=( + "No source coordinates, ROR organization match, or online" + " geocoding result." + ), + matched_location_name=None, + needs_confirmation=False, + ) + + +def resolve_compute_center_location_full( + record: Any, + metadata: dict[str, Any] | None = None, + *, + allow_online: bool = False, +) -> ResolutionResult: + metadata = metadata or {} + context = _record_context(record, metadata) + + from app.services.location.text import parse_float as _parse_float + + source_lat = _parse_float(get_record_field(record, "latitude")) + source_lon = _parse_float(get_record_field(record, "longitude")) + if source_lat in (None, 0.0): + source_lat = None + if source_lon in (None, 0.0): + source_lon = None + + query = _context_to_query( + context, source_lat=source_lat, source_lon=source_lon + ) + pipeline = ( + COMPUTE_CENTER_COLLECTION_PIPELINE + if allow_online + else COMPUTE_CENTER_PIPELINE + ) + pipeline_result = pipeline.resolve_best(query) + + if pipeline_result.location and pipeline_result.location.precision in RENDERABLE_PRECISIONS: + location = _candidate_to_location(pipeline_result.location, context=context) + return ResolutionResult(location=location, diagnostic=None) + + return ResolutionResult( + location=None, + diagnostic=_diagnostic_for( + record, + context, + failure_reason=( + "Could not resolve to city-level coordinates from source coords" + ", ROR organization lookup, or online geocoding." + if allow_online + else ( + "Could not resolve to city-level coordinates from source coords" + " or stored compute-center location." + ) + ), + attempted_queries=pipeline_result.attempted_queries, + ), + ) + + +def collect_location_candidates( + *, + name: str | None = None, + source: str | None = None, + source_id: str | None = None, + operator: str | None = None, + site: str | None = None, + city: str | None = None, + country: str | None = None, + organization: str | None = None, + record_id: int | None = None, +) -> tuple[list[LocationCandidate], list[str]]: + """Run the full resolution chain and return ranked candidates with attempted queries. + + The unused ``source`` / ``source_id`` / ``record_id`` arguments are kept + for backward compatibility with the API handler that calls this function. + """ + name_value = coerce_str(name) + context: dict[str, str] = { + "source": coerce_str(source), + "source_id": coerce_str(source_id), + "name": name_value, + "name_short": _short_system_name(name_value), + "city": coerce_str(city), + "country": coerce_str(country), + "site": coerce_str(site or organization), + "operator": coerce_str(operator or organization), + "organization": coerce_str(organization), + } + query = _context_to_query(context) + return COMPUTE_CENTER_COLLECTION_PIPELINE.collect_candidates(query) + + +def _record_operator(metadata: dict[str, Any]) -> str | None: + return coerce_str( + metadata.get("operator") + or metadata.get("organization") + or metadata.get("owner") + or metadata.get("manufacturer") + ) or None + + +async def seed_compute_center_locations_from_source_coords( + session: AsyncSession, +) -> None: + """Seed stored compute-center locations only from real source coordinates.""" + stmt = ( + select(CollectedData) + .where(CollectedData.source.in_(["top500", "epoch_ai_gpu"])) + .where(CollectedData.is_current.is_(True)) + ) + result = await session.execute(stmt) + records = result.scalars().all() + changed = False + + for record in records: + source_value = coerce_str(getattr(record, "source", None)) + source_id = coerce_str(getattr(record, "source_id", None)) + if not source_value or not source_id: + continue + latitude = parse_float(get_record_field(record, "latitude")) + longitude = parse_float(get_record_field(record, "longitude")) + if latitude in (None, 0.0) or longitude in (None, 0.0): + continue + existing = await session.scalar( + select(ComputeCenterLocationRecord) + .where(ComputeCenterLocationRecord.source == source_value) + .where(ComputeCenterLocationRecord.source_id == source_id) + ) + if existing: + continue + metadata = record.extra_data or {} + session.add( + ComputeCenterLocationRecord( + source=source_value, + source_id=source_id, + name=getattr(record, "name", None), + operator=_record_operator(metadata), + site=coerce_str(metadata.get("site") or metadata.get("organization")) or None, + city=coerce_str(get_record_field(record, "city")) or None, + country=coerce_str(get_record_field(record, "country")) or None, + latitude=latitude, + longitude=longitude, + precision="precise", + confidence=1.0, + location_source="source_coordinates", + source_note="Seeded from source-provided compute-center coordinates", + raw_payload={ + "record_id": getattr(record, "id", None), + "source": source_value, + "source_id": source_id, + }, + needs_confirmation=False, + verification_status="source_provided", + verified_at=None, + ) + ) + changed = True + + if changed: + await session.commit() + await refresh_compute_center_location_cache(session) + + +async def upsert_compute_center_location( + session: AsyncSession, + *, + source: str, + source_id: str, + name: str | None = None, + operator: str | None = None, + site: str | None = None, + city: str | None = None, + country: str | None = None, + latitude: float, + longitude: float, + precision: str = "city", + confidence: float | None = None, + location_source: str = "manual_selection", + source_url: str | None = None, + source_note: str | None = None, + raw_payload: dict[str, Any] | None = None, + needs_confirmation: bool = False, + verification_status: str = "verified", +) -> ComputeCenterLocationRecord: + existing = await session.scalar( + select(ComputeCenterLocationRecord) + .where(ComputeCenterLocationRecord.source == source) + .where(ComputeCenterLocationRecord.source_id == source_id) + ) + verified_at = None if needs_confirmation else datetime.now(UTC) + values = { + "name": name, + "operator": operator, + "site": site, + "city": city, + "country": country, + "latitude": latitude, + "longitude": longitude, + "precision": precision, + "confidence": confidence, + "location_source": location_source, + "source_url": source_url, + "source_note": source_note, + "raw_payload": raw_payload or {}, + "needs_confirmation": needs_confirmation, + "verification_status": verification_status, + "verified_at": verified_at, + } + if existing: + for key, value in values.items(): + setattr(existing, key, value) + record = existing + else: + record = ComputeCenterLocationRecord( + source=source, + source_id=source_id, + **values, + ) + session.add(record) + + await session.commit() + await session.refresh(record) + await refresh_compute_center_location_cache(session) + return record diff --git a/backend/app/services/docs_gatekeeper.py b/backend/app/services/docs_gatekeeper.py new file mode 100644 index 00000000..9cd90043 --- /dev/null +++ b/backend/app/services/docs_gatekeeper.py @@ -0,0 +1,119 @@ +"""Server-side Docs metadata and Gatekeeper authorization helpers.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + +from app.models.user import User + +DocsAccess = Literal["public", "docs_user", "docs_developer", "docs_admin"] +DocsLang = Literal["zh", "en"] + +VALID_DOCS_LANGS = {"zh", "en"} +DOCS_README_FILENAME = "README.md" +DEFAULT_DOCS_SLUG = "overview" + +REPO_ROOT = Path(__file__).resolve().parents[3] +TECHNICAL_DOCS_ROOT = REPO_ROOT / "docs" / "technical" + + +@dataclass(frozen=True) +class DocsMetadata: + filename: str + slug: str + access: DocsAccess + group: str + order: int + zh_title: str + en_title: str + + +DOCS_METADATA: tuple[DocsMetadata, ...] = ( + DocsMetadata(DOCS_README_FILENAME, DEFAULT_DOCS_SLUG, "public", "Overview", 0, "技术文档", "Technical Docs"), + DocsMetadata("quickstart.md", "quickstart", "public", "Manual", 1, "快速开始", "Quickstart"), + DocsMetadata("manual.md", "manual", "public", "Manual", 2, "Planet 使用手册", "Planet Manual"), + DocsMetadata("location-pipeline-user.md", "location-pipeline-user", "public", "Manual", 3, "Earth 位置候选采集使用手册", "Earth Location Candidate Collection User Guide"), + DocsMetadata("earth-frontend-context.md", "earth-frontend-context", "docs_developer", "Earth", 10, "Earth 前端结构", "Earth Frontend Context"), + DocsMetadata("earth-layer-style-reference.md", "earth-layer-style-reference", "docs_developer", "Earth", 11, "Earth 图层样式属性索引", "Earth Layer Style Reference"), + DocsMetadata("earth-render-layer-order.md", "earth-render-layer-order", "docs_developer", "Earth", 12, "Earth 渲染图层顺序", "Earth Render Layer Order"), + DocsMetadata("earth-satellite-footprint-policy.md", "earth-satellite-footprint-policy", "docs_developer", "Earth", 13, "Earth 卫星覆盖策略", "Earth Satellite Footprint Policy"), + DocsMetadata("earth-bgp-context.md", "earth-bgp-context", "docs_developer", "Earth", 14, "BGP 态势上下文", "BGP Context"), + DocsMetadata("earth-news-live-streams-collector-format.md", "earth-news-live-streams-collector-format", "docs_developer", "Earth", 15, "新闻直播采集格式", "News Live Streams Collector Format"), + DocsMetadata("earth-interactable-usage.md", "earth-interactable-usage", "docs_developer", "Earth", 16, "Earth 可交互图标接入", "Earth Interactable Usage"), + DocsMetadata("earth-toolbar-overlay-coordination.md", "earth-toolbar-overlay-coordination", "docs_developer", "Earth", 17, "Earth 工具栏与浮层协同", "Earth Toolbar and Overlay Coordination"), + DocsMetadata("frontend-admin-frontend-context.md", "frontend-admin-frontend-context", "docs_developer", "Frontend", 20, "控制台前端结构", "Admin Frontend Context"), + DocsMetadata("frontend-layout-guidelines.md", "frontend-layout-guidelines", "docs_developer", "Frontend", 21, "前端布局指南", "Frontend Layout Guidelines"), + DocsMetadata("docs-gatekeeper-development.md", "docs-gatekeeper-development", "docs_developer", "Frontend", 22, "Docs Gatekeeper 开发说明", "Docs Gatekeeper Development Guide"), + DocsMetadata("backend-collectors.md", "backend-collectors", "docs_developer", "Backend", 30, "数据采集系统", "Data Collectors"), + DocsMetadata("backend-system-service-control.md", "backend-system-service-control", "docs_admin", "Backend", 31, "系统服务控制", "System Service Control"), + DocsMetadata("datasource-collector-settings-connectivity.md", "datasource-collector-settings-connectivity", "docs_developer", "Backend", 32, "数据源、采集器设置与连接验证", "Datasource Collector Settings and Connectivity"), + DocsMetadata("backend-datasources-api-performance.md", "backend-datasources-api-performance", "docs_developer", "Backend", 33, "数据源 API 性能", "Datasource API Performance"), + DocsMetadata("location-pipeline-development.md", "location-pipeline-development", "docs_developer", "Backend", 34, "通用位置估算管线开发说明", "Shared Location Resolution Pipeline Development Guide"), + DocsMetadata("agents-aiprovider.md", "agents-aiprovider", "docs_developer", "Agents", 40, "AI Provider 指南", "AI Provider Guide"), + DocsMetadata("ops-docker-compose-buildx-upgrade.md", "ops-docker-compose-buildx-upgrade", "docs_admin", "Ops", 50, "Docker + Compose + Buildx 升级", "Docker + Compose + Buildx Upgrade"), + DocsMetadata("ops-planet-sh-startup.md", "ops-planet-sh-startup", "docs_admin", "Ops", 51, "planet.sh 启动机制", "planet.sh Startup"), +) + +DOCS_BY_SLUG = {entry.slug: entry for entry in DOCS_METADATA} + + +def get_user_gatekeeper_groups(user: User | None) -> set[str]: + if user is None: + return set() + + role = user.role.value if hasattr(user.role, "value") else str(user.role or "") + if role == "super_admin": + return {"docs_user", "docs_developer", "docs_admin"} + if role == "admin": + return {"docs_user", "docs_developer", "docs_admin"} + + groups = set() + raw_groups = user.gatekeeper_groups or [] + if isinstance(raw_groups, list): + groups.update(str(group) for group in raw_groups) + + if "docs_admin" in groups: + groups.update({"docs_developer", "docs_user"}) + if "docs_developer" in groups: + groups.add("docs_user") + return groups + + +def can_read_doc(entry: DocsMetadata, user: User | None) -> bool: + if entry.access == "public": + return True + return entry.access in get_user_gatekeeper_groups(user) + + +def doc_path_for(entry: DocsMetadata, lang: str) -> Path: + if lang not in VALID_DOCS_LANGS: + raise ValueError("Unsupported docs language") + return TECHNICAL_DOCS_ROOT / lang / entry.filename + + +def title_for(entry: DocsMetadata, lang: str) -> str: + return entry.zh_title if lang == "zh" else entry.en_title + + +def catalog_for_user(user: User | None) -> list[dict]: + items: list[dict] = [] + for entry in DOCS_METADATA: + if not can_read_doc(entry, user): + continue + for lang in sorted(VALID_DOCS_LANGS): + if not doc_path_for(entry, lang).exists(): + continue + items.append( + { + "slug": entry.slug, + "filename": entry.filename, + "lang": lang, + "title": title_for(entry, lang), + "group": entry.group, + "order": entry.order, + "access": entry.access, + } + ) + return sorted(items, key=lambda item: (item["lang"], item["order"], item["title"])) diff --git a/backend/app/services/location/__init__.py b/backend/app/services/location/__init__.py new file mode 100644 index 00000000..82c0adc3 --- /dev/null +++ b/backend/app/services/location/__init__.py @@ -0,0 +1,57 @@ +"""Shared location-resolution pipeline. + +A reusable abstraction for "given a record, decide its lat/lon" — used by +compute centers, BGP collectors, BGP events, and any future entity that needs +location estimation. + +Each domain wires its own :class:`LocationPipeline` from a sequence of +:class:`LocationResolver` instances. Future algorithms (peeringdb, IXP tables, +user-confirmed coordinates, …) plug in by implementing the protocol — no +changes needed to consumers. +""" + +from .models import ( + LocationCandidate, + LocationQuery, + ResolutionDiagnostic, + ResolutionResult, + ResolverOutput, +) +from .pipeline import LocationPipeline, LocationResolver +from .resolvers.inherit import InheritFromAnotherEntityResolver +from .resolvers.nominatim import ( + NominatimResolver, + build_default_nominatim_geocoder, + interpret_geocode_result, +) +from .resolvers.registry import RegistryResolver, default_score_alias_match +from .resolvers.source_coordinates import SourceCoordinatesResolver +from .text import ( + city_key, + coerce_str, + normalize_country_text, + normalize_text, + parse_float, +) + +__all__ = [ + "LocationCandidate", + "LocationPipeline", + "LocationQuery", + "LocationResolver", + "ResolutionDiagnostic", + "ResolutionResult", + "ResolverOutput", + "InheritFromAnotherEntityResolver", + "NominatimResolver", + "RegistryResolver", + "SourceCoordinatesResolver", + "build_default_nominatim_geocoder", + "city_key", + "coerce_str", + "default_score_alias_match", + "interpret_geocode_result", + "normalize_country_text", + "normalize_text", + "parse_float", +] diff --git a/backend/app/services/location/models.py b/backend/app/services/location/models.py new file mode 100644 index 00000000..ab626009 --- /dev/null +++ b/backend/app/services/location/models.py @@ -0,0 +1,126 @@ +"""Domain-neutral data structures for the location pipeline.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Mapping + +# Renderable precision tiers, ordered from most precise to least. +RENDERABLE_PRECISIONS: tuple[str, ...] = ("precise", "site", "city") + + +@dataclass(frozen=True) +class LocationQuery: + """Domain-neutral input for the resolution pipeline. + + ``name`` and ``aliases`` are matched against registry alias indexes; + ``city`` / ``country`` / ``region`` provide geographic context for both + registry lookups and Nominatim queries; ``source_latitude`` / + ``source_longitude`` short-circuit when the record already carries + coordinates; ``extra`` carries domain-specific fields (operator, site, + organization, asn, peer_ip, …) that resolvers can opt into. + """ + + name: str | None = None + aliases: tuple[str, ...] = () + city: str | None = None + country: str | None = None + region: str | None = None + source_latitude: float | None = None + source_longitude: float | None = None + extra: Mapping[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class LocationCandidate: + """A resolved location candidate produced by a resolver.""" + + latitude: float + longitude: float + display_name: str + precision: str # "precise" | "site" | "city" | (rejected: country/unknown) + confidence: float + query: str + source: str + source_note: str | None + matched_fields: tuple[str, ...] + needs_confirmation: bool + city: str | None = None + region: str | None = None + country: str | None = None + matched_location_name: str | None = None + location_verified_at: str | None = None + suggested_registry_entry: dict[str, Any] | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "latitude": self.latitude, + "longitude": self.longitude, + "display_name": self.display_name, + "precision": self.precision, + "confidence": self.confidence, + "query": self.query, + "source": self.source, + "source_note": self.source_note, + "matched_fields": list(self.matched_fields), + "needs_confirmation": self.needs_confirmation, + "city": self.city, + "region": self.region, + "country": self.country, + "matched_location_name": self.matched_location_name, + "location_verified_at": self.location_verified_at, + "suggested_registry_entry": self.suggested_registry_entry, + } + + +@dataclass(frozen=True) +class ResolverOutput: + """What a single resolver returns from one ``resolve()`` call.""" + + candidates: tuple[LocationCandidate, ...] = () + attempted_queries: tuple[str, ...] = () + + +@dataclass(frozen=True) +class ResolutionDiagnostic: + """Why we could not resolve, plus what we tried.""" + + failure_reason: str + attempted_queries: tuple[str, ...] = () + record_id: int | None = None + source: str | None = None + source_id: str | None = None + name: str | None = None + country: str | None = None + city: str | None = None + site: str | None = None + operator: str | None = None + extra: Mapping[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return { + "failure_reason": self.failure_reason, + "attempted_queries": list(self.attempted_queries), + "record_id": self.record_id, + "source": self.source, + "source_id": self.source_id, + "name": self.name, + "country": self.country, + "city": self.city, + "site": self.site, + "operator": self.operator, + **({"extra": dict(self.extra)} if self.extra else {}), + } + + +@dataclass(frozen=True) +class ResolutionResult: + """Pipeline output: best candidate (if any) + diagnostic on miss.""" + + location: LocationCandidate | None + diagnostic: ResolutionDiagnostic | None + attempted_queries: tuple[str, ...] = () + + @property + def is_resolved(self) -> bool: + return bool(self.location) diff --git a/backend/app/services/location/pipeline.py b/backend/app/services/location/pipeline.py new file mode 100644 index 00000000..322c46d9 --- /dev/null +++ b/backend/app/services/location/pipeline.py @@ -0,0 +1,126 @@ +"""Pipeline that runs a sequence of :class:`LocationResolver` instances.""" + +from __future__ import annotations + +from typing import Protocol, Sequence + +from .models import ( + LocationCandidate, + LocationQuery, + ResolutionDiagnostic, + ResolutionResult, + ResolverOutput, +) + + +class LocationResolver(Protocol): + """Pluggable location resolution step. + + Implementations: ``SourceCoordinatesResolver``, ``RegistryResolver``, + ``NominatimResolver``, ``InheritFromAnotherEntityResolver`` — see the + ``resolvers`` subpackage. New algorithms (peeringdb / IXP / user-confirmed + coordinates) plug in by implementing this protocol; the pipeline does not + care how candidates are produced. + """ + + name: str + + def resolve(self, query: LocationQuery) -> ResolverOutput: ... + + +def default_candidate_sort_key( + candidate: LocationCandidate, +) -> tuple[int, int, float]: + precision_rank = {"precise": 0, "site": 1, "city": 2}.get( + candidate.precision, 9 + ) + source_rank = { + "source_coordinates": 0, + "stored_compute_center_location": 1, + "stored_collector_location": 1, + "ror_organization_registry": 2, + "inherited": 3, + "nominatim_online_geocode": 4, + "local_registry": 8, + "local_registry_city": 9, + }.get(candidate.source, 9) + return (source_rank, precision_rank, -float(candidate.confidence or 0)) + + +class LocationPipeline: + """Orchestrate a sequence of resolvers. + + ``collect_candidates`` runs every resolver and returns *all* deduped + candidates plus the queries each resolver attempted (useful for + user-facing "why didn't this work?" diagnostics). + + ``resolve_best`` returns the top candidate per + :func:`default_candidate_sort_key` (or a custom sort). + """ + + def __init__( + self, + resolvers: Sequence[LocationResolver], + *, + sort_key=default_candidate_sort_key, + failure_reason: str = ( + "Could not resolve to renderable coordinates from any configured resolver." + ), + ) -> None: + self._resolvers = list(resolvers) + self._sort_key = sort_key + self._failure_reason = failure_reason + + @property + def resolvers(self) -> tuple[LocationResolver, ...]: + return tuple(self._resolvers) + + def collect_candidates( + self, query: LocationQuery + ) -> tuple[list[LocationCandidate], list[str]]: + candidates: list[LocationCandidate] = [] + attempted: list[str] = [] + seen_keys: set[tuple[str, str, str]] = set() + + for resolver in self._resolvers: + output = resolver.resolve(query) + for q in output.attempted_queries: + if q and q not in attempted: + attempted.append(q) + for candidate in output.candidates: + key = ( + candidate.source, + f"{candidate.latitude:.4f}", + f"{candidate.longitude:.4f}", + ) + if key in seen_keys: + continue + seen_keys.add(key) + candidates.append(candidate) + + candidates.sort(key=self._sort_key) + return candidates, attempted + + def resolve_best(self, query: LocationQuery) -> ResolutionResult: + candidates, attempted = self.collect_candidates(query) + if candidates: + return ResolutionResult( + location=candidates[0], + diagnostic=None, + attempted_queries=tuple(attempted), + ) + return ResolutionResult( + location=None, + diagnostic=ResolutionDiagnostic( + failure_reason=self._failure_reason, + attempted_queries=tuple(attempted), + name=query.name, + country=query.country, + city=query.city, + site=str(query.extra.get("site")) if query.extra.get("site") else None, + operator=str(query.extra.get("operator")) + if query.extra.get("operator") + else None, + ), + attempted_queries=tuple(attempted), + ) diff --git a/backend/app/services/location/resolvers/__init__.py b/backend/app/services/location/resolvers/__init__.py new file mode 100644 index 00000000..aaf5da41 --- /dev/null +++ b/backend/app/services/location/resolvers/__init__.py @@ -0,0 +1,20 @@ +"""Built-in resolver implementations.""" + +from .inherit import InheritFromAnotherEntityResolver +from .nominatim import ( + NominatimResolver, + build_default_nominatim_geocoder, + interpret_geocode_result, +) +from .registry import RegistryResolver, default_score_alias_match +from .source_coordinates import SourceCoordinatesResolver + +__all__ = [ + "InheritFromAnotherEntityResolver", + "NominatimResolver", + "RegistryResolver", + "SourceCoordinatesResolver", + "build_default_nominatim_geocoder", + "default_score_alias_match", + "interpret_geocode_result", +] diff --git a/backend/app/services/location/resolvers/inherit.py b/backend/app/services/location/resolvers/inherit.py new file mode 100644 index 00000000..46d39d90 --- /dev/null +++ b/backend/app/services/location/resolvers/inherit.py @@ -0,0 +1,31 @@ +"""Resolver that inherits a candidate from another entity's resolution. + +Used by BGP events to pick up the location of their owning collector. The +``source_lookup`` callable is the only domain coupling — it receives the +incoming :class:`LocationQuery` and returns either an already-resolved +:class:`LocationCandidate` (typically by querying another pipeline) or +``None`` to signal "no parent location available". +""" + +from __future__ import annotations + +from typing import Callable + +from ..models import LocationCandidate, LocationQuery, ResolverOutput + + +class InheritFromAnotherEntityResolver: + def __init__( + self, + *, + source_lookup: Callable[[LocationQuery], LocationCandidate | None], + name: str = "inherited", + ) -> None: + self.name = name + self._lookup = source_lookup + + def resolve(self, query: LocationQuery) -> ResolverOutput: + result = self._lookup(query) + if result is None: + return ResolverOutput() + return ResolverOutput(candidates=(result,)) diff --git a/backend/app/services/location/resolvers/nominatim.py b/backend/app/services/location/resolvers/nominatim.py new file mode 100644 index 00000000..59030961 --- /dev/null +++ b/backend/app/services/location/resolvers/nominatim.py @@ -0,0 +1,292 @@ +"""Nominatim-backed online geocoder. + +The actual HTTP call is encapsulated in :func:`build_default_nominatim_geocoder` +which returns an ``lru_cache``-wrapped function. Domain modules typically: + +1. Build a default geocoder via :func:`build_default_nominatim_geocoder`. +2. Re-export it under a stable module-level name (e.g. ``_geocode_online``). +3. Pass a *late-binding lambda* (``lambda q: _geocode_online(q)``) to + :class:`NominatimResolver`. + +This ensures tests that ``monkeypatch.setattr(module, "_geocode_online", ...)`` +can swap the geocoder behavior without touching pipeline construction. +""" + +from __future__ import annotations + +import time +from functools import lru_cache +from typing import Any, Callable + +import httpx + +from ..models import LocationCandidate, LocationQuery, ResolverOutput +from ..text import ( + coerce_str, + normalize_country_text, + normalize_text, + parse_float, +) + +NOMINATIM_SEARCH_URL = "https://nominatim.openstreetmap.org/search" +DEFAULT_USER_AGENT = "planet-earth-location-resolver/1.0" +DEFAULT_MIN_INTERVAL_SECONDS = 1.1 +DEFAULT_TIMEOUT_SECONDS = 8.0 + + +def build_default_nominatim_geocoder( + *, + user_agent: str = DEFAULT_USER_AGENT, + min_interval_seconds: float = DEFAULT_MIN_INTERVAL_SECONDS, + timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, + cache_size: int = 512, +) -> Callable[[str], dict[str, Any] | None]: + """Return a cached, rate-limited Nominatim geocoder.""" + + last_request_at = [0.0] + + @lru_cache(maxsize=cache_size) + def geocode(query: str) -> dict[str, Any] | None: + if not query: + return None + elapsed = time.monotonic() - last_request_at[0] + if elapsed < min_interval_seconds: + time.sleep(min_interval_seconds - elapsed) + last_request_at[0] = time.monotonic() + response = httpx.get( + NOMINATIM_SEARCH_URL, + params={ + "q": query, + "format": "jsonv2", + "limit": 1, + "addressdetails": 1, + }, + headers={"User-Agent": user_agent}, + timeout=timeout_seconds, + ) + response.raise_for_status() + payload = response.json() + if not isinstance(payload, list) or not payload: + return None + result = payload[0] + if not isinstance(result, dict): + return None + return result + + return geocode + + +_DEFAULT_SITE_CATEGORIES = frozenset( + { + "amenity", + "office", + "building", + "industrial", + "research", + "university", + "education", + "tourism", + "shop", + "man_made", + "campus", + "research_institute", + } +) + + +def interpret_geocode_result( + result: dict[str, Any], + *, + matched_fields: tuple[str, ...], + context_country: str | None, + site_categories: frozenset[str] = _DEFAULT_SITE_CATEGORIES, + site_promoting_match_fields: frozenset[str] = frozenset( + {"site", "operator", "name"} + ), +) -> tuple[float, float, dict[str, Any], str] | None: + """Validate a Nominatim raw result. Returns (lat, lon, address, classification).""" + latitude = parse_float(result.get("lat")) + longitude = parse_float(result.get("lon")) + if latitude in (None, 0.0) or longitude in (None, 0.0): + return None + + address = result.get("address") if isinstance(result.get("address"), dict) else {} + if not isinstance(address, dict): + address = {} + + has_city_level = bool( + address.get("city") + or address.get("town") + or address.get("village") + or address.get("municipality") + or address.get("hamlet") + or address.get("suburb") + ) + osm_class = str(result.get("class") or "").lower() + osm_type = str(result.get("type") or "").lower() + is_site_like = osm_class in site_categories or osm_type in site_categories + if not has_city_level and not is_site_like: + return None + + if context_country: + normalized_context = normalize_text(normalize_country_text(context_country)) + normalized_result = normalize_text( + normalize_country_text(address.get("country")) + ) + if ( + normalized_context + and normalized_result + and normalized_context != normalized_result + ): + return None + + classification = ( + "site" + if ( + is_site_like + and has_city_level + and any(field in site_promoting_match_fields for field in matched_fields) + ) + else "city" + ) + return float(latitude), float(longitude), address, classification + + +def _candidate_from_geocode( + *, + query: LocationQuery, + geocode_query: str, + matched_fields: tuple[str, ...], + raw_result: dict[str, Any], + interpret: Callable[..., tuple[float, float, dict[str, Any], str] | None], + source: str, + site_confidence: float, + city_confidence: float, +) -> LocationCandidate | None: + interpreted = interpret( + raw_result, + matched_fields=matched_fields, + context_country=query.country, + ) + if not interpreted: + return None + latitude, longitude, address, classification = interpreted + city = ( + address.get("city") + or address.get("town") + or address.get("village") + or address.get("municipality") + or query.city + or None + ) + region = address.get("state") or address.get("region") + country = address.get("country") or query.country or None + display_name = raw_result.get("display_name") or geocode_query + confidence = city_confidence if classification == "city" else site_confidence + + extra = query.extra or {} + suggested_registry_entry = { + "canonical_name": ( + (query.aliases[0] if query.aliases else None) + or query.name + or display_name + ), + "aliases": list( + { + value + for value in [ + query.name, + *query.aliases, + coerce_str(extra.get("operator")), + coerce_str(extra.get("site")), + ] + if value + } + ), + "operator": coerce_str(extra.get("operator")) or None, + "site": coerce_str(extra.get("site")) + or coerce_str(extra.get("organization")) + or None, + "country": country, + "city": city, + "region": region, + "latitude": latitude, + "longitude": longitude, + "precision": classification, + "confidence": confidence, + "source_note": ( + f"Resolved via Nominatim query '{geocode_query}' → {display_name}" + ), + } + return LocationCandidate( + latitude=latitude, + longitude=longitude, + display_name=display_name, + precision=classification, + confidence=confidence, + query=geocode_query, + source=source, + source_note=f"Nominatim search result: {display_name}", + matched_fields=matched_fields, + needs_confirmation=True, + city=city, + region=region, + country=country, + matched_location_name=display_name, + location_verified_at=None, + suggested_registry_entry=suggested_registry_entry, + ) + + +class NominatimResolver: + """Run a domain-specific query plan against Nominatim.""" + + def __init__( + self, + *, + query_plan_builder: Callable[ + [LocationQuery], list[tuple[str, tuple[str, ...]]] + ], + geocoder: Callable[[str], dict[str, Any] | None], + name: str = "nominatim_online_geocode", + site_confidence: float = 0.72, + city_confidence: float = 0.62, + interpret: Callable[..., tuple[float, float, dict[str, Any], str] | None] = ( + interpret_geocode_result + ), + ) -> None: + self.name = name + self._query_plan_builder = query_plan_builder + self._geocoder = geocoder + self._site_confidence = site_confidence + self._city_confidence = city_confidence + self._interpret = interpret + + def resolve(self, query: LocationQuery) -> ResolverOutput: + plan = self._query_plan_builder(query) + candidates: list[LocationCandidate] = [] + attempted: list[str] = [] + for geocode_query, matched_fields in plan: + attempted.append(geocode_query) + try: + raw_result = self._geocoder(geocode_query) + except Exception: + continue + if not raw_result: + continue + candidate = _candidate_from_geocode( + query=query, + geocode_query=geocode_query, + matched_fields=matched_fields, + raw_result=raw_result, + interpret=self._interpret, + source=self.name, + site_confidence=self._site_confidence, + city_confidence=self._city_confidence, + ) + if candidate is not None: + candidates.append(candidate) + return ResolverOutput( + candidates=tuple(candidates), + attempted_queries=tuple(attempted), + ) diff --git a/backend/app/services/location/resolvers/registry.py b/backend/app/services/location/resolvers/registry.py new file mode 100644 index 00000000..d844afe8 --- /dev/null +++ b/backend/app/services/location/resolvers/registry.py @@ -0,0 +1,323 @@ +"""Resolver that matches a query against a local JSON registry. + +Registry schema (a single JSON file): + + { + "locations": [ + { + "canonical_name": "...", + "aliases": ["...", "..."], + "operator": "...", + "site": "...", + "city": "...", + "country": "...", + "region": "...", + "latitude": 0.0, + "longitude": 0.0, + "precision": "precise" | "site" | "city", + "confidence": 0.0, + "verification_status": "verified", + "source_note": "...", + "verified_at": "YYYY-MM-DD" + } + ], + "city_fallbacks": [ {city, country, latitude, longitude, ...} ] + } +""" + +from __future__ import annotations + +import json +from functools import lru_cache +from pathlib import Path +from typing import Any, Callable, Iterable + +from ..models import ( + RENDERABLE_PRECISIONS, + LocationCandidate, + LocationQuery, + ResolverOutput, +) +from ..text import ( + city_key, + normalize_country_text, + normalize_text, + parse_float, +) + +# Field-priority weights when scoring "this query field text contains this +# alias text". Tuned to match the legacy compute-center ordering — name beats +# site beats operator beats city — which generalizes well to other domains. +_DEFAULT_FIELD_PRIORITY = { + "name": 8, + "site": 6, + "operator": 5, + "city": 3, +} + + +def default_score_alias_match( + alias_field: str, record_field: str, alias_text: str +) -> int: + score = max(0, len(alias_text)) + score += _DEFAULT_FIELD_PRIORITY.get(alias_field, 1) + if alias_field == record_field: + score += 4 + if alias_field == "name" and record_field in {"name", "name_short", "alias"}: + score += 6 + if alias_field == "site" and record_field in {"site", "organization"}: + score += 4 + if alias_field == "operator" and record_field in {"operator", "organization"}: + score += 4 + return score + + +@lru_cache(maxsize=32) +def _load_registry_file(path: str) -> dict[str, Any]: + with Path(path).open("r", encoding="utf-8") as handle: + return json.load(handle) + + +@lru_cache(maxsize=32) +def _build_alias_index( + path: str, +) -> tuple[tuple[dict[str, Any], tuple[tuple[str, str], ...]], ...]: + index: list[tuple[dict[str, Any], tuple[tuple[str, str], ...]]] = [] + for entry in _load_registry_file(path).get("locations", []): + aliases: list[tuple[str, str]] = [] + seen: set[str] = set() + for alias in [entry.get("canonical_name"), *(entry.get("aliases") or [])]: + normalized = normalize_text(alias) + if normalized and normalized not in seen: + aliases.append(("name", normalized)) + seen.add(normalized) + for field_name in ("operator", "site", "city"): + value = entry.get(field_name) + normalized = normalize_text(value) + if normalized and normalized not in seen: + aliases.append((field_name, normalized)) + seen.add(normalized) + index.append((entry, tuple(aliases))) + return tuple(index) + + +def _query_corpus(query: LocationQuery) -> dict[str, str]: + """Map a query into normalized strings keyed by source field.""" + fields: dict[str, str] = { + "name": query.name or "", + "city": query.city or "", + "country": query.country or "", + } + for alias in query.aliases: + if alias and alias != query.name: + fields["name_short"] = alias + break + extra = query.extra or {} + for key in ("site", "operator", "organization"): + value = extra.get(key) + if value: + fields[key] = str(value) + return {key: normalize_text(value) for key, value in fields.items() if value} + + +def _country_compatible(entry: dict[str, Any], query: LocationQuery) -> bool: + record_country = normalize_country_text(query.country) + entry_country = normalize_country_text(entry.get("country")) + if not record_country or not entry_country: + return True + return normalize_text(record_country) == normalize_text(entry_country) + + +def _normalized_alias_matches(alias_normalized: str, record_text: str) -> bool: + alias_tokens = alias_normalized.split() + record_tokens = record_text.split() + if not alias_tokens or not record_tokens: + return False + if len(alias_tokens) == 1: + return alias_tokens[0] in record_tokens + window_size = len(alias_tokens) + return any( + record_tokens[index : index + window_size] == alias_tokens + for index in range(0, len(record_tokens) - window_size + 1) + ) + + +def _entry_to_candidate( + entry: dict[str, Any], + *, + matched_alias: str, + matched_fields: Iterable[str], + source: str, + score_explainer: str, + confidence_floor: float, +) -> LocationCandidate: + canonical_name = entry.get("canonical_name") or matched_alias + # Registry entries are treated as candidates unless explicitly verified. + # This prevents migrated hard-coded hints from appearing as factual + # location evidence. + is_verified = entry.get("verification_status") == "verified" + precision = entry.get("precision") or "city" + if precision not in RENDERABLE_PRECISIONS: + precision = "city" + fields_summary = ", ".join(sorted(set(matched_fields))) or "name" + confidence_value = parse_float(entry.get("confidence")) + confidence = ( + float(confidence_value) + if confidence_value is not None + else confidence_floor + ) + return LocationCandidate( + latitude=float(parse_float(entry.get("latitude")) or 0.0), + longitude=float(parse_float(entry.get("longitude")) or 0.0), + display_name=canonical_name, + precision=precision, + confidence=confidence, + query=f"local_registry::{matched_alias or canonical_name}", + source=source, + source_note=entry.get("source_note") + or f"{score_explainer}: matched {fields_summary}", + matched_fields=tuple(sorted(set(matched_fields))) or ("name",), + needs_confirmation=bool(entry.get("needs_confirmation")) or not is_verified, + city=entry.get("city"), + region=entry.get("region"), + country=entry.get("country"), + matched_location_name=canonical_name, + location_verified_at=entry.get("verified_at") if is_verified else None, + suggested_registry_entry=None, + ) + + +class RegistryResolver: + """Match a query against a JSON registry (plus its city_fallbacks table).""" + + def __init__( + self, + *, + registry_path: Path | str, + name: str = "local_registry", + city_fallback_source: str = "local_registry_city", + city_fallback_confidence_default: float = 0.65, + confidence_default: float = 0.85, + score_alias_match: Callable[[str, str, str], int] = default_score_alias_match, + ) -> None: + self.name = name + self._registry_path = str(Path(registry_path)) + self._city_fallback_source = city_fallback_source + self._city_fallback_confidence_default = city_fallback_confidence_default + self._confidence_default = confidence_default + self._score = score_alias_match + + def reload(self) -> None: + """Drop the cached registry — useful when the JSON file is edited.""" + _load_registry_file.cache_clear() + _build_alias_index.cache_clear() + + def resolve(self, query: LocationQuery) -> ResolverOutput: + candidates: list[LocationCandidate] = [] + candidates.extend(self._registry_candidates(query)) + city_candidate = self._city_fallback_candidate(query) + if city_candidate is not None: + candidates.append(city_candidate) + return ResolverOutput(candidates=tuple(candidates)) + + # ── internals ────────────────────────────────────────────── + + def _registry_candidates( + self, query: LocationQuery + ) -> list[LocationCandidate]: + corpus = _query_corpus(query) + if not corpus: + return [] + + # When the query carries a name (a record-specific identifier), require + # at least one alias match against a name-class field — otherwise a + # generic shared field like operator="RIPE NCC" would promote every + # registry entry that lists that operator, regardless of whether the + # name matches. + query_has_name = bool(corpus.get("name") or corpus.get("name_short")) + + results: list[LocationCandidate] = [] + for entry, aliases in _build_alias_index(self._registry_path): + best_alias = "" + best_score = 0 + matched_fields: list[str] = [] + matched_via_name_alias = False + for alias_field, alias_normalized in aliases: + for record_field, record_text in corpus.items(): + if not _normalized_alias_matches(alias_normalized, record_text): + continue + score = self._score( + alias_field, record_field, alias_normalized + ) + if score > best_score or ( + score == best_score + and len(alias_normalized) > len(best_alias) + ): + best_score = score + best_alias = alias_normalized + if record_field not in matched_fields: + matched_fields.append(record_field) + if alias_field == "name" and record_field in {"name", "name_short"}: + matched_via_name_alias = True + if not matched_fields or best_score <= 0: + continue + if query_has_name and not matched_via_name_alias: + continue + if not _country_compatible(entry, query): + continue + results.append( + _entry_to_candidate( + entry, + matched_alias=best_alias, + matched_fields=matched_fields, + source=self.name, + score_explainer="Registry alias match", + confidence_floor=self._confidence_default, + ) + ) + return results + + def _city_fallback_candidate( + self, query: LocationQuery + ) -> LocationCandidate | None: + country = normalize_country_text(query.country) + city = city_key(query.city) + if not country or not city: + return None + + for fallback in _load_registry_file(self._registry_path).get( + "city_fallbacks", [] + ): + fallback_country = normalize_country_text(fallback.get("country")) + fallback_city = city_key(fallback.get("city")) + if fallback_country != country or fallback_city != city: + continue + confidence_value = parse_float(fallback.get("confidence")) + confidence = ( + float(confidence_value) + if confidence_value is not None + else self._city_fallback_confidence_default + ) + return LocationCandidate( + latitude=float(parse_float(fallback.get("latitude")) or 0.0), + longitude=float(parse_float(fallback.get("longitude")) or 0.0), + display_name=fallback.get("city") or "", + precision="city", + confidence=confidence, + query=( + f"city_fallback::{fallback.get('city')}, " + f"{fallback.get('country')}" + ), + source=self._city_fallback_source, + source_note=fallback.get("source_note") + or f"City fallback for {fallback.get('city')}, {fallback.get('country')}", + matched_fields=("city", "country"), + needs_confirmation=False, + city=fallback.get("city"), + region=fallback.get("region"), + country=fallback.get("country"), + matched_location_name=fallback.get("city"), + location_verified_at=fallback.get("verified_at"), + suggested_registry_entry=None, + ) + return None diff --git a/backend/app/services/location/resolvers/source_coordinates.py b/backend/app/services/location/resolvers/source_coordinates.py new file mode 100644 index 00000000..f9283fb5 --- /dev/null +++ b/backend/app/services/location/resolvers/source_coordinates.py @@ -0,0 +1,42 @@ +"""Resolver that consumes lat/lon already present on the source record.""" + +from __future__ import annotations + +from ..models import LocationCandidate, LocationQuery, ResolverOutput +from ..text import normalize_country_text + + +class SourceCoordinatesResolver: + """Pass-through for records that already carry valid coordinates.""" + + name = "source_coordinates" + + def __init__(self, *, source: str = "source_coordinates") -> None: + self._source = source + + def resolve(self, query: LocationQuery) -> ResolverOutput: + lat = query.source_latitude + lon = query.source_longitude + if lat in (None, 0.0) or lon in (None, 0.0): + return ResolverOutput() + + country = normalize_country_text(query.country) or query.country + candidate = LocationCandidate( + latitude=float(lat), + longitude=float(lon), + display_name=query.name or "", + precision="precise", + confidence=1.0, + query="source_coordinates", + source=self._source, + source_note="Source record provided valid coordinates.", + matched_fields=("source_coordinates",), + needs_confirmation=False, + city=query.city, + region=query.region, + country=country, + matched_location_name=query.name, + location_verified_at=None, + suggested_registry_entry=None, + ) + return ResolverOutput(candidates=(candidate,)) diff --git a/backend/app/services/location/text.py b/backend/app/services/location/text.py new file mode 100644 index 00000000..3db7c2ed --- /dev/null +++ b/backend/app/services/location/text.py @@ -0,0 +1,41 @@ +"""Text-normalization helpers shared by every resolver.""" + +from __future__ import annotations + +import re +from typing import Any + +from app.core.countries import normalize_country + + +def parse_float(value: Any) -> float | None: + try: + if value in (None, ""): + return None + return float(value) + except (TypeError, ValueError): + return None + + +def coerce_str(value: Any) -> str: + if value in (None, ""): + return "" + return str(value).strip() + + +def normalize_text(value: Any) -> str: + if value in (None, ""): + return "" + normalized = str(value).casefold() + normalized = re.sub(r"[^a-z0-9一-鿿]+", " ", normalized) + return re.sub(r"\s+", " ", normalized).strip() + + +def normalize_country_text(value: Any) -> str: + normalized = normalize_country(value) + return normalized or coerce_str(value) + + +def city_key(city: Any) -> str: + text = coerce_str(city).split(",", 1)[0] + return normalize_text(text) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index a34a9bb8..3f12b53a 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -2,10 +2,45 @@ import pytest import asyncio -from typing import AsyncGenerator -from unittest.mock import AsyncMock, MagicMock, patch +import json +from unittest.mock import AsyncMock, MagicMock -from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker +from sqlalchemy.ext.asyncio import AsyncSession + + +@pytest.fixture(autouse=True) +def bgp_collector_location_cache(): + """Mirror app startup seeding for tests that call sync BGP helpers.""" + from app.services.bgp_collector_locations import ( + SEED_PATH, + set_bgp_collector_location_cache, + ) + + payload = json.loads(SEED_PATH.read_text(encoding="utf-8")) + cache = {} + for entry in payload.get("locations", []): + collector_id = next( + alias for alias in entry.get("aliases", []) if str(alias).startswith("rrc") + ) + cache[collector_id] = { + "city": entry.get("city"), + "country": entry.get("country"), + "latitude": entry.get("latitude"), + "longitude": entry.get("longitude"), + "precision": entry.get("precision") or "city", + "source": "legacy_seed", + "needs_confirmation": True, + "matched_location_name": entry.get("site") or collector_id, + "verified_at": None, + "confidence": entry.get("confidence"), + "operator": entry.get("operator"), + "site": entry.get("site"), + "verification_status": "unverified", + "source_note": entry.get("source_note"), + } + set_bgp_collector_location_cache(cache) + yield + set_bgp_collector_location_cache({}) @pytest.fixture(scope="session") diff --git a/backend/tests/test_bgp.py b/backend/tests/test_bgp.py index 6482adf0..f76dc7f6 100644 --- a/backend/tests/test_bgp.py +++ b/backend/tests/test_bgp.py @@ -1,6 +1,7 @@ """Tests for BGP observability helpers.""" from datetime import UTC, datetime, timedelta +from types import SimpleNamespace import pytest from httpx import ASGITransport, AsyncClient @@ -54,11 +55,34 @@ class _FakeResult: def scalars(self): return _FakeScalarResult(self._rows) + def all(self): + if self._rows and all(isinstance(row, BGPObservation) for row in self._rows): + return [ + (row.prefix, row.origin_asn, row.collector, row.collector_geo) + for row in self._rows + ] + return self._rows + + def scalar(self): + if not self._rows: + return 0 + first = self._rows[0] + if isinstance(first, (int, float, str)): + return first + if isinstance(first, tuple) and len(first) == 1: + return first[0] + return len(self._rows) + def fetchall(self): return self._rows def fetchone(self): - return self._rows[0] if self._rows else None + if not self._rows: + return None + first = self._rows[0] + if isinstance(first, CollectedData): + return {"extra_data": first.extra_data} + return first class _FakeAsyncSession: @@ -988,7 +1012,7 @@ async def test_infer_related_infrastructure_links_nearby_cables(): data_type="cable", extra_data={"cable_id": 20}, ) - db = _FakeAsyncSession([[landing], [relation], [cable]]) + db = _FakeAsyncSession([[landing, relation, cable]]) result = await infer_related_infrastructure( db, @@ -1012,27 +1036,37 @@ async def test_infer_related_infrastructure_links_nearby_cables(): @pytest.mark.asyncio async def test_build_bgp_collector_coverage_summarizes_observations(): now = datetime.now(UTC) - obs_one = BGPObservation( - source="ris_live_bgp", + aggregate = SimpleNamespace( + collector="rrc00", + observation_count=2, + prefix_count=2, + origin_asn_count=2, + peer_asn_count=2, + recent_15m_observation_count=2, + recent_24h_observation_count=2, + recent_7d_observation_count=2, + recent_15m_prefix_count=2, + recent_24h_prefix_count=2, + recent_7d_prefix_count=2, + latest_observed_at=now + timedelta(minutes=5), + ) + latest = SimpleNamespace( + collector="rrc00", + latest_event_type="withdrawal", + country="Netherlands", + city="Amsterdam", + ) + top_event = SimpleNamespace( collector="rrc00", - prefix="203.0.113.0/24", - origin_asn=64496, - peer_asn=3333, event_type="announcement", - observed_at=now, - collector_geo={"city": "Amsterdam", "country": "Netherlands"}, + count=1, ) - obs_two = BGPObservation( - source="ris_live_bgp", + scope = SimpleNamespace( collector="rrc00", - prefix="198.51.100.0/24", - origin_asn=64497, - peer_asn=3334, - event_type="withdrawal", - observed_at=now + timedelta(minutes=5), - collector_geo={"city": "Amsterdam", "country": "Netherlands"}, + country="Netherlands", + city="Amsterdam", ) - db = _FakeAsyncSession([[obs_one, obs_two]]) + db = _FakeAsyncSession([[aggregate], [latest], [top_event], [scope]]) coverage = await build_bgp_collector_coverage(db, source_filter=BGP_SOURCES) @@ -1363,18 +1397,39 @@ async def test_bgp_event_summary_api_returns_aggregates(): @pytest.mark.asyncio async def test_bgp_collectors_api_returns_coverage(): now = datetime.now(UTC) - observation = BGPObservation( - id=1, - source="ris_live_bgp", + aggregate = SimpleNamespace( collector="rrc00", - peer_asn=3333, - prefix="203.0.113.0/24", - event_type="announcement", - origin_asn=64496, - observed_at=now, - collector_geo={"city": "Amsterdam", "country": "Netherlands"}, + observation_count=1, + prefix_count=1, + origin_asn_count=1, + peer_asn_count=1, + recent_15m_observation_count=1, + recent_24h_observation_count=1, + recent_7d_observation_count=1, + recent_15m_prefix_count=1, + recent_24h_prefix_count=1, + recent_7d_prefix_count=1, + latest_observed_at=now, + ) + latest = SimpleNamespace( + collector="rrc00", + latest_event_type="announcement", + country="Netherlands", + city="Amsterdam", + ) + top_event = SimpleNamespace( + collector="rrc00", + event_type="announcement", + count=1, + ) + scope = SimpleNamespace( + collector="rrc00", + country="Netherlands", + city="Amsterdam", + ) + db = _FakeAsyncSession( + [[aggregate], [latest], [top_event], [scope], [aggregate], [latest], [top_event], [scope]] ) - db = _FakeAsyncSession([[observation], [observation]]) client = await _bgp_test_client(db) try: diff --git a/backend/tests/test_bgp_collector_locations.py b/backend/tests/test_bgp_collector_locations.py new file mode 100644 index 00000000..62665ec9 --- /dev/null +++ b/backend/tests/test_bgp_collector_locations.py @@ -0,0 +1,149 @@ +"""Tests for the BGP collector + event location services.""" + +from __future__ import annotations + +import pytest + +from app.services import bgp_collector_locations +from app.services.bgp_collector_locations import ( + RIPE_RIS_COLLECTOR_COORDS, + collect_bgp_collector_location_candidates, + iter_known_collector_names, + resolve_bgp_collector_location, +) +from app.services.bgp_event_locations import ( + resolve_bgp_event_geo_dict, + resolve_bgp_event_location, +) + + +def test_legacy_dict_view_preserves_backward_compatible_keys(): + rrc00 = RIPE_RIS_COLLECTOR_COORDS["rrc00"] + assert rrc00["city"] == "Amsterdam" + assert rrc00["country"] == "Netherlands" + assert rrc00["latitude"] == pytest.approx(52.3676) + assert rrc00["longitude"] == pytest.approx(4.9041) + # New richer fields layered on top. + assert rrc00["precision"] == "city" + assert rrc00["source"] == "legacy_seed" + assert rrc00["needs_confirmation"] is True + + +def test_every_legacy_collector_present(): + expected = { + "rrc00", "rrc01", "rrc03", "rrc04", "rrc05", "rrc06", "rrc07", + "rrc10", "rrc11", "rrc12", "rrc13", "rrc14", "rrc15", "rrc16", + "rrc18", "rrc19", "rrc20", "rrc21", "rrc22", "rrc23", "rrc24", + "rrc25", "rrc26", + } + assert set(iter_known_collector_names()) == expected + + +def test_resolve_bgp_collector_returns_stored_location(): + result = resolve_bgp_collector_location("rrc12") + assert result.location is not None + assert result.location.city == "Frankfurt" + assert result.location.country == "Germany" + assert result.location.precision == "city" + assert result.location.source == "legacy_seed" + assert result.location.needs_confirmation is True + + +def test_resolve_unknown_bgp_collector_returns_diagnostic(monkeypatch): + monkeypatch.setattr(bgp_collector_locations, "_geocode_online", lambda q: None) + result = resolve_bgp_collector_location("rrc-doesnotexist") + assert result.location is None + assert result.diagnostic is not None + assert result.diagnostic.failure_reason + + +def test_collect_bgp_collector_candidates_uses_stored_context_without_registry(monkeypatch): + bgp_collector_locations._geocode_online.cache_clear() + + def _fake_geocode(query): + assert "CIXP" in query or "Geneva" in query + return { + "lat": "46.2044", + "lon": "6.1432", + "display_name": "Geneva, Switzerland", + "address": {"city": "Geneva", "country": "Switzerland"}, + } + + monkeypatch.setattr(bgp_collector_locations, "_geocode_online", _fake_geocode) + candidates, attempted = collect_bgp_collector_location_candidates( + collector="rrc04", + ) + assert attempted, "stored context should feed online query attempts" + assert candidates, "online geocoding should produce at least one candidate" + best = candidates[0] + assert best.source == "nominatim_online_geocode" + assert best.needs_confirmation is True + assert all(candidate.source != "local_registry" for candidate in candidates) + + +def test_collect_bgp_collector_candidates_uses_nominatim_when_registry_misses(monkeypatch): + bgp_collector_locations._geocode_online.cache_clear() + + def _fake_geocode(query): + if "Lyon" not in query and "France-IX" not in query and "FR-IX" not in query: + return None + return { + "lat": "45.764", + "lon": "4.8357", + "display_name": "Lyon, Auvergne-Rhône-Alpes, France", + "address": {"city": "Lyon", "country": "France"}, + } + + monkeypatch.setattr(bgp_collector_locations, "_geocode_online", _fake_geocode) + candidates, attempted = collect_bgp_collector_location_candidates( + collector="rrc-mystery", + city="Lyon", + country="France", + ) + assert attempted, "Nominatim plan should run" + online = [c for c in candidates if c.source == "nominatim_online_geocode"] + assert online, "online resolver must produce a candidate when registry misses" + assert online[0].needs_confirmation is True + + +# ── BGP event resolver ───────────────────────────────────────────── + + +def test_event_resolver_inherits_from_owning_collector(): + geo = resolve_bgp_event_geo_dict("rrc25") + assert geo["city"] == "Amsterdam" + assert geo["country"] == "Netherlands" + assert geo["source"] == "inherited_from_collector" + assert geo["precision"] == "city" + + +def test_event_resolver_does_not_match_unrelated_collectors(): + """Regression: passing operator=RIPE NCC must NOT make every collector match.""" + rrc12 = resolve_bgp_event_geo_dict("rrc12") + rrc25 = resolve_bgp_event_geo_dict("rrc25") + assert rrc12["city"] == "Frankfurt" + assert rrc25["city"] == "Amsterdam" + assert rrc12["latitude"] != rrc25["latitude"] + + +def test_event_resolver_uses_source_coordinates_when_present(): + geo = resolve_bgp_event_geo_dict( + "rrc12", + source_latitude=12.34, + source_longitude=56.78, + ) + assert geo["latitude"] == pytest.approx(12.34) + assert geo["longitude"] == pytest.approx(56.78) + assert geo["precision"] == "precise" + assert geo["source"] == "source_coordinates" + + +def test_event_resolver_returns_empty_for_unknown_collector_without_source_coords(): + geo = resolve_bgp_event_geo_dict("rrc-doesnotexist") + assert geo == {} + + +def test_event_resolver_full_result_carries_diagnostic_on_miss(): + result = resolve_bgp_event_location(collector="rrc-doesnotexist") + assert result.location is None + assert result.diagnostic is not None diff --git a/backend/tests/test_docs_gatekeeper.py b/backend/tests/test_docs_gatekeeper.py new file mode 100644 index 00000000..123a0a61 --- /dev/null +++ b/backend/tests/test_docs_gatekeeper.py @@ -0,0 +1,115 @@ +"""Docs Gatekeeper API tests.""" + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.api.v1 import docs as docs_api +from app.main import app +from app.models.user import User + + +def make_user(role: str = "viewer", groups: list[str] | None = None) -> User: + user = User( + id=1, + username="docs-user", + email="docs@example.com", + password_hash="x", + role=role, + is_active=True, + ) + user.gatekeeper_groups = groups or [] + return user + + +async def get_json(path: str, user: User | None = None): + if user is not None: + async def override_user(): + return user + + app.dependency_overrides[docs_api.get_optional_current_user] = override_user + + transport = ASGITransport(app=app) + try: + async with AsyncClient(transport=transport, base_url="http://test") as client: + return await client.get(path) + finally: + app.dependency_overrides.clear() + + +@pytest.mark.asyncio +async def test_public_catalog_only_for_anonymous_user(): + response = await get_json("/api/v1/docs/catalog") + + assert response.status_code == 200 + items = response.json()["items"] + assert {item["access"] for item in items} == {"public"} + assert {item["slug"] for item in items if item["lang"] == "zh"} == { + "overview", + "quickstart", + "manual", + "location-pipeline-user", + } + + +@pytest.mark.asyncio +async def test_anonymous_can_read_public_doc(): + response = await get_json("/api/v1/docs/zh/quickstart") + + assert response.status_code == 200 + assert response.json()["access"] == "public" + assert "快速开始" in response.json()["markdown"] + + +@pytest.mark.asyncio +async def test_anonymous_protected_doc_requires_authentication(): + response = await get_json("/api/v1/docs/zh/backend-collectors") + + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_viewer_without_group_cannot_read_developer_doc(): + response = await get_json( + "/api/v1/docs/zh/backend-collectors", + make_user(role="viewer"), + ) + + assert response.status_code == 403 + + +@pytest.mark.asyncio +async def test_developer_group_can_read_developer_but_not_admin_doc(): + user = make_user(role="viewer", groups=["docs_developer"]) + + developer_response = await get_json("/api/v1/docs/zh/backend-collectors", user) + admin_response = await get_json("/api/v1/docs/zh/backend-system-service-control", user) + + assert developer_response.status_code == 200 + assert developer_response.json()["access"] == "docs_developer" + assert admin_response.status_code == 403 + + +@pytest.mark.asyncio +async def test_admin_and_super_admin_can_read_admin_docs(): + admin_response = await get_json( + "/api/v1/docs/zh/backend-system-service-control", + make_user(role="admin"), + ) + super_admin_response = await get_json( + "/api/v1/docs/zh/backend-system-service-control", + make_user(role="super_admin"), + ) + + assert admin_response.status_code == 200 + assert super_admin_response.status_code == 200 + + +@pytest.mark.asyncio +async def test_unknown_language_slug_and_path_traversal_do_not_read_files(): + bad_lang = await get_json("/api/v1/docs/fr/quickstart") + bad_slug = await get_json("/api/v1/docs/zh/not-a-doc") + traversal = await get_json("/api/v1/docs/zh/..%2Fmanual") + + assert bad_lang.status_code == 404 + assert bad_slug.status_code == 404 + assert traversal.status_code == 404 diff --git a/backend/tests/test_location_pipeline.py b/backend/tests/test_location_pipeline.py new file mode 100644 index 00000000..93c415a2 --- /dev/null +++ b/backend/tests/test_location_pipeline.py @@ -0,0 +1,429 @@ +"""Tests for the shared location resolution pipeline. + +Validates the abstraction itself: the protocol contract, the orchestrator, +each built-in resolver, and the pluggability promise (a custom resolver can +be slotted in without touching consumers). +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from app.services.location import ( + InheritFromAnotherEntityResolver, + LocationCandidate, + LocationPipeline, + LocationQuery, + NominatimResolver, + RegistryResolver, + ResolverOutput, + SourceCoordinatesResolver, +) + + +# ── Test fixtures ──────────────────────────────────────────────────── + + +@pytest.fixture +def tmp_registry(tmp_path: Path) -> Path: + payload = { + "locations": [ + { + "canonical_name": "Test Site Alpha", + "aliases": ["alpha", "alpha-one", "Acme HQ"], + "operator": "Acme Networks", + "site": "Acme HQ", + "city": "Lyon", + "country": "France", + "latitude": 45.764, + "longitude": 4.8357, + "precision": "site", + "confidence": 0.92, + "source_note": "Test fixture", + "verified_at": "2026-05-08", + }, + { + "canonical_name": "Test Site Bravo", + "aliases": ["bravo"], + "operator": "Acme Networks", + "site": "Bravo POP", + "city": "Berlin", + "country": "Germany", + "latitude": 52.52, + "longitude": 13.405, + "precision": "city", + "confidence": 0.85, + }, + ], + "city_fallbacks": [ + { + "city": "Bhutan-Capital", + "country": "Bhutan", + "latitude": 27.4728, + "longitude": 89.639, + "precision": "city", + "confidence": 0.5, + } + ], + } + path = tmp_path / "registry.json" + path.write_text(json.dumps(payload), encoding="utf-8") + return path + + +# ── SourceCoordinatesResolver ──────────────────────────────────────── + + +def test_source_coordinates_resolver_passes_through_valid_coordinates(): + resolver = SourceCoordinatesResolver() + query = LocationQuery( + name="Acme HQ", + source_latitude=45.0, + source_longitude=4.0, + country="France", + ) + output = resolver.resolve(query) + assert len(output.candidates) == 1 + candidate = output.candidates[0] + assert candidate.latitude == 45.0 + assert candidate.longitude == 4.0 + assert candidate.precision == "precise" + assert candidate.source == "source_coordinates" + assert candidate.needs_confirmation is False + + +def test_source_coordinates_resolver_skips_zero_coordinates(): + resolver = SourceCoordinatesResolver() + output = resolver.resolve( + LocationQuery(name="X", source_latitude=0.0, source_longitude=0.0) + ) + assert output.candidates == () + + +def test_source_coordinates_resolver_skips_when_missing(): + resolver = SourceCoordinatesResolver() + output = resolver.resolve(LocationQuery(name="X")) + assert output.candidates == () + + +# ── RegistryResolver ───────────────────────────────────────────────── + + +def test_registry_resolver_matches_alias(tmp_registry): + resolver = RegistryResolver(registry_path=tmp_registry) + resolver.reload() + output = resolver.resolve( + LocationQuery(name="alpha", country="France") + ) + candidates = list(output.candidates) + assert candidates, "should match registry entry" + assert any(c.matched_location_name == "Test Site Alpha" for c in candidates) + alpha = next(c for c in candidates if c.matched_location_name == "Test Site Alpha") + assert alpha.precision == "site" + assert alpha.confidence == pytest.approx(0.92) + assert alpha.needs_confirmation is True + assert alpha.location_verified_at is None + + +def test_registry_resolver_filters_country_mismatch(tmp_registry): + resolver = RegistryResolver(registry_path=tmp_registry) + resolver.reload() + # alpha is in France; query says Spain → should reject + output = resolver.resolve( + LocationQuery(name="alpha", country="Spain") + ) + assert all( + c.matched_location_name != "Test Site Alpha" for c in output.candidates + ) + + +def test_registry_resolver_emits_city_fallback_candidate(tmp_registry): + resolver = RegistryResolver(registry_path=tmp_registry) + resolver.reload() + output = resolver.resolve( + LocationQuery(city="Bhutan-Capital", country="Bhutan") + ) + candidates = list(output.candidates) + assert candidates, "city fallback should fire" + assert any(c.source == "local_registry_city" for c in candidates) + + +# ── NominatimResolver ─────────────────────────────────────────────── + + +def test_nominatim_resolver_calls_geocoder_with_plan_queries(): + calls = [] + + def fake_geocoder(query: str): + calls.append(query) + return { + "lat": "12.34", + "lon": "56.78", + "display_name": "Test City, Country", + "address": {"city": "Test City", "country": "Country"}, + } + + def plan(query: LocationQuery): + return [ + ("primary query", ("name",)), + ("secondary query", ("city",)), + ] + + resolver = NominatimResolver( + query_plan_builder=plan, + geocoder=fake_geocoder, + ) + output = resolver.resolve(LocationQuery(name="X", country="Country")) + assert calls == ["primary query", "secondary query"] + assert output.attempted_queries == ("primary query", "secondary query") + assert len(output.candidates) == 2 + assert all(c.precision == "city" for c in output.candidates) + assert all(c.needs_confirmation for c in output.candidates) + + +def test_nominatim_resolver_skips_when_geocoder_returns_none(): + resolver = NominatimResolver( + query_plan_builder=lambda q: [("only", ("name",))], + geocoder=lambda q: None, + ) + output = resolver.resolve(LocationQuery(name="X")) + assert output.candidates == () + assert output.attempted_queries == ("only",) + + +def test_nominatim_resolver_swallows_exceptions_per_query(): + def boom(query): + raise RuntimeError("network down") + + resolver = NominatimResolver( + query_plan_builder=lambda q: [("a", ()), ("b", ())], + geocoder=boom, + ) + output = resolver.resolve(LocationQuery(name="X")) + assert output.candidates == () + assert output.attempted_queries == ("a", "b") + + +# ── InheritFromAnotherEntityResolver ──────────────────────────────── + + +def test_inherit_resolver_returns_provided_candidate(): + sentinel = LocationCandidate( + latitude=10.0, + longitude=20.0, + display_name="Inherited", + precision="city", + confidence=0.7, + query="inherit::test", + source="inherited", + source_note=None, + matched_fields=("collector",), + needs_confirmation=False, + ) + resolver = InheritFromAnotherEntityResolver( + source_lookup=lambda q: sentinel + ) + output = resolver.resolve(LocationQuery(name="X")) + assert output.candidates == (sentinel,) + + +def test_inherit_resolver_skips_when_lookup_returns_none(): + resolver = InheritFromAnotherEntityResolver(source_lookup=lambda q: None) + assert resolver.resolve(LocationQuery(name="X")).candidates == () + + +# ── LocationPipeline orchestration ────────────────────────────────── + + +def test_pipeline_aggregates_candidates_across_resolvers(tmp_registry): + pipeline = LocationPipeline( + [ + SourceCoordinatesResolver(), + RegistryResolver(registry_path=tmp_registry), + NominatimResolver( + query_plan_builder=lambda q: [("nominatim attempt", ("name",))], + geocoder=lambda q: { + "lat": "1.0", + "lon": "2.0", + "display_name": "Online City", + "address": {"city": "Online City", "country": "France"}, + }, + ), + ] + ) + pipeline.resolvers[1].reload() + candidates, attempted = pipeline.collect_candidates( + LocationQuery( + name="alpha", + country="France", + source_latitude=44.0, + source_longitude=5.0, + ) + ) + sources = {c.source for c in candidates} + assert "source_coordinates" in sources + assert "local_registry" in sources + assert "nominatim_online_geocode" in sources + assert "nominatim attempt" in attempted + + +def test_pipeline_dedupes_by_source_and_coordinates(): + same = LocationCandidate( + latitude=1.0, + longitude=2.0, + display_name="dup", + precision="city", + confidence=0.5, + query="x", + source="dup_source", + source_note=None, + matched_fields=(), + needs_confirmation=False, + ) + + class _DupResolver: + name = "dup_source" + + def resolve(self, query): + return ResolverOutput(candidates=(same, same)) + + pipeline = LocationPipeline([_DupResolver()]) + candidates, _ = pipeline.collect_candidates(LocationQuery(name="X")) + assert len(candidates) == 1 + + +def test_registry_short_aliases_do_not_match_inside_larger_tokens(tmp_path: Path): + registry_path = tmp_path / "registry.json" + registry_path.write_text( + json.dumps( + { + "locations": [ + { + "canonical_name": "Aurora", + "aliases": ["Aurora", "ANL"], + "site": "DOE/SC/Argonne National Laboratory", + "country": "United States", + "city": "Lemont", + "latitude": 41.713, + "longitude": -87.982, + "precision": "site", + }, + { + "canonical_name": "Venado", + "aliases": ["Venado"], + "site": "DOE/NNSA/LANL", + "country": "United States", + "city": "Los Alamos", + "latitude": 35.8443, + "longitude": -106.2872, + "precision": "site", + }, + ], + "city_fallbacks": [], + } + ), + encoding="utf-8", + ) + resolver = RegistryResolver(registry_path=registry_path) + resolver.reload() + + output = resolver.resolve( + LocationQuery( + name="Venado", + country="United States", + extra={"site": "DOE/NNSA/LANL"}, + ) + ) + + assert len(output.candidates) == 1 + assert output.candidates[0].matched_location_name == "Venado" + + +def test_pipeline_resolve_best_returns_highest_priority(): + online = LocationCandidate( + latitude=10.0, + longitude=20.0, + display_name="online", + precision="city", + confidence=0.9, + query="x", + source="nominatim_online_geocode", + source_note=None, + matched_fields=(), + needs_confirmation=True, + ) + source = LocationCandidate( + latitude=11.0, + longitude=21.0, + display_name="src", + precision="precise", + confidence=1.0, + query="x", + source="source_coordinates", + source_note=None, + matched_fields=(), + needs_confirmation=False, + ) + + class _StubResolver: + def __init__(self, c, name): + self._c = c + self.name = name + + def resolve(self, query): + return ResolverOutput(candidates=(self._c,)) + + pipeline = LocationPipeline( + [ + _StubResolver(online, "online"), + _StubResolver(source, "src"), + ] + ) + result = pipeline.resolve_best(LocationQuery(name="X")) + assert result.location is source, "source_coordinates should beat nominatim" + + +def test_pipeline_returns_diagnostic_when_nothing_resolves(): + pipeline = LocationPipeline([SourceCoordinatesResolver()]) + result = pipeline.resolve_best(LocationQuery(name="X", country="Bhutan")) + assert result.location is None + assert result.diagnostic is not None + assert result.diagnostic.country == "Bhutan" + + +def test_pluggability_custom_resolver_works_without_changing_pipeline(): + """Validates the abstraction promise: a new algorithm = a new class.""" + + class _PeeringDBStubResolver: + name = "fake_peeringdb" + + def resolve(self, query): + asn = (query.extra or {}).get("asn") + if asn != 174: + return ResolverOutput() + return ResolverOutput( + candidates=( + LocationCandidate( + latitude=1.0, + longitude=2.0, + display_name="Cogent HQ", + precision="site", + confidence=0.8, + query=f"peeringdb::{asn}", + source="peeringdb_stub", + source_note="Stub for testing", + matched_fields=("asn",), + needs_confirmation=False, + ), + ) + ) + + pipeline = LocationPipeline([_PeeringDBStubResolver()]) + candidates, _ = pipeline.collect_candidates( + LocationQuery(name="X", extra={"asn": 174}) + ) + assert len(candidates) == 1 + assert candidates[0].source == "peeringdb_stub" diff --git a/backend/tests/test_visualization_compute_centers.py b/backend/tests/test_visualization_compute_centers.py index 5253e9c7..5e4d8d15 100644 --- a/backend/tests/test_visualization_compute_centers.py +++ b/backend/tests/test_visualization_compute_centers.py @@ -4,6 +4,7 @@ import pytest from httpx import ASGITransport, AsyncClient from app.api.v1.visualization import convert_compute_centers_to_geojson +import app.services.compute_center_locations as compute_center_locations from app.db.session import get_db from app.main import app from app.models.collected_data import CollectedData @@ -89,6 +90,8 @@ def test_convert_compute_centers_to_geojson_unifies_sources(): assert supercomputer_feature["properties"]["operator"] == "ORNL" assert supercomputer_feature["properties"]["location_precision"] == "precise" assert supercomputer_feature["properties"]["is_estimated"] is False + assert supercomputer_feature["properties"]["location_source"] == "source_coordinates" + assert supercomputer_feature["properties"]["location_confidence"] == 1.0 gpu_feature = payload["features"][1] assert gpu_feature["properties"]["site_type"] == "gpu_cluster" @@ -98,8 +101,110 @@ def test_convert_compute_centers_to_geojson_unifies_sources(): assert gpu_feature["properties"]["location_precision"] == "precise" -def test_convert_compute_centers_to_geojson_uses_coordinate_hints(): - hinted_record = _build_record( +def test_convert_compute_centers_to_geojson_accepts_source_coordinate_aliases(): + record = _build_record( + record_id=3, + source="epoch_ai_gpu", + data_type="gpu_cluster", + name="Alias Coordinates", + country="United States", + city="New York", + latitude=0.0, + longitude=0.0, + metadata={ + "latitude": "", + "longitude": "", + "location": { + "lat": 40.7128, + "lng": -74.0060, + }, + "value": "1200", + "unit": "TFlop/s", + }, + ) + + payload = convert_compute_centers_to_geojson([record]) + + assert len(payload["features"]) == 1 + feature = payload["features"][0] + assert feature["geometry"]["coordinates"] == [-74.006, 40.7128] + assert feature["properties"]["location_source"] == "source_coordinates" + + +def test_compute_center_source_coordinates_win_over_stored_location(): + compute_center_locations.set_compute_center_location_cache({ + "top500:top500-31": { + "source": "top500", + "source_id": "top500-31", + "name": "Stored Wrong", + "latitude": 1.0, + "longitude": 2.0, + "precision": "city", + "confidence": 0.5, + "needs_confirmation": True, + } + }) + record = _build_record( + record_id=31, + source="top500", + data_type="supercomputer", + name="Source Wins", + country="United States", + city="Oak Ridge", + latitude=35.93, + longitude=-84.31, + metadata={"organization": "ORNL"}, + ) + + payload = convert_compute_centers_to_geojson([record]) + + assert payload["features"][0]["geometry"]["coordinates"] == [-84.31, 35.93] + assert payload["features"][0]["properties"]["location_source"] == "source_coordinates" + compute_center_locations.set_compute_center_location_cache({}) + + +def test_compute_center_geojson_uses_stored_location_when_source_coords_missing(): + compute_center_locations.set_compute_center_location_cache({ + "epoch_ai_gpu:epoch_ai_gpu-32": { + "source": "epoch_ai_gpu", + "source_id": "epoch_ai_gpu-32", + "name": "Stored Cluster", + "city": "Memphis", + "country": "United States", + "latitude": 35.1495, + "longitude": -90.049, + "precision": "city", + "confidence": 0.72, + "location_source": "manual_selection", + "source_note": "Saved by user", + "needs_confirmation": False, + "verified_at": "2026-05-08T00:00:00Z", + } + }) + record = _build_record( + record_id=32, + source="epoch_ai_gpu", + data_type="gpu_cluster", + name="Stored Cluster", + country="United States", + city="", + latitude=0.0, + longitude=0.0, + metadata={"value": "1200", "unit": "TFlop/s"}, + ) + + payload = convert_compute_centers_to_geojson([record]) + + assert len(payload["features"]) == 1 + feature = payload["features"][0] + assert feature["geometry"]["coordinates"] == [-90.049, 35.1495] + assert feature["properties"]["location_source"] == "stored_compute_center_location" + assert feature["properties"]["needs_confirmation"] is False + compute_center_locations.set_compute_center_location_cache({}) + + +def test_convert_compute_centers_to_geojson_does_not_use_registry_aliases(): + registry_record = _build_record( record_id=3, source="top500", data_type="supercomputer", @@ -114,23 +219,51 @@ def test_convert_compute_centers_to_geojson_uses_coordinate_hints(): }, ) - payload = convert_compute_centers_to_geojson([hinted_record]) + payload = convert_compute_centers_to_geojson([registry_record]) - assert len(payload["features"]) == 1 - coords = payload["features"][0]["geometry"]["coordinates"] - assert coords[0] == pytest.approx(-84.3107) - assert coords[1] == pytest.approx(35.9319) - assert payload["features"][0]["properties"]["is_estimated"] is True - assert payload["features"][0]["properties"]["location_precision"] == "estimated_site" + assert payload["features"] == [] + assert len(payload["unresolved"]) == 1 + assert payload["unresolved"][0]["name"] == "Frontier" + assert "source coords" in payload["unresolved"][0]["failure_reason"] -def test_convert_compute_centers_to_geojson_falls_back_to_country_centroid(): - centroid_record = _build_record( +def test_convert_compute_centers_to_geojson_does_not_use_city_fallback(): + city_record = _build_record( + record_id=4, + source="epoch_ai_gpu", + data_type="gpu_cluster", + name="Sample GPU Cluster", + country="United States", + city="San Francisco, CA", + latitude=0.0, + longitude=0.0, + metadata={ + "organization": "Sample Operator", + "value": "10000", + "unit": "TFlop/s", + }, + ) + + payload = convert_compute_centers_to_geojson([city_record]) + + assert payload["features"] == [] + assert len(payload["unresolved"]) == 1 + assert payload["unresolved"][0]["city"] == "San Francisco, CA" + + +def test_convert_compute_centers_to_geojson_does_not_online_geocode_on_startup(monkeypatch): + compute_center_locations._geocode_online.cache_clear() + + def _explode(_query): + raise AssertionError("startup GeoJSON must not call online geocoding") + + monkeypatch.setattr(compute_center_locations, "_geocode_online", _explode) + country_record = _build_record( record_id=4, source="epoch_ai_gpu", data_type="gpu_cluster", name="Unknown Cluster", - country="United States", + country="France", city="", latitude=0.0, longitude=0.0, @@ -141,16 +274,228 @@ def test_convert_compute_centers_to_geojson_falls_back_to_country_centroid(): }, ) - payload = convert_compute_centers_to_geojson([centroid_record]) + payload = convert_compute_centers_to_geojson([country_record]) - assert len(payload["features"]) == 1 - props = payload["features"][0]["properties"] - coords = payload["features"][0]["geometry"]["coordinates"] - assert coords[0] == pytest.approx(-98.5795) - assert coords[1] == pytest.approx(39.8283) - assert props["is_estimated"] is True - assert props["location_precision"] == "estimated_country" - assert props["geography_mode"] == "country_centroid" + assert payload["features"] == [] + assert len(payload["unresolved"]) == 1 + assert payload["unresolved"][0]["operator"] == "Unknown Operator" + + +def test_convert_compute_centers_to_geojson_records_diagnostics_when_online_geocode_fails(monkeypatch): + compute_center_locations._geocode_online.cache_clear() + monkeypatch.setattr(compute_center_locations, "_geocode_online", lambda _query: None) + country_record = _build_record( + record_id=5, + source="epoch_ai_gpu", + data_type="gpu_cluster", + name="Unknown French Cluster", + country="France", + city="", + latitude=0.0, + longitude=0.0, + metadata={ + "organization": "Unknown Operator", + "value": "10000", + "unit": "TFlop/s", + }, + ) + + payload = convert_compute_centers_to_geojson([country_record]) + + assert payload["features"] == [] + assert len(payload["unresolved"]) == 1 + diagnostic = payload["unresolved"][0] + assert diagnostic["record_id"] == 5 + assert diagnostic["source_id"] == "epoch_ai_gpu-5" + assert diagnostic["country"] == "France" + assert diagnostic["operator"] == "Unknown Operator" + assert diagnostic["failure_reason"] + assert diagnostic["attempted_queries"] == [] + + +def test_convert_compute_centers_to_geojson_records_diagnostics_when_no_country(monkeypatch): + compute_center_locations._geocode_online.cache_clear() + monkeypatch.setattr(compute_center_locations, "_geocode_online", lambda _query: None) + unknown_record = _build_record( + record_id=6, + source="epoch_ai_gpu", + data_type="gpu_cluster", + name="Unknown Offshore Cluster", + country="", + city="", + latitude=0.0, + longitude=0.0, + metadata={ + "organization": "Unknown Operator", + "value": "10000", + "unit": "TFlop/s", + }, + ) + + payload = convert_compute_centers_to_geojson([unknown_record]) + + assert payload["features"] == [] + assert len(payload["unresolved"]) == 1 + assert payload["unresolved"][0]["failure_reason"] + + +def test_convert_compute_centers_to_geojson_never_emits_zero_coordinates(monkeypatch): + compute_center_locations._geocode_online.cache_clear() + + def _zero_geocode(query): + return { + "lat": "0", + "lon": "0", + "display_name": "Null Island", + "address": {"city": "", "country": ""}, + } + + monkeypatch.setattr(compute_center_locations, "_geocode_online", _zero_geocode) + record = _build_record( + record_id=7, + source="epoch_ai_gpu", + data_type="gpu_cluster", + name="Null Island Cluster", + country="", + city="", + latitude=0.0, + longitude=0.0, + metadata={"organization": "Null Inc"}, + ) + + payload = convert_compute_centers_to_geojson([record]) + + for feature in payload["features"]: + coords = feature["geometry"]["coordinates"] + assert coords[0] not in (0, 0.0) + assert coords[1] not in (0, 0.0) + + +def test_convert_compute_centers_to_geojson_rejects_country_or_unknown_precision(monkeypatch): + compute_center_locations._geocode_online.cache_clear() + monkeypatch.setattr(compute_center_locations, "_geocode_online", lambda _query: None) + record = _build_record( + record_id=8, + source="top500", + data_type="supercomputer", + name="Phantom System", + country="Liechtenstein", + city="", + latitude=0.0, + longitude=0.0, + metadata={"organization": "Phantom Operator", "rmax": 100.0}, + ) + + payload = convert_compute_centers_to_geojson([record]) + + for feature in payload["features"]: + assert feature["properties"]["location_precision"] in {"precise", "site", "city"} + assert payload["features"] == [] + assert payload["unresolved"], "phantom record must surface as diagnostic" + + +def test_resolve_full_returns_diagnostic_for_unresolved(monkeypatch): + compute_center_locations._geocode_online.cache_clear() + monkeypatch.setattr(compute_center_locations, "_geocode_online", lambda _query: None) + record = _build_record( + record_id=11, + source="epoch_ai_gpu", + data_type="gpu_cluster", + name="Phantom Cluster", + country="Bhutan", + city="", + latitude=0.0, + longitude=0.0, + metadata={"organization": "Mystery Operator"}, + ) + result = compute_center_locations.resolve_compute_center_location_full(record, record.extra_data) + assert result.location is None + assert result.diagnostic is not None + assert result.diagnostic.failure_reason + assert result.diagnostic.country == "Bhutan" + + +def test_collect_location_candidates_ignores_registry_and_uses_online(monkeypatch): + compute_center_locations._geocode_online.cache_clear() + + def _fake_ror(query): + assert query == "Oak Ridge National Laboratory" + return { + "id": "https://ror.org/01qz5mb56", + "names": [ + {"types": ["ror_display"], "value": "Oak Ridge National Laboratory"} + ], + "locations": [ + { + "geonames_id": 4646571, + "geonames_details": { + "name": "Oak Ridge", + "country_subdivision_name": "Tennessee", + "country_name": "United States", + "lat": 36.01036, + "lng": -84.26964, + }, + } + ], + } + + monkeypatch.setattr(compute_center_locations, "_lookup_ror_organization", _fake_ror) + monkeypatch.setattr(compute_center_locations, "_geocode_online", lambda _query: None) + candidates, attempted = compute_center_locations.collect_location_candidates( + name="Frontier", + operator="Oak Ridge National Laboratory", + country="United States", + ) + assert candidates, "online source-traced query must produce a candidate" + best = candidates[0] + assert best.source == "ror_organization_registry" + assert best.precision == "city" + assert best.needs_confirmation is True + assert attempted[0] == "ror:Oak Ridge National Laboratory" + + +def test_collect_location_candidates_returns_online_when_registry_misses(monkeypatch): + compute_center_locations._geocode_online.cache_clear() + + def _fake_geocode(query): + if "Lyon" not in query and "Mystery Operator" not in query and "Lyon, France" not in query: + return None + return { + "lat": "45.7640", + "lon": "4.8357", + "display_name": "Lyon, Auvergne-Rhône-Alpes, France", + "address": {"city": "Lyon", "state": "Auvergne-Rhône-Alpes", "country": "France"}, + } + + monkeypatch.setattr(compute_center_locations, "_geocode_online", _fake_geocode) + monkeypatch.setattr(compute_center_locations, "_lookup_ror_organization", lambda _query: None) + candidates, attempted = compute_center_locations.collect_location_candidates( + name="Mystery System", + operator="Mystery Operator", + city="Lyon", + country="France", + ) + assert candidates, "online geocoding must produce a candidate" + online_candidates = [c for c in candidates if c.source == "nominatim_online_geocode"] + assert online_candidates, "must include at least one online candidate" + online = online_candidates[0] + assert online.precision == "city" + assert online.needs_confirmation is True + assert online.suggested_registry_entry is not None + assert attempted, "must record attempted query strings" + + +def test_collect_location_candidates_failure_returns_attempted_queries(monkeypatch): + compute_center_locations._geocode_online.cache_clear() + monkeypatch.setattr(compute_center_locations, "_lookup_ror_organization", lambda _query: None) + monkeypatch.setattr(compute_center_locations, "_geocode_online", lambda _query: None) + candidates, attempted = compute_center_locations.collect_location_candidates( + name="Mystery Offshore Cluster", + operator="Mystery Operator", + country="Bhutan", + ) + assert candidates == [] + assert attempted, "even on failure we record attempted queries for diagnostics" @pytest.mark.asyncio @@ -353,3 +698,304 @@ async def test_visualization_geo_summary_returns_counts(monkeypatch): assert stats["bgp_collector_count"] == 2 finally: app.dependency_overrides.clear() + + +@pytest.mark.asyncio +async def test_collect_location_endpoint_returns_candidates_for_known_record(monkeypatch): + def _fake_ror(query): + assert query == "Oak Ridge National Laboratory" + return { + "id": "https://ror.org/01qz5mb56", + "names": [ + {"types": ["ror_display"], "value": "Oak Ridge National Laboratory"} + ], + "locations": [ + { + "geonames_id": 4646571, + "geonames_details": { + "name": "Oak Ridge", + "country_subdivision_name": "Tennessee", + "country_name": "United States", + "lat": 36.01036, + "lng": -84.26964, + }, + } + ], + } + + monkeypatch.setattr(compute_center_locations, "_lookup_ror_organization", _fake_ror) + monkeypatch.setattr(compute_center_locations, "_geocode_online", lambda _query: None) + + target_record = _build_record( + record_id=42, + source="top500", + data_type="supercomputer", + name="Frontier", + country="United States", + city="", + latitude=0.0, + longitude=0.0, + metadata={"organization": "Oak Ridge National Laboratory", "rmax": 1102000.0}, + ) + + class _ScalarResult: + def __init__(self, rows): + self._rows = rows + + def scalars(self): + class _Scalars: + def __init__(self, rows): + self._rows = rows + + def first(self): + return self._rows[0] if self._rows else None + + def all(self): + return self._rows + + return _Scalars(self._rows) + + class _FakeSession: + async def execute(self, _query): + return _ScalarResult([target_record]) + + async def override_get_db(): + yield _FakeSession() + + app.dependency_overrides[get_db] = override_get_db + transport = ASGITransport(app=app) + try: + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/api/v1/visualization/compute-centers/top500-42/collect-location", + json={ + "name": "Frontier", + "operator": "Oak Ridge National Laboratory", + "country": "United States", + }, + ) + assert response.status_code == 200 + body = response.json() + assert body["success"] is True + assert body["candidates"], "must include candidates" + best = body["best_candidate"] + assert best["precision"] in {"precise", "site", "city"} + assert best["source"] == "ror_organization_registry" + assert best["needs_confirmation"] is True + assert best["matched_fields"], "matched_fields must be populated" + finally: + app.dependency_overrides.clear() + + +@pytest.mark.asyncio +async def test_collect_location_endpoint_returns_failure_reason(monkeypatch): + monkeypatch.setattr(compute_center_locations, "_lookup_ror_organization", lambda _query: None) + monkeypatch.setattr(compute_center_locations, "_geocode_online", lambda _query: None) + + class _ScalarResult: + def __init__(self, rows): + self._rows = rows + + def scalars(self): + class _Scalars: + def __init__(self, rows): + self._rows = rows + + def first(self): + return self._rows[0] if self._rows else None + + def all(self): + return self._rows + + return _Scalars(self._rows) + + class _FakeSession: + async def execute(self, _query): + return _ScalarResult([]) + + async def override_get_db(): + yield _FakeSession() + + app.dependency_overrides[get_db] = override_get_db + transport = ASGITransport(app=app) + try: + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/api/v1/visualization/compute-centers/epoch-mystery-99/collect-location", + json={ + "name": "Mystery Cluster", + "operator": "Mystery Operator", + "country": "Bhutan", + }, + ) + assert response.status_code == 200 + body = response.json() + assert body["success"] is False + assert body["failure_reason"] + assert body["candidates"] == [] + assert body["attempted_queries"], "must include attempted queries" + finally: + app.dependency_overrides.clear() + + +@pytest.mark.asyncio +async def test_save_location_endpoint_upserts_and_geojson_can_render(): + target_record = _build_record( + record_id=52, + source="epoch_ai_gpu", + data_type="gpu_cluster", + name="Saved Cluster", + country="United States", + city="", + latitude=0.0, + longitude=0.0, + metadata={"value": "1200", "unit": "TFlop/s"}, + ) + + class _ScalarResult: + def __init__(self, rows): + self._rows = rows + + def scalars(self): + class _Scalars: + def __init__(self, rows): + self._rows = rows + + def first(self): + return self._rows[0] if self._rows else None + + def all(self): + return self._rows + + return _Scalars(self._rows) + + class _FakeSession: + def __init__(self): + self.saved = [] + + async def execute(self, _query): + if self.saved: + return _ScalarResult(self.saved) + return _ScalarResult([target_record]) + + async def scalar(self, _query): + return None + + def add(self, record): + self.saved.append(record) + + async def commit(self): + return None + + async def refresh(self, _record): + return None + + fake_session = _FakeSession() + + async def override_get_db(): + yield fake_session + + app.dependency_overrides[get_db] = override_get_db + transport = ASGITransport(app=app) + try: + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/api/v1/visualization/compute-centers/epoch_ai_gpu-52/location", + json={ + "source": "epoch_ai_gpu", + "name": "Saved Cluster", + "latitude": 35.1495, + "longitude": -90.049, + "precision": "city", + "confidence": 0.72, + "location_source": "ror_organization_registry", + "source_note": "Selected by user", + "raw_payload": {"source": "ror_organization_registry"}, + }, + ) + assert response.status_code == 200 + body = response.json() + assert body["success"] is True + assert fake_session.saved + + payload = convert_compute_centers_to_geojson([target_record]) + assert len(payload["features"]) == 1 + feature = payload["features"][0] + assert feature["geometry"]["coordinates"] == [-90.049, 35.1495] + assert feature["properties"]["location_source"] == "stored_compute_center_location" + finally: + app.dependency_overrides.clear() + compute_center_locations.set_compute_center_location_cache({}) + + +def test_resolution_chain_orders_source_coords_first(monkeypatch): + def _explode(_query): + raise AssertionError("source coords must short-circuit before online geocoding") + + monkeypatch.setattr(compute_center_locations, "_geocode_online", _explode) + record = _build_record( + record_id=20, + source="top500", + data_type="supercomputer", + name="Frontier", + country="United States", + city="Oak Ridge", + latitude=35.93, + longitude=-84.31, + metadata={"organization": "ORNL"}, + ) + result = compute_center_locations.resolve_compute_center_location_full(record, record.extra_data) + assert result.is_resolved + assert result.location.location_precision == "precise" + assert result.location.location_source == "source_coordinates" + + +def test_no_country_centroid_or_major_compute_city_fallback(monkeypatch): + monkeypatch.setattr(compute_center_locations, "_geocode_online", lambda _query: None) + record = _build_record( + record_id=21, + source="top500", + data_type="supercomputer", + name="Phantom System", + country="France", + city="", + latitude=0.0, + longitude=0.0, + metadata={"organization": "Phantom Operator"}, + ) + result = compute_center_locations.resolve_compute_center_location_full(record, record.extra_data) + assert result.location is None, "must NOT fall back to country centroid or hashed major city" + assert result.diagnostic is not None + assert result.diagnostic.failure_reason + + +def test_repository_has_no_forbidden_precision_tokens(): + """Static guard: forbidden fallback strategies must not regress into the codebase. + + Each forbidden token may appear at most once per target file, and only inside + the FORBIDDEN_PRECISIONS guard list (so we still reject them at runtime). + """ + from pathlib import Path + + backend_root = Path(__file__).resolve().parents[1] + forbidden_tokens = ( + "country_centroid", + "country_major_compute_city", + "estimated_country", + ) + targets = [ + backend_root / "app" / "services" / "compute_center_locations.py", + backend_root / "app" / "api" / "v1" / "visualization.py", + ] + for target in targets: + text = target.read_text(encoding="utf-8") + for token in forbidden_tokens: + occurrences = text.count(token) + assert occurrences <= 1, ( + f"{token} appears {occurrences} times in {target}; " + "should only appear in FORBIDDEN_PRECISIONS guard list." + ) + if occurrences == 1: + assert "FORBIDDEN_PRECISIONS" in text, ( + f"{token} appears in {target} outside the FORBIDDEN_PRECISIONS guard" + ) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 0affca98..a20fd6f1 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,19 @@ This project follows the repository versioning rule: - `improvement` -> `+0.0.1`(bugfix + 小功能混合) - `bugfix` -> `+0.0.1` +## [0.49.0] — 2026-05-08 + +Released: 2026-05-08 + +### ✨ Features +- 新增统一地理位置解析 Pipeline,支持 SourceCoordinates / Nominatim / Registry / Inherit 多策略链式 resolver。 +- 新增 BGP 采集站与算力中心地理定位服务(`bgp_collector_locations`、`compute_center_locations`、`bgp_event_locations`)。 +- 新增 Docs Gatekeeper 带鉴权文档 API(`/api/v1/docs`),按用户权限动态返回文档目录与内容。 +- 新增 Earth 全球新闻栏(`/api/v1/news/earth-feed`),根据地球视角坐标推断地区并聚合多源 RSS 信息流。 +- Earth 新增 Mobile 算力中心国家高亮(`mobile-center-country-highlight.js`)。 + +--- + ## [0.48.0] — 2026-05-07 Released: 2026-05-07 diff --git a/docs/plans/README.md b/docs/plans/README.md index 31c25ac5..a31060b9 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -29,6 +29,8 @@ - [AIS 多源采集、冲突记录与聚合接口计划](/home/ray/dev/linkong/planet/docs/plans/earth-vessel-ais-aggregation-plan.md) - [earth-interactable-layer-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-interactable-layer-plan.md) - [frontend-public-docs-site-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-public-docs-site-plan.md) +- [Docs Gatekeeper 鉴权系统计划](/home/ray/dev/linkong/planet/docs/plans/docs-gatekeeper-auth-plan.md) +- [Location Resolver 共享管线计划](/home/ray/dev/linkong/planet/docs/plans/location-resolver-shared-pipeline-plan.md) - [frontend-ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-ai-playground-development-plan.md) - [ue5-mvp-fused-plan.md](/home/ray/dev/linkong/planet/docs/plans/ue5-mvp-fused-plan.md) diff --git a/docs/plans/docs-gatekeeper-auth-plan.md b/docs/plans/docs-gatekeeper-auth-plan.md new file mode 100644 index 00000000..2898cf52 --- /dev/null +++ b/docs/plans/docs-gatekeeper-auth-plan.md @@ -0,0 +1,92 @@ +# Docs Gatekeeper 鉴权系统计划 + +**状态**:已实现,当前行为见 [Docs Gatekeeper 开发说明](/home/ray/dev/linkong/planet/docs/technical/zh/docs-gatekeeper-development.md) +**创建日期**:2026-05-08 +**核心目标**:把 `/docs` 从前端公开打包 Markdown 改成后端受控读取,并通过用户 Gatekeeper 权限组划分公开文档、用户文档、开发文档和管理/运维文档。 + +## 背景 + +当前 Docs 页面通过前端 `import.meta.glob(...?raw)` 把 `docs/technical/{zh,en}` 中注册过的 Markdown 直接打进前端 bundle。即使在前端隐藏目录或增加路由守卫,受保护 Markdown 仍可能出现在构建产物中,无法形成真正鉴权。 + +本阶段需要把文档正文读取迁到后端,并让后端根据当前用户身份返回可见目录和正文。Earth 仍保持公开访问,其它控制台模块暂不改变既有鉴权。 + +## 鉴权模型 + +保留现有 `users.role`,新增 `gatekeeper_groups` 作为可叠加的权限组。`role` 继续用于控制台和系统操作;Gatekeeper 只负责 Docs 等内容权限。 + +默认权限: + +| 身份 | 默认 Docs 能力 | +| --- | --- | +| 未登录访客 | `public` | +| 普通登录用户 | `public`,以及用户被分配的 Gatekeeper 组 | +| `admin` | `docs_admin`,并隐含 `docs_developer` / `docs_user` | +| `super_admin` | 全部 Docs 权限 | + +Gatekeeper 组: + +- `docs_user`:登录用户操作类文档。 +- `docs_developer`:开发、前端、后端、Earth 实现文档。 +- `docs_admin`:运维、服务控制、凭证、环境变量和敏感操作文档。 + +## 初步文档划分 + +`public`: + +- `README.md` +- `quickstart.md` +- `manual.md` + +`docs_developer`: + +- `earth-frontend-context.md` +- `earth-interactable-usage.md` +- `earth-layer-style-reference.md` +- `earth-render-layer-order.md` +- `earth-satellite-footprint-policy.md` +- `earth-bgp-context.md` +- `earth-news-live-streams-collector-format.md` +- `earth-toolbar-overlay-coordination.md` +- `frontend-admin-frontend-context.md` +- `frontend-layout-guidelines.md` +- `backend-collectors.md` +- `datasource-collector-settings-connectivity.md` +- `backend-datasources-api-performance.md` +- `agents-aiprovider.md` + +`docs_admin`: + +- `backend-system-service-control.md` +- `ops-docker-compose-buildx-upgrade.md` +- `ops-planet-sh-startup.md` + +## 实施要点 + +后端新增: + +- `GET /api/v1/docs/catalog`:返回当前用户可见文档目录;未登录只返回 `public`。 +- `GET /api/v1/docs/{lang}/{slug}`:返回单篇 Markdown;未登录访问受保护文档返回 `401`,已登录无权限返回 `403`。 +- 服务端维护文档 metadata 白名单,禁止任意路径读取。 + +用户管理新增: + +- `users.gatekeeper_groups` JSON 字段。 +- 用户列表、创建和编辑支持展示/配置 Gatekeeper 权限组。 +- 只有 `super_admin` 能编辑 Gatekeeper 权限组。 + +前端 Docs 改造: + +- 移除 Markdown raw import 作为正文来源。 +- 从后端 catalog 构建目录和搜索记录。 +- 从后端 content API 加载正文。 +- 对 `401` 显示登录入口,对 `403` 显示无权限提示。 + +## 验证 + +- 未登录用户只能看到和读取 `public` 文档。 +- 未登录直接访问受保护文档返回 `401` 并显示登录提示。 +- 无 Gatekeeper 组的普通用户访问开发文档返回 `403`。 +- `docs_developer` 用户能读开发文档,不能读管理/运维文档。 +- `admin` 和 `super_admin` 能读管理/运维文档。 +- 未知 slug、未知语言和路径穿越字符串不能读取文件。 +- 前端构建产物不再包含受保护 Markdown raw import 生成的文档模块。 diff --git a/docs/plans/earth-mobile-center-country-highlight-plan.md b/docs/plans/earth-mobile-center-country-highlight-plan.md new file mode 100644 index 00000000..efd25978 --- /dev/null +++ b/docs/plans/earth-mobile-center-country-highlight-plan.md @@ -0,0 +1,252 @@ +# Earth Mobile Center Country Highlight Plan + +## Goal + +移动端打开 Earth 国界图层后,用屏幕中心,也就是当前镜头正对的地球表面位置,自动识别所在国家,并高亮该国家国界。 + +桌面端仍保持现有 hover 行为。移动端不引入新的国界渲染体系,而是复用已有 `country-boundaries.js` 的 GeoJSON 命中和 hover 高亮能力。 + +## Criteria for success + +1. 移动端 `layout-mode-mobile` 下,国界图层开启后,屏幕中心所在国家会自动高亮。 +2. 移动端旋转、缩放、巡航或自动旋转地球时,高亮会跟随镜头中心更新。 +3. 屏幕中心落在海洋或没有命中地球时,国家高亮会清除。 +4. 国界图层关闭时,不执行中心国家识别,也不显示残留高亮。 +5. 桌面端 pointer hover 行为保持不变。 +6. 移动端抽屉、搜索、设置、媒体、详情等前景 UI 打开时,不因为用户操作 UI 产生明显误高亮或抖动。 +7. 中心识别有节流或状态缓存,不把 GeoJSON point-in-polygon 检测放到无条件每帧高频执行。 +8. 实现后能通过本地静态检查或前端构建,并用移动端 viewport 手动或 Playwright 验证核心场景。 + +## Existing pieces + +当前项目已经具备大部分基础能力: + +- [frontend/public/earth/js/country-boundaries.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/country-boundaries.js) + - `updateCountryBoundaryHover(coords)`:根据 `{ lat, lon }` 命中国家并更新高亮线。 + - `clearCountryBoundaryHover()`:清除当前 hover 高亮。 + - `getShowCountryBoundaries()`:判断国界线图层是否可见。 +- [frontend/public/earth/js/utils.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/utils.js) + - `screenToEarthCoords(clientX, clientY, camera, earth, domElement)`:屏幕坐标 raycast 到地球表面。 + - `vector3ToLatLon(vector)`:地球本地坐标转经纬度。 +- [frontend/public/earth/js/constants.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/constants.js) + - `COUNTRY_BOUNDARY_CONFIG` 已定义普通国界线和 hover 国界线样式。 +- 移动端布局状态已经通过 `layout-mode-mobile` body class 区分。 + +因此本需求的核心不是新增图层,而是补一个移动端中心取点控制器。 + +## Non-goals + +- 不改变桌面端 hover 交互。 +- 不替换 `countries-admin0.min.geojson` 数据源。 +- 不新增后端 API。 +- 不把国家面填充做成新的 selected country 面状 shader。 +- 不为移动端增加永久准星 UI,除非后续产品明确需要视觉准星。 + +## Implementation plan + +### 1. Add a small mobile center hover controller + +新增一个轻量函数,建议放在现有主循环附近或单独模块,例如: + +```text +frontend/public/earth/js/mobile-center-country-highlight.js +``` + +建议导出: + +```js +updateMobileCenterCountryHighlight({ + camera, + earth, + renderer, + now, + isBlocked, +}); + +clearMobileCenterCountryHighlight(); +``` + +职责: + +1. 判断是否处于移动端。 +2. 判断国界图层是否开启。 +3. 判断当前是否被移动端前景 UI 阻塞。 +4. 对 renderer canvas 中心点做 raycast。 +5. 命中地球后转经纬度。 +6. 调用 `updateCountryBoundaryHover({ lat, lon })`。 +7. 无命中或禁用时调用 `clearCountryBoundaryHover()`。 + +### 2. Use canvas center, not window center + +中心点应基于 renderer canvas rect 计算: + +```js +const rect = renderer.domElement.getBoundingClientRect(); +const clientX = rect.left + rect.width / 2; +const clientY = rect.top + rect.height / 2; +``` + +这样在移动端安全区、地址栏变化、viewport resize 或 canvas 非全屏时仍然准确。 + +### 3. Convert center point into country hover coords + +复用已有工具: + +```js +const point = screenToEarthCoords(clientX, clientY, camera, earth, renderer.domElement); +if (!point) { + clearCountryBoundaryHover(); + return; +} + +const coords = vector3ToLatLon(point); +updateCountryBoundaryHover(coords); +``` + +注意:`screenToEarthCoords` 返回的是 earth local point,符合 `vector3ToLatLon` 的输入语义。 + +### 4. Gate updates by mobile and foreground UI state + +建议新增一个本地判断函数: + +```js +function isMobileCenterCountryHighlightBlocked() { + return ( + !document.body.classList.contains("layout-mode-mobile") || + document.body.classList.contains("earth-search-open") || + document.body.classList.contains("earth-settings-open") || + document.body.classList.contains("earth-media-open") || + document.body.classList.contains("earth-info-open") + ); +} +``` + +如果移动端抽屉只是半收起、且没有覆盖中心视野,可以继续允许中心高亮。若实际体验里抽屉展开会遮挡中心点,再把 drawer open 状态纳入阻塞条件。 + +### 5. Throttle and cache center updates + +GeoJSON polygon 命中不应该无条件每帧执行。 + +第一版建议: + +- `throttleMs = 120` +- 缓存上次经纬度,中心点变化小于 `0.05` 度时跳过。 +- 禁用、切回桌面、图层关闭、UI 阻塞时立即清除一次高亮。 + +伪代码: + +```js +if (now - lastUpdateAt < 120) return; +if (Math.abs(coords.lat - lastLat) < 0.05 && Math.abs(coords.lon - lastLon) < 0.05) return; +``` + +### 6. Wire into the Earth animation loop + +在 [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js) 的动画循环中调用: + +```js +updateMobileCenterCountryHighlight({ + camera, + earth, + renderer, + now: performance.now(), + isBlocked: isMobileCenterCountryHighlightBlocked(), +}); +``` + +这样自动旋转、手势旋转、缩放和巡航都会自然更新。 + +### 7. Keep desktop hover unchanged + +桌面 pointer hover 仍然走当前逻辑。 + +移动端中心高亮只在 `layout-mode-mobile` 下生效,不应该监听 pointer move,也不应该抢占 desktop hover 状态。 + +### 8. Optional visual tuning + +第一版复用: + +- `COUNTRY_BOUNDARY_CONFIG.hoverLineColor` +- `COUNTRY_BOUNDARY_CONFIG.hoverLineOpacity` +- `COUNTRY_BOUNDARY_CONFIG.hoverGlowOpacity` + +如果移动端体验太强,可以后续加独立配置: + +```js +mobileCenterHoverLineOpacity +mobileCenterHoverGlowOpacity +``` + +但第一版不建议过早分叉样式。 + +## Verification + +### Static checks + +1. `npm` 前端构建或现有 lint/typecheck 命令通过。 +2. `rg` 确认新增函数只在移动端路径调用,不影响桌面 pointer hover。 +3. `git diff --stat` 和目标文件 diff 确认改动范围集中。 + +### Manual mobile checks + +使用移动端 viewport,例如 390x844: + +1. 打开 Earth。 +2. 开启国界图层。 +3. 转动地球到中国、美国、澳大利亚等大块陆地区域,确认中心国家国界高亮。 +4. 转动到太平洋或印度洋,确认高亮消失。 +5. 缩放地球,确认高亮仍跟随中心点。 +6. 打开移动端搜索、设置、媒体或详情面板,确认没有明显误高亮或抖动。 +7. 切回桌面 viewport,确认 hover 仍由鼠标位置控制。 + +### Playwright smoke check + +如果已有 Playwright 流程,建议补一个移动端 smoke: + +1. 设置 viewport 为手机尺寸。 +2. 打开 Earth 页面。 +3. 开启国界图层。 +4. 等待国界数据加载。 +5. 截图确认中心附近国家边界有 hover 高亮线。 + +这个 smoke 不必断言具体国家名称,因为当前功能核心是视觉高亮;更稳定的自动化可以后续通过暴露 debug state 实现。 + +## Risks and mitigations + +### Polygon hit cost too高 + +风险:移动端设备上频繁 `featureContains` 可能带来卡顿。 + +缓解: + +- 使用 `120ms` 节流。 +- 经纬度变化小于阈值时跳过。 +- 后续如仍慢,再为 GeoJSON features 预计算 bbox,先 bbox 粗筛再 point-in-polygon。 + +### UI blocking state 不完整 + +风险:某些移动端前景 UI 没有对应 body class,中心点被遮挡但高亮仍更新。 + +缓解: + +- 第一版覆盖现有主要 class。 +- 验证时记录遗漏项,补充到 `isMobileCenterCountryHighlightBlocked()`。 + +### Desktop hover 被移动端状态污染 + +风险:移动端中心高亮和桌面 hover 共用 `_hoveredFeature` 状态。 + +缓解: + +- 只在 `layout-mode-mobile` 下运行中心高亮。 +- 切出 mobile 或图层关闭时调用一次 `clearCountryBoundaryHover()`。 +- 不改 `updateCountryBoundaryHover()` 的语义。 + +## Milestones + +1. 设计落地:完成本 plan,明确目标和验收标准。 +2. 最小实现:新增移动端中心取点 controller,并接入 animation loop。 +3. 性能保护:加入节流、经纬度阈值和禁用态清理。 +4. 验证:本地构建通过,移动端 viewport 手动检查通过。 +5. 调优:根据截图或真机体验微调阻塞条件和节流阈值。 + diff --git a/docs/plans/location-resolver-shared-pipeline-plan.md b/docs/plans/location-resolver-shared-pipeline-plan.md new file mode 100644 index 00000000..d54caa4e --- /dev/null +++ b/docs/plans/location-resolver-shared-pipeline-plan.md @@ -0,0 +1,127 @@ +# Location Resolver Shared Pipeline Plan + +**状态**:已实现,当前用户流程见 [Earth 位置候选采集使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/location-pipeline-user.md),开发接口见 [通用位置估算管线开发说明](/home/ray/dev/linkong/planet/docs/technical/zh/location-pipeline-development.md)。 + +## Goal + +把"给定一条记录,决定它的 lat/lon"这件事抽象成一条统一的可插拔管线,让算力中心、BGP 观测站、BGP 事件——以及未来任何需要位置估算的实体——共用同一套接口。新算法(peeringdb 设施查询、IXP 表、用户认领的精确点位等)通过实现一个 Resolver 类即可挂入,不需要改任何上层调用方。 + +## Background + +### 实施前现状 + +- **算力中心** (`backend/app/services/compute_center_locations.py`) 早期曾使用源坐标 → 本地 JSON 注册表 → 城市兜底 → Nominatim 在线地理编码。后续为避免硬编码位置污染事实链路,算力中心本地注册表已移除;主地图只使用源坐标,手动候选采集使用 ROR 和 Nominatim。 +- **BGP 观测站** (`collectors/bgp_common.py:RIPE_RIS_COLLECTOR_COORDS`) 是一张写死的字典,26 个 RIPE RIS collector 的城市级坐标。新增 collector / 升级到设施级精度都得改 Python。 +- **BGP 事件**继承所属 collector 的城市级坐标(`BGPObservation.collector_geo`)。 +- 用户原本以为 BGP 观测站位置是通过 iptoasn 推断的——其实 iptoasn 只用于前缀级国家归属(`bgp_enrichment.py`),不影响 marker 坐标。 + +### 痛点 + +1. 算力中心那条 4 层链路写死在算力中心模块里,BGP 想用得复制一遍。 +2. 三类实体各走各的坐标策略,缺统一抽象。 +3. 未来要插更精的算法(peeringdb / IXP / 用户认领),现在没有挂入点。 + +## Design + +### 接口契约 + +`backend/app/services/location/`: + +- `models.py` —— `LocationQuery`(输入)、`LocationCandidate`(候选)、`ResolverOutput`(单 resolver 输出)、`ResolutionResult`/`ResolutionDiagnostic`(管线最终结果) +- `pipeline.py` —— `LocationResolver` Protocol、`LocationPipeline` 编排器 +- `resolvers/source_coordinates.py` —— 记录自带 lat/lon 时直通 +- `resolvers/registry.py` —— 本地 JSON 注册表(locations + city_fallbacks),按别名得分 +- `resolvers/nominatim.py` —— 通用 Nominatim 客户端(rate-limited + LRU 缓存)+ 可注入 query plan +- `resolvers/inherit.py` —— 从外部回调取候选(事件继承 collector 用) +- `text.py` —— 文本规范化共享工具 + +核心 Protocol: + +```python +class LocationResolver(Protocol): + name: str + def resolve(self, query: LocationQuery) -> ResolverOutput: ... +``` + +`LocationPipeline.collect_candidates()` 跑全部 resolver,聚合所有候选,按 `(source_rank, precision_rank, -confidence)` 排序去重;`resolve_best()` 选 top 候选。 + +### 各领域管线 + +```python +# compute_center_locations.py(重构后,公共 API 不变) +COMPUTE_CENTER_PIPELINE = LocationPipeline([ + SourceCoordinatesResolver(), +]) + +COMPUTE_CENTER_COLLECTION_PIPELINE = LocationPipeline([ + SourceCoordinatesResolver(), + ROROrganizationResolver(), + NominatimResolver(query_plan_builder=_compute_center_query_plan, + geocoder=lambda q: _geocode_online(q)), +]) + +# bgp_collector_locations.py(新) +BGP_COLLECTOR_PIPELINE = LocationPipeline([ + SourceCoordinatesResolver(), + StoredCollectorLocationResolver(), +]) + +BGP_COLLECTOR_COLLECTION_PIPELINE = LocationPipeline([ + SourceCoordinatesResolver(), + NominatimResolver(query_plan_builder=_bgp_collector_query_plan, + geocoder=lambda q: _geocode_online(q)), +]) + +# bgp_event_locations.py(新) +BGP_EVENT_PIPELINE = LocationPipeline([ + SourceCoordinatesResolver(), + InheritFromAnotherEntityResolver(source_lookup=_inherit_from_owning_collector), + # 占位:将来插 ASNFacilityResolver / PrefixGeoResolver +]) +``` + +### 关键设计决策 + +1. **算力中心公共 API 完全不变**:`resolve_compute_center_location()`、`collect_location_candidates()`、`ComputeCenterLocation` dataclass、`_geocode_online` 模块级符号都保留,前端 / 上层调用方零改动;现有 19 个回归测试全绿。 +2. **`_geocode_online` 用 lambda 晚绑定**:`NominatimResolver(geocoder=lambda q: _geocode_online(q))` 能让测试 `monkeypatch.setattr(module, "_geocode_online", fake)` 继续生效。 +3. **`RIPE_RIS_COLLECTOR_COORDS` 自动从 DB-backed cache 重建**:启动时 seed/refresh `bgp_collector_locations` 维表,再原地刷新旧 `{rrcXX → {city, country, lat, lon}}` 字典。下游消费者(`bgp_collectors.py`、序列化、detector)不动即可获得新元数据。 +4. **修复隐藏 bug**:BGP collector 不再通过 registry/operator 模糊匹配晋升候选,避免 `operator="RIPE NCC"` 让每个事件都落到 `rrc00`。 +5. **事件继承走严格名字查询**:事件继承不跑 collector 的完整 pipeline,改成直接查 DB-backed cache。"改进位置"用户触发流程只跑源坐标和在线地理编码候选。 + +## Files + +### 新增 +- `backend/app/services/location/__init__.py` +- `backend/app/services/location/models.py` +- `backend/app/services/location/pipeline.py` +- `backend/app/services/location/text.py` +- `backend/app/services/location/resolvers/__init__.py` +- `backend/app/services/location/resolvers/source_coordinates.py` +- `backend/app/services/location/resolvers/registry.py` +- `backend/app/services/location/resolvers/nominatim.py` +- `backend/app/services/location/resolvers/inherit.py` +- `backend/app/services/bgp_collector_locations.py` +- `backend/app/services/bgp_event_locations.py` +- `backend/app/models/bgp_collector_location.py` +- `backend/tests/test_location_pipeline.py`(16 用例) +- `backend/tests/test_bgp_collector_locations.py`(11 用例) + +### 修改 +- `backend/app/services/compute_center_locations.py` —— 改为薄包装 +- `backend/app/services/collectors/bgp_common.py` —— 删除写死字典,改调 `resolve_bgp_event_geo_dict()` +- `backend/app/api/v1/bgp.py` —— 新增 `POST /api/v1/bgp/collectors/{collector_id}/collect-location` +- `frontend/public/earth/js/info-card.js` —— `renderComputeCenterCollectSection` → `renderLocationCollectSection`,BGP collector 走通用化路径 +- `frontend/public/earth/js/compute-centers.js` —— 新增通用 `collectLocationCandidates(endpoint, payload)` +- `frontend/public/earth/js/main.js` —— `previewComputeCenterCandidate` → `previewLocationCandidate`,事件名改为 `earth:preview-location-candidate` + +## Verification + +- `uv run pytest backend/tests/test_visualization_compute_centers.py` —— 19 个用例全绿(公共 API 未改) +- `uv run pytest backend/tests/test_location_pipeline.py backend/tests/test_bgp_collector_locations.py` —— 16 + 11 用例全绿 +- 抽象可插拔性测试:`test_pluggability_custom_resolver_works_without_changing_pipeline` —— 临时实现 `_PeeringDBStubResolver` 直接接入 `LocationPipeline`,验证管线不需要改一行就能识别新 source + +## Out of scope + +- 持久化用户认领的精确坐标(写回 JSON 注册表)—— `suggested_registry_entry` 字段已就绪,工作流单独立项 +- 真正实现 `ASNFacilityResolver` / `PrefixGeoResolver` —— 接口已留好,具体算法(peeringdb / IXP 表 / iptoasn 升级)单独立项 +- 算力中心 / 观测站 marker 合并避让 —— 上一轮已用 `SURFACE_AVOIDANCE_PROFILES.city` + halo 收敛解决 diff --git a/docs/technical/en/README.md b/docs/technical/en/README.md index 61e5e71d..06b9065b 100644 --- a/docs/technical/en/README.md +++ b/docs/technical/en/README.md @@ -25,8 +25,12 @@ What belongs here: - [Quickstart](/home/ray/dev/linkong/planet/docs/technical/en/quickstart.md): The shortest path to getting Planet running from scratch - [Planet Manual](/home/ray/dev/linkong/planet/docs/technical/en/manual.md): Complete usage guide for the console, `planet.sh`, Earth, and Docs +- [Earth Location Candidate Collection User Guide](/home/ray/dev/linkong/planet/docs/technical/en/location-pipeline-user.md): Collect and preview coordinate candidates for compute centers and BGP collectors on Earth - [Collector Settings and Connectivity Validation](/home/ray/dev/linkong/planet/docs/technical/en/datasource-collector-settings-connectivity.md): Data source catalog, collector settings, connectivity validation, and BarentsWatch credentials +- [Shared Location Resolution Pipeline Development Guide](/home/ray/dev/linkong/planet/docs/technical/en/location-pipeline-development.md): Backend location resolver / pipeline interfaces, registries, and extension points +- [Docs Gatekeeper Development Guide](/home/ray/dev/linkong/planet/docs/technical/en/docs-gatekeeper-development.md): Backend Docs catalog, Markdown content loading, and Gatekeeper permission groups - [Earth Interactable Usage](/home/ray/dev/linkong/planet/docs/technical/en/earth-interactable-usage.md): API, lifecycle, and integration examples for Earth surface icon Interactable +- [Earth Toolbar and Overlay Coordination](/home/ray/dev/linkong/planet/docs/technical/en/earth-toolbar-overlay-coordination.md): Closing matrix and integration rules for toolbar buttons, search, settings, news, and layer overlays What does not belong here: diff --git a/docs/technical/en/backend-collectors.md b/docs/technical/en/backend-collectors.md index 880d5360..edbea3b2 100644 --- a/docs/technical/en/backend-collectors.md +++ b/docs/technical/en/backend-collectors.md @@ -87,6 +87,10 @@ async def run(self, db): | BarentsWatch AIS | vessel | AIS vessel positions, speed, heading, MMSI, and related fields | Collector settings | | AISStream Vessels | vessel_ais | AIS WebSocket realtime stream, written to the raw observation layer and displayed through aggregation | Collector settings | +AIS vessel collectors use a different persistence path from regular `CollectedData` collectors. BarentsWatch, AISStream, and custom `vessel_ais` sources write into the AIS raw observation layer first, then the aggregation service merges those observations into the GeoJSON and detail payloads used by the Earth vessel layer. This preserves source, transport, field conflicts, and observation time instead of letting one realtime source overwrite the final display table. + +TOP500 and Epoch AI compute sources do not always provide usable coordinates. The unified Earth compute-center endpoint uses only valid source-provided coordinates or `compute_center_locations` dimension-table coordinates during the main map startup path; records without coordinates are returned as `unresolved` instead of being rendered from a local registry, country centroid, or guessed city. When users manually collect candidates, the backend queries ROR and Nominatim/OpenStreetMap from source fields; accepted candidates are saved into `compute_center_locations` and rendered from that table on the next layer refresh. + ## IV. Data Format (stored in CollectedData table) ```python @@ -213,13 +217,136 @@ backend/app/services/collectors/ ├── epoch_ai.py # Epoch AI collector ├── huggingface.py # HuggingFace collector ├── peeringdb.py # PeeringDB collector -└── telegeraphy.py # TeleGeography submarine cable collector +├── telegeraphy.py # TeleGeography submarine cable collector +├── vessel_ais.py # BarentsWatch AIS vessel collector +└── aisstream.py # AISStream WebSocket vessel collector + +backend/app/services/ +├── custom_datasource_runtime.py # Custom REST / WebSocket mapping runtime +├── datasource_mapping.py # Deterministic field mapping and target writes +├── vessel_ais_aggregation.py # AIS raw observation writes and aggregate reads +├── vessel_aggregation_strategy.py # Multi-source field selection, freshness fallback, and conflict records +└── vessel_enrichment.py # Vessel profile enrichment cache backend/app/models/ -└── collected_data.py # Unified data model +├── collected_data.py # Unified data model +└── vessel_enrichment.py # Vessel enrichment cache ``` -## IX. Data Usage +## IX. Credentialed Collectors + +Some collectors require external service credentials: + +| Collector | Credential provider | Credential sources | +| --- | --- | --- | +| `barentswatch_vessels` | `barentswatch` | Console collector settings, environment variables, `~/.zshrc` | +| `aisstream_vessels` | `aisstream` | Console collector settings, environment variables, `~/.zshrc` for connectivity checks; save it in collector settings or inject it into the backend environment for collection | +| `spacetrack_tle` | `spacetrack` | Environment variables, `~/.zshrc` | + +### BarentsWatch AIS + +BarentsWatch AIS credential resolution is centralized in: + +- [barentswatch.py](/home/ray/dev/linkong/planet/backend/app/services/barentswatch.py) + +`VesselAISCollector` only collects and transforms AIS data. It no longer reads environment variables or builds token requests directly. It uses: + +- `resolve_barentswatch_config()` +- `fetch_barentswatch_access_token()` + +Resolution priority: + +1. `DataSourceConfig.auth_config` +2. `DataSourceConfig.config` +3. Environment variables +4. `~/.zshrc` + +Supported variables: + +```bash +export BARENTSWATCH_CLIENT_ID="..." +export BARENTSWATCH_CLIENT_SECRET="..." +``` + +Historical misspellings are also supported: + +```bash +export BARRENTSWATCH_CLIENT_ID="..." +export BARRENTSWATCH_CLIENT_SECRET="..." +``` + +Connectivity validation requests `https://id.barentswatch.no/connect/token` for an access token with `scope=ais`, then requests the AIS endpoint with `Authorization: Bearer `. + +### AISStream Realtime Vessels + +AISStream uses the `wss://stream.aisstream.io/v0/stream` WebSocket endpoint. Its default runtime is a long-lived realtime collector rather than the traditional REST pattern of one request, progress to 100%, then completion. + +Runtime configuration: + +- `api_key`: read first from `DataSourceConfig.auth_config.api_key` or `config.api_key`; it can also come from the backend process environment variable `AISSTREAM_API_KEY`. +- `bounding_boxes`: AISStream subscription bounds. The default example is global `[[[-90, -180], [90, 180]]]`; demos and production runs should usually start with a smaller area. +- `message_types`: defaults to `PositionReport` and `ShipStaticData`. +- `streaming_enabled`: enables long-lived streaming by default; disabling it falls back to batch-style `fetch -> transform -> save`. +- `streaming_max_messages`: test-only stop limit. Non-zero values stop the stream after the requested number of messages. +- `reconnect_delay_seconds` and `receive_timeout_seconds`: control reconnect delay and idle receive waits. + +State semantics: + +- `connecting`: connecting to AISStream. +- `streaming`: receiving realtime messages; `records_processed` means messages seen, usually without a fixed total or percentage. +- `reconnecting`: upstream or network interruption; the collector records `AISSourceHealth` and waits before reconnecting. +- `stopped` / `cancelled`: stopped by a test limit or user action. + +AISStream connectivity validation reads the saved collector configuration, environment variables, and `AISSTREAM_API_KEY` in `~/.zshrc` through `datasource_connectivity.py`. For actual collection, the most reliable path is saving the API key in `Settings -> Collector Settings -> AISStream Vessels`; if the key only lives in `~/.zshrc`, confirm that the backend process inherited it. + +### AIS Raw Observations And Aggregation + +AIS observations do not directly replace final vessel records. They are first saved as raw observations: + +- `source` records the origin, such as `barentswatch_vessels`, `aisstream_vessels`, or a custom source name. +- `delivery_mode` captures realtime quality; `realtime_stream` outranks `polling`. +- `transport` records `websocket` or `http`. +- Dynamic fields such as position, speed, and course are selected by freshness and source priority. +- Static fields prefer non-empty values; conflicting candidates are recorded for detail and diagnostics views. + +Earth still reads vessel data from: + +```http +GET /api/v1/visualization/geo/vessels +GET /api/v1/visualization/vessels/{mmsi} +GET /api/v1/visualization/vessels/{mmsi}/track +GET /api/v1/visualization/vessels/{mmsi}/conflicts +GET /api/v1/visualization/vessels/aggregation/diagnostics +``` + +`/geo/vessels` merges raw observation aggregation with the legacy BarentsWatch latest-position tables so adding AISStream does not hide historical BarentsWatch-only vessels. + +## X. Collector Settings And Connectivity Validation + +The console "Collector Settings" page owns endpoint, headers, timeouts, retries, and credentials for all built-in collectors. Connectivity is derived by the backend checksum rather than by frontend button styling: + +- endpoint +- auth type +- headers +- config +- credential provider +- credential fingerprint + +Related APIs: + +```http +GET /api/v1/datasources/configs/all +POST /api/v1/datasources/configs/builtin/connection-status +POST /api/v1/datasources/configs/builtin/connect +POST /api/v1/settings/integrations/barentswatch/connect +GET /api/v1/settings/credential-guides/{provider} +POST /api/v1/settings/credential-guides/{provider}/generate +POST /api/v1/settings/credential-guides/{provider}/reset +``` + +See [Collector Settings and Connectivity Validation](/home/ray/dev/linkong/planet/docs/technical/en/datasource-collector-settings-connectivity.md) for the full flow. + +## XI. Data Usage Collected data ultimately: @@ -227,7 +354,7 @@ Collected data ultimately: 2. **Situational analysis** — global compute distribution statistics and growth trends 3. **Alert system** — detects changes to important nodes -## X. Collector Registration +## XII. Collector Registration Collectors are automatically registered at application startup: @@ -249,7 +376,7 @@ collector_registry.register(TeleGeographyCableSystemCollector()) **Core file**: `backend/app/services/collectors/registry.py` -## XI. Triggering Collection +## XIII. Triggering Collection ### Method 1: Scheduled diff --git a/docs/technical/en/backend-datasources-api-performance.md b/docs/technical/en/backend-datasources-api-performance.md new file mode 100644 index 00000000..06b192db --- /dev/null +++ b/docs/technical/en/backend-datasources-api-performance.md @@ -0,0 +1,99 @@ +# DataSources List API Performance Optimization + +## Background + +`GET /api/v1/datasources` is the core API for the Data Sources page. Slow responses directly block page rendering. + +## Query Path Before Optimization + +`_load_datasource_list_context` used to run these queries sequentially: + +| Order | Function | Query | Bottleneck | +| --- | --- | --- | --- | +| 1 | `_load_latest_running_tasks` | `collection_tasks` window query; stale check depends on this result | Must be serial | +| 2 | `_load_latest_completed_tasks` | `collection_tasks` window query for latest completed tasks | Serial wait | +| 3 | `_load_datasource_data_counts` | `COUNT(*) GROUP BY source` on `collected_data` | Slow full-table scan | +| 4 | `_load_datasource_endpoint_overrides` | Simple `datasource_configs` SELECT | Serial wait | + +## Phase 1: Parallelization + +The independent queries 2, 3, and 4 were moved to `asyncio.gather` with separate sessions: + +```python +async def _fetch_completed(): + async with async_session_factory() as s: + return await _load_latest_completed_tasks(s, datasource_ids) + +async def _fetch_counts(): + async with async_session_factory() as s: + return await _load_datasource_data_counts(s, sources) + +async def _fetch_overrides(): + async with async_session_factory() as s: + return await _load_datasource_endpoint_overrides(s, sources) + +completed_tasks, data_counts, endpoint_overrides = await asyncio.gather( + _fetch_completed(), _fetch_counts(), _fetch_overrides(), +) +``` + +SQLAlchemy `AsyncSession` does not support concurrent use from multiple coroutines, so every parallel branch needs its own session. + +## Phase 2: Remove Heavy Queries + +### Remove `_load_datasource_data_counts` + +`data_count` was only used by the frontend to show an edge-case `(0 records)` hint in the latest collection column. It was not worth keeping a `COUNT(*) GROUP BY` full-table scan. + +- Frontend `(0 records)` display logic was removed. +- `data_count` was removed from the `BuiltInDataSource` interface. + +### Remove `_load_latest_completed_tasks` + +`last_status` and `last_run_at` are already written to the `DataSource` model when collectors finish, so the list endpoint no longer needs to join `collection_tasks`: + +```python +# Before: completed_tasks query required +last_run_at = datasource.last_run_at or (last_task.completed_at if last_task else None) +last_status = datasource.last_status or (last_task.status if last_task else None) + +# After: read model fields directly +last_run_at = datasource.last_run_at +last_status = datasource.last_status +``` + +`last_records_processed` was removed as well because it came from completed task rows and is not displayed in the list. + +## Query Path After Optimization + +```text +datasources SELECT -> required primary data +_load_latest_running_tasks -> required for running state and stale check +_load_datasource_endpoint_overrides -> required for endpoint overrides and collector settings display +``` + +The endpoint now runs three queries instead of five. The last two run sequentially because running tasks are needed for stale checks and endpoint overrides are lightweight. + +## Frontend `triggerDatasource` Double Refresh Fix + +`triggerDatasource` previously called `fetchData()` twice: + +```typescript +// Before +} else { + window.setTimeout(() => { fetchData() }, 800) +} +fetchData() + +// After: mutually exclusive +if (res.data.task_id) { + fetchData() +} else { + window.setTimeout(fetchData, 800) +} +``` + +## Related Files + +- [datasources.py](/home/ray/dev/linkong/planet/backend/app/api/v1/datasources.py): `_load_datasource_list_context`, `list_datasources` +- [DataSources.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/DataSources/DataSources.tsx): `BuiltInDataSource`, `triggerDatasource` diff --git a/docs/technical/en/datasource-collector-settings-connectivity.md b/docs/technical/en/datasource-collector-settings-connectivity.md index 27c83619..40193768 100644 --- a/docs/technical/en/datasource-collector-settings-connectivity.md +++ b/docs/technical/en/datasource-collector-settings-connectivity.md @@ -62,7 +62,7 @@ File: Current behavior: - The `collector_credentials` tab is displayed as "Collector Settings". -- A select lists all built-in collectors. +- A select lists built-in collectors and supports maintaining custom supplemental sources that merge into built-in data. - The only button beside the select is a plug icon for health checks. - Status tags below the select show: - `Credentials required` / `No credentials required` @@ -72,6 +72,8 @@ Current behavior: - Whether the endpoint is overridden - Collectors that require credentials place the credential card above base configuration. - Collectors without credentials only show base configuration. +- The AISStream collector uses WebSocket semantics: connecting, streaming, reconnecting, or stopped. It does not use a fixed completion percentage. +- Custom source editing lives in collector settings. The data source catalog keeps overview, run controls, and read-only drawers. The connection button uses an inline Tabler-style plug icon with `plug-connected` semantics, avoiding the older refresh icon for a connection action. @@ -284,6 +286,100 @@ Normalization: - Vessel type usually comes from lower-frequency `ShipStaticData.Type`; the backend maps AIS numeric type codes to Cargo / Tanker / Passenger / Fishing / Military. - If a vessel has not yet produced a static message, its aggregated type can still be `Other`; v5 vessel profile enrichment is planned to fill that gap. +Connectivity validation reads saved configuration, environment variables, and `AISSTREAM_API_KEY` from `~/.zshrc`. For actual collection, prefer saving the API key in collector settings. If the key only lives in `~/.zshrc`, confirm that the backend process inherited it; otherwise validation may pass while the collector runtime cannot read the key. + +## Custom REST / WebSocket Mapping Runtime + +Files: + +- [custom_datasource_runtime.py](/home/ray/dev/linkong/planet/backend/app/services/custom_datasource_runtime.py) +- [datasource_mapping.py](/home/ray/dev/linkong/planet/backend/app/services/datasource_mapping.py) + +Custom sources are supplemental inputs for existing target schemas, not isolated data islands. The most complete target today is `vessel_ais`: a custom REST or WebSocket source is mapped deterministically, written into AIS raw observations, and then pushed to Earth through the `vessels` WebSocket channel. + +### Configuration Semantics + +Important fields: + +- `source_type`: `rest` / `http` / `websocket` / `ws`. +- `endpoint`: REST uses `http(s)://`; WebSocket uses `ws(s)://`. +- `auth_type`: `none`, `bearer`, `api_key`, or `basic`. +- `headers`: static request headers. +- `auth_config`: token, API key, or basic username/password; API keys can be sent by header or query. +- `config.target_schema`: for example `vessel_ais`. +- `config.delivery_mode`: REST defaults to `polling`; WebSocket defaults to `realtime_stream`. +- `config.merge_target_source`: records which built-in source this custom source supplements, such as `barentswatch_vessels`. + +The REST runner supports: + +- `GET` / `POST` +- query params +- JSON body +- headers and auth injection +- active mapping writes into the target schema + +The WebSocket runner supports: + +- endpoint format validation +- headers and auth injection +- optional `ws_subscribe_message` +- `ws_message_path` / `ws_items_path` extraction +- reconnects +- `debug_max_messages` debug limits +- background stream start / stop / status + +Related APIs: + +```http +POST /api/v1/datasources/custom/sample +GET /api/v1/datasources/target-schemas +POST /api/v1/datasources/{config_id}/run-mapped +POST /api/v1/datasources/{config_id}/stop-mapped +GET /api/v1/datasources/{config_id}/mapped-status +DELETE /api/v1/datasources/configs/{config_id}?delete_mappings=true&delete_source_data=true +``` + +`run-mapped?background=true` only matters for WebSocket sources and starts a background stream. REST sources remain one-shot collection runs. + +### Delete And Data Cleanup + +Deleting a custom source has three levels: + +- Delete configuration only: preserve mapping and historical data. +- Delete configuration and mapping: also delete mapping templates for that config. +- Delete configuration, mapping, and source data: delete that source's `collected_data`, `ais_raw_observations`, and `ais_source_health`. + +When deleted `vessel_ais` source data affects Earth, the backend broadcasts `reload_required` on the `vessels` channel so Earth reloads aggregated vessels. Legacy `vessel_position` rows are not deleted by custom source because that table cannot safely attribute rows back to a custom source. + +### Local AIS Mock WebSocket + +File: + +- [mock-ais-ws-server.ts](/home/ray/dev/linkong/planet/scripts/mock-ais-ws-server.ts) + +Run: + +```bash +bun run mock:ais-ws +``` + +The mock service continuously sends AIS-like JSON to validate the chain: WebSocket custom source -> mapping -> AIS raw observation -> `vessels` channel -> Earth vessel upsert. Typical config: + +```json +{ + "source_type": "websocket", + "endpoint": "ws://localhost:8787", + "config": { + "target_schema": "vessel_ais", + "delivery_mode": "realtime_stream", + "merge_target_source": "barentswatch_vessels", + "ws_message_path": "$.data", + "ws_items_path": "$.vessels[*]", + "ws_reconnect": true + } +} +``` + ## Credential Guide File: @@ -345,6 +441,7 @@ Added coverage: Credential providers currently supported: - `barentswatch` +- `aisstream` - `spacetrack` Other collectors with `requires_credentials=true` return that their credential chain has not been wired yet, and the frontend shows `Unavailable`. diff --git a/docs/technical/en/docs-gatekeeper-development.md b/docs/technical/en/docs-gatekeeper-development.md new file mode 100644 index 00000000..fb1c3f01 --- /dev/null +++ b/docs/technical/en/docs-gatekeeper-development.md @@ -0,0 +1,116 @@ +# Docs Gatekeeper Development Guide + +Docs Gatekeeper moves `/docs` from "bundle all Markdown into the frontend" to "return catalog and content from the backend according to permissions." Its goal is to keep public manuals, user docs, developer docs, and admin/ops docs in one searchable Docs page while making every protected Markdown body pass through a server-side whitelist and authorization check. + +For the user workflow, see the Docs section in [Planet Manual](/home/ray/dev/linkong/planet/docs/technical/en/manual.md). + +## Authorization Model + +Docs uses two permission layers: + +- `users.role`: preserved for console/system permissions. +- `users.gatekeeper_groups`: Docs content permission groups. + +Groups: + +| Group | Purpose | +| --- | --- | +| `docs_user` | User-operation docs | +| `docs_developer` | Earth, frontend, backend, collector, and AI Provider development docs | +| `docs_admin` | Service control, operations, environment, and sensitive-operation docs | + +Inheritance: + +- Anonymous users can only read `public`. +- `docs_developer` includes `docs_user`. +- `docs_admin` includes `docs_developer` and `docs_user`. +- `admin` and `super_admin` receive all Docs permissions by default. + +## Backend Entry Points + +Files: + +- [docs.py](/home/ray/dev/linkong/planet/backend/app/api/v1/docs.py) +- [docs_gatekeeper.py](/home/ray/dev/linkong/planet/backend/app/services/docs_gatekeeper.py) +- [user.py](/home/ray/dev/linkong/planet/backend/app/models/user.py) +- [users.py](/home/ray/dev/linkong/planet/backend/app/api/v1/users.py) + +APIs: + +```http +GET /api/v1/docs/catalog +GET /api/v1/docs/{lang}/{slug} +``` + +`catalog` returns only documents visible to the current user. The content endpoint validates language, slug, and file existence through the metadata whitelist before checking access: + +- Anonymous protected-doc request: `401`. +- Authenticated but insufficient permissions: `403`. +- Unknown language, unknown slug, or missing file: `404`. + +Markdown bodies can only come from whitelisted files under `docs/technical/{zh,en}/`; arbitrary path reads are not allowed. + +## Metadata Source + +Server-side metadata lives in [docs_gatekeeper.py](/home/ray/dev/linkong/planet/backend/app/services/docs_gatekeeper.py): + +```python +DocsMetadata( + "manual.md", + "manual", + "public", + "Manual", + 2, + "Planet 使用手册", + "Planet Manual", +) +``` + +When adding a public technical doc: + +- Add both Chinese and English Markdown files. +- Add filename, slug, access, group, order, and titles to server `DOCS_METADATA`. +- Add matching metadata to frontend [docs-content.ts](/home/ray/dev/linkong/planet/frontend/src/pages/Docs/docs-content.ts) so navigation titles and sorting stay aligned. +- Update `docs/technical/zh/README.md` and `docs/technical/en/README.md` when the document should be discoverable from the README. + +## User Management + +The `users` table has `gatekeeper_groups JSONB DEFAULT '[]'`. Startup [session.py](/home/ray/dev/linkong/planet/backend/app/db/session.py) applies `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` for existing local databases. + +The user API: + +- Writes `gatekeeper_groups` during user creation. +- Validates group names on update: only `docs_user`, `docs_developer`, and `docs_admin` are accepted. +- Allows only `super_admin` to modify Gatekeeper groups. + +Frontend [Users.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Users/Users.tsx) displays group tags and provides a multi-select in the edit form. Non-`super_admin` users see the field disabled, and submission removes `gatekeeper_groups` before sending. + +## Frontend Docs Loading + +Files: + +- [Docs.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Docs/Docs.tsx) +- [docs-content.ts](/home/ray/dev/linkong/planet/frontend/src/pages/Docs/docs-content.ts) +- [docs-search.ts](/home/ray/dev/linkong/planet/frontend/src/pages/Docs/docs-search.ts) + +Key changes: + +- Remove `import.meta.glob(...?raw)` as the Markdown content source. +- Load `/api/v1/docs/catalog` to build the visible navigation. +- Load `/api/v1/docs/{lang}/{slug}` for document bodies. +- Index search only across currently visible docs, loading Markdown from the backend as needed. +- Show login state for `401`, permission state for `403`, and unavailable-doc state for `404`. + +## Test Coverage + +Relevant tests: + +- [test_docs_gatekeeper.py](/home/ray/dev/linkong/planet/backend/tests/test_docs_gatekeeper.py) + +Tests should cover: + +- Anonymous users only see public docs. +- Protected content returns `401` or `403` appropriately. +- `docs_developer` can read developer docs but not admin docs. +- `admin` and `super_admin` can read admin docs. +- Unknown slugs, unknown languages, and path traversal strings cannot read files. diff --git a/docs/technical/en/earth-frontend-context.md b/docs/technical/en/earth-frontend-context.md index 785a3cbc..7747cffa 100644 --- a/docs/technical/en/earth-frontend-context.md +++ b/docs/technical/en/earth-frontend-context.md @@ -99,7 +99,7 @@ Responsibilities: - [vessels.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/vessels.js) - [bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) - [bgp-cruise-adapter.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp-cruise-adapter.js) -- [compute-centers.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/compute-centers.js) +- [compute-centers.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/compute-centers.js) renders supercomputer and GPU-cluster markers. The backend renders compute centers only from source-provided coordinates or `compute_center_locations` dimension-table coordinates during startup; manual candidate collection can query ROR and Nominatim/OpenStreetMap, and the layer keeps the `?` badge for unconfirmed positions while the details card shows precision, confidence, source notes, and verification date. - [country-boundaries.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/country-boundaries.js) Each module is responsible for its own: @@ -109,6 +109,8 @@ Each module is responsible for its own: - State tracking (loaded, visible, hover, locked) - Self-cleanup (dispose on scene destroy) +The compute-center layer row has a notification badge for GeoJSON `unresolved` records. The badge means "no trustworthy coordinates, cannot render on the globe"; it is different from the `?` marker drawn on already positioned but unconfirmed compute centers. Clicking the badge opens a fixed info card beside the layer panel. Row-level `采集` fetches candidates only. Header-level `一键采用` processes the queue top-to-bottom, saves the highest-confidence valid candidate, removes successful rows, renumbers the list, and dispatches `earth:compute-center-unresolved-count-change` so the badge updates immediately. When the batch ends, `earth:compute-center-location-saved` refreshes the real layer. + ### AIS Vessel Layer The vessel layer fetches `/api/v1/visualization/geo/vessels` and renders the aggregated AIS GeoJSON through `createInteractableLayer()`. By default it does not send a `limit` parameter, and `VESSEL_CONFIG.maxRenderedMarkers = 0` means the frontend does not clip the result to 5000 vessels. A positive `options.limit` or positive `maxRenderedMarkers` can still be used as an explicit temporary cap. @@ -154,6 +156,8 @@ Layer toggle buttons use `data-status-target` attributes to link button state to This is the canonical way to synchronize button visual state with actual layer state. Do not maintain separate boolean flags for button display. +Terrain should not block startup when it is not the restored visible layer. After deferred layer visibility settings are applied, `controls.js` schedules `scheduleTerrainPrefetch()` only when HD texture is enabled, terrain is not ready, and no prefetch is already running. The prefetch uses `setTimeout` plus `requestIdleCallback` so cloud, HD texture, and startup layer work keep first-screen priority. + ## Current Settings Persistence Earth settings are stored in `localStorage`. The key is typically a namespaced string defined in `constants.js`. `controls.js` handles read, write, and reset. @@ -171,6 +175,8 @@ Settings that affect visual layers (terrain opacity, day/night mode, satellite d When HD texture is off, terrain is temporarily hidden and its state is remembered. When HD texture comes back on, terrain restores its prior visibility. +Terrain tile fetching is batched. `terrain.js` deduplicates required Terrarium tile keys and sends chunks sized by `TERRAIN_CONFIG.batchRequestSize` to `/api/v1/visualization/terrain/terrarium/batch`. The backend proxies S3 Terrarium tiles with an in-memory LRU cache, per-batch deduplication, and bounded concurrency. The single tile endpoint remains for fallback paths and browser cache semantics. + ## Current High-Frequency Risk Points ### 1. Visual State and Business State Out of Sync diff --git a/docs/technical/en/earth-toolbar-overlay-coordination.md b/docs/technical/en/earth-toolbar-overlay-coordination.md new file mode 100644 index 00000000..7e92050c --- /dev/null +++ b/docs/technical/en/earth-toolbar-overlay-coordination.md @@ -0,0 +1,86 @@ +# Earth Toolbar And Overlay Coordination + +This document describes the current coordination rules between the Earth toolbar buttons and the search panel, settings modal, news/live panel, and layer panel. Use this matrix when changing interactions, adding buttons, or adjusting panels so one action does not close an unrelated overlay. + +Related entries: + +- [Earth Frontend Context](/home/ray/dev/linkong/planet/docs/technical/en/earth-frontend-context.md) +- [Frontend Layout Guidelines](/home/ray/dev/linkong/planet/docs/technical/en/frontend-layout-guidelines.md) + +## Toolbar Button Directory + +The toolbar is marked by `.earth-toolbar-btn` in [index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html): + +| ID | Title | Type | Overlay / action | +|----|-------|------|------------------| +| `layer-action` | Layers | Overlay toggle | HUD panel `layer-toggles` on desktop / mobile drawer `layers` card | +| `search-action` | Search | Overlay toggle | Search panel on desktop / mobile drawer `search` card | +| `rotate-toggle` | Auto rotate | Standalone toggle | No overlay | +| `toggle-tv` | News live | Overlay toggle | Media panel `media-panel` with TV and News tabs | +| `reload-data` | Reload data | Standalone action | No overlay | +| `zoom-trigger` | Zoom control | Floating menu | Zoom floating menu | +| `settings-trigger` | Settings | Overlay toggle | Settings modal on desktop / mobile drawer `settings` card | +| `reset-view` | Reset view | Standalone action | No overlay | +| `layout-toggle` | Maximize layout | Standalone toggle | No overlay | + +## Shared Coordination Entry Point + +[controls.js::closeTransientMobileOverlays](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js) is the shared coordinator for deciding what should close when an overlay opens. + +Every path that opens a fullscreen-style overlay calls `closeTransientMobileOverlays({ except })`, where `except` names the overlay that should stay open: + +```js +closeTransientMobileOverlays({ except: "search" }); +closeTransientMobileOverlays({ except: "settings" }); +closeTransientMobileOverlays({ except: "media" }); +closeTransientMobileOverlays({ except: "layer-toggles" }); +``` + +Current `except` values are `"search"`, `"settings"`, `"media"`, `"layer-toggles"`, or omitted to close all transient overlays. + +## Close Matrix + +`close` means the overlay closes; `keep` means it remains open. + +| Action | Search | Settings | Mobile layers drawer | News/live | +|--------|:------:|:--------:|:--------------------:|:---------:| +| Open search (`except: "search"`) | self | close | close | keep | +| Open settings (`except: "settings"`) | close | self | close | keep | +| Open news/live (`except: "media"`) | close | close | close | self | +| Open mobile layers (`except: "layer-toggles"`) | close | close | self | close | +| Close all (`except: null`) | close | close | close | close | + +Examples: + +- Clicking toolbar Settings closes search and the mobile layer drawer, but keeps news/live open. +- Clicking toolbar Layers on mobile opens the `layers` drawer and closes search, settings, and news. +- Clicking News Live closes search, settings, and the layer drawer, then toggles the media panel. + +## Design Rules + +1. **Floating menus such as `zoom-trigger` are not overlays.** They use `bindFloatingMenu` and are managed separately by `closeFloatingMenus()`. Opening any overlay first closes floating menus. +2. **Desktop `layer-toggles` is a persistent HUD panel.** `closeTransientMobileOverlays` only closes it when `activeMobileDrawerId === "layer-toggles"`, so desktop search, settings, and news do not disturb the layer panel. +3. **News/live is independent from settings.** Users often adjust collector settings while watching news, so opening settings does not close the media panel. This became an invariant after the May 2026 coordination patch. +4. **Search and news are both primary information overlays.** Search opens without closing news, and news opens without closing search. If product direction changes, update both sides in `closeTransientMobileOverlays` so the matrix stays symmetric. +5. **Mobile drawers are fullscreen-focus states.** Any mobile drawer, whether layers, search, or settings, uses `setMobileDrawerState` and closes other overlays. +6. **Escape has a fixed close order.** See [controls.js::setupKeyboardControls](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js): search, settings, mobile drawer, floating menu, toolbar hub, locked object. + +## Adding A Button Or Overlay + +1. Add the button in the `.earth-toolbar` container in [index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html), using the existing `floating-btn liquid-glass-surface earth-toolbar-btn` class pattern. +2. Decide whether it is a standalone action, a floating menu, or a mutually coordinated overlay. +3. For a coordinated overlay, call `closeTransientMobileOverlays({ except: "" })` when opening it. +4. Add the reciprocal close branch inside `closeTransientMobileOverlays`, so other overlays can close yours. +5. If the new overlay should coexist with an existing overlay, exclude that peer on both sides of the matrix. +6. Add an Escape close path in `setupKeyboardControls`. +7. On mobile, use `setMobileDrawerState({ open: true, card: "" })` for drawer-style panels. + +## Current Implementation Locations + +- Coordinator: [controls.js::closeTransientMobileOverlays](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js) +- Settings overlay: [controls.js::openSettingsModal / closeSettingsModal](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js) +- Search overlay: [controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js), imported from the search module +- News/live overlay: [tv.js::setTVPanelVisible](/home/ray/dev/linkong/planet/frontend/public/earth/js/tv.js), with the News tab in [news.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/news.js) +- Mobile layer drawer: [controls.js::setMobileDrawerState](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js) +- Floating menu: [controls.js::bindFloatingMenu](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js) +- Toolbar DOM: [index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html) diff --git a/docs/technical/en/frontend-admin-frontend-context.md b/docs/technical/en/frontend-admin-frontend-context.md index 0cd9822e..ed5130ec 100644 --- a/docs/technical/en/frontend-admin-frontend-context.md +++ b/docs/technical/en/frontend-admin-frontend-context.md @@ -155,6 +155,7 @@ Purpose: - Renders Markdown content for `/docs` - Supports headings, lists, blockquotes, code blocks, tables, and basic inline formatting - Code blocks and tables reuse `Scrollbar` so horizontal content does not blow out the docs page +- Docs content is returned by backend `/api/v1/docs/...` endpoints according to Gatekeeper permissions; the frontend only renders content visible to the current user Current constraints: @@ -190,9 +191,10 @@ Responsibilities: - Token - Current user +- Gatekeeper groups - Login / logout -`App.tsx` uses it to decide whether to redirect to the login page. +`App.tsx` uses it to decide whether to redirect to the login page. `/docs` remains a public route, but the backend decides the visible catalog and content from the token; anonymous visitors only receive public docs. ### 2. Business Data Gateway diff --git a/docs/technical/en/location-pipeline-development.md b/docs/technical/en/location-pipeline-development.md new file mode 100644 index 00000000..6147156a --- /dev/null +++ b/docs/technical/en/location-pipeline-development.md @@ -0,0 +1,200 @@ +# Shared Location Resolution Pipeline Development Guide + +`backend/app/services/location/` is the shared abstraction for any "given a record, decide its lat/lon" workflow. Compute centers, BGP collectors, and BGP events now run on this pipeline. Future entities such as satellite ground stations, user-claimed points, and IXP facilities should plug in here instead of creating another geocoding path. + +For the user workflow, see [Earth Location Candidate Collection User Guide](/home/ray/dev/linkong/planet/docs/technical/en/location-pipeline-user.md). + +## Design Goals + +Historically compute centers had their own four-tier chain, BGP collectors used a hard-coded dictionary, and BGP events inherited collector coordinates. These implementations did not share code, and new algorithms had no stable insertion point. + +The refactored rules: + +- Share the `LocationResolver` protocol and `LocationPipeline` orchestrator. +- Domain modules only build `LocationQuery` and choose resolver order. +- New algorithms join by adding resolver classes, without changing ingestion, API, or frontend envelopes. +- Earth renders only city-level or better locations. +- Local JSON registries are not runtime candidate sources for compute centers or BGP collectors; persisted location facts live in database dimension tables. + +## Core Interfaces + +```python +@dataclass(frozen=True) +class LocationQuery: + name: str | None + aliases: tuple[str, ...] + city: str | None + country: str | None + region: str | None + source_latitude: float | None + source_longitude: float | None + extra: Mapping[str, Any] +``` + +```python +@dataclass(frozen=True) +class LocationCandidate: + latitude: float + longitude: float + display_name: str + precision: str + confidence: float + source: str + needs_confirmation: bool + matched_fields: tuple[str, ...] + suggested_registry_entry: dict | None +``` + +```python +class LocationResolver(Protocol): + name: str + def resolve(self, query: LocationQuery) -> ResolverOutput: ... +``` + +`LocationPipeline.collect_candidates()` returns sorted candidates plus `attempted_queries`; `resolve_best()` returns the best candidate with diagnostics. The default sort key ranks source, precision, and confidence, then deduplicates candidates with the same source and rounded coordinates. + +## Built-In Resolvers + +| Resolver | File | Responsibility | +| --- | --- | --- | +| `SourceCoordinatesResolver` | `resolvers/source_coordinates.py` | Emits `precision="precise"` when the record already has lat/lon | +| `RegistryResolver` | `resolvers/registry.py` | Legacy generic resolver; current compute-center and BGP runtime paths do not use it to generate candidates | +| `NominatimResolver` | `resolvers/nominatim.py` | Runs a domain query plan against Nominatim with LRU cache and rate limiting | +| `InheritFromAnotherEntityResolver` | `resolvers/inherit.py` | Wraps an externally resolved entity location as a candidate | + +`RegistryResolver` remains available for future controlled import scenarios, but it should not be reconnected as a hard-coded hint source for compute centers or BGP. Matching common fields such as `operator` or `city` was the main reason multiple entities could collapse onto the same point. + +## Current Domain Pipelines + +### Compute Centers + +Entry points: + +- [compute_center_locations.py](/home/ray/dev/linkong/planet/backend/app/services/compute_center_locations.py) + +Resolver order: + +```python +SourceCoordinatesResolver() +StoredComputeCenterLocationResolver() +``` + +The main map startup path is source coordinates first, then the database-backed current-location table. The table is `compute_center_locations`, keyed by `(source, source_id)`, and stores manually accepted locations or true coordinates migrated from source records. `init_db()` only migrates source records that already contain real coordinates; it does not import old hard-coded hints and does not run ROR, Nominatim, or LLM geocoding during startup. + +Candidate collection is intentionally separate from rendering. `collect_location_candidates()` builds ROR and Nominatim/OpenStreetMap queries from source fields, but it does not emit the current `compute_center_locations` row as a candidate. After a user accepts a candidate, the save endpoint upserts it into the dimension table; the next map refresh renders it through `StoredComputeCenterLocationResolver`. + +`resolve_compute_center_location()`, `resolve_compute_center_location_full()`, and `collect_location_candidates()` remain the domain API. `visualization.py` consumes that API and no longer owns coordinate hints, country-centroid fallbacks, or Nominatim details. + +GeoJSON output includes only `RENDERABLE_PRECISIONS`. Unresolved records are returned in `unresolved` with `failure_reason`, `attempted_queries`, `source_id`, `record_id`, and related diagnostics. + +### BGP Collectors + +Entry points: + +- [bgp_collector_locations.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_collector_locations.py) +- [bgp_collector_location.py](/home/ray/dev/linkong/planet/backend/app/models/bgp_collector_location.py) + +Resolver order: + +```python +SourceCoordinatesResolver() +StoredCollectorLocationResolver() +NominatimResolver(_bgp_collector_query_plan) +``` + +The 23 RIPE RIS collector coordinates moved from the old table into the `bgp_collector_locations` dimension table with `source=legacy_seed` and `needs_confirmation=true`. The legacy dictionary is still maintained from the DB-backed cache for compatibility; manual candidate collection uses stored site/city/country as context but does not emit stored rows as candidates. + +### BGP Events + +Entry point: + +- [bgp_event_locations.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_event_locations.py) + +Resolver order: + +```python +SourceCoordinatesResolver() +InheritFromAnotherEntityResolver(_inherit_from_owning_collector) +``` + +Event inheritance uses a strict owning-collector lookup and does not run the full fuzzy collector registry. Future ASN facility, PrefixGeo, or PeeringDB resolvers can be inserted after inheritance. + +## API Envelope + +```http +POST /api/v1/visualization/compute-centers/{source_id}/collect-location +POST /api/v1/visualization/compute-centers/{source_id}/location +POST /api/v1/bgp/collectors/{collector_id}/collect-location +``` + +Both `collect-location` endpoints return the same envelope: + +```json +{ + "success": true, + "candidates": [], + "best_candidate": {}, + "attempted_queries": [], + "context": {} +} +``` + +`POST /api/v1/visualization/compute-centers/{source_id}/location` upserts the candidate selected by the frontend into `compute_center_locations`. Manual saves default to `needs_confirmation=false`, `verification_status="verified"`, and a `verified_at` timestamp. Future automated staging can pass `needs_confirmation=true` explicitly. + +The frontend [info-card.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/info-card.js) renders the shared candidate list and preview events. The compute-center layer button shows an `unresolved` badge; clicking it opens the unresolved queue. Row-level `采集` only fetches candidates. Header-level `一键采用` walks the queue top-to-bottom, picks the highest-confidence candidate with valid coordinates, saves it, removes the row, renumbers the list, and dispatches `earth:compute-center-unresolved-count-change` so the badge updates immediately. When the batch finishes, `earth:compute-center-location-saved` refreshes the real layer. + +If the remaining records have no city-level candidates, the batch must not invent coordinates. The UI keeps those rows and shows the backend `failure_reason` plus attempted queries. + +## Adding A Resolver + +A resolver only needs `name` and `resolve()`, returning `ResolverOutput`. + +```python +class PeeringDBFacilityResolver: + name = "peeringdb_facility" + + def __init__(self, client): + self._client = client + + def resolve(self, query): + asn = query.extra.get("origin_asn") + if not asn: + return ResolverOutput() + return ResolverOutput(candidates=tuple( + LocationCandidate( + latitude=f.latitude, + longitude=f.longitude, + display_name=f.name, + precision="site", + confidence=0.78, + query=f"peeringdb::{asn}", + source=self.name, + source_note=f"PeeringDB facility for AS{asn}", + matched_fields=("origin_asn",), + needs_confirmation=False, + city=f.city, + country=f.country, + ) + for f in self._client.facilities_for_asn(asn) + )) +``` + +Wire it in: + +```python +BGP_EVENT_PIPELINE = LocationPipeline([ + SourceCoordinatesResolver(), + InheritFromAnotherEntityResolver(source_lookup=...), + PeeringDBFacilityResolver(client=peeringdb_client), +]) +``` + +## Test Coverage + +Relevant tests: + +- [test_location_pipeline.py](/home/ray/dev/linkong/planet/backend/tests/test_location_pipeline.py) +- [test_bgp_collector_locations.py](/home/ray/dev/linkong/planet/backend/tests/test_bgp_collector_locations.py) +- [test_visualization_compute_centers.py](/home/ray/dev/linkong/planet/backend/tests/test_visualization_compute_centers.py) + +Coverage focuses on resolver pluggability, registry alias guards, BGP collector legacy dictionary compatibility, compute-center public API compatibility, and non-renderable locations being returned as `unresolved`. diff --git a/docs/technical/en/location-pipeline-user.md b/docs/technical/en/location-pipeline-user.md new file mode 100644 index 00000000..04977335 --- /dev/null +++ b/docs/technical/en/location-pipeline-user.md @@ -0,0 +1,127 @@ +# Earth Location Candidate Collection User Guide + +Location candidate collection helps fill or verify coordinates for compute centers and BGP collectors on Earth. Users do not type coordinates by hand; the backend ranks source coordinates, open organization-registry results, and online geocoding results into a previewable candidate list. + +## Supported Entities + +Currently supported: + +- Compute centers: TOP500 supercomputers and Epoch AI GPU clusters. +- BGP collectors: RIPE RIS `rrcXX` collectors. + +BGP events inherit the location of their owning collector. Events do not have a separate collection button yet; future ASN facility, prefix geography, or PeeringDB resolvers should use the same pipeline. + +## What Users See + +Clicking a compute center or BGP collector on Earth opens a detail card with location fields: + +| Field | Meaning | +| --- | --- | +| Location precision | Precise coordinates, site-level, city-level, or unconfirmed | +| Location source | Source coordinates, ROR organization registry, Nominatim online search, or stored BGP collector locations | +| Location confidence | Relative confidence reported by the backend resolver | +| Verification status | Confirmed, estimated, or online result pending confirmation | +| Resolution reason | Why the location was selected | +| Matched location name | Canonical name from an open source, online result, or stored collector location | +| Verified at | Verification date for confirmed locations; online candidates are usually empty | + +Compute-center GeoJSON no longer renders country centroids, unknown locations, or `[0, 0]` placeholders. Records that cannot reach city-level precision are returned in the endpoint's `unresolved` list and can be improved through candidate collection. + +A compute center with a `?` marker on Earth is not unresolved. It already has coordinates, but the coordinates still need confirmation, either because `needs_confirmation=true` or because the source is online geocoding. Truly unresolved records have no trustworthy coordinates and are therefore absent from the globe. + +## Collect Candidates + +1. Open `http://localhost:3000/earth`. +2. Enable the `Compute centers` or `BGP observation` layer. +3. Click an object to open its detail card. +4. Click `自动采集坐标候选` or `重新自动采集坐标`. +5. Wait for up to five candidates to appear. +6. Click `预览` on a candidate row; Earth flies to that latitude and longitude. + +Candidate rows show: + +- Candidate name. +- Precision: precise, site, or city. +- Resolver source. +- Confidence. +- Coordinates. + +Clicking `保存` on a candidate row writes the selected compute-center candidate into the location dimension table. After the save succeeds, the compute-center layer refreshes; if the record was previously in the unresolved queue, the unresolved count decreases. + +## Unresolved Queue And Adopt All + +The notification badge on the compute-center layer row shows the current unresolved count. Clicking it opens a fixed queue beside the layer panel: + +1. The queue contains only compute centers without trustworthy coordinates. +2. Row-level `采集` calls the candidate endpoint and shows up to five previewable candidates. +3. Header-level `一键采用` walks the list from top to bottom, chooses the highest-confidence candidate with valid coordinates, and saves it. +4. Each successful save immediately removes that row, renumbers the remaining rows, and updates the badge count. +5. When the batch completes, the frontend refreshes the compute-center layer so UI state and backend state converge. + +If a record has no saveable candidate, the system does not invent a country centroid, vendor headquarters, or hard-coded hint. The row stays in the queue with the backend failure reason and attempted queries so an operator can supply better evidence later. + +## Backend APIs + +The frontend buttons call: + +```http +POST /api/v1/visualization/compute-centers/{source_id}/collect-location +POST /api/v1/visualization/compute-centers/{source_id}/location +POST /api/v1/bgp/collectors/{collector_id}/collect-location +``` + +Both `collect-location` endpoints use the same response shape: + +```json +{ + "success": true, + "candidates": [], + "best_candidate": {}, + "attempted_queries": [], + "context": {} +} +``` + +When no candidate reaches city-level precision, `success` is `false` and the response includes `failure_reason` plus the attempted queries. This helps distinguish missing source fields, open-source gaps, and online geocoding misses. + +## Registry Maintenance + +Compute centers and BGP collectors no longer maintain local candidate registries. Compute-center accepted locations are stored in the `compute_center_locations` database dimension table keyed by `(source, source_id)`. BGP collector current locations are stored in the `bgp_collector_locations` database dimension table; the old RIPE RIS city-level coordinates are used only as initialization seed data and still require confirmation. + +For compute centers, prefer maintaining: + +- `source` / `source_id`: for example `top500` + `top500_50`. +- `name` / `operator` / `site`. +- `city` / `country`. +- `latitude` / `longitude`. +- `precision`: `precise`, `site`, or `city`. +- `confidence`: confidence from 0 to 1. +- `location_source` / `source_url` / `source_note` / `raw_payload`: evidence source. +- `needs_confirmation` / `verification_status` / `verified_at`: manual verification status and date. + +For BGP collectors, prefer maintaining: + +- `collector_id`: for example `rrc12`. +- `site` / `operator`: site and operator. +- `city` / `country` / `region`. +- `latitude` / `longitude`. +- `precision`: `precise`, `site`, or `city`. +- `confidence`: confidence from 0 to 1. +- `source` / `source_url` / `raw_payload`: evidence source. +- `verification_status` / `verified_at`: manual verification status and date. + +If only the city is known, use city-level precision. Do not enter a precise-looking coordinate that has not been verified. + +## Common Questions + +### Why are some compute centers missing on Earth? + +Earth only renders coordinates that reach city-level precision or better. If source data, verified storage, and online geocoding all fail, the record is returned as `unresolved` instead of being rendered at a misleading country center or `[0, 0]`. + +### Why do online results need confirmation? + +Nominatim/OpenStreetMap results may match same-name cities, organizations, or campuses. They are useful for previewing candidates, but should be manually confirmed before being persisted as verified locations. + +### Why do BGP events no longer all land in Amsterdam? + +The old behavior could match common fields like `operator="RIPE NCC"` and incorrectly promote `rrc00`. BGP event inheritance now uses a strict owning-collector lookup in the DB-backed cache instead of registry fuzzy matching. diff --git a/docs/technical/en/manual.md b/docs/technical/en/manual.md index 45de6166..3b2ae7ef 100644 --- a/docs/technical/en/manual.md +++ b/docs/technical/en/manual.md @@ -5,7 +5,7 @@ This manual is for daily use, demos, development integration, and local operatio - `planet.sh`: local start, stop, restart, health check, and log access - Earth: public 3D situational awareness page - Console: admin backend (login required) -- Docs: public developer documentation and manual +- Docs: backend Gatekeeper-controlled documentation; basic usage docs are public, while developer and operations docs require permission groups For the shortest path to getting started, see [Quickstart](/home/ray/dev/linkong/planet/docs/technical/en/quickstart.md). @@ -16,7 +16,7 @@ After a default startup, the common URLs are: | Name | URL | Login Required | Description | | --- | --- | --- | --- | | Earth | `http://localhost:3000/earth` | No | 3D globe, layers, BGP, satellites, cables, news situational awareness | -| Docs | `http://localhost:3000/docs` | No | Developer docs, technical reference, usage manual | +| Docs | `http://localhost:3000/docs` | Partly | Usage docs are public; developer, backend, and operations docs require Gatekeeper groups | | Console | `http://localhost:3000/admin` | Yes | Data, config, alerts, logs, and situational observation | | AI Playground | `http://localhost:3000/playground` | Yes | AI Provider status and debugging | | Backend API Docs | `http://localhost:8000/docs` | Depends on endpoint | FastAPI / OpenAPI documentation | @@ -176,7 +176,26 @@ Useful for: - Demos on phone or tablet - Another machine on the same LAN accessing the same dev instance -After starting, check your firewall and WSL network forwarding if access fails. +`--allow-lan` only makes the frontend and backend listen on `0.0.0.0`. When Planet runs in WSL, Windows can usually reach it through `localhost`, but access from a phone or another computer through `http://:3000` still depends on Windows port forwarding and firewall rules. + +Use this order to diagnose: + +```bash +# From WSL or the shell running Planet +curl http://localhost:3000 +curl http://localhost:8000/health +ss -ltnp | grep -E ':3000|:8000' +``` + +If this shows `0.0.0.0:3000` and `0.0.0.0:8000`, but the LAN IP still fails, configure Windows from an elevated PowerShell: + +```powershell +netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000 +netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8000 connectaddress=127.0.0.1 connectport=8000 + +New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000 +New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000 +``` ## Earth @@ -259,6 +278,12 @@ Earth search finds current globe objects, such as: Search results can be used to quickly locate objects and open their details. +### Location Candidate Collection + +Compute-center and BGP collector detail cards can collect candidate coordinates automatically. After clicking an object, use `自动采集坐标候选` or `重新自动采集坐标`; the backend ranks source coordinates, open organization lookups, and Nominatim online search results. Stored BGP collector locations are used as query context only and are not emitted as candidates. + +Candidates can be previewed directly on Earth. Compute-center candidates can be saved into the `compute_center_locations` dimension table from the detail card, then the layer refreshes immediately. The notification badge on the compute-center layer row shows unresolved records that cannot be rendered; clicking it opens the queue, where users can collect individual candidates or use `一键采用` to save the highest-confidence candidate top-to-bottom. Records without candidates stay in the queue and are not replaced by country centroids or hard-coded hints. See [Earth Location Candidate Collection User Guide](/home/ray/dev/linkong/planet/docs/technical/en/location-pipeline-user.md) for the full workflow. + ### Settings The settings panel contains: @@ -492,33 +517,41 @@ Then open `/logs` for more structured runtime information. ## Docs -Public documentation site: +Documentation site: ```text http://localhost:3000/docs ``` -Current public content comes from: +Docs content is read through backend APIs by permission. The frontend no longer bundles all Markdown files directly. Source files still live in: ```text docs/technical/zh/ (Chinese) docs/technical/en/ (English) ``` +Anonymous visitors only see `public` docs such as the overview, quickstart, and manual. Logged-in users can see more technical docs when assigned Gatekeeper groups: + +- `docs_user`: user-operation docs. +- `docs_developer`: Earth, frontend, backend, collector, and AI Provider development docs. +- `docs_admin`: service control, operations, environment variable, and sensitive-operation docs. + +`admin` receives admin-doc access by default, and `super_admin` can read all Docs content. Gatekeeper groups are configured in the console Users page. + Docs supports: - Category navigation - Markdown rendering - Tables and code blocks - In-document table of contents -- Local search +- Search across currently visible docs - Internal links between technical documents When adding a new technical document, check: - Does it have a clear top-level heading -- Does it need to be added to the `/docs` manual category and ordering -- Does it contain information that should not be publicly displayed +- Does it need to be added to backend Docs metadata for category and ordering +- Should it be classified as `public`, `docs_user`, `docs_developer`, or `docs_admin` ## Development Command Conventions @@ -589,5 +622,6 @@ When something goes wrong, follow this sequence: - [Admin Frontend Context](/home/ray/dev/linkong/planet/docs/technical/en/frontend-admin-frontend-context.md) - [Earth Frontend Context](/home/ray/dev/linkong/planet/docs/technical/en/earth-frontend-context.md) - [Earth Layer Style Reference](/home/ray/dev/linkong/planet/docs/technical/en/earth-layer-style-reference.md) +- [Earth Location Candidate Collection User Guide](/home/ray/dev/linkong/planet/docs/technical/en/location-pipeline-user.md) - [System Service Control](/home/ray/dev/linkong/planet/docs/technical/en/backend-system-service-control.md) - [Backend Collectors](/home/ray/dev/linkong/planet/docs/technical/en/backend-collectors.md) diff --git a/docs/technical/en/ops-planet-sh-startup.md b/docs/technical/en/ops-planet-sh-startup.md new file mode 100644 index 00000000..61941fba --- /dev/null +++ b/docs/technical/en/ops-planet-sh-startup.md @@ -0,0 +1,205 @@ +# `planet.sh` Startup Performance Optimization + +## Background + +`planet.sh` manages start, stop, restart, health checks, and logs for all local services. The previous implementation had several startup issues: + +1. AI Provider rebuilt every time, even when code had not changed. +2. Port cleanup could wait up to 45 seconds. +3. Port bind detection used a Python subprocess, adding about 300 ms per call. +4. Plain `restart` and `restart -b` behaved differently. + +## Issue 1: AI Provider Rebuilt Every Time + +### Root Cause + +The build stamp file lived under `/tmp/`. After WSL or Linux restart, `/tmp` is cleared, so the `stamp_non_empty` condition failed and the script decided to rebuild: + +```bash +# All three conditions had to be true to skip rebuild +image_exists AND stamp_non_empty AND fingerprint_match +``` + +### Fix + +The stamp file moved to a persistent cache path: + +```bash +AI_PROVIDER_BUILD_STAMP_FILE="$HOME/.cache/planet/aiprovider_build.sha256" +``` + +Writing the stamp creates the directory first: + +```bash +write_ai_provider_build_stamp() { + mkdir -p "$(dirname "$AI_PROVIDER_BUILD_STAMP_FILE")" + compute_ai_provider_build_fingerprint > "$AI_PROVIDER_BUILD_STAMP_FILE" +} +``` + +### Faster Fingerprint + +The previous implementation tarred the whole `aiprovider/` directory before hashing, which could take seconds in large trees. The new version uses `find + stat` and reads only file metadata: + +```bash +compute_ai_provider_build_fingerprint() { + find aiprovider \ + -type f \ + ! -path '*/__pycache__/*' \ + ! -name '.env' \ + ! -name '.env.*' \ + ! -name '*.pyc' \ + ! -name '*.pyo' \ + | LC_ALL=C sort \ + | xargs -r stat --format="%Y %s %n" 2>/dev/null + sha256sum docker-compose.yml docker-compose.simple.yml 2>/dev/null + python3 "$SCRIPT_DIR/scripts/compute_aiprovider_dependency_fingerprint.py" 2>/dev/null +} +``` + +This is roughly 10 times faster for many-small-file workloads while preserving the same practical rebuild signal. `.env` and `.env.*` are excluded because runtime model, key, and Base URL changes should not force an image rebuild. + +### Docker Build Context + +AI Provider only needs root `pyproject.toml`, `uv.lock`, and `aiprovider/` source code. Sending the entire repository as Docker build context wastes time on frontend assets, PDFs, historical data, and Unreal files. + +The root `.dockerignore` now narrows the context: + +```dockerignore +** + +!pyproject.toml +!uv.lock +!aiprovider/ +!aiprovider/** + +aiprovider/.env +aiprovider/.env.* +!aiprovider/.env.example +``` + +The Dockerfile copies only AI Provider inputs: + +```dockerfile +COPY pyproject.toml uv.lock /app/ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-dev + +COPY aiprovider /app/aiprovider +``` + +`uv sync` uses a BuildKit cache mount. The first build may still depend on network speed, but later builds reuse `/root/.cache/uv`. + +### Runtime Configuration + +Before starting AI Provider, `planet.sh` generates a temporary env-file and passes it to Compose or the manual `docker run` fallback. Configuration priority: + +1. `aiprovider/.env` +2. simple `export AI_...=...` or `AI_...=...` lines from `~/.zshrc` + +The default parser is static and only covers AI Provider, image, and proxy variables. It avoids executing interactive shell initialization. Complex shell expansion can be enabled explicitly: + +```bash +PLANET_LOAD_ZSHRC_ENV=source ./planet.sh start -a +``` + +To ignore personal shell config during debugging: + +```bash +PLANET_LOAD_ZSHRC_ENV=0 ./planet.sh start -a +``` + +### Skip-Rebuild Behavior + +When the fingerprint matches, the script skips `docker compose build` and starts the existing container: + +```bash +docker start planet_aiprovider +``` + +`docker stop` stops the container without deleting the image. `cleanup_exit_containers` removes exited containers but not images, so the next `docker start` can reuse the existing image. + +## Issue 2: Slow Port Cleanup + +### Cause + +`wait_for_port_release` could wait up to 45 seconds by default: 15 attempts times 3 seconds. + +### Fix + +Background process cleanup now uses a 3-second timeout: TERM, 1.5 seconds, KILL, 1.5 seconds. + +```bash +PORT_RELEASE_ATTEMPTS=15 +PORT_RELEASE_INTERVAL=0.2 + +wait_for_port_release "$port" 15 0.2 +``` + +`wait_for_port_release` accepts optional parameters so different situations can choose different timeouts. + +## Issue 3: Port Detection Used Python + +### Cause + +`can_bind_port` used `python3 -c "import socket..."`; each call cost about 300 ms. + +### Fix + +Prefer system tools and keep Python as a fallback: + +```bash +can_bind_port() { + local port="$1" + if command -v ss >/dev/null 2>&1; then + ! ss -tlnH 2>/dev/null | awk '{print $4}' | grep -qE ":${port}$" + return + fi + if command -v lsof >/dev/null 2>&1; then + [ -z "$(lsof -tiTCP:"${port}" -sTCP:LISTEN 2>/dev/null)" ] + return + fi + python3 - "$port" <<'PY' +import sys, socket +p = int(sys.argv[1]) +s = socket.socket() +s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +try: + s.bind(("", p)); s.close(); sys.exit(0) +except OSError: + sys.exit(1) +PY +} +``` + +Frontend startup now has an additional pre-start cleanup retry layer: + +- `PORT_PRESTART_RETRIES`: defaults to 3 attempts. +- `PORT_PRESTART_RETRY_INTERVAL`: defaults to 2 seconds. + +`kill_port_if_requested()` only kills processes when the current environment can identify listening PIDs. If no PID is visible but the port still cannot bind, it logs diagnostics and lets the service startup flow make the final decision. `start_frontend_with_retry()` only enters the pre-cleanup retry path when a listener PID is visible, so the script no longer spends its retry budget repeatedly killing nothing while a host-side or external network namespace is still releasing the port. Seeing "no listener found but port still unavailable" on the first restart usually means the external environment is still releasing the port, not that a local process cleanup loop is useful. + +## Issue 4: `restart` Behavior + +Before the stamp path fix: + +- `restart -b`: stop all services, check fingerprint, rebuild only when needed, then start. +- plain `restart`: stop all services, then often rebuild AI Provider because `/tmp` lost the stamp. + +After moving the stamp file, plain `restart` uses the same `stop + start` behavior and the same fingerprint check as `restart -b`. + +## Other Cleanup + +Two redundant `sleep 3` waits were removed because health checks already cover the same readiness: + +- `start_backend_service`: post-database-health-check sleep. +- `restart_database_service`: post-restart sleep. + +## Related Files + +- [planet.sh](/home/ray/dev/linkong/planet/planet.sh) +- [.dockerignore](/home/ray/dev/linkong/planet/.dockerignore) +- [aiprovider/Dockerfile](/home/ray/dev/linkong/planet/aiprovider/Dockerfile) +- [docker-compose.yml](/home/ray/dev/linkong/planet/docker-compose.yml) +- [docker-compose.simple.yml](/home/ray/dev/linkong/planet/docker-compose.simple.yml) +- [compute_aiprovider_dependency_fingerprint.py](/home/ray/dev/linkong/planet/scripts/compute_aiprovider_dependency_fingerprint.py) diff --git a/docs/technical/en/quickstart.md b/docs/technical/en/quickstart.md index 5df11163..7cb880e7 100644 --- a/docs/technical/en/quickstart.md +++ b/docs/technical/en/quickstart.md @@ -30,6 +30,16 @@ Personal AI Provider configuration can also live in `~/.zshrc`. `planet.sh` read ./planet.sh restart -a ``` +Collector credentials such as AISStream and BarentsWatch can also start in `~/.zshrc` for connectivity validation: + +```bash +export AISSTREAM_API_KEY="..." +export BARENTSWATCH_CLIENT_ID="..." +export BARENTSWATCH_CLIENT_SECRET="..." +``` + +For actual collection, prefer saving credentials in `Settings -> Collector Settings`, especially for AISStream's long-lived WebSocket collector. That keeps connectivity validation, backend collection tasks, and Earth realtime vessel aggregation on the same configuration source. + ## 1. Start Services From the repository root: @@ -44,7 +54,7 @@ After startup, the key URLs are: | --- | --- | --- | | Earth | `http://localhost:3000/earth` | Public 3D Earth visualization | | Console | `http://localhost:3000/admin` | Admin console (login required) | -| Docs | `http://localhost:3000/docs` | Public developer docs and manual | +| Docs | `http://localhost:3000/docs` | Usage docs are public; developer and operations docs require Gatekeeper groups | | AI Playground | `http://localhost:3000/playground` | AI debugging (login required) | | Backend API Docs | `http://localhost:8000/docs` | FastAPI / OpenAPI interface docs | @@ -64,6 +74,8 @@ The console requires login. For first-time use: Follow the prompts to enter username, password, and role. +To read developer or operations docs, log in as `super_admin` and assign Gatekeeper groups from the Users page. Use `docs_developer` for development docs and `docs_admin` for service-control and operations docs. + ## 3. Open Earth Visit: @@ -79,6 +91,7 @@ Once in, verify: - The globe renders correctly - The right-side layer panel can toggle layers on/off - Search can find cables, satellites, compute centers, BGP events +- Compute-center and BGP collector detail cards can collect and preview coordinate candidates; the compute-center unresolved badge can open the queue and save candidates - Mouse drag, wheel zoom, and zoom percent feedback work correctly - Settings panel can switch cruise mode, day/night mode, satellite display style @@ -176,6 +189,14 @@ To allow a Windows browser, phone, or another device on the same network: This makes the frontend and backend listen on a LAN-accessible address. +Note: `--allow-lan` only makes Planet listen on `0.0.0.0`; it does not automatically expose WSL services through the Windows LAN IP. A common pattern is: + +- `localhost:3000` / `localhost:8000` works inside WSL +- `localhost:3000` / `localhost:8000` works on Windows +- `http://:3000` fails from a phone or another computer + +That usually means Windows still needs port forwarding or firewall rules. + If access fails, check from the shell running Planet: ```bash @@ -184,6 +205,16 @@ curl http://localhost:8000/health ss -ltnp | grep -E ':3000|:8000' ``` +If WSL is listening on `0.0.0.0:3000` and `0.0.0.0:8000` but the LAN IP still fails, configure Windows forwarding and firewall rules from an elevated PowerShell: + +```powershell +netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000 +netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8000 connectaddress=127.0.0.1 connectport=8000 + +New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000 +New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000 +``` + ## 9. Stop Services ```bash diff --git a/docs/technical/zh/README.md b/docs/technical/zh/README.md index 716fb833..392e2734 100644 --- a/docs/technical/zh/README.md +++ b/docs/technical/zh/README.md @@ -23,7 +23,10 @@ - [快速开始](/home/ray/dev/linkong/planet/docs/technical/zh/quickstart.md):从零启动 Planet 的最短路径 - [Planet 使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/manual.md):控制台、`planet.sh`、Earth 和 Docs 的完整使用手册 +- [Earth 位置候选采集使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/location-pipeline-user.md):在 Earth 上为算力中心和 BGP 观测站采集、预览坐标候选 - [数据源、采集器设置与连接验证](/home/ray/dev/linkong/planet/docs/technical/zh/datasource-collector-settings-connectivity.md):数据源目录、采集器设置、连接验证、BarentsWatch 凭证链路 +- [通用位置估算管线开发说明](/home/ray/dev/linkong/planet/docs/technical/zh/location-pipeline-development.md):后端 location resolver / pipeline 的接口、注册表和扩展方式 +- [Docs Gatekeeper 开发说明](/home/ray/dev/linkong/planet/docs/technical/zh/docs-gatekeeper-development.md):后端 Docs 目录、正文读取和 Gatekeeper 权限组实现 - [Earth 可交互图标接入](/home/ray/dev/linkong/planet/docs/technical/zh/earth-interactable-usage.md):Earth 地表可交互图标 `Interactable` 的接口、生命周期和接入示例 - [Earth 工具栏与浮层协同](/home/ray/dev/linkong/planet/docs/technical/zh/earth-toolbar-overlay-coordination.md):工具栏按钮与搜索 / 设置 / 新闻 / 图层浮层之间的关闭矩阵和接入规则 diff --git a/docs/technical/zh/backend-collectors.md b/docs/technical/zh/backend-collectors.md index c6838acf..eb60fd95 100644 --- a/docs/technical/zh/backend-collectors.md +++ b/docs/technical/zh/backend-collectors.md @@ -88,6 +88,10 @@ async def run(self, db): | BarentsWatch AIS | vessel | 船只位置、航速、航向、MMSI 等 AIS 数据 | 依采集器配置 | | AISStream Vessels | vessel_ais | AIS WebSocket 实时流,写入原始观测层并由聚合接口展示 | 依采集器配置 | +AIS 船只类采集器和其它 `CollectedData` 采集器的落库路径不同。BarentsWatch、AISStream 和自定义 `vessel_ais` 源都会进入 AIS 原始观测层,随后由聚合服务合并成 Earth 船只图层使用的 GeoJSON 和详情数据。这样做可以保留来源、传输方式、字段冲突和观测时间,避免某个实时源直接覆盖最终展示表。 + +TOP500 和 Epoch AI 算力数据的公开源不总是提供可用经纬度。Earth 统一算力中心接口在主地图启动链路中只使用源数据自带坐标或 `compute_center_locations` 维表坐标;缺少坐标的记录会进入 `unresolved`,不会通过本地注册表、国家质心或猜测城市自动渲染。用户手动采集候选时,后端会用源字段调用 ROR 组织注册 API 和 Nominatim/OpenStreetMap 在线搜索;候选经前端保存后写入 `compute_center_locations`,后续地图刷新再从维表渲染。 + ## 四、数据格式 (统一存储到 CollectedData 表) ```python @@ -242,8 +246,16 @@ backend/app/services/collectors/ ├── vessel_ais.py # BarentsWatch AIS 船只采集器 └── aisstream.py # AISStream WebSocket 船只采集器 +backend/app/services/ +├── custom_datasource_runtime.py # 自定义 REST / WebSocket 映射运行时 +├── datasource_mapping.py # 确定性字段映射与目标写入 +├── vessel_ais_aggregation.py # AIS 原始观测写入与聚合读取 +├── vessel_aggregation_strategy.py # 多源字段选择、freshness fallback 和冲突记录 +└── vessel_enrichment.py # 船舶资料富化缓存 + backend/app/models/ -└── collected_data.py # 统一数据模型 +├── collected_data.py # 统一数据模型 +└── vessel_enrichment.py # 船舶富化结果缓存 ``` ## 九、凭证型采集器 @@ -253,7 +265,7 @@ backend/app/models/ | 采集器 | credential provider | 凭证来源 | | --- | --- | --- | | `barentswatch_vessels` | `barentswatch` | 控制台采集器设置、环境变量、`~/.zshrc` | -| `aisstream_vessels` | `aisstream` | 控制台采集器设置、环境变量 | +| `aisstream_vessels` | `aisstream` | 控制台采集器设置、环境变量、`~/.zshrc`(连接验证可读;正式采集建议保存到采集器设置或注入后端环境) | | `spacetrack_tle` | `spacetrack` | 环境变量、`~/.zshrc` | ### BarentsWatch AIS @@ -292,6 +304,50 @@ export BARRENTSWATCH_CLIENT_SECRET="..." 连接验证会先请求 `https://id.barentswatch.no/connect/token` 获取 `scope=ais` 的 access token,再用 `Authorization: Bearer ` 请求 AIS endpoint。 +### AISStream 实时船舶 + +AISStream 使用 `wss://stream.aisstream.io/v0/stream` WebSocket endpoint。默认运行方式是长连接实时采集,而不是传统 REST collector 的“请求一次、进度到 100%、完成”模型。 + +运行时配置: + +- `api_key`:优先从 `DataSourceConfig.auth_config.api_key` 或 `config.api_key` 读取;也可由后端进程环境变量 `AISSTREAM_API_KEY` 提供。 +- `bounding_boxes`:AISStream 订阅范围,默认示例为全球 `[[[-90, -180], [90, 180]]]`,生产或演示建议先缩小区域。 +- `message_types`:默认 `PositionReport` 和 `ShipStaticData`。 +- `streaming_enabled`:默认启用长连接;关闭后回退到批次式 `fetch -> transform -> save`。 +- `streaming_max_messages`:测试用上限,非 0 时收到指定消息数后停止。 +- `reconnect_delay_seconds`、`receive_timeout_seconds`:控制断线重连和空闲等待。 + +状态语义: + +- `connecting`:正在连接 AISStream。 +- `streaming`:持续接收实时消息,`records_processed` 表示已见消息数,通常没有固定总量和百分比。 +- `reconnecting`:上游断开或网络异常,采集器记录 `AISSourceHealth` 后等待重连。 +- `stopped` / `cancelled`:任务被测试上限或用户停止。 + +AISStream 连接验证会通过 `datasource_connectivity.py` 读取保存的采集器配置、环境变量和 `~/.zshrc` 中的 `AISSTREAM_API_KEY`。正式采集时,最稳妥的方式是把 API Key 保存到“设置 -> 采集器设置 -> AISStream 实时船舶”;如果只放在 `~/.zshrc`,需要确认后端进程实际继承到了该环境变量。 + +### AIS 原始观测与聚合 + +AIS 观测写入后不会直接替换最终船只记录,而是先保存为 raw observation: + +- `source` 记录来源,例如 `barentswatch_vessels`、`aisstream_vessels` 或自定义源名称。 +- `delivery_mode` 表达实时性,`realtime_stream` 优先于 `polling`。 +- `transport` 记录 `websocket` 或 `http`。 +- 位置、速度、航向等动态字段会按 freshness 和来源优先级选择。 +- 静态字段优先保留非空值;冲突候选会记录到详情接口,便于排查多源差异。 + +Earth 使用的接口仍是: + +```http +GET /api/v1/visualization/geo/vessels +GET /api/v1/visualization/vessels/{mmsi} +GET /api/v1/visualization/vessels/{mmsi}/track +GET /api/v1/visualization/vessels/{mmsi}/conflicts +GET /api/v1/visualization/vessels/aggregation/diagnostics +``` + +`/geo/vessels` 会合并 raw observation 聚合结果和 legacy BarentsWatch latest position 结果,避免只接入 AISStream 后把历史 BarentsWatch 船只遮蔽掉。 + ## 十、采集器设置与连接验证 控制台的“采集器设置”页提供所有内置采集器的 endpoint、请求头、超时、重试和凭证配置。连接验证不是只看前端按钮状态,而是由后端计算 checksum: diff --git a/docs/technical/zh/datasource-collector-settings-connectivity.md b/docs/technical/zh/datasource-collector-settings-connectivity.md index 7d6841da..09af46e9 100644 --- a/docs/technical/zh/datasource-collector-settings-connectivity.md +++ b/docs/technical/zh/datasource-collector-settings-connectivity.md @@ -62,7 +62,7 @@ 当前行为: - `collector_credentials` tab 展示为“采集器设置”。 -- 下拉框列出所有内置采集器。 +- 下拉框列出内置采集器,并支持维护合并到内置数据的自定义补充源。 - 下拉框右侧只有一个插头图标按钮,用于健康检查。 - 下拉框下方用状态标签展示: - `需要凭证` / `无需凭证` @@ -72,6 +72,8 @@ - 是否覆盖 endpoint - 需要凭证的采集器把凭证卡片放在基础配置上方。 - 不需要凭证的采集器只显示基础配置。 +- AISStream 采集器使用 WebSocket 语义,状态会显示为连接中、实时接收、重连或停止,不使用固定百分比表达完成度。 +- 自定义源入口放在采集器设置内,不在数据源目录里重复提供编辑入口;数据源目录只保留总览、运行和只读抽屉。 连接按钮使用内联 Tabler 风格插头图标,来源语义对应 `plug-connected`,避免继续使用刷新图标表达连接动作。 @@ -286,6 +288,100 @@ AISStream 使用 WebSocket 实时流,采集器只写入 `ais_raw_observations` - 船型通常来自低频 `ShipStaticData.Type`;后端会把 AIS 数字类型码映射为 Cargo / Tanker / Passenger / Fishing / Military。 - 如果某艘船尚未收到静态消息,聚合结果的船型仍可能是 `Other`,后续由 v5 船舶资料 enrichment 补齐。 +连接验证会读取保存配置、环境变量和 `~/.zshrc` 中的 `AISSTREAM_API_KEY`。正式采集时,推荐把 API Key 保存到采集器设置;如果只写在 `~/.zshrc`,需要确认后端进程实际继承了该变量,否则连接验证可能可用但 collector 运行时拿不到 key。 + +## 自定义 REST / WebSocket 映射运行时 + +文件: + +- [custom_datasource_runtime.py](/home/ray/dev/linkong/planet/backend/app/services/custom_datasource_runtime.py) +- [datasource_mapping.py](/home/ray/dev/linkong/planet/backend/app/services/datasource_mapping.py) + +自定义源现在不是独立的新数据孤岛,而是作为内置数据源的补充源写入目标 schema。当前最完整的目标是 `vessel_ais`:自定义 REST 或 WebSocket 源经过确定性 mapping 后写入 AIS raw observations,再通过 `vessels` WebSocket channel 推送给 Earth。 + +### 配置语义 + +关键字段: + +- `source_type`:`rest` / `http` / `websocket` / `ws`。 +- `endpoint`:REST 使用 `http(s)://`,WebSocket 使用 `ws(s)://`。 +- `auth_type`:`none`、`bearer`、`api_key`、`basic`。 +- `headers`:静态请求头。 +- `auth_config`:token、API key、basic 用户名密码,API key 支持 header 或 query。 +- `config.target_schema`:例如 `vessel_ais`。 +- `config.delivery_mode`:REST 默认 `polling`,WebSocket 默认 `realtime_stream`。 +- `config.merge_target_source`:记录该自定义源补充哪个内置数据,例如 `barentswatch_vessels`。 + +REST runner 支持: + +- `GET` / `POST` +- query params +- JSON body +- headers 和 auth 注入 +- active mapping 写入目标 schema + +WebSocket runner 支持: + +- endpoint 格式校验 +- headers 和 auth 注入 +- 可选 `ws_subscribe_message` +- `ws_message_path` / `ws_items_path` 提取消息主体或数组 +- 断线重连 +- `debug_max_messages` 调试上限 +- 后台 stream start / stop / status + +相关 API: + +```http +POST /api/v1/datasources/custom/sample +GET /api/v1/datasources/target-schemas +POST /api/v1/datasources/{config_id}/run-mapped +POST /api/v1/datasources/{config_id}/stop-mapped +GET /api/v1/datasources/{config_id}/mapped-status +DELETE /api/v1/datasources/configs/{config_id}?delete_mappings=true&delete_source_data=true +``` + +`run-mapped?background=true` 只对 WebSocket 源有意义,会启动后台 stream。REST 源仍是一次性采集。 + +### 删除与数据清理 + +删除自定义源时有三种层级: + +- 只删除配置:保留 mapping 和历史数据。 +- 删除配置和 mapping:同时删除该配置的 mapping 模板。 +- 删除配置、mapping 和该源数据:删除该源写入的 `collected_data`、`ais_raw_observations` 和 `ais_source_health`。 + +如果删除的是 `vessel_ais` 自定义源数据,后端会向 `vessels` channel 广播 `reload_required`,提示 Earth 重新拉取船只聚合结果。legacy `vessel_position` 不按自定义源直接删除,因为它没有可靠的 source 归因。 + +### 本地 AIS mock WebSocket + +文件: + +- [mock-ais-ws-server.ts](/home/ray/dev/linkong/planet/scripts/mock-ais-ws-server.ts) + +运行方式: + +```bash +bun run mock:ais-ws +``` + +mock 服务持续发送 AIS-like JSON,用于验证“WebSocket 自定义源 -> mapping -> AIS raw observation -> `vessels` channel -> Earth 船只 upsert”链路。典型配置: + +```json +{ + "source_type": "websocket", + "endpoint": "ws://localhost:8787", + "config": { + "target_schema": "vessel_ais", + "delivery_mode": "realtime_stream", + "merge_target_source": "barentswatch_vessels", + "ws_message_path": "$.data", + "ws_items_path": "$.vessels[*]", + "ws_reconnect": true + } +} +``` + ## 凭证教程 文件: @@ -347,6 +443,7 @@ BarentsWatch `client_secret` 保存时有特殊处理: 当前已经支持的凭证 provider: - `barentswatch` +- `aisstream` - `spacetrack` 其他 `requires_credentials=true` 的采集器如果还没有 provider,会返回“凭证链路尚未接入”,前端显示 `不可用`。 diff --git a/docs/technical/zh/docs-gatekeeper-development.md b/docs/technical/zh/docs-gatekeeper-development.md new file mode 100644 index 00000000..dd6a3188 --- /dev/null +++ b/docs/technical/zh/docs-gatekeeper-development.md @@ -0,0 +1,116 @@ +# Docs Gatekeeper 开发说明 + +Docs Gatekeeper 把 `/docs` 从“前端构建时打包所有 Markdown”改成“后端按权限返回目录和正文”。它的目标是让公开使用手册、用户文档、开发文档和管理/运维文档在同一个 Docs 页面内可检索,但正文读取必须经过服务端白名单和用户权限检查。 + +用户侧说明见 [Planet 使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/manual.md) 的 Docs 章节。 + +## 鉴权模型 + +Docs 使用两层权限: + +- `users.role`:保留给控制台系统权限。 +- `users.gatekeeper_groups`:Docs 内容权限组。 + +权限组: + +| 组 | 用途 | +| --- | --- | +| `docs_user` | 用户操作类文档 | +| `docs_developer` | Earth、前端、后端、采集器和 AI Provider 开发文档 | +| `docs_admin` | 服务控制、运维、环境变量和敏感操作文档 | + +继承规则: + +- 未登录用户只能读 `public`。 +- `docs_developer` 隐含 `docs_user`。 +- `docs_admin` 隐含 `docs_developer` 和 `docs_user`。 +- `admin` 和 `super_admin` 默认拥有全部 Docs 权限。 + +## 后端入口 + +文件: + +- [docs.py](/home/ray/dev/linkong/planet/backend/app/api/v1/docs.py) +- [docs_gatekeeper.py](/home/ray/dev/linkong/planet/backend/app/services/docs_gatekeeper.py) +- [user.py](/home/ray/dev/linkong/planet/backend/app/models/user.py) +- [users.py](/home/ray/dev/linkong/planet/backend/app/api/v1/users.py) + +API: + +```http +GET /api/v1/docs/catalog +GET /api/v1/docs/{lang}/{slug} +``` + +`catalog` 只返回当前用户可见文档。正文接口会先校验语言、slug 和文件是否在 metadata 白名单里,再判断权限: + +- 未登录访问受保护文档:`401`。 +- 已登录但权限不足:`403`。 +- 未知语言、未知 slug 或文件不存在:`404`。 + +正文文件只能来自 `docs/technical/{zh,en}/` 下的白名单文件,不能通过路径拼接读取任意文件。 + +## Metadata 来源 + +当前服务端 metadata 维护在 [docs_gatekeeper.py](/home/ray/dev/linkong/planet/backend/app/services/docs_gatekeeper.py): + +```python +DocsMetadata( + "manual.md", + "manual", + "public", + "Manual", + 2, + "Planet 使用手册", + "Planet Manual", +) +``` + +新增公开文档时,需要同步: + +- 新增中英文 Markdown 文件。 +- 在服务端 `DOCS_METADATA` 添加 filename、slug、access、group、order、标题。 +- 在前端 [docs-content.ts](/home/ray/dev/linkong/planet/frontend/src/pages/Docs/docs-content.ts) 添加同名 metadata,保持导航标题和排序一致。 +- 如果需要从 README 发现,更新 `docs/technical/zh/README.md` 和 `docs/technical/en/README.md`。 + +## 用户管理 + +`users` 表新增 `gatekeeper_groups JSONB DEFAULT '[]'`。启动时 [session.py](/home/ray/dev/linkong/planet/backend/app/db/session.py) 会用 `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` 补列,适配已有本地数据库。 + +用户 API 负责: + +- 创建用户时写入 `gatekeeper_groups`。 +- 更新用户时校验组名只能是 `docs_user`、`docs_developer`、`docs_admin`。 +- 只有 `super_admin` 能修改 Gatekeeper 权限组。 + +前端 [Users.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Users/Users.tsx) 展示权限组标签,并在编辑表单中提供多选框。非 `super_admin` 打开的表单会禁用该字段,并在提交前移除 `gatekeeper_groups`。 + +## 前端 Docs 加载 + +文件: + +- [Docs.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Docs/Docs.tsx) +- [docs-content.ts](/home/ray/dev/linkong/planet/frontend/src/pages/Docs/docs-content.ts) +- [docs-search.ts](/home/ray/dev/linkong/planet/frontend/src/pages/Docs/docs-search.ts) + +关键变化: + +- 移除 `import.meta.glob(...?raw)` 作为正文来源。 +- 页面加载时请求 `/api/v1/docs/catalog` 构建当前可见目录。 +- 打开正文时请求 `/api/v1/docs/{lang}/{slug}`。 +- 搜索只索引当前用户可见文档,并按需从后端读取 Markdown。 +- `401` 显示登录提示,`403` 显示权限提示,`404` 显示文档不可用。 + +## 测试覆盖 + +相关测试: + +- [test_docs_gatekeeper.py](/home/ray/dev/linkong/planet/backend/tests/test_docs_gatekeeper.py) + +测试应覆盖: + +- 匿名用户只能看到 public 文档。 +- 受保护正文的 `401` / `403`。 +- `docs_developer` 可读开发文档但不能读管理文档。 +- `admin` 和 `super_admin` 可读管理文档。 +- 未知 slug、未知语言和路径穿越字符串不能读取文件。 diff --git a/docs/technical/zh/earth-frontend-context.md b/docs/technical/zh/earth-frontend-context.md index 0ff60646..e90cadea 100644 --- a/docs/technical/zh/earth-frontend-context.md +++ b/docs/technical/zh/earth-frontend-context.md @@ -303,7 +303,9 @@ AISStream 的 `PositionReport` 常带实时位置和 `MetaData.ShipName`,但 登陆点是当前明确保留的例外:它曾接入 `Interactable`,但 pin 类 SVG 在地球边缘会被 `THREE.Points` 的深度测试裁切成碎片;关闭 depthTest 又会破坏背面遮挡语义。因此登陆点退回 `cables.js` 内的专用 `THREE.Sprite` 路径,并改为 canvas 生成的黄色扁平球纹理。它的 `altitudeOffset` 和 `renderOrder` 与海缆线一致,避免漂在海缆之上;Sprite 本体关闭 `depthTest` 保持球完整,背面可见性由 `isFacingCamera()` 的球体遮挡判断控制。 -图标资源可以继续用 canvas draw,也可以放到 `frontend/public/earth/assets/icons/` 后由 `Interactable` 预加载。asset 路径不会在每帧读取;图层加载阶段通过 `preloadAssets()` 只加载一次 SVG / 图片,之后按 `icon source + state + bucket + color` 生成 `CanvasTexture` 并复用。当前算力中心已经从 `assets/icons/compute-supercomputer.svg`、`assets/icons/compute-gpu-cluster.svg` 和备用 `assets/icons/compute-hdd-network.svg` 读取图标,再在 canvas 上叠加估算位置的 `?` badge。 +图标资源可以继续用 canvas draw,也可以放到 `frontend/public/earth/assets/icons/` 后由 `Interactable` 预加载。asset 路径不会在每帧读取;图层加载阶段通过 `preloadAssets()` 只加载一次 SVG / 图片,之后按 `icon source + state + bucket + color` 生成 `CanvasTexture` 并复用。当前算力中心已经从 `assets/icons/compute-supercomputer.svg`、`assets/icons/compute-gpu-cluster.svg` 和备用 `assets/icons/compute-hdd-network.svg` 读取图标,再在 canvas 上叠加未确认位置的 `?` badge。算力中心后端在启动链路只渲染源数据自带坐标或 `compute_center_locations` 维表坐标;手动候选采集会调用 ROR 和 Nominatim/OpenStreetMap,并在 GeoJSON 或候选响应中返回位置精度、置信度、来源说明和核验时间;前端详情卡展示这些字段。 + +算力中心图层行左上角的通知气泡显示 GeoJSON `unresolved` 数量。这个数字表示“完全没有可信坐标、不能渲染到地球上”的记录,不等同于地图上带 `?` 的已定位待确认点。点击气泡会在图层面板右侧打开固定信息卡,信息卡内容区内部滚动,不随鼠标 hover 消失。列表中的单条 `采集` 只展示候选;顶部 `一键采用` 会按当前列表顺序逐条采集、保存最高置信候选,成功一条就移除一条、重新编号,并通过 `earth:compute-center-unresolved-count-change` 同步气泡数量。批量结束后再触发 `earth:compute-center-location-saved` 刷新真实图层。 asset 图标大小由 `Interactable` 的 `icon.fitSize` 控制。SVG / 图片文件应尽量保持原始 viewBox 和路径,不要为了在地球上显示成 60x60 而手写 `transform`;`drawAssetIcon()` 会把资源等比 contain 到指定尺寸并居中绘制到 atlas canvas。 @@ -352,6 +354,10 @@ const scale = THREE.MathUtils.clamp( - 按钮:`#toggle-terrain` - 状态节点:`#terrain-status` +地形不是默认可见图层时,启动期不会立即阻塞加载地形瓦片。`controls.js` 会在图层可见性恢复完成后才调度 `scheduleTerrainPrefetch()`,并且只在高清材质可用、地形尚未 ready、预取未开始时执行。预取使用 `setTimeout` + `requestIdleCallback`,避免和首屏云图、高清材质、图层启动队列抢主线程。 + +地形瓦片请求也不再逐个散发大量单 tile 请求。`terrain.js` 会把需要的 Terrarium tile 去重后按 `TERRAIN_CONFIG.batchRequestSize` 分批请求 `/api/v1/visualization/terrain/terrarium/batch`;后端用 LRU 内存缓存、批次去重和并发限制代理 S3 Terrarium tile。单 tile endpoint 仍保留给回退路径和浏览器缓存语义。 + 以后别的异步图层也可以沿用这套约定。 ## 当前设置持久化 diff --git a/docs/technical/zh/frontend-admin-frontend-context.md b/docs/technical/zh/frontend-admin-frontend-context.md index 8e729e5f..1a4445be 100644 --- a/docs/technical/zh/frontend-admin-frontend-context.md +++ b/docs/technical/zh/frontend-admin-frontend-context.md @@ -155,6 +155,7 @@ - 渲染 `/docs` 的 Markdown 正文 - 支持标题、列表、引用、代码块、表格和基础行内格式 - 代码块和表格内部复用 `Scrollbar`,避免横向内容撑爆文档页 +- Docs 正文由后端 `/api/v1/docs/...` 按 Gatekeeper 权限返回;前端只渲染当前用户可见内容 当前约束: @@ -190,9 +191,10 @@ - token - 当前用户 +- Gatekeeper 权限组 - 登录/退出 -`App.tsx` 用它判断是否进入登录页。 +`App.tsx` 用它判断是否进入登录页。`/docs` 仍是公开路由,但目录和正文由后端按 token 决定;未登录时只返回公开文档。 ### 2. 业务数据网关 diff --git a/docs/technical/zh/location-pipeline-development.md b/docs/technical/zh/location-pipeline-development.md new file mode 100644 index 00000000..d7cce8c3 --- /dev/null +++ b/docs/technical/zh/location-pipeline-development.md @@ -0,0 +1,200 @@ +# 通用位置估算管线开发说明 + +`backend/app/services/location/` 是所有“给定一条记录,决定它的 lat/lon”业务的共享抽象。算力中心、BGP 观测站、BGP 事件目前都跑在这条管线上。未来需要位置估算的实体,例如卫星地面站、用户认领点位、IXP 设施,也应接入这里,而不是各自再写地理解析逻辑。 + +用户侧流程见 [Earth 位置候选采集使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/location-pipeline-user.md)。 + +## 设计目标 + +历史上算力中心有自己的 4 层链路,BGP 观测站使用写死字典,BGP 事件继承 collector。三套实现互不复用,新算法也没有稳定挂入点。 + +重构后的原则: + +- 共享 `LocationResolver` 协议和 `LocationPipeline` 编排器。 +- 各领域只负责构造 `LocationQuery` 和选择 resolver 顺序。 +- 新算法通过新增 resolver 类接入,不改 ingestion、API 和前端 envelope。 +- 只有达到城市级或更高精度的位置能渲染到 Earth。 +- 本地 JSON registry 不作为算力中心或 BGP 观测站的运行时候选来源;持久事实写入数据库维表。 + +## 核心接口 + +```python +@dataclass(frozen=True) +class LocationQuery: + name: str | None + aliases: tuple[str, ...] + city: str | None + country: str | None + region: str | None + source_latitude: float | None + source_longitude: float | None + extra: Mapping[str, Any] +``` + +```python +@dataclass(frozen=True) +class LocationCandidate: + latitude: float + longitude: float + display_name: str + precision: str + confidence: float + source: str + needs_confirmation: bool + matched_fields: tuple[str, ...] + suggested_registry_entry: dict | None +``` + +```python +class LocationResolver(Protocol): + name: str + def resolve(self, query: LocationQuery) -> ResolverOutput: ... +``` + +`LocationPipeline.collect_candidates()` 返回排序后的候选和 `attempted_queries`;`resolve_best()` 返回最佳候选及诊断信息。默认排序按 source rank、precision rank、confidence,且对同 source 和同坐标候选去重。 + +## 内置 resolver + +| Resolver | 文件 | 职责 | +| --- | --- | --- | +| `SourceCoordinatesResolver` | `resolvers/source_coordinates.py` | 源记录已有 lat/lon 时直接产出 `precision="precise"` | +| `RegistryResolver` | `resolvers/registry.py` | 遗留通用 resolver;当前算力中心和 BGP 运行时链路不使用它生成候选 | +| `NominatimResolver` | `resolvers/nominatim.py` | 按领域 query plan 调 Nominatim,带 LRU 缓存和速率限制 | +| `InheritFromAnotherEntityResolver` | `resolvers/inherit.py` | 把外部实体的已解析位置包装为候选 | + +`RegistryResolver` 仍保留给后续可能的受控导入场景,但它不应被重新接入算力中心或 BGP 作为“硬编码 hint”候选源。过去仅凭 `operator`、`city` 等通用字段匹配 registry 容易把多个实体落到同一个点,这是这次下线 registry 候选链路的主要原因。 + +## 当前领域管线 + +### 算力中心 + +入口文件: + +- [compute_center_locations.py](/home/ray/dev/linkong/planet/backend/app/services/compute_center_locations.py) + +管线顺序: + +```python +SourceCoordinatesResolver() +StoredComputeCenterLocationResolver() +``` + +主地图启动链路只做“源坐标优先,其次数据库维表坐标”。数据库表为 `compute_center_locations`,唯一键是 `(source, source_id)`,用于保存人工确认或从源记录真实坐标迁入的位置。`init_db()` 只幂等迁入源记录里已有的真实经纬度,不迁入旧硬编码 hint,不在启动期批量调用 ROR、Nominatim 或 LLM。 + +手动候选采集链路和渲染链路分开。`collect_location_candidates()` 使用源字段构造 ROR 和 Nominatim/OpenStreetMap 查询,但不会把 `compute_center_locations` 当前坐标当候选返回。用户在前端确认某个候选后,通过保存接口写入维表;之后地图刷新时由 `StoredComputeCenterLocationResolver` 渲染。 + +`resolve_compute_center_location()`、`resolve_compute_center_location_full()` 和 `collect_location_candidates()` 保留为领域 API。`visualization.py` 只消费领域 API,不再持有坐标提示常量、国家质心兜底或 Nominatim 细节。 + +GeoJSON 输出只包含 `RENDERABLE_PRECISIONS` 内的位置。未解析记录进入 `unresolved`,并带上 `failure_reason`、`attempted_queries`、`source_id`、`record_id` 等诊断字段。 + +### BGP 观测站 + +入口文件: + +- [bgp_collector_locations.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_collector_locations.py) +- [bgp_collector_location.py](/home/ray/dev/linkong/planet/backend/app/models/bgp_collector_location.py) + +管线顺序: + +```python +SourceCoordinatesResolver() +StoredCollectorLocationResolver() +NominatimResolver(_bgp_collector_query_plan) +``` + +23 个 RIPE RIS collector 坐标从旧表迁入 `bgp_collector_locations` 维表,默认 `source=legacy_seed`、`needs_confirmation=true`。旧字典仍由 DB-backed cache 维护,保证下游接口兼容;手动候选采集不会把这份维表坐标当作候选,只用它补齐 site/city/country 查询上下文。 + +### BGP 事件 + +入口文件: + +- [bgp_event_locations.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_event_locations.py) + +管线顺序: + +```python +SourceCoordinatesResolver() +InheritFromAnotherEntityResolver(_inherit_from_owning_collector) +``` + +事件继承使用所属 collector 的严格查找,不跑完整 collector registry 模糊匹配。后续 ASN 设施、PrefixGeo 或 PeeringDB resolver 可以挂在继承 resolver 之后。 + +## API envelope + +```http +POST /api/v1/visualization/compute-centers/{source_id}/collect-location +POST /api/v1/visualization/compute-centers/{source_id}/location +POST /api/v1/bgp/collectors/{collector_id}/collect-location +``` + +`collect-location` 返回统一 envelope: + +```json +{ + "success": true, + "candidates": [], + "best_candidate": {}, + "attempted_queries": [], + "context": {} +} +``` + +`POST /api/v1/visualization/compute-centers/{source_id}/location` 把前端选中的候选 upsert 到 `compute_center_locations`。人工保存默认 `needs_confirmation=false`、`verification_status="verified"` 并写入 `verified_at`;如果后续接入自动暂存,也可以显式传 `needs_confirmation=true`。 + +前端 [info-card.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/info-card.js) 使用通用候选列表和预览事件渲染对象详情卡。算力中心图层按钮左上角会显示 `unresolved` 数量;点击角标打开待定位列表。列表中的 `采集` 只拉候选,`一键采用` 会逐条调用候选采集接口,选择最高置信且有有效经纬度的候选保存。保存成功一条就从列表移除并重新编号,同时通过 `earth:compute-center-unresolved-count-change` 同步角标;批量结束后再触发 `earth:compute-center-location-saved` 刷新真实图层。 + +如果剩余记录没有任何 city-level 候选,批量采用不会伪造坐标。前端会保留这些记录并展示后端返回的 `failure_reason` 和已尝试查询。 + +## 新增 resolver + +resolver 只需要实现 `name` 和 `resolve()`,返回 `ResolverOutput`。 + +```python +class PeeringDBFacilityResolver: + name = "peeringdb_facility" + + def __init__(self, client): + self._client = client + + def resolve(self, query): + asn = query.extra.get("origin_asn") + if not asn: + return ResolverOutput() + return ResolverOutput(candidates=tuple( + LocationCandidate( + latitude=f.latitude, + longitude=f.longitude, + display_name=f.name, + precision="site", + confidence=0.78, + query=f"peeringdb::{asn}", + source=self.name, + source_note=f"PeeringDB facility for AS{asn}", + matched_fields=("origin_asn",), + needs_confirmation=False, + city=f.city, + country=f.country, + ) + for f in self._client.facilities_for_asn(asn) + )) +``` + +挂入: + +```python +BGP_EVENT_PIPELINE = LocationPipeline([ + SourceCoordinatesResolver(), + InheritFromAnotherEntityResolver(source_lookup=...), + PeeringDBFacilityResolver(client=peeringdb_client), +]) +``` + +## 测试覆盖 + +相关测试: + +- [test_location_pipeline.py](/home/ray/dev/linkong/planet/backend/tests/test_location_pipeline.py) +- [test_bgp_collector_locations.py](/home/ray/dev/linkong/planet/backend/tests/test_bgp_collector_locations.py) +- [test_visualization_compute_centers.py](/home/ray/dev/linkong/planet/backend/tests/test_visualization_compute_centers.py) + +测试重点包括 resolver 可插拔性、注册表 alias 约束、BGP collector 兼容字典、算力中心公共 API 兼容、不可渲染位置进入 `unresolved`。 diff --git a/docs/technical/zh/location-pipeline-user.md b/docs/technical/zh/location-pipeline-user.md new file mode 100644 index 00000000..5ff948f5 --- /dev/null +++ b/docs/technical/zh/location-pipeline-user.md @@ -0,0 +1,127 @@ +# Earth 位置候选采集使用手册 + +位置候选采集用于给 Earth 上的算力中心和 BGP 观测站补齐或核验经纬度。它不会要求用户手工输入坐标,而是把源数据、开放组织注册 API 和在线地理编码结果整理成候选列表,供用户预览和后续认领。 + +## 适用对象 + +当前支持: + +- 算力中心:TOP500 超算、Epoch AI GPU 集群。 +- BGP 观测站:RIPE RIS `rrcXX` collector。 + +BGP 事件的位置默认继承所属 collector。事件本身暂不提供单独按钮;后续 ASN 设施、Prefix 地理位置或 PeeringDB 算法接入后会继续走同一条管线。 + +## 用户能看到什么 + +在 Earth 上点击算力中心或 BGP 观测站后,详情卡会展示位置相关字段: + +| 字段 | 含义 | +| --- | --- | +| 位置精度 | `精确坐标`、`站点级位置`、`城市级位置` 或 `位置未确认` | +| 位置来源 | 源数据坐标、ROR 组织注册 API、Nominatim 在线搜索,或已存储的 BGP collector 维表位置 | +| 位置置信度 | 后端 resolver 给出的相对置信度百分比 | +| 核验状态 | 已确认、估算位置或在线检索结果待确认 | +| 解析依据 | 为什么选择这个位置,例如匹配了哪个站点或城市 | +| 匹配的位置名称 | 开放来源、在线结果或已存储 collector 位置中的规范名称 | +| 位置核验时间 | 已确认位置的核验日期,在线候选通常为空 | + +算力中心 GeoJSON 不再渲染国家质心、未知位置或 `[0, 0]` 占位点。无法达到城市级精度的数据会进入接口的 `unresolved` 列表,并在图层开关左上角显示待定位数量。点击这个通知气泡会打开待定位列表。 + +地图上带 `?` 的算力中心不是 `unresolved`。它们已经有坐标,只是 `needs_confirmation=true` 或来自在线地理编码,仍需人工核验。真正 `unresolved` 的记录没有可信经纬度,因此不会出现在地球上。 + +## 自动采集候选 + +1. 打开 `http://localhost:3000/earth`。 +2. 打开 `算力中心` 或 `BGP 观测` 图层。 +3. 点击目标对象打开详情卡。 +4. 点击 `自动采集坐标候选` 或 `重新自动采集坐标`。 +5. 等待详情卡列出最多 5 个候选位置。 +6. 点击候选行里的 `预览`,Earth 会飞到该候选经纬度附近。 + +候选列表会显示: + +- 候选名称。 +- 精度:精确、站点或城市。 +- 来源 resolver。 +- 置信度。 +- 经纬度。 + +点击候选行里的 `保存` 会把所选候选写入算力中心位置维表。保存成功后,算力中心图层会刷新;如果该记录原本在待定位列表中,待定位数量也会减少。 + +## 待定位列表和一键采用 + +算力中心图层按钮左上角的通知气泡显示当前 `unresolved` 数量。点击后会在图层面板右侧打开固定列表: + +1. 列表只包含没有可信经纬度的算力中心。 +2. 单条 `采集` 会调用候选接口,并展示最多 5 个候选供预览和保存。 +3. 顶部 `一键采用` 会从上到下逐条采集候选,选择置信度最高且有有效经纬度的候选保存。 +4. 成功保存一条后,该行会立即从列表中移除,下面的序号自动上移,通知气泡数量同步减少。 +5. 批量结束后,前端会刷新算力中心图层,确保 UI 和后端真实状态一致。 + +如果某条记录没有任何可保存候选,系统不会用国家中心点、厂商总部或硬编码 hint 伪造位置。该记录会留在列表中,并显示后端返回的失败原因和已尝试查询,等待人工补充更可靠的地址或坐标证据。 + +## 后端接口 + +前端按钮调用的接口如下: + +```http +POST /api/v1/visualization/compute-centers/{source_id}/collect-location +POST /api/v1/visualization/compute-centers/{source_id}/location +POST /api/v1/bgp/collectors/{collector_id}/collect-location +``` + +两个 `collect-location` 接口返回相同结构: + +```json +{ + "success": true, + "candidates": [], + "best_candidate": {}, + "attempted_queries": [], + "context": {} +} +``` + +当没有候选达到城市级精度时,`success` 为 `false`,响应会包含 `failure_reason` 和已尝试的查询文本,便于判断是源数据字段不足、开放来源缺项,还是在线地理编码没有命中。 + +## 数据维护建议 + +算力中心和 BGP 观测站都不再维护本地候选注册表。算力中心的人工确认位置保存在 `compute_center_locations` 数据库维表中,唯一键是 `(source, source_id)`;BGP 观测站的当前位置保存在 `bgp_collector_locations` 数据库维表中,旧 RIPE RIS 城市级坐标只作为初始化 seed 写入,默认仍需人工核验。 + +维护算力中心时优先补齐: + +- `source` / `source_id`:例如 `top500` + `top500_50`。 +- `name` / `operator` / `site`。 +- `city` / `country`。 +- `latitude` / `longitude`。 +- `precision`:`precise`、`site` 或 `city`。 +- `confidence`:0 到 1 的置信度。 +- `location_source` / `source_url` / `source_note` / `raw_payload`:证据来源。 +- `needs_confirmation` / `verification_status` / `verified_at`:人工核验状态和日期。 + +维护 BGP 观测站时优先补齐: + +- `collector_id`:例如 `rrc12`。 +- `site` / `operator`:站点和运营方。 +- `city` / `country` / `region`。 +- `latitude` / `longitude`。 +- `precision`:`precise`、`site` 或 `city`。 +- `confidence`:0 到 1 的置信度。 +- `source` / `source_url` / `raw_payload`:证据来源。 +- `verification_status` / `verified_at`:人工核验状态和日期。 + +如果只是知道城市,不知道设施坐标,应使用城市级精度,不要填一个看似精确但无法核验的点位。 + +## 常见问题 + +### 为什么有些算力中心不显示在 Earth 上 + +Earth 只渲染达到城市级或更高精度的坐标。源数据没有坐标、已验证位置没有命中、在线搜索也没有城市级结果时,记录会进入 `unresolved`,避免在地图上出现误导性的国家中心点或 `[0, 0]`。 + +### 为什么在线搜索结果显示“待确认” + +Nominatim/OpenStreetMap 结果来自在线地理编码,可能匹配到同名城市、机构或园区。它可以用于快速定位和预览,但在写入已验证位置前应人工确认。 + +### 为什么 BGP 事件没有全部落到 Amsterdam + +旧逻辑中,事件可能因为 `operator="RIPE NCC"` 这种通用字段误匹配到 `rrc00`。当前 BGP 事件继承只按所属 collector 在 DB-backed cache 中严格查找,不再用 registry 模糊匹配。 diff --git a/docs/technical/zh/manual.md b/docs/technical/zh/manual.md index 8d57a424..0677dd7d 100644 --- a/docs/technical/zh/manual.md +++ b/docs/technical/zh/manual.md @@ -5,7 +5,7 @@ - `planet.sh`:本地启动、停止、重启、健康检查和日志入口 - Earth:公开 3D 地球态势页面 - 控制台:登录后的管理后台 -- Docs:公开开发文档与使用手册 +- Docs:后端 Gatekeeper 受控的文档站,基础使用文档公开,开发/运维文档按权限组开放 快速启动路径见 [快速开始](/home/ray/dev/linkong/planet/docs/technical/zh/quickstart.md)。 @@ -16,7 +16,7 @@ | 名称 | 地址 | 是否需要登录 | 说明 | | --- | --- | --- | --- | | Earth | `http://localhost:3000/earth` | 否 | 3D 地球、图层、BGP、卫星、海缆、新闻态势 | -| Docs | `http://localhost:3000/docs` | 否 | 开发文档、技术说明、使用手册 | +| Docs | `http://localhost:3000/docs` | 部分需要 | 使用手册公开;开发、后端、运维文档按 Gatekeeper 权限组开放 | | 控制台 | `http://localhost:3000/admin` | 是 | 数据、配置、告警、日志和专题观测 | | AI Playground | `http://localhost:3000/playground` | 是 | AI Provider 状态和调试 | | 后端 API 文档 | `http://localhost:8000/docs` | 视接口而定 | FastAPI / OpenAPI 文档 | @@ -198,7 +198,26 @@ AI Provider 镜像只在代码、Dockerfile、Compose 配置或相关 Python 依 - 手机或平板演示 Earth - 局域网其他机器访问同一个开发实例 -启动后注意检查防火墙和 WSL 网络转发。 +`--allow-lan` 只负责让前端和后端监听 `0.0.0.0`。如果服务运行在 WSL 中,Windows 本机通常可以通过 `localhost` 访问,但手机或其他电脑访问 `http://:3000` 还依赖 Windows 端口转发和防火墙放行。 + +推荐按顺序判断: + +```bash +# 在 WSL 或运行 Planet 的 shell 中 +curl http://localhost:3000 +curl http://localhost:8000/health +ss -ltnp | grep -E ':3000|:8000' +``` + +如果这里能看到 `0.0.0.0:3000` 和 `0.0.0.0:8000`,但局域网 IP 访问失败,请在管理员 PowerShell 中配置: + +```powershell +netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000 +netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8000 connectaddress=127.0.0.1 connectport=8000 + +New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000 +New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000 +``` ## Earth @@ -290,6 +309,12 @@ Earth 搜索支持查找当前地球对象,例如: 搜索结果可以用于快速定位对象,并打开对应详情。 +### 位置候选采集 + +算力中心和 BGP 观测站详情卡支持自动采集坐标候选。点击对象后,使用详情卡中的 `自动采集坐标候选` 或 `重新自动采集坐标` 按钮,后端会从源坐标、开放组织注册 API 和在线地理编码中整理候选位置。BGP 观测站的已存储位置只用于补齐查询上下文,不会作为候选直接返回。 + +候选可以直接在 Earth 上预览。算力中心候选点击 `保存` 后会写入 `compute_center_locations` 维表,并立即刷新图层。算力中心图层左上角的通知气泡显示无法渲染的待定位数量;点击后可查看列表,单条采集候选,或用 `一键采用` 从上到下保存最高置信候选。没有可用候选的记录会留在列表中,不会被国家中心点或硬编码 hint 伪造位置。详细流程见 [Earth 位置候选采集使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/location-pipeline-user.md)。 + ### 设置 设置面板包含: @@ -536,33 +561,41 @@ BarentsWatch AIS 支持从以下位置读取凭证: ## Docs -公开文档站入口: +文档站入口: ```text http://localhost:3000/docs ``` -当前公开内容来自: +Docs 正文由后端 API 按权限读取,不再把全部 Markdown 直接打进前端构建产物。当前文档源文件仍位于: ```text docs/technical/zh/*.md docs/technical/en/*.md ``` +未登录访客默认只能看到 `public` 文档,例如首页、快速开始和使用手册。登录用户如果被分配 Gatekeeper 权限组,可以看到更多技术文档: + +- `docs_user`:用户操作类文档。 +- `docs_developer`:Earth、前端、后端、采集器和 AI Provider 等开发文档。 +- `docs_admin`:服务控制、运维、环境变量和敏感操作文档。 + +`admin` 默认拥有管理文档权限,`super_admin` 拥有全部 Docs 权限。Gatekeeper 权限组在控制台“用户管理”中配置。 + Docs 支持: - 分类导航 - Markdown 渲染 - 表格和代码块 - 文档内目录 -- 本地搜索 +- 对当前可见文档搜索 - technical 文档之间的内部链接跳转 如果新增 technical 文档,应同步检查: - 是否有清晰的一级标题 -- 是否需要加入 `/docs` 的人工分类和排序 -- 是否包含不适合公开展示的信息 +- 是否需要加入后端 Docs metadata 的人工分类和排序 +- 应归入 `public`、`docs_user`、`docs_developer` 还是 `docs_admin` ## 开发命令约定 @@ -633,6 +666,7 @@ source ~/.zshrc && bun run build - [控制台前端结构](/home/ray/dev/linkong/planet/docs/technical/zh/frontend-admin-frontend-context.md) - [Earth 前端结构](/home/ray/dev/linkong/planet/docs/technical/zh/earth-frontend-context.md) - [Earth 图层样式属性索引](/home/ray/dev/linkong/planet/docs/technical/zh/earth-layer-style-reference.md) +- [Earth 位置候选采集使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/location-pipeline-user.md) - [系统服务控制](/home/ray/dev/linkong/planet/docs/technical/zh/backend-system-service-control.md) - [数据采集系统](/home/ray/dev/linkong/planet/docs/technical/zh/backend-collectors.md) - [数据源、采集器设置与连接验证](/home/ray/dev/linkong/planet/docs/technical/zh/datasource-collector-settings-connectivity.md) diff --git a/docs/technical/zh/ops-planet-sh-startup.md b/docs/technical/zh/ops-planet-sh-startup.md index e4cc1643..e0f5bc75 100644 --- a/docs/technical/zh/ops-planet-sh-startup.md +++ b/docs/technical/zh/ops-planet-sh-startup.md @@ -150,6 +150,13 @@ wait_for_port_release() { } ``` +当前启动前端时还有一层预清理重试: + +- `PORT_PRESTART_RETRIES`:默认 3 次。 +- `PORT_PRESTART_RETRY_INTERVAL`:默认 2 秒。 + +`kill_port_if_requested()` 只在当前环境能找到监听 PID 时主动杀进程;如果没有 PID 但端口暂时不可绑定,它会记录诊断并把最终确认交给服务启动流程。`start_frontend_with_retry()` 也只在发现监听 PID 时进入预清理重试,避免在宿主机或外部 network namespace 尚未释放端口时做无意义的“空杀重试”。这意味着第一次重启时看到“未发现监听进程但端口仍不可绑定”通常是外部环境仍在释放端口;脚本不会再把这种情况当成立刻失败的本地进程清理问题。 + ## 问题三:端口检测用 Python ### 原因 diff --git a/docs/technical/zh/quickstart.md b/docs/technical/zh/quickstart.md index 7f5e1201..9590a8fd 100644 --- a/docs/technical/zh/quickstart.md +++ b/docs/technical/zh/quickstart.md @@ -30,6 +30,16 @@ AI Provider 的个人配置也可以放在 `~/.zshrc`。`planet.sh` 会读取简 ./planet.sh restart -a ``` +AISStream、BarentsWatch 等采集器凭证也可以先写在 `~/.zshrc` 里供连接验证读取,例如: + +```bash +export AISSTREAM_API_KEY="..." +export BARENTSWATCH_CLIENT_ID="..." +export BARENTSWATCH_CLIENT_SECRET="..." +``` + +正式采集更推荐在控制台 `设置 -> 采集器设置` 保存凭证,尤其是 AISStream 这类长连接 WebSocket collector。这样连接验证、后端采集任务和 Earth 实时船只聚合会使用同一份配置。 + ## 1. 启动服务 在仓库根目录执行: @@ -44,7 +54,7 @@ AI Provider 的个人配置也可以放在 `~/.zshrc`。`planet.sh` 会读取简 | --- | --- | --- | | Earth | `http://localhost:3000/earth` | 公开 3D Earth 可视化页面 | | 控制台 | `http://localhost:3000/admin` | 登录后的管理后台 | -| 文档站 | `http://localhost:3000/docs` | 公开开发文档和使用手册 | +| 文档站 | `http://localhost:3000/docs` | 使用手册公开;开发/运维文档按 Gatekeeper 权限组开放 | | AI Playground | `http://localhost:3000/playground` | 登录后的 AI 调试入口 | | 后端 API 文档 | `http://localhost:8000/docs` | FastAPI / OpenAPI 接口文档 | @@ -64,6 +74,8 @@ AI Provider 的个人配置也可以放在 `~/.zshrc`。`planet.sh` 会读取简 按提示输入用户名、密码和角色。 +如果需要阅读开发或运维文档,用 `super_admin` 登录控制台后,在“用户管理”里给目标用户分配 Gatekeeper 权限组:`docs_developer` 用于开发文档,`docs_admin` 用于服务控制和运维文档。 + ## 3. 打开 Earth 访问: @@ -79,6 +91,7 @@ Earth 是公开页面,不需要登录。 - 地球正常显示 - 右侧图层控制可打开/关闭图层 - 搜索可以查找海缆、卫星、算力中心、BGP 事件 +- 算力中心和 BGP 观测站详情卡可以自动采集并预览坐标候选;算力中心待定位气泡可以打开列表并保存候选 - 鼠标拖动、滚轮缩放和缩放百分比提示正常工作 - 设置面板可以切换巡航模式、日夜模式、卫星显示风格 @@ -176,6 +189,14 @@ http://localhost:3000/admin 这会让前端和后端监听局域网可访问地址。 +注意:`--allow-lan` 只负责让 Planet 服务监听 `0.0.0.0`,不等于自动把 WSL 服务暴露到 Windows 局域网 IP。常见情况是: + +- WSL 内 `localhost:3000` / `localhost:8000` 能访问 +- Windows 本机 `localhost:3000` / `localhost:8000` 能访问 +- 但手机或其他电脑访问 `http://:3000` 失败 + +这通常说明 Windows 端还缺少端口转发或防火墙放行。 + 如果访问失败,先在运行 Planet 的 shell 中检查: ```bash @@ -184,6 +205,16 @@ curl http://localhost:8000/health ss -ltnp | grep -E ':3000|:8000' ``` +如果确认 WSL 中已监听 `0.0.0.0:3000` 和 `0.0.0.0:8000`,但局域网 IP 仍不能访问,请在管理员 PowerShell 中配置 Windows 端转发和防火墙: + +```powershell +netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000 +netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8000 connectaddress=127.0.0.1 connectport=8000 + +New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000 +New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000 +``` + ## 9. 停止服务 ```bash diff --git a/docs/version-history.md b/docs/version-history.md index b784aa51..6976a24b 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,12 +16,13 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.48.0` +- `dev` 当前开发分支历史推导到:`0.49.0` ## Timeline | Version | Type | Branch | Commit | Summary | | --- | --- | --- | --- | --- | +| `0.49.0` | feature | `dev` | `pending` | 新增位置解析 Pipeline、BGP/算力中心地理定位、Docs Gatekeeper、Earth 新闻栏与 Mobile 国家高亮 | | `0.48.0` | feature | `dev` | `pending` | 新增自定义源 REST/WebSocket 实时 mock 链路,完善 AIS 多源聚合/船舶 enrichment,并将 Earth 全球态势统计改为轻量 SQL 聚合 | | `0.47.0` | feature | `dev` | `pending` | 新增 AISStream WebSocket 船只采集器、多源 AIS 原始观测聚合、采集器状态配置、船型显示修正和文档规则解耦 | | `0.46.3` | bugfix | `dev` | `pending` | 优化 Starlink footprint 拖拽性能,避免旋转地球时重复重建覆盖网格,并恢复线缆点击呼吸动画 | diff --git a/frontend/package.json b/frontend/package.json index 141cc612..deda0ea2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.48.0", + "version": "0.49.0", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/frontend/public/earth/css/info-panel.css b/frontend/public/earth/css/info-panel.css index e376fc97..79f88fbb 100644 --- a/frontend/public/earth/css/info-panel.css +++ b/frontend/public/earth/css/info-panel.css @@ -307,6 +307,10 @@ -webkit-user-select: none; } +.info-card.compute_unresolved .info-card-content { + max-height: min(calc(330px * var(--hud-scale)), calc(100vh - 180px)); +} + .info-card-content::-webkit-scrollbar { width: 4px; } @@ -529,3 +533,180 @@ max-width: none; text-align: left; } + +.info-card-compute-collect { + margin-top: calc(8px * var(--hud-scale)); + padding-top: calc(8px * var(--hud-scale)); + border-top: 1px solid rgba(214, 229, 245, 0.06); + pointer-events: auto; +} + +.info-card-compute-collect-button { + display: inline-flex; + align-items: center; + gap: 4px; + padding: calc(3px * var(--hud-scale)) calc(8px * var(--hud-scale)); + background: transparent; + color: var(--hud-text-soft); + border: 1px solid rgba(214, 229, 245, 0.18); + border-radius: 4px; + cursor: pointer; + font-size: calc(0.7rem * var(--hud-scale)); + letter-spacing: 0.04em; + transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease; +} + +.info-card-compute-collect-button:hover:not(:disabled) { + color: #c9dcff; + background: rgba(72, 138, 255, 0.14); + border-color: rgba(72, 138, 255, 0.45); +} + +.info-card-compute-collect-button:disabled { + opacity: 0.55; + cursor: progress; +} + +.info-card-compute-collect-button .material-symbols-rounded { + font-size: calc(13px * var(--hud-scale)); +} + +.info-card-compute-collect-status { + margin-top: calc(8px * var(--hud-scale)); + color: var(--hud-text-soft); + font-size: calc(0.7rem * var(--hud-scale)); +} + +.info-card-compute-collect-candidates { + margin-top: calc(6px * var(--hud-scale)); + display: flex; + flex-direction: column; + gap: calc(6px * var(--hud-scale)); +} + +.info-card-compute-candidate { + background: rgba(214, 229, 245, 0.04); + border: 1px solid rgba(214, 229, 245, 0.08); + border-radius: 6px; + padding: calc(6px * var(--hud-scale)) calc(8px * var(--hud-scale)); + font-size: calc(0.7rem * var(--hud-scale)); +} + +.info-card-compute-candidate.is-best { + border-color: rgba(72, 138, 255, 0.5); + background: rgba(72, 138, 255, 0.1); +} + +.info-card-compute-candidate-line { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; +} + +.info-card-compute-candidate-precision { + color: #cfe1ff; + font-weight: 600; +} + +.info-card-compute-candidate-preview { + background: transparent; + color: #c9dcff; + border: 1px solid rgba(214, 229, 245, 0.18); + border-radius: 4px; + cursor: pointer; + padding: 2px 6px; + font-size: calc(0.68rem * var(--hud-scale)); +} + +.info-card-compute-candidate-preview:hover { + background: rgba(72, 138, 255, 0.18); +} + +.info-card-unresolved-summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: calc(8px * var(--hud-scale)); + padding: calc(4px * var(--hud-scale)) 0 calc(8px * var(--hud-scale)); + color: var(--hud-text-soft); + font-size: calc(0.72rem * var(--hud-scale)); + line-height: 1.35; +} + +.info-card-unresolved-summary > span { + min-width: 0; +} + +.info-card-unresolved-list { + display: flex; + flex-direction: column; + gap: calc(7px * var(--hud-scale)); +} + +.info-card-unresolved-item { + padding: calc(7px * var(--hud-scale)) calc(8px * var(--hud-scale)); + border: 1px solid rgba(214, 229, 245, 0.08); + border-radius: 6px; + background: rgba(214, 229, 245, 0.035); + pointer-events: auto; +} + +.info-card-unresolved-main { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: calc(8px * var(--hud-scale)); +} + +.info-card-unresolved-index { + display: inline-flex; + align-items: center; + justify-content: center; + width: calc(18px * var(--hud-scale)); + height: calc(18px * var(--hud-scale)); + border-radius: 999px; + background: rgba(255, 171, 81, 0.14); + color: #ffd59b; + font-size: calc(0.62rem * var(--hud-scale)); + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.info-card-unresolved-copy { + min-width: 0; +} + +.info-card-unresolved-name { + overflow: hidden; + color: var(--hud-title); + font-size: calc(0.78rem * var(--hud-scale)); + font-weight: 600; + line-height: 1.3; + text-overflow: ellipsis; + white-space: nowrap; +} + +.info-card-unresolved-meta { + overflow: hidden; + color: var(--hud-text-soft); + font-size: calc(0.64rem * var(--hud-scale)); + line-height: 1.3; + text-overflow: ellipsis; + white-space: nowrap; +} + +.info-card-unresolved-adopt { + color: #ffe0aa; + border-color: rgba(255, 171, 81, 0.32); +} + +.info-card-unresolved-adopt:hover { + background: rgba(255, 171, 81, 0.16); +} + +.info-card-unresolved-empty { + padding: calc(10px * var(--hud-scale)) 0; + color: var(--hud-text-soft); + font-size: calc(0.74rem * var(--hud-scale)); +} diff --git a/frontend/public/earth/css/layer-panel.css b/frontend/public/earth/css/layer-panel.css index a29de894..ef3dc873 100644 --- a/frontend/public/earth/css/layer-panel.css +++ b/frontend/public/earth/css/layer-panel.css @@ -180,6 +180,7 @@ } .layer-row { + position: relative; display: flex; align-items: center; gap: calc(8px * var(--hud-scale)); @@ -324,6 +325,42 @@ transform: translateX(calc(14px * var(--hud-scale))); } +.layer-row-notification-badge { + appearance: none; + position: absolute; + top: calc(4px * var(--hud-scale)); + left: calc(19px * var(--hud-scale)); + z-index: 2; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: calc(16px * var(--hud-scale)); + height: calc(16px * var(--hud-scale)); + padding: 0 calc(4px * var(--hud-scale)); + border: 1px solid rgba(255, 226, 186, 0.62); + border-radius: 999px; + background: linear-gradient(180deg, rgba(255, 171, 81, 0.96), rgba(213, 78, 54, 0.96)); + box-shadow: + 0 calc(2px * var(--hud-scale)) calc(6px * var(--hud-scale)) rgba(2, 8, 20, 0.42), + 0 0 calc(10px * var(--hud-scale)) rgba(255, 123, 67, 0.34); + color: #fff8e8; + font-size: calc(0.5rem * var(--hud-scale)); + font-weight: 700; + font-variant-numeric: tabular-nums; + line-height: 1; + cursor: pointer; + transition: + filter 0.16s ease, + transform 0.16s ease, + border-color 0.16s ease; +} + +.layer-row-notification-badge:hover { + filter: brightness(1.08); + transform: translateY(calc(-1px * var(--hud-scale))); + border-color: rgba(255, 238, 205, 0.78); +} + @keyframes layer-toggle-loading-track { 0% { background-position: 0% 50%; diff --git a/frontend/public/earth/js/bgp.js b/frontend/public/earth/js/bgp.js index 6bfd546b..cb71ad1f 100644 --- a/frontend/public/earth/js/bgp.js +++ b/frontend/public/earth/js/bgp.js @@ -1,7 +1,10 @@ import * as THREE from "three"; import { BGP_CONFIG, CONFIG, PATHS } from "./constants.js"; -import { createInteractableLayer } from "./interactable.js"; +import { + createInteractableLayer, + SURFACE_AVOIDANCE_PROFILES, +} from "./interactable.js"; import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js"; const bgpGroup = new THREE.Group(); @@ -397,6 +400,7 @@ const bgpCollectorIconLayer = createInteractableLayer({ activity, }; }, + avoidance: SURFACE_AVOIDANCE_PROFILES.city, }); function clamp(value, min, max) { @@ -1480,20 +1484,28 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru scale *= 1 + 0.05 * pulse; } + // When this collector shares a city-level avoidance bucket with another + // interactable layer (e.g. compute centers), the icon is fanned out by + // ~1.4u but the decorative halos extend 11–40u and would still cover the + // neighbour. Shrink+fade them so the other layer remains visible. + const crossLayer = Boolean(marker.userData.icon_avoidance_cross_layer); + const haloOpacityMul = crossLayer && !isLocked && !isHovered ? 0.18 : 1; + const haloScaleMul = crossLayer && !isLocked && !isHovered ? 0.45 : 1; + if (marker.userData.heatHalo) { marker.userData.heatHalo.position.copy(marker.position); - marker.userData.heatHalo.material.opacity = haloOpacity; + marker.userData.heatHalo.material.opacity = haloOpacity * haloOpacityMul; marker.userData.heatHalo.material.color.setHex(marker.userData.baseColor || BGP_CONFIG.collectorColor); marker.userData.heatHalo.scale.setScalar( - marker.userData.activity?.haloScale * 0.58 * (1 + pulse * 0.01), + marker.userData.activity?.haloScale * 0.58 * (1 + pulse * 0.01) * haloScaleMul, ); } if (marker.userData.pulseHalo) { marker.userData.pulseHalo.position.copy(marker.position); - marker.userData.pulseHalo.material.opacity = pulseOpacity; + marker.userData.pulseHalo.material.opacity = pulseOpacity * haloOpacityMul; marker.userData.pulseHalo.material.color.setHex(marker.userData.baseColor || BGP_CONFIG.collectorColor); marker.userData.pulseHalo.scale.setScalar( - marker.userData.activity?.pulseHaloScale * 0.48 * (1 + pulse * 0.02), + marker.userData.activity?.pulseHaloScale * 0.48 * (1 + pulse * 0.02) * haloScaleMul, ); } if (marker.userData.statusCore) { @@ -1511,10 +1523,10 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru } if (marker.userData.coverageHalo) { marker.userData.coverageHalo.position.copy(marker.position); - marker.userData.coverageHalo.material.opacity = coverageOpacity; + marker.userData.coverageHalo.material.opacity = coverageOpacity * haloOpacityMul; marker.userData.coverageHalo.scale.set( - marker.userData.activity?.coverageHaloScale * 0.82 * (1 + pulse * 0.012), - marker.userData.activity?.coverageHaloScale * 0.56 * (1 + pulse * 0.012), + marker.userData.activity?.coverageHaloScale * 0.82 * (1 + pulse * 0.012) * haloScaleMul, + marker.userData.activity?.coverageHaloScale * 0.56 * (1 + pulse * 0.012) * haloScaleMul, 1, ); } diff --git a/frontend/public/earth/js/compute-centers.js b/frontend/public/earth/js/compute-centers.js index c7242da2..18d7553a 100644 --- a/frontend/public/earth/js/compute-centers.js +++ b/frontend/public/earth/js/compute-centers.js @@ -1,5 +1,8 @@ import { COMPUTE_CENTER_CONFIG, PATHS } from "./constants.js"; -import { createInteractableLayer } from "./interactable.js"; +import { + createInteractableLayer, + SURFACE_AVOIDANCE_PROFILES, +} from "./interactable.js"; const COMPUTE_CENTER_RENDER_ORDER = 4.5; const COMPUTE_CENTER_POINT_SIZE = 36; @@ -13,6 +16,9 @@ const COMPUTE_CENTER_ICON_SOURCES = { let showComputeCenters = true; let supercomputerCount = 0; let gpuClusterCount = 0; +let unresolvedComputeCenters = []; + +const COLLECT_LOCATION_API_BASE = "/api/v1/visualization/compute-centers"; function buildComputeCenterMarkerData(feature) { const props = feature?.properties || {}; @@ -88,6 +94,13 @@ function drawComputeCenterEstimatedBadge(context, isEstimated = false) { } } +function shouldShowComputeCenterEstimatedBadge(data) { + if (!data) return false; + if (data.needs_confirmation === true) return true; + if (data.location_source === "nominatim_online_geocode") return true; + return false; +} + function normalizeSiteType(siteType) { return siteType === "supercomputer" ? "supercomputer" : "gpu_cluster"; } @@ -133,9 +146,10 @@ const computeCenterIconLayer = createInteractableLayer({ ); }, afterDraw(context, { marker, item }) { + const data = marker?.userData || item; drawComputeCenterEstimatedBadge( context, - Boolean(marker?.userData?.is_estimated ?? item?.is_estimated), + shouldShowComputeCenterEstimatedBadge(data), ); }, }, @@ -147,12 +161,13 @@ const computeCenterIconLayer = createInteractableLayer({ getBucketKey: (marker) => [ marker.userData?.site_type || "gpu_cluster", - marker.userData?.is_estimated ? "estimated" : "precise", + shouldShowComputeCenterEstimatedBadge(marker.userData) ? "estimated" : "verified", ].join(":"), getUserData: (item) => ({ ...item, pulseOffset: Math.random() * Math.PI * 2, }), + avoidance: SURFACE_AVOIDANCE_PROFILES.city, }); export function formatComputeCenterTypeLabel(siteType) { @@ -176,9 +191,33 @@ export function formatComputeCenterUpdatedAt(value) { export function formatComputeCenterLocationPrecision(markerData) { const precision = markerData?.location_precision; if (precision === "precise") return "精确坐标"; - if (precision === "estimated_site") return "估算位置(站点级)"; - if (precision === "estimated_country") return "估算位置(国家级)"; - return "位置未知"; + if (precision === "site") return "站点级位置"; + if (precision === "city") return "城市级位置"; + return "位置未确认"; +} + +const COMPUTE_CENTER_LOCATION_SOURCE_LABELS = { + source_coordinates: "源数据自带坐标", + ror_organization_registry: "ROR 组织注册 API", + nominatim_online_geocode: "Nominatim 在线搜索", +}; + +export function formatComputeCenterLocationSource(markerData) { + const source = markerData?.location_source; + if (!source) return "未知来源"; + return COMPUTE_CENTER_LOCATION_SOURCE_LABELS[source] || source; +} + +export function formatComputeCenterNeedsConfirmation(markerData) { + if (markerData?.needs_confirmation === true) return "待人工核验"; + if (markerData?.is_estimated === true) return "估算位置"; + return "已确认"; +} + +export function formatComputeCenterLocationConfidence(markerData) { + const confidence = Number(markerData?.location_confidence); + if (!Number.isFinite(confidence)) return "-"; + return `${Math.round(confidence * 100)}%`; } export function getComputeCenterLegendItems() { @@ -226,9 +265,92 @@ export function clearComputeCenterSelection() { export function clearComputeCenterData(earth) { supercomputerCount = 0; gpuClusterCount = 0; + unresolvedComputeCenters = []; computeCenterIconLayer.clearData(earth); } +export function getUnresolvedComputeCenters() { + return unresolvedComputeCenters.slice(); +} + +// Generic helper used by every entity type that wires the shared +// /collect-location backend pipeline. The endpoint shape (sources, payload +// keys) is domain-specific; the request/response envelope is unified +// (success / candidates / attempted_queries / failure_reason / context). +export async function collectLocationCandidates(endpoint, payload = {}) { + if (!endpoint) throw new Error("endpoint is required"); + const response = await fetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload || {}), + }); + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error( + `Collect location failed: HTTP ${response.status} ${text}`.trim(), + ); + } + return response.json(); +} + +export async function collectComputeCenterLocation(sourceId, context = {}) { + if (!sourceId) { + throw new Error("sourceId is required"); + } + const url = `${COLLECT_LOCATION_API_BASE}/${encodeURIComponent(sourceId)}/collect-location`; + return collectLocationCandidates(url, { + name: context?.name ?? null, + operator: context?.operator ?? null, + site: context?.site ?? null, + organization: context?.organization ?? null, + city: context?.city ?? null, + country: context?.country ?? null, + source: context?.source ?? null, + id: context?.record_id ?? null, + }); +} + +export async function saveComputeCenterLocation(sourceId, candidate = {}, context = {}) { + if (!sourceId) { + throw new Error("sourceId is required"); + } + const latitude = Number(candidate?.latitude); + const longitude = Number(candidate?.longitude); + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) { + throw new Error("candidate latitude/longitude are required"); + } + const url = `${COLLECT_LOCATION_API_BASE}/${encodeURIComponent(sourceId)}/location`; + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + source: context?.source ?? null, + name: context?.name ?? candidate?.matched_location_name ?? candidate?.display_name ?? null, + operator: context?.operator ?? null, + site: context?.site ?? null, + city: candidate?.city ?? context?.city ?? null, + country: candidate?.country ?? context?.country ?? null, + latitude, + longitude, + precision: candidate?.precision ?? "city", + confidence: candidate?.confidence ?? null, + location_source: candidate?.source ?? "manual_selection", + source_url: candidate?.source_url ?? null, + source_note: candidate?.source_note ?? null, + raw_payload: candidate || {}, + needs_confirmation: false, + verification_status: "verified", + }), + }); + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error( + `Save compute center location failed: HTTP ${response.status} ${text}`.trim(), + ); + } + return response.json(); +} + export function toggleComputeCenters(show) { showComputeCenters = Boolean(show); computeCenterIconLayer.setVisible(showComputeCenters); @@ -245,8 +367,10 @@ export async function loadComputeCenters(_scene, earth) { } const payload = await response.json(); const features = Array.isArray(payload?.features) ? payload.features : []; + const unresolved = Array.isArray(payload?.unresolved) ? payload.unresolved : []; clearComputeCenterData(earth); + unresolvedComputeCenters = unresolved; const markerData = spreadComputeCenterPositions( features @@ -271,6 +395,8 @@ export async function loadComputeCenters(_scene, earth) { totalCount: getComputeCenterCount(), supercomputerCount, gpuClusterCount, + unresolvedCount: unresolvedComputeCenters.length, + unresolved: unresolvedComputeCenters.slice(), summary: getComputeCenterStatusSummary(), }; } diff --git a/frontend/public/earth/js/constants.js b/frontend/public/earth/js/constants.js index 6e56df54..061df60a 100644 --- a/frontend/public/earth/js/constants.js +++ b/frontend/public/earth/js/constants.js @@ -160,6 +160,7 @@ export const TERRAIN_CONFIG = { exaggeration: 34, landRevealFadeMeters: 220, maxConcurrentRequests: 10, + batchRequestSize: 64, opacity: 0.68, color: 0x8aa884, emissive: 0x030704, @@ -167,6 +168,7 @@ export const TERRAIN_CONFIG = { shininess: 16, urlTemplate: "/api/v1/visualization/terrain/terrarium/{z}/{x}/{y}.png", + batchUrl: "/api/v1/visualization/terrain/terrarium/batch", }; export const COUNTRY_BOUNDARY_CONFIG = { diff --git a/frontend/public/earth/js/controls.js b/frontend/public/earth/js/controls.js index 80b9db54..7bec2c55 100644 --- a/frontend/public/earth/js/controls.js +++ b/frontend/public/earth/js/controls.js @@ -132,7 +132,8 @@ let settingsModalTimer = null; let settingsSheetAnimation = null; let terrainToggleToken = 0; let terrainPrefetchStarted = false; -let terrainPrefetchScheduled = false; +let terrainPrefetchTimer = null; +let terrainPrefetchIdleHandle = null; let focusViewAnimationToken = 0; let earthSettingsDefaults = null; let lastZoomStatusUpdateTime = 0; @@ -1852,7 +1853,7 @@ function applyTerrainUiState(button, enabled) { } function prewarmTerrainIfNeeded() { - if (terrainPrefetchStarted || isTerrainReady()) return; + if (!getHighResTextureEnabled() || terrainPrefetchStarted || isTerrainReady()) return; terrainPrefetchStarted = true; ensureTerrainReady().catch((error) => { terrainPrefetchStarted = false; @@ -1860,23 +1861,45 @@ function prewarmTerrainIfNeeded() { }); } -function scheduleTerrainPrefetch() { - if (terrainPrefetchScheduled || terrainPrefetchStarted || isTerrainReady()) { +function clearScheduledTerrainPrefetch() { + if (terrainPrefetchTimer !== null) { + window.clearTimeout(terrainPrefetchTimer); + terrainPrefetchTimer = null; + } + if ( + terrainPrefetchIdleHandle !== null && + typeof window !== "undefined" && + "cancelIdleCallback" in window + ) { + window.cancelIdleCallback(terrainPrefetchIdleHandle); + } + terrainPrefetchIdleHandle = null; +} + +export function scheduleTerrainPrefetch({ delayMs = 4500, idleTimeoutMs = 6000 } = {}) { + clearScheduledTerrainPrefetch(); + if (!getHighResTextureEnabled() || terrainPrefetchStarted || isTerrainReady()) { return; } - terrainPrefetchScheduled = true; const runPrefetch = () => { - terrainPrefetchScheduled = false; + terrainPrefetchIdleHandle = null; prewarmTerrainIfNeeded(); }; - if (typeof window !== "undefined" && "requestIdleCallback" in window) { - window.requestIdleCallback(runPrefetch, { timeout: 2200 }); - return; - } - - window.setTimeout(runPrefetch, 1400); + terrainPrefetchTimer = window.setTimeout(() => { + terrainPrefetchTimer = null; + if (!getHighResTextureEnabled() || terrainPrefetchStarted || isTerrainReady()) { + return; + } + if (typeof window !== "undefined" && "requestIdleCallback" in window) { + terrainPrefetchIdleHandle = window.requestIdleCallback(runPrefetch, { + timeout: idleTimeoutMs, + }); + return; + } + runPrefetch(); + }, delayMs); } export function applyImmediateView(targetEarthObj, camera, options = {}) { @@ -3154,8 +3177,6 @@ function setupTerrainControls() { prewarmTerrainIfNeeded(); }); - scheduleTerrainPrefetch(); - bindListener(reloadBtn, "click", async () => { await reloadData(); }); @@ -3614,6 +3635,7 @@ function setupToolbarHubCluster() { } export function teardownControls() { + clearScheduledTerrainPrefetch(); resetCleanup(); activeCamera = null; } diff --git a/frontend/public/earth/js/info-card.js b/frontend/public/earth/js/info-card.js index 70c0909e..7dcfac83 100644 --- a/frontend/public/earth/js/info-card.js +++ b/frontend/public/earth/js/info-card.js @@ -33,6 +33,16 @@ function formatInfoCardValue(field, rawValue) { return value; } +function escapeInfoCardHtml(value) { + return String(value ?? '').replace(/[&<>"']/g, (char) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }[char])); +} + function getNewsSummaryText(data) { return (data?.summary || data?.title || '').trim() || '暂无摘要'; } @@ -130,6 +140,10 @@ function renderMobileDetailContent(type, config, data) { renderMobileNewsCardContent(content, data); return; } + if (config.className === 'compute_unresolved') { + renderComputeCenterUnresolvedContent(content, data); + return; + } let html = ''; for (const field of config.fields) { @@ -182,6 +196,11 @@ function ensureMobileDetailsListener() { } function renderDefaultCardContent(content, config, data) { + if (config.className === 'compute_unresolved') { + renderComputeCenterUnresolvedContent(content, data); + return; + } + let html = ''; for (const field of config.fields) { const value = formatInfoCardValue(field, data[field.key]); @@ -198,7 +217,472 @@ function renderDefaultCardContent(content, config, data) { html += renderVesselEnrichmentSection(data?.enrichment); } + const collectContext = buildLocationCollectContext(config, data); + if (collectContext) { + html += renderLocationCollectSection(collectContext); + } + content.innerHTML = html; + + if (collectContext) { + bindLocationCollectControls(content, collectContext); + } +} + +// Resolve which entity (if any) supports the shared "collect candidate location" +// flow on this info card. Returns a context object the renderer / binder both +// consume, or null when the entity has no location-collection backend. +function buildLocationCollectContext(config, data) { + if (!data || typeof data !== 'object') return null; + if (config.className === 'supercomputer' || config.className === 'gpu_cluster') { + if (!data.source_id) return null; + return { + entityType: 'compute_center', + entityId: data.source_id, + data, + needsConfirmation: + data.needs_confirmation === true + || data.location_source === 'nominatim_online_geocode', + collect: async () => { + const mod = await import('./compute-centers.js'); + return mod.collectComputeCenterLocation(data.source_id, { + name: data.name, + operator: data.operator, + site: data.site || data.metadata?.site, + organization: data.metadata?.organization, + city: data.city, + country: data.country, + source: data.source, + record_id: data.id, + }); + }, + save: async (candidate) => { + const mod = await import('./compute-centers.js'); + return mod.saveComputeCenterLocation(data.source_id, candidate, { + name: data.name, + operator: data.operator, + site: data.site || data.metadata?.site, + city: data.city, + country: data.country, + source: data.source, + }); + }, + }; + } + if (config.className === 'bgp') { + const collectorId = data.collector; + if (!collectorId) return null; + return { + entityType: 'bgp_collector', + entityId: collectorId, + data, + needsConfirmation: + data.needs_confirmation === true + || (data.location_source && data.location_source !== 'source_coordinates'), + collect: async () => { + const mod = await import('./compute-centers.js'); + return mod.collectLocationCandidates( + `/api/v1/bgp/collectors/${encodeURIComponent(collectorId)}/collect-location`, + { + site: data.site || data.matched_location_name, + city: data.city, + country: data.country, + operator: data.operator, + }, + ); + }, + }; + } + return null; +} + +function renderLocationCollectSection(context) { + const buttonLabel = context.needsConfirmation + ? '重新自动采集坐标' + : '自动采集坐标候选'; + return ` +
+ +
+
+
+ `; +} + +function bindLocationCollectControls(content, context) { + const collectRoot = content.querySelector('[data-collect-entity-id]'); + if (!collectRoot) return; + const button = collectRoot.querySelector('[data-collect-action="run"]'); + const statusEl = collectRoot.querySelector('[data-collect-status]'); + const candidatesEl = collectRoot.querySelector('[data-collect-candidates]'); + if (!button) return; + button.addEventListener('click', async (event) => { + event.stopPropagation(); + button.disabled = true; + statusEl.textContent = '正在采集坐标候选...'; + candidatesEl.innerHTML = ''; + try { + const result = await context.collect(); + if (!result?.success) { + statusEl.textContent = `未能采集到坐标:${result?.failure_reason || '未知原因'}`; + return; + } + const candidates = Array.isArray(result.candidates) ? result.candidates : []; + statusEl.textContent = `共找到 ${candidates.length} 个候选位置`; + candidatesEl.innerHTML = candidates + .slice(0, 5) + .map((candidate, index) => renderCollectCandidateRow(candidate, index === 0)) + .join(''); + bindCandidatePreviewButtons(candidatesEl, context); + bindCandidateSaveButtons(candidatesEl, context, statusEl); + } catch (error) { + console.error('collect-location failed', error); + statusEl.textContent = `采集失败:${error?.message || error}`; + } finally { + button.disabled = false; + } + }, { once: false }); +} + +function renderCollectCandidateRow(candidate, isBest) { + const precisionLabel = { + precise: '精确', + site: '站点', + city: '城市', + }[candidate.precision] || candidate.precision || '未知'; + const confidence = Number.isFinite(Number(candidate.confidence)) + ? `${Math.round(Number(candidate.confidence) * 100)}%` + : '-'; + const candidateJson = JSON.stringify(candidate).replace(/"/g, '"'); + return ` +
+
+ ${candidate.matched_location_name || candidate.display_name || '候选'} + ${precisionLabel} +
+
+ ${candidate.source} + 置信 ${confidence} +
+
+ ${Number(candidate.latitude).toFixed(4)}, ${Number(candidate.longitude).toFixed(4)} + + +
+
+ `; +} + +function getUnresolvedComputeCenterContext(item) { + const metadata = item?.metadata && typeof item.metadata === 'object' + ? item.metadata + : {}; + return { + sourceId: item?.source_id || item?.id || '', + recordId: item?.id || item?.record_id || '', + name: item?.name || item?.title || '未命名算力中心', + operator: item?.operator || item?.vendor || metadata.operator || '', + site: item?.site || metadata.site || metadata.organization || '', + city: item?.city || metadata.city || '', + country: item?.country || metadata.country || '', + source: item?.source || metadata.source || '', + }; +} + +function renderComputeCenterUnresolvedContent(content, data) { + const items = Array.isArray(data?.items) ? data.items : []; + if (!items.length) { + content.innerHTML = ` +
+ 当前没有待定位算力中心 +
+ `; + return; + } + + const rows = items + .map((item, index) => { + const context = getUnresolvedComputeCenterContext(item); + const contextJson = JSON.stringify(context).replace(/"/g, '"'); + const meta = [context.site || context.operator, context.city, context.country] + .filter(Boolean) + .join(' · ') || '缺少可用地址字段'; + return ` +
+
+
${index + 1}
+
+
${escapeInfoCardHtml(context.name)}
+
${escapeInfoCardHtml(meta)}
+
+ +
+
+
+
+ `; + }) + .join(''); + + content.innerHTML = ` +
+ ${items.length} 个算力中心没有可信坐标 + +
+
+
+ ${rows} +
+ `; + bindComputeCenterUnresolvedControls(content); +} + +function updateUnresolvedSummary(content) { + const remainingCount = content.querySelectorAll('[data-unresolved-item]').length; + const summaryText = content.querySelector('[data-unresolved-summary-text]'); + if (summaryText) { + summaryText.textContent = remainingCount > 0 + ? `${remainingCount} 个算力中心没有可信坐标` + : '当前没有待定位算力中心'; + } + const adoptAllButton = content.querySelector('[data-unresolved-adopt-all]'); + if (adoptAllButton instanceof HTMLButtonElement) { + adoptAllButton.hidden = remainingCount <= 0; + } + window.dispatchEvent( + new CustomEvent('earth:compute-center-unresolved-count-change', { + detail: { unresolvedCount: remainingCount }, + }), + ); + return remainingCount; +} + +function renumberUnresolvedItems(content) { + content.querySelectorAll('[data-unresolved-item]').forEach((item, index) => { + const indexEl = item.querySelector('.info-card-unresolved-index'); + if (indexEl) indexEl.textContent = String(index + 1); + }); +} + +function removeResolvedUnresolvedItem(content, itemRoot) { + itemRoot?.remove(); + renumberUnresolvedItems(content); + return updateUnresolvedSummary(content); +} + +async function collectUnresolvedComputeCenterCandidates(context) { + const mod = await import('./compute-centers.js'); + const result = await mod.collectComputeCenterLocation(context.sourceId, { + name: context.name, + operator: context.operator, + site: context.site, + city: context.city, + country: context.country, + source: context.source, + record_id: context.recordId, + }); + return { + mod, + result, + candidates: Array.isArray(result?.candidates) ? result.candidates : [], + }; +} + +function getBestLocationCandidate(candidates) { + return candidates + .filter((candidate) => ( + Number.isFinite(Number(candidate?.latitude)) + && Number.isFinite(Number(candidate?.longitude)) + )) + .slice() + .sort((a, b) => { + const confidenceA = Number.isFinite(Number(a?.confidence)) + ? Number(a.confidence) + : -1; + const confidenceB = Number.isFinite(Number(b?.confidence)) + ? Number(b.confidence) + : -1; + return confidenceB - confidenceA; + })[0] || null; +} + +function bindCandidatePreviewButtons(container, context) { + container.querySelectorAll('[data-preview-candidate]').forEach((el) => { + el.addEventListener('click', (clickEvt) => { + clickEvt.stopPropagation(); + const lat = Number(el.dataset.lat); + const lon = Number(el.dataset.lon); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return; + window.dispatchEvent( + new CustomEvent('earth:preview-location-candidate', { + detail: { + latitude: lat, + longitude: lon, + entityType: context.entityType, + entityId: context.entityId, + candidate: JSON.parse(el.dataset.candidateJson || '{}'), + }, + }), + ); + }); + }); +} + +function bindCandidateSaveButtons(container, context, statusEl) { + container.querySelectorAll('[data-save-candidate]').forEach((el) => { + el.addEventListener('click', async (clickEvt) => { + clickEvt.stopPropagation(); + if (typeof context.save !== 'function') return; + const candidate = JSON.parse(el.dataset.candidateJson || '{}'); + el.disabled = true; + if (statusEl) statusEl.textContent = '正在保存所选坐标...'; + try { + await context.save(candidate); + if (statusEl) statusEl.textContent = '坐标已保存,正在刷新图层...'; + window.dispatchEvent( + new CustomEvent('earth:compute-center-location-saved', { + detail: { + entityType: context.entityType, + entityId: context.entityId, + candidate, + }, + }), + ); + } catch (error) { + console.error('save compute-center location failed', error); + if (statusEl) statusEl.textContent = `保存失败:${error?.message || error}`; + } finally { + el.disabled = false; + } + }); + }); +} + +function bindComputeCenterUnresolvedControls(content) { + content.querySelectorAll('[data-unresolved-collect]').forEach((button) => { + button.addEventListener('click', async (event) => { + event.stopPropagation(); + const itemRoot = button.closest('[data-unresolved-item]'); + const statusEl = itemRoot?.querySelector('[data-unresolved-status]'); + const candidatesEl = itemRoot?.querySelector('[data-unresolved-candidates]'); + const context = JSON.parse(button.dataset.contextJson || '{}'); + if (!context.sourceId || !statusEl || !candidatesEl) return; + + button.disabled = true; + statusEl.textContent = '正在采集坐标候选...'; + candidatesEl.innerHTML = ''; + try { + const { mod, result, candidates } = await collectUnresolvedComputeCenterCandidates(context); + if (!result?.success) { + statusEl.textContent = `未能采集到坐标:${result?.failure_reason || '未知原因'}`; + return; + } + statusEl.textContent = `共找到 ${candidates.length} 个候选位置`; + candidatesEl.innerHTML = candidates + .slice(0, 5) + .map((candidate, index) => renderCollectCandidateRow(candidate, index === 0)) + .join(''); + const actionContext = { + entityType: 'compute_center', + entityId: context.sourceId, + save: (candidate) => mod.saveComputeCenterLocation(context.sourceId, candidate, context), + }; + bindCandidatePreviewButtons(candidatesEl, actionContext); + bindCandidateSaveButtons(candidatesEl, actionContext, statusEl); + } catch (error) { + console.error('collect unresolved compute-center location failed', error); + statusEl.textContent = `采集失败:${error?.message || error}`; + } finally { + button.disabled = false; + } + }); + }); + + const adoptAllButton = content.querySelector('[data-unresolved-adopt-all]'); + if (adoptAllButton instanceof HTMLButtonElement) { + adoptAllButton.addEventListener('click', async (event) => { + event.stopPropagation(); + const statusEl = content.querySelector('[data-unresolved-batch-status]'); + const buttons = Array.from(content.querySelectorAll('button')); + const pendingItems = Array.from(content.querySelectorAll('[data-unresolved-item]')) + .map((itemRoot) => { + const collectButton = itemRoot.querySelector('[data-unresolved-collect]'); + const context = JSON.parse(collectButton?.dataset.contextJson || '{}'); + return { itemRoot, context }; + }) + .filter(({ context }) => context.sourceId); + + if (!pendingItems.length) return; + buttons.forEach((button) => { button.disabled = true; }); + + let savedCount = 0; + let missedCount = 0; + try { + for (const [index, { itemRoot, context }] of pendingItems.entries()) { + const itemStatusEl = itemRoot.querySelector('[data-unresolved-status]'); + if (statusEl) { + statusEl.textContent = `正在采用最高置信候选 ${index + 1}/${pendingItems.length}...`; + } + try { + const { mod, result, candidates } = await collectUnresolvedComputeCenterCandidates(context); + if (!result?.success) { + if (itemStatusEl) { + itemStatusEl.textContent = `未找到可采用候选:${result?.failure_reason || '未知原因'}`; + } + missedCount += 1; + continue; + } + const bestCandidate = getBestLocationCandidate(candidates); + if (!bestCandidate) { + if (itemStatusEl) { + itemStatusEl.textContent = '未找到包含有效经纬度的候选'; + } + missedCount += 1; + continue; + } + await mod.saveComputeCenterLocation(context.sourceId, bestCandidate, context); + savedCount += 1; + removeResolvedUnresolvedItem(content, itemRoot); + } catch (error) { + console.error('adopt unresolved compute-center location failed', error); + if (itemStatusEl) { + itemStatusEl.textContent = `一键采用失败:${error?.message || error}`; + } + missedCount += 1; + } + } + + if (statusEl) { + statusEl.textContent = savedCount > 0 + ? `已采用 ${savedCount} 个最高置信候选${missedCount ? `,${missedCount} 个仍需手动处理` : ''}` + : `${missedCount} 个都没有可自动采用的候选,需要手动处理`; + } + if (savedCount > 0) { + window.dispatchEvent( + new CustomEvent('earth:compute-center-location-saved', { + detail: { + entityType: 'compute_center', + entityId: 'batch', + savedCount, + missedCount, + }, + }), + ); + } + } finally { + buttons.forEach((button) => { button.disabled = false; }); + } + }); + } } function getFieldSourceLabel(data, fieldKey) { @@ -283,6 +767,7 @@ function getMobilePopupTitle(type, data) { case 'bgp': return data.anomaly_type || 'BGP事件'; case 'news': return data.title || '新闻事件'; case 'bgp_collector': return data.collector || 'BGP观测站'; + case 'compute_center_unresolved': return '待定位算力中心'; case 'supercomputer': return data.name || '超算'; case 'gpu_cluster': return data.name || 'GPU集群'; case 'vessel': return data.name || '船只'; @@ -298,6 +783,7 @@ function getMobilePopupSubtitle(type, data) { case 'bgp': return data.severity || 'BGP路由异常'; case 'news': return getNewsSummaryPreview(data, 30) || '态势新闻'; case 'bgp_collector': return data.location || 'BGP观测站'; + case 'compute_center_unresolved': return `${data?.totalCount || 0} 个待定位`; case 'supercomputer': return data.country || '超级计算机'; case 'gpu_cluster': return data.country || 'GPU集群'; case 'vessel': return data.vessel_type || 'AIS 船只'; @@ -593,6 +1079,12 @@ const CARD_CONFIG = { { key: 'status', label: '状态' } ] }, + compute_center_unresolved: { + icon: '📍', + title: '待定位算力中心', + className: 'compute_unresolved', + fields: [] + }, supercomputer: { icon: '🖥️', title: '超算中心详情', @@ -609,6 +1101,13 @@ const CARD_CONFIG = { { key: 'country', label: '国家' }, { key: 'city', label: '城市' }, { key: 'location_precision_label', label: '位置精度' }, + { key: 'location_source_label', label: '位置来源' }, + { key: 'location_confidence', label: '位置置信度' }, + { key: 'location_status_label', label: '核验状态' }, + { key: 'estimated_reason', label: '解析依据' }, + { key: 'location_source_note', label: '位置来源说明' }, + { key: 'matched_location_name', label: '匹配的位置名称' }, + { key: 'location_verified_at', label: '位置核验时间' }, { key: 'source', label: '来源' }, { key: 'updated_at', label: '更新时间' } ] @@ -628,6 +1127,13 @@ const CARD_CONFIG = { { key: 'country', label: '国家' }, { key: 'city', label: '城市' }, { key: 'location_precision_label', label: '位置精度' }, + { key: 'location_source_label', label: '位置来源' }, + { key: 'location_confidence', label: '位置置信度' }, + { key: 'location_status_label', label: '核验状态' }, + { key: 'estimated_reason', label: '解析依据' }, + { key: 'location_source_note', label: '位置来源说明' }, + { key: 'matched_location_name', label: '匹配的位置名称' }, + { key: 'location_verified_at', label: '位置核验时间' }, { key: 'source', label: '来源' }, { key: 'updated_at', label: '更新时间' } ] @@ -875,6 +1381,7 @@ function showPanel(x, y, options = {}) { const panel = getPanel(); if (!panel) return; panel.classList.toggle('hud-panel-info--anchor-stable', options.anchorStable === true); + panel.dataset.sticky = options.sticky === true ? 'true' : 'false'; panel.removeAttribute('hidden'); panel.setAttribute('aria-hidden', 'false'); if (x != null && y != null) positionPanel(panel, x, y, options); @@ -896,6 +1403,7 @@ function hidePanel() { if (panel) { panel.classList.remove('is-visible'); panel.classList.remove('hud-panel-info--anchor-stable'); + delete panel.dataset.sticky; panel.setAttribute('aria-hidden', 'true'); panel.setAttribute('hidden', ''); } @@ -915,6 +1423,10 @@ export function setInfoCardNoBorder(noBorder = true) { } } +export function isInfoCardSticky() { + return getPanel()?.dataset.sticky === 'true'; +} + export function showInfoCard(type, data, options = {}) { const config = CARD_CONFIG[type]; if (!config) { diff --git a/frontend/public/earth/js/interactable.js b/frontend/public/earth/js/interactable.js index 98d8fddf..c1a62240 100644 --- a/frontend/public/earth/js/interactable.js +++ b/frontend/public/earth/js/interactable.js @@ -11,6 +11,26 @@ const DEFAULT_AVOIDANCE_PRECISION = 4; const DEFAULT_AVOIDANCE_RADIUS = 1.1; const DEFAULT_AVOIDANCE_STEP = 0.35; const AVOIDANCE_RING_SLOT_COUNT = 8; + +// Named avoidance profiles. Layers that should mutex with each other (e.g. fan +// out when sharing the same city center) must reference the SAME profile — +// markers are bucketed by the resulting key, and only equal keys collide. +// +// city — ~1.1km grid (precision 2). Use for site/observatory/POI markers +// that often share a city-center coordinate from geocoding. +// precise — ~11m grid (precision 4). Use for markers with building-level +// coordinates (default; preserves prior behavior). +// +// Layers that need a fully custom cluster identity (e.g. a city ID string) +// can pass `getKey: (item, position) => "..."` instead of using a profile. +export const SURFACE_AVOIDANCE_PROFILES = Object.freeze({ + city: Object.freeze({ precision: 2, radius: 1.4, step: 0.5 }), + precise: Object.freeze({ + precision: DEFAULT_AVOIDANCE_PRECISION, + radius: DEFAULT_AVOIDANCE_RADIUS, + step: DEFAULT_AVOIDANCE_STEP, + }), +}); const TANGENT_EPSILON_SQ = 1e-6; const avoidanceNorthPole = new THREE.Vector3(0, 1, 0); const avoidanceFallbackEast = new THREE.Vector3(1, 0, 0); @@ -58,7 +78,16 @@ function createCanvas(width, height) { return canvas; } -function getAvoidanceKey(position, basePosition, precision = 4) { +function getAvoidanceKey(item, position, basePosition, config) { + if (typeof config?.getKey === "function") { + const key = config.getKey(item, position, basePosition); + if (key) return String(key); + } + + const precision = Number.isFinite(config?.precision) + ? config.precision + : DEFAULT_AVOIDANCE_PRECISION; + if (position instanceof THREE.Vector3) { return [ "vec", @@ -85,11 +114,14 @@ function recomputeAvoidanceBucket(key) { if (!entries || entries.length === 0) return; const affectedLayerIds = new Set(entries.map((entry) => entry.layerId)); + const crossLayer = affectedLayerIds.size > 1; if (entries.length === 1) { const entry = entries[0]; entry.marker.position.copy(entry.marker.userData.icon_base_position); entry.marker.userData.icon_avoidance_index = 0; entry.marker.userData.icon_avoidance_count = 1; + entry.marker.userData.icon_avoidance_layer_count = 1; + entry.marker.userData.icon_avoidance_cross_layer = false; notifyAvoidancePositionChanged(affectedLayerIds); return; } @@ -127,6 +159,8 @@ function recomputeAvoidanceBucket(key) { entry.marker.position.copy(avoidancePositionScratch); entry.marker.userData.icon_avoidance_index = index; entry.marker.userData.icon_avoidance_count = count; + entry.marker.userData.icon_avoidance_layer_count = affectedLayerIds.size; + entry.marker.userData.icon_avoidance_cross_layer = crossLayer; }); notifyAvoidancePositionChanged(affectedLayerIds); @@ -657,9 +691,10 @@ export function createInteractableLayer(options = {}) { if (!position) return; const kind = getKind(item); const avoidanceKey = getAvoidanceKey( + item, rawPosition, position, - avoidanceConfig.precision, + avoidanceConfig, ); const marker = new THREE.Object3D(); marker.position.copy(position); diff --git a/frontend/public/earth/js/main.js b/frontend/public/earth/js/main.js index 1cc62ff4..3d512e9e 100644 --- a/frontend/public/earth/js/main.js +++ b/frontend/public/earth/js/main.js @@ -160,6 +160,9 @@ import { clearComputeCenterSelection, formatComputeCenterCapacity, formatComputeCenterLocationPrecision, + formatComputeCenterLocationConfidence, + formatComputeCenterLocationSource, + formatComputeCenterNeedsConfirmation, formatComputeCenterTypeLabel, formatComputeCenterUpdatedAt, getComputeCenterCount, @@ -167,6 +170,7 @@ import { getComputeCenterMarkers, getComputeCenterPointerIntersections as getComputeCenterIconPointerIntersections, getShowComputeCenters, + getUnresolvedComputeCenters, loadComputeCenters, setComputeCenterMarkerState, toggleComputeCenters, @@ -208,6 +212,7 @@ import { setTerrainLayerInteractable, setDayNightInteractable, applyDeferredLayerVisibilitySettings, + scheduleTerrainPrefetch, } from "./controls.js"; import { createLayerStartupTaskMap, @@ -224,6 +229,7 @@ import { initInfoCard, showInfoCard, hideInfoCard, + isInfoCardSticky, } from "./info-card.js"; import { initLegend, @@ -239,6 +245,10 @@ import { registerEarthClientErrorHandlers, reportEarthClientLog, } from "./client-logs.js"; +import { + clearMobileCenterCountryHighlight, + updateMobileCenterCountryHighlight, +} from "./mobile-center-country-highlight.js"; export let scene; export let camera; @@ -762,22 +772,36 @@ function showComputeCenterInfo(marker, coords) { ? "supercomputer" : "gpu_cluster"; setLegendMode("computeCenters"); + const ud = marker.userData || {}; showInfoCard(siteType, { - name: marker.userData?.name || "-", + id: ud.id, + source_id: ud.source_id, + name: ud.name || "-", site_type_label: formatComputeCenterTypeLabel(siteType), - rank: marker.userData?.rank ?? "-", - capacity: formatComputeCenterCapacity(marker.userData), - vendor: marker.userData?.vendor || "-", - operator: marker.userData?.operator || "-", - gpu_count: marker.userData?.gpu_count ?? "-", - gpu_type: marker.userData?.gpu_type || "-", - cores: marker.userData?.cores ?? "-", - power: marker.userData?.power ?? "-", - country: marker.userData?.country || "-", - city: marker.userData?.city || "-", - location_precision_label: formatComputeCenterLocationPrecision(marker.userData), - source: marker.userData?.source || "-", - updated_at: formatComputeCenterUpdatedAt(marker.userData?.updated_at), + rank: ud.rank ?? "-", + capacity: formatComputeCenterCapacity(ud), + vendor: ud.vendor || "-", + operator: ud.operator || "-", + gpu_count: ud.gpu_count ?? "-", + gpu_type: ud.gpu_type || "-", + cores: ud.cores ?? "-", + power: ud.power ?? "-", + country: ud.country || "-", + city: ud.city || "-", + metadata: ud.metadata || {}, + location_precision: ud.location_precision, + location_precision_label: formatComputeCenterLocationPrecision(ud), + location_source: ud.location_source, + location_source_label: formatComputeCenterLocationSource(ud), + location_status_label: formatComputeCenterNeedsConfirmation(ud), + needs_confirmation: ud.needs_confirmation === true, + estimated_reason: ud.estimated_reason || "-", + location_confidence: formatComputeCenterLocationConfidence(ud), + location_source_note: ud.location_source_note || "-", + matched_location_name: ud.matched_location_name || "-", + location_verified_at: ud.location_verified_at || "-", + source: ud.source || "-", + updated_at: formatComputeCenterUpdatedAt(ud.updated_at), }, coords); } @@ -1193,6 +1217,26 @@ async function focusSearchComputeCenter(marker) { ); } +async function previewLocationCandidate(detail) { + const lat = Number(detail?.latitude); + const lon = Number(detail?.longitude); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return; + interruptCruisePresentation({ resetLoop: true }); + setAutoRotate(false); + await focusSearchTarget({ lat, lon }, Math.max(getZoomLevel(), 1.16)); +} + +async function refreshComputeCentersAfterLocationSave() { + if (!scene || !earth) return; + const result = await loadComputeCenters(scene, earth); + toggleComputeCenters(getShowComputeCenters()); + updateComputeCenterHud(result); + setLegendItems("computeCenters", getComputeCenterLegendItems()); + refreshLegend(); + updateStatsSummary(); + showStatusMessage("算力中心坐标已保存", "success"); +} + async function focusSearchVessel(marker) { await setVesselsEnabled(true, { suppressStatus: true, @@ -1514,17 +1558,81 @@ async function loadEarthStatsSummary({ shouldApply = () => true } = {}) { function updateComputeCenterHud(computeCenterResult) { const computeBtn = document.getElementById("toggle-compute-centers"); + const unresolvedCount = Number(computeCenterResult?.unresolvedCount) || 0; + const unresolvedTooltip = + unresolvedCount > 0 ? `(${unresolvedCount} 个待定位)` : ""; + if (computeBtn) { setLayerButtonState(computeBtn, { active: getShowComputeCenters(), loading: false, - tooltip: getShowComputeCenters() ? "隐藏算力中心" : "显示算力中心", + tooltip: getShowComputeCenters() + ? `隐藏算力中心${unresolvedTooltip}` + : `显示算力中心${unresolvedTooltip}`, }); + updateComputeCenterUnresolvedBadge(computeBtn, unresolvedCount); } setEarthStatValue("compute-center-count", `${computeCenterResult.totalCount} 个`); } +function updateComputeCenterUnresolvedBadge(computeBtn, unresolvedCount) { + const count = Math.max(0, Number(unresolvedCount) || 0); + const layerRow = computeBtn.closest(".layer-row"); + if (!layerRow) return; + let badge = layerRow.querySelector("[data-compute-center-unresolved-badge]"); + + if (count <= 0) { + delete computeBtn.dataset.unresolvedCount; + badge?.remove(); + return; + } + + if (!(badge instanceof HTMLButtonElement)) { + badge?.remove(); + badge = document.createElement("button"); + badge.type = "button"; + badge.className = "layer-row-notification-badge"; + badge.dataset.computeCenterUnresolvedBadge = "true"; + layerRow.appendChild(badge); + } + badge.onclick = (event) => { + event.preventDefault(); + event.stopPropagation(); + showComputeCenterUnresolvedQueue(event.currentTarget); + }; + + computeBtn.dataset.unresolvedCount = String(count); + badge.textContent = count > 99 ? "99+" : String(count); + badge.title = `${count} 个算力中心待定位`; + badge.setAttribute("aria-label", `${count} 个算力中心待定位,点击查看`); +} + +function syncComputeCenterUnresolvedCount(unresolvedCount) { + const computeBtn = document.getElementById("toggle-compute-centers"); + if (!computeBtn) return; + updateComputeCenterUnresolvedBadge(computeBtn, unresolvedCount); +} + +function showComputeCenterUnresolvedQueue(anchorEl) { + const items = getUnresolvedComputeCenters(); + const panelRect = document.getElementById("layer-toggles")?.getBoundingClientRect(); + const anchorRect = anchorEl instanceof HTMLElement + ? anchorEl.getBoundingClientRect() + : null; + const rect = panelRect || anchorRect; + showInfoCard("compute_center_unresolved", { + totalCount: items.length, + items, + }, { + x: rect ? rect.right + 10 : 284, + y: rect ? rect.top : 120, + absolute: true, + anchorStable: true, + sticky: true, + }); +} + function updateBGPHud(bgpResult) { const bgpBtn = document.getElementById("toggle-bgp"); if (bgpBtn) { @@ -2045,15 +2153,21 @@ function applyCableVisualState() { (lockedObjectType === "satellite" && lockedSatellite) || (lockedObjectType === "bgp" && lockedObject) || (isCruiseModeActive() && isCruisePresentationPinned()); + const isHoveredCable = isSameCable(cable, hoveredCable); switch (state) { case CABLE_STATE.LOCKED: + cable.material.opacity = isHoveredCable + ? CABLE_CONFIG.lockedOpacityMax + : THREE.MathUtils.lerp( + CABLE_CONFIG.lockedOpacityMin, + CABLE_CONFIG.lockedOpacityMax, + pulse, + ); + cable.material.color.setRGB(0.92, 0.98, 1.0); + break; case CABLE_STATE.HOVERED: - cable.material.opacity = THREE.MathUtils.lerp( - CABLE_CONFIG.lockedOpacityMin, - CABLE_CONFIG.lockedOpacityMax, - pulse, - ); + cable.material.opacity = CABLE_CONFIG.lockedOpacityMax; cable.material.color.setRGB(0.92, 0.98, 1.0); break; case CABLE_STATE.NORMAL: @@ -2740,9 +2854,15 @@ async function loadData() { queueStatusMessage("数据已加载", "success"); } - applyDeferredLayerVisibilitySettings().catch((error) => { - console.warn("恢复 Earth 图层可见性失败:", error); - }); + applyDeferredLayerVisibilitySettings() + .catch((error) => { + console.warn("恢复 Earth 图层可见性失败:", error); + }) + .finally(() => { + if (loadToken === currentLoadToken && !destroyed) { + scheduleTerrainPrefetch(); + } + }); } const POSITION_UPDATE_FORCE_DELTA = 250; @@ -3053,6 +3173,20 @@ function setupEventListeners() { const handleRotationMode = (event) => handleRotationModeChange(event); const handleCruiseModules = () => handleCruiseModulesChange(); const handleInfoCardDrag = () => repositionCruiseConnector(); + const handlePreviewLocationCandidate = (event) => { + previewLocationCandidate(event?.detail || {}).catch((error) => { + console.warn("预览候选位置失败:", error); + }); + }; + const handleComputeCenterLocationSaved = () => { + refreshComputeCentersAfterLocationSave().catch((error) => { + console.warn("刷新算力中心图层失败:", error); + showStatusMessage("坐标已保存,但刷新算力中心失败", "error"); + }); + }; + const handleComputeCenterUnresolvedCountChange = (event) => { + syncComputeCenterUnresolvedCount(event?.detail?.unresolvedCount); + }; bindListener(window, "resize", handleResize); bindListener(document, "visibilitychange", handleVisibilityChange); @@ -3061,6 +3195,21 @@ function setupEventListeners() { bindListener(window, "earth:rotation-mode-change", handleRotationMode); bindListener(window, "earth:cruise-modules-change", handleCruiseModules); bindListener(window, "earth:info-card-drag", handleInfoCardDrag); + bindListener( + window, + "earth:preview-location-candidate", + handlePreviewLocationCandidate, + ); + bindListener( + window, + "earth:compute-center-location-saved", + handleComputeCenterLocationSaved, + ); + bindListener( + window, + "earth:compute-center-unresolved-count-change", + handleComputeCenterUnresolvedCountChange, + ); getCruiseModuleDefinitions().forEach((module) => { if (!module.externalEventName) return; bindListener(window, module.externalEventName, (event) => { @@ -3158,7 +3307,12 @@ function onMouseMove(event) { applyBGPHoverState(lockedObject); } else if (lockedObjectType === "bgp_collector" && lockedObject) { applyBGPHoverState(lockedObject); - } else if (!lockedObject && !lockedSatellite && !isCruisePresentationPinned()) { + } else if ( + !lockedObject && + !lockedSatellite && + !isCruisePresentationPinned() && + !isInfoCardSticky() + ) { hideInfoCard(); } hideTooltip(); @@ -3329,7 +3483,7 @@ function onMouseMove(event) { applyComputeCenterHoverState(lockedObject); } else if (lockedObjectType === "vessel" && lockedObject) { applyVesselHoverState(lockedObject); - } else if (!lockedObjectType && !isCruisePresentationPinned()) { + } else if (!lockedObjectType && !isCruisePresentationPinned() && !isInfoCardSticky()) { resetTransientBGPStates(); resetTransientComputeCenterStates(); if (vesselPick.checked) { @@ -3824,6 +3978,12 @@ function animate() { const currentSunDirection = getSunDirection(); setEarthSunDirection(currentSunDirection); setSatelliteSunDirection(currentSunDirection); + updateMobileCenterCountryHighlight({ + camera, + earth: getEarthSurfacePickTarget() || earth, + renderer, + now: performance.now(), + }); updateNewsViewFocus(getCurrentViewCenterCoords()); const satPositions = getSatellitePositions(); if ( @@ -3863,6 +4023,7 @@ export function destroy() { } clearLockedObject(); + clearMobileCenterCountryHighlight(); clearCableData(getEarth()); clearBGPData(getEarth()); clearComputeCenterData(getEarth()); diff --git a/frontend/public/earth/js/mobile-center-country-highlight.js b/frontend/public/earth/js/mobile-center-country-highlight.js new file mode 100644 index 00000000..6fee9dcf --- /dev/null +++ b/frontend/public/earth/js/mobile-center-country-highlight.js @@ -0,0 +1,128 @@ +import * as THREE from "three"; + +import { + clearCountryBoundaryHover, + getShowCountryBoundaries, + updateCountryBoundaryHover, +} from "./country-boundaries.js"; +import { screenToEarthCoords, vector3ToLatLon } from "./utils.js"; + +const UPDATE_INTERVAL_MS = 120; +const MIN_COORD_DELTA_DEGREES = 0.05; +const BLOCKING_BODY_CLASSES = [ + "earth-mobile-drawer-open", + "earth-search-open", + "earth-settings-open", + "earth-media-open", + "earth-info-open", +]; + +const centerRaycaster = new THREE.Raycaster(); +const centerMouse = new THREE.Vector2(); + +let ownsHighlight = false; +let lastUpdateAt = 0; +let lastLat = null; +let lastLon = null; + +function isMobileLayout() { + return document.body.classList.contains("layout-mode-mobile"); +} + +function hasBlockingForegroundUi() { + return BLOCKING_BODY_CLASSES.some((className) => + document.body.classList.contains(className), + ); +} + +function resetCachedCenter() { + lastUpdateAt = 0; + lastLat = null; + lastLon = null; +} + +export function clearMobileCenterCountryHighlight() { + if (ownsHighlight) { + clearCountryBoundaryHover(); + } + ownsHighlight = false; + resetCachedCenter(); +} + +function clearAnyMobileCountryHighlight() { + clearCountryBoundaryHover(); + ownsHighlight = false; + resetCachedCenter(); +} + +function shouldSkipForSmallMovement(coords) { + if (lastLat === null || lastLon === null) return false; + return ( + Math.abs(coords.lat - lastLat) < MIN_COORD_DELTA_DEGREES && + Math.abs(coords.lon - lastLon) < MIN_COORD_DELTA_DEGREES + ); +} + +export function updateMobileCenterCountryHighlight({ + camera, + earth, + renderer, + now = performance.now(), + isBlocked = false, +} = {}) { + const mobile = isMobileLayout(); + + if (!mobile) { + clearMobileCenterCountryHighlight(); + return null; + } + + if ( + !camera || + !earth || + !renderer?.domElement || + isBlocked || + hasBlockingForegroundUi() || + !getShowCountryBoundaries() + ) { + clearAnyMobileCountryHighlight(); + return null; + } + + if (now - lastUpdateAt < UPDATE_INTERVAL_MS) { + return null; + } + + const rect = renderer.domElement.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) { + clearMobileCenterCountryHighlight(); + return null; + } + + const earthPoint = screenToEarthCoords( + rect.left + rect.width / 2, + rect.top + rect.height / 2, + camera, + earth, + renderer.domElement, + centerRaycaster, + centerMouse, + ); + + lastUpdateAt = now; + + if (!earthPoint) { + clearMobileCenterCountryHighlight(); + return null; + } + + const coords = vector3ToLatLon(earthPoint); + if (shouldSkipForSmallMovement(coords)) { + return null; + } + + lastLat = coords.lat; + lastLon = coords.lon; + ownsHighlight = true; + return updateCountryBoundaryHover(coords); +} diff --git a/frontend/public/earth/js/terrain.js b/frontend/public/earth/js/terrain.js index 2b006f2f..a51ed467 100644 --- a/frontend/public/earth/js/terrain.js +++ b/frontend/public/earth/js/terrain.js @@ -33,6 +33,10 @@ function buildTileUrl(z, x, y) { .replace("{y}", String(y)); } +function buildTerrainTileKey(z, x, y) { + return `${z}/${x}/${y}`; +} + function getTerrainCanvas(size) { if (typeof OffscreenCanvas !== "undefined") { return new OffscreenCanvas(size, size); @@ -44,8 +48,34 @@ function getTerrainCanvas(size) { return canvas; } +async function decodeTerrainBlob(blob, cacheKey) { + const bitmap = await createImageBitmap(blob); + const canvas = getTerrainCanvas(TERRAIN_CONFIG.tileSize); + const ctx = canvas.getContext("2d", { willReadFrequently: true }); + ctx.drawImage(bitmap, 0, 0, TERRAIN_CONFIG.tileSize, TERRAIN_CONFIG.tileSize); + bitmap.close?.(); + const { data, width, height } = ctx.getImageData( + 0, + 0, + TERRAIN_CONFIG.tileSize, + TERRAIN_CONFIG.tileSize, + ); + const tileData = { data, width, height }; + resolvedTileCache.set(cacheKey, tileData); + return tileData; +} + +function base64ToBlob(base64, contentType = "image/png") { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return new Blob([bytes], { type: contentType }); +} + async function decodeTerrainTile(z, x, y) { - const cacheKey = `${z}/${x}/${y}`; + const cacheKey = buildTerrainTileKey(z, x, y); if (terrainTileCache.has(cacheKey)) { return terrainTileCache.get(cacheKey); } @@ -56,27 +86,89 @@ async function decodeTerrainTile(z, x, y) { throw new Error(`HTTP ${response.status} for terrain tile ${cacheKey}`); } - const blob = await response.blob(); - const bitmap = await createImageBitmap(blob); - const canvas = getTerrainCanvas(TERRAIN_CONFIG.tileSize); - const ctx = canvas.getContext("2d", { willReadFrequently: true }); - ctx.drawImage(bitmap, 0, 0, TERRAIN_CONFIG.tileSize, TERRAIN_CONFIG.tileSize); - bitmap.close?.(); - const { data, width, height } = ctx.getImageData( - 0, - 0, - TERRAIN_CONFIG.tileSize, - TERRAIN_CONFIG.tileSize, - ); - const tileData = { data, width, height }; - resolvedTileCache.set(cacheKey, tileData); - return tileData; + return decodeTerrainBlob(await response.blob(), cacheKey); })(); terrainTileCache.set(cacheKey, tilePromise); return tilePromise; } +async function fetchTerrainTileBatch(keys) { + const uncachedKeys = keys.filter((key) => !terrainTileCache.has(key)); + if (uncachedKeys.length === 0) { + return; + } + + if (!TERRAIN_CONFIG.batchUrl || typeof atob !== "function") { + uncachedKeys.forEach((key) => { + const [z, x, y] = key.split("/").map(Number); + terrainTileCache.set(key, decodeTerrainTile(z, x, y)); + }); + return; + } + + const batchPromise = (async () => { + const response = await fetch(TERRAIN_CONFIG.batchUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + tiles: uncachedKeys.map((key) => { + const [z, x, y] = key.split("/").map(Number); + return { z, x, y }; + }), + }), + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status} for terrain tile batch`); + } + + const payload = await response.json(); + const tiles = Array.isArray(payload?.tiles) ? payload.tiles : []; + const returnedKeys = new Set(); + + await Promise.all( + tiles.map(async (tile) => { + const z = Number(tile?.z); + const x = Number(tile?.x); + const y = Number(tile?.y); + const data = typeof tile?.data === "string" ? tile.data : ""; + if (!Number.isFinite(z) || !Number.isFinite(x) || !Number.isFinite(y) || !data) { + return; + } + const key = buildTerrainTileKey(z, x, y); + returnedKeys.add(key); + const blob = base64ToBlob(data, tile.content_type || "image/png"); + terrainTileCache.set(key, decodeTerrainBlob(blob, key)); + }), + ); + + uncachedKeys.forEach((key) => { + if (returnedKeys.has(key)) return; + const [z, x, y] = key.split("/").map(Number); + terrainTileCache.delete(key); + terrainTileCache.set(key, decodeTerrainTile(z, x, y)); + }); + })().catch((error) => { + console.warn("批量地形瓦片加载失败,回退到单瓦片请求:", error); + uncachedKeys.forEach((key) => { + terrainTileCache.delete(key); + const [z, x, y] = key.split("/").map(Number); + terrainTileCache.set(key, decodeTerrainTile(z, x, y)); + }); + }); + + uncachedKeys.forEach((key) => { + terrainTileCache.set( + key, + batchPromise.then(() => terrainTileCache.get(key)), + ); + }); + + await batchPromise; +} + function decodeTerrariumHeight(tile, pixelX, pixelY) { const safeX = THREE.MathUtils.clamp(pixelX, 0, tile.width - 1); const safeY = THREE.MathUtils.clamp(pixelY, 0, tile.height - 1); @@ -168,19 +260,39 @@ async function runWithConcurrency(items, limit, worker) { async function fetchRequiredTiles(samples) { const uniqueKeys = Array.from( - new Set(samples.map((sample) => `${TERRAIN_CONFIG.baseZoom}/${sample.tileX}/${sample.tileY}`)), + new Set( + samples.map((sample) => + buildTerrainTileKey(TERRAIN_CONFIG.baseZoom, sample.tileX, sample.tileY), + ), + ), ); const resolvedTiles = new Map(); + const batchSize = Math.max(1, Number(TERRAIN_CONFIG.batchRequestSize) || 1); + const batchChunks = []; + for (let i = 0; i < uniqueKeys.length; i += batchSize) { + batchChunks.push(uniqueKeys.slice(i, i + batchSize)); + } await runWithConcurrency( - uniqueKeys, + batchChunks, TERRAIN_CONFIG.maxConcurrentRequests, - async (key) => { - const [z, x, y] = key.split("/").map(Number); - resolvedTiles.set(key, await decodeTerrainTile(z, x, y)); + async (keys) => { + await fetchTerrainTileBatch(keys); }, ); + await Promise.all( + uniqueKeys.map(async (key) => { + const tilePromise = terrainTileCache.get(key); + if (!tilePromise) { + const [z, x, y] = key.split("/").map(Number); + resolvedTiles.set(key, await decodeTerrainTile(z, x, y)); + return; + } + resolvedTiles.set(key, await tilePromise); + }), + ); + return resolvedTiles; } diff --git a/frontend/src/pages/Docs/Docs.tsx b/frontend/src/pages/Docs/Docs.tsx index e69d68b5..389bece5 100644 --- a/frontend/src/pages/Docs/Docs.tsx +++ b/frontend/src/pages/Docs/Docs.tsx @@ -1,10 +1,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' +import axios from 'axios' import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer' import Scrollbar from '../../components/Scrollbar/Scrollbar' import SegmentedControl from '../../components/SegmentedControl/SegmentedControl' +import { useAuthStore } from '../../stores/auth' import { createHeadingIdResolver, defaultDocsSlug, @@ -15,12 +17,23 @@ import { groupDocsEntries, slugFromDocsHref, } from './docs-content' -import type { DocsHeading, DocsLang } from './docs-content' +import type { DocsCatalogItem, DocsEntry, DocsHeading, DocsLang } from './docs-content' import { buildDocsSearchRecords, searchDocs } from './docs-search' import type { DocsSearchRecord } from './docs-search' import './Docs.css' type DocsThemeMode = 'system' | 'light' | 'dark' +type DocsErrorState = 'none' | 'unauthenticated' | 'forbidden' | 'not_found' | 'load_failed' + +interface DocsCatalogResponse { + items: DocsCatalogItem[] + authenticated: boolean +} + +interface DocsContentResponse extends DocsEntry { + lang: DocsLang + markdown: string +} const MIN_TOC_HEADING_LEVEL = 2 const MAX_TOC_HEADING_LEVEL = 3 @@ -56,11 +69,16 @@ function getSystemTheme(): 'light' | 'dark' { export default function Docs() { const { slug } = useParams() const navigate = useNavigate() + const { token } = useAuthStore() const [lang, setLang] = useState(readStoredLang) const [themeMode, setThemeMode] = useState(readStoredThemeMode) const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(getSystemTheme) + const [catalogItems, setCatalogItems] = useState([]) + const [isCatalogLoading, setIsCatalogLoading] = useState(true) const [markdown, setMarkdown] = useState('') + const [activeContentEntry, setActiveContentEntry] = useState(null) + const [docError, setDocError] = useState('none') const [isLoading, setIsLoading] = useState(false) const [searchQuery, setSearchQuery] = useState('') const [isSearchOpen, setIsSearchOpen] = useState(false) @@ -69,9 +87,10 @@ export default function Docs() { const articleRef = useRef(null) const searchRef = useRef(null) - const docsEntries = useMemo(() => getDocsEntries(lang), [lang]) + const docsEntries = useMemo(() => getDocsEntries(lang, catalogItems), [catalogItems, lang]) const activeSlug = slug || defaultDocsSlug - const activeEntry = useMemo(() => getDocsEntry(activeSlug, lang), [activeSlug, lang]) + const activeEntry = useMemo(() => getDocsEntry(activeSlug, docsEntries), [activeSlug, docsEntries]) + const activeHeaderEntry = activeEntry || activeContentEntry const groupedEntries = useMemo(() => groupDocsEntries(docsEntries), [docsEntries]) const effectiveTheme = themeMode === 'system' ? systemTheme : themeMode const langOptions = useMemo(() => [ @@ -140,20 +159,54 @@ export default function Docs() { useEffect(() => { let isCancelled = false - if (!activeEntry) { - setMarkdown('') - return - } + setIsCatalogLoading(true) + axios.get('/api/v1/docs/catalog') + .then((response) => { + if (!isCancelled) setCatalogItems(response.data.items || []) + }) + .catch(() => { + if (!isCancelled) setCatalogItems([]) + }) + .finally(() => { + if (!isCancelled) setIsCatalogLoading(false) + }) + return () => { isCancelled = true } + }, [token]) + + useEffect(() => { + let isCancelled = false + if (isCatalogLoading) return setIsLoading(true) - activeEntry.loader() - .then((content) => { - if (!isCancelled) setMarkdown(content) + setDocError('none') + setMarkdown('') + setActiveContentEntry(activeEntry || null) + axios.get(`/api/v1/docs/${lang}/${activeSlug}`) + .then((response) => { + if (isCancelled) return + const content = response.data + setMarkdown(content.markdown) + setActiveContentEntry({ + slug: content.slug, + filename: content.filename, + title: content.title, + group: content.group, + order: content.order, + access: content.access, + }) + }) + .catch((error) => { + if (isCancelled) return + const status = (error as { response?: { status?: number } })?.response?.status + if (status === 401) setDocError('unauthenticated') + else if (status === 403) setDocError('forbidden') + else if (status === 404) setDocError('not_found') + else setDocError('load_failed') }) .finally(() => { if (!isCancelled) setIsLoading(false) }) return () => { isCancelled = true } - }, [activeEntry]) + }, [activeEntry, activeSlug, isCatalogLoading, lang]) useEffect(() => { if (!markdown || !window.location.hash) return @@ -164,11 +217,17 @@ export default function Docs() { useEffect(() => { let isCancelled = false - buildDocsSearchRecords(docsEntries).then((records) => { + const loadMarkdown = async (entry: DocsEntry) => { + const response = await axios.get(`/api/v1/docs/${lang}/${entry.slug}`) + return response.data.markdown + } + buildDocsSearchRecords(docsEntries, loadMarkdown).then((records) => { if (!isCancelled) setSearchRecords(records) + }).catch(() => { + if (!isCancelled) setSearchRecords([]) }) return () => { isCancelled = true } - }, [docsEntries]) + }, [docsEntries, lang]) useEffect(() => { const handlePointerDown = (event: PointerEvent) => { @@ -243,9 +302,9 @@ export default function Docs() { // Sidebar H2 sub-items for active Manual doc const sidebarSubHeadings = useMemo(() => { - if (activeEntry?.group !== 'Manual' || headings.length === 0) return [] + if (activeHeaderEntry?.group !== 'Manual' || headings.length === 0) return [] return headings.filter((h) => h.level === 2) - }, [activeEntry, headings]) + }, [activeHeaderEntry, headings]) return (
@@ -326,9 +385,11 @@ export default function Docs() {

- {activeEntry ? getDocsGroupLabel(activeEntry.group, lang) : 'Docs'} + {activeHeaderEntry ? getDocsGroupLabel(activeHeaderEntry.group, lang) : 'Docs'}

-

{activeEntry?.title || 'Document not found'}

+

+ {activeHeaderEntry?.title || (lang === 'zh' ? '文档不可用' : 'Document unavailable')} +

@@ -382,30 +443,50 @@ export default function Docs() {
- {activeEntry ? ( - isLoading ? ( + {isCatalogLoading || isLoading ? (
{lang === 'zh' ? '加载中...' : 'Loading document...'}
- ) : ( + ) : docError === 'none' ? ( - ) ) : (
-

{lang === 'zh' ? '文档未找到' : 'Document not found'}

-

- {lang === 'zh' - ? '请求的文档不在公开文档集中。' - : 'The requested guide is not part of the public technical documentation set.'} -

- - {lang === 'zh' ? '返回文档首页' : 'Return to docs overview'} - + {docError === 'unauthenticated' ? ( + <> +

{lang === 'zh' ? '需要登录' : 'Login required'}

+

+ {lang === 'zh' + ? '这份文档需要登录并具备对应 Gatekeeper 权限组后才能阅读。' + : 'This document requires login and the matching Gatekeeper permission group.'} +

+ {lang === 'zh' ? '前往登录' : 'Go to login'} + + ) : docError === 'forbidden' ? ( + <> +

{lang === 'zh' ? '无权访问' : 'Permission required'}

+

+ {lang === 'zh' + ? '当前账号没有阅读这份文档所需的 Gatekeeper 权限组。' + : 'Your account does not have the Gatekeeper permission group required for this document.'} +

+ {lang === 'zh' ? '返回文档首页' : 'Return to docs overview'} + + ) : ( + <> +

{lang === 'zh' ? '文档未找到' : 'Document not found'}

+

+ {lang === 'zh' + ? '请求的文档不存在,或当前语言没有对应内容。' + : 'The requested guide does not exist or is not available in the current language.'} +

+ {lang === 'zh' ? '返回文档首页' : 'Return to docs overview'} + + )}
)}
diff --git a/frontend/src/pages/Docs/docs-content.ts b/frontend/src/pages/Docs/docs-content.ts index 46359d60..ed1cbb4a 100644 --- a/frontend/src/pages/Docs/docs-content.ts +++ b/frontend/src/pages/Docs/docs-content.ts @@ -7,7 +7,19 @@ export interface DocsEntry { title: string group: DocsGroup order: number - loader: () => Promise + access: DocsAccess +} + +export type DocsAccess = 'public' | 'docs_user' | 'docs_developer' | 'docs_admin' + +export interface DocsCatalogItem { + slug: string + filename: string + lang: DocsLang + title: string + group: DocsGroup + order: number + access: DocsAccess } export interface DocsHeading { @@ -16,7 +28,7 @@ export interface DocsHeading { text: string } -interface DocsMetadataEntry { +export interface DocsMetadataEntry { zh: { title: string; group: DocsGroup; order: number } en: { title: string; group: DocsGroup; order: number } } @@ -48,17 +60,7 @@ const DOCS_README_FILENAME = 'README.md' const MAX_HEADING_ID_LENGTH = 80 export const defaultDocsSlug = 'overview' -const zhModules = import.meta.glob('../../../../docs/technical/zh/*.md', { - query: '?raw', - import: 'default', -}) - -const enModules = import.meta.glob('../../../../docs/technical/en/*.md', { - query: '?raw', - import: 'default', -}) - -const DOCS_METADATA: Record = { +export const DOCS_METADATA: Record = { [DOCS_README_FILENAME]: { zh: { title: '技术文档', group: 'Overview', order: 0 }, en: { title: 'Technical Docs', group: 'Overview', order: 0 }, @@ -71,6 +73,10 @@ const DOCS_METADATA: Record = { zh: { title: 'Planet 使用手册', group: 'Manual', order: 2 }, en: { title: 'Planet Manual', group: 'Manual', order: 2 }, }, + 'location-pipeline-user.md': { + zh: { title: 'Earth 位置候选采集使用手册', group: 'Manual', order: 3 }, + en: { title: 'Earth Location Candidate Collection User Guide', group: 'Manual', order: 3 }, + }, 'earth-frontend-context.md': { zh: { title: 'Earth 前端结构', group: 'Earth', order: 10 }, en: { title: 'Earth Frontend Context', group: 'Earth', order: 10 }, @@ -99,6 +105,10 @@ const DOCS_METADATA: Record = { zh: { title: 'Earth 可交互图标接入', group: 'Earth', order: 16 }, en: { title: 'Earth Interactable Usage', group: 'Earth', order: 16 }, }, + 'earth-toolbar-overlay-coordination.md': { + zh: { title: 'Earth 工具栏与浮层协同', group: 'Earth', order: 17 }, + en: { title: 'Earth Toolbar and Overlay Coordination', group: 'Earth', order: 17 }, + }, 'frontend-admin-frontend-context.md': { zh: { title: '控制台前端结构', group: 'Frontend', order: 20 }, en: { title: 'Admin Frontend Context', group: 'Frontend', order: 20 }, @@ -107,6 +117,10 @@ const DOCS_METADATA: Record = { zh: { title: '前端布局指南', group: 'Frontend', order: 21 }, en: { title: 'Frontend Layout Guidelines', group: 'Frontend', order: 21 }, }, + 'docs-gatekeeper-development.md': { + zh: { title: 'Docs Gatekeeper 开发说明', group: 'Frontend', order: 22 }, + en: { title: 'Docs Gatekeeper Development Guide', group: 'Frontend', order: 22 }, + }, 'backend-collectors.md': { zh: { title: '数据采集系统', group: 'Backend', order: 30 }, en: { title: 'Data Collectors', group: 'Backend', order: 30 }, @@ -119,6 +133,14 @@ const DOCS_METADATA: Record = { zh: { title: '数据源、采集器设置与连接验证', group: 'Backend', order: 32 }, en: { title: 'Datasource Collector Settings and Connectivity', group: 'Backend', order: 32 }, }, + 'backend-datasources-api-performance.md': { + zh: { title: '数据源 API 性能', group: 'Backend', order: 33 }, + en: { title: 'Datasource API Performance', group: 'Backend', order: 33 }, + }, + 'location-pipeline-development.md': { + zh: { title: '通用位置估算管线开发说明', group: 'Backend', order: 34 }, + en: { title: 'Shared Location Resolution Pipeline Development Guide', group: 'Backend', order: 34 }, + }, 'agents-aiprovider.md': { zh: { title: 'AI Provider 指南', group: 'Agents', order: 40 }, en: { title: 'AI Provider Guide', group: 'Agents', order: 40 }, @@ -127,47 +149,35 @@ const DOCS_METADATA: Record = { zh: { title: 'Docker + Compose + Buildx 升级', group: 'Ops', order: 50 }, en: { title: 'Docker + Compose + Buildx Upgrade', group: 'Ops', order: 50 }, }, + 'ops-planet-sh-startup.md': { + zh: { title: 'planet.sh 启动机制', group: 'Ops', order: 51 }, + en: { title: 'planet.sh Startup', group: 'Ops', order: 51 }, + }, } const GROUP_ORDER: DocsGroup[] = ['Overview', 'Manual', 'Earth', 'Frontend', 'Backend', 'Agents', 'Ops', 'Other'] -const ALL_KNOWN_SLUGS = new Set( - Object.keys(DOCS_METADATA).map((filename) => - filename === DOCS_README_FILENAME ? defaultDocsSlug : filename.replace(/\.md$/, '') - ) -) - -function filenameFromPath(path: string): string { - return path.split('/').pop() || path -} - export function slugFromFilename(filename: string): string { return filename === DOCS_README_FILENAME ? defaultDocsSlug : filename.replace(/\.md$/, '') } -export function getDocsEntries(lang: DocsLang): DocsEntry[] { - const modules = lang === 'zh' ? zhModules : enModules - return Object.entries(modules) - .filter(([path]) => DOCS_METADATA[filenameFromPath(path)]) - .map(([path, loader]) => { - const filename = filenameFromPath(path) - const meta = DOCS_METADATA[filename] - const langMeta = meta[lang] - return { - slug: slugFromFilename(filename), - filename, - title: langMeta.title, - group: langMeta.group, - order: langMeta.order, - loader: loader as () => Promise, - } - }) +export function getDocsEntries(lang: DocsLang, catalogItems: DocsCatalogItem[]): DocsEntry[] { + return catalogItems + .filter((item) => item.lang === lang) + .map((item) => ({ + slug: item.slug, + filename: item.filename, + title: item.title, + group: item.group, + order: item.order, + access: item.access, + })) .sort((a, b) => a.order - b.order || a.title.localeCompare(b.title)) } -export function getDocsEntry(slug: string | undefined, lang: DocsLang): DocsEntry | undefined { +export function getDocsEntry(slug: string | undefined, entries: DocsEntry[]): DocsEntry | undefined { const normalizedSlug = slug || defaultDocsSlug - return getDocsEntries(lang).find((entry) => entry.slug === normalizedSlug) + return entries.find((entry) => entry.slug === normalizedSlug) } export function groupDocsEntries(entries: DocsEntry[]): Array<{ group: DocsGroup; entries: DocsEntry[] }> { @@ -236,6 +246,5 @@ export function slugFromDocsHref(href: string): string | null { return null } - const slug = slugFromFilename(filename) - return ALL_KNOWN_SLUGS.has(slug) ? slug : null + return slugFromFilename(filename) } diff --git a/frontend/src/pages/Docs/docs-search.ts b/frontend/src/pages/Docs/docs-search.ts index 2dea8d2c..d6b5ecb9 100644 --- a/frontend/src/pages/Docs/docs-search.ts +++ b/frontend/src/pages/Docs/docs-search.ts @@ -52,10 +52,13 @@ function createExcerpt(text: string, query: string): string { return `${prefix}${text.slice(start, end)}${suffix}` } -export async function buildDocsSearchRecords(entries: DocsEntry[]): Promise { +export async function buildDocsSearchRecords( + entries: DocsEntry[], + loadMarkdown: (entry: DocsEntry) => Promise, +): Promise { const records = await Promise.all( entries.map(async (entry) => { - const markdown = await entry.loader() + const markdown = await loadMarkdown(entry) return { entry, markdown, diff --git a/frontend/src/pages/Users/Users.tsx b/frontend/src/pages/Users/Users.tsx index 43f7f6b1..6c655d23 100644 --- a/frontend/src/pages/Users/Users.tsx +++ b/frontend/src/pages/Users/Users.tsx @@ -6,17 +6,20 @@ import { TableActions, actionCellProps } from '../../components/TableActions/Tab import axios from 'axios' import AppLayout from '../../components/AppLayout/AppLayout' import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion' +import { useAuthStore } from '../../stores/auth' interface User { id: number username: string email: string role: string + gatekeeper_groups: string[] is_active: boolean created_at: string } function Users() { + const { user: currentUser } = useAuthStore() const [users, setUsers] = useState([]) const [loading, setLoading] = useState(false) const [modalVisible, setModalVisible] = useState(false) @@ -64,6 +67,9 @@ function Users() { const handleSubmit = async (values: Record) => { try { + if (currentUser?.role !== 'super_admin') { + delete values.gatekeeper_groups + } if (editingUser) { await axios.put(`/api/v1/users/${editingUser.id}`, values) message.success('更新成功') @@ -98,6 +104,21 @@ function Users() { return {role} }, }, + { + title: 'Gatekeeper', + dataIndex: 'gatekeeper_groups', + key: 'gatekeeper_groups', + width: 260, + render: (groups: string[] = []) => ( + <> + {groups.length > 0 ? groups.map((group) => ( + + {group} + + )) : 未配置} + + ), + }, { title: '状态', dataIndex: 'is_active', @@ -177,6 +198,18 @@ function Users() { 只读用户 + +