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:
101
README.md
101
README.md
@@ -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
19
TODO.md
Normal 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` 思路),实现近乎固定屏幕尺寸与更高密度可点击性
|
||||||
34
aiprovider/.env.example
Normal file
34
aiprovider/.env.example
Normal 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
23
aiprovider/Dockerfile
Normal 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
81
aiprovider/README.md
Normal 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
1
aiprovider/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""AI provider adapter service package."""
|
||||||
35
aiprovider/config.py
Normal file
35
aiprovider/config.py
Normal 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
79
aiprovider/main.py
Normal 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)
|
||||||
240
aiprovider/provider_service.py
Normal file
240
aiprovider/provider_service.py
Normal 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
27
aiprovider/schemas.py
Normal 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
|
||||||
@@ -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"]
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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
39
backend/app/api/v1/ai.py
Normal 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)
|
||||||
@@ -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
305
backend/app/api/v1/bgp.py
Normal 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()
|
||||||
@@ -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],
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)),
|
||||||
}
|
}
|
||||||
|
|||||||
167
backend/app/api/v1/system_control.py
Normal file
167
backend/app/api/v1/system_control.py
Normal 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)}
|
||||||
@@ -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),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
116
backend/app/core/satellite_tle.py
Normal file
116
backend/app/core/satellite_tle.py
Normal 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
|
||||||
@@ -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
20
backend/app/core/time.py
Normal 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")
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|||||||
58
backend/app/models/bgp_anomaly.py
Normal file
58
backend/app/models/bgp_anomaly.py
Normal 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),
|
||||||
|
}
|
||||||
64
backend/app/models/bgp_incident.py
Normal file
64
backend/app/models/bgp_incident.py
Normal 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),
|
||||||
|
}
|
||||||
62
backend/app/models/bgp_observation.py
Normal file
62
backend/app/models/bgp_observation.py
Normal 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,
|
||||||
|
}
|
||||||
@@ -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
27
backend/app/schemas/ai.py
Normal 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
|
||||||
109
backend/app/services/ai_client.py
Normal file
109
backend/app/services/ai_client.py
Normal 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()
|
||||||
173
backend/app/services/bgp_collectors.py
Normal file
173
backend/app/services/bgp_collectors.py
Normal 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
|
||||||
466
backend/app/services/bgp_detectors.py
Normal file
466
backend/app/services/bgp_detectors.py
Normal 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
|
||||||
408
backend/app/services/bgp_enrichment.py
Normal file
408
backend/app/services/bgp_enrichment.py
Normal 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
|
||||||
321
backend/app/services/bgp_incidents.py
Normal file
321
backend/app/services/bgp_incidents.py
Normal 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
|
||||||
@@ -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())
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
350
backend/app/services/collectors/bgp_common.py
Normal file
350
backend/app/services/collectors/bgp_common.py
Normal 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
|
||||||
132
backend/app/services/collectors/bgpstream.py
Normal file
132
backend/app/services/collectors/bgpstream.py
Normal 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()
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
204
backend/app/services/collectors/downloads.py
Normal file
204
backend/app/services/collectors/downloads.py
Normal 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
|
||||||
@@ -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"),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
207
backend/app/services/collectors/iptoasn.py
Normal file
207
backend/app/services/collectors/iptoasn.py
Normal 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
|
||||||
152
backend/app/services/collectors/nro_delegated.py
Normal file
152
backend/app/services/collectors/nro_delegated.py
Normal 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
|
||||||
135
backend/app/services/collectors/opengeofeed.py
Normal file
135
backend/app/services/collectors/opengeofeed.py
Normal 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
|
||||||
@@ -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):
|
||||||
|
|||||||
143
backend/app/services/collectors/ris_live.py
Normal file
143
backend/app/services/collectors/ris_live.py
Normal 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()
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
178
backend/app/services/system_control.py
Normal file
178
backend/app/services/system_control.py
Normal 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"
|
||||||
@@ -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
|
|
||||||
184
backend/scripts/system_restart_runner.py
Normal file
184
backend/scripts/system_restart_runner.py
Normal 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())
|
||||||
@@ -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
1399
backend/tests/test_bgp.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"""
|
||||||
|
|||||||
@@ -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"""
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
42
docker-compose.local-model.yml
Normal file
42
docker-compose.local-model.yml
Normal 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:
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
699
docs/CHANGELOG.md
Normal 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 Design’s 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
290
docs/aiprovider.md
Normal 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
355
docs/bgp-context.md
Normal 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
|
||||||
296
docs/bgp-earth-rendering-plan.md
Normal file
296
docs/bgp-earth-rendering-plan.md
Normal 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.`
|
||||||
487
docs/bgp-observability-plan.md
Normal file
487
docs/bgp-observability-plan.md
Normal 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)
|
||||||
422
docs/bgp-region-aggregation-plan.md
Normal file
422
docs/bgp-region-aggregation-plan.md
Normal 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
210
docs/earth-module-plan.md
Normal 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 全量清理
|
||||||
|
- 错误状态隔离
|
||||||
|
|
||||||
|
这个阶段不追求“更炫”,先追求“更稳”。稳定下来之后,再进入性能和架构层的优化。
|
||||||
216
docs/prefix-geography-plan.md
Normal file
216
docs/prefix-geography-plan.md
Normal 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.
|
||||||
347
docs/system-service-control.md
Normal file
347
docs/system-service-control.md
Normal 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
86
docs/version-history.md
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
3256
frontend/package-lock.json
generated
3256
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
435
frontend/public/earth/_backup/dock-centered-20260326/base.css
Normal file
435
frontend/public/earth/_backup/dock-centered-20260326/base.css
Normal 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);
|
||||||
|
}
|
||||||
421
frontend/public/earth/_backup/dock-centered-20260326/controls.js
vendored
Normal file
421
frontend/public/earth/_backup/dock-centered-20260326/controls.js
vendored
Normal 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;
|
||||||
|
}
|
||||||
227
frontend/public/earth/_backup/dock-centered-20260326/index.html
Normal file
227
frontend/public/earth/_backup/dock-centered-20260326/index.html
Normal 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>
|
||||||
BIN
frontend/public/earth/assets/earth_clouds_1024.png
Normal file
BIN
frontend/public/earth/assets/earth_clouds_1024.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 254 KiB |
6
frontend/public/earth/assets/icons/cables.svg
Normal file
6
frontend/public/earth/assets/icons/cables.svg
Normal 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
Reference in New Issue
Block a user