Merge pull request 'codex/aiprovider-foundation' (#4) from codex/aiprovider-foundation into main

Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
linkong
2026-04-07 09:35:48 +00:00
156 changed files with 21232 additions and 5267 deletions

101
README.md
View File

@@ -184,20 +184,109 @@
## 快速启动 ## 快速启动
```bash ```bash
# 启动全部服务 # 新机器首次初始化
docker-compose up -d ./scripts/bootstrap-dev.sh
# 会自动安装/检查 uv、bun并同步 Python/前端依赖
# 会在缺少时生成 backend/.env、aiprovider/.env、frontend/.env.local
# 启动后端 # 启动后端服务
cd backend && python -m uvicorn app.main:app --reload ./planet.sh start
# 仅启动前 # 仅重启后
cd frontend && npm run dev ./planet.sh restart -b
# 仅重启前端
./planet.sh restart -f
# 交互创建用户
./planet.sh createuser
# 查看服务状态
./planet.sh health
``` ```
## API 文档 ## API 文档
启动服务后访问: `http://localhost:8000/docs` 启动服务后访问: `http://localhost:8000/docs`
## AI 接口预留
项目现在采用“两层”设计:
- 主后端暴露稳定业务接口: `GET /api/v1/ai/provider/status``POST /api/v1/ai/situational-awareness/analyze`
- 独立 `aiprovider` 服务负责适配具体模型供应商
这样前端和业务代码不直接依赖 OpenAI、本地模型网关或其他订阅服务后续切换部署方式只需要调整环境变量。
主后端建议配置:
```env
AI_PROVIDER_SERVICE_URL=http://localhost:8010
AI_PROVIDER_SERVICE_TOKEN=change_me
AI_PROVIDER_TIMEOUT_SECONDS=60
```
`aiprovider` 服务建议配置:
```env
AI_PROVIDER=openai_compatible
AI_BASE_URL=https://api.openai.com/v1
AI_API_KEY=your_api_key
AI_MODEL=gpt-4o-mini
AI_TIMEOUT_SECONDS=60
AI_PROVIDER_SERVICE_TOKEN=change_me
```
OpenAI 兼容场景推荐使用:
- `AI_PROVIDER=openai_compatible`
Claude 兼容场景推荐使用:
- `AI_PROVIDER=anthropic`
- `AI_PROVIDER=anthropic_compatible`
- `AI_PROVIDER=claude_compatible`
Ollama 原生场景推荐使用:
- `AI_PROVIDER=ollama`
比如 MiniMax 或其他 Claude 兼容网关,可以这样配置:
```env
AI_PROVIDER=claude_compatible
AI_BASE_URL=https://your-claude-compatible-endpoint.example.com
AI_API_KEY=your_api_key
AI_MODEL=your-claude-compatible-model
AI_TIMEOUT_SECONDS=60
AI_MAX_TOKENS=1200
AI_ANTHROPIC_VERSION=2023-06-01
AI_PROVIDER_SERVICE_TOKEN=change_me
```
如果你要本地直接起模型适配层,项目里已经补了模板:
- [aiprovider/.env.example](/home/ray/dev/linkong/planet/aiprovider/.env.example)
- [docker-compose.local-model.yml](/home/ray/dev/linkong/planet/docker-compose.local-model.yml)
推荐映射关系:
- `vLLM` / `LM Studio` / `One API`: `AI_PROVIDER=openai_compatible`
- `MiniMax` / Claude 兼容网关: `AI_PROVIDER=claude_compatible`
- `Ollama`: `AI_PROVIDER=ollama`
运行与调用补充:
- `./planet.sh start` 默认会启动 `aiprovider`
- 其他服务优先调用主后端 `POST /api/v1/ai/situational-awareness/analyze`
- `backend -> aiprovider` 会透传 `X-Request-ID`
- `backend -> aiprovider``aiprovider -> 模型供应商` 都带轻量重试
详细文档:
- [docs/aiprovider.md](/home/ray/dev/linkong/planet/docs/aiprovider.md)
- [aiprovider/README.md](/home/ray/dev/linkong/planet/aiprovider/README.md)
## License ## License
待定 待定

19
TODO.md Normal file
View File

@@ -0,0 +1,19 @@
# TODO
- [x] 把 BGP 观测站和异常点的 `hover/click` 手感再磨细一点
- [x] 开始做 BGP 异常和海缆/区域的关联展示
- [x] 做 Earth 侧的 `BGP activity layer`,让低 incident 密度时地图仍然有持续可感知的观测存在感
- [x] 给 Earth BGP 补三层状态表达:`平稳观测态 / 局部波动态 / 事件活跃态`
- [x] 把“当前无活跃事件”改造成“观测网络仍在运行、当前未发现聚合级事件”的状态表达
- [x] 做 collector / region 近 15 分钟 activity score 聚合接口或动态聚合逻辑
- [x] 把 Earth 的 BGP incident 改成 `紧凑事件核 + 向外扩张环形 pulse`,替换当前大面积 glow
- [x] 为 BGP incident 建立符号系统:按事件类型用不同 marker而不是都用同一种亮点
- [x] 把 incident 地理定位从 `collector-centric` 改成 `prefix-centric`,优先使用 `prefix_geography`,其次 `prefix_scope`,再次 ASN 区域,最后才回退到观测区域质心
- [x] 新增 `prefix_geography` 数据层,不再把 `prefix_scope` 当成 prefix 地理归属本身
- [x] 接入 `IPtoASN / IPtoCountry` 作为 prefix-centric geography 的主数据源
- [x] 接入 `OpenGeoFeed` 作为 prefix geography 的高质量覆盖/override 数据源
- [x] 把 RIR delegated 设计成 prefix geography 的 fallback而不是主来源
- [ ] 接入 `inetnum` / `inet6num` whois 作为比 RIR 更细粒度的后备层
- [x] 在 activity layer 之后继续补 `route leak``path instability / flap` detector
- [ ] 对 [frontend/public/earth/js/bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) 做按职责拆分的小重构,拆成 data / markers / overlays / animation降低后续维护复杂度
- [ ] 可选优化(非必做):将 BGP incident/collector 标点改为 HTML marker参考 worldmonitor 的 `htmlElementsData` 思路),实现近乎固定屏幕尺寸与更高密度可点击性

1
VERSION Normal file
View File

@@ -0,0 +1 @@
0.23.0

34
aiprovider/.env.example Normal file
View File

@@ -0,0 +1,34 @@
# Shared service settings
SERVICE_NAME=planet-ai-provider
SERVICE_VERSION=0.1.0
AI_PROVIDER_SERVICE_TOKEN=change_me
AI_TIMEOUT_SECONDS=60
AI_HTTP_RETRY_ATTEMPTS=2
AI_ANALYSIS_SYSTEM_PROMPT=你是态势感知分析助手。请基于输入的上下文、观测与约束,输出结构化、克制、可执行的分析。
# Select one provider mode:
# - openai_compatible
# - claude_compatible
# - ollama
AI_PROVIDER=ollama
# Common model selection
AI_MODEL=qwen2.5:7b
# OpenAI-compatible example (vLLM / LM Studio / One API / local gateway)
# AI_PROVIDER=openai_compatible
# AI_BASE_URL=http://127.0.0.1:8001/v1
# AI_API_KEY=local-key
# Claude-compatible example (Anthropic / MiniMax / Claude-compatible gateway)
# AI_PROVIDER=claude_compatible
# AI_BASE_URL=http://127.0.0.1:8002
# AI_API_KEY=local-key
# AI_MAX_TOKENS=1200
# AI_ANTHROPIC_VERSION=2023-06-01
# Ollama native example
AI_BASE_URL=http://127.0.0.1:11434
AI_API_KEY=
AI_MAX_TOKENS=1200
AI_ANTHROPIC_VERSION=2023-06-01

23
aiprovider/Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
FROM python:3.14-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml uv.lock /app/
RUN uv sync --frozen --no-dev
COPY . /app
EXPOSE 8010
CMD ["uv", "run", "--frozen", "--no-dev", "--project", "/app", "python", "-m", "uvicorn", "aiprovider.main:app", "--host", "0.0.0.0", "--port", "8010", "--reload"]

81
aiprovider/README.md Normal file
View File

@@ -0,0 +1,81 @@
# AI Provider Service
`aiprovider` 是独立的模型适配服务,负责把项目内部的分析请求转发到具体的大模型供应商。
完整使用说明见:
- [docs/aiprovider.md](/home/ray/dev/linkong/planet/docs/aiprovider.md)
当前支持:
- `AI_PROVIDER=openai`
- `AI_PROVIDER=openai_compatible`
- `AI_PROVIDER=anthropic`
- `AI_PROVIDER=anthropic_compatible`
- `AI_PROVIDER=claude_compatible`
- `AI_PROVIDER=ollama`
典型配置:
```env
AI_PROVIDER=openai_compatible
AI_BASE_URL=https://api.openai.com/v1
AI_API_KEY=your_api_key
AI_MODEL=gpt-4o-mini
AI_TIMEOUT_SECONDS=60
AI_PROVIDER_SERVICE_TOKEN=change_me
```
Claude 兼容供应商示例:
```env
AI_PROVIDER=claude_compatible
AI_BASE_URL=https://your-claude-compatible-endpoint.example.com
AI_API_KEY=your_api_key
AI_MODEL=your-claude-compatible-model
AI_TIMEOUT_SECONDS=60
AI_MAX_TOKENS=1200
AI_ANTHROPIC_VERSION=2023-06-01
AI_PROVIDER_SERVICE_TOKEN=change_me
```
适用场景:
- Anthropic 官方 Claude API
- Claude 兼容网关
- MiniMax 等提供 Claude/Anthropic 风格消息接口的服务
Ollama 原生示例:
```env
AI_PROVIDER=ollama
AI_BASE_URL=http://127.0.0.1:11434
AI_API_KEY=
AI_MODEL=qwen2.5:7b
AI_TIMEOUT_SECONDS=60
AI_PROVIDER_SERVICE_TOKEN=change_me
```
本地模型接入建议:
- `vLLM``LM Studio``One API`:优先使用 `openai_compatible`
- `MiniMax`、Claude 兼容网关:使用 `claude_compatible`
- `Ollama`:可直接使用 `ollama`
启动模板:
- `aiprovider/.env.example`
- `docker-compose.local-model.yml`
跨服务调用补充:
- 业务服务优先调用主后端 `/api/v1/ai/...`
- 直接调用 `aiprovider` 时使用 `X-Provider-Token`
- 支持 `X-Request-ID` 透传
- 内置轻量重试,适合跨机器 HTTP RPC 场景
接口:
- `GET /health`
- `GET /v1/provider/status`
- `POST /v1/analyze`

1
aiprovider/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""AI provider adapter service package."""

35
aiprovider/config.py Normal file
View File

@@ -0,0 +1,35 @@
from functools import lru_cache
from pathlib import Path
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
SERVICE_NAME: str = "planet-ai-provider"
SERVICE_VERSION: str = "0.1.0"
AI_PROVIDER: str = "disabled"
AI_BASE_URL: str = "https://api.openai.com/v1"
AI_API_KEY: str = ""
AI_MODEL: str = ""
AI_TIMEOUT_SECONDS: int = 60
AI_HTTP_RETRY_ATTEMPTS: int = 2
AI_MAX_TOKENS: int = 1200
AI_ANTHROPIC_VERSION: str = "2023-06-01"
AI_ANALYSIS_SYSTEM_PROMPT: str = (
"你是态势感知分析助手。请基于输入的上下文、观测与约束,输出结构化、克制、可执行的分析。"
)
AI_PROVIDER_SERVICE_TOKEN: str = ""
class Config:
env_file = Path(__file__).parent / ".env"
case_sensitive = True
@lru_cache()
def get_settings() -> Settings:
return Settings()
settings = get_settings()

79
aiprovider/main.py Normal file
View File

@@ -0,0 +1,79 @@
from uuid import uuid4
from fastapi import Depends, FastAPI, Header, HTTPException, Request, Response, status
from aiprovider.config import settings
from aiprovider.provider_service import ProviderService
from aiprovider.schemas import (
AIProviderStatusResponse,
SituationalAnalysisRequest,
SituationalAnalysisResponse,
)
app = FastAPI(
title=settings.SERVICE_NAME,
version=settings.SERVICE_VERSION,
description="AI provider adapter service for Planet",
)
@app.middleware("http")
async def request_id_middleware(request: Request, call_next):
request_id = request.headers.get("X-Request-ID") or str(uuid4())
request.state.request_id = request_id
response = await call_next(request)
response.headers["X-Request-ID"] = request_id
return response
def verify_service_token(x_provider_token: str | None = Header(default=None)) -> None:
expected = settings.AI_PROVIDER_SERVICE_TOKEN
if not expected:
return
if x_provider_token != expected:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid provider service token",
)
def get_provider_service() -> ProviderService:
return ProviderService()
@app.get("/health")
async def health_check():
return {
"status": "healthy",
"service": settings.SERVICE_NAME,
"version": settings.SERVICE_VERSION,
}
@app.get(
"/v1/provider/status",
response_model=AIProviderStatusResponse,
dependencies=[Depends(verify_service_token)],
)
async def get_provider_status(
response: Response,
request: Request,
provider_service: ProviderService = Depends(get_provider_service),
):
response.headers["X-Request-ID"] = request.state.request_id
return provider_service.get_status()
@app.post(
"/v1/analyze",
response_model=SituationalAnalysisResponse,
dependencies=[Depends(verify_service_token)],
)
async def analyze(
payload: SituationalAnalysisRequest,
response: Response,
request: Request,
provider_service: ProviderService = Depends(get_provider_service),
):
response.headers["X-Request-ID"] = request.state.request_id
return await provider_service.analyze(payload)

View File

@@ -0,0 +1,240 @@
from __future__ import annotations
import asyncio
from typing import Any
import httpx
from fastapi import HTTPException, status
from aiprovider.config import settings
from aiprovider.schemas import (
AIProviderStatusResponse,
SituationalAnalysisRequest,
SituationalAnalysisResponse,
)
def _normalize_provider(value: str) -> str:
return (value or "disabled").strip().lower()
class ProviderService:
def __init__(self) -> None:
self.provider = _normalize_provider(settings.AI_PROVIDER)
self.base_url = settings.AI_BASE_URL.rstrip("/")
self.api_key = settings.AI_API_KEY
self.default_model = settings.AI_MODEL
self.timeout = settings.AI_TIMEOUT_SECONDS
self.http_retry_attempts = max(settings.AI_HTTP_RETRY_ATTEMPTS, 1)
self.max_tokens = settings.AI_MAX_TOKENS
self.anthropic_version = settings.AI_ANTHROPIC_VERSION
self.system_prompt = settings.AI_ANALYSIS_SYSTEM_PROMPT
def get_status(self) -> AIProviderStatusResponse:
enabled = self.provider != "disabled"
configured = enabled and bool(self.base_url and self.api_key and self.default_model)
return AIProviderStatusResponse(
provider=self.provider,
enabled=enabled,
configured=configured,
model=self.default_model or None,
base_url=self.base_url if enabled else None,
)
async def analyze(self, payload: SituationalAnalysisRequest) -> SituationalAnalysisResponse:
if self.provider == "disabled":
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="AI provider is disabled. Configure AI_PROVIDER in .env to enable analysis.",
)
model = payload.preferred_model or self.default_model
if not self.base_url or not self.api_key or not model:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="AI provider is not fully configured. Check AI_BASE_URL, AI_API_KEY, and AI_MODEL.",
)
prompt = self._build_prompt(payload)
if self.provider in {"openai", "openai_compatible"}:
data = await self._request_openai_compatible(model, prompt)
content = self._extract_openai_content(data)
elif self.provider in {"anthropic", "anthropic_compatible", "claude_compatible"}:
data = await self._request_anthropic_compatible(model, prompt)
content = self._extract_anthropic_content(data)
elif self.provider == "ollama":
data = await self._request_ollama(model, prompt)
content = self._extract_ollama_content(data)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported AI provider: {self.provider}",
)
return SituationalAnalysisResponse(
provider=self.provider,
model=model,
content=content,
raw_response=data,
)
def _build_prompt(self, payload: SituationalAnalysisRequest) -> str:
sections = [
f"任务标题:\n{payload.title}",
f"分析目标:\n{payload.objective}",
]
if payload.observations:
sections.append("观测事实:\n" + "\n".join(f"- {item}" for item in payload.observations))
if payload.constraints:
sections.append("约束条件:\n" + "\n".join(f"- {item}" for item in payload.constraints))
if payload.context:
sections.append(f"附加上下文:\n{payload.context}")
sections.append(
"请输出: 1) 态势摘要 2) 关键风险 3) 研判依据 4) 建议动作 5) 还缺少的数据。"
)
return "\n\n".join(sections)
async def _request_openai_compatible(self, model: str, prompt: str) -> dict[str, Any]:
request_body = {
"model": model,
"messages": [
{"role": "system", "content": self.system_prompt},
{"role": "user", "content": prompt},
],
"temperature": 0.2,
}
return await self._post(
path="/chat/completions",
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
request_body=request_body,
)
async def _request_anthropic_compatible(self, model: str, prompt: str) -> dict[str, Any]:
request_body = {
"model": model,
"system": self.system_prompt,
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": prompt,
}
],
}
],
"max_tokens": self.max_tokens,
"temperature": 0.2,
}
return await self._post(
path="/messages",
headers={
"x-api-key": self.api_key,
"anthropic-version": self.anthropic_version,
"Content-Type": "application/json",
},
request_body=request_body,
)
async def _request_ollama(self, model: str, prompt: str) -> dict[str, Any]:
request_body = {
"model": model,
"stream": False,
"system": self.system_prompt,
"prompt": prompt,
"options": {
"temperature": 0.2,
},
}
return await self._post(
path="/api/generate",
headers={
"Content-Type": "application/json",
},
request_body=request_body,
)
async def _post(
self,
path: str,
headers: dict[str, str],
request_body: dict[str, Any],
) -> dict[str, Any]:
last_error: Exception | None = None
for attempt in range(1, self.http_retry_attempts + 1):
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}{path}",
headers=headers,
json=request_body,
)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as exc:
last_error = exc
if attempt < self.http_retry_attempts and exc.response.status_code >= 500:
await asyncio.sleep(0.3 * attempt)
continue
detail = exc.response.text or "AI provider returned an error"
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"AI provider request failed: {detail}",
) from exc
except httpx.HTTPError as exc:
last_error = exc
if attempt < self.http_retry_attempts:
await asyncio.sleep(0.3 * attempt)
continue
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Failed to reach AI provider: {exc}",
) from exc
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"AI provider request failed: {last_error}",
)
def _extract_openai_content(self, payload: dict[str, Any]) -> str:
choices = payload.get("choices") or []
if not choices:
return ""
message = choices[0].get("message") or {}
content = message.get("content")
if isinstance(content, str):
return content
if isinstance(content, list):
return "".join(
item.get("text", "")
for item in content
if isinstance(item, dict)
)
return ""
def _extract_anthropic_content(self, payload: dict[str, Any]) -> str:
content = payload.get("content")
if isinstance(content, str):
return content
if not isinstance(content, list):
return ""
fragments: list[str] = []
for item in content:
if not isinstance(item, dict):
continue
if item.get("type") == "text" and isinstance(item.get("text"), str):
fragments.append(item["text"])
return "".join(fragments)
def _extract_ollama_content(self, payload: dict[str, Any]) -> str:
response = payload.get("response")
if isinstance(response, str):
return response
return ""

27
aiprovider/schemas.py Normal file
View File

@@ -0,0 +1,27 @@
from typing import Any
from pydantic import BaseModel, Field
class SituationalAnalysisRequest(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
objective: str = Field(..., min_length=1, max_length=1000)
context: dict[str, Any] = Field(default_factory=dict)
observations: list[str] = Field(default_factory=list)
constraints: list[str] = Field(default_factory=list)
preferred_model: str | None = Field(default=None, max_length=200)
class SituationalAnalysisResponse(BaseModel):
provider: str
model: str
content: str
raw_response: dict[str, Any] = Field(default_factory=dict)
class AIProviderStatusResponse(BaseModel):
provider: str
enabled: bool
configured: bool
model: str | None = None
base_url: str | None = None

View File

@@ -1,23 +1,26 @@
# Database PROJECT_NAME=Intelligent Planet Plan
APP_VERSION=0.23.0
SECRET_KEY=change_me_to_a_random_secret
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=0
REFRESH_TOKEN_EXPIRE_DAYS=0
POSTGRES_SERVER=localhost POSTGRES_SERVER=localhost
POSTGRES_USER=postgres POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres POSTGRES_PASSWORD=postgres
POSTGRES_DB=planet_db POSTGRES_DB=planet_db
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/planet_db
# Redis
REDIS_SERVER=localhost REDIS_SERVER=localhost
REDIS_PORT=6379 REDIS_PORT=6379
REDIS_DB=0
REDIS_URL=redis://localhost:6379/0
# Security AI_PROVIDER_SERVICE_URL=http://localhost:8010
SECRET_KEY=your-secret-key-change-in-production AI_PROVIDER_SERVICE_TOKEN=change_me
ALGORITHM=HS256 AI_PROVIDER_TIMEOUT_SECONDS=60
ACCESS_TOKEN_EXPIRE_MINUTES=15 AI_PROVIDER_RETRY_ATTEMPTS=2
REFRESH_TOKEN_EXPIRE_DAYS=7
# API SPACETRACK_USERNAME=
API_V1_STR=/api/v1 SPACETRACK_PASSWORD=
PROJECT_NAME="Intelligent Planet Plan"
VERSION=1.0.0
# CORS
CORS_ORIGINS=["http://localhost:3000", "http://localhost:8000"]

View File

@@ -1,19 +1,24 @@
FROM python:3.11-slim FROM python:3.14-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
WORKDIR /app WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
curl \ curl \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY requirements.txt . COPY pyproject.toml uv.lock /app/
RUN pip install --no-cache-dir -r requirements.txt RUN uv sync --frozen --no-dev
COPY . . COPY backend /app/backend
COPY VERSION /app/VERSION
EXPOSE 8000 EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] CMD ["uv", "run", "--frozen", "--no-dev", "--project", "/app", "python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

View File

@@ -1,5 +1,6 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.v1 import ( from app.api.v1 import (
ai,
auth, auth,
users, users,
datasource_config, datasource_config,
@@ -11,11 +12,14 @@ from app.api.v1 import (
settings, settings,
collected_data, collected_data,
visualization, visualization,
bgp,
system_control,
) )
api_router = APIRouter() api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(ai.router, prefix="/ai", tags=["ai"])
api_router.include_router(users.router, prefix="/users", tags=["users"]) api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router( api_router.include_router(
datasource_config.router, prefix="/datasources", tags=["datasource-config"] datasource_config.router, prefix="/datasources", tags=["datasource-config"]
@@ -26,4 +30,6 @@ api_router.include_router(tasks.router, prefix="/tasks", tags=["tasks"])
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"]) api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"])
api_router.include_router(alerts.router, prefix="/alerts", tags=["alerts"]) api_router.include_router(alerts.router, prefix="/alerts", tags=["alerts"])
api_router.include_router(settings.router, prefix="/settings", tags=["settings"]) api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
api_router.include_router(system_control.router, prefix="/system", tags=["system"])
api_router.include_router(visualization.router, prefix="/visualization", tags=["visualization"]) api_router.include_router(visualization.router, prefix="/visualization", tags=["visualization"])
api_router.include_router(bgp.router, prefix="/bgp", tags=["bgp"])

39
backend/app/api/v1/ai.py Normal file
View File

@@ -0,0 +1,39 @@
from uuid import uuid4
from fastapi import APIRouter, Depends, Request, Response
from app.core.security import get_current_user
from app.models.user import User
from app.schemas.ai import (
AIProviderStatusResponse,
SituationalAnalysisRequest,
SituationalAnalysisResponse,
)
from app.services.ai_client import AIProviderClient, get_ai_provider_client
router = APIRouter()
@router.get("/provider/status", response_model=AIProviderStatusResponse)
async def get_ai_provider_status(
request: Request,
response: Response,
current_user: User = Depends(get_current_user),
provider_client: AIProviderClient = Depends(get_ai_provider_client),
):
request_id = request.headers.get("X-Request-ID") or str(uuid4())
response.headers["X-Request-ID"] = request_id
return await provider_client.get_status(request_id=request_id)
@router.post("/situational-awareness/analyze", response_model=SituationalAnalysisResponse)
async def analyze_situational_awareness(
payload: SituationalAnalysisRequest,
request: Request,
response: Response,
current_user: User = Depends(get_current_user),
provider_client: AIProviderClient = Depends(get_ai_provider_client),
):
request_id = request.headers.get("X-Request-ID") or str(uuid4())
response.headers["X-Request-ID"] = request_id
return await provider_client.analyze(payload, request_id=request_id)

View File

@@ -1,4 +1,4 @@
from datetime import datetime from datetime import UTC, datetime
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
@@ -68,7 +68,7 @@ async def acknowledge_alert(
alert.status = AlertStatus.ACKNOWLEDGED alert.status = AlertStatus.ACKNOWLEDGED
alert.acknowledged_by = current_user.id alert.acknowledged_by = current_user.id
alert.acknowledged_at = datetime.utcnow() alert.acknowledged_at = datetime.now(UTC)
await db.commit() await db.commit()
return {"message": "Alert acknowledged", "alert": alert.to_dict()} return {"message": "Alert acknowledged", "alert": alert.to_dict()}
@@ -89,7 +89,7 @@ async def resolve_alert(
alert.status = AlertStatus.RESOLVED alert.status = AlertStatus.RESOLVED
alert.resolved_by = current_user.id alert.resolved_by = current_user.id
alert.resolved_at = datetime.utcnow() alert.resolved_at = datetime.now(UTC)
alert.resolution_notes = resolution alert.resolution_notes = resolution
await db.commit() await db.commit()

305
backend/app/api/v1/bgp.py Normal file
View File

@@ -0,0 +1,305 @@
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
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_collectors import build_bgp_collector_coverage
router = APIRouter()
BGP_SOURCES = ("ris_live_bgp", "bgpstream_bgp")
def _parse_dt(value: Optional[str]) -> Optional[datetime]:
if not value:
return None
return datetime.fromisoformat(value.replace("Z", "+00:00"))
def _matches_time(value: Optional[datetime], time_from: Optional[datetime], time_to: Optional[datetime]) -> bool:
if value is None:
return False
if time_from and value < time_from:
return False
if time_to and value > time_to:
return False
return True
@router.get("/events")
async def list_bgp_events(
prefix: Optional[str] = Query(None),
origin_asn: Optional[int] = Query(None),
peer_asn: Optional[int] = Query(None),
collector: Optional[str] = Query(None),
event_type: Optional[str] = Query(None),
source: Optional[str] = Query(None),
time_from: Optional[str] = Query(None),
time_to: Optional[str] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
stmt = (
select(BGPObservation)
.where(BGPObservation.source.in_(BGP_SOURCES))
.order_by(BGPObservation.observed_at.desc(), BGPObservation.id.desc())
)
if source:
stmt = stmt.where(BGPObservation.source == source)
result = await db.execute(stmt)
records = result.scalars().all()
dt_from = _parse_dt(time_from)
dt_to = _parse_dt(time_to)
filtered = []
for record in records:
if prefix and record.prefix != prefix:
continue
if origin_asn is not None and record.origin_asn != origin_asn:
continue
if peer_asn is not None and record.peer_asn != peer_asn:
continue
if collector and record.collector != collector:
continue
if event_type and record.event_type != event_type:
continue
if (dt_from or dt_to) and not _matches_time(record.observed_at, dt_from, dt_to):
continue
filtered.append(record)
offset = (page - 1) * page_size
return {
"total": len(filtered),
"page": page,
"page_size": page_size,
"data": [record.to_dict() for record in filtered[offset : offset + page_size]],
}
@router.get("/events/summary")
async def get_bgp_event_summary(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(BGPObservation).where(BGPObservation.source.in_(BGP_SOURCES)))
records = result.scalars().all()
collectors = sorted({record.collector for record in records if record.collector})
prefixes = sorted({record.prefix for record in records if record.prefix})
by_type: dict[str, int] = {}
for record in records:
by_type[record.event_type] = by_type.get(record.event_type, 0) + 1
return {
"total": len(records),
"collector_count": len(collectors),
"prefix_count": len(prefixes),
"by_type": by_type,
}
@router.get("/collectors")
async def list_bgp_collectors(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
data = await build_bgp_collector_coverage(db, source_filter=BGP_SOURCES)
return {
"total": len(data),
"data": data,
}
@router.get("/collectors/summary")
async def get_bgp_collector_summary(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
collectors = await build_bgp_collector_coverage(db, source_filter=BGP_SOURCES)
active_collectors = [item for item in collectors if item["observation_count"] > 0]
return {
"total": len(collectors),
"active_collectors": len(active_collectors),
"observed_prefixes": sum(item["prefix_count"] for item in active_collectors),
"observed_origins": sum(item["origin_asn_count"] for item in active_collectors),
"recent_24h_events": sum(item["recent_24h_observation_count"] for item in active_collectors),
"recent_7d_events": sum(item["recent_7d_observation_count"] for item in active_collectors),
}
@router.get("/events/{event_id}")
async def get_bgp_event(
event_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
record = await db.get(BGPObservation, event_id)
if not record or record.source not in BGP_SOURCES:
raise HTTPException(status_code=404, detail="BGP event not found")
return record.to_dict()
@router.get("/anomalies")
async def list_bgp_anomalies(
severity: Optional[str] = Query(None),
anomaly_type: Optional[str] = Query(None),
status: Optional[str] = Query(None),
prefix: Optional[str] = Query(None),
origin_asn: Optional[int] = Query(None),
time_from: Optional[str] = Query(None),
time_to: Optional[str] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
stmt = select(BGPAnomaly).order_by(BGPAnomaly.created_at.desc(), BGPAnomaly.id.desc())
if severity:
stmt = stmt.where(BGPAnomaly.severity == severity)
if anomaly_type:
stmt = stmt.where(BGPAnomaly.anomaly_type == anomaly_type)
if status:
stmt = stmt.where(BGPAnomaly.status == status)
if prefix:
stmt = stmt.where(BGPAnomaly.prefix == prefix)
if origin_asn is not None:
stmt = stmt.where(BGPAnomaly.origin_asn == origin_asn)
result = await db.execute(stmt)
records = result.scalars().all()
dt_from = _parse_dt(time_from)
dt_to = _parse_dt(time_to)
if dt_from or dt_to:
records = [record for record in records if _matches_time(record.created_at, dt_from, dt_to)]
offset = (page - 1) * page_size
return {
"total": len(records),
"page": page,
"page_size": page_size,
"data": [record.to_dict() for record in records[offset : offset + page_size]],
}
@router.get("/anomalies/summary")
async def get_bgp_anomaly_summary(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
total_result = await db.execute(select(func.count(BGPAnomaly.id)))
type_result = await db.execute(
select(BGPAnomaly.anomaly_type, func.count(BGPAnomaly.id))
.group_by(BGPAnomaly.anomaly_type)
.order_by(func.count(BGPAnomaly.id).desc())
)
severity_result = await db.execute(
select(BGPAnomaly.severity, func.count(BGPAnomaly.id))
.group_by(BGPAnomaly.severity)
.order_by(func.count(BGPAnomaly.id).desc())
)
status_result = await db.execute(
select(BGPAnomaly.status, func.count(BGPAnomaly.id))
.group_by(BGPAnomaly.status)
.order_by(func.count(BGPAnomaly.id).desc())
)
return {
"total": total_result.scalar() or 0,
"by_type": {row[0]: row[1] for row in type_result.fetchall()},
"by_severity": {row[0]: row[1] for row in severity_result.fetchall()},
"by_status": {row[0]: row[1] for row in status_result.fetchall()},
}
@router.get("/anomalies/{anomaly_id}")
async def get_bgp_anomaly(
anomaly_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
record = await db.get(BGPAnomaly, anomaly_id)
if not record:
raise HTTPException(status_code=404, detail="BGP anomaly not found")
return record.to_dict()
@router.get("/incidents")
async def list_bgp_incidents(
severity: Optional[str] = Query(None),
incident_type: Optional[str] = Query(None),
status: Optional[str] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
stmt = select(BGPIncident).order_by(BGPIncident.created_at.desc(), BGPIncident.id.desc())
if severity:
stmt = stmt.where(BGPIncident.severity == severity)
if incident_type:
stmt = stmt.where(BGPIncident.incident_type == incident_type)
if status:
stmt = stmt.where(BGPIncident.status == status)
result = await db.execute(stmt)
records = result.scalars().all()
offset = (page - 1) * page_size
return {
"total": len(records),
"page": page,
"page_size": page_size,
"data": [record.to_dict() for record in records[offset : offset + page_size]],
}
@router.get("/incidents/summary")
async def get_bgp_incident_summary(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
total_result = await db.execute(select(func.count(BGPIncident.id)))
type_result = await db.execute(
select(BGPIncident.incident_type, func.count(BGPIncident.id))
.group_by(BGPIncident.incident_type)
.order_by(func.count(BGPIncident.id).desc())
)
severity_result = await db.execute(
select(BGPIncident.severity, func.count(BGPIncident.id))
.group_by(BGPIncident.severity)
.order_by(func.count(BGPIncident.id).desc())
)
status_result = await db.execute(
select(BGPIncident.status, func.count(BGPIncident.id))
.group_by(BGPIncident.status)
.order_by(func.count(BGPIncident.id).desc())
)
return {
"total": total_result.scalar() or 0,
"by_type": {row[0]: row[1] for row in type_result.fetchall()},
"by_severity": {row[0]: row[1] for row in severity_result.fetchall()},
"by_status": {row[0]: row[1] for row in status_result.fetchall()},
}
@router.get("/incidents/{incident_id}")
async def get_bgp_incident(
incident_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
record = await db.get(BGPIncident, incident_id)
if not record:
raise HTTPException(status_code=404, detail="BGP incident not found")
return record.to_dict()

View File

@@ -9,10 +9,12 @@ import io
from app.core.collected_data_fields import get_metadata_field from app.core.collected_data_fields import get_metadata_field
from app.core.countries import COUNTRY_OPTIONS, get_country_search_variants, normalize_country from app.core.countries import COUNTRY_OPTIONS, get_country_search_variants, normalize_country
from app.core.time import to_iso8601_utc
from app.db.session import get_db from app.db.session import get_db
from app.models.user import User from app.models.user import User
from app.core.security import get_current_user from app.core.security import get_current_user
from app.models.collected_data import CollectedData from app.models.collected_data import CollectedData
from app.models.datasource import DataSource
router = APIRouter() router = APIRouter()
@@ -100,11 +102,13 @@ def build_search_rank_sql(search: Optional[str]) -> str:
""" """
def serialize_collected_row(row) -> dict: def serialize_collected_row(row, source_name_map: dict[str, str] | None = None) -> dict:
metadata = row[7] metadata = row[7]
source = row[1]
return { return {
"id": row[0], "id": row[0],
"source": row[1], "source": source,
"source_name": source_name_map.get(source, source) if source_name_map else source,
"source_id": row[2], "source_id": row[2],
"data_type": row[3], "data_type": row[3],
"name": row[4], "name": row[4],
@@ -121,12 +125,17 @@ def serialize_collected_row(row) -> dict:
"rmax": get_metadata_field(metadata, "rmax"), "rmax": get_metadata_field(metadata, "rmax"),
"rpeak": get_metadata_field(metadata, "rpeak"), "rpeak": get_metadata_field(metadata, "rpeak"),
"power": get_metadata_field(metadata, "power"), "power": get_metadata_field(metadata, "power"),
"collected_at": row[8].isoformat() if row[8] else None, "collected_at": to_iso8601_utc(row[8]),
"reference_date": row[9].isoformat() if row[9] else None, "reference_date": to_iso8601_utc(row[9]),
"is_valid": row[10], "is_valid": row[10],
} }
async def get_source_name_map(db: AsyncSession) -> dict[str, str]:
result = await db.execute(select(DataSource.source, DataSource.name))
return {row[0]: row[1] for row in result.fetchall()}
@router.get("") @router.get("")
async def list_collected_data( async def list_collected_data(
mode: str = Query("current", description="查询模式: current/history"), mode: str = Query("current", description="查询模式: current/history"),
@@ -188,10 +197,11 @@ async def list_collected_data(
result = await db.execute(query, params) result = await db.execute(query, params)
rows = result.fetchall() rows = result.fetchall()
source_name_map = await get_source_name_map(db)
data = [] data = []
for row in rows: for row in rows:
data.append(serialize_collected_row(row[:11])) data.append(serialize_collected_row(row[:11], source_name_map))
return { return {
"total": total, "total": total,
@@ -204,23 +214,38 @@ async def list_collected_data(
@router.get("/summary") @router.get("/summary")
async def get_data_summary( async def get_data_summary(
mode: str = Query("current", description="查询模式: current/history"), mode: str = Query("current", description="查询模式: current/history"),
source: Optional[str] = Query(None, description="数据源过滤"),
data_type: Optional[str] = Query(None, description="数据类型过滤"),
country: Optional[str] = Query(None, description="国家过滤"),
search: Optional[str] = Query(None, description="搜索名称"),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""获取数据汇总统计""" """获取数据汇总统计"""
where_sql = "WHERE COALESCE(is_current, TRUE) = TRUE" if mode != "history" else "" where_sql, params = build_where_clause(source, data_type, country, search)
if mode != "history":
where_sql = f"({where_sql}) AND COALESCE(is_current, TRUE) = TRUE"
overall_where_sql = "COALESCE(is_current, TRUE) = TRUE" if mode != "history" else "1=1"
overall_total_result = await db.execute(
text(f"SELECT COUNT(*) FROM collected_data WHERE {overall_where_sql}")
)
overall_total = overall_total_result.scalar() or 0
# By source and data_type # By source and data_type
result = await db.execute( result = await db.execute(
text(""" text(f"""
SELECT source, data_type, COUNT(*) as count SELECT source, data_type, COUNT(*) as count
FROM collected_data FROM collected_data
""" + where_sql + """ WHERE {where_sql}
GROUP BY source, data_type GROUP BY source, data_type
ORDER BY source, data_type ORDER BY source, data_type
""") """),
params,
) )
rows = result.fetchall() rows = result.fetchall()
source_name_map = await get_source_name_map(db)
by_source = {} by_source = {}
total = 0 total = 0
@@ -229,27 +254,56 @@ async def get_data_summary(
data_type = row[1] data_type = row[1]
count = row[2] count = row[2]
if source not in by_source: source_key = source_name_map.get(source, source)
by_source[source] = {} if source_key not in by_source:
by_source[source][data_type] = count by_source[source_key] = {}
by_source[source_key][data_type] = count
total += count total += count
# Total by source # Total by source
source_totals = await db.execute( source_totals = await db.execute(
text(""" text(f"""
SELECT source, COUNT(*) as count SELECT source, COUNT(*) as count
FROM collected_data FROM collected_data
""" + where_sql + """ WHERE {where_sql}
GROUP BY source GROUP BY source
ORDER BY count DESC ORDER BY count DESC
""") """),
params,
) )
source_rows = source_totals.fetchall() source_rows = source_totals.fetchall()
type_totals = await db.execute(
text(f"""
SELECT data_type, COUNT(*) as count
FROM collected_data
WHERE {where_sql}
GROUP BY data_type
ORDER BY count DESC, data_type
"""),
params,
)
type_rows = type_totals.fetchall()
return { return {
"total_records": total, "total_records": total,
"overall_total_records": overall_total,
"by_source": by_source, "by_source": by_source,
"source_totals": [{"source": row[0], "count": row[1]} for row in source_rows], "source_totals": [
{
"source": row[0],
"source_name": source_name_map.get(row[0], row[0]),
"count": row[1],
}
for row in source_rows
],
"type_totals": [
{
"data_type": row[0],
"count": row[1],
}
for row in type_rows
],
} }
@@ -269,9 +323,13 @@ async def get_data_sources(
""") """)
) )
rows = result.fetchall() rows = result.fetchall()
source_name_map = await get_source_name_map(db)
return { return {
"sources": [row[0] for row in rows], "sources": [
{"source": row[0], "source_name": source_name_map.get(row[0], row[0])}
for row in rows
],
} }
@@ -334,7 +392,8 @@ async def get_collected_data(
detail="数据不存在", detail="数据不存在",
) )
return serialize_collected_row(row) source_name_map = await get_source_name_map(db)
return serialize_collected_row(row, source_name_map)
def build_where_clause( def build_where_clause(
@@ -482,8 +541,8 @@ async def export_csv(
get_metadata_field(row[7], "value"), get_metadata_field(row[7], "value"),
get_metadata_field(row[7], "unit"), get_metadata_field(row[7], "unit"),
json.dumps(row[7]) if row[7] else "", json.dumps(row[7]) if row[7] else "",
row[8].isoformat() if row[8] else "", to_iso8601_utc(row[8]) or "",
row[9].isoformat() if row[9] else "", to_iso8601_utc(row[9]) or "",
row[10], row[10],
] ]
) )

View File

@@ -1,6 +1,6 @@
"""Dashboard API with caching and optimizations""" """Dashboard API with caching and optimizations"""
from datetime import datetime, timedelta from datetime import UTC, datetime, timedelta
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy import select, func, text from sqlalchemy import select, func, text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -13,6 +13,7 @@ from app.models.alert import Alert, AlertSeverity
from app.models.task import CollectionTask from app.models.task import CollectionTask
from app.core.security import get_current_user from app.core.security import get_current_user
from app.core.cache import cache from app.core.cache import cache
from app.core.time import to_iso8601_utc
# Built-in collectors info (mirrored from datasources.py) # Built-in collectors info (mirrored from datasources.py)
@@ -111,7 +112,7 @@ async def get_stats(
if cached_result: if cached_result:
return cached_result return cached_result
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) today_start = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
# Count built-in collectors # Count built-in collectors
built_in_count = len(COLLECTOR_INFO) built_in_count = len(COLLECTOR_INFO)
@@ -175,7 +176,7 @@ async def get_stats(
"active_datasources": active_datasources, "active_datasources": active_datasources,
"tasks_today": tasks_today, "tasks_today": tasks_today,
"success_rate": round(success_rate, 1), "success_rate": round(success_rate, 1),
"last_updated": datetime.utcnow().isoformat(), "last_updated": to_iso8601_utc(datetime.now(UTC)),
"alerts": { "alerts": {
"critical": critical_alerts, "critical": critical_alerts,
"warning": warning_alerts, "warning": warning_alerts,
@@ -230,10 +231,10 @@ async def get_summary(
summary[module] = { summary[module] = {
"datasources": data["datasources"], "datasources": data["datasources"],
"total_records": 0, # Built-in don't track this in dashboard stats "total_records": 0, # Built-in don't track this in dashboard stats
"last_updated": datetime.utcnow().isoformat(), "last_updated": to_iso8601_utc(datetime.now(UTC)),
} }
response = {"modules": summary, "last_updated": datetime.utcnow().isoformat()} response = {"modules": summary, "last_updated": to_iso8601_utc(datetime.now(UTC))}
cache.set(cache_key, response, expire_seconds=300) cache.set(cache_key, response, expire_seconds=300)

View File

@@ -14,6 +14,7 @@ from app.models.user import User
from app.models.datasource_config import DataSourceConfig from app.models.datasource_config import DataSourceConfig
from app.core.security import get_current_user from app.core.security import get_current_user
from app.core.cache import cache from app.core.cache import cache
from app.core.time import to_iso8601_utc
router = APIRouter() router = APIRouter()
@@ -123,8 +124,8 @@ async def list_configs(
"headers": c.headers, "headers": c.headers,
"config": c.config, "config": c.config,
"is_active": c.is_active, "is_active": c.is_active,
"created_at": c.created_at.isoformat() if c.created_at else None, "created_at": to_iso8601_utc(c.created_at),
"updated_at": c.updated_at.isoformat() if c.updated_at else None, "updated_at": to_iso8601_utc(c.updated_at),
} }
for c in configs for c in configs
], ],
@@ -155,8 +156,8 @@ async def get_config(
"headers": config.headers, "headers": config.headers,
"config": config.config, "config": config.config,
"is_active": config.is_active, "is_active": config.is_active,
"created_at": config.created_at.isoformat() if config.created_at else None, "created_at": to_iso8601_utc(config.created_at),
"updated_at": config.updated_at.isoformat() if config.updated_at else None, "updated_at": to_iso8601_utc(config.updated_at),
} }

View File

@@ -1,9 +1,12 @@
import asyncio
from datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.time import to_iso8601_utc
from app.core.security import get_current_user from app.core.security import get_current_user
from app.core.data_sources import get_data_sources_config from app.core.data_sources import get_data_sources_config
from app.db.session import get_db from app.db.session import get_db
@@ -14,6 +17,7 @@ from app.models.user import User
from app.services.scheduler import get_latest_task_id_for_datasource, run_collector_now, sync_datasource_job from app.services.scheduler import get_latest_task_id_for_datasource, run_collector_now, sync_datasource_job
router = APIRouter() router = APIRouter()
STALE_RUNNING_TASK_TIMEOUT_MINUTES = 90
def format_frequency_label(minutes: int) -> str: def format_frequency_label(minutes: int) -> str:
@@ -24,6 +28,12 @@ def format_frequency_label(minutes: int) -> str:
return f"{minutes}m" return f"{minutes}m"
def is_due_for_collection(datasource: DataSource, now: datetime) -> bool:
if datasource.last_run_at is None:
return True
return datasource.last_run_at + timedelta(minutes=datasource.frequency_minutes) <= now
async def get_datasource_record(db: AsyncSession, source_id: str) -> Optional[DataSource]: async def get_datasource_record(db: AsyncSession, source_id: str) -> Optional[DataSource]:
datasource = None datasource = None
try: try:
@@ -47,6 +57,7 @@ async def get_last_completed_task(db: AsyncSession, datasource_id: int) -> Optio
select(CollectionTask) select(CollectionTask)
.where(CollectionTask.datasource_id == datasource_id) .where(CollectionTask.datasource_id == datasource_id)
.where(CollectionTask.completed_at.isnot(None)) .where(CollectionTask.completed_at.isnot(None))
.where(CollectionTask.status.in_(("success", "failed", "cancelled")))
.order_by(CollectionTask.completed_at.desc()) .order_by(CollectionTask.completed_at.desc())
.limit(1) .limit(1)
) )
@@ -61,7 +72,32 @@ async def get_running_task(db: AsyncSession, datasource_id: int) -> Optional[Col
.order_by(CollectionTask.started_at.desc()) .order_by(CollectionTask.started_at.desc())
.limit(1) .limit(1)
) )
return result.scalar_one_or_none() task = result.scalar_one_or_none()
if not task:
return None
started_at = task.started_at
if started_at is None:
return task
now = datetime.now(timezone.utc)
if started_at.tzinfo is None:
started_at = started_at.replace(tzinfo=timezone.utc)
if now - started_at <= timedelta(minutes=STALE_RUNNING_TASK_TIMEOUT_MINUTES):
return task
existing_error = (task.error_message or "").strip()
stale_reason = (
f"Marked failed automatically after stale running timeout "
f"({STALE_RUNNING_TASK_TIMEOUT_MINUTES}m)"
)
task.status = "failed"
task.phase = "failed"
task.completed_at = now
task.error_message = f"{existing_error}\n{stale_reason}".strip() if existing_error else stale_reason
await db.commit()
return None
@router.get("") @router.get("")
@@ -94,9 +130,9 @@ async def list_datasources(
) )
data_count = data_count_result.scalar() or 0 data_count = data_count_result.scalar() or 0
last_run = None last_run_at = datasource.last_run_at or (last_task.completed_at if last_task else None)
if last_task and last_task.completed_at and data_count > 0: last_run = to_iso8601_utc(last_run_at)
last_run = last_task.completed_at.strftime("%Y-%m-%d %H:%M") last_status = datasource.last_status or (last_task.status if last_task else None)
collector_list.append( collector_list.append(
{ {
@@ -110,6 +146,10 @@ async def list_datasources(
"collector_class": datasource.collector_class, "collector_class": datasource.collector_class,
"endpoint": endpoint, "endpoint": endpoint,
"last_run": last_run, "last_run": last_run,
"last_run_at": to_iso8601_utc(last_run_at),
"last_status": last_status,
"last_records_processed": last_task.records_processed if last_task else None,
"data_count": data_count,
"is_running": running_task is not None, "is_running": running_task is not None,
"task_id": running_task.id if running_task else None, "task_id": running_task.id if running_task else None,
"progress": running_task.progress if running_task else None, "progress": running_task.progress if running_task else None,
@@ -122,6 +162,105 @@ async def list_datasources(
return {"total": len(collector_list), "data": collector_list} return {"total": len(collector_list), "data": collector_list}
@router.post("/trigger-all")
async def trigger_all_datasources(
force: bool = Query(False),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(DataSource)
.where(DataSource.is_active == True)
.order_by(DataSource.module, DataSource.id)
)
datasources = result.scalars().all()
if not datasources:
return {
"status": "noop",
"message": "No active data sources to trigger",
"triggered": [],
"skipped": [],
"failed": [],
}
previous_task_ids: dict[int, Optional[int]] = {}
triggered_sources: list[dict] = []
skipped_sources: list[dict] = []
failed_sources: list[dict] = []
now = datetime.now(timezone.utc)
for datasource in datasources:
running_task = await get_running_task(db, datasource.id)
if running_task is not None:
skipped_sources.append(
{
"id": datasource.id,
"source": datasource.source,
"name": datasource.name,
"reason": "already_running",
"task_id": running_task.id,
}
)
continue
if not force and not is_due_for_collection(datasource, now):
skipped_sources.append(
{
"id": datasource.id,
"source": datasource.source,
"name": datasource.name,
"reason": "within_frequency_window",
"last_run_at": to_iso8601_utc(datasource.last_run_at),
"next_run_at": to_iso8601_utc(
datasource.last_run_at + timedelta(minutes=datasource.frequency_minutes)
),
}
)
continue
previous_task_ids[datasource.id] = await get_latest_task_id_for_datasource(datasource.id)
success = run_collector_now(datasource.source)
if not success:
failed_sources.append(
{
"id": datasource.id,
"source": datasource.source,
"name": datasource.name,
"reason": "trigger_failed",
}
)
continue
triggered_sources.append(
{
"id": datasource.id,
"source": datasource.source,
"name": datasource.name,
"task_id": None,
}
)
for _ in range(20):
await asyncio.sleep(0.1)
pending = [item for item in triggered_sources if item["task_id"] is None]
if not pending:
break
for item in pending:
task_id = await get_latest_task_id_for_datasource(item["id"])
if task_id is not None and task_id != previous_task_ids.get(item["id"]):
item["task_id"] = task_id
return {
"status": "triggered" if triggered_sources else "partial",
"message": f"Triggered {len(triggered_sources)} data sources",
"force": force,
"triggered": triggered_sources,
"skipped": skipped_sources,
"failed": failed_sources,
}
@router.get("/{source_id}") @router.get("/{source_id}")
async def get_datasource( async def get_datasource(
source_id: str, source_id: str,
@@ -217,15 +356,19 @@ async def trigger_datasource(
if not datasource.is_active: if not datasource.is_active:
raise HTTPException(status_code=400, detail="Data source is disabled") raise HTTPException(status_code=400, detail="Data source is disabled")
previous_task_id = await get_latest_task_id_for_datasource(datasource.id)
success = run_collector_now(datasource.source) success = run_collector_now(datasource.source)
if not success: if not success:
raise HTTPException(status_code=500, detail=f"Failed to trigger collector '{datasource.source}'") raise HTTPException(status_code=500, detail=f"Failed to trigger collector '{datasource.source}'")
task_id = None task_id = None
for _ in range(10): for _ in range(20):
await asyncio.sleep(0.1)
task_id = await get_latest_task_id_for_datasource(datasource.id) task_id = await get_latest_task_id_for_datasource(datasource.id)
if task_id is not None: if task_id is not None and task_id != previous_task_id:
break break
if task_id == previous_task_id:
task_id = None
return { return {
"status": "triggered", "status": "triggered",

View File

@@ -1,4 +1,4 @@
from datetime import datetime from datetime import UTC, datetime
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
@@ -7,6 +7,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import get_current_user from app.core.security import get_current_user
from app.core.time import to_iso8601_utc
from app.db.session import get_db from app.db.session import get_db
from app.models.datasource import DataSource from app.models.datasource import DataSource
from app.models.system_setting import SystemSetting from app.models.system_setting import SystemSetting
@@ -114,9 +115,9 @@ def serialize_collector(datasource: DataSource) -> dict:
"frequency_minutes": datasource.frequency_minutes, "frequency_minutes": datasource.frequency_minutes,
"frequency": format_frequency_label(datasource.frequency_minutes), "frequency": format_frequency_label(datasource.frequency_minutes),
"is_active": datasource.is_active, "is_active": datasource.is_active,
"last_run_at": datasource.last_run_at.isoformat() if datasource.last_run_at else None, "last_run_at": to_iso8601_utc(datasource.last_run_at),
"last_status": datasource.last_status, "last_status": datasource.last_status,
"next_run_at": datasource.next_run_at.isoformat() if datasource.next_run_at else None, "next_run_at": to_iso8601_utc(datasource.next_run_at),
} }
@@ -216,5 +217,5 @@ async def get_all_settings(
"notifications": await get_setting_payload(db, "notifications"), "notifications": await get_setting_payload(db, "notifications"),
"security": await get_setting_payload(db, "security"), "security": await get_setting_payload(db, "security"),
"collectors": [serialize_collector(datasource) for datasource in datasources], "collectors": [serialize_collector(datasource) for datasource in datasources],
"generated_at": datetime.utcnow().isoformat() + "Z", "generated_at": to_iso8601_utc(datetime.now(UTC)),
} }

View File

@@ -0,0 +1,167 @@
from __future__ import annotations
import os
import subprocess
import sys
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from app.core.config import ROOT_DIR
from app.core.security import get_current_user
from app.models.user import User
from app.services.system_control import (
build_task_id,
clear_active_task_id,
get_active_task_id,
get_allowed_command,
get_runner_script_path,
is_task_stale,
get_task_logs,
require_super_admin,
serialize_task,
set_active_task_id,
upsert_task_state,
)
router = APIRouter()
class RestartTaskCreate(BaseModel):
action: str
class RestartTaskResponse(BaseModel):
task_id: str
action: str
status: str
stage: str
message: str
created_at: str
updated_at: str
requested_by: dict[str, object] | None = None
class RestartTaskLogsResponse(BaseModel):
task_id: str
lines: list[str]
def ensure_super_admin(current_user: User) -> None:
if not require_super_admin(current_user.role):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only super_admin can restart services",
)
@router.post("/restart-tasks", response_model=RestartTaskResponse)
async def create_restart_task(
payload: RestartTaskCreate,
current_user: User = Depends(get_current_user),
):
ensure_super_admin(current_user)
command = get_allowed_command(payload.action)
if command is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Unsupported system action",
)
active_task_id = get_active_task_id()
if active_task_id:
active_task = serialize_task(active_task_id)
if active_task and is_task_stale(active_task):
upsert_task_state(
active_task_id,
status="failed",
stage="failed",
message="Previous restart task became stale and was released",
)
clear_active_task_id(active_task_id)
elif active_task and active_task.get("status") in {"queued", "running"}:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Another restart task is already in progress",
)
task_id = build_task_id()
requested_by = {"id": current_user.id, "username": current_user.username}
task_state = upsert_task_state(
task_id,
action=payload.action,
status="queued",
stage="accepted",
message="Restart task accepted",
requested_by=requested_by,
)
set_active_task_id(task_id)
env = os.environ.copy()
backend_path = str(ROOT_DIR / "backend")
existing_pythonpath = env.get("PYTHONPATH", "")
env["PYTHONPATH"] = (
f"{backend_path}{os.pathsep}{existing_pythonpath}"
if existing_pythonpath
else backend_path
)
try:
subprocess.Popen(
[
sys.executable,
str(get_runner_script_path()),
"--task-id",
task_id,
"--action",
payload.action,
],
cwd=str(ROOT_DIR),
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
except OSError as exc:
task_state = upsert_task_state(
task_id,
action=payload.action,
status="failed",
stage="failed",
message=f"Unable to start restart runner: {exc}",
requested_by=requested_by,
)
clear_active_task_id(task_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=task_state["message"],
) from exc
return task_state
@router.get("/restart-tasks/{task_id}", response_model=RestartTaskResponse)
async def get_restart_task(
task_id: str,
current_user: User = Depends(get_current_user),
):
ensure_super_admin(current_user)
task = serialize_task(task_id)
if task is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Restart task not found")
return task
@router.get("/restart-tasks/{task_id}/logs", response_model=RestartTaskLogsResponse)
async def get_restart_task_logs(
task_id: str,
current_user: User = Depends(get_current_user),
):
ensure_super_admin(current_user)
task = serialize_task(task_id)
if task is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Restart task not found")
return {"task_id": task_id, "lines": get_task_logs(task_id)}

View File

@@ -1,4 +1,4 @@
from datetime import datetime from datetime import UTC, datetime
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
@@ -8,6 +8,7 @@ from sqlalchemy import text
from app.db.session import get_db from app.db.session import get_db
from app.models.user import User from app.models.user import User
from app.core.security import get_current_user from app.core.security import get_current_user
from app.core.time import to_iso8601_utc
from app.services.collectors.registry import collector_registry from app.services.collectors.registry import collector_registry
@@ -61,8 +62,8 @@ async def list_tasks(
"datasource_id": t[1], "datasource_id": t[1],
"datasource_name": t[2], "datasource_name": t[2],
"status": t[3], "status": t[3],
"started_at": t[4].isoformat() if t[4] else None, "started_at": to_iso8601_utc(t[4]),
"completed_at": t[5].isoformat() if t[5] else None, "completed_at": to_iso8601_utc(t[5]),
"records_processed": t[6], "records_processed": t[6],
"error_message": t[7], "error_message": t[7],
} }
@@ -100,8 +101,8 @@ async def get_task(
"datasource_id": task[1], "datasource_id": task[1],
"datasource_name": task[2], "datasource_name": task[2],
"status": task[3], "status": task[3],
"started_at": task[4].isoformat() if task[4] else None, "started_at": to_iso8601_utc(task[4]),
"completed_at": task[5].isoformat() if task[5] else None, "completed_at": to_iso8601_utc(task[5]),
"records_processed": task[6], "records_processed": task[6],
"error_message": task[7], "error_message": task[7],
} }
@@ -147,8 +148,8 @@ async def trigger_collection(
"status": result.get("status", "unknown"), "status": result.get("status", "unknown"),
"records_processed": result.get("records_processed", 0), "records_processed": result.get("records_processed", 0),
"error_message": result.get("error"), "error_message": result.get("error"),
"started_at": datetime.utcnow(), "started_at": datetime.now(UTC),
"completed_at": datetime.utcnow(), "completed_at": datetime.now(UTC),
}, },
) )

View File

@@ -4,16 +4,23 @@ Unified API for all visualization data sources.
Returns GeoJSON format compatible with Three.js, CesiumJS, and Unreal Cesium. Returns GeoJSON format compatible with Three.js, CesiumJS, and Unreal Cesium.
""" """
from datetime import datetime from datetime import UTC, datetime
from fastapi import APIRouter, HTTPException, Depends import math
from fastapi import APIRouter, HTTPException, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func from sqlalchemy import select, func
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from app.core.collected_data_fields import get_record_field from app.core.collected_data_fields import get_record_field
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 from app.db.session import get_db
from app.models.bgp_anomaly import BGPAnomaly
from app.models.bgp_incident import BGPIncident
from app.models.collected_data import CollectedData from app.models.collected_data import CollectedData
from app.services.cable_graph import build_graph_from_data, CableGraph 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.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS
router = APIRouter() router = APIRouter()
@@ -155,6 +162,20 @@ def convert_satellite_to_geojson(records: List[CollectedData]) -> Dict[str, Any]
if not norad_id: if not norad_id:
continue continue
tle_line1 = metadata.get("tle_line1")
tle_line2 = metadata.get("tle_line2")
if not tle_line1 or not tle_line2:
tle_line1, tle_line2 = build_tle_lines_from_elements(
norad_cat_id=norad_id,
epoch=metadata.get("epoch"),
inclination=metadata.get("inclination"),
raan=metadata.get("raan"),
eccentricity=metadata.get("eccentricity"),
arg_of_perigee=metadata.get("arg_of_perigee"),
mean_anomaly=metadata.get("mean_anomaly"),
mean_motion=metadata.get("mean_motion"),
)
features.append( features.append(
{ {
"type": "Feature", "type": "Feature",
@@ -174,6 +195,8 @@ def convert_satellite_to_geojson(records: List[CollectedData]) -> Dict[str, Any]
"mean_motion": metadata.get("mean_motion"), "mean_motion": metadata.get("mean_motion"),
"bstar": metadata.get("bstar"), "bstar": metadata.get("bstar"),
"classification_type": metadata.get("classification_type"), "classification_type": metadata.get("classification_type"),
"tle_line1": tle_line1,
"tle_line2": tle_line2,
"data_type": "satellite_tle", "data_type": "satellite_tle",
}, },
} }
@@ -182,6 +205,44 @@ def convert_satellite_to_geojson(records: List[CollectedData]) -> Dict[str, Any]
return {"type": "FeatureCollection", "features": features} return {"type": "FeatureCollection", "features": features}
def dedupe_satellite_records(records: List[CollectedData]) -> List[CollectedData]:
"""Keep only the newest record for each satellite identity."""
latest_by_key: Dict[str, CollectedData] = {}
for record in records:
metadata = record.extra_data or {}
norad_id = metadata.get("norad_cat_id")
dedupe_key = (
str(norad_id)
if norad_id not in (None, "")
else str(record.source_id or record.entity_key or record.name or record.id)
)
existing = latest_by_key.get(dedupe_key)
if existing is None or (record.id or 0) > (existing.id or 0):
latest_by_key[dedupe_key] = record
return sorted(latest_by_key.values(), key=lambda item: item.id or 0, reverse=True)
def dedupe_collected_records(records: List[CollectedData]) -> List[CollectedData]:
"""Keep only the newest record for each collected entity."""
latest_by_key: Dict[str, CollectedData] = {}
for record in records:
dedupe_key = str(
record.source_id
or record.entity_key
or record.name
or record.id
)
existing = latest_by_key.get(dedupe_key)
if existing is None or (record.id or 0) > (existing.id or 0):
latest_by_key[dedupe_key] = record
return sorted(latest_by_key.values(), key=lambda item: item.id or 0, reverse=True)
def convert_supercomputer_to_geojson(records: List[CollectedData]) -> Dict[str, Any]: def convert_supercomputer_to_geojson(records: List[CollectedData]) -> Dict[str, Any]:
"""Convert TOP500 supercomputer records to GeoJSON""" """Convert TOP500 supercomputer records to GeoJSON"""
features = [] features = []
@@ -256,6 +317,404 @@ def convert_gpu_cluster_to_geojson(records: List[CollectedData]) -> Dict[str, An
return {"type": "FeatureCollection", "features": features} return {"type": "FeatureCollection", "features": features}
def convert_bgp_anomalies_to_geojson(
records: List[BGPAnomaly],
geography_hints: Optional[Dict[str, Dict[str, Any]]] = None,
) -> Dict[str, Any]:
features = []
geography_hints = geography_hints or {}
for record in records:
evidence = record.evidence or {}
hint = geography_hints.get(str(record.entity_key or record.id), {})
collectors = evidence.get("collectors") or record.peer_scope or []
if not collectors:
nested = evidence.get("events") or []
collectors = [
str((item or {}).get("collector") or "").strip()
for item in nested
if (item or {}).get("collector")
]
collectors = [collector for collector in collectors if collector]
if not collectors:
collectors = []
as_path = []
if isinstance(evidence.get("as_path"), list):
as_path = evidence.get("as_path") or []
if not as_path:
nested = evidence.get("events") or []
for item in nested:
candidate_path = (item or {}).get("as_path")
if isinstance(candidate_path, list) and candidate_path:
as_path = candidate_path
break
impacted_regions = []
seen_regions = set()
for collector_name in collectors:
collector_location = RIPE_RIS_COLLECTOR_COORDS.get(str(collector_name))
if not collector_location:
continue
region_key = (
collector_location.get("country"),
collector_location.get("city"),
)
if region_key in seen_regions:
continue
seen_regions.add(region_key)
impacted_regions.append(
{
"collector": collector_name,
"country": collector_location.get("country"),
"city": collector_location.get("city"),
"latitude": collector_location.get("latitude"),
"longitude": collector_location.get("longitude"),
}
)
geography_regions = _normalize_geo_regions(hint.get("regions") or [])
geography_mode = hint.get("geography_mode") or "collector_centroid"
collector = collectors[0] if collectors else None
location = geography_regions[0] if geography_regions else None
if location is None and collector:
location = RIPE_RIS_COLLECTOR_COORDS.get(str(collector))
if location is None:
nested = evidence.get("events") or []
for item in nested:
collector_name = (item or {}).get("collector")
if collector_name and collector_name in RIPE_RIS_COLLECTOR_COORDS:
location = RIPE_RIS_COLLECTOR_COORDS[collector_name]
collector = collector_name
geography_mode = "collector_centroid"
break
if location is None:
continue
features.append(
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [location["longitude"], location["latitude"]],
},
"properties": {
"id": record.id,
"collector": collector,
"city": location.get("city"),
"country": location.get("country"),
"source": record.source,
"anomaly_type": record.anomaly_type,
"severity": record.severity,
"status": record.status,
"prefix": record.prefix,
"origin_asn": record.origin_asn,
"new_origin_asn": record.new_origin_asn,
"collectors": collectors,
"collector_count": len(collectors) or 1,
"as_path": as_path,
"impacted_regions": impacted_regions,
"geography_mode": geography_mode,
"confidence": record.confidence,
"summary": record.summary,
"created_at": to_iso8601_utc(record.created_at),
},
}
)
return {"type": "FeatureCollection", "features": features}
async def build_anomaly_geography_hints(
db: AsyncSession,
records: List[BGPAnomaly],
) -> Dict[str, Dict[str, Any]]:
hints: Dict[str, Dict[str, Any]] = {}
for record in records:
hint = _extract_evidence_geography_hint(record.evidence or {})
if hint:
hints[str(record.entity_key or record.id)] = hint
return hints
def convert_bgp_collectors_to_geojson(
coverage_by_collector: Dict[str, Dict[str, Any]] | None = None,
) -> Dict[str, Any]:
features = []
coverage_by_collector = coverage_by_collector or {}
for collector, location in sorted(RIPE_RIS_COLLECTOR_COORDS.items()):
coverage = coverage_by_collector.get(collector, {})
features.append(
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [location["longitude"], location["latitude"]],
},
"properties": {
"collector": collector,
"city": coverage.get("city") or location.get("city"),
"country": coverage.get("country") or location.get("country"),
"status": "online",
"observation_count": coverage.get("observation_count", 0),
"prefix_count": coverage.get("prefix_count", 0),
"origin_asn_count": coverage.get("origin_asn_count", 0),
"peer_asn_count": coverage.get("peer_asn_count", 0),
"recent_15m_observation_count": coverage.get("recent_15m_observation_count", 0),
"recent_24h_observation_count": coverage.get("recent_24h_observation_count", 0),
"recent_7d_observation_count": coverage.get("recent_7d_observation_count", 0),
"recent_15m_prefix_count": coverage.get("recent_15m_prefix_count", 0),
"recent_24h_prefix_count": coverage.get("recent_24h_prefix_count", 0),
"recent_7d_prefix_count": coverage.get("recent_7d_prefix_count", 0),
"top_event_types": coverage.get("top_event_types", []),
"latest_observed_at": coverage.get("latest_observed_at"),
"latest_event_type": coverage.get("latest_event_type"),
"baseline_scope": coverage.get(
"baseline_scope",
{
"countries": [location.get("country")] if location.get("country") else [],
"cities": [location.get("city")] if location.get("city") else [],
},
),
},
}
)
return {"type": "FeatureCollection", "features": features}
def _incident_estimated_center(valid_regions: List[Dict[str, Any]]) -> Dict[str, float]:
x = 0.0
y = 0.0
z = 0.0
for region in valid_regions:
lat_rad = math.radians(float(region["latitude"]))
lon_rad = math.radians(float(region["longitude"]))
x += math.cos(lat_rad) * math.cos(lon_rad)
y += math.cos(lat_rad) * math.sin(lon_rad)
z += math.sin(lat_rad)
total = float(len(valid_regions))
if total <= 0:
return {"latitude": 0.0, "longitude": 0.0}
x /= total
y /= total
z /= total
hyp = math.sqrt((x * x) + (y * y))
if hyp == 0:
return {"latitude": 0.0, "longitude": 0.0}
return {
"latitude": math.degrees(math.atan2(z, hyp)),
"longitude": math.degrees(math.atan2(y, x)),
}
def _incident_estimated_radius_km(center: Dict[str, float], valid_regions: List[Dict[str, Any]]) -> float:
center_coords = (float(center["longitude"]), float(center["latitude"]))
distances = [
haversine_distance(
center_coords,
(float(region["longitude"]), float(region["latitude"])),
)
for region in valid_regions
]
return round(max(distances) if distances else 0.0, 1)
def _normalize_geo_regions(regions: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
normalized: list[dict[str, Any]] = []
seen: set[tuple[Any, ...]] = set()
for region in regions:
if not isinstance(region, dict):
continue
latitude = region.get("latitude")
longitude = region.get("longitude")
if not isinstance(latitude, (int, float)) or not isinstance(longitude, (int, float)):
continue
item = {
"collector": region.get("collector"),
"country": region.get("country"),
"city": region.get("city"),
"latitude": float(latitude),
"longitude": float(longitude),
}
key = (
item["collector"],
item["country"],
item["city"],
item["latitude"],
item["longitude"],
)
if key in seen:
continue
seen.add(key)
normalized.append(item)
return normalized
def _extract_evidence_geography_hint(evidence: Dict[str, Any]) -> Dict[str, Any] | None:
prefix_geo_regions = []
prefix_regions = []
asn_regions = []
evidence_prefix_geography = evidence.get("prefix_geography") or {}
prefix_geo_regions.extend(
_normalize_geo_regions(evidence_prefix_geography.get("regions") or [])
)
prefix_scope = evidence.get("prefix_scope") or {}
prefix_regions.extend(_normalize_geo_regions(prefix_scope.get("regions") or []))
for profile_key in ("origin_asn_profile", "new_origin_asn_profile"):
profile = evidence.get(profile_key) or {}
latitude = profile.get("latitude")
longitude = profile.get("longitude")
if isinstance(latitude, (int, float)) and isinstance(longitude, (int, float)):
asn_regions.append(
{
"country": profile.get("country"),
"city": profile.get("city"),
"latitude": float(latitude),
"longitude": float(longitude),
}
)
prefix_geo_regions = _normalize_geo_regions(prefix_geo_regions)
prefix_regions = _normalize_geo_regions(prefix_regions)
asn_regions = _normalize_geo_regions(asn_regions)
if prefix_geo_regions:
return {"regions": prefix_geo_regions, "geography_mode": "prefix_geography"}
if prefix_regions:
return {"regions": prefix_regions, "geography_mode": "prefix_scope"}
if asn_regions:
return {"regions": asn_regions, "geography_mode": "asn_region"}
return None
async def build_incident_geography_hints(
db: AsyncSession,
records: List[BGPIncident],
) -> Dict[str, Dict[str, Any]]:
evidence_refs = sorted(
{
str(ref)
for record in records
for ref in (record.evidence_refs or [])
if ref
}
)
anomalies = []
if evidence_refs:
result = await db.execute(
select(BGPAnomaly).where(BGPAnomaly.entity_key.in_(evidence_refs))
)
anomalies = result.scalars().all()
anomaly_by_key = {
str(anomaly.entity_key): anomaly
for anomaly in anomalies
if anomaly.entity_key
}
hints: Dict[str, Dict[str, Any]] = {}
for record in records:
merged_hint: Dict[str, Any] | None = None
priority = {"prefix_geography": 3, "prefix_scope": 2, "asn_region": 1}
for ref in record.evidence_refs or []:
anomaly = anomaly_by_key.get(str(ref))
if anomaly is None:
continue
hint = _extract_evidence_geography_hint(anomaly.evidence or {})
if hint is None:
continue
if merged_hint is None:
merged_hint = {
"regions": list(hint["regions"]),
"geography_mode": hint["geography_mode"],
}
continue
if priority[hint["geography_mode"]] > priority[merged_hint["geography_mode"]]:
merged_hint = {
"regions": list(hint["regions"]),
"geography_mode": hint["geography_mode"],
}
elif priority[hint["geography_mode"]] == priority[merged_hint["geography_mode"]]:
merged_hint["regions"].extend(hint["regions"])
if merged_hint:
merged_hint["regions"] = _normalize_geo_regions(merged_hint["regions"])
hints[record.incident_key] = merged_hint
return hints
def convert_bgp_incidents_to_geojson(
records: List[BGPIncident],
geography_hints: Optional[Dict[str, Dict[str, Any]]] = None,
) -> Dict[str, Any]:
features = []
for record in records:
hint = (geography_hints or {}).get(record.incident_key, {})
regions = hint.get("regions") or (record.affected_regions or [])
if not regions:
continue
valid_regions = _normalize_geo_regions(regions)
if not valid_regions:
continue
estimated_center = _incident_estimated_center(valid_regions)
estimated_radius_km = _incident_estimated_radius_km(estimated_center, valid_regions)
features.append(
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [
estimated_center["longitude"],
estimated_center["latitude"],
],
},
"properties": {
"id": record.id,
"incident_key": record.incident_key,
"incident_type": record.incident_type,
"title": record.title,
"summary": record.summary,
"severity": record.severity,
"status": record.status,
"confidence": record.confidence,
"affected_prefixes": record.affected_prefixes or [],
"affected_asns": record.affected_asns or [],
"affected_collectors": record.affected_collectors or [],
"affected_regions": valid_regions,
"estimated_center": estimated_center,
"estimated_radius_km": estimated_radius_km,
"geography_mode": hint.get("geography_mode") or "collector_centroid",
"related_cables": record.related_cables or [],
"related_ixps": record.related_ixps or [],
"created_at": to_iso8601_utc(record.created_at),
"started_at": to_iso8601_utc(record.started_at),
},
}
)
return {"type": "FeatureCollection", "features": features}
# ============== API Endpoints ============== # ============== API Endpoints ==============
@@ -265,7 +724,7 @@ async def get_cables_geojson(db: AsyncSession = Depends(get_db)):
try: try:
stmt = select(CollectedData).where(CollectedData.source == "arcgis_cables") stmt = select(CollectedData).where(CollectedData.source == "arcgis_cables")
result = await db.execute(stmt) result = await db.execute(stmt)
records = result.scalars().all() records = dedupe_collected_records(list(result.scalars().all()))
if not records: if not records:
raise HTTPException( raise HTTPException(
@@ -285,15 +744,15 @@ async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)):
try: try:
landing_stmt = select(CollectedData).where(CollectedData.source == "arcgis_landing_points") landing_stmt = select(CollectedData).where(CollectedData.source == "arcgis_landing_points")
landing_result = await db.execute(landing_stmt) landing_result = await db.execute(landing_stmt)
records = landing_result.scalars().all() records = dedupe_collected_records(list(landing_result.scalars().all()))
relation_stmt = select(CollectedData).where(CollectedData.source == "arcgis_cable_landing_relation") relation_stmt = select(CollectedData).where(CollectedData.source == "arcgis_cable_landing_relation")
relation_result = await db.execute(relation_stmt) relation_result = await db.execute(relation_stmt)
relation_records = relation_result.scalars().all() relation_records = dedupe_collected_records(list(relation_result.scalars().all()))
cable_stmt = select(CollectedData).where(CollectedData.source == "arcgis_cables") cable_stmt = select(CollectedData).where(CollectedData.source == "arcgis_cables")
cable_result = await db.execute(cable_stmt) cable_result = await db.execute(cable_stmt)
cable_records = cable_result.scalars().all() cable_records = dedupe_collected_records(list(cable_result.scalars().all()))
city_to_cable_ids_map = {} city_to_cable_ids_map = {}
for rel in relation_records: for rel in relation_records:
@@ -331,15 +790,15 @@ async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)):
async def get_all_geojson(db: AsyncSession = Depends(get_db)): async def get_all_geojson(db: AsyncSession = Depends(get_db)):
cables_stmt = select(CollectedData).where(CollectedData.source == "arcgis_cables") cables_stmt = select(CollectedData).where(CollectedData.source == "arcgis_cables")
cables_result = await db.execute(cables_stmt) cables_result = await db.execute(cables_stmt)
cables_records = cables_result.scalars().all() cables_records = dedupe_collected_records(list(cables_result.scalars().all()))
points_stmt = select(CollectedData).where(CollectedData.source == "arcgis_landing_points") points_stmt = select(CollectedData).where(CollectedData.source == "arcgis_landing_points")
points_result = await db.execute(points_stmt) points_result = await db.execute(points_stmt)
points_records = points_result.scalars().all() points_records = dedupe_collected_records(list(points_result.scalars().all()))
relation_stmt = select(CollectedData).where(CollectedData.source == "arcgis_cable_landing_relation") relation_stmt = select(CollectedData).where(CollectedData.source == "arcgis_cable_landing_relation")
relation_result = await db.execute(relation_stmt) relation_result = await db.execute(relation_stmt)
relation_records = relation_result.scalars().all() relation_records = dedupe_collected_records(list(relation_result.scalars().all()))
city_to_cable_ids_map = {} city_to_cable_ids_map = {}
for rel in relation_records: for rel in relation_records:
@@ -383,7 +842,11 @@ async def get_all_geojson(db: AsyncSession = Depends(get_db)):
@router.get("/geo/satellites") @router.get("/geo/satellites")
async def get_satellites_geojson( async def get_satellites_geojson(
limit: int = 10000, limit: Optional[int] = Query(
None,
ge=1,
description="Maximum number of satellites to return. Omit for no limit.",
),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""获取卫星 TLE GeoJSON 数据""" """获取卫星 TLE GeoJSON 数据"""
@@ -392,10 +855,12 @@ async def get_satellites_geojson(
.where(CollectedData.source == "celestrak_tle") .where(CollectedData.source == "celestrak_tle")
.where(CollectedData.name != "Unknown") .where(CollectedData.name != "Unknown")
.order_by(CollectedData.id.desc()) .order_by(CollectedData.id.desc())
.limit(limit)
) )
result = await db.execute(stmt) result = await db.execute(stmt)
records = result.scalars().all() records = dedupe_satellite_records(list(result.scalars().all()))
if limit is not None:
records = records[:limit]
if not records: if not records:
return {"type": "FeatureCollection", "features": [], "count": 0} return {"type": "FeatureCollection", "features": [], "count": 0}
@@ -417,10 +882,11 @@ async def get_supercomputers_geojson(
select(CollectedData) select(CollectedData)
.where(CollectedData.source == "top500") .where(CollectedData.source == "top500")
.where(CollectedData.name != "Unknown") .where(CollectedData.name != "Unknown")
.limit(limit) .order_by(CollectedData.id.desc())
) )
result = await db.execute(stmt) result = await db.execute(stmt)
records = result.scalars().all() records = dedupe_collected_records(list(result.scalars().all()))
records = records[:limit]
if not records: if not records:
return {"type": "FeatureCollection", "features": [], "count": 0} return {"type": "FeatureCollection", "features": [], "count": 0}
@@ -442,10 +908,11 @@ async def get_gpu_clusters_geojson(
select(CollectedData) select(CollectedData)
.where(CollectedData.source == "epoch_ai_gpu") .where(CollectedData.source == "epoch_ai_gpu")
.where(CollectedData.name != "Unknown") .where(CollectedData.name != "Unknown")
.limit(limit) .order_by(CollectedData.id.desc())
) )
result = await db.execute(stmt) result = await db.execute(stmt)
records = result.scalars().all() records = dedupe_collected_records(list(result.scalars().all()))
records = records[:limit]
if not records: if not records:
return {"type": "FeatureCollection", "features": [], "count": 0} return {"type": "FeatureCollection", "features": [], "count": 0}
@@ -457,6 +924,61 @@ async def get_gpu_clusters_geojson(
} }
@router.get("/geo/bgp-anomalies")
async def get_bgp_anomalies_geojson(
severity: Optional[str] = Query(None),
status: Optional[str] = Query("active"),
limit: int = Query(200, ge=1, le=1000),
db: AsyncSession = Depends(get_db),
):
stmt = select(BGPAnomaly).order_by(BGPAnomaly.created_at.desc()).limit(limit)
if severity:
stmt = stmt.where(BGPAnomaly.severity == severity)
if status:
stmt = stmt.where(BGPAnomaly.status == status)
result = await db.execute(stmt)
records = list(result.scalars().all())
geography_hints = await build_anomaly_geography_hints(db, records)
geojson = convert_bgp_anomalies_to_geojson(records, geography_hints)
return {**geojson, "count": len(geojson.get("features", []))}
@router.get("/geo/bgp-incidents")
async def get_bgp_incidents_geojson(
severity: Optional[str] = Query(None),
status: Optional[str] = Query("active"),
limit: int = Query(100, ge=1, le=500),
db: AsyncSession = Depends(get_db),
):
stmt = select(BGPIncident).order_by(BGPIncident.created_at.desc()).limit(limit)
if severity:
stmt = stmt.where(BGPIncident.severity == severity)
if status:
stmt = stmt.where(BGPIncident.status == status)
result = await db.execute(stmt)
records = list(result.scalars().all())
geography_hints = await build_incident_geography_hints(db, records)
geojson = convert_bgp_incidents_to_geojson(records, geography_hints)
return {**geojson, "count": len(geojson.get("features", []))}
@router.get("/geo/bgp-collectors")
async def get_bgp_collectors_geojson(db: AsyncSession = Depends(get_db)):
coverage = await build_bgp_collector_coverage(
db,
source_filter=("ris_live_bgp", "bgpstream_bgp"),
)
coverage_by_collector = {
item["collector"]: item
for item in coverage
if item.get("collector")
}
geojson = convert_bgp_collectors_to_geojson(coverage_by_collector)
return {**geojson, "count": len(geojson.get("features", []))}
@router.get("/all") @router.get("/all")
async def get_all_visualization_data(db: AsyncSession = Depends(get_db)): async def get_all_visualization_data(db: AsyncSession = Depends(get_db)):
"""获取所有可视化数据的统一端点 """获取所有可视化数据的统一端点
@@ -470,11 +992,11 @@ async def get_all_visualization_data(db: AsyncSession = Depends(get_db)):
""" """
cables_stmt = select(CollectedData).where(CollectedData.source == "arcgis_cables") cables_stmt = select(CollectedData).where(CollectedData.source == "arcgis_cables")
cables_result = await db.execute(cables_stmt) cables_result = await db.execute(cables_stmt)
cables_records = list(cables_result.scalars().all()) cables_records = dedupe_collected_records(list(cables_result.scalars().all()))
points_stmt = select(CollectedData).where(CollectedData.source == "arcgis_landing_points") points_stmt = select(CollectedData).where(CollectedData.source == "arcgis_landing_points")
points_result = await db.execute(points_stmt) points_result = await db.execute(points_stmt)
points_records = list(points_result.scalars().all()) points_records = dedupe_collected_records(list(points_result.scalars().all()))
satellites_stmt = ( satellites_stmt = (
select(CollectedData) select(CollectedData)
@@ -482,7 +1004,7 @@ async def get_all_visualization_data(db: AsyncSession = Depends(get_db)):
.where(CollectedData.name != "Unknown") .where(CollectedData.name != "Unknown")
) )
satellites_result = await db.execute(satellites_stmt) satellites_result = await db.execute(satellites_stmt)
satellites_records = list(satellites_result.scalars().all()) satellites_records = dedupe_satellite_records(list(satellites_result.scalars().all()))
supercomputers_stmt = ( supercomputers_stmt = (
select(CollectedData) select(CollectedData)
@@ -490,7 +1012,7 @@ async def get_all_visualization_data(db: AsyncSession = Depends(get_db)):
.where(CollectedData.name != "Unknown") .where(CollectedData.name != "Unknown")
) )
supercomputers_result = await db.execute(supercomputers_stmt) supercomputers_result = await db.execute(supercomputers_stmt)
supercomputers_records = list(supercomputers_result.scalars().all()) supercomputers_records = dedupe_collected_records(list(supercomputers_result.scalars().all()))
gpu_stmt = ( gpu_stmt = (
select(CollectedData) select(CollectedData)
@@ -498,7 +1020,7 @@ async def get_all_visualization_data(db: AsyncSession = Depends(get_db)):
.where(CollectedData.name != "Unknown") .where(CollectedData.name != "Unknown")
) )
gpu_result = await db.execute(gpu_stmt) gpu_result = await db.execute(gpu_stmt)
gpu_records = list(gpu_result.scalars().all()) gpu_records = dedupe_collected_records(list(gpu_result.scalars().all()))
cables = ( cables = (
convert_cable_to_geojson(cables_records) convert_cable_to_geojson(cables_records)
@@ -527,7 +1049,7 @@ async def get_all_visualization_data(db: AsyncSession = Depends(get_db)):
) )
return { return {
"generated_at": datetime.utcnow().isoformat() + "Z", "generated_at": to_iso8601_utc(datetime.now(UTC)),
"version": "1.0", "version": "1.0",
"data": { "data": {
"satellites": satellites, "satellites": satellites,

View File

@@ -3,13 +3,14 @@
import asyncio import asyncio
import json import json
import logging import logging
from datetime import datetime from datetime import UTC, datetime
from typing import Optional from typing import Optional
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
from jose import jwt, JWTError from jose import jwt, JWTError
from app.core.config import settings from app.core.config import settings
from app.core.time import to_iso8601_utc
from app.core.websocket.manager import manager from app.core.websocket.manager import manager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -59,6 +60,7 @@ async def websocket_endpoint(
"ixp_nodes", "ixp_nodes",
"alerts", "alerts",
"dashboard", "dashboard",
"datasource_tasks",
], ],
}, },
} }
@@ -72,7 +74,7 @@ async def websocket_endpoint(
await websocket.send_json( await websocket.send_json(
{ {
"type": "heartbeat", "type": "heartbeat",
"data": {"action": "pong", "timestamp": datetime.utcnow().isoformat()}, "data": {"action": "pong", "timestamp": to_iso8601_utc(datetime.now(UTC))},
} }
) )
elif data.get("type") == "subscribe": elif data.get("type") == "subscribe":

View File

@@ -6,9 +6,16 @@ import os
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
ROOT_DIR = Path(__file__).parent.parent.parent.parent
VERSION_FILE = ROOT_DIR / "VERSION"
class Settings(BaseSettings): class Settings(BaseSettings):
PROJECT_NAME: str = "Intelligent Planet Plan" PROJECT_NAME: str = "Intelligent Planet Plan"
VERSION: str = "1.0.0" VERSION: str = (
os.getenv("APP_VERSION")
or (VERSION_FILE.read_text(encoding="utf-8").strip() if VERSION_FILE.exists() else "0.19.0")
)
API_V1_STR: str = "/api/v1" API_V1_STR: str = "/api/v1"
SECRET_KEY: str = "your-secret-key-change-in-production" SECRET_KEY: str = "your-secret-key-change-in-production"
ALGORITHM: str = "HS256" ALGORITHM: str = "HS256"
@@ -30,6 +37,11 @@ class Settings(BaseSettings):
SPACETRACK_USERNAME: str = "" SPACETRACK_USERNAME: str = ""
SPACETRACK_PASSWORD: str = "" SPACETRACK_PASSWORD: str = ""
AI_PROVIDER_SERVICE_URL: str = "http://localhost:8010"
AI_PROVIDER_SERVICE_TOKEN: str = ""
AI_PROVIDER_TIMEOUT_SECONDS: int = 60
AI_PROVIDER_RETRY_ATTEMPTS: int = 2
@property @property
def REDIS_URL(self) -> str: def REDIS_URL(self) -> str:
return os.getenv( return os.getenv(
@@ -39,6 +51,7 @@ class Settings(BaseSettings):
class Config: class Config:
env_file = Path(__file__).parent.parent.parent / ".env" env_file = Path(__file__).parent.parent.parent / ".env"
case_sensitive = True case_sensitive = True
extra = "ignore"
@lru_cache() @lru_cache()

View File

@@ -232,6 +232,57 @@ for canonical, aliases in COUNTRY_ENTRIES:
COUNTRY_ALIAS_MAP[alias.casefold()] = canonical COUNTRY_ALIAS_MAP[alias.casefold()] = canonical
COUNTRY_CENTROIDS = {
"美国": {"latitude": 39.8283, "longitude": -98.5795},
"英国": {"latitude": 55.3781, "longitude": -3.4360},
"荷兰": {"latitude": 52.1326, "longitude": 5.2913},
"日本": {"latitude": 36.2048, "longitude": 138.2529},
"德国": {"latitude": 51.1657, "longitude": 10.4515},
"法国": {"latitude": 46.2276, "longitude": 2.2137},
"新加坡": {"latitude": 1.3521, "longitude": 103.8198},
"中国": {"latitude": 35.8617, "longitude": 104.1954},
"中国(香港)": {"latitude": 22.3193, "longitude": 114.1694},
"中国(台湾)": {"latitude": 23.6978, "longitude": 120.9605},
"韩国": {"latitude": 35.9078, "longitude": 127.7669},
"俄罗斯": {"latitude": 61.5240, "longitude": 105.3188},
"加拿大": {"latitude": 56.1304, "longitude": -106.3468},
"澳大利亚": {"latitude": -25.2744, "longitude": 133.7751},
"巴西": {"latitude": -14.2350, "longitude": -51.9253},
"南非": {"latitude": -30.5595, "longitude": 22.9375},
"西班牙": {"latitude": 40.4637, "longitude": -3.7492},
"意大利": {"latitude": 41.8719, "longitude": 12.5674},
"瑞士": {"latitude": 46.8182, "longitude": 8.2275},
"阿联酋": {"latitude": 23.4241, "longitude": 53.8478},
"莫桑比克": {"latitude": -18.6657, "longitude": 35.5296},
"哥斯达黎加": {"latitude": 9.7489, "longitude": -83.7534},
"尼日利亚": {"latitude": 9.0820, "longitude": 8.6753},
"印度尼西亚": {"latitude": -0.7893, "longitude": 113.9213},
"芬兰": {"latitude": 61.9241, "longitude": 25.7482},
"巴基斯坦": {"latitude": 30.3753, "longitude": 69.3451},
"泰国": {"latitude": 15.8700, "longitude": 100.9925},
"墨西哥": {"latitude": 23.6345, "longitude": -102.5528},
"安哥拉": {"latitude": -11.2027, "longitude": 17.8739},
"摩尔多瓦": {"latitude": 47.4116, "longitude": 28.3699},
"印度": {"latitude": 20.5937, "longitude": 78.9629},
"乌克兰": {"latitude": 48.3794, "longitude": 31.1656},
"阿富汗": {"latitude": 33.9391, "longitude": 67.7100},
"肯尼亚": {"latitude": -0.0236, "longitude": 37.9062},
"土耳其": {"latitude": 38.9637, "longitude": 35.2433},
"多米尼加": {"latitude": 18.7357, "longitude": -70.1627},
"叙利亚": {"latitude": 34.8021, "longitude": 38.9968},
"乌干达": {"latitude": 1.3733, "longitude": 32.2903},
"卢森堡": {"latitude": 49.8153, "longitude": 6.1296},
"罗马尼亚": {"latitude": 45.9432, "longitude": 24.9668},
"尼泊尔": {"latitude": 28.3949, "longitude": 84.1240},
"匈牙利": {"latitude": 47.1625, "longitude": 19.5033},
"埃及": {"latitude": 26.8206, "longitude": 30.8025},
"波兰": {"latitude": 51.9194, "longitude": 19.1451},
"哥伦比亚": {"latitude": 4.5709, "longitude": -74.2973},
"爱尔兰": {"latitude": 53.1424, "longitude": -7.6921},
"菲律宾": {"latitude": 12.8797, "longitude": 121.7740},
}
def normalize_country(value: Any) -> Optional[str]: def normalize_country(value: Any) -> Optional[str]:
if value is None: if value is None:
return None return None
@@ -258,6 +309,13 @@ def normalize_country(value: Any) -> Optional[str]:
return COUNTRY_ALIAS_MAP.get(lowered) return COUNTRY_ALIAS_MAP.get(lowered)
def get_country_centroid(value: Any) -> Optional[dict[str, float]]:
canonical = normalize_country(value)
if not canonical:
return None
return COUNTRY_CENTROIDS.get(canonical)
def get_country_search_variants(value: Any) -> list[str]: def get_country_search_variants(value: Any) -> list[str]:
canonical = normalize_country(value) canonical = normalize_country(value)
if canonical is None: if canonical is None:

View File

@@ -23,6 +23,11 @@ COLLECTOR_URL_KEYS = {
"top500": "top500.url", "top500": "top500.url",
"epoch_ai_gpu": "epoch_ai.gpu_clusters_url", "epoch_ai_gpu": "epoch_ai.gpu_clusters_url",
"spacetrack_tle": "spacetrack.tle_query_url", "spacetrack_tle": "spacetrack.tle_query_url",
"ris_live_bgp": "ris_live.url",
"bgpstream_bgp": "bgpstream.url",
"iptoasn_prefix_geo": "iptoasn.combined_url",
"opengeofeed_prefix_geo": "opengeofeed.public_csv_url",
"nro_delegated_prefix_geo": "nro.delegated_stats_url",
} }

View File

@@ -37,3 +37,18 @@ epoch_ai:
spacetrack: spacetrack:
base_url: "https://www.space-track.org" base_url: "https://www.space-track.org"
tle_query_url: "https://www.space-track.org/basicspacedata/query/class/gp/orderby/EPOCH%20desc/limit/1000/format/json" tle_query_url: "https://www.space-track.org/basicspacedata/query/class/gp/orderby/EPOCH%20desc/limit/1000/format/json"
ris_live:
url: "https://ris-live.ripe.net/v1/stream/?format=json&client=planet-ris-live"
bgpstream:
url: "https://broker.bgpstream.caida.org/v2"
iptoasn:
combined_url: "https://iptoasn.com/data/ip2asn-combined.tsv.gz"
opengeofeed:
public_csv_url: "https://opengeofeed.org/feed/public.csv"
nro:
delegated_stats_url: "https://ftp.ripe.net/pub/stats/ripencc/nro-stats/latest/nro-delegated-stats"

View File

@@ -120,6 +120,41 @@ DEFAULT_DATASOURCES = {
"priority": "P2", "priority": "P2",
"frequency_minutes": 1440, "frequency_minutes": 1440,
}, },
"ris_live_bgp": {
"id": 21,
"name": "RIPE RIS Live BGP",
"module": "L3",
"priority": "P1",
"frequency_minutes": 15,
},
"bgpstream_bgp": {
"id": 22,
"name": "CAIDA BGPStream Backfill",
"module": "L3",
"priority": "P1",
"frequency_minutes": 360,
},
"iptoasn_prefix_geo": {
"id": 23,
"name": "IPtoASN Prefix Geography",
"module": "L3",
"priority": "P1",
"frequency_minutes": 1440,
},
"opengeofeed_prefix_geo": {
"id": 24,
"name": "OpenGeoFeed Prefix Geography",
"module": "L3",
"priority": "P1",
"frequency_minutes": 1440,
},
"nro_delegated_prefix_geo": {
"id": 25,
"name": "NRO Delegated Prefix Geography",
"module": "L3",
"priority": "P1",
"frequency_minutes": 1440,
},
} }
ID_TO_COLLECTOR = {info["id"]: name for name, info in DEFAULT_DATASOURCES.items()} ID_TO_COLLECTOR = {info["id"]: name for name, info in DEFAULT_DATASOURCES.items()}

View File

@@ -0,0 +1,116 @@
"""Helpers for building stable TLE lines from orbital elements."""
from __future__ import annotations
from datetime import datetime
from typing import Any, Optional
def compute_tle_checksum(line: str) -> str:
"""Compute the standard modulo-10 checksum for a TLE line."""
total = 0
for char in line[:68]:
if char.isdigit():
total += int(char)
elif char == "-":
total += 1
return str(total % 10)
def _parse_epoch(value: Any) -> Optional[datetime]:
if not value:
return None
if isinstance(value, datetime):
return value
if isinstance(value, str):
return datetime.fromisoformat(value.replace("Z", "+00:00"))
return None
def build_tle_line1(norad_cat_id: Any, epoch: Any) -> Optional[str]:
"""Build a valid TLE line 1 from the NORAD id and epoch."""
epoch_date = _parse_epoch(epoch)
if not norad_cat_id or epoch_date is None:
return None
epoch_year = epoch_date.year % 100
start_of_year = datetime(epoch_date.year, 1, 1, tzinfo=epoch_date.tzinfo)
day_of_year = (epoch_date - start_of_year).days + 1
ms_of_day = (
epoch_date.hour * 3600000
+ epoch_date.minute * 60000
+ epoch_date.second * 1000
+ int(epoch_date.microsecond / 1000)
)
day_fraction = ms_of_day / 86400000
decimal_fraction = f"{day_fraction:.8f}"[1:]
epoch_str = f"{epoch_year:02d}{day_of_year:03d}{decimal_fraction}"
core = (
f"1 {int(norad_cat_id):05d}U 00001A {epoch_str}"
" .00000000 00000-0 00000-0 0 999"
)
return core + compute_tle_checksum(core)
def build_tle_line2(
norad_cat_id: Any,
inclination: Any,
raan: Any,
eccentricity: Any,
arg_of_perigee: Any,
mean_anomaly: Any,
mean_motion: Any,
) -> Optional[str]:
"""Build a valid TLE line 2 from the standard orbital elements."""
required = [
norad_cat_id,
inclination,
raan,
eccentricity,
arg_of_perigee,
mean_anomaly,
mean_motion,
]
if any(value is None for value in required):
return None
eccentricity_digits = str(round(float(eccentricity) * 10_000_000)).zfill(7)
core = (
f"2 {int(norad_cat_id):05d}"
f" {float(inclination):8.4f}"
f" {float(raan):8.4f}"
f" {eccentricity_digits}"
f" {float(arg_of_perigee):8.4f}"
f" {float(mean_anomaly):8.4f}"
f" {float(mean_motion):11.8f}"
"00000"
)
return core + compute_tle_checksum(core)
def build_tle_lines_from_elements(
*,
norad_cat_id: Any,
epoch: Any,
inclination: Any,
raan: Any,
eccentricity: Any,
arg_of_perigee: Any,
mean_anomaly: Any,
mean_motion: Any,
) -> tuple[Optional[str], Optional[str]]:
"""Build both TLE lines from a metadata payload."""
line1 = build_tle_line1(norad_cat_id, epoch)
line2 = build_tle_line2(
norad_cat_id,
inclination,
raan,
eccentricity,
arg_of_perigee,
mean_anomaly,
mean_motion,
)
return line1, line2

View File

@@ -1,4 +1,4 @@
from datetime import datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Optional from typing import Optional
import bcrypt import bcrypt
@@ -49,9 +49,9 @@ def get_password_hash(password: str) -> str:
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy() to_encode = data.copy()
if expires_delta: if expires_delta:
expire = datetime.utcnow() + expires_delta expire = datetime.now(UTC) + expires_delta
elif settings.ACCESS_TOKEN_EXPIRE_MINUTES > 0: elif settings.ACCESS_TOKEN_EXPIRE_MINUTES > 0:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) expire = datetime.now(UTC) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
else: else:
expire = None expire = None
if expire: if expire:
@@ -65,7 +65,7 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -
def create_refresh_token(data: dict) -> str: def create_refresh_token(data: dict) -> str:
to_encode = data.copy() to_encode = data.copy()
if settings.REFRESH_TOKEN_EXPIRE_DAYS > 0: if settings.REFRESH_TOKEN_EXPIRE_DAYS > 0:
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS) expire = datetime.now(UTC) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire}) to_encode.update({"exp": expire})
to_encode.update({"type": "refresh"}) to_encode.update({"type": "refresh"})
if "sub" in to_encode: if "sub" in to_encode:

20
backend/app/core/time.py Normal file
View File

@@ -0,0 +1,20 @@
"""Time helpers for API serialization."""
from __future__ import annotations
from datetime import UTC, datetime
def ensure_utc(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
def to_iso8601_utc(value: datetime | None) -> str | None:
normalized = ensure_utc(value)
if normalized is None:
return None
return normalized.isoformat().replace("+00:00", "Z")

View File

@@ -1,9 +1,10 @@
"""Data broadcaster for WebSocket connections""" """Data broadcaster for WebSocket connections"""
import asyncio import asyncio
from datetime import datetime from datetime import UTC, datetime
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
from app.core.time import to_iso8601_utc
from app.core.websocket.manager import manager from app.core.websocket.manager import manager
@@ -22,7 +23,7 @@ class DataBroadcaster:
"active_datasources": 8, "active_datasources": 8,
"tasks_today": 45, "tasks_today": 45,
"success_rate": 97.8, "success_rate": 97.8,
"last_updated": datetime.utcnow().isoformat(), "last_updated": to_iso8601_utc(datetime.now(UTC)),
"alerts": {"critical": 0, "warning": 2, "info": 5}, "alerts": {"critical": 0, "warning": 2, "info": 5},
} }
@@ -35,7 +36,7 @@ class DataBroadcaster:
{ {
"type": "data_frame", "type": "data_frame",
"channel": "dashboard", "channel": "dashboard",
"timestamp": datetime.utcnow().isoformat(), "timestamp": to_iso8601_utc(datetime.now(UTC)),
"payload": {"stats": stats}, "payload": {"stats": stats},
}, },
channel="dashboard", channel="dashboard",
@@ -49,7 +50,7 @@ class DataBroadcaster:
await manager.broadcast( await manager.broadcast(
{ {
"type": "alert_notification", "type": "alert_notification",
"timestamp": datetime.utcnow().isoformat(), "timestamp": to_iso8601_utc(datetime.now(UTC)),
"data": {"alert": alert}, "data": {"alert": alert},
} }
) )
@@ -60,7 +61,7 @@ class DataBroadcaster:
{ {
"type": "data_frame", "type": "data_frame",
"channel": "gpu_clusters", "channel": "gpu_clusters",
"timestamp": datetime.utcnow().isoformat(), "timestamp": to_iso8601_utc(datetime.now(UTC)),
"payload": data, "payload": data,
} }
) )
@@ -71,12 +72,24 @@ class DataBroadcaster:
{ {
"type": "data_frame", "type": "data_frame",
"channel": channel, "channel": channel,
"timestamp": datetime.utcnow().isoformat(), "timestamp": to_iso8601_utc(datetime.now(UTC)),
"payload": data, "payload": data,
}, },
channel=channel if channel in manager.active_connections else "all", channel=channel if channel in manager.active_connections else "all",
) )
async def broadcast_datasource_task_update(self, data: Dict[str, Any]):
"""Broadcast datasource task progress updates to connected clients."""
await manager.broadcast(
{
"type": "data_frame",
"channel": "datasource_tasks",
"timestamp": to_iso8601_utc(datetime.now(UTC)),
"payload": data,
},
channel="all",
)
def start(self): def start(self):
"""Start all broadcasters""" """Start all broadcasters"""
if not self.running: if not self.running:

View File

@@ -60,6 +60,28 @@ async def seed_default_datasources(session: AsyncSession):
await session.commit() await session.commit()
async def ensure_default_admin_user(session: AsyncSession):
from app.core.security import get_password_hash
from app.models.user import User
result = await session.execute(
text("SELECT id FROM users WHERE username = 'admin'")
)
if result.fetchone():
return
session.add(
User(
username="admin",
email="admin@planet.local",
password_hash=get_password_hash("admin123"),
role="super_admin",
is_active=True,
)
)
await session.commit()
async def init_db(): async def init_db():
import app.models.user # noqa: F401 import app.models.user # noqa: F401
import app.models.gpu_cluster # noqa: F401 import app.models.gpu_cluster # noqa: F401
@@ -68,6 +90,9 @@ async def init_db():
import app.models.datasource # noqa: F401 import app.models.datasource # noqa: F401
import app.models.datasource_config # noqa: F401 import app.models.datasource_config # noqa: F401
import app.models.alert # noqa: F401 import app.models.alert # noqa: F401
import app.models.bgp_anomaly # 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.collected_data # noqa: F401
import app.models.system_setting # noqa: F401 import app.models.system_setting # noqa: F401
@@ -125,3 +150,4 @@ async def init_db():
async with async_session_factory() as session: async with async_session_factory() as session:
await seed_default_datasources(session) await seed_default_datasources(session)
await ensure_default_admin_user(session)

View File

@@ -5,6 +5,9 @@ from app.models.data_snapshot import DataSnapshot
from app.models.datasource import DataSource from app.models.datasource import DataSource
from app.models.datasource_config import DataSourceConfig from app.models.datasource_config import DataSourceConfig
from app.models.alert import Alert, AlertSeverity, AlertStatus from app.models.alert import Alert, AlertSeverity, AlertStatus
from app.models.bgp_anomaly import BGPAnomaly
from app.models.bgp_incident import BGPIncident
from app.models.bgp_observation import BGPObservation
from app.models.system_setting import SystemSetting from app.models.system_setting import SystemSetting
__all__ = [ __all__ = [
@@ -18,4 +21,7 @@ __all__ = [
"Alert", "Alert",
"AlertSeverity", "AlertSeverity",
"AlertStatus", "AlertStatus",
] "BGPAnomaly",
"BGPIncident",
"BGPObservation",
]

View File

@@ -5,6 +5,7 @@ from typing import Optional
from sqlalchemy import Column, Integer, String, DateTime, Text, ForeignKey, Enum as SQLEnum from sqlalchemy import Column, Integer, String, DateTime, Text, ForeignKey, Enum as SQLEnum
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.core.time import to_iso8601_utc
from app.db.session import Base from app.db.session import Base
@@ -50,8 +51,8 @@ class Alert(Base):
"acknowledged_by": self.acknowledged_by, "acknowledged_by": self.acknowledged_by,
"resolved_by": self.resolved_by, "resolved_by": self.resolved_by,
"resolution_notes": self.resolution_notes, "resolution_notes": self.resolution_notes,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": to_iso8601_utc(self.created_at),
"updated_at": self.updated_at.isoformat() if self.updated_at else None, "updated_at": to_iso8601_utc(self.updated_at),
"acknowledged_at": self.acknowledged_at.isoformat() if self.acknowledged_at else None, "acknowledged_at": to_iso8601_utc(self.acknowledged_at),
"resolved_at": self.resolved_at.isoformat() if self.resolved_at else None, "resolved_at": to_iso8601_utc(self.resolved_at),
} }

View File

@@ -0,0 +1,58 @@
"""BGP anomaly model for derived routing intelligence."""
from datetime import datetime
from sqlalchemy import Column, DateTime, Float, ForeignKey, Index, Integer, JSON, String, Text
from app.core.time import to_iso8601_utc
from app.db.session import Base
class BGPAnomaly(Base):
__tablename__ = "bgp_anomalies"
id = Column(Integer, primary_key=True, index=True)
snapshot_id = Column(Integer, ForeignKey("data_snapshots.id"), nullable=True, index=True)
task_id = Column(Integer, ForeignKey("collection_tasks.id"), nullable=True, index=True)
source = Column(String(100), nullable=False, index=True)
anomaly_type = Column(String(50), nullable=False, index=True)
severity = Column(String(20), nullable=False, index=True)
status = Column(String(20), nullable=False, default="active", index=True)
entity_key = Column(String(255), nullable=False, index=True)
prefix = Column(String(64), nullable=True, index=True)
origin_asn = Column(Integer, nullable=True, index=True)
new_origin_asn = Column(Integer, nullable=True, index=True)
peer_scope = Column(JSON, default=list)
started_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow, index=True)
ended_at = Column(DateTime(timezone=True), nullable=True)
confidence = Column(Float, nullable=False, default=0.5)
summary = Column(Text, nullable=False)
evidence = Column(JSON, default=dict)
created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow, index=True)
__table_args__ = (
Index("idx_bgp_anomalies_source_created", "source", "created_at"),
Index("idx_bgp_anomalies_type_status", "anomaly_type", "status"),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"snapshot_id": self.snapshot_id,
"task_id": self.task_id,
"source": self.source,
"anomaly_type": self.anomaly_type,
"severity": self.severity,
"status": self.status,
"entity_key": self.entity_key,
"prefix": self.prefix,
"origin_asn": self.origin_asn,
"new_origin_asn": self.new_origin_asn,
"peer_scope": self.peer_scope or [],
"started_at": to_iso8601_utc(self.started_at),
"ended_at": to_iso8601_utc(self.ended_at),
"confidence": self.confidence,
"summary": self.summary,
"evidence": self.evidence or {},
"created_at": to_iso8601_utc(self.created_at),
}

View File

@@ -0,0 +1,64 @@
"""BGP incident model for aggregated routing events."""
from datetime import datetime
from sqlalchemy import Column, DateTime, Float, ForeignKey, Index, Integer, JSON, String, Text
from app.core.time import to_iso8601_utc
from app.db.session import Base
class BGPIncident(Base):
__tablename__ = "bgp_incidents"
id = Column(Integer, primary_key=True, index=True)
snapshot_id = Column(Integer, ForeignKey("data_snapshots.id"), nullable=True, index=True)
task_id = Column(Integer, ForeignKey("collection_tasks.id"), nullable=True, index=True)
source = Column(String(100), nullable=False, index=True)
incident_key = Column(String(255), nullable=False, index=True)
incident_type = Column(String(50), nullable=False, index=True)
title = Column(String(255), nullable=False)
summary = Column(Text, nullable=False)
severity = Column(String(20), nullable=False, index=True)
status = Column(String(20), nullable=False, default="active", index=True)
confidence = Column(Float, nullable=False, default=0.5)
started_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow, index=True)
ended_at = Column(DateTime(timezone=True), nullable=True)
affected_prefixes = Column(JSON, default=list)
affected_asns = Column(JSON, default=list)
affected_collectors = Column(JSON, default=list)
affected_regions = Column(JSON, default=list)
related_cables = Column(JSON, default=list)
related_ixps = Column(JSON, default=list)
evidence_refs = Column(JSON, default=list)
created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow, index=True)
__table_args__ = (
Index("idx_bgp_incidents_source_created", "source", "created_at"),
Index("idx_bgp_incidents_type_status", "incident_type", "status"),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"snapshot_id": self.snapshot_id,
"task_id": self.task_id,
"source": self.source,
"incident_key": self.incident_key,
"incident_type": self.incident_type,
"title": self.title,
"summary": self.summary,
"severity": self.severity,
"status": self.status,
"confidence": self.confidence,
"started_at": to_iso8601_utc(self.started_at),
"ended_at": to_iso8601_utc(self.ended_at),
"affected_prefixes": self.affected_prefixes or [],
"affected_asns": self.affected_asns or [],
"affected_collectors": self.affected_collectors or [],
"affected_regions": self.affected_regions or [],
"related_cables": self.related_cables or [],
"related_ixps": self.related_ixps or [],
"evidence_refs": self.evidence_refs or [],
"created_at": to_iso8601_utc(self.created_at),
}

View File

@@ -0,0 +1,62 @@
"""BGP raw observation model for routing event ingestion."""
from sqlalchemy import Column, DateTime, ForeignKey, Index, Integer, JSON, String, Text
from sqlalchemy.sql import func
from app.core.time import to_iso8601_utc
from app.db.session import Base
class BGPObservation(Base):
__tablename__ = "bgp_observations"
id = Column(Integer, primary_key=True, index=True)
snapshot_id = Column(Integer, ForeignKey("data_snapshots.id"), nullable=True, index=True)
task_id = Column(Integer, ForeignKey("collection_tasks.id"), nullable=True, index=True)
source = Column(String(100), nullable=False, index=True)
ingest_batch_id = Column(String(100), nullable=True, index=True)
source_event_id = Column(String(100), nullable=True, index=True)
collector = Column(String(100), nullable=True, index=True)
peer_asn = Column(Integer, nullable=True, index=True)
peer_ip = Column(String(100), nullable=True)
prefix = Column(String(64), nullable=True, index=True)
event_type = Column(String(32), nullable=False, index=True)
as_path = Column(JSON, default=list)
origin_asn = Column(Integer, nullable=True, index=True)
next_hop = Column(String(100), nullable=True)
communities = Column(JSON, default=list)
observed_at = Column(DateTime(timezone=True), nullable=False, index=True)
collector_geo = Column(JSON, default=dict)
raw_payload = Column(JSON, default=dict)
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), index=True)
note = Column(Text, nullable=True)
__table_args__ = (
Index("idx_bgp_obs_source_observed", "source", "observed_at"),
Index("idx_bgp_obs_collector_prefix", "collector", "prefix"),
Index("idx_bgp_obs_task_source_event", "task_id", "source_event_id"),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"snapshot_id": self.snapshot_id,
"task_id": self.task_id,
"source": self.source,
"ingest_batch_id": self.ingest_batch_id,
"source_event_id": self.source_event_id,
"collector": self.collector,
"peer_asn": self.peer_asn,
"peer_ip": self.peer_ip,
"prefix": self.prefix,
"event_type": self.event_type,
"as_path": self.as_path or [],
"origin_asn": self.origin_asn,
"next_hop": self.next_hop,
"communities": self.communities or [],
"observed_at": to_iso8601_utc(self.observed_at),
"collector_geo": self.collector_geo or {},
"raw_payload": self.raw_payload or {},
"created_at": to_iso8601_utc(self.created_at),
"note": self.note,
}

View File

@@ -4,6 +4,7 @@ from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, T
from sqlalchemy.sql import func from sqlalchemy.sql import func
from app.core.collected_data_fields import get_record_field from app.core.collected_data_fields import get_record_field
from app.core.time import to_iso8601_utc
from app.db.session import Base from app.db.session import Base
@@ -74,15 +75,11 @@ class CollectedData(Base):
"value": get_record_field(self, "value"), "value": get_record_field(self, "value"),
"unit": get_record_field(self, "unit"), "unit": get_record_field(self, "unit"),
"metadata": self.extra_data, "metadata": self.extra_data,
"collected_at": self.collected_at.isoformat() "collected_at": to_iso8601_utc(self.collected_at),
if self.collected_at is not None "reference_date": to_iso8601_utc(self.reference_date),
else None,
"reference_date": self.reference_date.isoformat()
if self.reference_date is not None
else None,
"is_current": self.is_current, "is_current": self.is_current,
"previous_record_id": self.previous_record_id, "previous_record_id": self.previous_record_id,
"change_type": self.change_type, "change_type": self.change_type,
"change_summary": self.change_summary, "change_summary": self.change_summary,
"deleted_at": self.deleted_at.isoformat() if self.deleted_at is not None else None, "deleted_at": to_iso8601_utc(self.deleted_at),
} }

27
backend/app/schemas/ai.py Normal file
View File

@@ -0,0 +1,27 @@
from typing import Any
from pydantic import BaseModel, Field
class SituationalAnalysisRequest(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
objective: str = Field(..., min_length=1, max_length=1000)
context: dict[str, Any] = Field(default_factory=dict)
observations: list[str] = Field(default_factory=list)
constraints: list[str] = Field(default_factory=list)
preferred_model: str | None = Field(default=None, max_length=200)
class SituationalAnalysisResponse(BaseModel):
provider: str
model: str
content: str
raw_response: dict[str, Any] = Field(default_factory=dict)
class AIProviderStatusResponse(BaseModel):
provider: str
enabled: bool
configured: bool
model: str | None = None
base_url: str | None = None

View File

@@ -0,0 +1,109 @@
from __future__ import annotations
import asyncio
import httpx
from fastapi import HTTPException, status
from app.core.config import settings
from app.schemas.ai import (
AIProviderStatusResponse,
SituationalAnalysisRequest,
SituationalAnalysisResponse,
)
class AIProviderClient:
def __init__(self) -> None:
self.service_url = settings.AI_PROVIDER_SERVICE_URL.rstrip("/")
self.service_token = settings.AI_PROVIDER_SERVICE_TOKEN
self.timeout = settings.AI_PROVIDER_TIMEOUT_SECONDS
self.retry_attempts = max(settings.AI_PROVIDER_RETRY_ATTEMPTS, 1)
def _headers(self, request_id: str | None = None) -> dict[str, str]:
headers = {"Content-Type": "application/json"}
if self.service_token:
headers["X-Provider-Token"] = self.service_token
if request_id:
headers["X-Request-ID"] = request_id
return headers
async def get_status(self, request_id: str | None = None) -> AIProviderStatusResponse:
if not self.service_url:
return AIProviderStatusResponse(
provider="unconfigured",
enabled=False,
configured=False,
model=None,
base_url=None,
)
data = await self._request("GET", "/v1/provider/status", request_id=request_id)
return AIProviderStatusResponse.model_validate(data)
async def analyze(
self,
payload: SituationalAnalysisRequest,
request_id: str | None = None,
) -> SituationalAnalysisResponse:
if not self.service_url:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="AI provider service URL is not configured.",
)
data = await self._request(
"POST",
"/v1/analyze",
json=payload.model_dump(),
request_id=request_id,
)
return SituationalAnalysisResponse.model_validate(data)
async def _request(
self,
method: str,
path: str,
json: dict | None = None,
request_id: str | None = None,
) -> dict:
last_error: Exception | None = None
for attempt in range(1, self.retry_attempts + 1):
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.request(
method,
f"{self.service_url}{path}",
headers=self._headers(request_id),
json=json,
)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as exc:
last_error = exc
if attempt < self.retry_attempts and exc.response.status_code >= 500:
await asyncio.sleep(0.3 * attempt)
continue
detail = exc.response.text or "AI provider service returned an error"
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"AI provider service request failed: {detail}",
) from exc
except httpx.HTTPError as exc:
last_error = exc
if attempt < self.retry_attempts:
await asyncio.sleep(0.3 * attempt)
continue
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Failed to reach AI provider service: {exc}",
) from exc
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"AI provider service request failed: {last_error}",
)
def get_ai_provider_client() -> AIProviderClient:
return AIProviderClient()

View File

@@ -0,0 +1,173 @@
"""Collector baseline and coverage helpers for BGP observations."""
from __future__ import annotations
from collections import defaultdict
from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.time import to_iso8601_utc
from app.models.bgp_observation import BGPObservation
from app.services.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS
async def build_bgp_collector_coverage(
db: AsyncSession,
*,
source_filter: tuple[str, ...] | None = None,
) -> list[dict[str, Any]]:
now = datetime.now(UTC)
recent_15m_threshold = now - timedelta(minutes=15)
recent_24h_threshold = now - timedelta(hours=24)
recent_7d_threshold = now - timedelta(days=7)
stmt = select(BGPObservation).order_by(BGPObservation.observed_at.desc(), BGPObservation.id.desc())
if source_filter:
stmt = stmt.where(BGPObservation.source.in_(source_filter))
result = await db.execute(stmt)
records = list(result.scalars().all())
by_collector: dict[str, dict[str, Any]] = {}
for record in records:
collector = str(record.collector or "").strip()
if not collector:
continue
coverage = by_collector.get(collector)
if coverage is None:
location = record.collector_geo or RIPE_RIS_COLLECTOR_COORDS.get(collector, {})
coverage = {
"collector": collector,
"city": location.get("city"),
"country": location.get("country"),
"latitude": location.get("latitude"),
"longitude": location.get("longitude"),
"observation_count": 0,
"prefixes": set(),
"origin_asns": set(),
"peer_asns": set(),
"event_types": defaultdict(int),
"countries": set(),
"cities": set(),
"recent_15m_observation_count": 0,
"recent_24h_observation_count": 0,
"recent_7d_observation_count": 0,
"recent_15m_prefixes": set(),
"recent_24h_prefixes": set(),
"recent_7d_prefixes": set(),
"latest_observed_at": None,
"latest_event_type": None,
}
by_collector[collector] = coverage
coverage["observation_count"] += 1
if record.prefix:
coverage["prefixes"].add(record.prefix)
if record.origin_asn is not None:
coverage["origin_asns"].add(record.origin_asn)
if record.peer_asn is not None:
coverage["peer_asns"].add(record.peer_asn)
if record.event_type:
coverage["event_types"][record.event_type] += 1
observed_at = record.observed_at
if observed_at is not None:
aware_observed_at = (
observed_at.astimezone(UTC)
if observed_at.tzinfo
else observed_at.replace(tzinfo=UTC)
)
if aware_observed_at >= recent_15m_threshold:
coverage["recent_15m_observation_count"] += 1
if record.prefix:
coverage["recent_15m_prefixes"].add(record.prefix)
if aware_observed_at >= recent_24h_threshold:
coverage["recent_24h_observation_count"] += 1
if record.prefix:
coverage["recent_24h_prefixes"].add(record.prefix)
if aware_observed_at >= recent_7d_threshold:
coverage["recent_7d_observation_count"] += 1
if record.prefix:
coverage["recent_7d_prefixes"].add(record.prefix)
geo = record.collector_geo or {}
if geo.get("country"):
coverage["countries"].add(geo["country"])
if geo.get("city"):
coverage["cities"].add(geo["city"])
current_latest = coverage["latest_observed_at"]
if current_latest is None or (
record.observed_at is not None and record.observed_at > current_latest
):
coverage["latest_observed_at"] = record.observed_at
coverage["latest_event_type"] = record.event_type
for collector, location in RIPE_RIS_COLLECTOR_COORDS.items():
if collector in by_collector:
continue
by_collector[collector] = {
"collector": collector,
"city": location.get("city"),
"country": location.get("country"),
"latitude": location.get("latitude"),
"longitude": location.get("longitude"),
"observation_count": 0,
"prefixes": set(),
"origin_asns": set(),
"peer_asns": set(),
"event_types": defaultdict(int),
"countries": {location.get("country")} if location.get("country") else set(),
"cities": {location.get("city")} if location.get("city") else set(),
"recent_15m_observation_count": 0,
"recent_24h_observation_count": 0,
"recent_7d_observation_count": 0,
"recent_15m_prefixes": set(),
"recent_24h_prefixes": set(),
"recent_7d_prefixes": set(),
"latest_observed_at": None,
"latest_event_type": None,
}
results: list[dict[str, Any]] = []
for collector in sorted(by_collector.keys()):
item = by_collector[collector]
top_event_types = sorted(
item["event_types"].items(),
key=lambda pair: (-pair[1], pair[0]),
)
results.append(
{
"collector": item["collector"],
"city": item["city"],
"country": item["country"],
"latitude": item["latitude"],
"longitude": item["longitude"],
"observation_count": item["observation_count"],
"prefix_count": len(item["prefixes"]),
"origin_asn_count": len(item["origin_asns"]),
"peer_asn_count": len(item["peer_asns"]),
"recent_15m_observation_count": item["recent_15m_observation_count"],
"recent_24h_observation_count": item["recent_24h_observation_count"],
"recent_7d_observation_count": item["recent_7d_observation_count"],
"recent_15m_prefix_count": len(item["recent_15m_prefixes"]),
"recent_24h_prefix_count": len(item["recent_24h_prefixes"]),
"recent_7d_prefix_count": len(item["recent_7d_prefixes"]),
"top_event_types": [
{"event_type": event_type, "count": count}
for event_type, count in top_event_types[:3]
],
"latest_observed_at": to_iso8601_utc(item["latest_observed_at"]),
"latest_event_type": item["latest_event_type"],
"baseline_scope": {
"countries": sorted(country for country in item["countries"] if country),
"cities": sorted(city for city in item["cities"] if city),
},
}
)
return results

View File

@@ -0,0 +1,466 @@
"""Detector helpers for BGP anomaly generation."""
from __future__ import annotations
from collections import Counter, defaultdict
from datetime import UTC, datetime
from typing import Any
from app.models.bgp_anomaly import BGPAnomaly
def _iter_event_regions(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
regions: list[dict[str, Any]] = []
seen: set[tuple[Any, ...]] = set()
for event in events:
metadata = event.get("metadata") or {}
location = metadata.get("collector_location") or {}
region = {
"collector": metadata.get("collector"),
"country": location.get("country"),
"city": location.get("city"),
"latitude": location.get("latitude"),
"longitude": location.get("longitude"),
}
region_key = (
region.get("collector"),
region.get("country"),
region.get("city"),
region.get("latitude"),
region.get("longitude"),
)
if region_key in seen:
continue
seen.add(region_key)
regions.append(region)
return regions
def _unique_collectors(events: list[dict[str, Any]]) -> list[str]:
return sorted(
{
str((event.get("metadata") or {}).get("collector"))
for event in events
if (event.get("metadata") or {}).get("collector")
}
)
def _unique_peers(events: list[dict[str, Any]]) -> list[int]:
peers: set[int] = set()
for event in events:
peer_asn = (event.get("metadata") or {}).get("peer_asn")
if peer_asn is not None:
peers.add(int(peer_asn))
return sorted(peers)
def _path_signature(metadata: dict[str, Any]) -> tuple[int, ...]:
path = metadata.get("as_path") or []
return tuple(int(asn) for asn in path if asn is not None)
def detect_origin_change_anomalies(
*,
source: str,
snapshot_id: int | None,
task_id: int | None,
events: list[dict[str, Any]],
previous_origin_map: dict[str, set[int]],
) -> list[BGPAnomaly]:
prefix_to_origins: defaultdict[str, set[int]] = defaultdict(set)
for event in events:
metadata = event.get("metadata") or {}
prefix = metadata.get("prefix")
origin_asn = metadata.get("origin_asn")
if prefix and origin_asn is not None:
prefix_to_origins[str(prefix)].add(int(origin_asn))
anomalies: list[BGPAnomaly] = []
for prefix, origins in prefix_to_origins.items():
historic = previous_origin_map.get(prefix, set())
new_origins = sorted(origin for origin in origins if origin not in historic)
related_events = [
event
for event in events
if (event.get("metadata") or {}).get("prefix") == prefix
]
related_collectors = _unique_collectors(related_events)
related_regions = _iter_event_regions(related_events)
moas_candidate = not historic and len(origins) >= 2 and len(related_collectors) >= 2
if (not historic or not new_origins) and not moas_candidate:
continue
target_origins = new_origins or sorted(origins)
for new_origin in target_origins:
sample_event = next(
(
event
for event in related_events
if (event.get("metadata") or {}).get("prefix") == prefix
and int((event.get("metadata") or {}).get("origin_asn") or -1) == new_origin
),
{},
)
sample_metadata = sample_event.get("metadata") or {}
sample_enrichment = sample_metadata.get("enrichment") or {}
sample_prefix_geography = sample_enrichment.get("prefix_geography") or {}
anomaly_type = "origin_change"
severity = "critical"
confidence = 0.86
summary = f"Prefix {prefix} is now originated by AS{new_origin}, outside the current baseline."
evidence_previous_origins = sorted(historic)
if moas_candidate and not historic:
anomaly_type = "origin_conflict"
severity = "high"
confidence = 0.74
summary = (
f"Prefix {prefix} is being originated by multiple ASNs "
f"{sorted(origins)} across {len(related_collectors)} collectors."
)
evidence_previous_origins = []
anomalies.append(
BGPAnomaly(
snapshot_id=snapshot_id,
task_id=task_id,
source=source,
anomaly_type=anomaly_type,
severity=severity,
status="active",
entity_key=f"{anomaly_type}:{prefix}:{new_origin}",
prefix=prefix,
origin_asn=sorted(historic)[0] if historic else None,
new_origin_asn=new_origin,
peer_scope=related_collectors,
started_at=datetime.now(UTC),
confidence=confidence,
summary=summary,
evidence={
"previous_origins": evidence_previous_origins,
"current_origins": sorted(origins),
"events": [
(item.get("metadata") or {})
for item in related_events[:10]
],
"origin_asn_profile": sample_enrichment.get("origin_asn_profile"),
"new_origin_asn_profile": sample_enrichment.get("new_origin_asn_profile"),
"rpki_validation": sample_enrichment.get("rpki_validation"),
"prefix_geography": sample_prefix_geography,
"prefix_scope": sample_enrichment.get("prefix_scope"),
"impacted_regions": sample_prefix_geography.get("regions")
or related_regions
or sample_enrichment.get("prefix_scope", {}).get("regions", []),
},
)
)
return anomalies
def detect_more_specific_burst_anomalies(
*,
source: str,
snapshot_id: int | None,
task_id: int | None,
events: list[dict[str, Any]],
) -> list[BGPAnomaly]:
prefix_to_more_specifics: defaultdict[str, list[dict[str, Any]]] = defaultdict(list)
for event in events:
metadata = event.get("metadata") or {}
enrichment = metadata.get("enrichment") or {}
root_prefix = enrichment.get("prefix_supernet")
if root_prefix and enrichment.get("is_more_specific"):
prefix_to_more_specifics[str(root_prefix)].append(event)
anomalies: list[BGPAnomaly] = []
for root_prefix, more_specifics in prefix_to_more_specifics.items():
unique_prefixes = sorted(
{
str((item.get("metadata") or {}).get("prefix"))
for item in more_specifics
if (item.get("metadata") or {}).get("prefix")
}
)
related_collectors = _unique_collectors(more_specifics)
if len(unique_prefixes) < 2 and len(related_collectors) < 2:
continue
sample = more_specifics[0].get("metadata") or {}
sample_enrichment = sample.get("enrichment") or {}
sample_prefix_geography = sample_enrichment.get("prefix_geography") or {}
event_count = len(more_specifics)
anomalies.append(
BGPAnomaly(
snapshot_id=snapshot_id,
task_id=task_id,
source=source,
anomaly_type="more_specific_burst",
severity="high",
status="active",
entity_key=f"more_specific_burst:{root_prefix}:{len(unique_prefixes)}:{len(related_collectors)}",
prefix=sample.get("prefix"),
origin_asn=sample.get("origin_asn"),
new_origin_asn=None,
peer_scope=related_collectors,
started_at=datetime.now(UTC),
confidence=min(0.64 + (0.04 * min(event_count, 5)), 0.88),
summary=(
f"{len(unique_prefixes)} more-specific prefixes clustered under {root_prefix} "
f"across {len(related_collectors) or 1} collectors."
),
evidence={
"events": [item.get("metadata") for item in more_specifics[:10]],
"unique_prefixes": unique_prefixes,
"rpki_validation": sample_enrichment.get("rpki_validation"),
"origin_asn_profile": sample_enrichment.get("origin_asn_profile"),
"prefix_geography": sample_prefix_geography,
"prefix_scope": sample_enrichment.get("prefix_scope"),
"impacted_regions": sample_prefix_geography.get("regions")
or _iter_event_regions(more_specifics)
or sample_enrichment.get("prefix_scope", {}).get("regions", []),
},
)
)
return anomalies
def detect_mass_withdrawal_anomalies(
*,
source: str,
snapshot_id: int | None,
task_id: int | None,
events: list[dict[str, Any]],
) -> list[BGPAnomaly]:
withdrawal_counter: Counter[tuple[str, int | None]] = Counter()
withdrawal_events_by_key: defaultdict[tuple[str, int | None], list[dict[str, Any]]] = defaultdict(list)
for event in events:
metadata = event.get("metadata") or {}
prefix = metadata.get("prefix")
if prefix and metadata.get("event_type") == "withdrawal":
key = (str(prefix), metadata.get("origin_asn"))
withdrawal_counter[key] += 1
withdrawal_events_by_key[key].append(event)
anomalies: list[BGPAnomaly] = []
for (prefix, origin_asn), count in withdrawal_counter.items():
related_events = withdrawal_events_by_key[(prefix, origin_asn)]
related_collectors = _unique_collectors(related_events)
related_peers = _unique_peers(related_events)
if count < 3 and not (count >= 2 and len(related_collectors) >= 2):
continue
sample_event = related_events[0] if related_events else {}
sample_metadata = sample_event.get("metadata") or {}
sample_enrichment = sample_metadata.get("enrichment") or {}
sample_prefix_geography = sample_enrichment.get("prefix_geography") or {}
severity = "medium"
if count >= 4 or len(related_collectors) >= 3:
severity = "high"
if count >= 8:
severity = "critical"
anomalies.append(
BGPAnomaly(
snapshot_id=snapshot_id,
task_id=task_id,
source=source,
anomaly_type="mass_withdrawal",
severity=severity,
status="active",
entity_key=f"mass_withdrawal:{prefix}:{origin_asn}:{len(related_collectors)}:{count}",
prefix=prefix,
origin_asn=origin_asn,
new_origin_asn=None,
peer_scope=related_collectors,
started_at=datetime.now(UTC),
confidence=min(0.5 + (count * 0.06) + (0.04 * max(len(related_collectors) - 1, 0)), 0.95),
summary=(
f"{count} withdrawal events observed for {prefix} "
f"across {len(related_collectors) or 1} collectors in the current ingest window."
),
evidence={
"withdrawal_count": count,
"collector_count": len(related_collectors),
"peer_count": len(related_peers),
"events": [
(item.get("metadata") or {})
for item in related_events[:10]
],
"origin_asn_profile": sample_enrichment.get("origin_asn_profile"),
"rpki_validation": sample_enrichment.get("rpki_validation"),
"prefix_geography": sample_prefix_geography,
"prefix_scope": sample_enrichment.get("prefix_scope"),
"impacted_regions": sample_prefix_geography.get("regions")
or _iter_event_regions(related_events)
or sample_enrichment.get("prefix_scope", {}).get("regions", []),
},
)
)
return anomalies
def detect_route_leak_anomalies(
*,
source: str,
snapshot_id: int | None,
task_id: int | None,
events: list[dict[str, Any]],
) -> list[BGPAnomaly]:
events_by_prefix: defaultdict[str, list[dict[str, Any]]] = defaultdict(list)
for event in events:
metadata = event.get("metadata") or {}
prefix = metadata.get("prefix")
if prefix and metadata.get("event_type") == "announcement":
events_by_prefix[str(prefix)].append(event)
anomalies: list[BGPAnomaly] = []
for prefix, related_events in events_by_prefix.items():
related_collectors = _unique_collectors(related_events)
if len(related_collectors) < 2:
continue
path_signatures = Counter()
max_path_length = 0
for event in related_events:
metadata = event.get("metadata") or {}
signature = _path_signature(metadata)
if signature:
path_signatures[signature] += 1
max_path_length = max(max_path_length, len(signature))
if len(path_signatures) < 2:
continue
dominant_length = len(path_signatures.most_common(1)[0][0])
if max_path_length < max(dominant_length + 2, 5):
continue
sample_event = max(
related_events,
key=lambda event: len(_path_signature((event.get("metadata") or {}))),
)
sample_metadata = sample_event.get("metadata") or {}
sample_enrichment = sample_metadata.get("enrichment") or {}
sample_prefix_geography = sample_enrichment.get("prefix_geography") or {}
peer_scope = related_collectors
path_lengths = sorted({len(signature) for signature in path_signatures if signature})
anomalies.append(
BGPAnomaly(
snapshot_id=snapshot_id,
task_id=task_id,
source=source,
anomaly_type="route_leak_candidate",
severity="high" if max_path_length >= dominant_length + 3 else "medium",
status="active",
entity_key=f"route_leak_candidate:{prefix}:{max_path_length}:{len(related_collectors)}",
prefix=prefix,
origin_asn=sample_metadata.get("origin_asn"),
new_origin_asn=None,
peer_scope=peer_scope,
started_at=datetime.now(UTC),
confidence=min(0.58 + (0.05 * min(len(related_collectors), 4)) + (0.03 * min(max_path_length - dominant_length, 4)), 0.88),
summary=(
f"Prefix {prefix} shows divergent long AS paths across "
f"{len(related_collectors)} collectors, suggesting a possible route leak."
),
evidence={
"path_lengths": path_lengths,
"dominant_path_length": dominant_length,
"max_path_length": max_path_length,
"path_signatures": [
{"path": list(signature), "count": count}
for signature, count in path_signatures.most_common(5)
],
"events": [(item.get("metadata") or {}) for item in related_events[:10]],
"origin_asn_profile": sample_enrichment.get("origin_asn_profile"),
"rpki_validation": sample_enrichment.get("rpki_validation"),
"prefix_geography": sample_prefix_geography,
"prefix_scope": sample_enrichment.get("prefix_scope"),
"impacted_regions": sample_prefix_geography.get("regions")
or _iter_event_regions(related_events)
or sample_enrichment.get("prefix_scope", {}).get("regions", []),
},
)
)
return anomalies
def detect_path_flap_anomalies(
*,
source: str,
snapshot_id: int | None,
task_id: int | None,
events: list[dict[str, Any]],
) -> list[BGPAnomaly]:
events_by_prefix: defaultdict[str, list[dict[str, Any]]] = defaultdict(list)
for event in events:
metadata = event.get("metadata") or {}
prefix = metadata.get("prefix")
if prefix:
events_by_prefix[str(prefix)].append(event)
anomalies: list[BGPAnomaly] = []
for prefix, related_events in events_by_prefix.items():
ordered = sorted(
related_events,
key=lambda event: str((event.get("metadata") or {}).get("timestamp") or ""),
)
event_types = [str((item.get("metadata") or {}).get("event_type") or "") for item in ordered]
transitions = sum(1 for index in range(1, len(event_types)) if event_types[index] != event_types[index - 1])
distinct_paths = {
_path_signature(item.get("metadata") or {})
for item in ordered
if _path_signature(item.get("metadata") or {})
}
related_collectors = _unique_collectors(ordered)
if transitions < 3 and len(distinct_paths) < 3:
continue
sample_metadata = (ordered[0].get("metadata") or {}) if ordered else {}
sample_enrichment = sample_metadata.get("enrichment") or {}
sample_prefix_geography = sample_enrichment.get("prefix_geography") or {}
severity = "medium"
if transitions >= 5 or len(distinct_paths) >= 4:
severity = "high"
anomalies.append(
BGPAnomaly(
snapshot_id=snapshot_id,
task_id=task_id,
source=source,
anomaly_type="path_flap",
severity=severity,
status="active",
entity_key=f"path_flap:{prefix}:{transitions}:{len(distinct_paths)}",
prefix=prefix,
origin_asn=sample_metadata.get("origin_asn"),
new_origin_asn=None,
peer_scope=related_collectors,
started_at=datetime.now(UTC),
confidence=min(0.54 + (0.05 * min(transitions, 5)) + (0.03 * min(len(distinct_paths), 4)), 0.9),
summary=(
f"Prefix {prefix} shows repeated state/path changes "
f"({transitions} transitions, {len(distinct_paths)} distinct paths) in the current window."
),
evidence={
"transitions": transitions,
"event_types": event_types[:12],
"distinct_paths": [list(path) for path in list(distinct_paths)[:6]],
"events": [(item.get("metadata") or {}) for item in ordered[:10]],
"origin_asn_profile": sample_enrichment.get("origin_asn_profile"),
"rpki_validation": sample_enrichment.get("rpki_validation"),
"prefix_geography": sample_prefix_geography,
"prefix_scope": sample_enrichment.get("prefix_scope"),
"impacted_regions": sample_prefix_geography.get("regions")
or _iter_event_regions(ordered)
or sample_enrichment.get("prefix_scope", {}).get("regions", []),
},
)
)
return anomalies

View File

@@ -0,0 +1,408 @@
"""Enrichment helpers for BGP observation and anomaly pipelines."""
from __future__ import annotations
import ipaddress
from collections import defaultdict
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.countries import get_country_centroid, normalize_country
from app.models.bgp_observation import BGPObservation
from app.models.collected_data import CollectedData
def _safe_int(value: Any) -> int | None:
try:
if value in (None, ""):
return None
return int(value)
except (TypeError, ValueError):
return None
def _parse_timestamp(value: Any) -> datetime:
if isinstance(value, datetime):
return value.astimezone(UTC) if value.tzinfo else value.replace(tzinfo=UTC)
if isinstance(value, (int, float)):
return datetime.fromtimestamp(value, tz=UTC)
if isinstance(value, str) and value:
normalized = value.replace("Z", "+00:00")
parsed = datetime.fromisoformat(normalized)
return parsed.astimezone(UTC) if parsed.tzinfo else parsed.replace(tzinfo=UTC)
return datetime.now(UTC)
def _dedupe_as_path(as_path: list[int]) -> list[int]:
deduped: list[int] = []
for asn in as_path:
if not deduped or deduped[-1] != asn:
deduped.append(asn)
return deduped
def _compact_locations(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
results: list[dict[str, Any]] = []
seen: set[tuple[Any, ...]] = set()
for item in items:
key = (
item.get("country"),
item.get("city"),
item.get("latitude"),
item.get("longitude"),
)
if key in seen:
continue
seen.add(key)
results.append(item)
return results
def extract_bgp_network_fields(prefix: str) -> dict[str, Any]:
if not prefix:
return {
"prefix_family": None,
"prefix_length": None,
"prefix_supernet": None,
"is_more_specific": False,
}
try:
network = ipaddress.ip_network(prefix, strict=False)
except ValueError:
return {
"prefix_family": None,
"prefix_length": None,
"prefix_supernet": None,
"is_more_specific": False,
}
supernet_prefix = 16 if network.version == 4 else 32
if network.prefixlen > supernet_prefix:
prefix_supernet = str(network.supernet(new_prefix=supernet_prefix))
else:
prefix_supernet = str(network)
return {
"prefix_family": f"ipv{network.version}",
"prefix_length": int(network.prefixlen),
"prefix_supernet": prefix_supernet,
"is_more_specific": network.prefixlen > (24 if network.version == 4 else 48),
}
async def _lookup_prefix_geography(
db: AsyncSession,
prefix_values: list[str],
) -> dict[str, dict[str, Any]]:
async def _query_prefix_metadata(
*,
source: str,
family: str,
range_start: str,
range_end: str,
) -> dict[str, Any] | None:
result = await db.execute(
text(
"""
SELECT metadata
FROM collected_data
WHERE source = :source
AND COALESCE(is_current, TRUE) = TRUE
AND metadata->>'family' = :family
AND CAST(metadata->>'range_start' AS inet) <= CAST(:range_start AS inet)
AND CAST(metadata->>'range_end' AS inet) >= CAST(:range_end AS inet)
ORDER BY
masklen(CAST(metadata->>'prefix' AS cidr)) DESC NULLS LAST,
id DESC
LIMIT 1
"""
),
{
"source": source,
"family": family,
"range_start": range_start,
"range_end": range_end,
},
)
row = result.fetchone()
if not row:
return None
if isinstance(row, dict):
payload = row.get("metadata") or row.get("extra_data")
elif hasattr(row, "_mapping"):
payload = row._mapping.get("metadata") or row._mapping.get("extra_data")
else:
payload = row[0]
return payload if isinstance(payload, dict) else None
results: dict[str, dict[str, Any]] = {}
for prefix in prefix_values:
try:
network = ipaddress.ip_network(prefix, strict=False)
except ValueError:
continue
family = f"ipv{network.version}"
range_start = str(network.network_address)
range_end = str(network.broadcast_address)
payload = await _query_prefix_metadata(
source="opengeofeed_prefix_geo",
family=family,
range_start=range_start,
range_end=range_end,
)
selected_source = "opengeofeed"
if not payload:
payload = await _query_prefix_metadata(
source="iptoasn_prefix_geo",
family=family,
range_start=range_start,
range_end=range_end,
)
selected_source = "iptoasn"
if not payload:
payload = await _query_prefix_metadata(
source="nro_delegated_prefix_geo",
family=family,
range_start=range_start,
range_end=range_end,
)
selected_source = "nro_delegated"
if not payload:
continue
country = normalize_country(payload.get("country") or payload.get("country_code"))
prefix_hint = payload.get("prefix") or prefix
asn = _safe_int(payload.get("asn"))
as_name = payload.get("as_name")
city = payload.get("city")
centroid = get_country_centroid(country)
regions = []
if country:
regions.append(
{
"country": country,
"city": city,
"latitude": centroid.get("latitude") if centroid else None,
"longitude": centroid.get("longitude") if centroid else None,
}
)
results[prefix] = {
"prefix": prefix_hint,
"country": country,
"city": city,
"asn": asn,
"as_name": as_name,
"source": payload.get("source_dataset")
or (
"opengeofeed_public"
if selected_source == "opengeofeed"
else (
"iptoasn_combined"
if selected_source == "iptoasn"
else "nro_delegated_stats"
)
),
"confidence": payload.get("confidence")
or (
"geofeed"
if selected_source == "opengeofeed"
else (
"country_range"
if selected_source == "iptoasn"
else "registry_allocated"
)
),
"geography_mode": "prefix_geography",
"regions": regions,
}
return results
async def enrich_bgp_events_for_batch(
db: AsyncSession,
*,
source: str,
events: list[dict[str, Any]],
) -> list[dict[str, Any]]:
if not events:
return []
prefixes = {
str((event.get("metadata") or {}).get("prefix") or "").strip()
for event in events
if (event.get("metadata") or {}).get("prefix")
}
prefix_values = sorted(prefix for prefix in prefixes if prefix)
origin_asns = sorted(
{
asn
for event in events
for asn in [
_safe_int((event.get("metadata") or {}).get("origin_asn")),
_safe_int((event.get("metadata") or {}).get("new_origin_asn")),
]
if asn is not None
}
)
historical_prefix_baseline: dict[str, dict[str, Any]] = {}
if prefix_values:
previous_result = await db.execute(
select(BGPObservation).where(
BGPObservation.source == source,
BGPObservation.prefix.in_(prefix_values),
)
)
by_prefix: defaultdict[str, list[BGPObservation]] = defaultdict(list)
for observation in previous_result.scalars().all():
if observation.prefix:
by_prefix[observation.prefix].append(observation)
for prefix, observations in by_prefix.items():
unique_origins = sorted(
{
observation.origin_asn
for observation in observations
if observation.origin_asn is not None
}
)
unique_collectors = sorted(
{
observation.collector
for observation in observations
if observation.collector
}
)
historical_prefix_baseline[prefix] = {
"historical_origin_asns": unique_origins,
"historical_collectors": unique_collectors,
"historical_observation_count": len(observations),
"historical_regions": _compact_locations(
[
observation.collector_geo or {}
for observation in observations
if observation.collector_geo
]
),
}
asn_profiles: dict[int, dict[str, Any]] = {}
prefix_geographies = await _lookup_prefix_geography(db, prefix_values) if prefix_values else {}
if origin_asns:
peeringdb_result = await db.execute(
select(CollectedData).where(CollectedData.source == "peeringdb_network")
)
for record in peeringdb_result.scalars().all():
metadata = record.extra_data or {}
asn = _safe_int(metadata.get("asn"))
if asn is None or asn not in origin_asns:
continue
current = asn_profiles.get(asn)
if current and (current.get("id") or 0) > (record.id or 0):
continue
asn_profiles[asn] = {
"id": record.id,
"asn": asn,
"name": record.name,
"country": metadata.get("country"),
"city": metadata.get("city"),
"source": "peeringdb_network",
"info_type": metadata.get("info_type"),
"info_traffic": metadata.get("info_traffic"),
"info_ratio": metadata.get("info_ratio"),
"ix_count": metadata.get("ix_count"),
"url": metadata.get("url"),
}
collector_counts: defaultdict[str, int] = defaultdict(int)
for event in events:
collector = (event.get("metadata") or {}).get("collector")
if collector:
collector_counts[str(collector)] += 1
enriched: list[dict[str, Any]] = []
for event in events:
metadata = dict(event.get("metadata") or {})
prefix = str(metadata.get("prefix") or "").strip()
as_path = metadata.get("as_path") or []
normalized_as_path = [asn for asn in (_safe_int(item) for item in as_path) if asn is not None]
deduped_as_path = _dedupe_as_path(normalized_as_path)
collector = str(metadata.get("collector") or "").strip()
collector_location = metadata.get("collector_location") or {}
baseline = historical_prefix_baseline.get(prefix, {})
prefix_geography = prefix_geographies.get(prefix)
observed_at = _parse_timestamp(metadata.get("timestamp") or event.get("reference_date"))
origin_asn = _safe_int(metadata.get("origin_asn"))
new_origin_asn = _safe_int(metadata.get("new_origin_asn"))
baseline_regions = baseline.get("historical_regions", [])
prefix_scope_regions = _compact_locations([*baseline_regions])
enrichment = {
**extract_bgp_network_fields(prefix),
"observed_at": observed_at.isoformat(),
"normalized_as_path": normalized_as_path,
"deduped_as_path": deduped_as_path,
"deduped_as_path_length": len(deduped_as_path),
"path_prepending": len(normalized_as_path) > len(deduped_as_path),
"collector_region": {
"city": collector_location.get("city"),
"country": collector_location.get("country"),
},
"collector_observation_count_in_batch": collector_counts.get(collector, 0),
"batch_visibility_collectors": sorted(collector_counts.keys()),
"prefix_baseline": baseline,
"is_new_origin_for_prefix": (
origin_asn is not None
and origin_asn
not in set(baseline.get("historical_origin_asns", []))
),
"rpki_validation": {
"status": "unknown",
"reason": "no_rpki_roa_dataset_configured",
},
"origin_asn_profile": asn_profiles.get(origin_asn),
"new_origin_asn_profile": asn_profiles.get(new_origin_asn),
"prefix_geography": prefix_geography,
"prefix_scope": {
"countries": sorted(
{
item.get("country")
for item in prefix_scope_regions
if item.get("country")
}
),
"cities": sorted(
{
item.get("city")
for item in prefix_scope_regions
if item.get("city")
}
),
"regions": prefix_scope_regions,
},
}
enriched.append(
{
**event,
"metadata": {
**metadata,
"enrichment": enrichment,
},
}
)
return enriched

View File

@@ -0,0 +1,321 @@
"""Incident aggregation helpers for BGP anomalies."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.collected_data_fields import get_record_field
from app.models.bgp_anomaly import BGPAnomaly
from app.models.bgp_incident import BGPIncident
from app.models.collected_data import CollectedData
from app.services.cable_graph import haversine_distance
def _severity_rank(value: str | None) -> int:
mapping = {"critical": 4, "high": 3, "medium": 2, "low": 1, "info": 0}
return mapping.get(str(value or "").lower(), 0)
def _pick_severity(values: list[str]) -> str:
ordered = sorted(values, key=_severity_rank, reverse=True)
return ordered[0] if ordered else "medium"
def _collector_regions_from_anomaly(anomaly: BGPAnomaly) -> list[dict]:
evidence = anomaly.evidence or {}
regions = evidence.get("impacted_regions") or []
if regions:
return regions
collected = []
for item in evidence.get("events") or []:
collector = item.get("collector")
location = item.get("collector_location") or {}
if collector or location:
collected.append(
{
"collector": collector,
"country": location.get("country"),
"city": location.get("city"),
"latitude": location.get("latitude"),
"longitude": location.get("longitude"),
}
)
return collected
def _dedupe_collected_records(records: list[CollectedData]) -> list[CollectedData]:
latest_by_key: dict[str, CollectedData] = {}
for record in records:
dedupe_key = str(record.source_id or record.entity_key or record.name or record.id)
existing = latest_by_key.get(dedupe_key)
if existing is None or (record.id or 0) > (existing.id or 0):
latest_by_key[dedupe_key] = record
return list(latest_by_key.values())
async def infer_related_infrastructure(
db: AsyncSession,
affected_regions: list[dict],
*,
max_matches: int = 6,
max_distance_km: float = 450.0,
) -> dict[str, list[dict[str, Any]]]:
valid_regions = [
region
for region in affected_regions
if isinstance(region, dict)
and isinstance(region.get("latitude"), (int, float))
and isinstance(region.get("longitude"), (int, float))
]
if not valid_regions:
return {"related_cables": [], "related_ixps": []}
landing_result = await db.execute(
select(CollectedData).where(CollectedData.source == "arcgis_landing_points")
)
relation_result = await db.execute(
select(CollectedData).where(CollectedData.source == "arcgis_cable_landing_relation")
)
cable_result = await db.execute(
select(CollectedData).where(CollectedData.source == "arcgis_cables")
)
landing_records = _dedupe_collected_records(list(landing_result.scalars().all()))
relation_records = _dedupe_collected_records(list(relation_result.scalars().all()))
cable_records = _dedupe_collected_records(list(cable_result.scalars().all()))
city_to_cable_ids: dict[int, list[int]] = {}
for relation in relation_records:
metadata = relation.extra_data or {}
city_id = metadata.get("city_id")
cable_id = metadata.get("cable_id")
if city_id is None or cable_id is None:
continue
city_key = int(city_id)
cable_key = int(cable_id)
city_to_cable_ids.setdefault(city_key, [])
if cable_key not in city_to_cable_ids[city_key]:
city_to_cable_ids[city_key].append(cable_key)
cable_id_to_name: dict[int, str] = {}
for cable in cable_records:
metadata = cable.extra_data or {}
cable_id = metadata.get("cable_id")
if cable_id is None or not cable.name:
continue
cable_id_to_name[int(cable_id)] = cable.name
matches: list[dict[str, Any]] = []
seen_match_keys: set[tuple[Any, ...]] = set()
for region in valid_regions:
region_coords = (float(region["longitude"]), float(region["latitude"]))
for landing in landing_records:
try:
latitude = get_record_field(landing, "latitude")
longitude = get_record_field(landing, "longitude")
landing_lat = float(latitude) if latitude is not None else None
landing_lon = float(longitude) if longitude is not None else None
except (TypeError, ValueError):
landing_lat = None
landing_lon = None
if landing_lat is None or landing_lon is None:
continue
distance_km = haversine_distance(region_coords, (landing_lon, landing_lat))
if distance_km > max_distance_km:
continue
landing_meta = landing.extra_data or {}
city_id = landing_meta.get("city_id")
cable_names = []
if city_id is not None:
for cable_id in city_to_cable_ids.get(int(city_id), []):
cable_name = cable_id_to_name.get(int(cable_id))
if cable_name and cable_name not in cable_names:
cable_names.append(cable_name)
match = {
"landing_point": landing.name or "Unknown",
"city": get_record_field(landing, "city"),
"country": get_record_field(landing, "country"),
"distance_km": round(distance_km, 1),
"collector": region.get("collector"),
"cable_names": cable_names,
}
match_key = (
match["landing_point"],
match["city"],
match["country"],
)
if match_key in seen_match_keys:
continue
seen_match_keys.add(match_key)
matches.append(match)
matches.sort(
key=lambda item: (
item.get("distance_km", 999999),
str(item.get("landing_point") or ""),
)
)
matches = matches[:max_matches]
related_ixps = []
seen_ixp_keys: set[tuple[str, str]] = set()
for item in matches:
city = str(item.get("city") or "").strip()
country = str(item.get("country") or "").strip()
if not city and not country:
continue
key = (city, country)
if key in seen_ixp_keys:
continue
seen_ixp_keys.add(key)
related_ixps.append(
{
"name": ", ".join(part for part in [city, country] if part),
"type": "regional_exchange_hint",
}
)
return {
"related_cables": matches,
"related_ixps": related_ixps,
}
async def create_bgp_incidents_for_anomalies(
db: AsyncSession,
*,
source: str,
snapshot_id: int | None,
task_id: int | None,
anomalies: list[BGPAnomaly],
) -> int:
if not anomalies:
return 0
grouped: dict[str, list[BGPAnomaly]] = {}
for anomaly in anomalies:
incident_key = f"{anomaly.anomaly_type}:{anomaly.prefix or 'unknown'}:{anomaly.new_origin_asn or anomaly.origin_asn or 'na'}"
grouped.setdefault(incident_key, []).append(anomaly)
existing_result = await db.execute(
select(BGPIncident).where(BGPIncident.incident_key.in_(sorted(grouped.keys())))
)
existing_incidents = {
incident.incident_key: incident for incident in existing_result.scalars().all()
}
created = 0
for incident_key, items in grouped.items():
items = sorted(items, key=lambda item: item.created_at or item.started_at or datetime.now(UTC))
primary = items[0]
prefixes = sorted({item.prefix for item in items if item.prefix})
asns = sorted(
{
asn
for item in items
for asn in [item.origin_asn, item.new_origin_asn]
if asn is not None
}
)
collectors = sorted(
{
collector
for item in items
for collector in (item.peer_scope or [])
if collector
}
)
regions: list[dict] = []
seen_regions: set[tuple] = set()
for item in items:
for region in _collector_regions_from_anomaly(item):
region_key = (
region.get("collector"),
region.get("country"),
region.get("city"),
)
if region_key in seen_regions:
continue
seen_regions.add(region_key)
regions.append(region)
if not collectors:
collectors = sorted(
{
region.get("collector")
for region in regions
if region.get("collector")
}
)
evidence_refs = [item.entity_key for item in items if item.entity_key]
severity = _pick_severity([item.severity for item in items])
confidence = max((item.confidence or 0.0) for item in items)
title = f"{primary.anomaly_type.replace('_', ' ').title()} incident on {primary.prefix or 'unknown prefix'}"
summary = (
f"{len(items)} anomaly signal(s) grouped into one {primary.anomaly_type} incident, "
f"affecting {len(prefixes) or 1} prefix scope(s) across {len(collectors)} collector(s)."
)
related_infrastructure = await infer_related_infrastructure(db, regions)
existing = existing_incidents.get(incident_key)
if existing is not None:
existing.snapshot_id = snapshot_id
existing.task_id = task_id
existing.source = source
existing.incident_type = primary.anomaly_type
existing.title = title
existing.summary = summary
existing.severity = severity
existing.status = "active"
existing.confidence = confidence
existing.started_at = primary.started_at or existing.started_at or datetime.now(UTC)
existing.ended_at = None
existing.affected_prefixes = prefixes
existing.affected_asns = asns
existing.affected_collectors = collectors
existing.affected_regions = regions
existing.related_cables = related_infrastructure["related_cables"]
existing.related_ixps = related_infrastructure["related_ixps"]
existing.evidence_refs = evidence_refs
continue
db.add(
BGPIncident(
snapshot_id=snapshot_id,
task_id=task_id,
source=source,
incident_key=incident_key,
incident_type=primary.anomaly_type,
title=title,
summary=summary,
severity=severity,
status="active",
confidence=confidence,
started_at=primary.started_at or datetime.now(UTC),
affected_prefixes=prefixes,
affected_asns=asns,
affected_collectors=collectors,
affected_regions=regions,
related_cables=related_infrastructure["related_cables"],
related_ixps=related_infrastructure["related_ixps"],
evidence_refs=evidence_refs,
)
)
created += 1
if created or existing_incidents:
await db.commit()
return created

View File

@@ -30,6 +30,11 @@ from app.services.collectors.arcgis_landing import ArcGISLandingPointCollector
from app.services.collectors.arcgis_relation import ArcGISCableLandingRelationCollector from app.services.collectors.arcgis_relation import ArcGISCableLandingRelationCollector
from app.services.collectors.spacetrack import SpaceTrackTLECollector from app.services.collectors.spacetrack import SpaceTrackTLECollector
from app.services.collectors.celestrak import CelesTrakTLECollector from app.services.collectors.celestrak import CelesTrakTLECollector
from app.services.collectors.ris_live import RISLiveCollector
from app.services.collectors.bgpstream import BGPStreamBackfillCollector
from app.services.collectors.iptoasn import IPtoASNPrefixGeoCollector
from app.services.collectors.opengeofeed import OpenGeoFeedPrefixGeoCollector
from app.services.collectors.nro_delegated import NRODelegatedPrefixGeoCollector
collector_registry.register(TOP500Collector()) collector_registry.register(TOP500Collector())
collector_registry.register(EpochAIGPUCollector()) collector_registry.register(EpochAIGPUCollector())
@@ -51,3 +56,8 @@ collector_registry.register(ArcGISLandingPointCollector())
collector_registry.register(ArcGISCableLandingRelationCollector()) collector_registry.register(ArcGISCableLandingRelationCollector())
collector_registry.register(SpaceTrackTLECollector()) collector_registry.register(SpaceTrackTLECollector())
collector_registry.register(CelesTrakTLECollector()) collector_registry.register(CelesTrakTLECollector())
collector_registry.register(RISLiveCollector())
collector_registry.register(BGPStreamBackfillCollector())
collector_registry.register(IPtoASNPrefixGeoCollector())
collector_registry.register(OpenGeoFeedPrefixGeoCollector())
collector_registry.register(NRODelegatedPrefixGeoCollector())

View File

@@ -5,7 +5,7 @@ Collects submarine cable data from ArcGIS GeoJSON API.
import json import json
from typing import Dict, Any, List from typing import Dict, Any, List
from datetime import datetime from datetime import UTC, datetime
import httpx import httpx
from app.services.collectors.base import BaseCollector from app.services.collectors.base import BaseCollector
@@ -84,7 +84,7 @@ class ArcGISCableCollector(BaseCollector):
"color": props.get("color"), "color": props.get("color"),
"route_coordinates": route_coordinates, "route_coordinates": route_coordinates,
}, },
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"), "reference_date": datetime.now(UTC).strftime("%Y-%m-%d"),
} }
result.append(entry) result.append(entry)
except (ValueError, TypeError, KeyError): except (ValueError, TypeError, KeyError):

View File

@@ -1,5 +1,5 @@
from typing import Dict, Any, List from typing import Dict, Any, List
from datetime import datetime from datetime import UTC, datetime
import httpx import httpx
from app.services.collectors.base import BaseCollector from app.services.collectors.base import BaseCollector
@@ -67,7 +67,7 @@ class ArcGISLandingPointCollector(BaseCollector):
"status": props.get("status"), "status": props.get("status"),
"landing_point_id": props.get("landing_point_id"), "landing_point_id": props.get("landing_point_id"),
}, },
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"), "reference_date": datetime.now(UTC).strftime("%Y-%m-%d"),
} }
result.append(entry) result.append(entry)
except (ValueError, TypeError, KeyError): except (ValueError, TypeError, KeyError):

View File

@@ -1,5 +1,5 @@
import asyncio import asyncio
from datetime import datetime from datetime import UTC, datetime
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import httpx import httpx
@@ -143,7 +143,7 @@ class ArcGISCableLandingRelationCollector(BaseCollector):
"facility": facility, "facility": facility,
"status": status, "status": status,
}, },
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"), "reference_date": datetime.now(UTC).strftime("%Y-%m-%d"),
} }
result.append(entry) result.append(entry)
except (ValueError, TypeError, KeyError): except (ValueError, TypeError, KeyError):

View File

@@ -2,7 +2,7 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Dict, List, Any, Optional from typing import Dict, List, Any, Optional
from datetime import datetime from datetime import UTC, datetime
import httpx import httpx
from sqlalchemy import select, text from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -10,6 +10,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.collected_data_fields import build_dynamic_metadata, get_record_field from app.core.collected_data_fields import build_dynamic_metadata, get_record_field
from app.core.config import settings from app.core.config import settings
from app.core.countries import normalize_country from app.core.countries import normalize_country
from app.core.time import to_iso8601_utc
from app.core.websocket.broadcaster import broadcaster
class BaseCollector(ABC): class BaseCollector(ABC):
@@ -20,12 +22,14 @@ class BaseCollector(ABC):
module: str = "L1" module: str = "L1"
frequency_hours: int = 4 frequency_hours: int = 4
data_type: str = "generic" data_type: str = "generic"
fail_on_empty: bool = False
def __init__(self): def __init__(self):
self._current_task = None self._current_task = None
self._db_session = None self._db_session = None
self._datasource_id = 1 self._datasource_id = 1
self._resolved_url: Optional[str] = None self._resolved_url: Optional[str] = None
self._last_broadcast_progress: Optional[int] = None
async def resolve_url(self, db: AsyncSession) -> None: async def resolve_url(self, db: AsyncSession) -> None:
from app.core.data_sources import get_data_sources_config from app.core.data_sources import get_data_sources_config
@@ -33,18 +37,53 @@ class BaseCollector(ABC):
config = get_data_sources_config() config = get_data_sources_config()
self._resolved_url = await config.get_url(self.name, db) self._resolved_url = await config.get_url(self.name, db)
def update_progress(self, records_processed: int): async def _publish_task_update(self, force: bool = False):
if not self._current_task:
return
progress = float(self._current_task.progress or 0.0)
rounded_progress = int(round(progress))
if not force and self._last_broadcast_progress == rounded_progress:
return
await broadcaster.broadcast_datasource_task_update(
{
"datasource_id": getattr(self, "_datasource_id", None),
"collector_name": self.name,
"task_id": self._current_task.id,
"status": self._current_task.status,
"phase": self._current_task.phase,
"progress": progress,
"records_processed": self._current_task.records_processed,
"total_records": self._current_task.total_records,
"started_at": to_iso8601_utc(self._current_task.started_at),
"completed_at": to_iso8601_utc(self._current_task.completed_at),
"error_message": self._current_task.error_message,
}
)
self._last_broadcast_progress = rounded_progress
async def update_progress(self, records_processed: int, *, commit: bool = False, force: bool = False):
"""Update task progress - call this during data processing""" """Update task progress - call this during data processing"""
if self._current_task and self._db_session and self._current_task.total_records > 0: if self._current_task and self._db_session:
self._current_task.records_processed = records_processed self._current_task.records_processed = records_processed
self._current_task.progress = ( if self._current_task.total_records and self._current_task.total_records > 0:
records_processed / self._current_task.total_records self._current_task.progress = (
) * 100 records_processed / self._current_task.total_records
) * 100
else:
self._current_task.progress = 0.0
if commit:
await self._db_session.commit()
await self._publish_task_update(force=force)
async def set_phase(self, phase: str): async def set_phase(self, phase: str):
if self._current_task and self._db_session: if self._current_task and self._db_session:
self._current_task.phase = phase self._current_task.phase = phase
await self._db_session.commit() await self._db_session.commit()
await self._publish_task_update(force=True)
@abstractmethod @abstractmethod
async def fetch(self) -> List[Dict[str, Any]]: async def fetch(self) -> List[Dict[str, Any]]:
@@ -133,7 +172,7 @@ class BaseCollector(ABC):
from app.models.task import CollectionTask from app.models.task import CollectionTask
from app.models.data_snapshot import DataSnapshot from app.models.data_snapshot import DataSnapshot
start_time = datetime.utcnow() start_time = datetime.now(UTC)
datasource_id = getattr(self, "_datasource_id", 1) datasource_id = getattr(self, "_datasource_id", 1)
snapshot_id: Optional[int] = None snapshot_id: Optional[int] = None
@@ -152,14 +191,20 @@ class BaseCollector(ABC):
self._current_task = task self._current_task = task
self._db_session = db self._db_session = db
self._last_broadcast_progress = None
await self.resolve_url(db) await self.resolve_url(db)
await self._publish_task_update(force=True)
try: try:
await self.set_phase("fetching") await self.set_phase("fetching")
raw_data = await self.fetch() raw_data = await self.fetch()
task.total_records = len(raw_data) task.total_records = len(raw_data)
await db.commit() await db.commit()
await self._publish_task_update(force=True)
if self.fail_on_empty and not raw_data:
raise RuntimeError(f"Collector {self.name} returned no data")
await self.set_phase("transforming") await self.set_phase("transforming")
data = self.transform(raw_data) data = self.transform(raw_data)
@@ -172,33 +217,35 @@ class BaseCollector(ABC):
task.phase = "completed" task.phase = "completed"
task.records_processed = records_count task.records_processed = records_count
task.progress = 100.0 task.progress = 100.0
task.completed_at = datetime.utcnow() task.completed_at = datetime.now(UTC)
await db.commit() await db.commit()
await self._publish_task_update(force=True)
return { return {
"status": "success", "status": "success",
"task_id": task_id, "task_id": task_id,
"records_processed": records_count, "records_processed": records_count,
"execution_time_seconds": (datetime.utcnow() - start_time).total_seconds(), "execution_time_seconds": (datetime.now(UTC) - start_time).total_seconds(),
} }
except Exception as e: except Exception as e:
task.status = "failed" task.status = "failed"
task.phase = "failed" task.phase = "failed"
task.error_message = str(e) task.error_message = str(e)
task.completed_at = datetime.utcnow() task.completed_at = datetime.now(UTC)
if snapshot_id is not None: if snapshot_id is not None:
snapshot = await db.get(DataSnapshot, snapshot_id) snapshot = await db.get(DataSnapshot, snapshot_id)
if snapshot: if snapshot:
snapshot.status = "failed" snapshot.status = "failed"
snapshot.completed_at = datetime.utcnow() snapshot.completed_at = datetime.now(UTC)
snapshot.summary = {"error": str(e)} snapshot.summary = {"error": str(e)}
await db.commit() await db.commit()
await self._publish_task_update(force=True)
return { return {
"status": "failed", "status": "failed",
"task_id": task_id, "task_id": task_id,
"error": str(e), "error": str(e),
"execution_time_seconds": (datetime.utcnow() - start_time).total_seconds(), "execution_time_seconds": (datetime.now(UTC) - start_time).total_seconds(),
} }
async def _save_data( async def _save_data(
@@ -219,11 +266,11 @@ class BaseCollector(ABC):
snapshot.record_count = 0 snapshot.record_count = 0
snapshot.summary = {"created": 0, "updated": 0, "unchanged": 0} snapshot.summary = {"created": 0, "updated": 0, "unchanged": 0}
snapshot.status = "success" snapshot.status = "success"
snapshot.completed_at = datetime.utcnow() snapshot.completed_at = datetime.now(UTC)
await db.commit() await db.commit()
return 0 return 0
collected_at = datetime.utcnow() collected_at = datetime.now(UTC)
records_added = 0 records_added = 0
created_count = 0 created_count = 0
updated_count = 0 updated_count = 0
@@ -329,8 +376,7 @@ class BaseCollector(ABC):
records_added += 1 records_added += 1
if i % 100 == 0: if i % 100 == 0:
self.update_progress(i + 1) await self.update_progress(i + 1, commit=True)
await db.commit()
if snapshot_id is not None: if snapshot_id is not None:
deleted_keys = previous_current_keys - seen_entity_keys deleted_keys = previous_current_keys - seen_entity_keys
@@ -350,7 +396,7 @@ class BaseCollector(ABC):
if snapshot: if snapshot:
snapshot.record_count = records_added snapshot.record_count = records_added
snapshot.status = "success" snapshot.status = "success"
snapshot.completed_at = datetime.utcnow() snapshot.completed_at = datetime.now(UTC)
snapshot.summary = { snapshot.summary = {
"created": created_count, "created": created_count,
"updated": updated_count, "updated": updated_count,
@@ -359,7 +405,7 @@ class BaseCollector(ABC):
} }
await db.commit() await db.commit()
self.update_progress(len(data)) await self.update_progress(len(data), force=True)
return records_added return records_added
async def save(self, db: AsyncSession, data: List[Dict[str, Any]]) -> int: async def save(self, db: AsyncSession, data: List[Dict[str, Any]]) -> int:
@@ -406,8 +452,8 @@ async def log_task(
status=status, status=status,
records_processed=records_processed, records_processed=records_processed,
error_message=error_message, error_message=error_message,
started_at=datetime.utcnow(), started_at=datetime.now(UTC),
completed_at=datetime.utcnow(), completed_at=datetime.now(UTC),
) )
db.add(task) db.add(task)
await db.commit() await db.commit()

View File

@@ -0,0 +1,350 @@
"""Shared helpers for BGP collectors."""
from __future__ import annotations
import hashlib
from collections import defaultdict
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
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_incidents import create_bgp_incidents_for_anomalies
from app.services.bgp_detectors import (
detect_mass_withdrawal_anomalies,
detect_more_specific_burst_anomalies,
detect_origin_change_anomalies,
detect_path_flap_anomalies,
detect_route_leak_anomalies,
)
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},
}
def _safe_int(value: Any) -> int | None:
try:
if value in (None, ""):
return None
return int(value)
except (TypeError, ValueError):
return None
def _parse_timestamp(value: Any) -> datetime:
if isinstance(value, datetime):
return value.astimezone(UTC) if value.tzinfo else value.replace(tzinfo=UTC)
if isinstance(value, (int, float)):
return datetime.fromtimestamp(value, tz=UTC)
if isinstance(value, str) and value:
normalized = value.replace("Z", "+00:00")
parsed = datetime.fromisoformat(normalized)
return parsed.astimezone(UTC) if parsed.tzinfo else parsed.replace(tzinfo=UTC)
return datetime.now(UTC)
def _normalize_as_path(raw_path: Any) -> list[int]:
if raw_path in (None, ""):
return []
if isinstance(raw_path, list):
return [asn for asn in (_safe_int(item) for item in raw_path) if asn is not None]
if isinstance(raw_path, str):
parts = raw_path.replace("{", "").replace("}", "").split()
return [asn for asn in (_safe_int(item) for item in parts) if asn is not None]
return []
def normalize_bgp_event(payload: dict[str, Any], *, project: str) -> dict[str, Any]:
raw_message = payload.get("raw_message", payload)
raw_path = (
payload.get("path")
or payload.get("as_path")
or payload.get("attrs", {}).get("path")
or payload.get("attrs", {}).get("as_path")
or []
)
as_path = _normalize_as_path(raw_path)
raw_type = str(payload.get("event_type") or payload.get("type") or payload.get("msg_type") or "").lower()
if raw_type in {"a", "announce", "announcement"}:
event_type = "announcement"
elif raw_type in {"w", "withdraw", "withdrawal"}:
event_type = "withdrawal"
elif raw_type in {"r", "rib"}:
event_type = "rib"
else:
event_type = raw_type or "announcement"
prefix = str(payload.get("prefix") or payload.get("prefixes") or payload.get("target_prefix") or "").strip()
if prefix.startswith("[") and prefix.endswith("]"):
prefix = prefix[1:-1]
timestamp = _parse_timestamp(payload.get("timestamp") or payload.get("time") or payload.get("ts"))
collector = str(payload.get("collector") or payload.get("host") or payload.get("router") or "unknown")
peer_asn = _safe_int(payload.get("peer_asn") or payload.get("peer"))
peer_ip = payload.get("peer_ip") or payload.get("peer_address")
if peer_ip in (None, ""):
peer_candidate = payload.get("peer")
peer_ip = str(peer_candidate) if isinstance(peer_candidate, str) and ":" in peer_candidate else peer_candidate
origin_asn = _safe_int(payload.get("origin_asn")) or (as_path[-1] if as_path else None)
source_material = "|".join(
[
collector,
str(peer_asn or ""),
prefix,
event_type,
timestamp.isoformat(),
",".join(str(asn) for asn in as_path),
]
)
source_id = hashlib.sha1(source_material.encode("utf-8")).hexdigest()[:24]
collector_location = RIPE_RIS_COLLECTOR_COORDS.get(collector, {})
network_fields = extract_bgp_network_fields(prefix)
metadata = {
"project": project,
"collector": collector,
"peer_asn": peer_asn,
"peer_ip": peer_ip,
"event_type": event_type,
"prefix": prefix,
"origin_asn": origin_asn,
"as_path": as_path,
"communities": payload.get("communities")
or payload.get("community")
or payload.get("attrs", {}).get("communities")
or [],
"next_hop": payload.get("next_hop") or payload.get("attrs", {}).get("next_hop"),
"med": payload.get("med") or payload.get("attrs", {}).get("med"),
"local_pref": payload.get("local_pref") or payload.get("attrs", {}).get("local_pref"),
"timestamp": timestamp.isoformat(),
"as_path_length": len(as_path),
"visibility_weight": 1,
"collector_location": collector_location,
"raw_message": raw_message,
"prefix_family": network_fields.get("prefix_family"),
"prefix_length": network_fields.get("prefix_length"),
"prefix_supernet": network_fields.get("prefix_supernet"),
"is_more_specific": network_fields.get("is_more_specific", False),
}
return {
"source_id": source_id,
"name": prefix or f"{collector}:{event_type}",
"title": f"{event_type} {prefix}".strip(),
"description": f"{collector} observed {event_type} for {prefix}".strip(),
"reference_date": timestamp.isoformat(),
"country": collector_location.get("country"),
"city": collector_location.get("city"),
"latitude": collector_location.get("latitude"),
"longitude": collector_location.get("longitude"),
"metadata": metadata,
}
async def save_bgp_observations_for_batch(
db: AsyncSession,
*,
source: str,
snapshot_id: int | None,
task_id: int | None,
events: list[dict[str, Any]],
) -> int:
if not events:
return 0
ingest_batch_id = f"{source}:{task_id or 'adhoc'}:{snapshot_id or 'nosnapshot'}"
created = 0
for event in events:
metadata = event.get("metadata", {}) or {}
collector_location = metadata.get("collector_location") or {}
observed_at = _parse_timestamp(
metadata.get("timestamp") or event.get("reference_date")
)
db.add(
BGPObservation(
snapshot_id=snapshot_id,
task_id=task_id,
source=source,
ingest_batch_id=ingest_batch_id,
source_event_id=event.get("source_id"),
collector=metadata.get("collector"),
peer_asn=_safe_int(metadata.get("peer_asn")),
peer_ip=metadata.get("peer_ip"),
prefix=metadata.get("prefix"),
event_type=str(metadata.get("event_type") or "announcement"),
as_path=metadata.get("as_path") or [],
origin_asn=_safe_int(metadata.get("origin_asn")),
next_hop=metadata.get("next_hop"),
communities=metadata.get("communities") or [],
observed_at=observed_at,
collector_geo=collector_location,
raw_payload=metadata.get("raw_message") or {},
note=event.get("description"),
)
)
created += 1
if created:
await db.commit()
return created
async def create_bgp_anomalies_for_batch(
db: AsyncSession,
*,
source: str,
snapshot_id: int | None,
task_id: int | None,
events: list[dict[str, Any]],
) -> int:
if not events:
return 0
enriched_events = await enrich_bgp_events_for_batch(
db,
source=source,
events=events,
)
prefixes = {
event["metadata"].get("prefix")
for event in enriched_events
if event.get("metadata", {}).get("prefix")
}
previous_origin_map: dict[str, set[int]] = defaultdict(set)
if prefixes:
previous_query = await db.execute(
select(CollectedData).where(
CollectedData.source == source,
CollectedData.snapshot_id != snapshot_id,
CollectedData.extra_data["prefix"].as_string().in_(sorted(prefixes)),
)
)
for record in previous_query.scalars().all():
metadata = record.extra_data or {}
prefix = metadata.get("prefix")
origin = _safe_int(metadata.get("origin_asn"))
if prefix and origin is not None:
previous_origin_map[prefix].add(origin)
pending_anomalies = [
*detect_origin_change_anomalies(
source=source,
snapshot_id=snapshot_id,
task_id=task_id,
events=enriched_events,
previous_origin_map=previous_origin_map,
),
*detect_more_specific_burst_anomalies(
source=source,
snapshot_id=snapshot_id,
task_id=task_id,
events=enriched_events,
),
*detect_mass_withdrawal_anomalies(
source=source,
snapshot_id=snapshot_id,
task_id=task_id,
events=enriched_events,
),
*detect_route_leak_anomalies(
source=source,
snapshot_id=snapshot_id,
task_id=task_id,
events=enriched_events,
),
*detect_path_flap_anomalies(
source=source,
snapshot_id=snapshot_id,
task_id=task_id,
events=enriched_events,
),
]
if not pending_anomalies:
return 0
existing_result = await db.execute(
select(BGPAnomaly.entity_key).where(
BGPAnomaly.entity_key.in_([item.entity_key for item in pending_anomalies])
)
)
existing_keys = {row[0] for row in existing_result.fetchall()}
existing_anomalies: list[BGPAnomaly] = []
if existing_keys:
existing_anomaly_result = await db.execute(
select(BGPAnomaly).where(BGPAnomaly.entity_key.in_(sorted(existing_keys)))
)
existing_anomalies = existing_anomaly_result.scalars().all()
created = 0
created_anomalies: list[BGPAnomaly] = []
refreshed_anomalies: list[BGPAnomaly] = []
existing_map = {item.entity_key: item for item in existing_anomalies if item.entity_key}
for anomaly in pending_anomalies:
if anomaly.entity_key in existing_keys:
existing = existing_map.get(anomaly.entity_key)
if existing is not None:
existing.severity = anomaly.severity
existing.status = anomaly.status
existing.summary = anomaly.summary
existing.confidence = anomaly.confidence
existing.peer_scope = anomaly.peer_scope
existing.evidence = anomaly.evidence
existing.new_origin_asn = anomaly.new_origin_asn
existing.origin_asn = anomaly.origin_asn
refreshed_anomalies.append(existing)
continue
db.add(anomaly)
created_anomalies.append(anomaly)
created += 1
if created or refreshed_anomalies:
await db.commit()
incident_seed_anomalies = [*created_anomalies, *refreshed_anomalies]
if incident_seed_anomalies:
await create_bgp_incidents_for_anomalies(
db,
source=source,
snapshot_id=snapshot_id,
task_id=task_id,
anomalies=incident_seed_anomalies,
)
return created

View File

@@ -0,0 +1,132 @@
"""BGPStream backfill collector."""
from __future__ import annotations
import asyncio
import json
import time
import urllib.parse
import urllib.request
from typing import Any
from app.services.collectors.base import BaseCollector
from app.services.collectors.bgp_common import (
create_bgp_anomalies_for_batch,
normalize_bgp_event,
save_bgp_observations_for_batch,
)
class BGPStreamBackfillCollector(BaseCollector):
name = "bgpstream_bgp"
priority = "P1"
module = "L3"
frequency_hours = 6
data_type = "bgp_rib"
fail_on_empty = True
async def fetch(self) -> list[dict[str, Any]]:
if not self._resolved_url:
raise RuntimeError("BGPStream URL is not configured")
return await asyncio.to_thread(self._fetch_resource_windows)
def _fetch_resource_windows(self) -> list[dict[str, Any]]:
end = int(time.time()) - 3600
start = end - 86400
params = [
("projects[]", "routeviews"),
("collectors[]", "route-views2"),
("types[]", "updates"),
("intervals[]", f"{start},{end}"),
]
url = f"{self._resolved_url}/data?{urllib.parse.urlencode(params)}"
request = urllib.request.Request(
url,
headers={"User-Agent": "Planet-Intelligence-System/1.0 (Python/collector)"},
)
with urllib.request.urlopen(request, timeout=30) as response:
body = json.loads(response.read().decode())
if body.get("error"):
raise RuntimeError(f"BGPStream broker error: {body['error']}")
return body.get("data", {}).get("resources", [])
def transform(self, raw_data: list[dict[str, Any]]) -> list[dict[str, Any]]:
transformed: list[dict[str, Any]] = []
for item in raw_data:
if not isinstance(item, dict):
continue
is_broker_window = any(key in item for key in ("filename", "url", "startTime", "start_time"))
if {"collector", "prefix"} <= set(item.keys()) and not is_broker_window:
transformed.append(normalize_bgp_event(item, project="bgpstream"))
continue
# Broker responses provide file windows rather than decoded events.
collector = item.get("collector") or item.get("project") or "bgpstream"
timestamp = item.get("time") or item.get("startTime") or item.get("start_time")
name = item.get("filename") or item.get("url") or f"{collector}-window"
normalized = normalize_bgp_event(
{
"collector": collector,
"event_type": "rib",
"prefix": item.get("prefix") or "historical-window",
"timestamp": timestamp,
"origin_asn": item.get("origin_asn"),
"path": item.get("path") or [],
"raw_message": item,
},
project="bgpstream",
)
transformed.append(
normalized
| {
"name": name,
"title": f"BGPStream {collector}",
"description": "Historical BGPStream backfill window",
"metadata": {
**normalized["metadata"],
"broker_record": item,
},
}
)
self._latest_transformed_batch = transformed
return transformed
async def run(self, db):
result = await super().run(db)
if result.get("status") != "success":
return result
snapshot_id = await self._resolve_snapshot_id(db, result.get("task_id"))
observation_count = await save_bgp_observations_for_batch(
db,
source=self.name,
snapshot_id=snapshot_id,
task_id=result.get("task_id"),
events=getattr(self, "_latest_transformed_batch", []),
)
anomaly_count = await create_bgp_anomalies_for_batch(
db,
source=self.name,
snapshot_id=snapshot_id,
task_id=result.get("task_id"),
events=getattr(self, "_latest_transformed_batch", []),
)
result["observations_created"] = observation_count
result["anomalies_created"] = anomaly_count
return result
async def _resolve_snapshot_id(self, db, task_id: int | None) -> int | None:
if task_id is None:
return None
from sqlalchemy import select
from app.models.data_snapshot import DataSnapshot
result = await db.execute(
select(DataSnapshot.id).where(DataSnapshot.task_id == task_id).order_by(DataSnapshot.id.desc())
)
return result.scalar_one_or_none()

View File

@@ -8,6 +8,7 @@ import json
from typing import Dict, Any, List from typing import Dict, Any, List
import httpx import httpx
from app.core.satellite_tle import build_tle_lines_from_elements
from app.services.collectors.base import BaseCollector from app.services.collectors.base import BaseCollector
@@ -61,6 +62,17 @@ class CelesTrakTLECollector(BaseCollector):
def transform(self, raw_data: List[Dict[str, Any]]) -> List[Dict[str, Any]]: def transform(self, raw_data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
transformed = [] transformed = []
for item in raw_data: for item in raw_data:
tle_line1, tle_line2 = build_tle_lines_from_elements(
norad_cat_id=item.get("NORAD_CAT_ID"),
epoch=item.get("EPOCH"),
inclination=item.get("INCLINATION"),
raan=item.get("RA_OF_ASC_NODE"),
eccentricity=item.get("ECCENTRICITY"),
arg_of_perigee=item.get("ARG_OF_PERICENTER"),
mean_anomaly=item.get("MEAN_ANOMALY"),
mean_motion=item.get("MEAN_MOTION"),
)
transformed.append( transformed.append(
{ {
"name": item.get("OBJECT_NAME", "Unknown"), "name": item.get("OBJECT_NAME", "Unknown"),
@@ -80,6 +92,10 @@ class CelesTrakTLECollector(BaseCollector):
"mean_motion_dot": item.get("MEAN_MOTION_DOT"), "mean_motion_dot": item.get("MEAN_MOTION_DOT"),
"mean_motion_ddot": item.get("MEAN_MOTION_DDOT"), "mean_motion_ddot": item.get("MEAN_MOTION_DDOT"),
"ephemeris_type": item.get("EPHEMERIS_TYPE"), "ephemeris_type": item.get("EPHEMERIS_TYPE"),
# Prefer the original TLE lines when the source provides them.
# If they are missing, store a normalized TLE pair built once on the backend.
"tle_line1": item.get("TLE_LINE1") or tle_line1,
"tle_line2": item.get("TLE_LINE2") or tle_line2,
}, },
} }
) )

View File

@@ -10,7 +10,7 @@ Some endpoints require authentication for higher rate limits.
import asyncio import asyncio
import os import os
from typing import Dict, Any, List from typing import Dict, Any, List
from datetime import datetime from datetime import UTC, datetime
import httpx import httpx
from app.services.collectors.base import HTTPCollector from app.services.collectors.base import HTTPCollector
@@ -59,7 +59,7 @@ class CloudflareRadarDeviceCollector(HTTPCollector):
"other_percent": float(summary.get("other", 0)), "other_percent": float(summary.get("other", 0)),
"date_range": result.get("meta", {}).get("dateRange", {}), "date_range": result.get("meta", {}).get("dateRange", {}),
}, },
"reference_date": datetime.utcnow().isoformat(), "reference_date": datetime.now(UTC).isoformat(),
} }
data.append(entry) data.append(entry)
except (ValueError, TypeError, KeyError): except (ValueError, TypeError, KeyError):
@@ -107,7 +107,7 @@ class CloudflareRadarTrafficCollector(HTTPCollector):
"requests": item.get("requests"), "requests": item.get("requests"),
"visit_duration": item.get("visitDuration"), "visit_duration": item.get("visitDuration"),
}, },
"reference_date": item.get("datetime", datetime.utcnow().isoformat()), "reference_date": item.get("datetime", datetime.now(UTC).isoformat()),
} }
data.append(entry) data.append(entry)
except (ValueError, TypeError, KeyError): except (ValueError, TypeError, KeyError):
@@ -155,7 +155,7 @@ class CloudflareRadarTopASCollector(HTTPCollector):
"traffic_share": item.get("trafficShare"), "traffic_share": item.get("trafficShare"),
"country_code": item.get("location", {}).get("countryCode"), "country_code": item.get("location", {}).get("countryCode"),
}, },
"reference_date": datetime.utcnow().isoformat(), "reference_date": datetime.now(UTC).isoformat(),
} }
data.append(entry) data.append(entry)
except (ValueError, TypeError, KeyError): except (ValueError, TypeError, KeyError):

View File

@@ -0,0 +1,204 @@
"""Shared resumable download helpers for collectors."""
from __future__ import annotations
import hashlib
import json
import tempfile
import time
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Awaitable, Callable
import httpx
ProgressCallback = Callable[[int, int | None], Awaitable[None]]
ValidateCallback = Callable[[Path], bool]
class ResumableFileDownloader:
"""Download files with cache validators and byte-range resume support."""
def __init__(
self,
*,
cache_namespace: str,
user_agent: str = "Planet-Intelligence-System/1.0 (Python/collector)",
default_accept: str = "*/*",
) -> None:
self._cache_dir = Path(tempfile.gettempdir()) / "planet-download-cache" / cache_namespace
self._user_agent = user_agent
self._default_accept = default_accept
@staticmethod
def _cache_key(url: str) -> str:
return hashlib.sha1(url.encode("utf-8")).hexdigest()[:16]
def _cache_paths(self, url: str, extension: str) -> tuple[Path, Path, Path]:
key = self._cache_key(url)
normalized_ext = extension if extension.startswith(".") else f".{extension}"
final_path = self._cache_dir / f"{key}{normalized_ext}"
part_path = self._cache_dir / f"{key}{normalized_ext}.part"
meta_path = self._cache_dir / f"{key}.meta.json"
return final_path, part_path, meta_path
@staticmethod
def _load_meta(meta_path: Path) -> dict[str, Any]:
if not meta_path.exists():
return {}
try:
return json.loads(meta_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return {}
@staticmethod
def _save_meta(meta_path: Path, payload: dict[str, Any]) -> None:
meta_path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
@staticmethod
def _validators_match(meta: dict[str, Any], remote: dict[str, Any]) -> bool:
etag = str(remote.get("etag") or "").strip()
last_modified = str(remote.get("last_modified") or "").strip()
if etag:
return etag == str(meta.get("etag") or "").strip()
if last_modified:
return last_modified == str(meta.get("last_modified") or "").strip()
return True
async def fetch_remote_info(self, client: httpx.AsyncClient, url: str) -> dict[str, Any]:
try:
response = await client.head(url)
if response.status_code >= 400:
return {}
content_length_raw = response.headers.get("content-length")
content_length = int(content_length_raw) if content_length_raw else None
return {
"etag": response.headers.get("etag"),
"last_modified": response.headers.get("last-modified"),
"content_length": content_length,
"accept_ranges": (response.headers.get("accept-ranges") or "").lower(),
}
except (httpx.HTTPError, ValueError):
return {}
async def download_file(
self,
client: httpx.AsyncClient,
url: str,
*,
extension: str,
accept: str | None = None,
progress_callback: ProgressCallback | None = None,
validate_existing: ValidateCallback | None = None,
) -> Path:
self._cache_dir.mkdir(parents=True, exist_ok=True)
final_path, part_path, meta_path = self._cache_paths(url, extension)
meta = self._load_meta(meta_path)
remote = await self.fetch_remote_info(client, url)
expected_size = remote.get("content_length")
if final_path.exists():
local_size = final_path.stat().st_size
size_match = expected_size is None or local_size == expected_size
if self._validators_match(meta, remote) and size_match:
if validate_existing and not validate_existing(final_path):
final_path.unlink(missing_ok=True)
else:
if progress_callback and expected_size and expected_size > 0:
await progress_callback(expected_size, expected_size)
return final_path
can_resume = (remote.get("accept_ranges") or "") == "bytes"
resume_from = part_path.stat().st_size if part_path.exists() else 0
if expected_size is not None and resume_from > expected_size:
part_path.unlink(missing_ok=True)
resume_from = 0
if not self._validators_match(meta, remote):
part_path.unlink(missing_ok=True)
resume_from = 0
headers = {
"User-Agent": self._user_agent,
"Accept": accept or self._default_accept,
}
if final_path.exists():
if meta.get("etag"):
headers["If-None-Match"] = str(meta.get("etag"))
elif meta.get("last_modified"):
headers["If-Modified-Since"] = str(meta.get("last_modified"))
if can_resume and resume_from > 0:
headers["Range"] = f"bytes={resume_from}-"
if remote.get("etag"):
headers["If-Range"] = str(remote.get("etag"))
elif remote.get("last_modified"):
headers["If-Range"] = str(remote.get("last_modified"))
async with client.stream("GET", url, headers=headers) as response:
if response.status_code == 304 and final_path.exists():
if progress_callback and expected_size and expected_size > 0:
await progress_callback(expected_size, expected_size)
return final_path
response.raise_for_status()
if response.status_code == 206 and resume_from > 0:
mode = "ab"
else:
mode = "wb"
resume_from = 0
downloaded = resume_from
last_emit_bytes = 0
last_emit_time = time.monotonic()
min_emit_bytes = (
max(expected_size // 150, 512 * 1024) if expected_size and expected_size > 0 else 1024 * 1024
)
with part_path.open(mode) as f:
if progress_callback and downloaded > 0:
await progress_callback(downloaded, expected_size)
async for chunk in response.aiter_bytes():
if not chunk:
continue
f.write(chunk)
downloaded += len(chunk)
if not progress_callback:
continue
now = time.monotonic()
should_emit = (
expected_size is None
or downloaded >= expected_size
or downloaded - last_emit_bytes >= min_emit_bytes
or now - last_emit_time >= 2.0
)
if should_emit:
last_emit_bytes = downloaded
last_emit_time = now
await progress_callback(downloaded, expected_size)
final_size = part_path.stat().st_size if part_path.exists() else 0
if expected_size is not None and final_size != expected_size:
raise RuntimeError(
f"Resumable download incomplete for {url}: expected={expected_size}, got={final_size}"
)
part_path.replace(final_path)
self._save_meta(
meta_path,
{
"url": url,
"etag": remote.get("etag"),
"last_modified": remote.get("last_modified"),
"content_length": expected_size,
"updated_at": datetime.now(UTC).isoformat(),
},
)
if validate_existing and not validate_existing(final_path):
raise RuntimeError(f"Downloaded file validation failed for {url}")
if progress_callback and expected_size and expected_size > 0:
await progress_callback(expected_size, expected_size)
return final_path

View File

@@ -6,7 +6,7 @@ https://epoch.ai/data/gpu-clusters
import re import re
from typing import Dict, Any, List from typing import Dict, Any, List
from datetime import datetime from datetime import UTC, datetime
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
import httpx import httpx
@@ -64,7 +64,7 @@ class EpochAIGPUCollector(BaseCollector):
"metadata": { "metadata": {
"raw_data": perf_cell, "raw_data": perf_cell,
}, },
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"), "reference_date": datetime.now(UTC).strftime("%Y-%m-%d"),
} }
data.append(entry) data.append(entry)
except (ValueError, IndexError, AttributeError): except (ValueError, IndexError, AttributeError):
@@ -114,6 +114,6 @@ class EpochAIGPUCollector(BaseCollector):
"metadata": { "metadata": {
"note": "Sample data - Epoch AI page structure may vary", "note": "Sample data - Epoch AI page structure may vary",
}, },
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"), "reference_date": datetime.now(UTC).strftime("%Y-%m-%d"),
}, },
] ]

View File

@@ -4,7 +4,7 @@ Collects landing point data from FAO CSV API.
""" """
from typing import Dict, Any, List from typing import Dict, Any, List
from datetime import datetime from datetime import UTC, datetime
import httpx import httpx
from app.services.collectors.base import BaseCollector from app.services.collectors.base import BaseCollector
@@ -58,7 +58,7 @@ class FAOLandingPointCollector(BaseCollector):
"is_tbd": is_tbd, "is_tbd": is_tbd,
"original_id": feature_id, "original_id": feature_id,
}, },
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"), "reference_date": datetime.now(UTC).strftime("%Y-%m-%d"),
} }
result.append(entry) result.append(entry)
except (ValueError, IndexError): except (ValueError, IndexError):

View File

@@ -7,7 +7,7 @@ https://huggingface.co/spaces
""" """
from typing import Dict, Any, List from typing import Dict, Any, List
from datetime import datetime from datetime import UTC, datetime
from app.services.collectors.base import HTTPCollector from app.services.collectors.base import HTTPCollector
@@ -46,7 +46,7 @@ class HuggingFaceModelCollector(HTTPCollector):
"library_name": item.get("library_name"), "library_name": item.get("library_name"),
"created_at": item.get("createdAt"), "created_at": item.get("createdAt"),
}, },
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"), "reference_date": datetime.now(UTC).strftime("%Y-%m-%d"),
} }
data.append(entry) data.append(entry)
except (ValueError, TypeError, KeyError): except (ValueError, TypeError, KeyError):
@@ -87,7 +87,7 @@ class HuggingFaceDatasetCollector(HTTPCollector):
"tags": (item.get("tags", []) or [])[:10], "tags": (item.get("tags", []) or [])[:10],
"created_at": item.get("createdAt"), "created_at": item.get("createdAt"),
}, },
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"), "reference_date": datetime.now(UTC).strftime("%Y-%m-%d"),
} }
data.append(entry) data.append(entry)
except (ValueError, TypeError, KeyError): except (ValueError, TypeError, KeyError):
@@ -128,7 +128,7 @@ class HuggingFaceSpacesCollector(HTTPCollector):
"tags": (item.get("tags", []) or [])[:10], "tags": (item.get("tags", []) or [])[:10],
"created_at": item.get("createdAt"), "created_at": item.get("createdAt"),
}, },
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"), "reference_date": datetime.now(UTC).strftime("%Y-%m-%d"),
} }
data.append(entry) data.append(entry)
except (ValueError, TypeError, KeyError): except (ValueError, TypeError, KeyError):

View File

@@ -0,0 +1,207 @@
"""IPtoASN prefix geography collector.
Downloads the public combined IPv4+IPv6 TSV database and stores coarse
prefix-to-country/ASN geography hints for BGP enrichment.
"""
from __future__ import annotations
import asyncio
import gzip
import time
from datetime import UTC, datetime
from ipaddress import summarize_address_range, ip_address
from pathlib import Path
from typing import Any
import httpx
from app.services.collectors.base import BaseCollector
from app.services.collectors.downloads import ResumableFileDownloader
class IPtoASNPrefixGeoCollector(BaseCollector):
name = "iptoasn_prefix_geo"
priority = "P1"
module = "L3"
frequency_hours = 24
data_type = "prefix_geography"
fail_on_empty = True
_downloader = ResumableFileDownloader(
cache_namespace="iptoasn",
default_accept="application/gzip,application/octet-stream,*/*",
)
@staticmethod
def _build_dataset_urls(resolved_url: str) -> list[str]:
if "ip2asn-combined.tsv.gz" in resolved_url:
return [
resolved_url.replace("ip2asn-combined.tsv.gz", "ip2asn-v4.tsv.gz"),
resolved_url.replace("ip2asn-combined.tsv.gz", "ip2asn-v6.tsv.gz"),
]
return [resolved_url]
def _parse_rows_from_gzip_file(self, file_path: Path) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
with gzip.open(file_path, "rt", encoding="utf-8", errors="replace") as f:
for raw_line in f:
line = raw_line.strip()
if not line or line.startswith("#"):
continue
parts = line.split("\t")
if len(parts) < 5:
continue
range_start, range_end, asn, country_code, as_name = parts[:5]
rows.append(
{
"range_start": range_start,
"range_end": range_end,
"asn": asn,
"country_code": country_code,
"as_name": as_name,
}
)
if not rows:
raise RuntimeError(f"IPtoASN dataset parsed empty rows: {file_path.name}")
return rows
async def _fetch_dataset_rows(
self,
client: httpx.AsyncClient,
url: str,
*,
progress_callback=None,
) -> list[dict[str, Any]]:
file_path = await self._downloader.download_file(
client,
url,
extension=".tsv.gz",
progress_callback=progress_callback,
validate_existing=lambda p: self._validate_gzip_dataset(p),
)
return self._parse_rows_from_gzip_file(file_path)
def _validate_gzip_dataset(self, file_path: Path) -> bool:
try:
self._parse_rows_from_gzip_file(file_path)
return True
except Exception:
return False
async def fetch(self) -> list[dict[str, Any]]:
if not self._resolved_url:
raise RuntimeError("IPtoASN combined URL is not configured")
dataset_urls = self._build_dataset_urls(self._resolved_url)
async with httpx.AsyncClient(timeout=180.0, follow_redirects=True) as client:
remote_infos = await asyncio.gather(
*(self._downloader.fetch_remote_info(client, url) for url in dataset_urls)
)
expected_sizes = [
info.get("content_length")
for info in remote_infos
if isinstance(info.get("content_length"), int)
]
total_expected = sum(expected_sizes) if expected_sizes else 0
if total_expected > 0 and self._current_task and self._db_session:
self._current_task.total_records = total_expected
self._current_task.records_processed = 0
self._current_task.progress = 0.0
await self._db_session.commit()
await self._publish_task_update(force=True)
url_progress: dict[str, int] = {url: 0 for url in dataset_urls}
progress_lock = asyncio.Lock()
last_emit = {"t": 0.0, "value": 0}
min_emit_bytes = max(total_expected // 200, 2 * 1024 * 1024) if total_expected > 0 else 4 * 1024 * 1024
async def on_url_progress(url: str, downloaded_bytes: int, total_bytes: int | None) -> None:
if total_expected <= 0:
return
async with progress_lock:
current = max(0, downloaded_bytes)
if current < url_progress[url]:
return
url_progress[url] = current
aggregated = sum(url_progress.values())
now = time.monotonic()
should_emit = (
aggregated >= total_expected
or aggregated - last_emit["value"] >= min_emit_bytes
or now - last_emit["t"] >= 2.0
)
if not should_emit:
return
last_emit["value"] = aggregated
last_emit["t"] = now
await self.update_progress(min(aggregated, total_expected), commit=True)
batches = await asyncio.gather(
*(
self._fetch_dataset_rows(
client,
url,
progress_callback=lambda downloaded, total, u=url: on_url_progress(u, downloaded, total),
)
for url in dataset_urls
)
)
if total_expected > 0:
await self.update_progress(total_expected, commit=True, force=True)
rows: list[dict[str, Any]] = []
for batch in batches:
rows.extend(batch)
return rows
def transform(self, raw_data: list[dict[str, Any]]) -> list[dict[str, Any]]:
reference_date = datetime.now(UTC).isoformat()
transformed: list[dict[str, Any]] = []
for item in raw_data:
try:
start_ip = ip_address(str(item["range_start"]))
end_ip = ip_address(str(item["range_end"]))
except ValueError:
continue
if start_ip.version != end_ip.version:
continue
summarized = list(summarize_address_range(start_ip, end_ip))
primary_prefix = str(summarized[0]) if summarized else f"{start_ip}/{32 if start_ip.version == 4 else 128}"
family = f"ipv{start_ip.version}"
asn_value = item.get("asn")
try:
normalized_asn = int(str(asn_value))
except (TypeError, ValueError):
normalized_asn = None
transformed.append(
{
"source_id": f"{family}:{item['range_start']}-{item['range_end']}",
"name": primary_prefix,
"title": f"{primary_prefix} {item.get('country_code', '').strip()}".strip(),
"country": item.get("country_code"),
"city": "",
"latitude": None,
"longitude": None,
"metadata": {
"family": family,
"range_start": item["range_start"],
"range_end": item["range_end"],
"prefix": primary_prefix,
"prefixes": [str(prefix) for prefix in summarized[:8]],
"range_prefix_count": len(summarized),
"country_code": item.get("country_code"),
"asn": normalized_asn,
"as_name": item.get("as_name"),
"source_dataset": "iptoasn_combined",
},
"reference_date": reference_date,
}
)
return transformed

View File

@@ -0,0 +1,152 @@
"""NRO delegated stats prefix geography collector.
Parses the delegated extended/statistics file and stores coarse registry
allocation geography as prefix-centric fallback hints.
"""
from __future__ import annotations
import ipaddress
from datetime import UTC, datetime
from typing import Any
import httpx
from app.services.collectors.base import BaseCollector
from app.services.collectors.downloads import ResumableFileDownloader
class NRODelegatedPrefixGeoCollector(BaseCollector):
name = "nro_delegated_prefix_geo"
priority = "P1"
module = "L3"
frequency_hours = 24
data_type = "prefix_geography"
fail_on_empty = True
_downloader = ResumableFileDownloader(
cache_namespace="nro",
default_accept="text/plain,*/*",
)
async def fetch(self) -> list[dict[str, Any]]:
if not self._resolved_url:
raise RuntimeError("NRO delegated stats URL is not configured")
async with httpx.AsyncClient(timeout=180.0, follow_redirects=True) as client:
remote = await self._downloader.fetch_remote_info(client, self._resolved_url)
total_expected = remote.get("content_length") or 0
if total_expected > 0 and self._current_task and self._db_session:
self._current_task.total_records = total_expected
self._current_task.records_processed = 0
self._current_task.progress = 0.0
await self._db_session.commit()
await self._publish_task_update(force=True)
async def on_progress(downloaded: int, total: int | None) -> None:
if not total or total <= 0:
return
await self.update_progress(min(downloaded, total), commit=True)
body_path = await self._downloader.download_file(
client,
self._resolved_url,
extension=".txt",
progress_callback=on_progress,
)
body = body_path.read_text(encoding="utf-8", errors="replace")
rows: list[dict[str, Any]] = []
for raw_line in body.splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
parts = line.split("|")
if len(parts) < 7:
continue
rir = (parts[0] or "").strip().lower()
country_code = (parts[1] or "").strip().upper()
record_type = (parts[2] or "").strip().lower()
start = (parts[3] or "").strip()
value = (parts[4] or "").strip()
allocated_date = (parts[5] or "").strip()
status = (parts[6] or "").strip().lower()
if record_type not in {"ipv4", "ipv6"}:
continue
if not start or not value:
continue
rows.append(
{
"rir": rir,
"country_code": country_code,
"type": record_type,
"start": start,
"value": value,
"allocated_date": allocated_date,
"status": status,
}
)
return rows
def transform(self, raw_data: list[dict[str, Any]]) -> list[dict[str, Any]]:
reference_date = datetime.now(UTC).isoformat()
transformed: list[dict[str, Any]] = []
for item in raw_data:
record_type = str(item.get("type") or "").strip().lower()
start = str(item.get("start") or "").strip()
value = str(item.get("value") or "").strip()
country_code = str(item.get("country_code") or "").strip().upper()
try:
if record_type == "ipv4":
start_ip = ipaddress.ip_address(start)
count = int(value)
if count <= 0:
continue
end_ip_int = int(start_ip) + count - 1
end_ip = ipaddress.ip_address(end_ip_int)
network = list(ipaddress.summarize_address_range(start_ip, end_ip))[0]
elif record_type == "ipv6":
prefixlen = int(value)
network = ipaddress.ip_network(f"{start}/{prefixlen}", strict=False)
start_ip = network.network_address
end_ip = network.broadcast_address
else:
continue
except (ValueError, TypeError):
continue
family = f"ipv{network.version}"
prefix = str(network)
transformed.append(
{
"source_id": f"{item.get('rir')}:{family}:{prefix}:{country_code}",
"name": prefix,
"title": f"{prefix} {country_code}".strip(),
"country": country_code,
"city": "",
"latitude": None,
"longitude": None,
"metadata": {
"family": family,
"prefix": prefix,
"range_start": str(start_ip),
"range_end": str(end_ip),
"country_code": country_code,
"rir": item.get("rir"),
"status": item.get("status"),
"allocated_date": item.get("allocated_date"),
"source_dataset": "nro_delegated_stats",
"confidence": "registry_allocated",
},
"reference_date": reference_date,
}
)
return transformed

View File

@@ -0,0 +1,135 @@
"""OpenGeoFeed prefix geography collector.
Fetches public OpenGeoFeed CSV data and stores higher-confidence
prefix-to-location hints for BGP prefix-centric enrichment.
"""
from __future__ import annotations
import csv
import ipaddress
from datetime import UTC, datetime
from typing import Any
import httpx
from app.services.collectors.base import BaseCollector
from app.services.collectors.downloads import ResumableFileDownloader
class OpenGeoFeedPrefixGeoCollector(BaseCollector):
name = "opengeofeed_prefix_geo"
priority = "P1"
module = "L3"
frequency_hours = 24
data_type = "prefix_geography"
fail_on_empty = True
_downloader = ResumableFileDownloader(
cache_namespace="opengeofeed",
default_accept="text/csv,*/*",
)
async def fetch(self) -> list[dict[str, Any]]:
if not self._resolved_url:
raise RuntimeError("OpenGeoFeed URL is not configured")
async with httpx.AsyncClient(timeout=180.0, follow_redirects=True) as client:
remote = await self._downloader.fetch_remote_info(client, self._resolved_url)
total_expected = remote.get("content_length") or 0
if total_expected > 0 and self._current_task and self._db_session:
self._current_task.total_records = total_expected
self._current_task.records_processed = 0
self._current_task.progress = 0.0
await self._db_session.commit()
await self._publish_task_update(force=True)
async def on_progress(downloaded: int, total: int | None) -> None:
if not total or total <= 0:
return
await self.update_progress(min(downloaded, total), commit=True)
body_path = await self._downloader.download_file(
client,
self._resolved_url,
extension=".csv",
progress_callback=on_progress,
)
body = body_path.read_text(encoding="utf-8", errors="replace")
rows: list[dict[str, Any]] = []
reader = csv.reader(body.splitlines())
for fields in reader:
if not fields:
continue
first = (fields[0] or "").strip().lower()
if not first or first.startswith("#") or first == "prefix":
continue
prefix = (fields[0] or "").strip()
country_code = (fields[1] if len(fields) > 1 else "").strip()
region = (fields[2] if len(fields) > 2 else "").strip()
city = (fields[3] if len(fields) > 3 else "").strip()
postal_code = (fields[4] if len(fields) > 4 else "").strip()
# Keep additional columns for future enrichment without breaking
# current normalized schema.
extras = [value.strip() for value in fields[5:]] if len(fields) > 5 else []
rows.append(
{
"prefix": prefix,
"country_code": country_code,
"region": region,
"city": city,
"postal_code": postal_code,
"extra_columns": extras,
}
)
return rows
def transform(self, raw_data: list[dict[str, Any]]) -> list[dict[str, Any]]:
reference_date = datetime.now(UTC).isoformat()
transformed: list[dict[str, Any]] = []
for item in raw_data:
prefix = str(item.get("prefix") or "").strip()
if not prefix:
continue
try:
network = ipaddress.ip_network(prefix, strict=False)
except ValueError:
continue
family = f"ipv{network.version}"
country_code = str(item.get("country_code") or "").strip().upper()
region = str(item.get("region") or "").strip()
city = str(item.get("city") or "").strip()
postal_code = str(item.get("postal_code") or "").strip()
transformed.append(
{
"source_id": f"{family}:{prefix}:{country_code}:{region}:{city}",
"name": prefix,
"title": f"{prefix} {country_code}".strip(),
"country": country_code,
"city": city,
"latitude": None,
"longitude": None,
"metadata": {
"family": family,
"prefix": prefix,
"range_start": str(network.network_address),
"range_end": str(network.broadcast_address),
"country_code": country_code,
"region": region,
"city": city,
"postal_code": postal_code,
"extra_columns": item.get("extra_columns") or [],
"source_dataset": "opengeofeed_public",
"confidence": "geofeed",
},
"reference_date": reference_date,
}
)
return transformed

View File

@@ -13,7 +13,7 @@ To get higher limits, set PEERINGDB_API_KEY environment variable.
import asyncio import asyncio
import os import os
from typing import Dict, Any, List from typing import Dict, Any, List
from datetime import datetime from datetime import UTC, datetime
import httpx import httpx
from app.services.collectors.base import HTTPCollector from app.services.collectors.base import HTTPCollector
@@ -106,7 +106,7 @@ class PeeringDBIXPCollector(HTTPCollector):
"created": item.get("created"), "created": item.get("created"),
"updated": item.get("updated"), "updated": item.get("updated"),
}, },
"reference_date": datetime.utcnow().isoformat(), "reference_date": datetime.now(UTC).isoformat(),
} }
data.append(entry) data.append(entry)
except (ValueError, TypeError, KeyError): except (ValueError, TypeError, KeyError):
@@ -209,7 +209,7 @@ class PeeringDBNetworkCollector(HTTPCollector):
"created": item.get("created"), "created": item.get("created"),
"updated": item.get("updated"), "updated": item.get("updated"),
}, },
"reference_date": datetime.utcnow().isoformat(), "reference_date": datetime.now(UTC).isoformat(),
} }
data.append(entry) data.append(entry)
except (ValueError, TypeError, KeyError): except (ValueError, TypeError, KeyError):
@@ -311,7 +311,7 @@ class PeeringDBFacilityCollector(HTTPCollector):
"created": item.get("created"), "created": item.get("created"),
"updated": item.get("updated"), "updated": item.get("updated"),
}, },
"reference_date": datetime.utcnow().isoformat(), "reference_date": datetime.now(UTC).isoformat(),
} }
data.append(entry) data.append(entry)
except (ValueError, TypeError, KeyError): except (ValueError, TypeError, KeyError):

View File

@@ -0,0 +1,143 @@
"""RIPE RIS Live collector."""
from __future__ import annotations
import asyncio
import json
import urllib.request
from typing import Any
from app.services.collectors.base import BaseCollector
from app.services.collectors.bgp_common import (
create_bgp_anomalies_for_batch,
normalize_bgp_event,
save_bgp_observations_for_batch,
)
class RISLiveCollector(BaseCollector):
name = "ris_live_bgp"
priority = "P1"
module = "L3"
frequency_hours = 1
data_type = "bgp_update"
fail_on_empty = True
max_messages = 100
idle_timeout_seconds = 15
async def fetch(self) -> list[dict[str, Any]]:
if not self._resolved_url:
raise RuntimeError("RIS Live URL is not configured")
return await asyncio.to_thread(self._fetch_via_stream)
def _fetch_via_stream(self) -> list[dict[str, Any]]:
events: list[dict[str, Any]] = []
stream_url = "https://ris-live.ripe.net/v1/stream/?format=json&client=planet-ris-live"
subscribe = json.dumps(
{
"host": "rrc00",
"type": "UPDATE",
"require": "announcements",
}
)
request = urllib.request.Request(
stream_url,
headers={"X-RIS-Subscribe": subscribe},
)
with urllib.request.urlopen(request, timeout=20) as response:
while len(events) < self.max_messages:
line = response.readline().decode().strip()
if not line:
break
payload = json.loads(line)
if payload.get("type") != "ris_message":
continue
data = payload.get("data", {})
if isinstance(data, dict):
events.append(data)
return events
def transform(self, raw_data: list[dict[str, Any]]) -> list[dict[str, Any]]:
transformed: list[dict[str, Any]] = []
for item in raw_data:
announcements = item.get("announcements") or []
withdrawals = item.get("withdrawals") or []
for announcement in announcements:
next_hop = announcement.get("next_hop")
for prefix in announcement.get("prefixes") or []:
transformed.append(
normalize_bgp_event(
{
**item,
"collector": item.get("host", "").replace(".ripe.net", ""),
"event_type": "announcement",
"prefix": prefix,
"next_hop": next_hop,
},
project="ris-live",
)
)
for prefix in withdrawals:
transformed.append(
normalize_bgp_event(
{
**item,
"collector": item.get("host", "").replace(".ripe.net", ""),
"event_type": "withdrawal",
"prefix": prefix,
},
project="ris-live",
)
)
if not announcements and not withdrawals:
transformed.append(
normalize_bgp_event(
{
**item,
"collector": item.get("host", "").replace(".ripe.net", ""),
},
project="ris-live",
)
)
self._latest_transformed_batch = transformed
return transformed
async def run(self, db):
result = await super().run(db)
if result.get("status") != "success":
return result
snapshot_id = await self._resolve_snapshot_id(db, result.get("task_id"))
observation_count = await save_bgp_observations_for_batch(
db,
source=self.name,
snapshot_id=snapshot_id,
task_id=result.get("task_id"),
events=getattr(self, "_latest_transformed_batch", []),
)
anomaly_count = await create_bgp_anomalies_for_batch(
db,
source=self.name,
snapshot_id=snapshot_id,
task_id=result.get("task_id"),
events=getattr(self, "_latest_transformed_batch", []),
)
result["observations_created"] = observation_count
result["anomalies_created"] = anomaly_count
return result
async def _resolve_snapshot_id(self, db, task_id: int | None) -> int | None:
if task_id is None:
return None
from sqlalchemy import select
from app.models.data_snapshot import DataSnapshot
result = await db.execute(
select(DataSnapshot.id).where(DataSnapshot.task_id == task_id).order_by(DataSnapshot.id.desc())
)
return result.scalar_one_or_none()

View File

@@ -10,6 +10,7 @@ import httpx
from app.services.collectors.base import BaseCollector from app.services.collectors.base import BaseCollector
from app.core.data_sources import get_data_sources_config from app.core.data_sources import get_data_sources_config
from app.core.satellite_tle import build_tle_lines_from_elements
class SpaceTrackTLECollector(BaseCollector): class SpaceTrackTLECollector(BaseCollector):
@@ -169,25 +170,41 @@ class SpaceTrackTLECollector(BaseCollector):
"""Transform TLE data to internal format""" """Transform TLE data to internal format"""
transformed = [] transformed = []
for item in raw_data: for item in raw_data:
tle_line1, tle_line2 = build_tle_lines_from_elements(
norad_cat_id=item.get("NORAD_CAT_ID"),
epoch=item.get("EPOCH"),
inclination=item.get("INCLINATION"),
raan=item.get("RAAN"),
eccentricity=item.get("ECCENTRICITY"),
arg_of_perigee=item.get("ARG_OF_PERIGEE"),
mean_anomaly=item.get("MEAN_ANOMALY"),
mean_motion=item.get("MEAN_MOTION"),
)
transformed.append( transformed.append(
{ {
"name": item.get("OBJECT_NAME", "Unknown"), "name": item.get("OBJECT_NAME", "Unknown"),
"norad_cat_id": item.get("NORAD_CAT_ID"), "reference_date": item.get("EPOCH", ""),
"international_designator": item.get("INTL_DESIGNATOR"), "metadata": {
"epoch": item.get("EPOCH"), "norad_cat_id": item.get("NORAD_CAT_ID"),
"mean_motion": item.get("MEAN_MOTION"), "international_designator": item.get("INTL_DESIGNATOR"),
"eccentricity": item.get("ECCENTRICITY"), "epoch": item.get("EPOCH"),
"inclination": item.get("INCLINATION"), "mean_motion": item.get("MEAN_MOTION"),
"raan": item.get("RAAN"), "eccentricity": item.get("ECCENTRICITY"),
"arg_of_perigee": item.get("ARG_OF_PERIGEE"), "inclination": item.get("INCLINATION"),
"mean_anomaly": item.get("MEAN_ANOMALY"), "raan": item.get("RAAN"),
"ephemeris_type": item.get("EPHEMERIS_TYPE"), "arg_of_perigee": item.get("ARG_OF_PERIGEE"),
"classification_type": item.get("CLASSIFICATION_TYPE"), "mean_anomaly": item.get("MEAN_ANOMALY"),
"element_set_no": item.get("ELEMENT_SET_NO"), "ephemeris_type": item.get("EPHEMERIS_TYPE"),
"rev_at_epoch": item.get("REV_AT_EPOCH"), "classification_type": item.get("CLASSIFICATION_TYPE"),
"bstar": item.get("BSTAR"), "element_set_no": item.get("ELEMENT_SET_NO"),
"mean_motion_dot": item.get("MEAN_MOTION_DOT"), "rev_at_epoch": item.get("REV_AT_EPOCH"),
"mean_motion_ddot": item.get("MEAN_MOTION_DDOT"), "bstar": item.get("BSTAR"),
"mean_motion_dot": item.get("MEAN_MOTION_DOT"),
"mean_motion_ddot": item.get("MEAN_MOTION_DDOT"),
# Prefer original lines from the source, but keep a backend-built pair as a stable fallback.
"tle_line1": item.get("TLE_LINE1") or item.get("TLE1") or tle_line1,
"tle_line2": item.get("TLE_LINE2") or item.get("TLE2") or tle_line2,
},
} }
) )
return transformed return transformed

View File

@@ -7,7 +7,7 @@ Uses Wayback Machine as backup data source since live data requires JavaScript r
import json import json
import re import re
from typing import Dict, Any, List from typing import Dict, Any, List
from datetime import datetime from datetime import UTC, datetime
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
import httpx import httpx
@@ -103,7 +103,7 @@ class TeleGeographyCableCollector(BaseCollector):
"capacity_tbps": item.get("capacity"), "capacity_tbps": item.get("capacity"),
"url": item.get("url"), "url": item.get("url"),
}, },
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"), "reference_date": datetime.now(UTC).strftime("%Y-%m-%d"),
} }
result.append(entry) result.append(entry)
except (ValueError, TypeError, KeyError): except (ValueError, TypeError, KeyError):
@@ -131,7 +131,7 @@ class TeleGeographyCableCollector(BaseCollector):
"owner": "Meta, Orange, Vodafone, etc.", "owner": "Meta, Orange, Vodafone, etc.",
"status": "active", "status": "active",
}, },
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"), "reference_date": datetime.now(UTC).strftime("%Y-%m-%d"),
}, },
{ {
"source_id": "telegeo_sample_2", "source_id": "telegeo_sample_2",
@@ -147,7 +147,7 @@ class TeleGeographyCableCollector(BaseCollector):
"owner": "Alibaba, NEC", "owner": "Alibaba, NEC",
"status": "planned", "status": "planned",
}, },
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"), "reference_date": datetime.now(UTC).strftime("%Y-%m-%d"),
}, },
] ]
@@ -187,7 +187,7 @@ class TeleGeographyLandingPointCollector(BaseCollector):
"cable_count": len(item.get("cables", [])), "cable_count": len(item.get("cables", [])),
"url": item.get("url"), "url": item.get("url"),
}, },
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"), "reference_date": datetime.now(UTC).strftime("%Y-%m-%d"),
} }
result.append(entry) result.append(entry)
except (ValueError, TypeError, KeyError): except (ValueError, TypeError, KeyError):
@@ -211,7 +211,7 @@ class TeleGeographyLandingPointCollector(BaseCollector):
"value": "", "value": "",
"unit": "", "unit": "",
"metadata": {"note": "Sample data"}, "metadata": {"note": "Sample data"},
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"), "reference_date": datetime.now(UTC).strftime("%Y-%m-%d"),
}, },
] ]
@@ -258,7 +258,7 @@ class TeleGeographyCableSystemCollector(BaseCollector):
"investment": item.get("investment"), "investment": item.get("investment"),
"url": item.get("url"), "url": item.get("url"),
}, },
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"), "reference_date": datetime.now(UTC).strftime("%Y-%m-%d"),
} }
result.append(entry) result.append(entry)
except (ValueError, TypeError, KeyError): except (ValueError, TypeError, KeyError):
@@ -282,6 +282,6 @@ class TeleGeographyCableSystemCollector(BaseCollector):
"value": "5000", "value": "5000",
"unit": "km", "unit": "km",
"metadata": {"note": "Sample data"}, "metadata": {"note": "Sample data"},
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"), "reference_date": datetime.now(UTC).strftime("%Y-%m-%d"),
}, },
] ]

View File

@@ -2,7 +2,7 @@
import asyncio import asyncio
import logging import logging
from datetime import datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler
@@ -10,6 +10,7 @@ from apscheduler.triggers.interval import IntervalTrigger
from sqlalchemy import select from sqlalchemy import select
from app.db.session import async_session_factory from app.db.session import async_session_factory
from app.core.time import to_iso8601_utc
from app.models.datasource import DataSource from app.models.datasource import DataSource
from app.models.task import CollectionTask from app.models.task import CollectionTask
from app.services.collectors.registry import collector_registry from app.services.collectors.registry import collector_registry
@@ -17,6 +18,7 @@ from app.services.collectors.registry import collector_registry
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
scheduler = AsyncIOScheduler() scheduler = AsyncIOScheduler()
RUNNING_TASK_GUARD_TIMEOUT_MINUTES = 90
async def _update_next_run_at(datasource: DataSource, session) -> None: async def _update_next_run_at(datasource: DataSource, session) -> None:
@@ -75,16 +77,64 @@ async def run_collector_task(collector_name: str):
logger.info("Skipping disabled collector: %s", collector_name) logger.info("Skipping disabled collector: %s", collector_name)
return return
running_result = await db.execute(
select(CollectionTask)
.where(
CollectionTask.datasource_id == datasource.id,
CollectionTask.status == "running",
)
.order_by(CollectionTask.started_at.desc(), CollectionTask.id.desc())
.limit(1)
)
existing_running = running_result.scalar_one_or_none()
if existing_running is not None:
now = datetime.now(UTC)
started_at = existing_running.started_at
if started_at is not None and started_at.tzinfo is None:
started_at = started_at.replace(tzinfo=UTC)
is_stale = (
started_at is not None
and (now - started_at) > timedelta(minutes=RUNNING_TASK_GUARD_TIMEOUT_MINUTES)
)
if not is_stale:
logger.warning(
"Skipping collector %s trigger because task %s is already running",
collector_name,
existing_running.id,
)
return
existing_error = (existing_running.error_message or "").strip()
stale_reason = (
f"Marked failed automatically after stale running timeout "
f"({RUNNING_TASK_GUARD_TIMEOUT_MINUTES}m) in scheduler guard"
)
existing_running.status = "failed"
existing_running.phase = "failed"
existing_running.completed_at = now
existing_running.error_message = (
f"{existing_error}\n{stale_reason}".strip()
if existing_error
else stale_reason
)
await db.commit()
logger.warning(
"Marked stale running task %s as failed before rerun of %s",
existing_running.id,
collector_name,
)
try: try:
collector._datasource_id = datasource.id collector._datasource_id = datasource.id
logger.info("Running collector: %s (datasource_id=%s)", collector_name, datasource.id) logger.info("Running collector: %s (datasource_id=%s)", collector_name, datasource.id)
task_result = await collector.run(db) task_result = await collector.run(db)
datasource.last_run_at = datetime.utcnow() datasource.last_run_at = datetime.now(UTC)
datasource.last_status = task_result.get("status") datasource.last_status = task_result.get("status")
await _update_next_run_at(datasource, db) await _update_next_run_at(datasource, db)
logger.info("Collector %s completed: %s", collector_name, task_result) logger.info("Collector %s completed: %s", collector_name, task_result)
except Exception as exc: except Exception as exc:
datasource.last_run_at = datetime.utcnow() datasource.last_run_at = datetime.now(UTC)
datasource.last_status = "failed" datasource.last_status = "failed"
await db.commit() await db.commit()
logger.exception("Collector %s failed: %s", collector_name, exc) logger.exception("Collector %s failed: %s", collector_name, exc)
@@ -92,7 +142,7 @@ async def run_collector_task(collector_name: str):
async def cleanup_stale_running_tasks(max_age_hours: int = 2) -> int: async def cleanup_stale_running_tasks(max_age_hours: int = 2) -> int:
"""Mark stale running tasks as failed after restarts or collector hangs.""" """Mark stale running tasks as failed after restarts or collector hangs."""
cutoff = datetime.utcnow() - timedelta(hours=max_age_hours) cutoff = datetime.now(UTC) - timedelta(hours=max_age_hours)
async with async_session_factory() as db: async with async_session_factory() as db:
result = await db.execute( result = await db.execute(
@@ -107,7 +157,7 @@ async def cleanup_stale_running_tasks(max_age_hours: int = 2) -> int:
for task in stale_tasks: for task in stale_tasks:
task.status = "failed" task.status = "failed"
task.phase = "failed" task.phase = "failed"
task.completed_at = datetime.utcnow() task.completed_at = datetime.now(UTC)
existing_error = (task.error_message or "").strip() existing_error = (task.error_message or "").strip()
cleanup_error = "Marked failed automatically after stale running task cleanup" cleanup_error = "Marked failed automatically after stale running task cleanup"
task.error_message = f"{existing_error}\n{cleanup_error}".strip() if existing_error else cleanup_error task.error_message = f"{existing_error}\n{cleanup_error}".strip() if existing_error else cleanup_error
@@ -167,7 +217,7 @@ def get_scheduler_jobs() -> list[Dict[str, Any]]:
{ {
"id": job.id, "id": job.id,
"name": job.name, "name": job.name,
"next_run_time": job.next_run_time.isoformat() if job.next_run_time else None, "next_run_time": to_iso8601_utc(job.next_run_time),
"trigger": str(job.trigger), "trigger": str(job.trigger),
} }
) )

View File

@@ -0,0 +1,178 @@
from __future__ import annotations
import json
import secrets
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
from app.core.config import ROOT_DIR
from app.core.security import redis_client
SYSTEM_TASK_TTL_SECONDS = 24 * 60 * 60
SYSTEM_TASK_LOG_LIMIT = 100
SYSTEM_TASK_ACTIVE_KEY = "system:restart_task:active"
SYSTEM_TASK_STALE_SECONDS = 5 * 60
ALLOWED_ACTIONS: dict[str, dict[str, Any]] = {
"restart-backend": {
"command": ["./planet.sh", "restart", "-b"],
"recovery_mode": "backend",
},
"restart-ai-provider": {
"command": ["./planet.sh", "restart", "-a"],
"recovery_mode": "ai-provider",
},
"restart-database": {
"command": ["./planet.sh", "restart", "-d"],
"recovery_mode": "database",
},
"restart-system": {
"command": ["./planet.sh", "restart"],
"recovery_mode": "system",
},
}
def utc_now_iso() -> str:
return datetime.now(UTC).isoformat()
def normalize_user_role(role: Any) -> str:
return role.value if hasattr(role, "value") else str(role)
def require_super_admin(user_role: Any) -> bool:
return normalize_user_role(user_role) == "super_admin"
def build_task_id(prefix: str = "restart") -> str:
timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
return f"{prefix}_{timestamp}_{secrets.token_hex(3)}"
def get_task_key(task_id: str) -> str:
return f"system:restart_task:{task_id}"
def get_task_logs_key(task_id: str) -> str:
return f"{get_task_key(task_id)}:logs"
def get_allowed_command(action: str) -> list[str] | None:
config = ALLOWED_ACTIONS.get(action)
if config is None:
return None
return list(config["command"])
def get_action_recovery_mode(action: str) -> str | None:
config = ALLOWED_ACTIONS.get(action)
if config is None:
return None
return str(config["recovery_mode"])
def serialize_task(task_id: str) -> dict[str, Any] | None:
payload = redis_client.hgetall(get_task_key(task_id))
if not payload:
return None
if payload.get("requested_by"):
try:
payload["requested_by"] = json.loads(payload["requested_by"])
except json.JSONDecodeError:
payload["requested_by"] = {"username": payload["requested_by"]}
return payload
def append_task_log(task_id: str, line: str) -> None:
logs_key = get_task_logs_key(task_id)
redis_client.rpush(logs_key, line)
redis_client.ltrim(logs_key, -SYSTEM_TASK_LOG_LIMIT, -1)
redis_client.expire(logs_key, SYSTEM_TASK_TTL_SECONDS)
def upsert_task_state(
task_id: str,
*,
action: str | None = None,
status: str,
stage: str,
message: str,
requested_by: dict[str, Any] | None = None,
) -> dict[str, Any]:
existing = serialize_task(task_id) or {}
now = utc_now_iso()
payload: dict[str, Any] = {
"task_id": task_id,
"action": action or existing.get("action") or "",
"status": status,
"stage": stage,
"message": message,
"created_at": existing.get("created_at") or now,
"updated_at": now,
}
if requested_by is not None:
payload["requested_by"] = requested_by
elif existing.get("requested_by") is not None:
payload["requested_by"] = existing["requested_by"]
redis_payload = {
key: json.dumps(value, ensure_ascii=False) if key == "requested_by" else str(value)
for key, value in payload.items()
if value is not None
}
task_key = get_task_key(task_id)
redis_client.hset(task_key, mapping=redis_payload)
redis_client.expire(task_key, SYSTEM_TASK_TTL_SECONDS)
return payload
def get_task_logs(task_id: str) -> list[str]:
return [str(item) for item in redis_client.lrange(get_task_logs_key(task_id), 0, -1)]
def get_active_task_id() -> str | None:
value = redis_client.get(SYSTEM_TASK_ACTIVE_KEY)
return str(value) if value else None
def set_active_task_id(task_id: str) -> None:
redis_client.set(SYSTEM_TASK_ACTIVE_KEY, task_id, ex=SYSTEM_TASK_TTL_SECONDS)
def clear_active_task_id(task_id: str) -> None:
current = get_active_task_id()
if current == task_id:
redis_client.delete(SYSTEM_TASK_ACTIVE_KEY)
def parse_task_timestamp(value: str | None) -> datetime | None:
if not value:
return None
try:
return datetime.fromisoformat(value)
except ValueError:
return None
def is_task_stale(task: dict[str, Any], *, max_age_seconds: int = SYSTEM_TASK_STALE_SECONDS) -> bool:
if task.get("status") not in {"queued", "running"}:
return False
updated_at = parse_task_timestamp(str(task.get("updated_at") or ""))
if updated_at is None:
return False
if updated_at.tzinfo is None:
updated_at = updated_at.replace(tzinfo=UTC)
return datetime.now(UTC) - updated_at > timedelta(seconds=max_age_seconds)
def get_runner_script_path() -> Path:
return ROOT_DIR / "backend" / "scripts" / "system_restart_runner.py"

View File

@@ -1,19 +0,0 @@
fastapi>=0.109.0
uvicorn[standard]>=0.27.0
sqlalchemy[asyncio]>=2.0.25
asyncpg>=0.29.0
redis>=5.0.1
pydantic>=2.5.0
pydantic-settings>=2.1.0
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
python-multipart>=0.0.6
httpx>=0.26.0
beautifulsoup4>=4.12.0
aiofiles>=23.2.1
python-dotenv>=1.0.0
email-validator
apscheduler>=3.10.4
pytest>=7.4.0
pytest-asyncio>=0.23.0
networkx>=3.0

View File

@@ -0,0 +1,184 @@
from __future__ import annotations
import argparse
import os
import subprocess
import sys
import time
from pathlib import Path
from urllib.error import URLError
from urllib.request import urlopen
ROOT_DIR = Path(__file__).resolve().parents[2]
BACKEND_DIR = ROOT_DIR / "backend"
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
from app.services.system_control import ( # noqa: E402
append_task_log,
clear_active_task_id,
get_allowed_command,
get_action_recovery_mode,
upsert_task_state,
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--task-id", required=True)
parser.add_argument("--action", required=True)
return parser.parse_args()
def wait_for_http(url: str, timeout_seconds: int = 90, interval_seconds: float = 2.0) -> bool:
deadline = time.time() + timeout_seconds
success_streak = 0
while time.time() < deadline:
try:
with urlopen(url, timeout=2) as response:
if response.status == 200:
success_streak += 1
if success_streak >= 2:
return True
else:
success_streak = 0
except URLError:
success_streak = 0
except Exception:
success_streak = 0
time.sleep(interval_seconds)
return False
def wait_for_recovery(action: str) -> tuple[bool, str]:
recovery_mode = get_action_recovery_mode(action)
if recovery_mode == "backend":
return wait_for_http("http://localhost:8000/health"), "backend health recovery"
if recovery_mode == "ai-provider":
return wait_for_http("http://localhost:8010/health"), "ai provider health recovery"
if recovery_mode == "database":
return True, "database container restart completion"
if recovery_mode == "system":
backend_ok = wait_for_http("http://localhost:8000/health")
frontend_ok = wait_for_http("http://localhost:3000")
return backend_ok and frontend_ok, "system service recovery"
return False, "unsupported recovery mode"
def main() -> int:
args = parse_args()
command = get_allowed_command(args.action)
recovery_mode = get_action_recovery_mode(args.action)
if command is None or recovery_mode is None:
upsert_task_state(
args.task_id,
action=args.action,
status="failed",
stage="failed",
message="Unsupported system action",
)
clear_active_task_id(args.task_id)
return 1
upsert_task_state(
args.task_id,
action=args.action,
status="running",
stage="spawning",
message="Spawning restart command",
)
append_task_log(args.task_id, f"accepted {args.action} request")
append_task_log(args.task_id, f"resolved command: {' '.join(command)}")
env = os.environ.copy()
env["PATH"] = f"{Path.home() / '.bun' / 'bin'}:{Path.home() / '.local' / 'bin'}:{env.get('PATH', '')}"
try:
upsert_task_state(
args.task_id,
action=args.action,
status="running",
stage="stopping",
message="Restart command is running",
)
append_task_log(args.task_id, "restart command started")
completed = subprocess.run(
command,
cwd=str(ROOT_DIR),
env=env,
capture_output=True,
text=True,
check=False,
)
if completed.stdout.strip():
for line in completed.stdout.strip().splitlines()[-20:]:
append_task_log(args.task_id, line)
if completed.stderr.strip():
for line in completed.stderr.strip().splitlines()[-20:]:
append_task_log(args.task_id, line)
if completed.returncode != 0:
upsert_task_state(
args.task_id,
action=args.action,
status="failed",
stage="failed",
message=f"Restart command failed with exit code {completed.returncode}",
)
clear_active_task_id(args.task_id)
return completed.returncode
upsert_task_state(
args.task_id,
action=args.action,
status="running",
stage="waiting_for_health",
message=f"Waiting for {recovery_mode} recovery",
)
append_task_log(args.task_id, f"waiting for {recovery_mode} recovery")
recovered, recovery_label = wait_for_recovery(args.action)
if recovered:
upsert_task_state(
args.task_id,
action=args.action,
status="succeeded",
stage="healthy",
message="Restart completed successfully",
)
append_task_log(args.task_id, f"{recovery_label} completed")
clear_active_task_id(args.task_id)
return 0
upsert_task_state(
args.task_id,
action=args.action,
status="timeout",
stage="failed",
message="Restart timed out waiting for recovery",
)
append_task_log(args.task_id, f"{recovery_label} timed out")
clear_active_task_id(args.task_id)
return 2
except Exception as exc:
append_task_log(args.task_id, f"runner exception: {exc}")
upsert_task_state(
args.task_id,
action=args.action,
status="failed",
stage="failed",
message=f"Restart runner failed: {exc}",
)
clear_active_task_id(args.task_id)
return 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -8,6 +8,9 @@ from httpx import AsyncClient, ASGITransport
from app.main import app from app.main import app
from app.core.config import settings from app.core.config import settings
from app.core.security import create_access_token from app.core.security import create_access_token
from app.db.session import get_db
from app.models.user import User
from app.schemas.ai import AIProviderStatusResponse, SituationalAnalysisResponse
@pytest.fixture @pytest.fixture
@@ -90,10 +93,58 @@ async def test_alerts_without_auth():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_alerts_endpoint_with_auth(auth_headers): async def test_alerts_endpoint_with_auth(auth_headers):
"""Test alerts endpoint with authentication""" """Test alerts endpoint with authentication"""
class _ScalarResult:
def __init__(self, rows=None, scalar_value=0):
self._rows = rows or []
self._scalar_value = scalar_value
def scalars(self):
class _Scalars:
def __init__(self, rows):
self._rows = rows
def all(self):
return self._rows
return _Scalars(self._rows)
def scalar(self):
return self._scalar_value
class _FakeAlertsSession:
def __init__(self):
self.calls = 0
async def execute(self, _query):
self.calls += 1
if self.calls == 1:
return _ScalarResult(rows=[])
return _ScalarResult(rows=[], scalar_value=0)
def override_get_current_user():
return User(
id=1,
username="testuser",
email="test@example.com",
password_hash="hashed",
role="admin",
is_active=True,
)
async def override_get_db():
yield _FakeAlertsSession()
app.dependency_overrides = {
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
get_db: override_get_db,
}
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client: try:
response = await client.get("/api/v1/alerts", headers=auth_headers) async with AsyncClient(transport=transport, base_url="http://test") as client:
assert response.status_code == 200 response = await client.get("/api/v1/alerts", headers=auth_headers)
assert response.status_code == 200
finally:
app.dependency_overrides.clear()
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -106,3 +157,89 @@ async def test_invalid_token():
headers={"Authorization": "Bearer invalid_token"}, headers={"Authorization": "Bearer invalid_token"},
) )
assert response.status_code == 401 assert response.status_code == 401
@pytest.mark.asyncio
async def test_ai_provider_status_with_auth(auth_headers):
"""Test AI provider status endpoint"""
class _FakeAIProviderClient:
async def get_status(self, request_id=None):
return AIProviderStatusResponse(
provider="openai_compatible",
enabled=True,
configured=True,
model="test-model",
base_url="http://aiprovider:8010",
)
def override_get_current_user():
return User(
id=1,
username="testuser",
email="test@example.com",
password_hash="hashed",
role="admin",
is_active=True,
)
app.dependency_overrides = {
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
__import__("app.services.ai_client", fromlist=["get_ai_provider_client"]).get_ai_provider_client: lambda: _FakeAIProviderClient(),
}
transport = ASGITransport(app=app)
try:
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/ai/provider/status", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert "provider" in data
assert "configured" in data
finally:
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_ai_situational_analysis_returns_503_when_disabled(auth_headers):
"""Test AI analysis endpoint proxies provider service response"""
class _FakeAIProviderClient:
async def analyze(self, _payload, request_id=None):
return SituationalAnalysisResponse(
provider="openai_compatible",
model="test-model",
content="1) 态势摘要: 测试返回",
raw_response={"id": "mock-response"},
)
def override_get_current_user():
return User(
id=1,
username="testuser",
email="test@example.com",
password_hash="hashed",
role="admin",
is_active=True,
)
app.dependency_overrides = {
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
__import__("app.services.ai_client", fromlist=["get_ai_provider_client"]).get_ai_provider_client: lambda: _FakeAIProviderClient(),
}
transport = ASGITransport(app=app)
try:
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/ai/situational-awareness/analyze",
headers=auth_headers,
json={
"title": "BGP 异常研判",
"objective": "给出当前异常的风险摘要和建议动作",
"observations": ["collector A 在 5 分钟内出现多个 origin 变更"],
"constraints": ["不要假设缺失数据"],
},
)
assert response.status_code == 200
data = response.json()
assert data["provider"] == "openai_compatible"
assert data["content"]
finally:
app.dependency_overrides.clear()

1399
backend/tests/test_bgp.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -46,48 +46,57 @@ class TestTOP500Collector:
def test_parse_response_empty(self): def test_parse_response_empty(self):
"""Test parsing empty response""" """Test parsing empty response"""
collector = TOP500Collector() collector = TOP500Collector()
result = collector.parse_response({"items": []}) result = collector.parse_response("<html><body><table></table></body></html>")
assert result == [] assert len(result) > 0
def test_parse_response_single_item(self): def test_parse_response_single_item(self):
"""Test parsing single item response""" """Test parsing single item response"""
collector = TOP500Collector() collector = TOP500Collector()
response = { response = """
"items": [ <table class="top500-table">
{ <tr><th>Rank</th><th>System</th><th>Cores</th><th>Rmax</th><th>Rpeak</th><th>Power</th></tr>
"rank": 1, <tr>
"system_name": "Test Supercomputer", <td>1</td>
"country": "USA", <td><a href="/system/1/">Test Supercomputer</a>, Test Corp\nTest Site\nUSA</td>
"city": "San Francisco", <td>100000</td>
"latitude": 37.7749, <td>100 PFLOP/s</td>
"longitude": -122.4194, <td>150 PFLOP/s</td>
"manufacturer": "Test Corp", <td>5000</td>
"r_max": 100000.0, </tr>
"r_peak": 150000.0, </table>
"power": 5000.0, """
"cores": 100000,
"interconnect": "InfiniBand",
"os": "Linux",
}
]
}
result = collector.parse_response(response) result = collector.parse_response(response)
assert len(result) == 1 assert len(result) == 1
assert result[0]["cluster_id"] == "top500_1" assert result[0]["source_id"] == "top500_1"
assert result[0]["name"] == "Test Supercomputer" assert result[0]["name"] == "Test Supercomputer"
assert result[0]["country"] == "USA" assert result[0]["country"] == "USA"
assert result[0]["rank"] == 1 assert result[0]["metadata"]["rank"] == 1
assert result[0]["source"] == "TOP500" assert "Test Corp" in result[0]["metadata"]["manufacturer"]
def test_parse_response_skips_invalid_item(self): def test_parse_response_skips_invalid_item(self):
"""Test parsing skips items with missing data""" """Test parsing skips items with missing data"""
collector = TOP500Collector() collector = TOP500Collector()
response = { response = """
"items": [ <table class="top500-table">
{"rank": 1, "system_name": "Valid"}, <tr><th>Rank</th><th>System</th><th>Cores</th><th>Rmax</th><th>Rpeak</th><th>Power</th></tr>
{"rank": None, "system_name": "Invalid"}, <tr>
] <td>1</td>
} <td>Valid\nVendor\nSite\nUSA</td>
<td>1000</td>
<td>10 PFLOP/s</td>
<td>12 PFLOP/s</td>
<td>100</td>
</tr>
<tr>
<td>-</td>
<td>Invalid</td>
<td>1000</td>
<td>10 PFLOP/s</td>
<td>12 PFLOP/s</td>
<td>100</td>
</tr>
</table>
"""
result = collector.parse_response(response) result = collector.parse_response(response)
assert len(result) == 1 assert len(result) == 1
assert result[0]["name"] == "Valid" assert result[0]["name"] == "Valid"
@@ -99,9 +108,9 @@ class TestHTTPCollector:
def test_http_collector_attributes(self): def test_http_collector_attributes(self):
"""Test HTTP collector has correct default attributes via concrete class""" """Test HTTP collector has correct default attributes via concrete class"""
collector = TOP500Collector() collector = TOP500Collector()
assert collector.base_url == "https://top500.org/api/v1.0/lists/"
assert collector.name == "top500" assert collector.name == "top500"
assert collector.priority == "P0" assert collector.priority == "P0"
assert hasattr(collector, "fetch")
def test_collector_has_required_methods(self): def test_collector_has_required_methods(self):
"""Test HTTP collector has required methods""" """Test HTTP collector has required methods"""

View File

@@ -81,7 +81,7 @@ class TestAlertModel:
assert result["severity"] == "critical" assert result["severity"] == "critical"
assert result["status"] == "active" assert result["status"] == "active"
assert result["message"] == "Critical alert" assert result["message"] == "Critical alert"
assert result["created_at"] == "2024-01-01T12:00:00" assert result["created_at"] == "2024-01-01T12:00:00Z"
def test_alert_severity_enum(self): def test_alert_severity_enum(self):
"""Test alert severity enum values""" """Test alert severity enum values"""

View File

@@ -72,11 +72,10 @@ class TestTokenCreation:
def test_access_token_expiration(self): def test_access_token_expiration(self):
"""Test access token has correct expiration""" """Test access token has correct expiration"""
data = {"sub": "123"} data = {"sub": "123"}
token = create_access_token(data) token = create_access_token(data, expires_delta=timedelta(minutes=15))
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
exp_timestamp = payload["exp"] exp_timestamp = payload["exp"]
# Token should expire in approximately 15 minutes (accounting for timezone) expected_minutes = 15
expected_minutes = settings.ACCESS_TOKEN_EXPIRE_MINUTES
# The timestamp is in seconds since epoch # The timestamp is in seconds since epoch
import time import time
@@ -89,12 +88,15 @@ class TestTokenCreation:
data = {"sub": "123"} data = {"sub": "123"}
token = create_refresh_token(data) token = create_refresh_token(data)
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
exp = datetime.fromtimestamp(payload["exp"]) if settings.REFRESH_TOKEN_EXPIRE_DAYS > 0:
now = datetime.utcnow() assert "exp" in payload
# Token should expire in approximately 7 days (with some tolerance) exp = datetime.fromtimestamp(payload["exp"])
delta = exp - now now = datetime.now()
assert delta.days >= 6 # At least 6 days delta = exp - now
assert delta.days <= 8 # Less than 8 days assert delta.days >= settings.REFRESH_TOKEN_EXPIRE_DAYS - 1
assert delta.days <= settings.REFRESH_TOKEN_EXPIRE_DAYS + 1
else:
assert "exp" not in payload
class TestJWTSecurity: class TestJWTSecurity:

View File

@@ -0,0 +1,42 @@
version: '3.8'
services:
ollama:
image: ollama/ollama:latest
container_name: planet_ollama
ports:
- "11434:11434"
volumes:
- ollama_data:/root/.ollama
healthcheck:
test: ["CMD", "ollama", "list"]
interval: 20s
timeout: 10s
retries: 10
aiprovider:
build:
context: .
dockerfile: aiprovider/Dockerfile
container_name: planet_aiprovider
ports:
- "8010:8010"
environment:
AI_PROVIDER: ollama
AI_BASE_URL: http://ollama:11434
AI_API_KEY: ""
AI_MODEL: qwen2.5:7b
AI_TIMEOUT_SECONDS: 60
AI_MAX_TOKENS: 1200
AI_PROVIDER_SERVICE_TOKEN: change_me
depends_on:
ollama:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8010/health"]
interval: 10s
timeout: 5s
retries: 5
volumes:
ollama_data:

View File

@@ -1,6 +1,14 @@
version: '3.8' version: '3.8'
services: services:
aiprovider:
build:
context: .
dockerfile: aiprovider/Dockerfile
container_name: planet_aiprovider
ports:
- "8010:8010"
postgres: postgres:
image: postgres:15 image: postgres:15
container_name: planet_postgres container_name: planet_postgres

View File

@@ -1,6 +1,19 @@
version: '3.8' version: '3.8'
services: services:
aiprovider:
build:
context: .
dockerfile: aiprovider/Dockerfile
container_name: planet_aiprovider
ports:
- "8010:8010"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8010/health"]
interval: 10s
timeout: 5s
retries: 5
postgres: postgres:
image: postgres:15 image: postgres:15
container_name: planet_postgres container_name: planet_postgres

699
docs/CHANGELOG.md Normal file
View File

@@ -0,0 +1,699 @@
# Changelog
All notable changes to `planet` are documented here.
This project follows the repository versioning rule:
- `feature` -> `+0.1.0`
- `bugfix` -> `+0.0.1`
## 0.23.0
Released: 2026-04-07
### Highlights
- Introduced a dedicated `aiprovider` service so the main backend now exposes stable AI business APIs while model-vendor integration lives behind an internal adapter boundary.
- Added multi-protocol model access for `openai-compatible`, `claude-compatible`, and native `ollama` local-model flows, including local startup templates and service-aware restart controls.
- Standardized Python runtime management on `uv` across backend and `aiprovider`, removing the old container-side `pip/requirements.txt` installation path.
### Added
- Added [backend/app/api/v1/ai.py](/home/ray/dev/linkong/planet/backend/app/api/v1/ai.py), exposing stable AI business endpoints for provider status and situational-awareness analysis.
- Added [backend/app/services/ai_client.py](/home/ray/dev/linkong/planet/backend/app/services/ai_client.py), introducing an internal HTTP client for `backend -> aiprovider` calls with request-id propagation and lightweight retry.
- Added [aiprovider/main.py](/home/ray/dev/linkong/planet/aiprovider/main.py), [aiprovider/provider_service.py](/home/ray/dev/linkong/planet/aiprovider/provider_service.py), and related config/schema files to stand up the dedicated adapter service.
- Added [aiprovider/.env.example](/home/ray/dev/linkong/planet/aiprovider/.env.example) and [docker-compose.local-model.yml](/home/ray/dev/linkong/planet/docker-compose.local-model.yml) as ready-to-edit local-model templates.
- Added [docs/aiprovider.md](/home/ray/dev/linkong/planet/docs/aiprovider.md), documenting architecture, configuration, single-machine and multi-machine deployment, and cross-service calling patterns.
- Added a dedicated `重启 AI Provider` control path in [Dashboard.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Dashboard/Dashboard.tsx), [system_control.py](/home/ray/dev/linkong/planet/backend/app/services/system_control.py), and [system_restart_runner.py](/home/ray/dev/linkong/planet/backend/scripts/system_restart_runner.py).
### Improved
- Improved backend-to-provider tracing by propagating `X-Request-ID` through the AI call chain and returning the same header from both backend and `aiprovider`.
- Improved resilience by adding lightweight retry handling to both `backend -> aiprovider` and `aiprovider -> model provider` HTTP calls.
- Improved operator workflow by folding `aiprovider` startup, health checks, restart support, and log viewing into [planet.sh](/home/ray/dev/linkong/planet/planet.sh).
- Improved container consistency by switching [backend/Dockerfile](/home/ray/dev/linkong/planet/backend/Dockerfile) and [aiprovider/Dockerfile](/home/ray/dev/linkong/planet/aiprovider/Dockerfile) to `uv sync` / `uv run`.
### Changed
- Changed the repository Python dependency source of truth to `pyproject.toml + uv.lock`, and removed the old `backend/requirements.txt` path.
- Changed local restart wording in the dashboard from the vague `重启服务器` label to the more specific `重启后端`, reducing ambiguity once `aiprovider` became independently restartable.
## 0.22.14
Released: 2026-04-07
### Highlights
- Completed the pending prefix-geography collector add-ons by shipping dedicated `OpenGeoFeed` and `NRO delegated stats` collectors.
- Finalized this slice as a bugfix release (`+0.0.1`) with version metadata synchronized across backend/frontend lockfiles.
### Added
- Added [opengeofeed.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/opengeofeed.py), introducing `opengeofeed_prefix_geo` ingestion for high-confidence geofeed-backed prefix geography overrides.
- Added [nro_delegated.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/nro_delegated.py), introducing `nro_delegated_prefix_geo` ingestion for registry-allocation fallback geography.
### Improved
- Improved collector registration wiring to include the new prefix-geography sources in the runtime collector registry and datasource catalog integration flow.
- Improved BGP roadmap traceability by aligning shipped collector capabilities with the staged prefix-geography strategy (override source + registry fallback source).
### Fixed
- Fixed repository drift where datasource mappings and enrichment priority chain referenced `OpenGeoFeed/NRO` source names before the corresponding collector modules were fully committed in-tree.
## 0.22.13
Released: 2026-04-07
### Highlights
- Added zoom-aware drag sensitivity on Earth interaction so drag distance naturally becomes finer when zoomed in and faster when zoomed out.
- Shipped as a focused bugfix release (`+0.0.1`) to improve operation feel without changing existing data/visual layers.
### Improved
- Improved [main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js) by replacing fixed drag rotation gain with a dynamic factor derived from current zoom level.
- Improved [main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js) by introducing `getDragRotationFactor()` so interaction tuning stays centralized instead of mixing formulas in pointer handlers.
- Improved [constants.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/constants.js) by adding explicit drag tuning knobs:
`dragRotationFactorBase`, `dragRotationScaleMin`, and `dragRotationScaleMax`.
### Fixed
- Fixed the previous interaction mismatch where drag traveled the same angular distance regardless of zoom level, making close-up inspection too jumpy and far-view browsing too slow.
- Fixed maintainability drift where drag sensitivity had a hardcoded literal in logic code instead of configurable constants.
## 0.22.12
Released: 2026-04-02
### Highlights
- Completed Earth `BGP / cables / landing points` visual-parameter consolidation so the latest overlap/size fixes are no longer scattered as magic numbers.
- Shipped a bugfix version bump for the constant-extraction and marker-size stabilization work (`+0.0.1`).
### Improved
- Improved [constants.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/constants.js) by introducing structured `BGP_CONFIG` groups (`marker`, `pulse`, `ring`, `halo`, `sizeStabilization`) and by extracting additional cable/landing-point render parameters into `CABLE_CONFIG`.
- Improved [bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) by fully switching BGP marker/ring/pulse logic to the new nested constants, including collector status-core scale floors and distance/FOV-based size-stabilization knobs.
- Improved [cables.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cables.js) by removing hardcoded landing-point visual constants and centralizing base scale, pulse, dimming, opacity, emissive, and stabilization thresholds.
- Improved [main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js) by passing `camera` through landing-point visual update/reset flows so stabilized sizing remains consistent during normal render-loop interactions.
- Improved planning continuity in [TODO.md](/home/ray/dev/linkong/planet/TODO.md) by documenting the optional `HTML marker` migration path for near-fixed screen-size BGP markers as a non-blocking follow-up.
### Fixed
- Fixed drift from earlier iterative tuning where BGP/cable size and animation behavior depended on multiple dispersed literals, which made overlap and readability regressions more likely during subsequent tweaks.
- Fixed a partially applied refactor state where some BGP collector/status-core scaling still bypassed shared config, preventing clean global tuning.
### Notes
- During this round, an over-tight marker-size clamp path was intentionally not kept; the final shipped config uses wider stabilization bounds so zoom-level response remains visible while still reducing overlap blow-up.
## 0.22.11
Released: 2026-04-02
### Highlights
- Cleaned up the most obvious BGP/Earth cleanup leftovers from the recent stabilization work without changing the current user-facing interaction model.
### Improved
- Improved [bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) by factoring the BGP Earth loading flow into smaller helpers, separating timed GeoJSON fetch fallback from `incident vs anomaly` render-mode selection.
- Improved [bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) by extracting shared feature parsing helpers so anomaly and incident marker preparation no longer duplicate coordinate, severity, and timestamp parsing logic.
- Improved [visualization.py](/home/ray/dev/linkong/planet/backend/app/api/v1/visualization.py) by consolidating repeated BGP evidence geography parsing into a shared helper used by both anomaly and incident geography-hint builders.
- Improved planning hygiene in [TODO.md](/home/ray/dev/linkong/planet/TODO.md) by recording the deferred `bgp.js` responsibility split as an explicit follow-up task instead of leaving the idea only in conversation context.
### Refined
- Refined [constants.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/constants.js) by removing stale Earth BGP configuration entries that were left behind after the floating event hub design was removed.
- Refined [bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) by removing no-longer-used arc helpers that only served the deleted off-surface hub/link route.
- Refined [visualization.py](/home/ray/dev/linkong/planet/backend/app/api/v1/visualization.py) by dropping an unused `_lookup_prefix_geography` import after the earlier rollback from expensive live prefix-geography visualization lookups.
### Fixed
- Fixed the codebase drift where Earth BGP cleanup patches had left behind dead constants, duplicate parsing branches, and fallback-loading glue that was harder to reason about than the now-stable runtime required.
## 0.22.10
Released: 2026-04-02
### Highlights
- Recovered the Earth-side BGP experience after a failed cache-busting / asset-loading refactor temporarily broke the globe runtime, removed textures, and made the BGP layer disappear when one backend endpoint timed out.
- Added a first usable `prefix_geography` data layer backed by `IPtoASN / IP-to-Country` ingestion so BGP geography can start moving away from pure collector-centric placement.
- Reworked BGP Earth rendering to keep collectors visible under degraded backend conditions, restore symbol-based incident markers, and split icon pulse from outward event-ring animation.
### Added
- Added a new `IPtoASN Prefix Geography` collector in [iptoasn.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/iptoasn.py) and registered it through [data_sources.yaml](/home/ray/dev/linkong/planet/backend/app/core/data_sources.yaml), [data_sources.py](/home/ray/dev/linkong/planet/backend/app/core/data_sources.py), [datasource_defaults.py](/home/ray/dev/linkong/planet/backend/app/core/datasource_defaults.py), and [collectors/__init__.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/__init__.py).
- Added country centroid helpers in [countries.py](/home/ray/dev/linkong/planet/backend/app/core/countries.py) so country-level prefix geography can produce map coordinates instead of only labels.
- Added a dedicated prefix-geography implementation note in [prefix-geography-plan.md](/home/ray/dev/linkong/planet/docs/prefix-geography-plan.md).
- Added recent `15m` collector activity dimensions to BGP coverage output in [bgp_collectors.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_collectors.py) and [visualization.py](/home/ray/dev/linkong/planet/backend/app/api/v1/visualization.py).
- Added additional BGP detector coverage for `route_leak_candidate` and `path_flap` flows in [test_bgp.py](/home/ray/dev/linkong/planet/backend/tests/test_bgp.py).
- Added a local Earth cloud texture at [earth_clouds_1024.png](/home/ray/dev/linkong/planet/frontend/public/earth/assets/earth_clouds_1024.png) to avoid remote cloud-map dependency failures.
### Improved
- Improved BGP enrichment in [bgp_enrichment.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_enrichment.py) so events now attach `prefix_geography`, `prefix_scope`, ASN profile context, and country-centroid-backed geography hints in one place.
- Improved incident aggregation in [bgp_incidents.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_incidents.py) so existing incidents refresh their regions and geography metadata instead of remaining pinned to stale first-generation evidence forever.
- Improved anomaly generation flow in [bgp_common.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/bgp_common.py) by cleaning up duplicate incident-seeding paths and only feeding newly created or refreshed anomalies forward.
- Improved Earth BGP loading in [bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) so collectors are now the mandatory baseline layer while anomalies and incidents can fail independently without blanking the whole BGP surface.
- Improved Earth BGP marker language in [bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) and [constants.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/constants.js) by restoring typed event symbols, reducing additive white blowout, and making the incident ring animation read as an outward pulse instead of a generic glow blob.
- Improved Earth event animation semantics in [bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) by separating icon pulse from ring expansion so the center marker can breathe while the ring expands independently.
- Improved Earth texture reliability in [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js) by switching clouds back to a local static asset under the restored `public/earth` runtime.
- Improved frontend boot noise in [frontend/index.html](/home/ray/dev/linkong/planet/frontend/index.html) by removing the default Vite favicon request that was generating irrelevant `vite.svg` timeouts during Earth debugging.
- Improved project planning docs in [bgp-context.md](/home/ray/dev/linkong/planet/docs/bgp-context.md) and [TODO.md](/home/ray/dev/linkong/planet/TODO.md) so the roadmap now explicitly prioritizes `activity layer`, `prefix-centric geography`, and follow-up geofeed/whois work.
### Fixed
- Fixed a failed Earth asset-versioning route where hand-applied cache-busting and a parallel Vite multi-entry experiment introduced duplicate module instances, broken `/earth` boot paths, missing textures, and severe runtime instability; the globe has now been restored to the stable `frontend/public/earth` runtime instead of the abandoned refactor path.
- Fixed Earth cloud and terrain loading regressions by restoring the old static Earth entrypoint and ensuring local cloud and 8K day-map assets resolve again from `public/earth/assets`.
- Fixed a full-layer BGP disappearance regression where `bgp-anomalies` or `bgp-incidents` timeouts caused the entire BGP layer to show `0` collectors and `0` events even though collector data still existed.
- Fixed `prefix_geography` lookups in [bgp_enrichment.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_enrichment.py) that previously failed because JSON metadata access mixed SQL column names and ORM property names.
- Fixed stale anomaly and incident geography reuse so pre-existing records can now absorb refreshed evidence instead of staying locked to older Amsterdam-centric geography forever.
- Fixed a wrong optimization path in [visualization.py](/home/ray/dev/linkong/planet/backend/app/api/v1/visualization.py) where live `prefix_geography` lookups were pushed directly into Earth visualization endpoints, causing `bgp-anomalies` and `bgp-incidents` to time out under load; the visualization layer now prefers cached evidence again so Earth remains responsive.
- Fixed Earth-side BGP fallback rendering so anomaly fallback no longer collapses into a single undifferentiated glow layer when incidents are unavailable.
- Fixed extreme incident brightness and same-coordinate blowout in [bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) by removing additive blending from incident cores, reducing ring intensity, and deduplicating incident rendering at the coordinate level.
- Fixed the confusing floating BGP event hub in [bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) by removing the suspended off-surface glow anchor and its arc links, leaving Earth incidents grounded on the globe surface with regional halo context instead.
## 0.22.9
Released: 2026-04-02
### Highlights
- Polished startup wait messaging in `planet.sh` so the script no longer shows retry counters before an actual restart attempt happens.
- Kept the service boot flow quieter during normal warm-up while preserving explicit retry feedback when a backend or frontend relaunch is really needed.
### Improved
- Improved [planet.sh](/home/ray/dev/linkong/planet/planet.sh) wait-state messaging by simplifying the initial readiness output to `等待服务就绪...` during health polling.
- Improved operator readability during local startup by reserving numbered retry messages for real process restart attempts instead of normal first-pass health checks.
### Fixed
- Fixed misleading startup output where the first health-check loop looked like an immediate retry sequence even though the service was still in its initial boot window.
## 0.22.8
Released: 2026-04-01
### Highlights
- Fixed the configuration center form lifecycle so revisiting the Settings page no longer triggers Ant Design's `useForm` warning during async settings hydration.
- Aligned repository version metadata across the backend, frontend, and lockfiles so the new bugfix release reports a consistent `0.22.8` version everywhere.
### Improved
- Improved settings data hydration in [Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Settings/Settings.tsx) by staging fetched system, notification, and security payloads in React state first instead of writing directly into form instances during the request callback.
- Improved settings form synchronization in [Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Settings/Settings.tsx) by delaying `setFieldsValue(...)` until the page loading skeleton is gone, ensuring each Ant Design form is actually mounted before values are pushed into it.
- Improved tab readiness in [Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Settings/Settings.tsx) by keeping the primary configuration tabs force-rendered so revisits and tab switches no longer race form instance registration.
- Improved release consistency by updating [VERSION](/home/ray/dev/linkong/planet/VERSION), [pyproject.toml](/home/ray/dev/linkong/planet/pyproject.toml), [frontend/package.json](/home/ray/dev/linkong/planet/frontend/package.json), [frontend/package-lock.json](/home/ray/dev/linkong/planet/frontend/package-lock.json), and [uv.lock](/home/ray/dev/linkong/planet/uv.lock) to the same bugfix version.
### Fixed
- Fixed the `Instance created by useForm is not connected to any Form element` warning in [Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Settings/Settings.tsx), which could still reappear after navigating away from the configuration center and returning while settings were being refetched.
- Fixed the timing bug where the page attempted to hydrate hidden/loading tab forms before the enclosing `Card loading` state had mounted the real form tree.
## 0.22.7
Released: 2026-04-01
### Highlights
- Fixed datasource bulk-collection progress so the realtime progress bar now tracks a dedicated batch lifecycle instead of collapsing back to zero when completed tasks drop out of the live running queue.
- Stabilized frontend WebSocket behavior by unifying dashboard subscriptions on the shared hook, reducing reconnect churn, and making local `/ws` fallback handling more resilient.
- Cleaned up several frontend console and type-check noise sources so the admin pages now run with a clean `tsc --noEmit` result.
### Improved
- Improved bulk collection progress tracking in [DataSources.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/DataSources/DataSources.tsx) by introducing a separate `bulkProgressBatch` state that persists task outcomes across the full `trigger-all` batch instead of averaging only the currently running tasks.
- Improved datasource progress summaries in [DataSources.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/DataSources/DataSources.tsx) and [index.css](/home/ray/dev/linkong/planet/frontend/src/index.css) by replacing the previous ad hoc tag row with unified stat pills for total builtin sources, enabled sources, running tasks, successful batch completions, and failed batch items.
- Improved datasource notifications in [DataSources.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/DataSources/DataSources.tsx) by switching from static `message.*` calls to `message.useMessage()`, which lets the page consume Ant Design context correctly under dynamic themes.
- Improved WebSocket address selection in [useWebSocket.ts](/home/ray/dev/linkong/planet/frontend/src/hooks/useWebSocket.ts) so the client prefers same-origin `/ws`, then falls back to direct backend access on local development hosts when the Vite proxy path is unavailable.
- Improved dashboard realtime updates in [Dashboard.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Dashboard/Dashboard.tsx) by removing the page-local WebSocket implementation and reusing the shared `useWebSocket` hook for the `dashboard` channel.
- Improved dashboard loading behavior in [Dashboard.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Dashboard/Dashboard.tsx) by updating the initial spinner to Ant Designs supported fullscreen pattern, eliminating the invalid `Spin tip` warning.
### Fixed
- Fixed the bulk datasource progress regression where each finished task disappeared from the running queue and caused the overall progress bar to reset or jump backward mid-batch.
- Fixed stale batch summaries in [DataSources.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/DataSources/DataSources.tsx) by clearing completed `bulkProgressBatch` state once every tracked datasource reaches a terminal non-running state.
- Fixed a WebSocket lifecycle bug in [useWebSocket.ts](/home/ray/dev/linkong/planet/frontend/src/hooks/useWebSocket.ts) where changing callbacks or unmounting during `CONNECTING` could leave behind orphaned sockets or produce repeated browser-side “closed before the connection is established” noise.
- Fixed repeated dashboard/admin websocket logic drift by consolidating channel subscription behavior into the shared hook instead of maintaining a second handwritten `new WebSocket(...)` path in the dashboard page.
- Fixed remaining frontend TypeScript hygiene issues in [BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx) and [Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Settings/Settings.tsx) by removing unused anomaly summary state and an unused `Space` import.
## 0.22.6
Released: 2026-04-01
### Highlights
- Finished the remaining Earth-side BGP interaction changes that were left out of the previous push, so collector and incident selections now use the intended semantic linking behavior in the globe view.
### Improved
- Improved [main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js) so BGP incident cards synthesize a clearer narrative summary when backend text is sparse, instead of exposing a thin raw summary.
- Improved [main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js) so incident selection reports impacted region counts and related cable counts directly in the Earth status message.
- Improved [main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js) so BGP incident-linked cables use a locked visual state, making event-to-cable correlation easier to read on the globe.
### Fixed
- Fixed the omitted Earth-side BGP semantic update from the previous push by actually shipping the `incident nearby satellites` wording, incident narrative fallback, and related-cable lock highlighting in [main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js).
- Fixed collector selection semantics in [main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js) so selecting a BGP collector no longer triggers the weaker nearby-satellite hint and no longer dims cable and landing-point layers as if it were an incident state.
## 0.22.5
Released: 2026-03-31
### Highlights
- Relaxed the BGP anomaly pipeline so realtime observation batches can produce visible anomaly and incident signals more consistently instead of staying observation-only.
- Added incident backfill-on-detection behavior so Earth and the BGP console can recover incident objects even when matching anomaly rows already existed from earlier ingests.
- Tightened BGP detector test coverage around low-signal withdrawal bursts, origin conflicts without historic baseline, and anomaly-to-incident regeneration.
### Improved
- Improved [bgp_detectors.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_detectors.py) by broadening origin-change detection into a multi-origin conflict path when multiple collectors observe competing origins without a prior baseline.
- Improved [bgp_detectors.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_detectors.py) so more-specific burst detection groups on normalized supernets and accepts cross-collector clusters instead of only a same-root count heuristic.
- Improved [bgp_detectors.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_detectors.py) so mass-withdrawal detection can trigger on smaller but cross-collector withdrawal pairs, with severity and confidence scaled by collector spread and event count.
- Improved anomaly evidence payloads in [bgp_detectors.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_detectors.py) with collector counts, peer counts, unique prefixes, and deduplicated impacted regions, which gives Earth and downstream incident views stronger context.
- Improved [bgp_common.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/bgp_common.py) so incident aggregation now seeds from both newly created anomalies and already-existing matching anomalies, allowing missing incidents to be rebuilt during later ingests.
### Fixed
- Fixed the “observations exist but anomalies/incidents stay at zero” failure mode where realtime BGP batches often produced no visible signals because detector thresholds were too strict for live traffic windows.
- Fixed the “existing anomaly but missing incident” gap where the pipeline only created incidents from freshly inserted anomaly rows and skipped rebuilding incident objects for already-known anomaly keys.
- Fixed stale BGP coverage test expectations in [test_bgp.py](/home/ray/dev/linkong/planet/backend/tests/test_bgp.py) by anchoring recent-window assertions to current UTC time instead of hard-coded past timestamps.
## 0.22.4
Released: 2026-03-31
### Highlights
- Fixed the admin dashboard navigation so the dashboard no longer appears to hard-reload when operators click the current “仪表盘” entry.
- Smoothed dashboard revisits by caching the last stats snapshot, reducing visible loading flashes even when the page is remounted by navigation flow.
### Improved
- Improved route ownership in [App.tsx](/home/ray/dev/linkong/planet/frontend/src/App.tsx) so the dashboard lives only at `/admin`, while `/` no longer opens the admin dashboard directly.
- Improved sidebar navigation behavior in [AppLayout.tsx](/home/ray/dev/linkong/planet/frontend/src/components/AppLayout/AppLayout.tsx) by replacing direct link rendering with guarded programmatic navigation that ignores clicks on the already-active route.
- Improved dashboard data reuse in [Dashboard.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Dashboard/Dashboard.tsx) by caching the latest stats payload and reusing it on remount before the next refresh cycle completes.
### Fixed
- Fixed the dashboard menu entry switching between `/` and `/admin`, which caused the dashboard route to remount and replay its initial data-fetch/WebSocket bootstrap.
- Fixed the most visible “reload” symptom on repeated dashboard clicks by preventing no-op navigation and avoiding an empty loading state when cached dashboard stats are available.
## 0.22.3
Released: 2026-03-31
### Highlights
- Added a controlled restart console on the dashboard so `super_admin` users can trigger backend, database, or full-system restarts from the UI.
- Unified restart operations behind `planet.sh` semantics, including a new database-only restart flag and stale task recovery for interrupted restart jobs.
### Added
- Added `/api/v1/system/restart-tasks` task creation, task status, and task log endpoints in [system_control.py](/home/ray/dev/linkong/planet/backend/app/api/v1/system_control.py).
- Added restart-task Redis helpers and whitelist command mapping in [system_control.py](/home/ray/dev/linkong/planet/backend/app/services/system_control.py).
- Added detached restart runner orchestration in [system_restart_runner.py](/home/ray/dev/linkong/planet/backend/scripts/system_restart_runner.py).
- Added `-d` / `--database` support to [planet.sh](/home/ray/dev/linkong/planet/planet.sh) for database-only restarts.
- Added restart control documentation in [system-service-control.md](/home/ray/dev/linkong/planet/docs/system-service-control.md).
### Improved
- Improved the dashboard control surface in [Dashboard.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Dashboard/Dashboard.tsx) with restart action selection, guided full-restart terminal output, persisted task log polling, and clearer modal layout.
- Improved dashboard restart styling in [index.css](/home/ray/dev/linkong/planet/frontend/src/index.css) with a dedicated toolbar, terminal panel, and scoped action button styles.
- Improved Vite dev proxy coverage in [vite.config.ts](/home/ray/dev/linkong/planet/frontend/vite.config.ts) so dashboard recovery polling can reach backend health checks during local development.
### Fixed
- Fixed restart-task deadlocks by automatically releasing stale in-progress restart tasks before accepting a new one.
## 0.21.9
Released: 2026-03-30
### Highlights
- Upgraded BGP ingestion from anomaly-only output to a layered pipeline with raw observations, enrichment context, detector modules, and aggregated incidents.
- Stabilized Earth and visualization data feeds so repeated collections no longer inflate satellite and other entity counts in the globe view.
- Brought the backend test suite back to green and expanded BGP-specific coverage across helpers, aggregation, and API endpoints.
### Added
- Added raw BGP observation persistence in [bgp_observation.py](/home/ray/dev/linkong/planet/backend/app/models/bgp_observation.py).
- Added aggregated BGP incident persistence in [bgp_incident.py](/home/ray/dev/linkong/planet/backend/app/models/bgp_incident.py).
- Added BGP enrichment helpers in [bgp_enrichment.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_enrichment.py) for prefix scope, AS path normalization, ASN organization context, and baseline tracking.
- Added modular BGP detector helpers in [bgp_detectors.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_detectors.py).
- Added BGP incident aggregation helpers in [bgp_incidents.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_incidents.py).
- Added `/api/v1/bgp/incidents` and `/api/v1/bgp/incidents/{id}` in [bgp.py](/home/ray/dev/linkong/planet/backend/app/api/v1/bgp.py).
### Improved
- Improved BGP collectors so [ris_live.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/ris_live.py) and [bgpstream.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/bgpstream.py) now write observations before deriving anomalies.
- Improved anomaly generation in [bgp_common.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/bgp_common.py) by routing signals through enrichment and dedicated detector modules, then rolling them up into incidents.
- Improved the Earth cable layer in [main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js) so hide/show preserves loaded state correctly and stats remain visible while a layer is hidden.
- Improved backend visualization endpoints in [visualization.py](/home/ray/dev/linkong/planet/backend/app/api/v1/visualization.py) so repeated collections return only the latest unique entity records.
- Improved backend test coverage across [test_bgp.py](/home/ray/dev/linkong/planet/backend/tests/test_bgp.py), [test_api.py](/home/ray/dev/linkong/planet/backend/tests/test_api.py), [test_collectors.py](/home/ray/dev/linkong/planet/backend/tests/test_collectors.py), [test_models.py](/home/ray/dev/linkong/planet/backend/tests/test_models.py), and [test_security.py](/home/ray/dev/linkong/planet/backend/tests/test_security.py).
### Fixed
- Fixed Earth satellite overcounting caused by historical duplicate records being returned by visualization endpoints.
- Fixed BGP event APIs to read from observation records instead of the generic collected-data model.
- Fixed several stale backend tests so they match the current token, timestamp, collector, and API behavior.
## 0.21.8
Released: 2026-03-27
### Highlights
- Improved Earth-layer performance controls so satellite and cable toggles now fully unload their scene/runtime state instead of only hiding objects.
### Improved
- Improved the Earth satellite toggle to stop position/trail updates and release satellite rendering resources while disabled, then reload on demand when re-enabled.
- Improved the Earth cable toggle to unload submarine cable and landing-point objects while disabled so drag and interaction cost drops with the layer turned off.
- Improved Earth data reload behavior so disabled satellite and cable layers stay disabled instead of being implicitly reloaded during refresh.
## 0.21.7
Released: 2026-03-27
### Highlights
- Added Earth-side BGP collector visualization support so anomaly markers and collector stations can be explored together.
- Refined the collected-data distribution treemap so square tiles better reflect relative volume while staying readable in dense layouts.
### Added
- Added `/api/v1/visualization/geo/bgp-collectors` to expose RIPE RIS collector locations as GeoJSON.
- Added dedicated Earth collector marker handling and BGP collector detail cards in the Earth runtime.
- Added collector-specific BGP visual tuning for altitude, opacity, scale, and pulse behavior.
### Improved
- Improved the collected-data distribution treemap with dynamic square-grid sizing, clearer area-based span rules, centered compact tiles, and tooltip coverage on both icons and labels.
- Improved compact treemap readability by hiding `1x1` labels, reducing `1x1` value font size, and centering icon/value content.
- Improved Earth BGP interactions so anomaly markers and collector markers can both participate in hover, lock, legend, and info-card flows.
### Fixed
- Fixed Earth BGP data loading gaps by adding the missing `bgp.js` runtime module required by the current control and visualization flow.
- Fixed treemap layout drift where compact tiles could appear oversized or visually inconsistent with the intended square-grid distribution.
## 0.21.6
Released: 2026-03-27
### Highlights
- Refined the Earth page interaction loop with object-driven legend switching, clearer selection feedback, and cleaner HUD copy/layout behavior.
- Improved the Earth info surfaces so status toasts, info-card interactions, and title/subtitle presentation feel more intentional and easier to scan.
### Added
- Added click-to-copy support for info-card labels so clicking a field label copies the matching field value.
- Added runtime-generated legend content for cables and satellites based on current Earth data and selection state.
### Improved
- Improved Earth legend behavior so selected cables and selected satellite categories are promoted to the top of the legend list.
- Improved legend overflow handling by constraining the visible list and using scroll for additional entries.
- Improved info-panel heading layout with centered title/subtitle styling and better subtitle hierarchy.
- Improved status-message behavior with replayable slide-in notifications when messages change in quick succession.
### Fixed
- Fixed info-card content spacing by targeting the actual `#info-card-content` node instead of a non-matching class selector.
- Fixed cable legend generation so it follows backend-returned cable names and colors instead of stale hard-coded placeholder categories.
- Fixed reset-view and legend-related HUD behaviors so selection and legend state stay in sync when users interact with real Earth objects.
## 0.21.5
Released: 2026-03-27
### Highlights
- Reworked the collected-data overview into a clearer split between KPI cards and a switchable treemap distribution.
- Added a direct Earth entry on the dashboard and tightened several admin-side scrolling/layout behaviors.
### Added
- Added a dashboard quick-access card linking directly to `/earth`.
- Added collected-data treemap switching between `按数据源` and `按类型`.
- Added data-type-specific icons for the collected-data overview treemap.
### Improved
- Improved collected-data summary behavior so overview counts follow the active filters and search state.
- Improved the collected-data treemap with square tiles, a wider default overview panel width, and narrower overview scrollbars.
- Improved responsive behavior near the tablet breakpoint so the collected-data page can scroll instead of clipping the overview or crushing the table.
### Fixed
- Fixed the user-management table overflow issue by restoring `ant-table-body` to auto height for that page so the outer container no longer incorrectly takes over vertical scrolling.
- Fixed overly wide scrollbar presentation in collected-data and related admin surfaces by aligning them with the slimmer in-app scrollbar style.
## 0.21.3
Released: 2026-03-27
### Highlights
- Upgraded the startup script into a more resilient local control entrypoint with retry-based service boot, selective restart targeting, and guided CLI user creation.
- Reduced friction when developing across slower machines by making backend and frontend startup checks more tolerant and operator-friendly.
### Added
- Added interactive `createuser` support to [planet.sh](/home/ray/dev/linkong/planet/planet.sh) for CLI-driven username, email, password, and admin-role creation.
### Improved
- Improved `start` and `restart` in [planet.sh](/home/ray/dev/linkong/planet/planet.sh) with optional backend/frontend port targeting and on-demand port cleanup.
- Improved startup robustness with repeated health checks and automatic retry loops for both backend and frontend services.
- Improved restart ergonomics so `restart -b` and `restart -f` can restart only the requested service instead of forcing a full stack restart.
### Fixed
- Fixed false startup failures on slower environments where services needed longer than a single fixed wait window to become healthy.
- Fixed first-run login dead-end by ensuring a default admin user is created during backend initialization when the database has no users.
## 0.21.2
Released: 2026-03-26
### Highlights
- Reworked the Earth page HUD into a bottom-centered floating toolbar with grouped popovers and richer interaction feedback.
- Unified toolbar and corner cards under a liquid-glass visual language and refined status toasts, object info cards, and legend behavior.
- Made the legend state reflect the currently selected Earth object instead of a fixed static list.
### Added
- Added a reusable Earth legend module in [legend.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/legend.js).
- Added Material Symbols-based Earth toolbar icons and dedicated fullscreen-collapse icon support.
- Added click-to-copy support for info-card field labels.
### Improved
- Improved Earth toolbar layout with centered floating controls, popover-based display toggles, and zoom controls.
- Improved Earth HUD visuals with liquid-glass styling for buttons, info cards, panels, and animated status messages.
- Improved info-card spacing, scrollbar styling, and object detail readability.
- Improved legend rendering so cable and satellite object selection can drive the displayed legend content.
### Fixed
- Fixed tooltip coverage and splash copy mismatches in the Earth page controls.
- Fixed several toolbar icon clarity, centering, and state-toggle issues.
- Fixed status-message behavior so repeated notifications replay the slide-in animation.
## 0.20.0
Released: 2026-03-26
### Highlights
- Stabilized the Earth big-screen module for longer-running sessions.
- Fixed satellite orbit generation by correcting TLE handling end to end.
- Added a reusable backend TLE helper and exposed `tle_line1 / tle_line2` to the Earth frontend.
### Added
- Added a dedicated Earth module remediation plan in [earth-module-plan.md](/home/ray/dev/linkong/planet/docs/earth-module-plan.md).
- Added backend TLE helpers in [satellite_tle.py](/home/ray/dev/linkong/planet/backend/app/core/satellite_tle.py).
- Added backend support for returning `tle_line1` and `tle_line2` from the satellite visualization API.
### Improved
- Reworked the Earth module lifecycle with cleaner init, reload, and destroy paths.
- Improved scene cleanup for cables, landing points, satellite markers, and related runtime state.
- Reduced Earth interaction overhead by reusing hot-path math and pointer objects.
- Switched satellite animation timing to real delta-based updates for more stable motion.
- Reduced fragile global-state coupling inside the legacy Earth runtime.
### Fixed
- Fixed white-screen risk caused by iframe cleanup behavior in development mode.
- Fixed incorrect client-side TLE generation:
- corrected line 2 field ordering
- corrected eccentricity formatting
- added checksum generation
- Fixed fallback orbit issues affecting some Starlink satellites such as `STARLINK-36158`.
- Fixed partial Earth data load failures so one failed source is less likely to break the whole view.
### Notes
- The Earth frontend now prefers backend-provided raw TLE lines.
- Older satellite records can still fall back to backend-generated TLE lines when raw lines are unavailable.
- This release is primarily focused on Earth module stability rather than visible admin UI changes.
## 0.21.1
Released: 2026-03-26
### Highlights
- Refined the Earth big-screen toolbar with clearer controls, hover hints, and more consistent visual language.
- Replaced emoji-based Earth toolbar controls with SVG icons for a cleaner HUD.
- Updated the Earth loading splash so manual reloads no longer show legacy wording.
### Improved
- Improved zoom controls by adding tooltips for reset view, zoom in, zoom out, and resetting zoom to `100%`.
- Improved Earth toolbar readability with larger icons and revised glyphs for rotation, reload, satellites, trails, cables, terrain, and collapse.
- Improved loading overlay copy to better distinguish initial initialization from manual refresh.
### Fixed
- Fixed rotate toggle rendering so play/pause state no longer relies on emoji text replacement.
- Fixed Earth autorotation target syncing so inertial drag is preserved while the globe is still coasting.
## 0.21.0
Released: 2026-03-26
### Highlights
- Added legacy-inspired inertial drag behavior to the Earth big-screen module.
- Removed the hard 10,000-satellite ceiling when Earth satellite loading is configured as unlimited.
- Tightened Earth toolbar and hover-state synchronization for a more consistent runtime feel.
### Added
- Added inertial drag state and smoothing to the Earth runtime so drag release now decays naturally.
### Improved
- Improved drag handling so moving the pointer outside the canvas no longer prematurely stops rotation.
- Improved satellite loading to support dynamic frontend buffer sizing when no explicit limit is set.
- Improved Earth interaction fidelity by keeping the hover ring synchronized with moving satellites.
### Fixed
- Fixed the trails toolbar button so its default visual state matches the actual default runtime state.
- Fixed the satellite GeoJSON endpoint so omitting `limit` no longer silently falls back to `10000`.
- Fixed hover ring lag where the ring could stay behind the satellite until the next mouse move.
## 0.19.0
Released: 2026-03-25
### Highlights
- Refined data collection storage and history handling.
- Moved collected data away from several strongly coupled legacy columns.
- Improved data list filtering, metadata-driven detail rendering, and collection workflows.
### Added
- Added collected data history planning docs.
- Added metadata backfill and removal-readiness scripts.
- Added version history tracking.
### Improved
- Improved datasource task tracking and collection status flow.
- Improved collected data search, filtering, and metadata rendering.
- Improved configuration center layout consistency across admin pages.
### Fixed
- Fixed several collected-data field mapping issues.
- Fixed frontend table layout inconsistencies across multiple admin pages.
- Fixed TOP500 parsing and related metadata alignment issues.
## 0.22.2
Released: 2026-03-31
### Highlights
- Refined Earth BGP collector presentation so coverage sectors, scanning behavior, and surface-aligned station markers read more like a coherent observability layer.
### Improved
- Improved BGP collector rendering with surface-aligned mesh icons, directional scanning sectors, and tighter sector geometry that stays closer to the globe surface.
- Improved collector icon styling by centralizing marker visual parameters in `BGP_CONFIG.collectorIcon` and restoring clearer low-saturation status color on the icon body.
- Improved dense collector readability by widening overlap spreading and reducing default non-selected glow so stations stay legible without blooming into bright clusters.
## 0.22.1
Released: 2026-03-31
### Highlights
- Refined the Earth HUD interaction model so panels and floating menus no longer leak pointer state into the globe runtime.
- Normalized `Escape` handling across hover menus, click-open menus, and locked scene selections.
### Improved
- Improved floating toolbar behavior so `Escape` now closes visible secondary menus before clearing locked scene objects.
- Improved toolbar hover recovery after forced close, avoiding double-hover reopening glitches.
- Improved control-module maintainability by deduplicating tooltip, menu, and hide-selection helpers.
### Fixed
- Fixed HUD overlap cases where hovering panels could still leave stale scene hover state active.
- Fixed info card pointer blocking so panel regions consistently shield underlying globe interactions.
## 0.22.0
Released: 2026-03-31
### Highlights
- Expanded the BGP stack from raw events into collector coverage, incident weak-correlation, and Earth-side observability overlays.
- Upgraded the BGP console and Earth runtime so collector baselines and nearby infrastructure context remain visible even outside active anomaly spikes.
### Added
- Added BGP collector coverage APIs and dynamic GeoJSON enrichment for collector baseline metrics.
- Added weak correlation from BGP incidents to nearby landing points, cable names, and regional exchange hints.
- Added BGP collector coverage views in the admin console, including recent activity and baseline scope summaries.
### Improved
- Improved Earth BGP rendering with clearer collector markers, activity-driven halos, selected coverage overlays, and nearby satellite highlighting.
- Improved incident presentation by surfacing related infrastructure directly in BGP incident listings and Earth detail cards.
- Improved BGP backend test coverage around collector coverage and infrastructure inference.
## 0.21.9
Released: 2026-03-31
### Highlights
- Expanded the BGP pipeline with observations, enrichment, detectors, and incidents.
- Stabilized backend tests and improved visualization deduplication across repeated collections.
### Improved
- Improved visualization APIs so repeated satellite and infrastructure collections no longer inflate rendered entity counts.
- Improved backend coverage for BGP observations, incidents, and API summaries.
### Fixed
- Fixed several backend tests to match the current UTC serialization and collector behavior.

290
docs/aiprovider.md Normal file
View File

@@ -0,0 +1,290 @@
# AI Provider Guide
## Overview
`aiprovider` is the model-adapter service for Planet.
It isolates model-vendor details from the main backend so the rest of the system can call a stable business API:
- Caller service -> `planet backend`
- `planet backend` -> `aiprovider`
- `aiprovider` -> concrete model provider
The recommended default is:
- External and cross-service callers use `planet backend`
- Only infrastructure-grade internal jobs call `aiprovider` directly
## Responsibilities
`backend` is responsible for:
- authentication and authorization
- business-level request shaping
- stable `/api/v1/ai/...` endpoints
- internal service-to-service authentication toward `aiprovider`
`aiprovider` is responsible for:
- model protocol adaptation
- provider selection by `.env`
- timeout and lightweight retry
- request tracing via `X-Request-ID`
## Supported Providers
`aiprovider` currently supports:
- `openai`
- `openai_compatible`
- `anthropic`
- `anthropic_compatible`
- `claude_compatible`
- `ollama`
Provider mapping:
- `vLLM`, `LM Studio`, `One API`: `openai_compatible`
- `MiniMax`, Claude-compatible gateways: `claude_compatible`
- `Ollama`: `ollama`
## API Surfaces
### Main backend API
Preferred stable entrypoints:
- `GET /api/v1/ai/provider/status`
- `POST /api/v1/ai/situational-awareness/analyze`
Authentication:
- `Authorization: Bearer <jwt>`
Optional tracing header:
- `X-Request-ID: <caller-generated-id>`
The backend will propagate `X-Request-ID` to `aiprovider` and return the same header in the response.
### AI provider internal API
Internal-only endpoints:
- `GET /v1/provider/status`
- `POST /v1/analyze`
Authentication:
- `X-Provider-Token: <shared-secret>`
Optional tracing header:
- `X-Request-ID: <caller-generated-id>`
## Request Example
### Call through backend
```bash
curl -X POST http://localhost:8000/api/v1/ai/situational-awareness/analyze \
-H "Authorization: Bearer <access_token>" \
-H "X-Request-ID: bgp-incident-20260407-001" \
-H "Content-Type: application/json" \
-d '{
"title": "BGP异常研判",
"objective": "总结当前风险并给出处置建议",
"observations": [
"collector A 在 5 分钟内出现多次 origin 变更",
"异常集中在同一地区前缀"
],
"constraints": [
"不要编造不存在的数据",
"区分事实和推断"
],
"context": {
"source": "bgp-monitor",
"severity": "high"
}
}'
```
### Call `aiprovider` directly
```bash
curl -X POST http://localhost:8010/v1/analyze \
-H "X-Provider-Token: change_me" \
-H "X-Request-ID: ai-batch-job-001" \
-H "Content-Type: application/json" \
-d '{
"title": "链路波动分析",
"objective": "给出简要态势摘要和下一步建议",
"observations": [
"多个节点出现延迟上升"
],
"constraints": [
"不要假设根因已经确认"
],
"context": {
"region": "APAC"
}
}'
```
## Response Shape
Both backend and `aiprovider` return the same payload shape:
```json
{
"provider": "openai_compatible",
"model": "gpt-4o-mini",
"content": "1) 态势摘要 ...",
"raw_response": {}
}
```
Both services also return:
- `X-Request-ID: <id>`
## Configuration
### Backend
Recommended backend `.env`:
```env
AI_PROVIDER_SERVICE_URL=http://localhost:8010
AI_PROVIDER_SERVICE_TOKEN=change_me
AI_PROVIDER_TIMEOUT_SECONDS=60
AI_PROVIDER_RETRY_ATTEMPTS=2
```
Reference file:
- [backend/.env.example](/home/ray/dev/linkong/planet/backend/.env.example)
### AI Provider
Reference file:
- [aiprovider/.env.example](/home/ray/dev/linkong/planet/aiprovider/.env.example)
Frontend local reference:
- [frontend/.env.example](/home/ray/dev/linkong/planet/frontend/.env.example)
Common settings:
```env
SERVICE_NAME=planet-ai-provider
SERVICE_VERSION=0.1.0
AI_PROVIDER_SERVICE_TOKEN=change_me
AI_TIMEOUT_SECONDS=60
AI_HTTP_RETRY_ATTEMPTS=2
AI_ANALYSIS_SYSTEM_PROMPT=你是态势感知分析助手。请基于输入的上下文、观测与约束,输出结构化、克制、可执行的分析。
```
### OpenAI-compatible example
```env
AI_PROVIDER=openai_compatible
AI_BASE_URL=http://127.0.0.1:8001/v1
AI_API_KEY=local-key
AI_MODEL=your-local-model
```
### Claude-compatible example
```env
AI_PROVIDER=claude_compatible
AI_BASE_URL=https://your-claude-compatible-endpoint.example.com
AI_API_KEY=your_api_key
AI_MODEL=your-model
AI_MAX_TOKENS=1200
AI_ANTHROPIC_VERSION=2023-06-01
```
### Ollama example
```env
AI_PROVIDER=ollama
AI_BASE_URL=http://127.0.0.1:11434
AI_API_KEY=
AI_MODEL=qwen2.5:7b
```
## Deployment Modes
### Single machine
Recommended local flow:
- `backend` on `localhost:8000`
- `aiprovider` on `localhost:8010`
- local model gateway on `localhost:11434` or another local port
Helpers already included:
- [planet.sh](/home/ray/dev/linkong/planet/planet.sh)
- [docker-compose.local-model.yml](/home/ray/dev/linkong/planet/docker-compose.local-model.yml)
### Multi-machine
Example topology:
- app machine: `backend`
- AI gateway machine: `aiprovider`
- model machine: local model service or cloud proxy
In that case, this becomes service-to-service HTTP RPC:
- caller -> backend
- backend -> `http://10.0.0.12:8010`
- `aiprovider` -> model endpoint
Recommended cross-machine backend config:
```env
AI_PROVIDER_SERVICE_URL=http://10.0.0.12:8010
AI_PROVIDER_SERVICE_TOKEN=change_me
AI_PROVIDER_TIMEOUT_SECONDS=60
AI_PROVIDER_RETRY_ATTEMPTS=2
```
Recommended operating rules:
- keep `aiprovider` on a private network
- protect it with `X-Provider-Token` at minimum
- always send `X-Request-ID`
- keep callers on the backend API unless they are infrastructure jobs
## Retry And Failure Behavior
`backend -> aiprovider`:
- retries lightweight network / 5xx failures
- returns `502` when the provider service is unavailable
`aiprovider -> model provider`:
- retries lightweight network / 5xx failures
- returns `502` when the model provider is unavailable
This is intentionally conservative. It avoids masking persistent errors while still absorbing short hiccups.
## Operational Notes
- `./planet.sh start` now starts `aiprovider` automatically
- `./planet.sh restart -a` restarts only `aiprovider`
- `./planet.sh log -a` tails `aiprovider` logs
- `./planet.sh health` reports `aiprovider` health
## Recommended Calling Policy
- Frontend and application services: call `backend`
- Scheduled infra jobs and diagnostics: optionally call `aiprovider`
- Do not let multiple business services integrate model vendors independently
That keeps provider switching centralized and avoids model-specific drift across the system.

355
docs/bgp-context.md Normal file
View File

@@ -0,0 +1,355 @@
# BGP Context
## Current Goal
The BGP module is being evolved from an anomaly-only demo into a layered observability pipeline:
`raw observations -> enrichment -> detectors -> incidents -> console/Earth visualization`
The practical product goal is no longer just to "show incidents on the globe". The current product objective is:
1. keep BGP visually present on Earth even when incident density is low
2. make incidents clearly feel like a higher-confidence layer than anomalies
3. show that the observation network is still active even when there are no active incidents
In practice, that means Earth should behave like an observability surface, not only an incident map:
- `collectors` show that observation is happening
- `activity` shows where routing state is currently active or noisy
- `incidents` become the highest-confidence focus layer
## Current Backend Architecture
### Data Layers
1. `BGPObservation`
- File: `backend/app/models/bgp_observation.py`
- Purpose: store normalized raw routing observations from live/history sources.
- Typical fields:
- `source`
- `collector`
- `peer_asn`
- `peer_ip`
- `prefix`
- `event_type`
- `as_path`
- `origin_asn`
- `next_hop`
- `communities`
- `observed_at`
- `raw_payload`
- `collector_geo`
- `ingest_batch_id`
2. `BGPAnomaly`
- File: `backend/app/models/bgp_anomaly.py`
- Purpose: hold atomic detector outputs.
- Current detector output types include:
- `origin_change`
- `more_specific_burst`
- `mass_withdrawal`
3. `BGPIncident`
- File: `backend/app/models/bgp_incident.py`
- Purpose: aggregate atomic anomalies into incident-level objects for humans and the UI.
### Pipeline
Main flow is currently anchored in:
- `backend/app/services/collectors/bgp_common.py`
- `backend/app/services/bgp_enrichment.py`
- `backend/app/services/bgp_detectors.py`
- `backend/app/services/bgp_incidents.py`
Operational flow:
1. collectors fetch raw BGP data
2. `normalize_bgp_event()` standardizes payloads
3. observations are persisted to `bgp_observations`
4. enrichment augments events with analysis context
5. detectors create `bgp_anomalies`
6. incident aggregation rolls anomalies up into `bgp_incidents`
### Current Ingest Sources
1. `RIPE RIS Live`
- Collector file: `backend/app/services/collectors/ris_live.py`
- Used for realtime observation flow.
2. `CAIDA BGPStream Backfill`
- Collector file: `backend/app/services/collectors/bgpstream.py`
- Used as history/backfill entry point.
## Current Enrichment Status
Implemented enrichment skeleton in:
- `backend/app/services/bgp_enrichment.py`
Current enrichments:
- prefix family / prefix length
- supernet / more-specific derivation
- deduplicated AS path
- path prepending hints
- collector region info
- prefix baseline hints
- new-origin detection
- ASN organization profile from PeeringDB where available
- prefix scope / impacted region hints
- prefix geography source priority:
- `OpenGeoFeed` (override/high confidence)
- `IPtoASN` (country-range baseline)
- `NRO delegated stats` (registry-allocation fallback)
Current limitation:
- `RPKI` is still placeholder-only and returns `unknown`
- no real ROA validation source is integrated yet
- `inetnum` / `inet6num` whois fallback is still pending
## Current API Surface
Primary API file:
- `backend/app/api/v1/bgp.py`
Available endpoints:
- `/api/v1/bgp/events`
- `/api/v1/bgp/events/summary`
- `/api/v1/bgp/events/{id}`
- `/api/v1/bgp/anomalies`
- `/api/v1/bgp/anomalies/summary`
- `/api/v1/bgp/anomalies/{id}`
- `/api/v1/bgp/incidents`
- `/api/v1/bgp/incidents/summary`
- `/api/v1/bgp/incidents/{id}`
Visualization GeoJSON endpoints:
- `backend/app/api/v1/visualization.py`
- `/api/v1/visualization/geo/bgp-collectors`
- `/api/v1/visualization/geo/bgp-anomalies`
- `/api/v1/visualization/geo/bgp-incidents`
## Current Earth Behavior
Relevant files:
- `frontend/public/earth/js/bgp.js`
- `frontend/public/earth/js/main.js`
- `frontend/public/earth/js/info-card.js`
- `frontend/public/earth/js/constants.js`
- `frontend/public/earth/index.html`
Current design:
1. Collectors are always shown when BGP is enabled.
2. Incident markers are now the primary Earth BGP markers.
3. If there are no incidents, Earth falls back to anomaly markers.
4. If there are no anomalies either, collectors still provide presence.
5. A dedicated `activity layer` now adds:
- per-collector recent 15-minute activity halos
- clustered regional activity hints derived from active collectors
6. Incident markers now use:
- symbol-driven event cores
- outward ring pulses
- reduced diffuse glow compared with older Earth builds
5. The right-side stats now show:
- BGP events
- collector count
- BGP status summary
This is directionally correct, but still incomplete for low-event-density periods. Right now Earth can still feel too quiet when incidents are sparse because the system lacks a dedicated `activity layer` between raw observation and incident focus.
Current BGP status strategy:
- incidents present: show active incident count
- no incidents but anomalies present: show active anomaly count, plus active observation regions when available
- no incidents/anomalies but activity present: show `观测网络运行中`
- no incidents/anomalies but collectors present: show `观测网络运行中 · 当前未发现聚合级事件`
- no BGP data at all: show `暂无观测数据`
Earth info-card strategy:
- `bgp` card is now incident-centric in wording
- `bgp_collector` card shows collector location and current event count
## Current Product Gap
The main product gap is not architecture correctness. It is low-density visualization strategy.
Current reality:
- incident count is naturally much lower than anomaly count
- that is expected, because incidents are aggregated and de-noised
- but incident-first rendering makes the Earth view look too quiet unless there is another always-available activity layer
Implementation detail for the recommended `activity layer` is expanded in [bgp-region-aggregation-plan.md](/home/ray/dev/linkong/planet/docs/bgp-region-aggregation-plan.md).
So the immediate next milestone is:
`event map -> observability map`
That means Earth needs three simultaneously readable layers:
1. `observation layer`
- collectors
- recent collector activity
- baseline coverage
2. `activity layer`
- recent event density
- anomaly/noise hotspots
- regional activity scoring
- incident presence bonus
3. `incident layer`
- sparse but highly legible, high-confidence event objects
- symbol-driven markers
- outward ring pulse instead of broad diffuse glow
## Incident Visual Direction
The Earth `incident` layer should not read like a large glowing patch. It should read like a compact, high-confidence event focus.
Design principles:
1. `incident` markers should use a strong primary symbol
- the symbol shape should carry type meaning where possible
- examples:
- `origin_change`: triangle-like warning marker
- `mass_withdrawal`: alert/exclamation-style marker
- `more_specific_burst`: split/radiating marker
2. emphasis should come from outward ring pulses, not area flooding
- use a compact hot core
- use one or more expanding ring pulses
- avoid broad luminous blobs that make the event center feel vague
3. `collector` and `incident` must stay visually distinct
- collectors are observation infrastructure
- incidents are extracted event focus
- collector activity should stay quieter than incident pulse language
4. calm periods still need observability presence
- collectors and activity layers should keep the map alive
- once incidents appear, they should clearly dominate nearby BGP visuals
5. incident geography should become `prefix-centric`
- collectors should remain evidence sources, not the primary event location
- preferred geography priority:
- `prefix_geography`
- `prefix_scope`
- `ASN organization region`
- `collector centroid` as final fallback
- `prefix_scope` should remain an observation-derived scope hint
- a new `prefix_geography` layer should be introduced for actual prefix-centric placement
Reference inspiration:
- `World Monitor`
- sparse event symbols
- compact centers
- ring-like outward pulses
- stronger incident legibility than diffuse glow
## Current Console Behavior
Relevant page:
- `frontend/src/pages/BGP/BGP.tsx`
Current BGP console page has three levels:
1. observation summary
- total events
- collector count
- prefix count
2. incident summary and incident table
3. anomaly detail table plus recent observation events
This means the BGP page still has useful signal even when there are zero anomalies.
## Known Product/Engineering Boundaries
1. The current system is still closer to an event board than a full BGP sensing platform.
2. RIS coverage still needs to expand beyond narrow subscription scope.
3. BGPStream history is still not full MRT-to-prefix decoded analytics.
4. Collector geography still depends heavily on static RIPE RIS mappings.
5. Incident-to-cable/IXP/region association is still weak and early-stage.
6. Earth currently visualizes logical observation/impact structure, not true physical traffic paths.
## Test Status
BGP-specific tests live in:
- `backend/tests/test_bgp.py`
Verified status at this point:
- `25 passed` for `backend/tests/test_bgp.py`
- `62 passed` for `backend/tests`
Covered areas include:
- normalization
- observation serialization
- enrichment
- detectors, including route leak candidate and path flap
- incident aggregation
- batch anomaly creation
- BGP events/incidents API
- summary endpoints
## Most Relevant Files
Backend:
- `backend/app/models/bgp_observation.py`
- `backend/app/models/bgp_anomaly.py`
- `backend/app/models/bgp_incident.py`
- `backend/app/services/collectors/bgp_common.py`
- `backend/app/services/bgp_enrichment.py`
- `backend/app/services/bgp_detectors.py`
- `backend/app/services/bgp_incidents.py`
- `backend/app/api/v1/bgp.py`
- `backend/app/api/v1/visualization.py`
Frontend:
- `frontend/src/pages/BGP/BGP.tsx`
- `frontend/public/earth/js/bgp.js`
- `frontend/public/earth/js/main.js`
- `frontend/public/earth/js/info-card.js`
- `frontend/public/earth/js/constants.js`
- `frontend/public/earth/index.html`
## Recommended Next Steps
### Next Backend / Detection Priority
1. Integrate real RPKI validation data.
2. Expand realtime collector coverage and include withdrawals more broadly.
3. Continue refining route leak and path instability detectors with stronger heuristics.
### Next Correlation / Storytelling Priority
4. Strengthen incident aggregation semantics and titles.
5. Add weak correlation from incidents to:
- cable corridors
- landing points
- IXPs
- other traffic anomaly sources
6. Refine Earth hover/click handoff between collectors and incidents.
### Next Visualization Priority
7. Refine regional activity scoring so the activity layer is informative without becoming noisy.
8. Add more incident symbol types as new detectors land.
9. Add a real prefix geography source:
- `IPtoASN / IPtoCountry` as the first practical dataset
- `OpenGeoFeed` as a higher-quality override layer
- registry/whois only as fallback

View File

@@ -0,0 +1,296 @@
# BGP Earth Rendering Plan
## Goal
This document defines how the BGP `region activity layer` and `incident layer` should coexist on Earth without conflicting.
The main question it answers is:
- how to add a regional observability background layer
- without weakening the current incident-first event focus
## Core Principle
The Earth design should follow a strict semantic hierarchy:
- `collector layer` = observation infrastructure
- `region activity layer` = background situational awareness
- `incident layer` = focal high-confidence event objects
In short:
- collectors prove the network is observing
- regions show where routing behavior is active or abnormal
- incidents show the concrete event worth clicking
Region aggregation is therefore not a replacement for incident rendering.
It is the context layer that makes sparse incident markers legible.
## Rendering Hierarchy
Recommended visual stack order:
1. collector network / collector halos
2. region activity glow
3. incident markers and incident pulses
This ordering should always hold.
Why:
- collectors should stay visible but quiet
- regions should create ambient activity presence
- incidents must remain the first thing users notice as a concrete event
## Role Separation
### Region Layer
The region layer answers:
- where is routing activity building up
- where is there current noise or instability
- which part of the world is currently worth looking at
The region layer should feel:
- broad
- ambient
- low-frequency
- contextual
### Incident Layer
The incident layer answers:
- which exact event should the user inspect
- where is the highest-confidence routing event located right now
The incident layer should feel:
- sharp
- compact
- high-contrast
- intentionally clickable
## Non-Conflict Rules
To avoid visual and semantic conflict, these implementation rules should be treated as hard constraints:
1. region markers must not use the same symbol language as incidents
2. region emphasis must stay weaker than incident emphasis
3. region animation frequency must stay lower than incident animation frequency
4. incident markers must always render above region glows
5. region layer should support the event, not compete with it
If a user notices the region layer first but misses the incident marker, the region layer is too strong.
If a user only sees isolated incident points and cannot feel broader activity context, the region layer is too weak.
## Region Rendering Rules
The region layer should not be rendered as a second kind of incident point.
Recommended representation:
- diffuse glow
- halo
- low-detail pulse
- soft center, not a sharp icon
### Status Mapping
#### `observing`
- weak glow
- cool color, such as cyan or blue
- little to no pulse
- purpose: keep the globe alive during calm periods
#### `anomaly`
- stronger glow
- warmer color, such as amber
- gentle breathing or low-frequency pulse
- purpose: show that a region is experiencing abnormal routing noise
#### `incident`
- strongest regional background emphasis
- still clearly weaker than the incident marker itself
- purpose: lift the surrounding area so the focal event does not feel isolated
### Region Visual Characteristics
Recommended properties:
- large radius
- low opacity
- soft edge
- low-contrast outline or no outline
- low pulse amplitude
Avoid:
- sharp symbol shapes
- strong icon silhouettes
- bright hard-edged centers
- incident-like pulse language
## Incident Rendering Rules
The incident layer should remain visually sharper and more explicit than region activity.
Recommended qualities:
- clear event symbol
- compact hot core
- one or two outward ring pulses
- high contrast
- clear click target
The incident layer should read as:
- focal
- deliberate
- high-confidence
while the region layer should read as:
- contextual
- ambient
- supporting
## Region And Incident In The Same Area
When a region contains one or more incidents:
- the region glow may intensify
- but the incident marker must remain the dominant local feature
Interpretation should be:
- `region` says this area is in an event state
- `incident marker` says this is the concrete event object
So a region with `incident` status is not itself the event marker.
It is the background state around the event.
## Interaction Model
Interaction should also preserve hierarchy.
### Click Region
Open a regional situation view, such as:
- region name
- observation count
- anomaly count
- incident count
- affected prefix count
- affected ASN count
- recent incidents in the region
### Click Incident
Keep the current incident-focused detail interaction.
This creates a natural two-step flow:
1. region gives context
2. incident gives detail
## Layer Relationship To Existing BGP Elements
### Collector Layer
Collectors should remain:
- quieter than regions
- more infrastructural than semantic
- proof of coverage, not proof of incident
### Region Layer
Regions should become:
- the main ambient activity layer
- the bridge between collectors and incidents
- the answer to low-density map quietness
### Incident Layer
Incidents should remain:
- the most legible event layer
- sparse but dominant
- compact and symbol-driven
## Practical Visual Test
Use this test when tuning the Earth implementation:
### Calm Period
Expected result:
- collectors visible
- some weak region glows present
- no region feels alarm-heavy
- globe still feels alive
### Anomaly Period
Expected result:
- one or more regions brighten noticeably
- user can sense the active area before clicking
- still no confusion between region background and incident objects
### Incident Period
Expected result:
- region provides broader context
- incident marker is the first explicit focal object the eye lands on
- user can immediately tell both:
- which region is active
- which specific event to inspect
## Failure Modes To Avoid
### Region Too Strong
Symptoms:
- incident markers disappear into the glow
- users treat the region center as the main event
- the map feels like area flooding instead of event focus
### Region Too Weak
Symptoms:
- incident markers still feel isolated
- low-incident periods still look visually empty
- users cannot tell where routing activity is generally happening
### Region Uses Incident Language
Symptoms:
- region and incident both look like event markers
- users cannot distinguish context from event
## Final Design Rule
The desired reading order is:
1. see the specific incident marker
2. feel the active region around it
3. understand that collectors and background activity keep the globe alive even during quieter periods
In one sentence:
`incident is the point; region is the field.`

View File

@@ -0,0 +1,487 @@
# BGP Observability Plan
## Goal
Build a global routing observability capability on top of:
- [RIPE RIS Live](https://ris-live.ripe.net/)
- [CAIDA BGPStream data access overview](https://bgpstream.caida.org/docs/overview/data-access)
The target is to support:
- real-time routing event ingestion
- historical replay and baseline analysis
- anomaly detection
- Earth big-screen visualization
## Important Scope Note
These data sources expose the BGP control plane, not user traffic itself.
That means the system can infer:
- route propagation direction
- prefix reachability changes
- AS path changes
- visibility changes across collectors
But it cannot directly measure:
- exact application traffic volume
- exact user packet path
- real bandwidth consumption between countries or operators
Product wording should therefore use phrases like:
- global routing propagation
- route visibility
- control-plane anomalies
- suspected path diversion
Instead of claiming direct traffic measurement.
## Data Source Roles
### RIS Live
Use RIS Live as the real-time feed.
Recommended usage:
- subscribe to update streams over WebSocket
- ingest announcements and withdrawals continuously
- trigger low-latency alerts
Best suited for:
- hijack suspicion
- withdrawal bursts
- real-time path changes
- live Earth event overlay
### BGPStream
Use BGPStream as the historical and replay layer.
Recommended usage:
- backfill time windows
- build normal baselines
- compare current events against history
- support investigations and playback
Best suited for:
- historical anomaly confirmation
- baseline path frequency
- visibility baselines
- postmortem analysis
## Recommended Architecture
```mermaid
flowchart LR
A["RIS Live WebSocket"] --> B["Realtime Collector"]
C["BGPStream Historical Access"] --> D["Backfill Collector"]
B --> E["Normalization Layer"]
D --> E
E --> F["data_snapshots"]
E --> G["collected_data"]
E --> H["bgp_anomalies"]
H --> I["Alerts API"]
G --> J["Visualization API"]
H --> J
J --> K["Earth Big Screen"]
```
## Storage Design
The current project already has:
- [data_snapshot.py](/home/ray/dev/linkong/planet/backend/app/models/data_snapshot.py)
- [collected_data.py](/home/ray/dev/linkong/planet/backend/app/models/collected_data.py)
So the lowest-risk path is:
1. keep raw and normalized BGP events in `collected_data`
2. use `data_snapshots` to group each ingest window
3. add a dedicated anomaly table for higher-value derived events
## Proposed Data Types
### `collected_data`
Use these `source` values:
- `ris_live_bgp`
- `bgpstream_bgp`
Use these `data_type` values:
- `bgp_update`
- `bgp_rib`
- `bgp_visibility`
- `bgp_path_change`
Recommended stable fields:
- `source`
- `source_id`
- `entity_key`
- `data_type`
- `name`
- `reference_date`
- `metadata`
Recommended `entity_key` strategy:
- event entity: `collector|peer|prefix|event_time`
- prefix state entity: `collector|peer|prefix`
- origin state entity: `prefix|origin_asn`
### `metadata` schema for raw events
Store the normalized event payload in `metadata`:
```json
{
"project": "ris-live",
"collector": "rrc00",
"peer_asn": 3333,
"peer_ip": "2001:db8::1",
"event_type": "announcement",
"prefix": "203.0.113.0/24",
"origin_asn": 64496,
"as_path": [3333, 64500, 64496],
"communities": ["3333:100", "64500:1"],
"next_hop": "192.0.2.1",
"med": 0,
"local_pref": null,
"timestamp": "2026-03-26T08:00:00Z",
"raw_message": {}
}
```
### New anomaly table
Add a new table, recommended name: `bgp_anomalies`
Suggested columns:
- `id`
- `snapshot_id`
- `task_id`
- `source`
- `anomaly_type`
- `severity`
- `status`
- `entity_key`
- `prefix`
- `origin_asn`
- `new_origin_asn`
- `peer_scope`
- `started_at`
- `ended_at`
- `confidence`
- `summary`
- `evidence`
- `created_at`
This table should represent derived intelligence, not raw updates.
## Collector Design
## 1. `RISLiveCollector`
Responsibility:
- maintain WebSocket connection
- subscribe to relevant message types
- normalize messages
- write event batches into snapshots
- optionally emit derived anomalies in near real time
Suggested runtime mode:
- long-running background task
Suggested snapshot strategy:
- one snapshot per rolling time window
- for example every 1 minute or every 5 minutes
## 2. `BGPStreamBackfillCollector`
Responsibility:
- fetch historical data windows
- normalize to the same schema as real-time data
- build baselines
- re-run anomaly rules on past windows if needed
Suggested runtime mode:
- scheduled task
- or ad hoc task for investigations
Suggested snapshot strategy:
- one snapshot per historical query window
## Normalization Rules
Normalize both sources into the same internal event model.
Required normalized fields:
- `collector`
- `peer_asn`
- `peer_ip`
- `event_type`
- `prefix`
- `origin_asn`
- `as_path`
- `timestamp`
Derived normalized fields:
- `as_path_length`
- `country_guess`
- `prefix_length`
- `is_more_specific`
- `visibility_weight`
## Anomaly Detection Rules
Start with these five rules first.
### 1. Origin ASN Change
Trigger when:
- the same prefix is announced by a new origin ASN not seen in the baseline window
Use for:
- hijack suspicion
- origin drift detection
### 2. More-Specific Burst
Trigger when:
- a more-specific prefix appears suddenly
- especially from an unexpected origin ASN
Use for:
- subprefix hijack suspicion
### 3. Mass Withdrawal
Trigger when:
- the same prefix or ASN sees many withdrawals across collectors within a short window
Use for:
- outage suspicion
- regional incident detection
### 4. Path Deviation
Trigger when:
- AS path length jumps sharply
- or a rarely seen transit ASN appears
- or path frequency drops below baseline norms
Use for:
- route leak suspicion
- unusual path diversion
### 5. Visibility Drop
Trigger when:
- a prefix is visible from far fewer collectors/peers than its baseline
Use for:
- regional reachability degradation
## Baseline Strategy
Use BGPStream historical data to build:
- common origin ASN per prefix
- common AS path patterns
- collector visibility distribution
- normal withdrawal frequency
Recommended baseline windows:
- short baseline: last 24 hours
- medium baseline: last 7 days
- long baseline: last 30 days
The first implementation can start with only the 7-day baseline.
## API Design
### Raw event API
Add endpoints like:
- `GET /api/v1/bgp/events`
- `GET /api/v1/bgp/events/{id}`
Suggested filters:
- `prefix`
- `origin_asn`
- `peer_asn`
- `collector`
- `event_type`
- `time_from`
- `time_to`
- `source`
### Anomaly API
Add endpoints like:
- `GET /api/v1/bgp/anomalies`
- `GET /api/v1/bgp/anomalies/{id}`
- `GET /api/v1/bgp/anomalies/summary`
Suggested filters:
- `severity`
- `anomaly_type`
- `status`
- `prefix`
- `origin_asn`
- `time_from`
- `time_to`
### Visualization API
Add an Earth-oriented endpoint like:
- `GET /api/v1/visualization/geo/bgp-anomalies`
Recommended feature shapes:
- point: collector locations
- arc: inferred propagation or suspicious path edge
- pulse point: active anomaly hotspot
## Earth Big-Screen Design
Recommended layers:
### Layer 1: Collector layer
Show known collector locations and current activity intensity.
### Layer 2: Route propagation arcs
Use arcs for:
- origin ASN country to collector country
- or collector-to-collector visibility edges
Important note:
This is an inferred propagation view, not real packet flow.
### Layer 3: Active anomaly overlay
Show:
- hijack suspicion in red
- mass withdrawal in orange
- visibility drop in yellow
- path deviation in blue
### Layer 4: Time playback
Use `data_snapshots` to replay:
- minute-by-minute route changes
- anomaly expansion
- recovery timeline
## Alerting Strategy
Map anomaly severity to the current alert system.
Recommended severity mapping:
- `critical`
- likely hijack
- very large withdrawal burst
- `high`
- clear origin change
- large visibility drop
- `medium`
- unusual path change
- moderate more-specific burst
- `low`
- weak or localized anomalies
## Delivery Plan
### Phase 1
- add `RISLiveCollector`
- normalize updates into `collected_data`
- create `bgp_anomalies`
- implement 3 rules:
- origin change
- more-specific burst
- mass withdrawal
### Phase 2
- add `BGPStreamBackfillCollector`
- build 7-day baseline
- implement:
- path deviation
- visibility drop
### Phase 3
- add Earth visualization layer
- add time playback
- add anomaly filtering and drilldown
## Practical Implementation Notes
- Start with IPv4 first, then add IPv6 after the event schema is stable.
- Store the original raw payload in `metadata.raw_message` for traceability.
- Deduplicate events by a stable hash of collector, peer, prefix, type, and timestamp.
- Keep anomaly generation idempotent so replay and backfill do not create duplicate alerts.
- Expect noisy data and partial views; confidence scoring matters.
## Recommended First Patch Set
The first code milestone should include:
1. `backend/app/services/collectors/ris_live.py`
2. `backend/app/services/collectors/bgpstream.py`
3. `backend/app/models/bgp_anomaly.py`
4. `backend/app/api/v1/bgp.py`
5. `backend/app/api/v1/visualization.py`
add BGP anomaly geo endpoint
6. `frontend/src/pages`
add a BGP anomaly list or summary page
7. `frontend/public/earth/js`
add BGP anomaly rendering layer
## Sources
- [RIPE RIS Live](https://ris-live.ripe.net/)
- [CAIDA BGPStream Data Access Overview](https://bgpstream.caida.org/docs/overview/data-access)

View File

@@ -0,0 +1,422 @@
# BGP Region Aggregation Plan
## Goal
This document refines the current BGP `activity layer` into an implementation-ready regional aggregation design.
Primary product goal:
- turn sparse prefix-level observations, anomalies, and incidents into a readable `regional observability layer`
- keep Earth visually alive during low-incident periods
- make `incident markers` remain the highest-confidence foreground layer instead of replacing them
This layer is not a new collector, detector, or raw storage table.
It is an aggregation/view-model layer:
`observations -> enrichment -> anomalies/incidents -> geography mapping -> region aggregation -> Earth/UI activity layer`
## Why This Layer Exists
Current product gap from [bgp-context.md](/home/ray/dev/linkong/planet/docs/bgp-context.md):
- incident density is naturally low
- anomaly density is higher, but still not enough to keep the globe expressive all the time
- collector presence alone proves coverage, but does not communicate `where routing is currently active or noisy`
So the missing middle layer is:
- `collectors` show that observation exists
- `regions` show where activity is building up
- `incidents` show the specific high-confidence focus events
## Scope
This plan is specifically for:
- a backend aggregation service
- a summary API for console/stats
- a GeoJSON API for Earth rendering
- an Earth background activity layer that supports, but does not replace, incident markers
This plan does not attempt to solve:
- exact prefix geolocation quality
- polygon-heavy geopolitical visualization
- persistent materialized region tables in v1
## Region Layer Definition
Recommended conceptual model:
- `region layer` = background situational awareness
- `incident layer` = focal event markers
That means:
- region activity should answer `where is routing behavior currently active or abnormal`
- incident markers should answer `which concrete event should the user click`
## Recommended Output Model
Suggested backend output object:
## `BGPRegionActivity`
```json
{
"region_key": "sea",
"region_name": "Southeast Asia",
"center_lat": 1.3521,
"center_lon": 103.8198,
"observation_count": 128,
"anomaly_count": 9,
"incident_count": 2,
"activity_score": 17.6,
"status": "incident",
"affected_prefix_count": 14,
"affected_asn_count": 6,
"collector_count": 5,
"first_seen_at": "2026-04-02T10:00:00Z",
"last_seen_at": "2026-04-02T10:12:00Z"
}
```
### Fields To Keep In MVP
- `region_key`
- `region_name`
- `center_lat`
- `center_lon`
- `observation_count`
- `anomaly_count`
- `incident_count`
- `activity_score`
- `status`
- `affected_prefix_count`
- `affected_asn_count`
- `collector_count`
- `first_seen_at`
- `last_seen_at`
### Fields To Delay
These are useful, but not required for the first implementation:
- `bounding_box`
- `top_incident_types`
- `top_prefixes`
- polygon geometry
## Region Definition Strategy
### Recommendation
Use a static region-definition table first.
Examples:
- `north_america`
- `south_america`
- `western_europe`
- `eastern_europe`
- `east_asia`
- `southeast_asia`
- `south_asia`
- `middle_east`
- `north_africa`
- `sub_saharan_africa`
- `oceania`
Why this is the right v1 choice:
- stable UI semantics
- strong readability on Earth
- easier debugging and explanation
- lower implementation cost than geohash or H3 grids
### Not Recommended For V1
- geohash cell aggregation
- H3 aggregation
- fine-grained lat/lon bucket maps
Those are more flexible, but they make the map feel fragmented and less explainable.
## Geography Mapping Strategy
Do not reduce the implementation to only `prefix -> exact geo`.
The region layer should follow the same geography-priority logic already implied by the current BGP direction:
1. `prefix_geography`
2. `prefix_scope`
3. `ASN organization region`
4. `collector centroid` fallback
This matters because exact prefix geography will often be incomplete or approximate.
The region layer should stay robust even when only partial enrichment is available.
## Backend Design
Recommended new service file:
- [backend/app/services/bgp_regions.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_regions.py)
Suggested responsibilities:
- `map_record_to_region(...)`
- `aggregate_region_activity(...)`
- `build_region_geojson(...)`
- `resolve_activity_status(...)`
- `compute_activity_score(...)`
### Data Source Inputs
Use a recent rolling window, default `15 minutes`, and aggregate from:
- `BGPObservation`
- `BGPAnomaly`
- active `BGPIncident`
### Aggregation Flow
1. query observations in the time window
2. query anomalies in the same window
3. query active incidents in the same window or active status set
4. resolve each record to a best-effort region
5. accumulate per-region counters
6. compute score and status
7. return region activity list
## Status Model
Recommended status buckets:
- `idle`
- `observing`
- `anomaly`
- `incident`
Suggested rule:
```text
if incident_count > 0: incident
elif anomaly_count > 0: anomaly
elif observation_count > 0: observing
else: idle
```
This aligns well with the current Earth status language and keeps the visual mapping simple.
## Activity Score
The score should be a tunable heuristic, not a fixed truth model.
Recommended v1 formula:
```text
activity_score =
min(observation_count, 50) * 0.03
+ anomaly_count * 1.2
+ incident_count * 5.0
```
Why cap observations:
- observation volume is usually much larger than anomaly or incident volume
- uncapped observation counts would overwhelm the score
- capped observation counts preserve baseline presence without drowning real abnormality
### Practical Guidance
- treat coefficients as configuration-like constants
- expect to retune after looking at real data
- keep `incident` weight dominant
## API Design
### 1. Summary/List API
Suggested endpoint:
- `/api/v1/bgp/regions/activity`
Response shape:
```json
{
"window_minutes": 15,
"regions": []
}
```
Use cases:
- BGP console summaries
- right-side Earth stats
- future region list panels
### 2. GeoJSON API
Suggested endpoint:
- `/api/v1/visualization/geo/bgp-regions`
Response shape:
```json
{
"type": "FeatureCollection",
"features": []
}
```
Each feature should include:
- `geometry`
- v1: `Point`
- later: optional `Polygon`
- `properties`
- `region_key`
- `region_name`
- `status`
- `activity_score`
- `observation_count`
- `anomaly_count`
- `incident_count`
- `affected_prefix_count`
- `affected_asn_count`
- `collector_count`
## Earth Rendering Plan
Detailed visual layering guidance is expanded in [bgp-earth-rendering-plan.md](/home/ray/dev/linkong/planet/docs/bgp-earth-rendering-plan.md).
### Layer Relationship
- `region layer` = ambient background activity
- `incident marker` = focal event object
Do not replace incident markers with region markers.
### Region Visual Rules
Suggested mapping:
- `observing`
- weak glow
- low pulse or no pulse
- `anomaly`
- stronger glow
- more visible pulse
- `incident`
- strongest regional emphasis
- but still visually secondary to the incident marker itself
### Region Labels
Good v2 enhancement:
- show region name
- show counts like `2 incidents / 5 anomalies`
This is useful, but should come after the core aggregation and Earth glow layer are working.
## Interaction Model
### Click Region
Recommended detail payload:
- region name
- observation/anomaly/incident counts in the selected window
- affected prefix count
- affected ASN count
- collector count
- recent incidents in the region
### Click Incident
Keep the current incident-detail flow.
Interaction should feel hierarchical:
1. region gives situational context
2. incident gives event focus
## MVP Implementation Order
### Step 1
Define static `REGIONS` in code or config.
### Step 2
Map geography-enriched BGP records into regions using the fallback chain.
### Step 3
Aggregate recent window counts:
- `observation_count`
- `anomaly_count`
- `incident_count`
### Step 4
Compute `activity_score` and `status`.
### Step 5
Expose:
- `/api/v1/bgp/regions/activity`
- `/api/v1/visualization/geo/bgp-regions`
### Step 6
Render region glows on Earth behind incident markers.
## Out Of Scope For MVP
- persistent materialized region tables
- geohash or H3 support
- polygon-filled regional overlays
- detailed top-prefix ranking in the first release
- complicated scoring personalization
## Risks And Constraints
### Geography Quality
Prefix geography is approximate and incomplete.
The region layer must tolerate fallback-based placement.
### Query Cost
Dynamic aggregation is the right v1 choice, but repeated short-window queries may eventually need:
- in-process caching
- scheduled pre-aggregation
- materialized summaries
### UI Overcrowding
If region glow, collector activity, and incidents all become too strong at once, Earth readability will regress.
The region layer must remain supportive, not dominant.
## Final Recommendation
The current BGP roadmap should explicitly add:
- `region aggregation` as the concrete implementation of the missing `activity layer`
The recommended product interpretation is:
- `collectors` prove observation coverage
- `regions` communicate live routing activity and abnormality
- `incidents` remain the clearest high-confidence event objects
In one sentence:
`region aggregation is not a replacement for incidents; it is the situational background that makes sparse incidents feel legible on Earth.`

210
docs/earth-module-plan.md Normal file
View File

@@ -0,0 +1,210 @@
# Earth 模块整治计划
## 背景
`planet` 前端中的 Earth 模块是当前最重要的大屏 3D 星球展示能力,但它仍以 legacy iframe 页面形式存在:
- React 页面入口仅为 [Earth.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Earth/Earth.tsx)
- 实际 3D 实现位于 [frontend/public/earth](/home/ray/dev/linkong/planet/frontend/public/earth)
当前模块已经具备基础展示能力,但在生命周期、性能、可恢复性、可维护性方面存在明显隐患,不适合长期无人值守的大屏场景直接扩展。
## 目标
本计划的目标不是立刻重写 Earth而是分阶段把它从“能跑的 legacy 展示页”提升到“可稳定运行、可持续演进的大屏核心模块”。
核心目标:
1. 先止血,解决资源泄漏、重载污染、假性卡顿等稳定性问题
2. 再梳理数据加载、交互和渲染循环,降低性能风险
3. 最后逐步从 iframe legacy 向可控模块化架构迁移
## 现阶段主要问题
### 1. 生命周期缺失
- 没有统一 `destroy()` / 卸载清理逻辑
- `requestAnimationFrame`
- `window/document/dom listeners`
- `THREE` geometry / material / texture
- 运行时全局状态
都没有系统回收
### 2. 数据重载不完整
- `reloadData()` 没有彻底清理旧场景对象
- cable、landing point、satellite 相关缓存与对象存在累积风险
### 3. 渲染与命中检测成本高
- 鼠标移动时频繁创建 `Raycaster` / `Vector2`
- cable 命中前会重复做 bounding box 计算
- 卫星每帧计算量偏高
### 4. 状态管理分裂
- 大量依赖 `window.*` 全局桥接
- 模块之间靠隐式共享状态通信
- React 外层无法有效感知 Earth 内部状态
### 5. 错误恢复弱
- 数据加载失败主要依赖 `console` 和轻提示
- 缺少统一重试、降级、局部失败隔离机制
## 分阶段计划
## Phase 1稳定性止血
目标:
- 不改视觉主形态
- 优先解决泄漏、卡死、重载污染
### 任务
1. 补 Earth 生命周期管理
- 为 [main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js) 增加:
- `init()`
- `destroy()`
- `reloadData()`
三类明确入口
- 统一记录并释放:
- animation frame id
- interval / timeout
- DOM 事件监听
- `window` 暴露对象
2. 增加场景对象清理层
- 为 cable / landing point / satellite sprite / orbit line 提供统一清理函数
- reload 前先 dispose 旧对象,再重新加载
3. 增加 stale 状态恢复
- 页面重新进入时先清理上一次遗留选择态、hover 态、锁定态
- 避免 iframe reload 后出现旧状态残留
4. 加强失败提示
- 电缆、登陆点、卫星加载拆分为独立状态
- 某一类数据失败时,其它类型仍可继续显示
- 提供明确的页面内提示而不是只打 console
### 验收标准
- 页面重复进入 / 离开后内存不持续上涨
- 连续多次点“重新加载数据”后对象数量不异常增加
- 单一数据源加载失败时页面不整体失效
## Phase 2性能优化
目标:
- 控制鼠标交互和动画循环成本
- 提升大屏长时间运行的稳定帧率
### 任务
1. 复用交互对象
- 复用 `Raycaster``Vector2`、中间 `Vector3`
- 避免 `mousemove` 热路径中频繁 new 对象
2. 优化 cable 命中逻辑
- 提前缓存 cable 中心点 / bounding 数据
- 移除 `mousemove` 内重复 `computeBoundingBox()`
- 必要时增加分层命中:
- 先粗筛
- 再精确相交
3. 改造动画循环
- 使用真实 `deltaTime`
- 把卫星位置更新、呼吸动画、视觉状态更新拆成独立阶段
- 为不可见对象减少无意义更新
4. 卫星轨迹与预测轨道优化
- 评估轨迹更新频率
- 对高开销几何计算增加缓存
- 限制预测轨道生成频次
### 验收标准
- 鼠标移动时不明显掉帧
- 中高数据量下动画速度不受帧率明显影响
- 长时间运行 CPU/GPU 占用更平稳
## Phase 3架构收编
目标:
- 降低 legacy iframe 架构带来的维护成本
- 让 React 主应用重新获得对 Earth 模块的控制力
### 任务
1. 抽离 Earth App Shell
- 将数据加载、错误状态、控制面板状态抽到更明确的模块边界
- 减少 `window.*` 全局依赖
2. 规范模块通信
- 统一 `main / controls / cables / satellites / ui` 的状态流
- 明确只读配置、运行时状态、渲染对象的职责分层
3. 评估去 iframe 迁移
- 中期可以保留 public/legacy 资源目录
- 但逐步把 Earth 作为前端内嵌模块而不是完全孤立页面
### 验收标准
- Earth 内部状态不再大量依赖全局变量
- React 外层可以感知 Earth 加载状态和错误状态
- 后续功能开发不再必须修改多个 legacy 文件才能完成
## 优先级建议
### P0
- 生命周期清理
- reload 清理
- stale 状态恢复
### P1
- 命中检测优化
- 动画 `deltaTime`
- 数据加载失败隔离
### P2
- 全局状态收编
- iframe 架构迁移
## 推荐实施顺序
1. 先做 Phase 1
2. 再做交互热路径与动画循环优化
3. 最后再考虑架构迁移
## 风险提示
1. Earth 是 legacy 模块,修复时容易牵一发而动全身
2. 如果不先补清理逻辑,后续所有性能优化收益都会被泄漏问题吃掉
3. 如果过早重写而不先止血,短期会影响现有演示稳定性
## 当前建议
最值得马上启动的是一个小范围稳定性 sprint
- 生命周期清理
- reload 全量清理
- 错误状态隔离
这个阶段不追求“更炫”,先追求“更稳”。稳定下来之后,再进入性能和架构层的优化。

View File

@@ -0,0 +1,216 @@
# Prefix Geography Plan
## Goal
Make Earth BGP incidents `prefix-centric` instead of `collector-centric`.
The map should primarily answer:
- where a prefix-related event is likely centered
- which regions the prefix is likely associated with
- which collectors observed the event as evidence
It should not continue to imply that the event is located at the collector itself unless no better geography is available.
## Why Current Geography Is Not Enough
Current incident geography can still collapse back to collector-derived regions because:
1. `prefix_scope` is currently built mostly from observed collector regions and historical observation regions.
2. `origin_asn_profile` currently comes from `peeringdb_network`, which is useful for ASN footprint hints but not sufficient as a primary prefix location source.
3. `collector centroid` is still a common fallback and therefore dominates sparse incidents.
This makes Earth feel like a collector map with event decorations instead of a prefix impact map.
## Data Source Layers
Prefix geography should be built from four layers, ordered by confidence.
### Layer 1. Prefix-to-country / prefix-to-region
This is the primary source layer and the current missing piece.
Recommended sources:
1. `IPtoASN / IPtoCountry`
- URL: <https://iptoasn.com/>
- Good fit for this project because it provides downloadable IPv4/IPv6 range-to-ASN and range-to-country mappings.
- Best use:
- map a prefix to country code
- enrich prefixes with coarse regional placement
2. `OpenGeoFeed`
- URL: <https://opengeofeed.org/faq/>
- Best use:
- override coarse country mappings when the prefix holder publishes a geofeed
- provide a more realistic deployment/service region than whois-style registration country
### Layer 2. Registry allocation fallback
Use these only as fallback signals, not as a ground-truth physical location.
Candidate inputs:
- RIR delegated stats
- `inetnum` / `inet6num` whois
Best use:
- detect registration country / allocation region
- provide fallback when no direct prefix geolocation dataset is available
### Layer 3. ASN footprint hints
Existing in this project:
- `peeringdb_network`
- `peeringdb_facility`
- `peeringdb_ixp`
Best use:
- derive ASN city/country footprint
- identify likely exchange/facility regions
- act as secondary evidence when prefix-specific geography is unavailable
### Layer 4. Observation evidence
Existing in this project:
- `RIPE RIS Live`
- `CAIDA BGPStream Backfill`
Best use:
- prove who observed the event
- derive affected observation regions
- support impact evidence
This should remain the final fallback and evidence layer, not the primary event geography.
## Recommended Geography Priority
The backend should compute incident geography with this order:
1. `prefix_geography`
- prefix-to-country / region / geofeed-backed result
2. `asn_region`
- ASN organization / facility / IXP footprint
3. `collector_centroid`
- observed collector regions only as final fallback
Returned GeoJSON should keep exposing the selected mode through:
- `geography_mode = prefix_geography | asn_region | collector_centroid`
## Proposed Backend Changes
### 1. Add a dedicated prefix geography dataset
New datasource candidates:
- `ip2asn_prefix_geo`
- optionally `opengeofeed_prefix_geo`
Suggested storage model:
- keep downloaded rows in `CollectedData` first for speed of integration
- later move to a dedicated table if lookup volume grows
Minimum normalized fields:
- `range_start`
- `range_end`
- `prefix`
- `country`
- `continent`
- `asn`
- `as_name`
- `source`
- `confidence`
### 2. Add prefix geography enrichment
Extend:
- `backend/app/services/bgp_enrichment.py`
New enrichment payload should include:
- `prefix_geography`
- `country`
- `continent`
- `regions`
- `source`
- `confidence`
This should be separate from the current `prefix_scope`.
Suggested distinction:
- `prefix_scope`
- observation-derived scope hint
- `prefix_geography`
- prefix-centric geography estimate
### 3. Update incident visualization geography selection
Extend:
- `backend/app/api/v1/visualization.py`
Selection order:
1. `prefix_geography.regions`
2. ASN geography hints from PeeringDB-derived profile
3. observation-derived `affected_regions`
### 4. Keep evidence visible in the frontend
Earth should distinguish:
- event center = prefix geography estimate
- evidence lines / collectors = observation proof
This keeps the event meaningful for non-expert users without losing collector evidence.
## Earth UX Result
After this change, a user should see:
- an incident marker near the estimated affected prefix region
- collectors as supporting evidence, not as the event center itself
- cables / landing points / nearby infrastructure as weak correlation around the estimated region
This makes BGP incidents readable as “where the event is likely happening or affecting”, instead of “which station saw it”.
## Implementation Order
### Phase 1
1. Add `IPtoASN / IPtoCountry` datasource support
2. Normalize rows into lookup-friendly format
3. Enrich BGP events with `prefix_geography`
4. Switch incident geography priority to prefer `prefix_geography`
### Phase 2
5. Add `OpenGeoFeed` support
6. Let geofeed override coarse country-level prefix geography
7. Add confidence scoring per geography source
### Phase 3
8. Add RIR / whois fallback
9. Add better ASN regional footprint from PeeringDB facilities / IXPs
10. Refine Earth visual semantics for prefix geography vs observation evidence
## Recommendation
The best next engineering move is:
1. integrate `IPtoASN / IPtoCountry`
2. model `prefix_geography` separately from `prefix_scope`
3. only then continue refining incident map placement
Without this layer, any further Earth tuning will still be constrained by collector-centric data.

View File

@@ -0,0 +1,347 @@
# System Service Control
This document defines the fixed mapping between admin control-plane actions and
the existing `planet.sh` service-management commands.
The goal is to reuse the current operational script semantics without exposing
arbitrary shell execution to the frontend or API callers.
## Scope
- This mapping is for admin-side operational controls only.
- The control plane must submit a fixed action name, not a raw shell command.
- The backend is responsible for translating an allowed action into a fixed
`planet.sh` invocation.
## Design Rules
- Only whitelist actions may be executed.
- The frontend must never send arbitrary shell strings.
- The backend must build command arguments from a fixed mapping table.
- High-risk actions should be restricted to `super_admin`.
- Prefer partial restarts over full-stack restarts when UI continuity matters.
## Action Mapping
| Action name | Intended use | `planet.sh` command | Notes |
| --- | --- | --- | --- |
| `restart-backend` | Restart backend API only | `./planet.sh restart -b` | Recommended first implementation for UI-triggered restart flows. |
| `restart-database` | Restart PostgreSQL and Redis containers | `./planet.sh restart -d` | Useful when database/cache services need a controlled bounce without restarting the UI. |
| `restart-system` | Restart the whole application stack | `./planet.sh restart` | Frontend continuity breaks briefly; UI should switch to guided recovery mode. |
| `restart-frontend` | Restart frontend dev server only | `./planet.sh restart -f` | Use with caution; UI continuity is weaker than backend-only restart. |
| `restart-backend-port` | Restart backend on a specific port | `./planet.sh restart -b <port>` | Port must be backend-validated before execution. |
| `restart-frontend-port` | Restart frontend on a specific port | `./planet.sh restart -f <port>` | Port must be backend-validated before execution. |
| `health-check` | Read current service health | `./planet.sh health` | Safe read-only operational action. |
| `show-logs-backend` | Inspect backend logs | `./planet.sh log -b` | Best used for CLI/operator tooling, not normal Web UI streaming. |
| `show-logs-frontend` | Inspect frontend logs | `./planet.sh log -f` | Best used for CLI/operator tooling, not normal Web UI streaming. |
## Not Exposed In UI By Default
The following existing script capabilities should not be exposed directly in the
Web UI unless there is an explicit product need and an additional safety review:
- `./planet.sh restart`
- `./planet.sh start`
- `./planet.sh stop`
- `./planet.sh createuser`
- any future raw shell passthrough
Reason:
- full restart can break the current control session;
- stop/start have larger blast radius;
- user creation is not a service-control operation;
- raw shell passthrough creates unnecessary privilege risk.
## Recommended First-Phase UI Contract
### Frontend action payload
```json
{
"action": "restart-backend"
}
```
### Backend command resolution
```text
restart-backend -> ["./planet.sh", "restart", "-b"]
restart-database -> ["./planet.sh", "restart", "-d"]
restart-system -> ["./planet.sh", "restart"]
restart-frontend -> ["./planet.sh", "restart", "-f"]
health-check -> ["./planet.sh", "health"]
```
## API Draft
### Primary Endpoint
- `POST /api/v1/system/restart-tasks`
Purpose:
- create a controlled restart task;
- resolve a whitelist action into a fixed `planet.sh` command;
- hand execution off to an external runner or detached subprocess.
### Request Body
```json
{
"action": "restart-backend"
}
```
Optional future shape:
```json
{
"action": "restart-backend-port",
"port": 8000
}
```
### Response
```json
{
"task_id": "restart_20260331_153000_ab12cd",
"action": "restart-backend",
"status": "queued",
"stage": "accepted",
"message": "Restart task accepted"
}
```
### Task Query Endpoint
- `GET /api/v1/system/restart-tasks/{task_id}`
Response shape:
```json
{
"task_id": "restart_20260331_153000_ab12cd",
"action": "restart-backend",
"status": "queued",
"stage": "accepted",
"message": "Waiting for execution",
"requested_by": {
"id": 1,
"username": "admin"
},
"created_at": "2026-03-31T15:30:00+08:00",
"updated_at": "2026-03-31T15:30:02+08:00"
}
```
### Optional Log Endpoint
- `GET /api/v1/system/restart-tasks/{task_id}/logs`
Suggested response:
```json
{
"task_id": "restart_20260331_153000_ab12cd",
"lines": [
"accepted restart-backend request",
"spawning restart command",
"waiting for backend shutdown",
"waiting for backend health recovery"
]
}
```
This log endpoint is optional for phase one. The first version can work with
task state plus `/health` polling alone.
## Task State Model
### Status
- `queued`
- `running`
- `succeeded`
- `failed`
- `timeout`
### Stage
- `accepted`
- `spawning`
- `stopping`
- `starting`
- `waiting_for_health`
- `healthy`
- `failed`
### Interpretation
- `status` is the high-level terminal or non-terminal state.
- `stage` is the operator-facing execution phase for the UI.
- `message` is the short human-readable line shown in the modal or full-screen
overlay.
## Permission Model
- `restart-backend` should require `super_admin`.
- Permission checks should follow the same role pattern already used in
[users.py](/home/ray/dev/linkong/planet/backend/app/api/v1/users.py).
- Frontend visibility may hide controls for non-`super_admin`, but backend must
still enforce authorization.
## Storage Model
Recommended first implementation:
- store restart task state in Redis;
- keep task lifetime short;
- keep recent logs as a bounded list.
Suggested keys:
- `system:restart_task:{task_id}`
- `system:restart_task:{task_id}:logs`
Suggested stored fields:
- `task_id`
- `action`
- `status`
- `stage`
- `message`
- `requested_by_id`
- `requested_by_username`
- `created_at`
- `updated_at`
## Execution Model
The request-handling API process should not depend on itself surviving long
enough to stream the whole restart output.
Recommended execution flow:
1. validate caller and action
2. create task state in Redis
3. resolve action to fixed `planet.sh` argv
4. spawn detached executor
5. return `task_id`
6. executor updates task state while restart is in progress
7. frontend polls health and/or task state until recovery
Recommended command resolution examples:
```text
restart-backend -> ["./planet.sh", "restart", "-b"]
restart-frontend -> ["./planet.sh", "restart", "-f"]
restart-backend-port -> ["./planet.sh", "restart", "-b", "<port>"]
health-check -> ["./planet.sh", "health"]
```
## Frontend Polling Flow
Recommended first-phase UX:
1. user clicks `重启后端`
2. confirmation modal explains temporary unavailability
3. frontend calls `POST /api/v1/system/restart-tasks`
4. UI enters blocking restart state
5. frontend polls `/health` every `1-2s`
6. temporary request failures are treated as expected
7. after `2-3` consecutive successful health checks, frontend reloads page
Optional richer polling:
1. poll task status endpoint while backend is still reachable
2. switch to `/health` recovery polling after disconnect begins
3. refresh page after health recovery
## Frontend State Machine
- `idle`
- `confirming`
- `submitting`
- `waiting_for_shutdown`
- `waiting_for_recovery`
- `recovered`
- `failed`
- `timeout`
Suggested UI messages:
- `已发送重启指令`
- `正在停止后端服务`
- `正在等待服务恢复`
- `服务已恢复,正在刷新页面`
- `恢复超时,请手动检查服务状态`
## Phase-One Recommendation
Implement only the following in phase one:
- `restart-backend`
- `super_admin` permission gate
- task creation endpoint
- Redis-backed task state
- frontend confirmation modal
- frontend `/health` polling
- automatic page reload after recovery
Do not implement in phase one:
- full `./planet.sh restart`
- raw shell command passthrough
- arbitrary service control
- full terminal stdout streaming
- multi-action concurrent restart queueing
## Implementation Checklist
### Backend
1. add a dedicated system-control API module under `backend/app/api/v1/`
2. add a whitelist-based action resolver for `planet.sh`
3. store restart task state in Redis
4. add detached restart-runner script execution
5. expose:
- `POST /api/v1/system/restart-tasks`
- `GET /api/v1/system/restart-tasks/{task_id}`
- optional task log endpoint
6. enforce `super_admin` permission on all restart-task endpoints
### Frontend
1. add a `重启后端` control on the dashboard for `super_admin`
2. show a confirmation modal before dispatch
3. after submission, switch modal into blocking restart state
4. poll `/health` until backend recovery is confirmed
5. auto-refresh page after consecutive successful health checks
6. show short stage-oriented logs instead of raw terminal streaming
### Operational Notes
1. phase one should target backend-only restart
2. frontend restart should remain out of scope initially
3. command execution must always originate from repository root
4. only fixed action names may cross the API boundary
## Validation Requirements
- Reject any action not present in the whitelist.
- If a port-bearing action is added, validate the port as an integer in
`1..65535`.
- Resolve commands from the repository root so `planet.sh` runs with a stable
working directory.
- Record the requested action, operator identity, execution start time, and
result.
## Implementation Guidance
- For UI-triggered restart flows, prefer `restart-backend` first.
- Do not rely on the current API request process to stream full restart output
after it triggers its own restart.
- Use a task record plus polling/health-check recovery flow instead of raw
terminal streaming as the primary UX.

86
docs/version-history.md Normal file
View File

@@ -0,0 +1,86 @@
# Version History
## Rules
- 初始版本从 `0.0.1-beta` 开始
- 每次 `bugfix` 递增 `0.0.1`
- 每次 `feature` 递增 `0.1.0`
- `refactor / docs / maintenance` 默认不单独 bump 版本
## Assumptions
- 本文基于 `main``dev` 的非 merge commit 历史整理
- 对于既包含修复又明显引入新能力的提交,按 `feature` 处理
- `main` 表示已进入主线,`dev` 表示当前仍在开发分支上的增量
## Current Version
- `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.23.0`
## Timeline
| Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- |
| `0.0.1-beta` | bootstrap | `main` | `e7033775` | first commit |
| `0.1.0` | feature | `main` | `6cb4398f` | Modularize 3D Earth page with ES Modules |
| `0.2.0` | feature | `main` | `aaae6a53` | Add cable graph service and data collectors |
| `0.2.1` | bugfix | `main` | `ceb1b728` | highlight all cable segments by cable_id |
| `0.3.0` | feature | `main` | `14d11cd9` | add ArcGIS landing points and cable-landing relation collectors |
| `0.4.0` | feature | `main` | `99771a88` | make ArcGIS data source URLs configurable |
| `0.5.0` | feature | `main` | `de325521` | add data sources config system and Earth API integration |
| `0.5.1` | bugfix | `main` | `b06cb460` | remove ignored files from tracking |
| `0.5.2` | bugfix | `main` | `948af2c8` | fix coordinates-display position |
| `0.6.0` | feature | `main` | `4e487b31` | upload new geo json |
| `0.6.1` | bugfix | `main` | `02991730` | add cable_id to API response for cable highlighting |
| `0.6.2` | bugfix | `main` | `c82e1d5a` | 修复 3D 地球坐标映射多个严重 bug |
| `0.7.0` | feature | `main` | `3b0e9dec` | 统一卫星和线缆锁定逻辑,使用 lockedObject 系统 |
| `0.7.1` | bugfix | `main` | `11a9dda9` | 修复 resetView 调用并统一启动脚本到 `planet.sh` |
| `0.7.2` | bugfix | `main` | `e21b783b` | 修复 ArcGIS landing GeoJSON 坐标解析错误 |
| `0.8.0` | feature | `main` | `f5083071` | 自动旋转按钮改为播放/暂停图标状态 |
| `0.8.1` | bugfix | `main` | `777891f8` | 修复 resetView 视角和离开地球隐藏 tooltip |
| `0.9.0` | feature | `main` | `1189fec0` | init view to China coordinates |
| `0.10.0` | feature | `main` | `6fabbcfe` | request geolocation on resetView, fallback to China |
| `0.11.0` | feature | `main` | `0ecc1bc5` | cable state management, hover/lock visual separation |
| `0.12.0` | feature | `main` | `bb6b18fe` | satellite dot rendering with hover/lock rings |
| `0.13.0` | feature | `main` | `3fcbae55` | add cable-landing point relation via `city_id` |
| `0.14.0` | feature | `main` | `96222b9e` | toolbar layout and cable breathing effect improvements |
| `0.15.0` | feature | `main` | `49a9c338` | toolbar and zoom improvements |
| `0.16.0` | feature | `main` | `78bb639a` | toolbar zoom improvements and toggle-cables |
| `0.16.1` | bugfix | `main` | `d9a64f77` | fix iframe scrollbar issue |
| `0.16.2` | bugfix | `main` | `af29e90c` | prevent cable hover/click when cables are hidden |
| `0.16.3` | bugfix | `main` | `eabdbdc8` | clear lock state when hiding satellites or cables |
| `0.16.4` | bugfix | `main` | `0c950262` | fix satellite trail origin line and sync button state |
| `0.16.5` | bugfix | `main` | `9d135bf2` | revert unstable toolbar change |
| `0.16.6` | bugfix | `dev` | `465129ee` | use timestamp-based trail filtering to prevent flash |
| `0.17.0` | feature | `dev` | `1784c057` | add predicted orbit display for locked satellites |
| `0.17.1` | bugfix | `dev` | `543fe35f` | fix ring size attenuation and breathing animation |
| `0.17.2` | bugfix | `dev` | `b9fbacad` | prevent selecting satellites on far side of earth |
| `0.17.3` | bugfix | `dev` | `b57d69c9` | remove debug console.log for ring create/update |
| `0.17.4` | bugfix | `dev` | `81a0ca5e` | fix back-facing detection with proper coordinate transform |
| `0.18.0` | feature | `dev` | `ef0fefdf` | persist system settings and refine admin layouts |
| `0.18.1` | bugfix | `dev` | `cc5f16f8` | fix settings layout and frontend startup checks |
| `0.19.0` | feature | `dev` | `020c1d50` | refine data management and collection workflows |
| `0.20.0` | feature | `dev` | `ce5feba3` | stabilize Earth module and fix satellite TLE handling |
| `0.21.0` | feature | `dev` | `pending` | add Earth inertial drag, sync hover/trail state, and support unlimited satellite loading |
| `0.21.1` | bugfix | `dev` | `pending` | polish Earth toolbar controls, icons, and loading copy |
| `0.21.2` | bugfix | `dev` | `pending` | redesign Earth HUD with liquid-glass controls, dynamic legend switching, and info-card interaction polish |
| `0.21.3` | bugfix | `dev` | `30a29a6e` | harden `planet.sh` startup controls, add selective restart and interactive user creation |
| `0.21.4` | bugfix | `dev` | `7ec9586f` | add Earth HUD backup snapshots and icon assets |
| `0.21.5` | bugfix | `dev` | `a761dfc5` | refine Earth legend item presentation |
| `0.21.6` | bugfix | `dev` | `pending` | improve Earth legend generation, info-card interactions, and HUD messaging polish |
| `0.22.9` | bugfix | `dev` | `6bfcd053` | simplify `planet.sh` readiness messaging and only show retry counts on actual restart |
| `0.23.0` | feature | `dev` | `pending` | add dedicated `aiprovider` service, multi-protocol AI adapters, uv-only Python runtime, and AI Provider restart controls |
## Maintenance Commits Not Counted as Version Bumps
这些提交被视为维护性工作,因此未单独递增版本号:
- `3145ff08` Add `.gitignore` and clean
- `4ada75ca` new branch
- `c2eba54d` 整理资源文件,添加 legacy 路由
- `82f7aa29` 提取地球坐标常量到 `EARTH_CONFIG`
- `d18e400f` remove dead code
- `869d661a` abstract cable highlight logic
- `4f922f13` extract satellite config to `SATELLITE_CONFIG`
- `3e3090d7` docs: add architecture refactor and webgl instancing plans

View File

@@ -1,2 +1,3 @@
VITE_API_URL=/api/v1 VITE_API_URL=/api/v1
VITE_WS_URL=ws://localhost:8000/ws VITE_WS_URL=
VITE_SA_GATEWAY=http

View File

@@ -1,12 +1,12 @@
FROM node:20-alpine FROM oven/bun:1-alpine
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package.json bun.lock ./
RUN npm install RUN bun install --frozen-lockfile
COPY . . COPY . .
EXPOSE 3000 EXPOSE 3000
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] CMD ["bun", "run", "dev", "--", "--host", "0.0.0.0"]

View File

@@ -2,7 +2,7 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" href="data:," />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>智能星球计划</title> <title>智能星球计划</title>
</head> </head>

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "planet-frontend", "name": "planet-frontend",
"version": "1.0.0", "version": "0.23.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@ant-design/icons": "^5.2.6", "@ant-design/icons": "^5.2.6",

View File

@@ -0,0 +1,435 @@
/* base.css - 公共基础样式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #0a0a1a;
color: #fff;
overflow: hidden;
}
#container {
position: relative;
width: 100vw;
height: 100vh;
}
#container.dragging {
cursor: grabbing;
}
/* Bottom Dock */
#right-toolbar-group {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
z-index: 200;
}
#right-toolbar-group,
#info-panel,
#coordinates-display,
#legend,
#earth-stats {
transition:
top 0.45s ease,
right 0.45s ease,
bottom 0.45s ease,
left 0.45s ease,
transform 0.45s ease,
box-shadow 0.45s ease;
}
/* Zoom Toolbar - Right side, vertical */
#zoom-toolbar {
position: relative;
bottom: auto;
right: auto;
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
#zoom-toolbar .zoom-percent {
font-size: 0.75rem;
font-weight: 600;
color: #4db8ff;
min-width: 30px;
display: inline-block;
text-align: center;
cursor: pointer;
padding: 2px 4px;
border-radius: 3px;
transition: all 0.2s ease;
}
#zoom-toolbar .zoom-percent:hover {
background: rgba(77, 184, 255, 0.2);
box-shadow: 0 0 10px rgba(77, 184, 255, 0.3);
}
#zoom-toolbar .zoom-btn {
width: 28px;
height: 28px;
min-width: 28px;
border: none;
border-radius: 50%;
background: rgba(77, 184, 255, 0.2);
color: #4db8ff;
font-size: 14px;
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
padding: 0;
margin: 0;
flex: 0 0 auto;
box-sizing: border-box;
position: relative;
}
#zoom-toolbar .zoom-btn:hover {
background: rgba(77, 184, 255, 0.4);
transform: scale(1.1);
box-shadow: 0 0 10px rgba(77, 184, 255, 0.5);
}
#zoom-toolbar #reset-view svg {
width: 18px;
height: 18px;
stroke: currentColor;
stroke-width: 1.8;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
#zoom-toolbar .zoom-percent {
position: relative;
}
#zoom-toolbar .tooltip {
position: absolute;
bottom: calc(100% + 12px);
left: 50%;
transform: translateX(-50%);
background: rgba(10, 10, 30, 0.95);
color: #fff;
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
border: 1px solid rgba(77, 184, 255, 0.4);
pointer-events: none;
z-index: 100;
}
#zoom-toolbar .zoom-btn:hover .tooltip,
#zoom-toolbar .zoom-percent:hover .tooltip {
opacity: 1;
visibility: visible;
}
#zoom-toolbar .tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: rgba(77, 184, 255, 0.4);
}
#loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 1.2rem;
color: #4db8ff;
z-index: 100;
text-align: center;
background-color: rgba(10, 10, 30, 0.95);
padding: 30px;
border-radius: 10px;
border: 1px solid #4db8ff;
box-shadow: 0 0 30px rgba(77,184,255,0.3);
}
#loading-spinner {
border: 4px solid rgba(77, 184, 255, 0.3);
border-top: 4px solid #4db8ff;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto 15px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error-message {
color: #ff4444;
margin-top: 10px;
font-size: 0.9rem;
display: none;
padding: 10px;
background-color: rgba(255, 68, 68, 0.1);
border-radius: 5px;
border-left: 3px solid #ff4444;
}
.terrain-controls {
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.slider-container {
margin-bottom: 10px;
}
.slider-label {
display: flex;
justify-content: space-between;
margin-bottom: 5px;
font-size: 0.9rem;
}
input[type="range"] {
width: 100%;
height: 8px;
-webkit-appearance: none;
background: rgba(0, 102, 204, 0.3);
border-radius: 4px;
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #4db8ff;
cursor: pointer;
box-shadow: 0 0 10px #4db8ff;
}
.status-message {
position: absolute;
top: 20px;
right: 260px;
background-color: rgba(10, 10, 30, 0.85);
border-radius: 10px;
padding: 10px 15px;
z-index: 10;
box-shadow: 0 0 20px rgba(0, 150, 255, 0.3);
border: 1px solid rgba(0, 150, 255, 0.2);
font-size: 0.9rem;
display: none;
backdrop-filter: blur(5px);
}
.status-message.success {
color: #44ff44;
border-left: 3px solid #44ff44;
}
.status-message.warning {
color: #ffff44;
border-left: 3px solid #ffff44;
}
.status-message.error {
color: #ff4444;
border-left: 3px solid #ff4444;
}
.tooltip {
position: absolute;
background-color: rgba(10, 10, 30, 0.95);
border: 1px solid #4db8ff;
border-radius: 5px;
padding: 5px 10px;
font-size: 0.8rem;
color: #fff;
pointer-events: none;
z-index: 100;
box-shadow: 0 0 10px rgba(77, 184, 255, 0.3);
display: none;
user-select: none;
}
/* Control Toolbar - Stellarium/Star Walk style */
#control-toolbar {
position: relative;
bottom: auto;
right: auto;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
background: rgba(10, 10, 30, 0.9);
border-radius: 999px;
padding: 10px 14px;
border: 1px solid rgba(77, 184, 255, 0.3);
box-shadow: 0 0 20px rgba(77, 184, 255, 0.2);
transition: all 0.3s ease;
}
.toolbar-items {
display: flex;
gap: 6px;
align-items: center;
flex-wrap: nowrap;
}
.toolbar-divider {
width: 1px;
height: 28px;
background: rgba(77, 184, 255, 0.28);
flex-shrink: 0;
}
.toolbar-btn {
position: relative;
width: 28px;
height: 28px;
border: none;
border-radius: 50%;
background: rgba(77, 184, 255, 0.15);
color: #4db8ff;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
box-sizing: border-box;
padding: 0;
margin: 0;
}
.toolbar-btn:hover {
background: rgba(77, 184, 255, 0.35);
transform: scale(1.1);
box-shadow: 0 0 15px rgba(77, 184, 255, 0.5);
}
.toolbar-btn:active {
transform: scale(0.95);
}
.toolbar-btn.active {
background: rgba(77, 184, 255, 0.4);
box-shadow: 0 0 10px rgba(77, 184, 255, 0.4) inset;
}
.toolbar-btn .icon {
display: inline-flex;
align-items: center;
justify-content: center;
}
.toolbar-btn svg {
width: 18px;
height: 18px;
stroke: currentColor;
stroke-width: 2.1;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
#rotate-toggle .icon-play,
#rotate-toggle.is-stopped .icon-pause {
display: none;
}
#rotate-toggle.is-stopped .icon-play {
display: inline-flex;
}
#container.layout-expanded #info-panel {
top: 20px;
left: 20px;
transform: translate(calc(-100% + 20px), calc(-100% + 20px));
}
#container.layout-expanded #coordinates-display {
top: 20px;
right: 20px;
transform: translate(calc(100% - 20px), calc(-100% + 20px));
}
#container.layout-expanded #legend {
left: 20px;
bottom: 20px;
transform: translate(calc(-100% + 20px), calc(100% - 20px));
}
#container.layout-expanded #earth-stats {
right: 20px;
bottom: 20px;
transform: translate(calc(100% - 20px), calc(100% - 20px));
}
#container.layout-expanded #right-toolbar-group {
bottom: 20px;
transform: translateX(-50%);
}
.toolbar-btn .tooltip {
position: absolute;
bottom: 50px;
left: 50%;
transform: translateX(-50%);
background: rgba(10, 10, 30, 0.95);
color: #fff;
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
border: 1px solid rgba(77, 184, 255, 0.4);
pointer-events: none;
z-index: 100;
}
.toolbar-btn:hover .tooltip {
opacity: 1;
visibility: visible;
bottom: 52px;
}
.toolbar-btn .tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: rgba(77, 184, 255, 0.4);
}

View File

@@ -0,0 +1,421 @@
// controls.js - Zoom, rotate and toggle controls
import { CONFIG, EARTH_CONFIG } from "./constants.js";
import { updateZoomDisplay, showStatusMessage } from "./ui.js";
import { toggleTerrain } from "./earth.js";
import { reloadData, clearLockedObject } from "./main.js";
import {
toggleSatellites,
toggleTrails,
getShowSatellites,
getSatelliteCount,
} from "./satellites.js";
import { toggleCables, getShowCables } from "./cables.js";
export let autoRotate = true;
export let zoomLevel = 1.0;
export let showTerrain = false;
export let isDragging = false;
export let layoutExpanded = false;
let earthObj = null;
let listeners = [];
let cleanupFns = [];
function bindListener(element, eventName, handler, options) {
if (!element) return;
element.addEventListener(eventName, handler, options);
listeners.push(() =>
element.removeEventListener(eventName, handler, options),
);
}
function resetCleanup() {
cleanupFns.forEach((cleanup) => cleanup());
cleanupFns = [];
listeners.forEach((cleanup) => cleanup());
listeners = [];
}
export function setupControls(camera, renderer, scene, earth) {
resetCleanup();
earthObj = earth;
setupZoomControls(camera);
setupWheelZoom(camera, renderer);
setupRotateControls(camera, earth);
setupTerrainControls();
}
function setupZoomControls(camera) {
let zoomInterval = null;
let holdTimeout = null;
let startTime = 0;
const HOLD_THRESHOLD = 150;
const LONG_PRESS_TICK = 50;
const CLICK_STEP = 10;
const MIN_PERCENT = CONFIG.minZoom * 100;
const MAX_PERCENT = CONFIG.maxZoom * 100;
function doZoomStep(direction) {
let currentPercent = Math.round(zoomLevel * 100);
let newPercent =
direction > 0 ? currentPercent + CLICK_STEP : currentPercent - CLICK_STEP;
if (newPercent > MAX_PERCENT) newPercent = MAX_PERCENT;
if (newPercent < MIN_PERCENT) newPercent = MIN_PERCENT;
zoomLevel = newPercent / 100;
applyZoom(camera);
}
function doContinuousZoom(direction) {
let currentPercent = Math.round(zoomLevel * 100);
let newPercent = direction > 0 ? currentPercent + 1 : currentPercent - 1;
if (newPercent > MAX_PERCENT) newPercent = MAX_PERCENT;
if (newPercent < MIN_PERCENT) newPercent = MIN_PERCENT;
zoomLevel = newPercent / 100;
applyZoom(camera);
}
function startContinuousZoom(direction) {
doContinuousZoom(direction);
zoomInterval = window.setInterval(() => {
doContinuousZoom(direction);
}, LONG_PRESS_TICK);
}
function stopZoom() {
if (zoomInterval) {
clearInterval(zoomInterval);
zoomInterval = null;
}
if (holdTimeout) {
clearTimeout(holdTimeout);
holdTimeout = null;
}
}
function handleMouseDown(direction) {
startTime = Date.now();
stopZoom();
holdTimeout = window.setTimeout(() => {
startContinuousZoom(direction);
}, HOLD_THRESHOLD);
}
function handleMouseUp(direction) {
const heldTime = Date.now() - startTime;
stopZoom();
if (heldTime < HOLD_THRESHOLD) {
doZoomStep(direction);
}
}
cleanupFns.push(stopZoom);
const zoomIn = document.getElementById("zoom-in");
const zoomOut = document.getElementById("zoom-out");
const zoomValue = document.getElementById("zoom-value");
bindListener(zoomIn, "mousedown", () => handleMouseDown(1));
bindListener(zoomIn, "mouseup", () => handleMouseUp(1));
bindListener(zoomIn, "mouseleave", stopZoom);
bindListener(zoomIn, "touchstart", (e) => {
e.preventDefault();
handleMouseDown(1);
});
bindListener(zoomIn, "touchend", () => handleMouseUp(1));
bindListener(zoomOut, "mousedown", () => handleMouseDown(-1));
bindListener(zoomOut, "mouseup", () => handleMouseUp(-1));
bindListener(zoomOut, "mouseleave", stopZoom);
bindListener(zoomOut, "touchstart", (e) => {
e.preventDefault();
handleMouseDown(-1);
});
bindListener(zoomOut, "touchend", () => handleMouseUp(-1));
bindListener(zoomValue, "click", () => {
const startZoomVal = zoomLevel;
const targetZoom = 1.0;
const startDistance = CONFIG.defaultCameraZ / startZoomVal;
const targetDistance = CONFIG.defaultCameraZ / targetZoom;
animateValue(
0,
1,
600,
(progress) => {
const ease = 1 - Math.pow(1 - progress, 3);
zoomLevel = startZoomVal + (targetZoom - startZoomVal) * ease;
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
const distance =
startDistance + (targetDistance - startDistance) * ease;
updateZoomDisplay(zoomLevel, distance.toFixed(0));
},
() => {
zoomLevel = 1.0;
showStatusMessage("缩放已重置到100%", "info");
},
);
});
}
function setupWheelZoom(camera, renderer) {
bindListener(
renderer?.domElement,
"wheel",
(e) => {
e.preventDefault();
if (e.deltaY < 0) {
zoomLevel = Math.min(zoomLevel + 0.1, CONFIG.maxZoom);
} else {
zoomLevel = Math.max(zoomLevel - 0.1, CONFIG.minZoom);
}
applyZoom(camera);
},
{ passive: false },
);
}
function applyZoom(camera) {
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
const distance = camera.position.z.toFixed(0);
updateZoomDisplay(zoomLevel, distance);
}
function animateValue(start, end, duration, onUpdate, onComplete) {
const startTime = performance.now();
function update(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const easeProgress = 1 - Math.pow(1 - progress, 3);
const current = start + (end - start) * easeProgress;
onUpdate(current);
if (progress < 1) {
requestAnimationFrame(update);
} else if (onComplete) {
onComplete();
}
}
requestAnimationFrame(update);
}
export function resetView(camera) {
if (!earthObj) return;
function animateToView(targetLat, targetLon, targetRotLon) {
const latRot = (targetLat * Math.PI) / 180;
const targetRotX =
EARTH_CONFIG.tiltRad + latRot * EARTH_CONFIG.latCoefficient;
const targetRotY = -((targetRotLon * Math.PI) / 180);
const startRotX = earthObj.rotation.x;
const startRotY = earthObj.rotation.y;
const startZoom = zoomLevel;
const targetZoom = 1.0;
animateValue(
0,
1,
800,
(progress) => {
const ease = 1 - Math.pow(1 - progress, 3);
earthObj.rotation.x = startRotX + (targetRotX - startRotX) * ease;
earthObj.rotation.y = startRotY + (targetRotY - startRotY) * ease;
zoomLevel = startZoom + (targetZoom - startZoom) * ease;
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0));
},
() => {
zoomLevel = 1.0;
showStatusMessage("视角已重置", "info");
},
);
}
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(pos) =>
animateToView(
pos.coords.latitude,
pos.coords.longitude,
-pos.coords.longitude,
),
() =>
animateToView(
EARTH_CONFIG.chinaLat,
EARTH_CONFIG.chinaLon,
EARTH_CONFIG.chinaRotLon,
),
{ timeout: 5000, enableHighAccuracy: false },
);
} else {
animateToView(
EARTH_CONFIG.chinaLat,
EARTH_CONFIG.chinaLon,
EARTH_CONFIG.chinaRotLon,
);
}
clearLockedObject();
}
function setupRotateControls(camera) {
const rotateBtn = document.getElementById("rotate-toggle");
const resetViewBtn = document.getElementById("reset-view");
bindListener(rotateBtn, "click", () => {
const isRotating = toggleAutoRotate();
showStatusMessage(isRotating ? "自动旋转已开启" : "自动旋转已暂停", "info");
});
updateRotateUI();
bindListener(resetViewBtn, "click", () => {
resetView(camera);
});
}
function setupTerrainControls() {
const container = document.getElementById("container");
const terrainBtn = document.getElementById("toggle-terrain");
const satellitesBtn = document.getElementById("toggle-satellites");
const trailsBtn = document.getElementById("toggle-trails");
const cablesBtn = document.getElementById("toggle-cables");
const layoutBtn = document.getElementById("layout-toggle");
const reloadBtn = document.getElementById("reload-data");
if (trailsBtn) {
trailsBtn.classList.add("active");
const tooltip = trailsBtn.querySelector(".tooltip");
if (tooltip) tooltip.textContent = "隐藏轨迹";
}
bindListener(terrainBtn, "click", function () {
showTerrain = !showTerrain;
toggleTerrain(showTerrain);
this.classList.toggle("active", showTerrain);
const tooltip = this.querySelector(".tooltip");
if (tooltip) tooltip.textContent = showTerrain ? "隐藏地形" : "显示地形";
const terrainStatus = document.getElementById("terrain-status");
if (terrainStatus)
terrainStatus.textContent = showTerrain ? "开启" : "关闭";
showStatusMessage(showTerrain ? "地形已显示" : "地形已隐藏", "info");
});
bindListener(satellitesBtn, "click", function () {
const showSats = !getShowSatellites();
if (!showSats) {
clearLockedObject();
}
toggleSatellites(showSats);
this.classList.toggle("active", showSats);
const tooltip = this.querySelector(".tooltip");
if (tooltip) tooltip.textContent = showSats ? "隐藏卫星" : "显示卫星";
const satelliteCountEl = document.getElementById("satellite-count");
if (satelliteCountEl)
satelliteCountEl.textContent = getSatelliteCount() + " 颗";
showStatusMessage(showSats ? "卫星已显示" : "卫星已隐藏", "info");
});
bindListener(trailsBtn, "click", function () {
const isActive = this.classList.contains("active");
const nextShowTrails = !isActive;
toggleTrails(nextShowTrails);
this.classList.toggle("active", nextShowTrails);
const tooltip = this.querySelector(".tooltip");
if (tooltip) tooltip.textContent = nextShowTrails ? "隐藏轨迹" : "显示轨迹";
showStatusMessage(nextShowTrails ? "轨迹已显示" : "轨迹已隐藏", "info");
});
bindListener(cablesBtn, "click", function () {
const showNextCables = !getShowCables();
if (!showNextCables) {
clearLockedObject();
}
toggleCables(showNextCables);
this.classList.toggle("active", showNextCables);
const tooltip = this.querySelector(".tooltip");
if (tooltip) tooltip.textContent = showNextCables ? "隐藏线缆" : "显示线缆";
showStatusMessage(showNextCables ? "线缆已显示" : "线缆已隐藏", "info");
});
bindListener(reloadBtn, "click", async () => {
await reloadData();
});
bindListener(layoutBtn, "click", () => {
const expanded = toggleLayoutExpanded(container);
showStatusMessage(expanded ? "布局已最大化" : "布局已恢复", "info");
});
updateLayoutUI(container);
}
export function teardownControls() {
resetCleanup();
}
export function getAutoRotate() {
return autoRotate;
}
function updateRotateUI() {
const btn = document.getElementById("rotate-toggle");
if (btn) {
btn.classList.toggle("active", autoRotate);
btn.classList.toggle("is-stopped", !autoRotate);
const tooltip = btn.querySelector(".tooltip");
if (tooltip) tooltip.textContent = autoRotate ? "暂停旋转" : "开始旋转";
}
}
export function setAutoRotate(value) {
autoRotate = value;
updateRotateUI();
}
export function toggleAutoRotate() {
autoRotate = !autoRotate;
updateRotateUI();
clearLockedObject();
return autoRotate;
}
export function getZoomLevel() {
return zoomLevel;
}
export function getShowTerrain() {
return showTerrain;
}
function updateLayoutUI(container) {
if (container) {
container.classList.toggle("layout-expanded", layoutExpanded);
}
const btn = document.getElementById("layout-toggle");
if (btn) {
btn.classList.toggle("active", layoutExpanded);
const tooltip = btn.querySelector(".tooltip");
const nextLabel = layoutExpanded ? "恢复布局" : "最大化布局";
btn.title = nextLabel;
if (tooltip) tooltip.textContent = nextLabel;
}
}
function toggleLayoutExpanded(container) {
layoutExpanded = !layoutExpanded;
updateLayoutUI(container);
return layoutExpanded;
}

View File

@@ -0,0 +1,227 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>智能星球计划 - 现实层宇宙全息感知</title>
<script type="importmap">
{
"imports": {
"three": "https://esm.sh/three@0.128.0",
"simplex-noise": "https://esm.sh/simplex-noise@4.0.1",
"satellite.js": "https://esm.sh/satellite.js@5.0.0"
}
}
</script>
<link rel="stylesheet" href="css/base.css">
<link rel="stylesheet" href="css/info-panel.css">
<link rel="stylesheet" href="css/coordinates-display.css">
<link rel="stylesheet" href="css/legend.css">
<link rel="stylesheet" href="css/earth-stats.css">
</head>
<body>
<div id="container">
<div id="info-panel">
<h1>智能星球计划</h1>
<div class="subtitle">现实层宇宙全息感知系统 | 卫星 · 海底光缆 · 算力基础设施</div>
<div id="info-card" class="info-card" style="display: none;">
<div class="info-card-header">
<span class="info-card-icon" id="info-card-icon">🛰️</span>
<h3 id="info-card-title">详情</h3>
</div>
<div id="info-card-content"></div>
</div>
<div id="error-message" class="error-message"></div>
</div>
<div id="right-toolbar-group">
<div id="control-toolbar">
<div class="toolbar-items">
<button id="layout-toggle" class="toolbar-btn" title="最大化布局">
<span class="icon" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path d="M9 9H5V5"></path>
<path d="M15 9h4V5"></path>
<path d="M9 15H5v4"></path>
<path d="M15 15h4v4"></path>
<path d="M5 5l5 5"></path>
<path d="M19 5l-5 5"></path>
<path d="M5 19l5-5"></path>
<path d="M19 19l-5-5"></path>
</svg>
</span>
<span class="tooltip">最大化布局</span>
</button>
<button id="rotate-toggle" class="toolbar-btn" title="自动旋转">
<span class="icon rotate-icon icon-pause" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path d="M9 6v12"></path>
<path d="M15 6v12"></path>
</svg>
</span>
<span class="icon rotate-icon icon-play" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path d="M8 6.5v11l9-5.5z" fill="currentColor" stroke="none"></path>
</svg>
</span>
<span class="tooltip">自动旋转</span>
</button>
<button id="toggle-cables" class="toolbar-btn active" title="显示/隐藏线缆">
<span class="icon" aria-hidden="true">
<svg viewBox="0 0 24 24">
<circle cx="12" cy="12" r="6.5"></circle>
<path d="M5.8 12h12.4"></path>
<path d="M12 5.8a8.5 8.5 0 0 1 0 12.4"></path>
<path d="M8 16c2-1.8 6-1.8 8 0"></path>
</svg>
</span>
<span class="tooltip">隐藏线缆</span>
</button>
<button id="toggle-terrain" class="toolbar-btn" title="显示/隐藏地形">
<span class="icon" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path d="M3 18h18"></path>
<path d="M4.5 18l5-7 3 4 3.5-6 3.5 9"></path>
<path d="M11 18l2-3 1.5 2"></path>
</svg>
</span>
<span class="tooltip">显示/隐藏地形</span>
</button>
<button id="toggle-satellites" class="toolbar-btn" title="显示/隐藏卫星">
<span class="icon" aria-hidden="true">
<svg viewBox="0 0 24 24">
<rect x="10" y="10" width="4" height="4" rx="0.8"></rect>
<rect x="4" y="9" width="4" height="6" rx="0.8"></rect>
<rect x="16" y="9" width="4" height="6" rx="0.8"></rect>
<path d="M8 12h2"></path>
<path d="M14 12h2"></path>
<path d="M12 8V6"></path>
<path d="M11 6h2"></path>
<path d="M12 14v4"></path>
<path d="M10 18h4"></path>
</svg>
</span>
<span class="tooltip">显示卫星</span>
</button>
<button id="toggle-trails" class="toolbar-btn active" title="显示/隐藏轨迹">
<span class="icon" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path d="M5 17h7"></path>
<path d="M7 13.5h8"></path>
<path d="M10 10h6"></path>
<circle cx="17.5" cy="8.5" r="2.2" fill="currentColor" stroke="none"></circle>
<path d="M15.8 10.2l2.8-2.8"></path>
</svg>
</span>
<span class="tooltip">隐藏轨迹</span>
</button>
<button id="reload-data" class="toolbar-btn" title="重新加载数据">
<span class="icon" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path d="M20 5v5h-5"></path>
<path d="M20 10a8 8 0 1 0 2 5"></path>
</svg>
</span>
<span class="tooltip">重新加载数据</span>
</button>
</div>
<div class="toolbar-divider" aria-hidden="true"></div>
<div id="zoom-toolbar">
<button id="zoom-out" class="zoom-btn" title="缩小"><span class="tooltip">缩小</span></button>
<span id="zoom-value" class="zoom-percent" title="重置缩放到100%">100%<span class="tooltip">重置缩放到100%</span></span>
<button id="zoom-in" class="zoom-btn" title="放大">+<span class="tooltip">放大</span></button>
<button id="reset-view" class="zoom-btn" title="重置视角">
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="5"></circle>
<path d="M12 3v4"></path>
<path d="M12 17v4"></path>
<path d="M3 12h4"></path>
<path d="M17 12h4"></path>
<circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none"></circle>
</svg>
<span class="tooltip">重置视角</span>
</button>
</div>
</div>
</div>
<div id="coordinates-display">
<h3 style="color:#4db8ff; margin-bottom:8px; font-size:1.1rem;">坐标信息</h3>
<div class="coord-item">
<span class="coord-label">经度:</span>
<span id="longitude-value" class="coord-value">0.00°</span>
</div>
<div class="coord-item">
<span class="coord-label">纬度:</span>
<span id="latitude-value" class="coord-value">0.00°</span>
</div>
<div id="zoom-level">缩放: 1.0x</div>
<div class="mouse-coords" id="mouse-coords">鼠标位置: 无</div>
</div>
<div id="legend">
<h3 style="color:#4db8ff; margin-bottom:10px; font-size:1.1rem;">图例</h3>
<div class="legend-item">
<div class="legend-color" style="background-color: #ff4444;"></div>
<span>Americas II</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #44ff44;"></div>
<span>AU Aleutian A</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #4444ff;"></div>
<span>AU Aleutian B</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #ffff44;"></div>
<span>其他电缆</span>
</div>
</div>
<div id="earth-stats">
<h3 style="color:#4db8ff; margin-bottom:10px; font-size:1.1rem;">地球信息</h3>
<div class="stats-item">
<span class="stats-label">电缆系统:</span>
<span class="stats-value" id="cable-count">0个</span>
</div>
<div class="stats-item">
<span class="stats-label">状态:</span>
<span class="stats-value" id="cable-status-summary">-</span>
</div>
<div class="stats-item">
<span class="stats-label">登陆点:</span>
<span class="stats-value" id="landing-point-count">0个</span>
</div>
<div class="stats-item">
<span class="stats-label">地形:</span>
<span class="stats-value" id="terrain-status">开启</span>
</div>
<div class="stats-item">
<span class="stats-label">卫星:</span>
<span class="stats-value" id="satellite-count">0 颗</span>
</div>
<div class="stats-item">
<span class="stats-label">视角距离:</span>
<span class="stats-value" id="camera-distance">300 km</span>
</div>
<div class="stats-item">
<span class="stats-label">纹理质量:</span>
<span class="stats-value" id="texture-quality">8K 卫星图</span>
</div>
</div>
<div id="loading">
<div id="loading-spinner"></div>
<div id="loading-title">正在初始化全球态势数据...</div>
<div id="loading-subtitle" style="font-size:0.9rem; margin-top:10px; color:#aaa;">同步卫星、海底光缆与登陆点数据</div>
</div>
<div id="status-message" class="status-message" style="display: none;"></div>
<div id="tooltip" class="tooltip"></div>
</div>
<script type="module" src="js/main.js"></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

View File

@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="6.75" stroke="#4DB8FF" stroke-width="2.1"/>
<path d="M5.75 12H18.25" stroke="#4DB8FF" stroke-width="2.1" stroke-linecap="round"/>
<path d="M12 5.8C14.7 7.75 14.7 16.25 12 18.2" stroke="#4DB8FF" stroke-width="2.1" stroke-linecap="round"/>
<path d="M8 16C9.95 14.2 14.05 14.2 16 16" stroke="#4DB8FF" stroke-width="2.1" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 480 B

Some files were not shown because too many files have changed in this diff Show More