diff --git a/README.md b/README.md index 689ed6d2..25665855 100644 --- a/README.md +++ b/README.md @@ -184,20 +184,109 @@ ## 快速启动 ```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 文档 启动服务后访问: `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 待定 diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..b2aa5385 --- /dev/null +++ b/TODO.md @@ -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` 思路),实现近乎固定屏幕尺寸与更高密度可点击性 diff --git a/VERSION b/VERSION new file mode 100644 index 00000000..ca222b7c --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.23.0 diff --git a/aiprovider/.env.example b/aiprovider/.env.example new file mode 100644 index 00000000..cd5f57e1 --- /dev/null +++ b/aiprovider/.env.example @@ -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 diff --git a/aiprovider/Dockerfile b/aiprovider/Dockerfile new file mode 100644 index 00000000..c598f6be --- /dev/null +++ b/aiprovider/Dockerfile @@ -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"] diff --git a/aiprovider/README.md b/aiprovider/README.md new file mode 100644 index 00000000..2aae82be --- /dev/null +++ b/aiprovider/README.md @@ -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` diff --git a/aiprovider/__init__.py b/aiprovider/__init__.py new file mode 100644 index 00000000..9a939890 --- /dev/null +++ b/aiprovider/__init__.py @@ -0,0 +1 @@ +"""AI provider adapter service package.""" diff --git a/aiprovider/config.py b/aiprovider/config.py new file mode 100644 index 00000000..4d668b4f --- /dev/null +++ b/aiprovider/config.py @@ -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() diff --git a/aiprovider/main.py b/aiprovider/main.py new file mode 100644 index 00000000..afcc71af --- /dev/null +++ b/aiprovider/main.py @@ -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) diff --git a/aiprovider/provider_service.py b/aiprovider/provider_service.py new file mode 100644 index 00000000..763e9ca1 --- /dev/null +++ b/aiprovider/provider_service.py @@ -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 "" diff --git a/aiprovider/schemas.py b/aiprovider/schemas.py new file mode 100644 index 00000000..5e07fbf1 --- /dev/null +++ b/aiprovider/schemas.py @@ -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 diff --git a/backend/.env.example b/backend/.env.example index 9cb0296a..e69c62d3 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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_USER=postgres POSTGRES_PASSWORD=postgres POSTGRES_DB=planet_db +DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/planet_db -# Redis REDIS_SERVER=localhost REDIS_PORT=6379 +REDIS_DB=0 +REDIS_URL=redis://localhost:6379/0 -# Security -SECRET_KEY=your-secret-key-change-in-production -ALGORITHM=HS256 -ACCESS_TOKEN_EXPIRE_MINUTES=15 -REFRESH_TOKEN_EXPIRE_DAYS=7 +AI_PROVIDER_SERVICE_URL=http://localhost:8010 +AI_PROVIDER_SERVICE_TOKEN=change_me +AI_PROVIDER_TIMEOUT_SECONDS=60 +AI_PROVIDER_RETRY_ATTEMPTS=2 -# API -API_V1_STR=/api/v1 -PROJECT_NAME="Intelligent Planet Plan" -VERSION=1.0.0 - -# CORS -CORS_ORIGINS=["http://localhost:3000", "http://localhost:8000"] +SPACETRACK_USERNAME= +SPACETRACK_PASSWORD= diff --git a/backend/Dockerfile b/backend/Dockerfile index 75a98ce8..88bd01a6 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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 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 requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +COPY pyproject.toml uv.lock /app/ +RUN uv sync --frozen --no-dev -COPY . . +COPY backend /app/backend +COPY VERSION /app/VERSION 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"] diff --git a/backend/app/api/main.py b/backend/app/api/main.py index 1cd69b55..ad8883e2 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,5 +1,6 @@ from fastapi import APIRouter from app.api.v1 import ( + ai, auth, users, datasource_config, @@ -11,11 +12,14 @@ from app.api.v1 import ( settings, collected_data, visualization, + bgp, + system_control, ) api_router = APIRouter() 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( 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(alerts.router, prefix="/alerts", tags=["alerts"]) 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(bgp.router, prefix="/bgp", tags=["bgp"]) diff --git a/backend/app/api/v1/ai.py b/backend/app/api/v1/ai.py new file mode 100644 index 00000000..490d356b --- /dev/null +++ b/backend/app/api/v1/ai.py @@ -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) diff --git a/backend/app/api/v1/alerts.py b/backend/app/api/v1/alerts.py index f77766c4..c741f46b 100644 --- a/backend/app/api/v1/alerts.py +++ b/backend/app/api/v1/alerts.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import UTC, datetime from typing import Optional from fastapi import APIRouter, Depends @@ -68,7 +68,7 @@ async def acknowledge_alert( alert.status = AlertStatus.ACKNOWLEDGED alert.acknowledged_by = current_user.id - alert.acknowledged_at = datetime.utcnow() + alert.acknowledged_at = datetime.now(UTC) await db.commit() return {"message": "Alert acknowledged", "alert": alert.to_dict()} @@ -89,7 +89,7 @@ async def resolve_alert( alert.status = AlertStatus.RESOLVED alert.resolved_by = current_user.id - alert.resolved_at = datetime.utcnow() + alert.resolved_at = datetime.now(UTC) alert.resolution_notes = resolution await db.commit() diff --git a/backend/app/api/v1/bgp.py b/backend/app/api/v1/bgp.py new file mode 100644 index 00000000..26067b1e --- /dev/null +++ b/backend/app/api/v1/bgp.py @@ -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() diff --git a/backend/app/api/v1/collected_data.py b/backend/app/api/v1/collected_data.py index 0b77edfa..0a62783a 100644 --- a/backend/app/api/v1/collected_data.py +++ b/backend/app/api/v1/collected_data.py @@ -9,10 +9,12 @@ import io 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.time import to_iso8601_utc from app.db.session import get_db from app.models.user import User from app.core.security import get_current_user from app.models.collected_data import CollectedData +from app.models.datasource import DataSource 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] + source = row[1] return { "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], "data_type": row[3], "name": row[4], @@ -121,12 +125,17 @@ def serialize_collected_row(row) -> dict: "rmax": get_metadata_field(metadata, "rmax"), "rpeak": get_metadata_field(metadata, "rpeak"), "power": get_metadata_field(metadata, "power"), - "collected_at": row[8].isoformat() if row[8] else None, - "reference_date": row[9].isoformat() if row[9] else None, + "collected_at": to_iso8601_utc(row[8]), + "reference_date": to_iso8601_utc(row[9]), "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("") async def list_collected_data( mode: str = Query("current", description="查询模式: current/history"), @@ -188,10 +197,11 @@ async def list_collected_data( result = await db.execute(query, params) rows = result.fetchall() + source_name_map = await get_source_name_map(db) data = [] for row in rows: - data.append(serialize_collected_row(row[:11])) + data.append(serialize_collected_row(row[:11], source_name_map)) return { "total": total, @@ -204,23 +214,38 @@ async def list_collected_data( @router.get("/summary") async def get_data_summary( 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), 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 result = await db.execute( - text(""" + text(f""" SELECT source, data_type, COUNT(*) as count FROM collected_data - """ + where_sql + """ + WHERE {where_sql} GROUP BY source, data_type ORDER BY source, data_type - """) + """), + params, ) rows = result.fetchall() + source_name_map = await get_source_name_map(db) by_source = {} total = 0 @@ -229,27 +254,56 @@ async def get_data_summary( data_type = row[1] count = row[2] - if source not in by_source: - by_source[source] = {} - by_source[source][data_type] = count + source_key = source_name_map.get(source, source) + if source_key not in by_source: + by_source[source_key] = {} + by_source[source_key][data_type] = count total += count # Total by source source_totals = await db.execute( - text(""" + text(f""" SELECT source, COUNT(*) as count FROM collected_data - """ + where_sql + """ + WHERE {where_sql} GROUP BY source ORDER BY count DESC - """) + """), + params, ) 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 { "total_records": total, + "overall_total_records": overall_total, "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() + source_name_map = await get_source_name_map(db) 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="数据不存在", ) - 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( @@ -482,8 +541,8 @@ async def export_csv( get_metadata_field(row[7], "value"), get_metadata_field(row[7], "unit"), json.dumps(row[7]) if row[7] else "", - row[8].isoformat() if row[8] else "", - row[9].isoformat() if row[9] else "", + to_iso8601_utc(row[8]) or "", + to_iso8601_utc(row[9]) or "", row[10], ] ) diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 8548a992..ddf70afa 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -1,6 +1,6 @@ """Dashboard API with caching and optimizations""" -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from fastapi import APIRouter, Depends from sqlalchemy import select, func, text 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.core.security import get_current_user from app.core.cache import cache +from app.core.time import to_iso8601_utc # Built-in collectors info (mirrored from datasources.py) @@ -111,7 +112,7 @@ async def get_stats( if 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 built_in_count = len(COLLECTOR_INFO) @@ -175,7 +176,7 @@ async def get_stats( "active_datasources": active_datasources, "tasks_today": tasks_today, "success_rate": round(success_rate, 1), - "last_updated": datetime.utcnow().isoformat(), + "last_updated": to_iso8601_utc(datetime.now(UTC)), "alerts": { "critical": critical_alerts, "warning": warning_alerts, @@ -230,10 +231,10 @@ async def get_summary( summary[module] = { "datasources": data["datasources"], "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) diff --git a/backend/app/api/v1/datasource_config.py b/backend/app/api/v1/datasource_config.py index 5995082a..edb72286 100644 --- a/backend/app/api/v1/datasource_config.py +++ b/backend/app/api/v1/datasource_config.py @@ -14,6 +14,7 @@ from app.models.user import User from app.models.datasource_config import DataSourceConfig from app.core.security import get_current_user from app.core.cache import cache +from app.core.time import to_iso8601_utc router = APIRouter() @@ -123,8 +124,8 @@ async def list_configs( "headers": c.headers, "config": c.config, "is_active": c.is_active, - "created_at": c.created_at.isoformat() if c.created_at else None, - "updated_at": c.updated_at.isoformat() if c.updated_at else None, + "created_at": to_iso8601_utc(c.created_at), + "updated_at": to_iso8601_utc(c.updated_at), } for c in configs ], @@ -155,8 +156,8 @@ async def get_config( "headers": config.headers, "config": config.config, "is_active": config.is_active, - "created_at": config.created_at.isoformat() if config.created_at else None, - "updated_at": config.updated_at.isoformat() if config.updated_at else None, + "created_at": to_iso8601_utc(config.created_at), + "updated_at": to_iso8601_utc(config.updated_at), } diff --git a/backend/app/api/v1/datasources.py b/backend/app/api/v1/datasources.py index be4e4543..22337662 100644 --- a/backend/app/api/v1/datasources.py +++ b/backend/app/api/v1/datasources.py @@ -1,9 +1,12 @@ +import asyncio +from datetime import datetime, timedelta, timezone from typing import Optional -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import func, select 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.data_sources import get_data_sources_config 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 router = APIRouter() +STALE_RUNNING_TASK_TIMEOUT_MINUTES = 90 def format_frequency_label(minutes: int) -> str: @@ -24,6 +28,12 @@ def format_frequency_label(minutes: int) -> str: 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]: datasource = None try: @@ -47,6 +57,7 @@ async def get_last_completed_task(db: AsyncSession, datasource_id: int) -> Optio select(CollectionTask) .where(CollectionTask.datasource_id == datasource_id) .where(CollectionTask.completed_at.isnot(None)) + .where(CollectionTask.status.in_(("success", "failed", "cancelled"))) .order_by(CollectionTask.completed_at.desc()) .limit(1) ) @@ -61,7 +72,32 @@ async def get_running_task(db: AsyncSession, datasource_id: int) -> Optional[Col .order_by(CollectionTask.started_at.desc()) .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("") @@ -94,9 +130,9 @@ async def list_datasources( ) data_count = data_count_result.scalar() or 0 - last_run = None - if last_task and last_task.completed_at and data_count > 0: - last_run = last_task.completed_at.strftime("%Y-%m-%d %H:%M") + last_run_at = datasource.last_run_at or (last_task.completed_at if last_task else None) + last_run = to_iso8601_utc(last_run_at) + last_status = datasource.last_status or (last_task.status if last_task else None) collector_list.append( { @@ -110,6 +146,10 @@ async def list_datasources( "collector_class": datasource.collector_class, "endpoint": endpoint, "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, "task_id": running_task.id 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} +@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}") async def get_datasource( source_id: str, @@ -217,15 +356,19 @@ async def trigger_datasource( if not datasource.is_active: 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) if not success: raise HTTPException(status_code=500, detail=f"Failed to trigger collector '{datasource.source}'") 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) - if task_id is not None: + if task_id is not None and task_id != previous_task_id: break + if task_id == previous_task_id: + task_id = None return { "status": "triggered", diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py index cdde6d2a..6439a859 100644 --- a/backend/app/api/v1/settings.py +++ b/backend/app/api/v1/settings.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import UTC, datetime from typing import Optional from fastapi import APIRouter, Depends, HTTPException @@ -7,6 +7,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession 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.models.datasource import DataSource from app.models.system_setting import SystemSetting @@ -114,9 +115,9 @@ def serialize_collector(datasource: DataSource) -> dict: "frequency_minutes": datasource.frequency_minutes, "frequency": format_frequency_label(datasource.frequency_minutes), "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, - "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"), "security": await get_setting_payload(db, "security"), "collectors": [serialize_collector(datasource) for datasource in datasources], - "generated_at": datetime.utcnow().isoformat() + "Z", - } + "generated_at": to_iso8601_utc(datetime.now(UTC)), + } diff --git a/backend/app/api/v1/system_control.py b/backend/app/api/v1/system_control.py new file mode 100644 index 00000000..21c81208 --- /dev/null +++ b/backend/app/api/v1/system_control.py @@ -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)} diff --git a/backend/app/api/v1/tasks.py b/backend/app/api/v1/tasks.py index b04f816e..8dfb7af5 100644 --- a/backend/app/api/v1/tasks.py +++ b/backend/app/api/v1/tasks.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import UTC, datetime from typing import Optional from fastapi import APIRouter, Depends, HTTPException, status @@ -8,6 +8,7 @@ from sqlalchemy import text from app.db.session import get_db from app.models.user import 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 @@ -61,8 +62,8 @@ async def list_tasks( "datasource_id": t[1], "datasource_name": t[2], "status": t[3], - "started_at": t[4].isoformat() if t[4] else None, - "completed_at": t[5].isoformat() if t[5] else None, + "started_at": to_iso8601_utc(t[4]), + "completed_at": to_iso8601_utc(t[5]), "records_processed": t[6], "error_message": t[7], } @@ -100,8 +101,8 @@ async def get_task( "datasource_id": task[1], "datasource_name": task[2], "status": task[3], - "started_at": task[4].isoformat() if task[4] else None, - "completed_at": task[5].isoformat() if task[5] else None, + "started_at": to_iso8601_utc(task[4]), + "completed_at": to_iso8601_utc(task[5]), "records_processed": task[6], "error_message": task[7], } @@ -147,8 +148,8 @@ async def trigger_collection( "status": result.get("status", "unknown"), "records_processed": result.get("records_processed", 0), "error_message": result.get("error"), - "started_at": datetime.utcnow(), - "completed_at": datetime.utcnow(), + "started_at": datetime.now(UTC), + "completed_at": datetime.now(UTC), }, ) diff --git a/backend/app/api/v1/visualization.py b/backend/app/api/v1/visualization.py index c3e295e6..bf32c579 100644 --- a/backend/app/api/v1/visualization.py +++ b/backend/app/api/v1/visualization.py @@ -4,16 +4,23 @@ Unified API for all visualization data sources. Returns GeoJSON format compatible with Three.js, CesiumJS, and Unreal Cesium. """ -from datetime import datetime -from fastapi import APIRouter, HTTPException, Depends +from datetime import UTC, datetime +import math +from fastapi import APIRouter, HTTPException, Depends, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func from typing import List, Dict, Any, Optional from app.core.collected_data_fields import get_record_field +from app.core.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.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 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() @@ -155,6 +162,20 @@ def convert_satellite_to_geojson(records: List[CollectedData]) -> Dict[str, Any] if not norad_id: 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( { "type": "Feature", @@ -174,6 +195,8 @@ def convert_satellite_to_geojson(records: List[CollectedData]) -> Dict[str, Any] "mean_motion": metadata.get("mean_motion"), "bstar": metadata.get("bstar"), "classification_type": metadata.get("classification_type"), + "tle_line1": tle_line1, + "tle_line2": tle_line2, "data_type": "satellite_tle", }, } @@ -182,6 +205,44 @@ def convert_satellite_to_geojson(records: List[CollectedData]) -> Dict[str, Any] 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]: """Convert TOP500 supercomputer records to GeoJSON""" features = [] @@ -256,6 +317,404 @@ def convert_gpu_cluster_to_geojson(records: List[CollectedData]) -> Dict[str, An 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 ============== @@ -265,7 +724,7 @@ async def get_cables_geojson(db: AsyncSession = Depends(get_db)): try: stmt = select(CollectedData).where(CollectedData.source == "arcgis_cables") result = await db.execute(stmt) - records = result.scalars().all() + records = dedupe_collected_records(list(result.scalars().all())) if not records: raise HTTPException( @@ -285,15 +744,15 @@ async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)): try: landing_stmt = select(CollectedData).where(CollectedData.source == "arcgis_landing_points") 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_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_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 = {} 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)): cables_stmt = select(CollectedData).where(CollectedData.source == "arcgis_cables") 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_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_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 = {} for rel in relation_records: @@ -383,7 +842,11 @@ async def get_all_geojson(db: AsyncSession = Depends(get_db)): @router.get("/geo/satellites") 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), ): """获取卫星 TLE GeoJSON 数据""" @@ -392,10 +855,12 @@ async def get_satellites_geojson( .where(CollectedData.source == "celestrak_tle") .where(CollectedData.name != "Unknown") .order_by(CollectedData.id.desc()) - .limit(limit) ) 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: return {"type": "FeatureCollection", "features": [], "count": 0} @@ -417,10 +882,11 @@ async def get_supercomputers_geojson( select(CollectedData) .where(CollectedData.source == "top500") .where(CollectedData.name != "Unknown") - .limit(limit) + .order_by(CollectedData.id.desc()) ) result = await db.execute(stmt) - records = result.scalars().all() + records = dedupe_collected_records(list(result.scalars().all())) + records = records[:limit] if not records: return {"type": "FeatureCollection", "features": [], "count": 0} @@ -442,10 +908,11 @@ async def get_gpu_clusters_geojson( select(CollectedData) .where(CollectedData.source == "epoch_ai_gpu") .where(CollectedData.name != "Unknown") - .limit(limit) + .order_by(CollectedData.id.desc()) ) result = await db.execute(stmt) - records = result.scalars().all() + records = dedupe_collected_records(list(result.scalars().all())) + records = records[:limit] if not records: 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") 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_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_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 = ( select(CollectedData) @@ -482,7 +1004,7 @@ async def get_all_visualization_data(db: AsyncSession = Depends(get_db)): .where(CollectedData.name != "Unknown") ) 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 = ( select(CollectedData) @@ -490,7 +1012,7 @@ async def get_all_visualization_data(db: AsyncSession = Depends(get_db)): .where(CollectedData.name != "Unknown") ) 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 = ( select(CollectedData) @@ -498,7 +1020,7 @@ async def get_all_visualization_data(db: AsyncSession = Depends(get_db)): .where(CollectedData.name != "Unknown") ) 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 = ( convert_cable_to_geojson(cables_records) @@ -527,7 +1049,7 @@ async def get_all_visualization_data(db: AsyncSession = Depends(get_db)): ) return { - "generated_at": datetime.utcnow().isoformat() + "Z", + "generated_at": to_iso8601_utc(datetime.now(UTC)), "version": "1.0", "data": { "satellites": satellites, diff --git a/backend/app/api/v1/websocket.py b/backend/app/api/v1/websocket.py index 85bac489..23ccb3fe 100644 --- a/backend/app/api/v1/websocket.py +++ b/backend/app/api/v1/websocket.py @@ -3,13 +3,14 @@ import asyncio import json import logging -from datetime import datetime +from datetime import UTC, datetime from typing import Optional from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query from jose import jwt, JWTError from app.core.config import settings +from app.core.time import to_iso8601_utc from app.core.websocket.manager import manager logger = logging.getLogger(__name__) @@ -59,6 +60,7 @@ async def websocket_endpoint( "ixp_nodes", "alerts", "dashboard", + "datasource_tasks", ], }, } @@ -72,7 +74,7 @@ async def websocket_endpoint( await websocket.send_json( { "type": "heartbeat", - "data": {"action": "pong", "timestamp": datetime.utcnow().isoformat()}, + "data": {"action": "pong", "timestamp": to_iso8601_utc(datetime.now(UTC))}, } ) elif data.get("type") == "subscribe": diff --git a/backend/app/core/config.py b/backend/app/core/config.py index d7bbb0c2..24e3559b 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -6,9 +6,16 @@ import os from pydantic_settings import BaseSettings +ROOT_DIR = Path(__file__).parent.parent.parent.parent +VERSION_FILE = ROOT_DIR / "VERSION" + + class Settings(BaseSettings): 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" SECRET_KEY: str = "your-secret-key-change-in-production" ALGORITHM: str = "HS256" @@ -30,6 +37,11 @@ class Settings(BaseSettings): SPACETRACK_USERNAME: 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 def REDIS_URL(self) -> str: return os.getenv( @@ -39,6 +51,7 @@ class Settings(BaseSettings): class Config: env_file = Path(__file__).parent.parent.parent / ".env" case_sensitive = True + extra = "ignore" @lru_cache() diff --git a/backend/app/core/countries.py b/backend/app/core/countries.py index b1e8bc3c..409717d7 100644 --- a/backend/app/core/countries.py +++ b/backend/app/core/countries.py @@ -232,6 +232,57 @@ for canonical, aliases in COUNTRY_ENTRIES: 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]: if value is None: return None @@ -258,6 +309,13 @@ def normalize_country(value: Any) -> Optional[str]: 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]: canonical = normalize_country(value) if canonical is None: diff --git a/backend/app/core/data_sources.py b/backend/app/core/data_sources.py index 13f078a0..0ec35942 100644 --- a/backend/app/core/data_sources.py +++ b/backend/app/core/data_sources.py @@ -23,6 +23,11 @@ COLLECTOR_URL_KEYS = { "top500": "top500.url", "epoch_ai_gpu": "epoch_ai.gpu_clusters_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", } diff --git a/backend/app/core/data_sources.yaml b/backend/app/core/data_sources.yaml index 7e97d335..e1261be8 100644 --- a/backend/app/core/data_sources.yaml +++ b/backend/app/core/data_sources.yaml @@ -37,3 +37,18 @@ epoch_ai: spacetrack: 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" + +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" diff --git a/backend/app/core/datasource_defaults.py b/backend/app/core/datasource_defaults.py index 7c5f9430..189030ee 100644 --- a/backend/app/core/datasource_defaults.py +++ b/backend/app/core/datasource_defaults.py @@ -120,6 +120,41 @@ DEFAULT_DATASOURCES = { "priority": "P2", "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()} diff --git a/backend/app/core/satellite_tle.py b/backend/app/core/satellite_tle.py new file mode 100644 index 00000000..a4392aff --- /dev/null +++ b/backend/app/core/satellite_tle.py @@ -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 diff --git a/backend/app/core/security.py b/backend/app/core/security.py index e0cdfdbf..4b0bdcc5 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from typing import Optional 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: to_encode = data.copy() if expires_delta: - expire = datetime.utcnow() + expires_delta + expire = datetime.now(UTC) + expires_delta 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: expire = None if expire: @@ -65,7 +65,7 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) - def create_refresh_token(data: dict) -> str: to_encode = data.copy() 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({"type": "refresh"}) if "sub" in to_encode: diff --git a/backend/app/core/time.py b/backend/app/core/time.py new file mode 100644 index 00000000..0e6a1303 --- /dev/null +++ b/backend/app/core/time.py @@ -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") diff --git a/backend/app/core/websocket/broadcaster.py b/backend/app/core/websocket/broadcaster.py index d65fa214..0fbc2523 100644 --- a/backend/app/core/websocket/broadcaster.py +++ b/backend/app/core/websocket/broadcaster.py @@ -1,9 +1,10 @@ """Data broadcaster for WebSocket connections""" import asyncio -from datetime import datetime +from datetime import UTC, datetime from typing import Dict, Any, Optional +from app.core.time import to_iso8601_utc from app.core.websocket.manager import manager @@ -22,7 +23,7 @@ class DataBroadcaster: "active_datasources": 8, "tasks_today": 45, "success_rate": 97.8, - "last_updated": datetime.utcnow().isoformat(), + "last_updated": to_iso8601_utc(datetime.now(UTC)), "alerts": {"critical": 0, "warning": 2, "info": 5}, } @@ -35,7 +36,7 @@ class DataBroadcaster: { "type": "data_frame", "channel": "dashboard", - "timestamp": datetime.utcnow().isoformat(), + "timestamp": to_iso8601_utc(datetime.now(UTC)), "payload": {"stats": stats}, }, channel="dashboard", @@ -49,7 +50,7 @@ class DataBroadcaster: await manager.broadcast( { "type": "alert_notification", - "timestamp": datetime.utcnow().isoformat(), + "timestamp": to_iso8601_utc(datetime.now(UTC)), "data": {"alert": alert}, } ) @@ -60,7 +61,7 @@ class DataBroadcaster: { "type": "data_frame", "channel": "gpu_clusters", - "timestamp": datetime.utcnow().isoformat(), + "timestamp": to_iso8601_utc(datetime.now(UTC)), "payload": data, } ) @@ -71,12 +72,24 @@ class DataBroadcaster: { "type": "data_frame", "channel": channel, - "timestamp": datetime.utcnow().isoformat(), + "timestamp": to_iso8601_utc(datetime.now(UTC)), "payload": data, }, 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): """Start all broadcasters""" if not self.running: diff --git a/backend/app/db/session.py b/backend/app/db/session.py index 4d3ccf69..66368051 100644 --- a/backend/app/db/session.py +++ b/backend/app/db/session.py @@ -60,6 +60,28 @@ async def seed_default_datasources(session: AsyncSession): 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(): import app.models.user # 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_config # 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.system_setting # noqa: F401 @@ -125,3 +150,4 @@ async def init_db(): async with async_session_factory() as session: await seed_default_datasources(session) + await ensure_default_admin_user(session) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 38e79102..30c52b05 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -5,6 +5,9 @@ from app.models.data_snapshot import DataSnapshot from app.models.datasource import DataSource from app.models.datasource_config import DataSourceConfig from app.models.alert import Alert, AlertSeverity, AlertStatus +from app.models.bgp_anomaly import BGPAnomaly +from app.models.bgp_incident import BGPIncident +from app.models.bgp_observation import BGPObservation from app.models.system_setting import SystemSetting __all__ = [ @@ -18,4 +21,7 @@ __all__ = [ "Alert", "AlertSeverity", "AlertStatus", -] + "BGPAnomaly", + "BGPIncident", + "BGPObservation", +] diff --git a/backend/app/models/alert.py b/backend/app/models/alert.py index ca5c7f1d..9c141e4c 100644 --- a/backend/app/models/alert.py +++ b/backend/app/models/alert.py @@ -5,6 +5,7 @@ from typing import Optional from sqlalchemy import Column, Integer, String, DateTime, Text, ForeignKey, Enum as SQLEnum from sqlalchemy.orm import relationship +from app.core.time import to_iso8601_utc from app.db.session import Base @@ -50,8 +51,8 @@ class Alert(Base): "acknowledged_by": self.acknowledged_by, "resolved_by": self.resolved_by, "resolution_notes": self.resolution_notes, - "created_at": self.created_at.isoformat() if self.created_at else None, - "updated_at": self.updated_at.isoformat() if self.updated_at else None, - "acknowledged_at": self.acknowledged_at.isoformat() if self.acknowledged_at else None, - "resolved_at": self.resolved_at.isoformat() if self.resolved_at else None, + "created_at": to_iso8601_utc(self.created_at), + "updated_at": to_iso8601_utc(self.updated_at), + "acknowledged_at": to_iso8601_utc(self.acknowledged_at), + "resolved_at": to_iso8601_utc(self.resolved_at), } diff --git a/backend/app/models/bgp_anomaly.py b/backend/app/models/bgp_anomaly.py new file mode 100644 index 00000000..013aa8fa --- /dev/null +++ b/backend/app/models/bgp_anomaly.py @@ -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), + } diff --git a/backend/app/models/bgp_incident.py b/backend/app/models/bgp_incident.py new file mode 100644 index 00000000..4e901c7a --- /dev/null +++ b/backend/app/models/bgp_incident.py @@ -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), + } diff --git a/backend/app/models/bgp_observation.py b/backend/app/models/bgp_observation.py new file mode 100644 index 00000000..d40b2ac5 --- /dev/null +++ b/backend/app/models/bgp_observation.py @@ -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, + } diff --git a/backend/app/models/collected_data.py b/backend/app/models/collected_data.py index 84791f15..438db389 100644 --- a/backend/app/models/collected_data.py +++ b/backend/app/models/collected_data.py @@ -4,6 +4,7 @@ from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, T from sqlalchemy.sql import func from app.core.collected_data_fields import get_record_field +from app.core.time import to_iso8601_utc from app.db.session import Base @@ -74,15 +75,11 @@ class CollectedData(Base): "value": get_record_field(self, "value"), "unit": get_record_field(self, "unit"), "metadata": self.extra_data, - "collected_at": self.collected_at.isoformat() - if self.collected_at is not None - else None, - "reference_date": self.reference_date.isoformat() - if self.reference_date is not None - else None, + "collected_at": to_iso8601_utc(self.collected_at), + "reference_date": to_iso8601_utc(self.reference_date), "is_current": self.is_current, "previous_record_id": self.previous_record_id, "change_type": self.change_type, "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), } diff --git a/backend/app/schemas/ai.py b/backend/app/schemas/ai.py new file mode 100644 index 00000000..5e07fbf1 --- /dev/null +++ b/backend/app/schemas/ai.py @@ -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 diff --git a/backend/app/services/ai_client.py b/backend/app/services/ai_client.py new file mode 100644 index 00000000..916a02d1 --- /dev/null +++ b/backend/app/services/ai_client.py @@ -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() diff --git a/backend/app/services/bgp_collectors.py b/backend/app/services/bgp_collectors.py new file mode 100644 index 00000000..56a7a209 --- /dev/null +++ b/backend/app/services/bgp_collectors.py @@ -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 diff --git a/backend/app/services/bgp_detectors.py b/backend/app/services/bgp_detectors.py new file mode 100644 index 00000000..c1263ff8 --- /dev/null +++ b/backend/app/services/bgp_detectors.py @@ -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 diff --git a/backend/app/services/bgp_enrichment.py b/backend/app/services/bgp_enrichment.py new file mode 100644 index 00000000..0d37ee67 --- /dev/null +++ b/backend/app/services/bgp_enrichment.py @@ -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 diff --git a/backend/app/services/bgp_incidents.py b/backend/app/services/bgp_incidents.py new file mode 100644 index 00000000..ee7bf41f --- /dev/null +++ b/backend/app/services/bgp_incidents.py @@ -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 diff --git a/backend/app/services/collectors/__init__.py b/backend/app/services/collectors/__init__.py index 69a2b11b..fdc4d4a7 100644 --- a/backend/app/services/collectors/__init__.py +++ b/backend/app/services/collectors/__init__.py @@ -30,6 +30,11 @@ from app.services.collectors.arcgis_landing import ArcGISLandingPointCollector from app.services.collectors.arcgis_relation import ArcGISCableLandingRelationCollector from app.services.collectors.spacetrack import SpaceTrackTLECollector 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(EpochAIGPUCollector()) @@ -51,3 +56,8 @@ collector_registry.register(ArcGISLandingPointCollector()) collector_registry.register(ArcGISCableLandingRelationCollector()) collector_registry.register(SpaceTrackTLECollector()) collector_registry.register(CelesTrakTLECollector()) +collector_registry.register(RISLiveCollector()) +collector_registry.register(BGPStreamBackfillCollector()) +collector_registry.register(IPtoASNPrefixGeoCollector()) +collector_registry.register(OpenGeoFeedPrefixGeoCollector()) +collector_registry.register(NRODelegatedPrefixGeoCollector()) diff --git a/backend/app/services/collectors/arcgis_cables.py b/backend/app/services/collectors/arcgis_cables.py index ac3db539..23e5c9a0 100644 --- a/backend/app/services/collectors/arcgis_cables.py +++ b/backend/app/services/collectors/arcgis_cables.py @@ -5,7 +5,7 @@ Collects submarine cable data from ArcGIS GeoJSON API. import json from typing import Dict, Any, List -from datetime import datetime +from datetime import UTC, datetime import httpx from app.services.collectors.base import BaseCollector @@ -84,7 +84,7 @@ class ArcGISCableCollector(BaseCollector): "color": props.get("color"), "route_coordinates": route_coordinates, }, - "reference_date": datetime.utcnow().strftime("%Y-%m-%d"), + "reference_date": datetime.now(UTC).strftime("%Y-%m-%d"), } result.append(entry) except (ValueError, TypeError, KeyError): diff --git a/backend/app/services/collectors/arcgis_landing.py b/backend/app/services/collectors/arcgis_landing.py index 93976198..5aa9c294 100644 --- a/backend/app/services/collectors/arcgis_landing.py +++ b/backend/app/services/collectors/arcgis_landing.py @@ -1,5 +1,5 @@ from typing import Dict, Any, List -from datetime import datetime +from datetime import UTC, datetime import httpx from app.services.collectors.base import BaseCollector @@ -67,7 +67,7 @@ class ArcGISLandingPointCollector(BaseCollector): "status": props.get("status"), "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) except (ValueError, TypeError, KeyError): diff --git a/backend/app/services/collectors/arcgis_relation.py b/backend/app/services/collectors/arcgis_relation.py index d06a46c8..9ec45688 100644 --- a/backend/app/services/collectors/arcgis_relation.py +++ b/backend/app/services/collectors/arcgis_relation.py @@ -1,5 +1,5 @@ import asyncio -from datetime import datetime +from datetime import UTC, datetime from typing import Any, Dict, List, Optional import httpx @@ -143,7 +143,7 @@ class ArcGISCableLandingRelationCollector(BaseCollector): "facility": facility, "status": status, }, - "reference_date": datetime.utcnow().strftime("%Y-%m-%d"), + "reference_date": datetime.now(UTC).strftime("%Y-%m-%d"), } result.append(entry) except (ValueError, TypeError, KeyError): diff --git a/backend/app/services/collectors/base.py b/backend/app/services/collectors/base.py index 0288dd61..64453974 100644 --- a/backend/app/services/collectors/base.py +++ b/backend/app/services/collectors/base.py @@ -2,7 +2,7 @@ from abc import ABC, abstractmethod from typing import Dict, List, Any, Optional -from datetime import datetime +from datetime import UTC, datetime import httpx from sqlalchemy import select, text 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.config import settings 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): @@ -20,12 +22,14 @@ class BaseCollector(ABC): module: str = "L1" frequency_hours: int = 4 data_type: str = "generic" + fail_on_empty: bool = False def __init__(self): self._current_task = None self._db_session = None self._datasource_id = 1 self._resolved_url: Optional[str] = None + self._last_broadcast_progress: Optional[int] = None async def resolve_url(self, db: AsyncSession) -> None: from app.core.data_sources import get_data_sources_config @@ -33,18 +37,53 @@ class BaseCollector(ABC): config = get_data_sources_config() 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""" - 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.progress = ( - records_processed / self._current_task.total_records - ) * 100 + if self._current_task.total_records and self._current_task.total_records > 0: + self._current_task.progress = ( + 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): if self._current_task and self._db_session: self._current_task.phase = phase await self._db_session.commit() + await self._publish_task_update(force=True) @abstractmethod async def fetch(self) -> List[Dict[str, Any]]: @@ -133,7 +172,7 @@ class BaseCollector(ABC): from app.models.task import CollectionTask from app.models.data_snapshot import DataSnapshot - start_time = datetime.utcnow() + start_time = datetime.now(UTC) datasource_id = getattr(self, "_datasource_id", 1) snapshot_id: Optional[int] = None @@ -152,14 +191,20 @@ class BaseCollector(ABC): self._current_task = task self._db_session = db + self._last_broadcast_progress = None await self.resolve_url(db) + await self._publish_task_update(force=True) try: await self.set_phase("fetching") raw_data = await self.fetch() task.total_records = len(raw_data) 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") data = self.transform(raw_data) @@ -172,33 +217,35 @@ class BaseCollector(ABC): task.phase = "completed" task.records_processed = records_count task.progress = 100.0 - task.completed_at = datetime.utcnow() + task.completed_at = datetime.now(UTC) await db.commit() + await self._publish_task_update(force=True) return { "status": "success", "task_id": task_id, "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: task.status = "failed" task.phase = "failed" task.error_message = str(e) - task.completed_at = datetime.utcnow() + task.completed_at = datetime.now(UTC) if snapshot_id is not None: snapshot = await db.get(DataSnapshot, snapshot_id) if snapshot: snapshot.status = "failed" - snapshot.completed_at = datetime.utcnow() + snapshot.completed_at = datetime.now(UTC) snapshot.summary = {"error": str(e)} await db.commit() + await self._publish_task_update(force=True) return { "status": "failed", "task_id": task_id, "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( @@ -219,11 +266,11 @@ class BaseCollector(ABC): snapshot.record_count = 0 snapshot.summary = {"created": 0, "updated": 0, "unchanged": 0} snapshot.status = "success" - snapshot.completed_at = datetime.utcnow() + snapshot.completed_at = datetime.now(UTC) await db.commit() return 0 - collected_at = datetime.utcnow() + collected_at = datetime.now(UTC) records_added = 0 created_count = 0 updated_count = 0 @@ -329,8 +376,7 @@ class BaseCollector(ABC): records_added += 1 if i % 100 == 0: - self.update_progress(i + 1) - await db.commit() + await self.update_progress(i + 1, commit=True) if snapshot_id is not None: deleted_keys = previous_current_keys - seen_entity_keys @@ -350,7 +396,7 @@ class BaseCollector(ABC): if snapshot: snapshot.record_count = records_added snapshot.status = "success" - snapshot.completed_at = datetime.utcnow() + snapshot.completed_at = datetime.now(UTC) snapshot.summary = { "created": created_count, "updated": updated_count, @@ -359,7 +405,7 @@ class BaseCollector(ABC): } await db.commit() - self.update_progress(len(data)) + await self.update_progress(len(data), force=True) return records_added async def save(self, db: AsyncSession, data: List[Dict[str, Any]]) -> int: @@ -406,8 +452,8 @@ async def log_task( status=status, records_processed=records_processed, error_message=error_message, - started_at=datetime.utcnow(), - completed_at=datetime.utcnow(), + started_at=datetime.now(UTC), + completed_at=datetime.now(UTC), ) db.add(task) await db.commit() diff --git a/backend/app/services/collectors/bgp_common.py b/backend/app/services/collectors/bgp_common.py new file mode 100644 index 00000000..ec54a73c --- /dev/null +++ b/backend/app/services/collectors/bgp_common.py @@ -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 diff --git a/backend/app/services/collectors/bgpstream.py b/backend/app/services/collectors/bgpstream.py new file mode 100644 index 00000000..88418d68 --- /dev/null +++ b/backend/app/services/collectors/bgpstream.py @@ -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() diff --git a/backend/app/services/collectors/celestrak.py b/backend/app/services/collectors/celestrak.py index a0e91d43..e6c5f749 100644 --- a/backend/app/services/collectors/celestrak.py +++ b/backend/app/services/collectors/celestrak.py @@ -8,6 +8,7 @@ import json from typing import Dict, Any, List import httpx +from app.core.satellite_tle import build_tle_lines_from_elements 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]]: transformed = [] 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( { "name": item.get("OBJECT_NAME", "Unknown"), @@ -80,6 +92,10 @@ class CelesTrakTLECollector(BaseCollector): "mean_motion_dot": item.get("MEAN_MOTION_DOT"), "mean_motion_ddot": item.get("MEAN_MOTION_DDOT"), "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, }, } ) diff --git a/backend/app/services/collectors/cloudflare.py b/backend/app/services/collectors/cloudflare.py index b3061642..4eb24015 100644 --- a/backend/app/services/collectors/cloudflare.py +++ b/backend/app/services/collectors/cloudflare.py @@ -10,7 +10,7 @@ Some endpoints require authentication for higher rate limits. import asyncio import os from typing import Dict, Any, List -from datetime import datetime +from datetime import UTC, datetime import httpx from app.services.collectors.base import HTTPCollector @@ -59,7 +59,7 @@ class CloudflareRadarDeviceCollector(HTTPCollector): "other_percent": float(summary.get("other", 0)), "date_range": result.get("meta", {}).get("dateRange", {}), }, - "reference_date": datetime.utcnow().isoformat(), + "reference_date": datetime.now(UTC).isoformat(), } data.append(entry) except (ValueError, TypeError, KeyError): @@ -107,7 +107,7 @@ class CloudflareRadarTrafficCollector(HTTPCollector): "requests": item.get("requests"), "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) except (ValueError, TypeError, KeyError): @@ -155,7 +155,7 @@ class CloudflareRadarTopASCollector(HTTPCollector): "traffic_share": item.get("trafficShare"), "country_code": item.get("location", {}).get("countryCode"), }, - "reference_date": datetime.utcnow().isoformat(), + "reference_date": datetime.now(UTC).isoformat(), } data.append(entry) except (ValueError, TypeError, KeyError): diff --git a/backend/app/services/collectors/downloads.py b/backend/app/services/collectors/downloads.py new file mode 100644 index 00000000..5f604176 --- /dev/null +++ b/backend/app/services/collectors/downloads.py @@ -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 diff --git a/backend/app/services/collectors/epoch_ai.py b/backend/app/services/collectors/epoch_ai.py index 8d22ca32..1fa813d6 100644 --- a/backend/app/services/collectors/epoch_ai.py +++ b/backend/app/services/collectors/epoch_ai.py @@ -6,7 +6,7 @@ https://epoch.ai/data/gpu-clusters import re from typing import Dict, Any, List -from datetime import datetime +from datetime import UTC, datetime from bs4 import BeautifulSoup import httpx @@ -64,7 +64,7 @@ class EpochAIGPUCollector(BaseCollector): "metadata": { "raw_data": perf_cell, }, - "reference_date": datetime.utcnow().strftime("%Y-%m-%d"), + "reference_date": datetime.now(UTC).strftime("%Y-%m-%d"), } data.append(entry) except (ValueError, IndexError, AttributeError): @@ -114,6 +114,6 @@ class EpochAIGPUCollector(BaseCollector): "metadata": { "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"), }, ] diff --git a/backend/app/services/collectors/fao_landing.py b/backend/app/services/collectors/fao_landing.py index 5adba41a..f8c38570 100644 --- a/backend/app/services/collectors/fao_landing.py +++ b/backend/app/services/collectors/fao_landing.py @@ -4,7 +4,7 @@ Collects landing point data from FAO CSV API. """ from typing import Dict, Any, List -from datetime import datetime +from datetime import UTC, datetime import httpx from app.services.collectors.base import BaseCollector @@ -58,7 +58,7 @@ class FAOLandingPointCollector(BaseCollector): "is_tbd": is_tbd, "original_id": feature_id, }, - "reference_date": datetime.utcnow().strftime("%Y-%m-%d"), + "reference_date": datetime.now(UTC).strftime("%Y-%m-%d"), } result.append(entry) except (ValueError, IndexError): diff --git a/backend/app/services/collectors/huggingface.py b/backend/app/services/collectors/huggingface.py index 149752f3..d4c0af55 100644 --- a/backend/app/services/collectors/huggingface.py +++ b/backend/app/services/collectors/huggingface.py @@ -7,7 +7,7 @@ https://huggingface.co/spaces """ from typing import Dict, Any, List -from datetime import datetime +from datetime import UTC, datetime from app.services.collectors.base import HTTPCollector @@ -46,7 +46,7 @@ class HuggingFaceModelCollector(HTTPCollector): "library_name": item.get("library_name"), "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) except (ValueError, TypeError, KeyError): @@ -87,7 +87,7 @@ class HuggingFaceDatasetCollector(HTTPCollector): "tags": (item.get("tags", []) or [])[:10], "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) except (ValueError, TypeError, KeyError): @@ -128,7 +128,7 @@ class HuggingFaceSpacesCollector(HTTPCollector): "tags": (item.get("tags", []) or [])[:10], "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) except (ValueError, TypeError, KeyError): diff --git a/backend/app/services/collectors/iptoasn.py b/backend/app/services/collectors/iptoasn.py new file mode 100644 index 00000000..665aad94 --- /dev/null +++ b/backend/app/services/collectors/iptoasn.py @@ -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 diff --git a/backend/app/services/collectors/nro_delegated.py b/backend/app/services/collectors/nro_delegated.py new file mode 100644 index 00000000..52967ad1 --- /dev/null +++ b/backend/app/services/collectors/nro_delegated.py @@ -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 diff --git a/backend/app/services/collectors/opengeofeed.py b/backend/app/services/collectors/opengeofeed.py new file mode 100644 index 00000000..bb91f4fb --- /dev/null +++ b/backend/app/services/collectors/opengeofeed.py @@ -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 diff --git a/backend/app/services/collectors/peeringdb.py b/backend/app/services/collectors/peeringdb.py index 3c2b0179..5510095c 100644 --- a/backend/app/services/collectors/peeringdb.py +++ b/backend/app/services/collectors/peeringdb.py @@ -13,7 +13,7 @@ To get higher limits, set PEERINGDB_API_KEY environment variable. import asyncio import os from typing import Dict, Any, List -from datetime import datetime +from datetime import UTC, datetime import httpx from app.services.collectors.base import HTTPCollector @@ -106,7 +106,7 @@ class PeeringDBIXPCollector(HTTPCollector): "created": item.get("created"), "updated": item.get("updated"), }, - "reference_date": datetime.utcnow().isoformat(), + "reference_date": datetime.now(UTC).isoformat(), } data.append(entry) except (ValueError, TypeError, KeyError): @@ -209,7 +209,7 @@ class PeeringDBNetworkCollector(HTTPCollector): "created": item.get("created"), "updated": item.get("updated"), }, - "reference_date": datetime.utcnow().isoformat(), + "reference_date": datetime.now(UTC).isoformat(), } data.append(entry) except (ValueError, TypeError, KeyError): @@ -311,7 +311,7 @@ class PeeringDBFacilityCollector(HTTPCollector): "created": item.get("created"), "updated": item.get("updated"), }, - "reference_date": datetime.utcnow().isoformat(), + "reference_date": datetime.now(UTC).isoformat(), } data.append(entry) except (ValueError, TypeError, KeyError): diff --git a/backend/app/services/collectors/ris_live.py b/backend/app/services/collectors/ris_live.py new file mode 100644 index 00000000..38da086a --- /dev/null +++ b/backend/app/services/collectors/ris_live.py @@ -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() diff --git a/backend/app/services/collectors/spacetrack.py b/backend/app/services/collectors/spacetrack.py index 4f66c97d..bc960f6b 100644 --- a/backend/app/services/collectors/spacetrack.py +++ b/backend/app/services/collectors/spacetrack.py @@ -10,6 +10,7 @@ import httpx from app.services.collectors.base import BaseCollector from app.core.data_sources import get_data_sources_config +from app.core.satellite_tle import build_tle_lines_from_elements class SpaceTrackTLECollector(BaseCollector): @@ -169,25 +170,41 @@ class SpaceTrackTLECollector(BaseCollector): """Transform TLE data to internal format""" transformed = [] 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( { "name": item.get("OBJECT_NAME", "Unknown"), - "norad_cat_id": item.get("NORAD_CAT_ID"), - "international_designator": item.get("INTL_DESIGNATOR"), - "epoch": item.get("EPOCH"), - "mean_motion": item.get("MEAN_MOTION"), - "eccentricity": item.get("ECCENTRICITY"), - "inclination": item.get("INCLINATION"), - "raan": item.get("RAAN"), - "arg_of_perigee": item.get("ARG_OF_PERIGEE"), - "mean_anomaly": item.get("MEAN_ANOMALY"), - "ephemeris_type": item.get("EPHEMERIS_TYPE"), - "classification_type": item.get("CLASSIFICATION_TYPE"), - "element_set_no": item.get("ELEMENT_SET_NO"), - "rev_at_epoch": item.get("REV_AT_EPOCH"), - "bstar": item.get("BSTAR"), - "mean_motion_dot": item.get("MEAN_MOTION_DOT"), - "mean_motion_ddot": item.get("MEAN_MOTION_DDOT"), + "reference_date": item.get("EPOCH", ""), + "metadata": { + "norad_cat_id": item.get("NORAD_CAT_ID"), + "international_designator": item.get("INTL_DESIGNATOR"), + "epoch": item.get("EPOCH"), + "mean_motion": item.get("MEAN_MOTION"), + "eccentricity": item.get("ECCENTRICITY"), + "inclination": item.get("INCLINATION"), + "raan": item.get("RAAN"), + "arg_of_perigee": item.get("ARG_OF_PERIGEE"), + "mean_anomaly": item.get("MEAN_ANOMALY"), + "ephemeris_type": item.get("EPHEMERIS_TYPE"), + "classification_type": item.get("CLASSIFICATION_TYPE"), + "element_set_no": item.get("ELEMENT_SET_NO"), + "rev_at_epoch": item.get("REV_AT_EPOCH"), + "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 diff --git a/backend/app/services/collectors/telegeography.py b/backend/app/services/collectors/telegeography.py index f01188e9..b3bd7c72 100644 --- a/backend/app/services/collectors/telegeography.py +++ b/backend/app/services/collectors/telegeography.py @@ -7,7 +7,7 @@ Uses Wayback Machine as backup data source since live data requires JavaScript r import json import re from typing import Dict, Any, List -from datetime import datetime +from datetime import UTC, datetime from bs4 import BeautifulSoup import httpx @@ -103,7 +103,7 @@ class TeleGeographyCableCollector(BaseCollector): "capacity_tbps": item.get("capacity"), "url": item.get("url"), }, - "reference_date": datetime.utcnow().strftime("%Y-%m-%d"), + "reference_date": datetime.now(UTC).strftime("%Y-%m-%d"), } result.append(entry) except (ValueError, TypeError, KeyError): @@ -131,7 +131,7 @@ class TeleGeographyCableCollector(BaseCollector): "owner": "Meta, Orange, Vodafone, etc.", "status": "active", }, - "reference_date": datetime.utcnow().strftime("%Y-%m-%d"), + "reference_date": datetime.now(UTC).strftime("%Y-%m-%d"), }, { "source_id": "telegeo_sample_2", @@ -147,7 +147,7 @@ class TeleGeographyCableCollector(BaseCollector): "owner": "Alibaba, NEC", "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", [])), "url": item.get("url"), }, - "reference_date": datetime.utcnow().strftime("%Y-%m-%d"), + "reference_date": datetime.now(UTC).strftime("%Y-%m-%d"), } result.append(entry) except (ValueError, TypeError, KeyError): @@ -211,7 +211,7 @@ class TeleGeographyLandingPointCollector(BaseCollector): "value": "", "unit": "", "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"), "url": item.get("url"), }, - "reference_date": datetime.utcnow().strftime("%Y-%m-%d"), + "reference_date": datetime.now(UTC).strftime("%Y-%m-%d"), } result.append(entry) except (ValueError, TypeError, KeyError): @@ -282,6 +282,6 @@ class TeleGeographyCableSystemCollector(BaseCollector): "value": "5000", "unit": "km", "metadata": {"note": "Sample data"}, - "reference_date": datetime.utcnow().strftime("%Y-%m-%d"), + "reference_date": datetime.now(UTC).strftime("%Y-%m-%d"), }, ] diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py index ce43ea66..ed0e45dd 100644 --- a/backend/app/services/scheduler.py +++ b/backend/app/services/scheduler.py @@ -2,7 +2,7 @@ import asyncio import logging -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from typing import Any, Dict, Optional from apscheduler.schedulers.asyncio import AsyncIOScheduler @@ -10,6 +10,7 @@ from apscheduler.triggers.interval import IntervalTrigger from sqlalchemy import select 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.task import CollectionTask from app.services.collectors.registry import collector_registry @@ -17,6 +18,7 @@ from app.services.collectors.registry import collector_registry logger = logging.getLogger(__name__) scheduler = AsyncIOScheduler() +RUNNING_TASK_GUARD_TIMEOUT_MINUTES = 90 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) 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: collector._datasource_id = datasource.id logger.info("Running collector: %s (datasource_id=%s)", collector_name, datasource.id) 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") await _update_next_run_at(datasource, db) logger.info("Collector %s completed: %s", collector_name, task_result) except Exception as exc: - datasource.last_run_at = datetime.utcnow() + datasource.last_run_at = datetime.now(UTC) datasource.last_status = "failed" await db.commit() 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: """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: 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: task.status = "failed" task.phase = "failed" - task.completed_at = datetime.utcnow() + task.completed_at = datetime.now(UTC) existing_error = (task.error_message or "").strip() 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 @@ -167,7 +217,7 @@ def get_scheduler_jobs() -> list[Dict[str, Any]]: { "id": job.id, "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), } ) diff --git a/backend/app/services/system_control.py b/backend/app/services/system_control.py new file mode 100644 index 00000000..7176177e --- /dev/null +++ b/backend/app/services/system_control.py @@ -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" diff --git a/backend/requirements.txt b/backend/requirements.txt deleted file mode 100644 index 00d9865f..00000000 --- a/backend/requirements.txt +++ /dev/null @@ -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 diff --git a/backend/scripts/system_restart_runner.py b/backend/scripts/system_restart_runner.py new file mode 100644 index 00000000..a30ed2d6 --- /dev/null +++ b/backend/scripts/system_restart_runner.py @@ -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()) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 47bfd35f..8c0aabde 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -8,6 +8,9 @@ from httpx import AsyncClient, ASGITransport from app.main import app from app.core.config import settings 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 @@ -90,10 +93,58 @@ async def test_alerts_without_auth(): @pytest.mark.asyncio async def test_alerts_endpoint_with_auth(auth_headers): """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) - async with AsyncClient(transport=transport, base_url="http://test") as client: - response = await client.get("/api/v1/alerts", headers=auth_headers) - assert response.status_code == 200 + try: + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/alerts", headers=auth_headers) + assert response.status_code == 200 + finally: + app.dependency_overrides.clear() @pytest.mark.asyncio @@ -106,3 +157,89 @@ async def test_invalid_token(): headers={"Authorization": "Bearer invalid_token"}, ) 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() diff --git a/backend/tests/test_bgp.py b/backend/tests/test_bgp.py new file mode 100644 index 00000000..6482adf0 --- /dev/null +++ b/backend/tests/test_bgp.py @@ -0,0 +1,1399 @@ +"""Tests for BGP observability helpers.""" + +from datetime import UTC, datetime, timedelta + +import pytest +from httpx import ASGITransport, AsyncClient +from unittest.mock import AsyncMock, patch + +from app.api.v1.bgp import BGP_SOURCES +from app.core.security import get_current_user +from app.db.session import get_db +from app.main import app +from app.services.bgp_detectors import ( + detect_mass_withdrawal_anomalies, + detect_origin_change_anomalies, + detect_path_flap_anomalies, + detect_route_leak_anomalies, +) +from app.services.collectors.bgp_common import ( + create_bgp_anomalies_for_batch, + save_bgp_observations_for_batch, +) +from app.services.bgp_enrichment import enrich_bgp_events_for_batch, extract_bgp_network_fields +from app.services.bgp_incidents import ( + create_bgp_incidents_for_anomalies, + infer_related_infrastructure, +) +from app.services.bgp_collectors import build_bgp_collector_coverage +from app.api.v1.visualization import convert_bgp_incidents_to_geojson, build_incident_geography_hints +from app.models.bgp_anomaly import BGPAnomaly +from app.models.collected_data import CollectedData +from app.models.bgp_incident import BGPIncident +from app.models.bgp_observation import BGPObservation +from app.models.user import User +from app.services.collectors.bgp_common import normalize_bgp_event +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 + + +class _FakeScalarResult: + def __init__(self, rows): + self._rows = rows + + def all(self): + return self._rows + + +class _FakeResult: + def __init__(self, rows): + self._rows = rows + + def scalars(self): + return _FakeScalarResult(self._rows) + + def fetchall(self): + return self._rows + + def fetchone(self): + return self._rows[0] if self._rows else None + + +class _FakeAsyncSession: + def __init__(self, results, gets=None): + self._results = list(results) + self._gets = gets or {} + self.added = [] + self.commits = 0 + + async def execute(self, _stmt, _params=None): + if not self._results: + return _FakeResult([]) + return _FakeResult(self._results.pop(0)) + + async def get(self, model, item_id): + return self._gets.get((model, item_id)) + + def add(self, item): + self.added.append(item) + + async def commit(self): + self.commits += 1 + + +def test_normalize_bgp_event_from_live_payload(): + event = normalize_bgp_event( + { + "collector": "rrc00", + "peer_asn": "3333", + "peer_ip": "2001:db8::1", + "type": "UPDATE", + "event_type": "announcement", + "prefix": "203.0.113.0/24", + "path": ["3333", "64500", "64496"], + "communities": ["3333:100"], + "timestamp": "2026-03-26T08:00:00Z", + }, + project="ris-live", + ) + + assert event["name"] == "203.0.113.0/24" + assert event["metadata"]["collector"] == "rrc00" + assert event["metadata"]["peer_asn"] == 3333 + assert event["metadata"]["origin_asn"] == 64496 + assert event["metadata"]["as_path_length"] == 3 + assert event["metadata"]["prefix_length"] == 24 + assert event["metadata"]["is_more_specific"] is False + + +def test_normalize_bgp_event_uses_peer_and_community_fallbacks(): + event = normalize_bgp_event( + { + "collector": "rrc00", + "peer_asn": "3333", + "peer": "2405:a640::50", + "type": "UPDATE", + "prefix": "2401:2260::/32", + "path": [3333, 15412, 9304, 151650], + "community": [[15412, 603], [3333, 100]], + "timestamp": "2026-03-27T06:07:18.470000+00:00", + }, + project="ris-live", + ) + + assert event["metadata"]["peer_ip"] == "2405:a640::50" + assert event["metadata"]["communities"] == [[15412, 603], [3333, 100]] + + +def test_bgpstream_transform_preserves_broker_record(): + collector = BGPStreamBackfillCollector() + transformed = collector.transform( + [ + { + "project": "routeviews", + "collector": "route-views.sg", + "filename": "rib.20260326.0800.gz", + "startTime": "2026-03-26T08:00:00Z", + "prefix": "198.51.100.0/24", + "origin_asn": 64512, + } + ] + ) + + assert len(transformed) == 1 + record = transformed[0] + assert record["name"] == "rib.20260326.0800.gz" + assert record["metadata"]["project"] == "bgpstream" + assert record["metadata"]["broker_record"]["filename"] == "rib.20260326.0800.gz" + + +def test_iptoasn_transform_creates_prefix_geography_records(): + collector = IPtoASNPrefixGeoCollector() + transformed = collector.transform( + [ + { + "range_start": "1.0.0.0", + "range_end": "1.0.0.255", + "asn": "13335", + "country_code": "AU", + "as_name": "CLOUDFLARENET", + } + ] + ) + + assert len(transformed) == 1 + record = transformed[0] + assert record["name"] == "1.0.0.0/24" + assert record["metadata"]["family"] == "ipv4" + assert record["metadata"]["country_code"] == "AU" + assert record["metadata"]["asn"] == 13335 + assert record["metadata"]["source_dataset"] == "iptoasn_combined" + + +def test_iptoasn_build_dataset_urls_from_combined(): + urls = IPtoASNPrefixGeoCollector._build_dataset_urls( + "https://iptoasn.com/data/ip2asn-combined.tsv.gz" + ) + assert urls == [ + "https://iptoasn.com/data/ip2asn-v4.tsv.gz", + "https://iptoasn.com/data/ip2asn-v6.tsv.gz", + ] + + +def test_iptoasn_build_dataset_urls_passthrough_non_combined(): + url = "https://example.com/custom.tsv.gz" + urls = IPtoASNPrefixGeoCollector._build_dataset_urls(url) + assert urls == [url] + + +def test_opengeofeed_transform_creates_prefix_geography_records(): + collector = OpenGeoFeedPrefixGeoCollector() + transformed = collector.transform( + [ + { + "prefix": "203.0.113.0/24", + "country_code": "GB", + "region": "GB-LND", + "city": "London", + "postal_code": "EC1A", + "extra_columns": ["source:example"], + } + ] + ) + + assert len(transformed) == 1 + record = transformed[0] + assert record["name"] == "203.0.113.0/24" + assert record["metadata"]["family"] == "ipv4" + assert record["metadata"]["range_start"] == "203.0.113.0" + assert record["metadata"]["range_end"] == "203.0.113.255" + assert record["metadata"]["country_code"] == "GB" + assert record["metadata"]["source_dataset"] == "opengeofeed_public" + assert record["metadata"]["confidence"] == "geofeed" + + +def test_nro_delegated_transform_creates_prefix_geography_records(): + collector = NRODelegatedPrefixGeoCollector() + transformed = collector.transform( + [ + { + "rir": "ripencc", + "country_code": "DE", + "type": "ipv4", + "start": "198.51.100.0", + "value": "256", + "allocated_date": "20250401", + "status": "allocated", + } + ] + ) + + assert len(transformed) == 1 + record = transformed[0] + assert record["name"] == "198.51.100.0/24" + assert record["metadata"]["family"] == "ipv4" + assert record["metadata"]["range_start"] == "198.51.100.0" + assert record["metadata"]["range_end"] == "198.51.100.255" + assert record["metadata"]["country_code"] == "DE" + assert record["metadata"]["source_dataset"] == "nro_delegated_stats" + assert record["metadata"]["confidence"] == "registry_allocated" + + +def test_bgp_anomaly_to_dict(): + anomaly = BGPAnomaly( + source="ris_live_bgp", + anomaly_type="origin_change", + severity="critical", + status="active", + entity_key="origin_change:203.0.113.0/24:64497", + prefix="203.0.113.0/24", + origin_asn=64496, + new_origin_asn=64497, + summary="Origin ASN changed", + confidence=0.9, + evidence={"previous_origins": [64496], "current_origins": [64497]}, + ) + + data = anomaly.to_dict() + assert data["source"] == "ris_live_bgp" + assert data["anomaly_type"] == "origin_change" + assert data["new_origin_asn"] == 64497 + assert data["evidence"]["previous_origins"] == [64496] + + +def test_bgp_observation_to_dict(): + observation = BGPObservation( + source="ris_live_bgp", + ingest_batch_id="ris_live_bgp:1:1", + source_event_id="evt-1", + collector="rrc00", + peer_asn=3333, + peer_ip="2001:db8::1", + prefix="203.0.113.0/24", + event_type="announcement", + as_path=[3333, 64500, 64496], + origin_asn=64496, + next_hop="2001:db8::2", + communities=["3333:100"], + collector_geo={"city": "Amsterdam", "country": "Netherlands"}, + raw_payload={"raw": "deadbeef"}, + ) + + data = observation.to_dict() + assert data["source"] == "ris_live_bgp" + assert data["collector"] == "rrc00" + assert data["event_type"] == "announcement" + assert data["as_path"] == [3333, 64500, 64496] + assert data["collector_geo"]["city"] == "Amsterdam" + + +def test_extract_bgp_network_fields(): + ipv4 = extract_bgp_network_fields("203.0.113.0/24") + assert ipv4["prefix_family"] == "ipv4" + assert ipv4["prefix_length"] == 24 + assert ipv4["prefix_supernet"] == "203.0.0.0/16" + assert ipv4["is_more_specific"] is False + + ipv6 = extract_bgp_network_fields("2001:db8:1::/48") + assert ipv6["prefix_family"] == "ipv6" + assert ipv6["prefix_length"] == 48 + assert ipv6["prefix_supernet"] == "2001:db8::/32" + assert ipv6["is_more_specific"] is False + + +def test_detect_mass_withdrawal_anomalies(): + events = [ + { + "metadata": { + "prefix": "203.0.113.0/24", + "origin_asn": 64496, + "event_type": "withdrawal", + } + } + for _ in range(3) + ] + + anomalies = detect_mass_withdrawal_anomalies( + source="ris_live_bgp", + snapshot_id=1, + task_id=2, + events=events, + ) + + assert len(anomalies) == 1 + assert anomalies[0].anomaly_type == "mass_withdrawal" + assert anomalies[0].prefix == "203.0.113.0/24" + + +def test_detect_origin_change_anomalies_creates_conflict_without_baseline(): + events = [ + { + "metadata": { + "prefix": "203.0.113.0/24", + "origin_asn": 64496, + "collector": "rrc00", + "collector_location": { + "country": "Netherlands", + "city": "Amsterdam", + "latitude": 52.3676, + "longitude": 4.9041, + }, + } + }, + { + "metadata": { + "prefix": "203.0.113.0/24", + "origin_asn": 64497, + "collector": "rrc01", + "collector_location": { + "country": "United Kingdom", + "city": "London", + "latitude": 51.5072, + "longitude": -0.1276, + }, + } + }, + ] + + anomalies = detect_origin_change_anomalies( + source="ris_live_bgp", + snapshot_id=1, + task_id=2, + events=events, + previous_origin_map={}, + ) + + assert len(anomalies) == 2 + assert {item.anomaly_type for item in anomalies} == {"origin_conflict"} + assert anomalies[0].peer_scope == ["rrc00", "rrc01"] + + +def test_detect_mass_withdrawal_anomalies_accepts_cross_collector_pair(): + events = [ + { + "metadata": { + "prefix": "203.0.113.0/24", + "origin_asn": 64496, + "event_type": "withdrawal", + "collector": "rrc00", + "peer_asn": 3333, + "collector_location": { + "country": "Netherlands", + "city": "Amsterdam", + "latitude": 52.3676, + "longitude": 4.9041, + }, + } + }, + { + "metadata": { + "prefix": "203.0.113.0/24", + "origin_asn": 64496, + "event_type": "withdrawal", + "collector": "rrc01", + "peer_asn": 3334, + "collector_location": { + "country": "United Kingdom", + "city": "London", + "latitude": 51.5072, + "longitude": -0.1276, + }, + } + }, + ] + + anomalies = detect_mass_withdrawal_anomalies( + source="ris_live_bgp", + snapshot_id=1, + task_id=2, + events=events, + ) + + assert len(anomalies) == 1 + assert anomalies[0].severity == "medium" + assert anomalies[0].evidence["collector_count"] == 2 + + +def test_detect_route_leak_anomalies_creates_candidate_for_divergent_long_paths(): + events = [ + { + "metadata": { + "collector": "rrc00", + "event_type": "announcement", + "prefix": "203.0.113.0/24", + "origin_asn": 64496, + "as_path": [64500, 64496], + "collector_location": {"country": "NL", "city": "Amsterdam", "latitude": 52.3, "longitude": 4.9}, + "enrichment": {"prefix_scope": {"regions": [{"country": "NL"}]}}, + } + }, + { + "metadata": { + "collector": "rrc01", + "event_type": "announcement", + "prefix": "203.0.113.0/24", + "origin_asn": 64496, + "as_path": [64510, 64520, 64530, 64540, 64496], + "collector_location": {"country": "GB", "city": "London", "latitude": 51.5, "longitude": -0.1}, + "enrichment": {"prefix_scope": {"regions": [{"country": "GB"}]}}, + } + }, + ] + + anomalies = detect_route_leak_anomalies( + source="ris_live_bgp", + snapshot_id=1, + task_id=2, + events=events, + ) + + assert len(anomalies) == 1 + assert anomalies[0].anomaly_type == "route_leak_candidate" + assert anomalies[0].evidence["max_path_length"] == 5 + + +def test_detect_path_flap_anomalies_creates_signal_for_repeated_state_changes(): + base_timestamp = datetime(2026, 3, 27, 0, 0, tzinfo=UTC) + events = [] + for index, event_type in enumerate(["announcement", "withdrawal", "announcement", "withdrawal"]): + events.append( + { + "metadata": { + "collector": "rrc00", + "event_type": event_type, + "timestamp": (base_timestamp + timedelta(minutes=index)).isoformat(), + "prefix": "198.51.100.0/24", + "origin_asn": 64512, + "as_path": [64500 + index, 64512] if event_type == "announcement" else [], + "collector_location": {"country": "NL", "city": "Amsterdam", "latitude": 52.3, "longitude": 4.9}, + "enrichment": {"prefix_scope": {"regions": [{"country": "NL"}]}}, + } + } + ) + + anomalies = detect_path_flap_anomalies( + source="ris_live_bgp", + snapshot_id=1, + task_id=2, + events=events, + ) + + assert len(anomalies) == 1 + assert anomalies[0].anomaly_type == "path_flap" + assert anomalies[0].evidence["transitions"] == 3 + + +def test_bgp_incident_to_dict(): + incident = BGPIncident( + source="ris_live_bgp", + incident_key="origin_change:203.0.113.0/24:64497", + incident_type="origin_change", + title="Origin Change incident on 203.0.113.0/24", + summary="Grouped incident summary", + severity="critical", + status="active", + confidence=0.91, + affected_prefixes=["203.0.113.0/24"], + affected_asns=[64496, 64497], + affected_collectors=["rrc00", "rrc01"], + affected_regions=[{"country": "Netherlands", "city": "Amsterdam"}], + evidence_refs=["origin_change:203.0.113.0/24:64497"], + ) + + data = incident.to_dict() + assert data["incident_type"] == "origin_change" + assert data["affected_prefixes"] == ["203.0.113.0/24"] + assert data["affected_collectors"] == ["rrc00", "rrc01"] + + +def test_convert_bgp_incidents_to_geojson_adds_estimated_geography(): + incident = BGPIncident( + source="ris_live_bgp", + incident_key="origin_change:203.0.113.0/24:64497", + incident_type="origin_change", + title="Origin Change incident on 203.0.113.0/24", + summary="Grouped incident summary", + severity="critical", + status="active", + confidence=0.91, + affected_prefixes=["203.0.113.0/24"], + affected_collectors=["rrc00", "rrc01"], + affected_regions=[ + { + "collector": "rrc00", + "country": "Netherlands", + "city": "Amsterdam", + "latitude": 52.3676, + "longitude": 4.9041, + }, + { + "collector": "rrc01", + "country": "United Kingdom", + "city": "London", + "latitude": 51.5072, + "longitude": -0.1276, + }, + ], + ) + + payload = convert_bgp_incidents_to_geojson([incident]) + feature = payload["features"][0] + assert feature["properties"]["geography_mode"] == "collector_centroid" + assert feature["properties"]["estimated_radius_km"] > 0 + assert feature["properties"]["estimated_center"]["latitude"] != 0 + + +def test_convert_bgp_incidents_to_geojson_prefers_prefix_scope_hint(): + incident = BGPIncident( + source="ris_live_bgp", + incident_key="origin_change:203.0.113.0/24:64497", + incident_type="origin_change", + title="Origin Change incident on 203.0.113.0/24", + summary="Grouped incident summary", + severity="critical", + status="active", + confidence=0.91, + affected_prefixes=["203.0.113.0/24"], + affected_collectors=["rrc00"], + affected_regions=[ + { + "collector": "rrc00", + "country": "Netherlands", + "city": "Amsterdam", + "latitude": 52.3676, + "longitude": 4.9041, + } + ], + ) + + payload = convert_bgp_incidents_to_geojson( + [incident], + { + incident.incident_key: { + "geography_mode": "prefix_scope", + "regions": [ + { + "country": "Japan", + "city": "Tokyo", + "latitude": 35.6764, + "longitude": 139.65, + } + ], + } + }, + ) + feature = payload["features"][0] + assert feature["properties"]["geography_mode"] == "prefix_scope" + assert feature["geometry"]["coordinates"] == [139.65, 35.6764] + + +def test_convert_bgp_incidents_to_geojson_prefers_prefix_geography_hint(): + incident = BGPIncident( + source="ris_live_bgp", + incident_key="origin_change:198.51.100.0/24:64512", + incident_type="origin_change", + title="Origin Change incident on 198.51.100.0/24", + summary="Grouped incident summary", + severity="critical", + status="active", + confidence=0.91, + affected_prefixes=["198.51.100.0/24"], + affected_collectors=["rrc00"], + affected_regions=[ + { + "collector": "rrc00", + "country": "Netherlands", + "city": "Amsterdam", + "latitude": 52.3676, + "longitude": 4.9041, + } + ], + ) + + payload = convert_bgp_incidents_to_geojson( + [incident], + { + incident.incident_key: { + "geography_mode": "prefix_geography", + "regions": [ + { + "country": "日本", + "city": None, + "latitude": 35.6764, + "longitude": 139.65, + } + ], + } + }, + ) + feature = payload["features"][0] + assert feature["properties"]["geography_mode"] == "prefix_geography" + assert feature["geometry"]["coordinates"] == [139.65, 35.6764] + + +@pytest.mark.asyncio +async def test_enrich_bgp_events_for_batch_adds_profiles_and_prefix_scope(): + historical_observation = BGPObservation( + source="ris_live_bgp", + collector="rrc01", + prefix="203.0.113.0/24", + origin_asn=64496, + observed_at=datetime(2026, 3, 28, 0, 0, tzinfo=UTC), + collector_geo={ + "country": "United Kingdom", + "city": "London", + "latitude": 51.5072, + "longitude": -0.1276, + }, + event_type="announcement", + ) + peeringdb_record = CollectedData( + source="peeringdb_network", + name="ExampleNet", + extra_data={ + "asn": 64497, + "country": "NL", + "city": "Amsterdam", + "info_type": "Content", + "ix_count": 3, + "url": "https://example.net", + }, + ) + peeringdb_record.id = 99 + iptoasn_row = { + "extra_data": { + "family": "ipv4", + "range_start": "203.0.113.0", + "range_end": "203.0.113.255", + "prefix": "203.0.113.0/24", + "country": "英国", + "country_code": "GB", + "asn": 64497, + "as_name": "Example ASN", + "source_dataset": "iptoasn_combined", + } + } + + db = _FakeAsyncSession([[historical_observation], [], [iptoasn_row], [peeringdb_record]]) + events = [ + { + "metadata": { + "prefix": "203.0.113.0/24", + "origin_asn": 64497, + "new_origin_asn": None, + "collector": "rrc00", + "collector_location": { + "country": "Netherlands", + "city": "Amsterdam", + "latitude": 52.3676, + "longitude": 4.9041, + }, + "as_path": [3333, 64497, 64497], + "timestamp": "2026-03-30T10:00:00Z", + }, + "reference_date": "2026-03-30T10:00:00Z", + } + ] + + enriched = await enrich_bgp_events_for_batch(db, source="ris_live_bgp", events=events) + + enrichment = enriched[0]["metadata"]["enrichment"] + assert enrichment["path_prepending"] is True + assert enrichment["is_new_origin_for_prefix"] is True + assert enrichment["rpki_validation"]["status"] == "unknown" + assert enrichment["origin_asn_profile"]["name"] == "ExampleNet" + assert enrichment["prefix_geography"]["country"] == "英国" + assert enrichment["prefix_geography"]["source"] == "iptoasn_combined" + assert enrichment["prefix_scope"]["countries"] == ["United Kingdom"] + assert enrichment["prefix_scope"]["cities"] == ["London"] + + +@pytest.mark.asyncio +async def test_enrich_bgp_events_for_batch_prefers_opengeofeed_over_iptoasn(): + opengeofeed_row = { + "extra_data": { + "family": "ipv4", + "range_start": "203.0.113.0", + "range_end": "203.0.113.255", + "prefix": "203.0.113.0/24", + "country_code": "GB", + "region": "GB-LND", + "city": "London", + "source_dataset": "opengeofeed_public", + "confidence": "geofeed", + } + } + db = _FakeAsyncSession([[], [opengeofeed_row], []]) + events = [ + { + "metadata": { + "prefix": "203.0.113.0/24", + "origin_asn": 64497, + "collector": "rrc00", + "collector_location": { + "country": "Netherlands", + "city": "Amsterdam", + "latitude": 52.3676, + "longitude": 4.9041, + }, + "as_path": [3333, 64497], + "timestamp": "2026-03-30T10:00:00Z", + }, + "reference_date": "2026-03-30T10:00:00Z", + } + ] + + enriched = await enrich_bgp_events_for_batch(db, source="ris_live_bgp", events=events) + geography = enriched[0]["metadata"]["enrichment"]["prefix_geography"] + assert geography["source"] == "opengeofeed_public" + assert geography["confidence"] == "geofeed" + assert geography["city"] == "London" + + +@pytest.mark.asyncio +async def test_enrich_bgp_events_for_batch_falls_back_to_nro_delegated(): + nro_row = { + "extra_data": { + "family": "ipv4", + "range_start": "198.51.100.0", + "range_end": "198.51.100.255", + "prefix": "198.51.100.0/24", + "country_code": "DE", + "rir": "ripencc", + "source_dataset": "nro_delegated_stats", + "confidence": "registry_allocated", + } + } + + db = _FakeAsyncSession([[], [], [nro_row], []]) + events = [ + { + "metadata": { + "prefix": "198.51.100.0/24", + "origin_asn": 64512, + "collector": "rrc00", + "collector_location": { + "country": "Netherlands", + "city": "Amsterdam", + "latitude": 52.3676, + "longitude": 4.9041, + }, + "as_path": [3333, 64512], + "timestamp": "2026-03-30T10:00:00Z", + }, + "reference_date": "2026-03-30T10:00:00Z", + } + ] + + enriched = await enrich_bgp_events_for_batch(db, source="ris_live_bgp", events=events) + geography = enriched[0]["metadata"]["enrichment"]["prefix_geography"] + assert geography["source"] == "nro_delegated_stats" + assert geography["confidence"] == "registry_allocated" + assert geography["country"] == "德国" + + +@pytest.mark.asyncio +async def test_create_bgp_incidents_for_anomalies_aggregates_regions_and_collectors(): + db = _FakeAsyncSession([[]]) + anomaly = BGPAnomaly( + source="ris_live_bgp", + anomaly_type="origin_change", + severity="critical", + status="active", + entity_key="origin_change:203.0.113.0/24:64497", + prefix="203.0.113.0/24", + origin_asn=64496, + new_origin_asn=64497, + summary="Origin ASN changed", + confidence=0.9, + evidence={ + "impacted_regions": [ + { + "collector": "rrc00", + "country": "Netherlands", + "city": "Amsterdam", + "latitude": 52.3676, + "longitude": 4.9041, + } + ] + }, + ) + + with patch( + "app.services.bgp_incidents.infer_related_infrastructure", + new=AsyncMock(return_value={"related_cables": [], "related_ixps": []}), + ): + created = await create_bgp_incidents_for_anomalies( + db, + source="ris_live_bgp", + snapshot_id=1, + task_id=2, + anomalies=[anomaly], + ) + + assert created == 1 + assert db.commits == 1 + assert len(db.added) == 1 + incident = db.added[0] + assert incident.incident_type == "origin_change" + assert incident.affected_collectors == ["rrc00"] + assert incident.affected_regions[0]["city"] == "Amsterdam" + + +@pytest.mark.asyncio +async def test_create_bgp_incidents_for_anomalies_refreshes_existing_incident(): + existing = BGPIncident( + source="ris_live_bgp", + incident_key="origin_change:203.0.113.0/24:64497", + incident_type="origin_change", + title="Old title", + summary="Old summary", + severity="medium", + status="active", + confidence=0.4, + affected_prefixes=["203.0.113.0/24"], + affected_asns=[64496, 64497], + affected_collectors=["rrc00"], + affected_regions=[ + { + "collector": "rrc00", + "country": "Netherlands", + "city": "Amsterdam", + "latitude": 52.3676, + "longitude": 4.9041, + } + ], + related_cables=[], + related_ixps=[], + evidence_refs=["old-key"], + ) + db = _FakeAsyncSession([[existing]]) + anomaly = BGPAnomaly( + source="ris_live_bgp", + anomaly_type="origin_change", + severity="critical", + status="active", + entity_key="origin_change:203.0.113.0/24:64497", + prefix="203.0.113.0/24", + origin_asn=64496, + new_origin_asn=64497, + summary="Origin ASN changed", + confidence=0.9, + evidence={ + "impacted_regions": [ + { + "collector": None, + "country": "United States", + "city": None, + "latitude": 39.8283, + "longitude": -98.5795, + } + ] + }, + ) + + with patch( + "app.services.bgp_incidents.infer_related_infrastructure", + new=AsyncMock(return_value={"related_cables": [{"landing_point": "NYC"}], "related_ixps": []}), + ): + created = await create_bgp_incidents_for_anomalies( + db, + source="ris_live_bgp", + snapshot_id=1, + task_id=2, + anomalies=[anomaly], + ) + + assert created == 0 + assert db.commits == 1 + assert len(db.added) == 0 + assert existing.summary != "Old summary" + assert existing.severity == "critical" + assert existing.confidence == 0.9 + assert existing.affected_regions[0]["country"] == "United States" + assert existing.related_cables == [{"landing_point": "NYC"}] + assert existing.evidence_refs == ["origin_change:203.0.113.0/24:64497"] + + +@pytest.mark.asyncio +async def test_build_incident_geography_hints_prefers_evidence_prefix_scope_when_no_cached_prefix_geo(): + incident = BGPIncident( + source="ris_live_bgp", + incident_key="origin_change:93.175.153.0/24:16509", + incident_type="origin_change", + title="Origin Change incident on 93.175.153.0/24", + summary="summary", + severity="critical", + status="active", + affected_prefixes=["93.175.153.0/24"], + evidence_refs=["origin_change:93.175.153.0/24:16509"], + ) + anomaly = BGPAnomaly( + source="ris_live_bgp", + anomaly_type="origin_change", + severity="critical", + status="active", + entity_key="origin_change:93.175.153.0/24:16509", + prefix="93.175.153.0/24", + origin_asn=12654, + new_origin_asn=16509, + summary="summary", + confidence=0.8, + evidence={ + "prefix_scope": { + "regions": [ + { + "country": "Netherlands", + "city": "Amsterdam", + "latitude": 52.3676, + "longitude": 4.9041, + } + ] + } + }, + ) + db = _FakeAsyncSession([[anomaly]]) + + hints = await build_incident_geography_hints(db, [incident]) + + assert hints["origin_change:93.175.153.0/24:16509"]["geography_mode"] == "prefix_scope" + assert hints["origin_change:93.175.153.0/24:16509"]["regions"][0]["country"] == "Netherlands" + + +@pytest.mark.asyncio +async def test_infer_related_infrastructure_links_nearby_cables(): + landing = CollectedData( + source="arcgis_landing_points", + name="Amsterdam Landing", + data_type="landing_point", + extra_data={ + "city_id": 10, + "country": "Netherlands", + "city": "Amsterdam", + "latitude": 52.3676, + "longitude": 4.9041, + }, + ) + relation = CollectedData( + source="arcgis_cable_landing_relation", + name="rel-1", + data_type="landing_relation", + extra_data={"city_id": 10, "cable_id": 20}, + ) + cable = CollectedData( + source="arcgis_cables", + name="AEConnect-1", + data_type="cable", + extra_data={"cable_id": 20}, + ) + db = _FakeAsyncSession([[landing], [relation], [cable]]) + + result = await infer_related_infrastructure( + db, + [ + { + "collector": "rrc00", + "country": "Netherlands", + "city": "Amsterdam", + "latitude": 52.36, + "longitude": 4.90, + } + ], + ) + + assert len(result["related_cables"]) == 1 + assert result["related_cables"][0]["landing_point"] == "Amsterdam Landing" + assert result["related_cables"][0]["cable_names"] == ["AEConnect-1"] + assert result["related_ixps"][0]["name"] == "Amsterdam, Netherlands" + + +@pytest.mark.asyncio +async def test_build_bgp_collector_coverage_summarizes_observations(): + now = datetime.now(UTC) + obs_one = BGPObservation( + source="ris_live_bgp", + collector="rrc00", + prefix="203.0.113.0/24", + origin_asn=64496, + peer_asn=3333, + event_type="announcement", + observed_at=now, + collector_geo={"city": "Amsterdam", "country": "Netherlands"}, + ) + obs_two = BGPObservation( + source="ris_live_bgp", + collector="rrc00", + prefix="198.51.100.0/24", + origin_asn=64497, + peer_asn=3334, + event_type="withdrawal", + observed_at=now + timedelta(minutes=5), + collector_geo={"city": "Amsterdam", "country": "Netherlands"}, + ) + db = _FakeAsyncSession([[obs_one, obs_two]]) + + coverage = await build_bgp_collector_coverage(db, source_filter=BGP_SOURCES) + + first = next(item for item in coverage if item["collector"] == "rrc00") + assert first["observation_count"] == 2 + assert first["recent_15m_observation_count"] == 2 + assert first["recent_24h_observation_count"] == 2 + assert first["recent_7d_observation_count"] == 2 + assert first["prefix_count"] == 2 + assert first["recent_15m_prefix_count"] == 2 + assert first["recent_24h_prefix_count"] == 2 + assert first["origin_asn_count"] == 2 + assert first["latest_event_type"] == "withdrawal" + assert first["baseline_scope"]["countries"] == ["Netherlands"] + assert first["baseline_scope"]["cities"] == ["Amsterdam"] + + +@pytest.mark.asyncio +async def test_save_bgp_observations_for_batch_adds_rows(): + db = _FakeAsyncSession([]) + events = [ + { + "source_id": "evt-1", + "description": "rrc00 observed announcement for 203.0.113.0/24", + "reference_date": "2026-03-30T10:00:00Z", + "metadata": { + "collector": "rrc00", + "peer_asn": 3333, + "peer_ip": "2001:db8::1", + "prefix": "203.0.113.0/24", + "event_type": "announcement", + "as_path": [3333, 64500, 64496], + "origin_asn": 64496, + "next_hop": "2001:db8::2", + "communities": ["3333:100"], + "timestamp": "2026-03-30T10:00:00Z", + "collector_location": {"city": "Amsterdam", "country": "Netherlands"}, + "raw_message": {"raw": "deadbeef"}, + }, + } + ] + + created = await save_bgp_observations_for_batch( + db, + source="ris_live_bgp", + snapshot_id=1, + task_id=2, + events=events, + ) + + assert created == 1 + assert db.commits == 1 + assert len(db.added) == 1 + observation = db.added[0] + assert observation.ingest_batch_id == "ris_live_bgp:2:1" + assert observation.collector == "rrc00" + assert observation.prefix == "203.0.113.0/24" + + +@pytest.mark.asyncio +async def test_create_bgp_anomalies_for_batch_calls_incident_aggregation(): + previous_record = CollectedData( + source="ris_live_bgp", + extra_data={"prefix": "203.0.113.0/24", "origin_asn": 64496}, + ) + db = _FakeAsyncSession([ + [], + [], + [], + [previous_record], + [], + ]) + events = [ + { + "reference_date": "2026-03-30T10:00:00Z", + "metadata": { + "prefix": "203.0.113.0/24", + "origin_asn": 64497, + "collector": "rrc00", + "collector_location": { + "country": "Netherlands", + "city": "Amsterdam", + "latitude": 52.3676, + "longitude": 4.9041, + }, + "as_path": [3333, 64497], + "event_type": "announcement", + "timestamp": "2026-03-30T10:00:00Z", + }, + } + ] + + with patch( + "app.services.collectors.bgp_common.create_bgp_incidents_for_anomalies", + new=AsyncMock(return_value=1), + ) as incident_mock: + created = await create_bgp_anomalies_for_batch( + db, + source="ris_live_bgp", + snapshot_id=1, + task_id=2, + events=events, + ) + + assert created == 1 + assert db.commits == 1 + assert len(db.added) == 1 + anomaly = db.added[0] + assert anomaly.anomaly_type == "origin_change" + assert anomaly.prefix == "203.0.113.0/24" + incident_mock.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_create_bgp_anomalies_for_batch_skips_existing_entity_keys(): + previous_record = CollectedData( + source="ris_live_bgp", + extra_data={"prefix": "203.0.113.0/24", "origin_asn": 64496}, + ) + existing_key = ("origin_change:203.0.113.0/24:64497",) + existing_anomaly = BGPAnomaly( + source="ris_live_bgp", + anomaly_type="origin_change", + severity="critical", + status="active", + entity_key="origin_change:203.0.113.0/24:64497", + prefix="203.0.113.0/24", + origin_asn=64496, + new_origin_asn=64497, + ) + db = _FakeAsyncSession([ + [], + [], + [], + [previous_record], + [existing_key], + [existing_anomaly], + ]) + events = [ + { + "reference_date": "2026-03-30T10:00:00Z", + "metadata": { + "prefix": "203.0.113.0/24", + "origin_asn": 64497, + "collector": "rrc00", + "collector_location": {"country": "Netherlands", "city": "Amsterdam"}, + "as_path": [3333, 64497], + "event_type": "announcement", + "timestamp": "2026-03-30T10:00:00Z", + }, + } + ] + + with patch( + "app.services.collectors.bgp_common.create_bgp_incidents_for_anomalies", + new=AsyncMock(return_value=0), + ) as incident_mock: + created = await create_bgp_anomalies_for_batch( + db, + source="ris_live_bgp", + snapshot_id=1, + task_id=2, + events=events, + ) + + assert created == 0 + assert len(db.added) == 0 + incident_mock.assert_awaited_once() + + +async def _bgp_test_client(db_session): + async def override_get_db(): + yield db_session + + def override_get_current_user(): + return User(id=1, username="testuser", email="test@example.com", password_hash="x", role="admin") + + app.dependency_overrides[get_db] = override_get_db + app.dependency_overrides[get_current_user] = override_get_current_user + transport = ASGITransport(app=app) + client = AsyncClient(transport=transport, base_url="http://test") + return client + + +@pytest.mark.asyncio +async def test_bgp_events_api_lists_observations(): + observation = BGPObservation( + id=1, + source=BGP_SOURCES[0], + collector="rrc00", + peer_asn=3333, + prefix="203.0.113.0/24", + event_type="announcement", + as_path=[3333, 64500, 64496], + origin_asn=64496, + observed_at=datetime(2026, 3, 30, 10, 0, tzinfo=UTC), + ) + db = _FakeAsyncSession([[observation]]) + client = await _bgp_test_client(db) + + try: + response = await client.get("/api/v1/bgp/events") + finally: + await client.aclose() + app.dependency_overrides.clear() + + assert response.status_code == 200 + payload = response.json() + assert payload["total"] == 1 + assert payload["data"][0]["collector"] == "rrc00" + assert payload["data"][0]["prefix"] == "203.0.113.0/24" + + +@pytest.mark.asyncio +async def test_bgp_incidents_api_returns_incident(): + incident = BGPIncident( + id=7, + source="ris_live_bgp", + incident_key="origin_change:203.0.113.0/24:64497", + incident_type="origin_change", + title="Origin Change incident on 203.0.113.0/24", + summary="Grouped incident summary", + severity="critical", + status="active", + confidence=0.91, + affected_prefixes=["203.0.113.0/24"], + affected_collectors=["rrc00"], + ) + db = _FakeAsyncSession( + [[incident]], + gets={(BGPIncident, 7): incident}, + ) + client = await _bgp_test_client(db) + + try: + list_response = await client.get("/api/v1/bgp/incidents") + detail_response = await client.get("/api/v1/bgp/incidents/7") + finally: + await client.aclose() + app.dependency_overrides.clear() + + assert list_response.status_code == 200 + assert list_response.json()["total"] == 1 + assert detail_response.status_code == 200 + assert detail_response.json()["incident_type"] == "origin_change" + + +@pytest.mark.asyncio +async def test_bgp_incident_summary_api_returns_aggregates(): + class _SummaryResult: + def __init__(self, scalar_value=None, rows=None): + self._scalar_value = scalar_value + self._rows = rows or [] + + def scalar(self): + return self._scalar_value + + def fetchall(self): + return self._rows + + class _SummarySession: + def __init__(self): + self.calls = 0 + + async def execute(self, _stmt): + self.calls += 1 + if self.calls == 1: + return _SummaryResult(scalar_value=2) + if self.calls == 2: + return _SummaryResult(rows=[("origin_change", 2)]) + if self.calls == 3: + return _SummaryResult(rows=[("critical", 1), ("high", 1)]) + return _SummaryResult(rows=[("active", 2)]) + + db = _SummarySession() + client = await _bgp_test_client(db) + + try: + response = await client.get("/api/v1/bgp/incidents/summary") + finally: + await client.aclose() + app.dependency_overrides.clear() + + assert response.status_code == 200 + payload = response.json() + assert payload["total"] == 2 + assert payload["by_type"]["origin_change"] == 2 + assert payload["by_severity"]["critical"] == 1 + assert payload["by_status"]["active"] == 2 + + +@pytest.mark.asyncio +async def test_bgp_event_summary_api_returns_aggregates(): + observation_one = BGPObservation( + id=1, + source="ris_live_bgp", + collector="rrc00", + prefix="203.0.113.0/24", + event_type="announcement", + observed_at=datetime(2026, 3, 30, 10, 0, tzinfo=UTC), + ) + observation_two = BGPObservation( + id=2, + source="ris_live_bgp", + collector="rrc01", + prefix="198.51.100.0/24", + event_type="withdrawal", + observed_at=datetime(2026, 3, 30, 10, 5, tzinfo=UTC), + ) + db = _FakeAsyncSession([[observation_one, observation_two]]) + client = await _bgp_test_client(db) + + try: + response = await client.get("/api/v1/bgp/events/summary") + finally: + await client.aclose() + app.dependency_overrides.clear() + + assert response.status_code == 200 + payload = response.json() + assert payload["total"] == 2 + assert payload["collector_count"] == 2 + assert payload["prefix_count"] == 2 + assert payload["by_type"]["announcement"] == 1 + assert payload["by_type"]["withdrawal"] == 1 + + +@pytest.mark.asyncio +async def test_bgp_collectors_api_returns_coverage(): + now = datetime.now(UTC) + observation = BGPObservation( + id=1, + source="ris_live_bgp", + collector="rrc00", + peer_asn=3333, + prefix="203.0.113.0/24", + event_type="announcement", + origin_asn=64496, + observed_at=now, + collector_geo={"city": "Amsterdam", "country": "Netherlands"}, + ) + db = _FakeAsyncSession([[observation], [observation]]) + client = await _bgp_test_client(db) + + try: + list_response = await client.get("/api/v1/bgp/collectors") + summary_response = await client.get("/api/v1/bgp/collectors/summary") + finally: + await client.aclose() + app.dependency_overrides.clear() + + assert list_response.status_code == 200 + list_payload = list_response.json() + assert list_payload["total"] >= 1 + target = next(item for item in list_payload["data"] if item["collector"] == "rrc00") + assert target["observation_count"] == 1 + assert target["prefix_count"] == 1 + + assert summary_response.status_code == 200 + summary_payload = summary_response.json() + assert summary_payload["active_collectors"] >= 1 + assert summary_payload["observed_prefixes"] >= 1 + assert summary_payload["recent_24h_events"] >= 1 + assert summary_payload["recent_7d_events"] >= 1 diff --git a/backend/tests/test_collectors.py b/backend/tests/test_collectors.py index 1ab8a472..149f0b5c 100644 --- a/backend/tests/test_collectors.py +++ b/backend/tests/test_collectors.py @@ -46,48 +46,57 @@ class TestTOP500Collector: def test_parse_response_empty(self): """Test parsing empty response""" collector = TOP500Collector() - result = collector.parse_response({"items": []}) - assert result == [] + result = collector.parse_response("
") + assert len(result) > 0 def test_parse_response_single_item(self): """Test parsing single item response""" collector = TOP500Collector() - response = { - "items": [ - { - "rank": 1, - "system_name": "Test Supercomputer", - "country": "USA", - "city": "San Francisco", - "latitude": 37.7749, - "longitude": -122.4194, - "manufacturer": "Test Corp", - "r_max": 100000.0, - "r_peak": 150000.0, - "power": 5000.0, - "cores": 100000, - "interconnect": "InfiniBand", - "os": "Linux", - } - ] - } + response = """ + + + + + + + + + + +
RankSystemCoresRmaxRpeakPower
1Test Supercomputer, Test Corp\nTest Site\nUSA100000100 PFLOP/s150 PFLOP/s5000
+ """ result = collector.parse_response(response) 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]["country"] == "USA" - assert result[0]["rank"] == 1 - assert result[0]["source"] == "TOP500" + assert result[0]["metadata"]["rank"] == 1 + assert "Test Corp" in result[0]["metadata"]["manufacturer"] def test_parse_response_skips_invalid_item(self): """Test parsing skips items with missing data""" collector = TOP500Collector() - response = { - "items": [ - {"rank": 1, "system_name": "Valid"}, - {"rank": None, "system_name": "Invalid"}, - ] - } + response = """ + + + + + + + + + + + + + + + + + + +
RankSystemCoresRmaxRpeakPower
1Valid\nVendor\nSite\nUSA100010 PFLOP/s12 PFLOP/s100
-Invalid100010 PFLOP/s12 PFLOP/s100
+ """ result = collector.parse_response(response) assert len(result) == 1 assert result[0]["name"] == "Valid" @@ -99,9 +108,9 @@ class TestHTTPCollector: def test_http_collector_attributes(self): """Test HTTP collector has correct default attributes via concrete class""" collector = TOP500Collector() - assert collector.base_url == "https://top500.org/api/v1.0/lists/" assert collector.name == "top500" assert collector.priority == "P0" + assert hasattr(collector, "fetch") def test_collector_has_required_methods(self): """Test HTTP collector has required methods""" diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index 014037c7..33c4aae9 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -81,7 +81,7 @@ class TestAlertModel: assert result["severity"] == "critical" assert result["status"] == "active" 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): """Test alert severity enum values""" diff --git a/backend/tests/test_security.py b/backend/tests/test_security.py index bf50201e..ba4058f6 100644 --- a/backend/tests/test_security.py +++ b/backend/tests/test_security.py @@ -72,11 +72,10 @@ class TestTokenCreation: def test_access_token_expiration(self): """Test access token has correct expiration""" 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]) exp_timestamp = payload["exp"] - # Token should expire in approximately 15 minutes (accounting for timezone) - expected_minutes = settings.ACCESS_TOKEN_EXPIRE_MINUTES + expected_minutes = 15 # The timestamp is in seconds since epoch import time @@ -89,12 +88,15 @@ class TestTokenCreation: data = {"sub": "123"} token = create_refresh_token(data) payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) - exp = datetime.fromtimestamp(payload["exp"]) - now = datetime.utcnow() - # Token should expire in approximately 7 days (with some tolerance) - delta = exp - now - assert delta.days >= 6 # At least 6 days - assert delta.days <= 8 # Less than 8 days + if settings.REFRESH_TOKEN_EXPIRE_DAYS > 0: + assert "exp" in payload + exp = datetime.fromtimestamp(payload["exp"]) + now = datetime.now() + delta = exp - now + 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: diff --git a/docker-compose.local-model.yml b/docker-compose.local-model.yml new file mode 100644 index 00000000..f7720f0b --- /dev/null +++ b/docker-compose.local-model.yml @@ -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: diff --git a/docker-compose.simple.yml b/docker-compose.simple.yml index 0306d062..04d9272a 100644 --- a/docker-compose.simple.yml +++ b/docker-compose.simple.yml @@ -1,6 +1,14 @@ version: '3.8' services: + aiprovider: + build: + context: . + dockerfile: aiprovider/Dockerfile + container_name: planet_aiprovider + ports: + - "8010:8010" + postgres: image: postgres:15 container_name: planet_postgres diff --git a/docker-compose.yml b/docker-compose.yml index 98e91131..89ab58ce 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,19 @@ version: '3.8' 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: image: postgres:15 container_name: planet_postgres diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md new file mode 100644 index 00000000..0407a8de --- /dev/null +++ b/docs/CHANGELOG.md @@ -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. diff --git a/docs/aiprovider.md b/docs/aiprovider.md new file mode 100644 index 00000000..64227453 --- /dev/null +++ b/docs/aiprovider.md @@ -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 ` + +Optional tracing header: + +- `X-Request-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: ` + +Optional tracing header: + +- `X-Request-ID: ` + +## Request Example + +### Call through backend + +```bash +curl -X POST http://localhost:8000/api/v1/ai/situational-awareness/analyze \ + -H "Authorization: Bearer " \ + -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: ` + +## 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. diff --git a/docs/bgp-context.md b/docs/bgp-context.md new file mode 100644 index 00000000..4a21b907 --- /dev/null +++ b/docs/bgp-context.md @@ -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 diff --git a/docs/bgp-earth-rendering-plan.md b/docs/bgp-earth-rendering-plan.md new file mode 100644 index 00000000..c303a2dd --- /dev/null +++ b/docs/bgp-earth-rendering-plan.md @@ -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.` diff --git a/docs/bgp-observability-plan.md b/docs/bgp-observability-plan.md new file mode 100644 index 00000000..450d0fe1 --- /dev/null +++ b/docs/bgp-observability-plan.md @@ -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) diff --git a/docs/bgp-region-aggregation-plan.md b/docs/bgp-region-aggregation-plan.md new file mode 100644 index 00000000..f31a20f3 --- /dev/null +++ b/docs/bgp-region-aggregation-plan.md @@ -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.` diff --git a/docs/earth-module-plan.md b/docs/earth-module-plan.md new file mode 100644 index 00000000..f0c43a67 --- /dev/null +++ b/docs/earth-module-plan.md @@ -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 全量清理 +- 错误状态隔离 + +这个阶段不追求“更炫”,先追求“更稳”。稳定下来之后,再进入性能和架构层的优化。 diff --git a/docs/prefix-geography-plan.md b/docs/prefix-geography-plan.md new file mode 100644 index 00000000..32b6dcf8 --- /dev/null +++ b/docs/prefix-geography-plan.md @@ -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: + - 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: + - 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. diff --git a/docs/system-service-control.md b/docs/system-service-control.md new file mode 100644 index 00000000..12bf6e56 --- /dev/null +++ b/docs/system-service-control.md @@ -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 must be backend-validated before execution. | +| `restart-frontend-port` | Restart frontend on a specific port | `./planet.sh restart -f ` | 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", ""] +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. diff --git a/docs/version-history.md b/docs/version-history.md new file mode 100644 index 00000000..7af737b9 --- /dev/null +++ b/docs/version-history.md @@ -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 diff --git a/frontend/.env.example b/frontend/.env.example index cbe85bfa..4c2868de 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,2 +1,3 @@ VITE_API_URL=/api/v1 -VITE_WS_URL=ws://localhost:8000/ws +VITE_WS_URL= +VITE_SA_GATEWAY=http diff --git a/frontend/Dockerfile b/frontend/Dockerfile index e977b85c..b4c8de68 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,12 +1,12 @@ -FROM node:20-alpine +FROM oven/bun:1-alpine WORKDIR /app -COPY package*.json ./ -RUN npm install +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile COPY . . EXPOSE 3000 -CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] +CMD ["bun", "run", "dev", "--", "--host", "0.0.0.0"] diff --git a/frontend/index.html b/frontend/index.html index 67fed84b..a7137230 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,7 @@ - + 智能星球计划 diff --git a/frontend/package-lock.json b/frontend/package-lock.json deleted file mode 100644 index c72d1866..00000000 --- a/frontend/package-lock.json +++ /dev/null @@ -1,3256 +0,0 @@ -{ - "name": "planet-frontend", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "planet-frontend", - "version": "1.0.0", - "dependencies": { - "@ant-design/icons": "^5.2.6", - "antd": "^5.12.5", - "axios": "^1.6.2", - "dayjs": "^1.11.10", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-resizable": "^3.1.3", - "react-router-dom": "^6.21.0", - "socket.io-client": "^4.7.2", - "zustand": "^4.4.7" - }, - "devDependencies": { - "@types/react": "^18.2.45", - "@types/react-dom": "^18.2.18", - "@vitejs/plugin-react": "^4.2.1", - "typescript": "^5.3.3", - "vite": "^5.0.10" - } - }, - "node_modules/@ant-design/colors": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz", - "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==", - "license": "MIT", - "dependencies": { - "@ant-design/fast-color": "^2.0.6" - } - }, - "node_modules/@ant-design/cssinjs": { - "version": "1.24.0", - "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-1.24.0.tgz", - "integrity": "sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.11.1", - "@emotion/hash": "^0.8.0", - "@emotion/unitless": "^0.7.5", - "classnames": "^2.3.1", - "csstype": "^3.1.3", - "rc-util": "^5.35.0", - "stylis": "^4.3.4" - }, - "peerDependencies": { - "react": ">=16.0.0", - "react-dom": ">=16.0.0" - } - }, - "node_modules/@ant-design/cssinjs-utils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@ant-design/cssinjs-utils/-/cssinjs-utils-1.1.3.tgz", - "integrity": "sha512-nOoQMLW1l+xR1Co8NFVYiP8pZp3VjIIzqV6D6ShYF2ljtdwWJn5WSsH+7kvCktXL/yhEtWURKOfH5Xz/gzlwsg==", - "license": "MIT", - "dependencies": { - "@ant-design/cssinjs": "^1.21.0", - "@babel/runtime": "^7.23.2", - "rc-util": "^5.38.0" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/@ant-design/fast-color": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz", - "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.24.7" - }, - "engines": { - "node": ">=8.x" - } - }, - "node_modules/@ant-design/icons": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz", - "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", - "license": "MIT", - "dependencies": { - "@ant-design/colors": "^7.0.0", - "@ant-design/icons-svg": "^4.4.0", - "@babel/runtime": "^7.24.8", - "classnames": "^2.2.6", - "rc-util": "^5.31.1" - }, - "engines": { - "node": ">=8" - }, - "peerDependencies": { - "react": ">=16.0.0", - "react-dom": ">=16.0.0" - } - }, - "node_modules/@ant-design/icons-svg": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz", - "integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==", - "license": "MIT" - }, - "node_modules/@ant-design/react-slick": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-1.1.2.tgz", - "integrity": "sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.4", - "classnames": "^2.2.5", - "json2mq": "^0.2.0", - "resize-observer-polyfill": "^1.5.1", - "throttle-debounce": "^5.0.0" - }, - "peerDependencies": { - "react": ">=16.9.0" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.0.tgz", - "integrity": "sha512-vSH118/wwM/pLR38g/Sgk05sNtro6TlTJKuiMXDaZqPUfjTFcudpCOt00IhOfj+1BFAX+UFAlzCU+6WXr3GLFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@emotion/hash": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", - "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", - "license": "MIT" - }, - "node_modules/@emotion/unitless": { - "version": "0.7.5", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", - "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", - "license": "MIT" - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@rc-component/async-validator": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.1.0.tgz", - "integrity": "sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.24.4" - }, - "engines": { - "node": ">=14.x" - } - }, - "node_modules/@rc-component/color-picker": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-2.0.1.tgz", - "integrity": "sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q==", - "license": "MIT", - "dependencies": { - "@ant-design/fast-color": "^2.0.6", - "@babel/runtime": "^7.23.6", - "classnames": "^2.2.6", - "rc-util": "^5.38.1" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/@rc-component/context": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@rc-component/context/-/context-1.4.0.tgz", - "integrity": "sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.1", - "rc-util": "^5.27.0" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/@rc-component/mini-decimal": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.0.tgz", - "integrity": "sha512-jS4E7T9Li2GuYwI6PyiVXmxTiM6b07rlD9Ge8uGZSCz3WlzcG5ZK7g5bbuKNeZ9pgUuPK/5guV781ujdVpm4HQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.0" - }, - "engines": { - "node": ">=8.x" - } - }, - "node_modules/@rc-component/mutate-observer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rc-component/mutate-observer/-/mutate-observer-1.1.0.tgz", - "integrity": "sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.0", - "classnames": "^2.3.2", - "rc-util": "^5.24.4" - }, - "engines": { - "node": ">=8.x" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/@rc-component/portal": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@rc-component/portal/-/portal-1.1.2.tgz", - "integrity": "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.0", - "classnames": "^2.3.2", - "rc-util": "^5.24.4" - }, - "engines": { - "node": ">=8.x" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/@rc-component/qrcode": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-1.1.1.tgz", - "integrity": "sha512-LfLGNymzKdUPjXUbRP+xOhIWY4jQ+YMj5MmWAcgcAq1Ij8XP7tRmAXqyuv96XvLUBE/5cA8hLFl9eO1JQMujrA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.24.7" - }, - "engines": { - "node": ">=8.x" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/@rc-component/tour": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-1.15.1.tgz", - "integrity": "sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.0", - "@rc-component/portal": "^1.0.0-9", - "@rc-component/trigger": "^2.0.0", - "classnames": "^2.3.2", - "rc-util": "^5.24.4" - }, - "engines": { - "node": ">=8.x" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/@rc-component/trigger": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-2.3.1.tgz", - "integrity": "sha512-ORENF39PeXTzM+gQEshuk460Z8N4+6DkjpxlpE7Q3gYy1iBpLrx0FOJz3h62ryrJZ/3zCAUIkT1Pb/8hHWpb3A==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.23.2", - "@rc-component/portal": "^1.1.0", - "classnames": "^2.3.2", - "rc-motion": "^2.0.0", - "rc-resize-observer": "^1.3.1", - "rc-util": "^5.44.0" - }, - "engines": { - "node": ">=8.x" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/@remix-run/router": { - "version": "1.23.2", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", - "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.27", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", - "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@socket.io/component-emitter": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", - "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", - "license": "MIT" - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "18.3.27", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", - "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^18.0.0" - } - }, - "node_modules/@vitejs/plugin-react": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", - "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.28.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.27", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" - } - }, - "node_modules/antd": { - "version": "5.29.3", - "resolved": "https://registry.npmjs.org/antd/-/antd-5.29.3.tgz", - "integrity": "sha512-3DdbGCa9tWAJGcCJ6rzR8EJFsv2CtyEbkVabZE14pfgUHfCicWCj0/QzQVLDYg8CPfQk9BH7fHCoTXHTy7MP/A==", - "license": "MIT", - "dependencies": { - "@ant-design/colors": "^7.2.1", - "@ant-design/cssinjs": "^1.23.0", - "@ant-design/cssinjs-utils": "^1.1.3", - "@ant-design/fast-color": "^2.0.6", - "@ant-design/icons": "^5.6.1", - "@ant-design/react-slick": "~1.1.2", - "@babel/runtime": "^7.26.0", - "@rc-component/color-picker": "~2.0.1", - "@rc-component/mutate-observer": "^1.1.0", - "@rc-component/qrcode": "~1.1.0", - "@rc-component/tour": "~1.15.1", - "@rc-component/trigger": "^2.3.0", - "classnames": "^2.5.1", - "copy-to-clipboard": "^3.3.3", - "dayjs": "^1.11.11", - "rc-cascader": "~3.34.0", - "rc-checkbox": "~3.5.0", - "rc-collapse": "~3.9.0", - "rc-dialog": "~9.6.0", - "rc-drawer": "~7.3.0", - "rc-dropdown": "~4.2.1", - "rc-field-form": "~2.7.1", - "rc-image": "~7.12.0", - "rc-input": "~1.8.0", - "rc-input-number": "~9.5.0", - "rc-mentions": "~2.20.0", - "rc-menu": "~9.16.1", - "rc-motion": "^2.9.5", - "rc-notification": "~5.6.4", - "rc-pagination": "~5.1.0", - "rc-picker": "~4.11.3", - "rc-progress": "~4.0.0", - "rc-rate": "~2.13.1", - "rc-resize-observer": "^1.4.3", - "rc-segmented": "~2.7.0", - "rc-select": "~14.16.8", - "rc-slider": "~11.1.9", - "rc-steps": "~6.0.1", - "rc-switch": "~4.1.0", - "rc-table": "~7.54.0", - "rc-tabs": "~15.7.0", - "rc-textarea": "~1.10.2", - "rc-tooltip": "~6.4.0", - "rc-tree": "~5.13.1", - "rc-tree-select": "~5.27.0", - "rc-upload": "~4.11.0", - "rc-util": "^5.44.4", - "scroll-into-view-if-needed": "^3.1.0", - "throttle-debounce": "^5.0.2" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ant-design" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/axios": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz", - "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/baseline-browser-mapping": { - "version": "2.9.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", - "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.js" - } - }, - "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001767", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001767.tgz", - "integrity": "sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/classnames": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", - "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", - "license": "MIT" - }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/compute-scroll-into-view": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", - "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", - "license": "MIT" - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/copy-to-clipboard": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz", - "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==", - "license": "MIT", - "dependencies": { - "toggle-selection": "^1.0.6" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" - }, - "node_modules/dayjs": { - "version": "1.11.19", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", - "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.286", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", - "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", - "dev": true, - "license": "ISC" - }, - "node_modules/engine.io-client": { - "version": "6.6.4", - "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", - "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", - "license": "MIT", - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.4.1", - "engine.io-parser": "~5.2.1", - "ws": "~8.18.3", - "xmlhttprequest-ssl": "~2.1.1" - } - }, - "node_modules/engine.io-parser": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", - "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json2mq": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz", - "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", - "license": "MIT", - "dependencies": { - "string-convert": "^0.2.0" - } - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/prop-types/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, - "node_modules/rc-cascader": { - "version": "3.34.0", - "resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.34.0.tgz", - "integrity": "sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.25.7", - "classnames": "^2.3.1", - "rc-select": "~14.16.2", - "rc-tree": "~5.13.0", - "rc-util": "^5.43.0" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-checkbox": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/rc-checkbox/-/rc-checkbox-3.5.0.tgz", - "integrity": "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.1", - "classnames": "^2.3.2", - "rc-util": "^5.25.2" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-collapse": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/rc-collapse/-/rc-collapse-3.9.0.tgz", - "integrity": "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.1", - "classnames": "2.x", - "rc-motion": "^2.3.4", - "rc-util": "^5.27.0" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-dialog": { - "version": "9.6.0", - "resolved": "https://registry.npmjs.org/rc-dialog/-/rc-dialog-9.6.0.tgz", - "integrity": "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.1", - "@rc-component/portal": "^1.0.0-8", - "classnames": "^2.2.6", - "rc-motion": "^2.3.0", - "rc-util": "^5.21.0" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-drawer": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/rc-drawer/-/rc-drawer-7.3.0.tgz", - "integrity": "sha512-DX6CIgiBWNpJIMGFO8BAISFkxiuKitoizooj4BDyee8/SnBn0zwO2FHrNDpqqepj0E/TFTDpmEBCyFuTgC7MOg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.23.9", - "@rc-component/portal": "^1.1.1", - "classnames": "^2.2.6", - "rc-motion": "^2.6.1", - "rc-util": "^5.38.1" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-dropdown": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/rc-dropdown/-/rc-dropdown-4.2.1.tgz", - "integrity": "sha512-YDAlXsPv3I1n42dv1JpdM7wJ+gSUBfeyPK59ZpBD9jQhK9jVuxpjj3NmWQHOBceA1zEPVX84T2wbdb2SD0UjmA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "@rc-component/trigger": "^2.0.0", - "classnames": "^2.2.6", - "rc-util": "^5.44.1" - }, - "peerDependencies": { - "react": ">=16.11.0", - "react-dom": ">=16.11.0" - } - }, - "node_modules/rc-field-form": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rc-field-form/-/rc-field-form-2.7.1.tgz", - "integrity": "sha512-vKeSifSJ6HoLaAB+B8aq/Qgm8a3dyxROzCtKNCsBQgiverpc4kWDQihoUwzUj+zNWJOykwSY4dNX3QrGwtVb9A==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.0", - "@rc-component/async-validator": "^5.0.3", - "rc-util": "^5.32.2" - }, - "engines": { - "node": ">=8.x" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-image": { - "version": "7.12.0", - "resolved": "https://registry.npmjs.org/rc-image/-/rc-image-7.12.0.tgz", - "integrity": "sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.11.2", - "@rc-component/portal": "^1.0.2", - "classnames": "^2.2.6", - "rc-dialog": "~9.6.0", - "rc-motion": "^2.6.2", - "rc-util": "^5.34.1" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-input": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/rc-input/-/rc-input-1.8.0.tgz", - "integrity": "sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.11.1", - "classnames": "^2.2.1", - "rc-util": "^5.18.1" - }, - "peerDependencies": { - "react": ">=16.0.0", - "react-dom": ">=16.0.0" - } - }, - "node_modules/rc-input-number": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/rc-input-number/-/rc-input-number-9.5.0.tgz", - "integrity": "sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.1", - "@rc-component/mini-decimal": "^1.0.1", - "classnames": "^2.2.5", - "rc-input": "~1.8.0", - "rc-util": "^5.40.1" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-mentions": { - "version": "2.20.0", - "resolved": "https://registry.npmjs.org/rc-mentions/-/rc-mentions-2.20.0.tgz", - "integrity": "sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.22.5", - "@rc-component/trigger": "^2.0.0", - "classnames": "^2.2.6", - "rc-input": "~1.8.0", - "rc-menu": "~9.16.0", - "rc-textarea": "~1.10.0", - "rc-util": "^5.34.1" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-menu": { - "version": "9.16.1", - "resolved": "https://registry.npmjs.org/rc-menu/-/rc-menu-9.16.1.tgz", - "integrity": "sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.1", - "@rc-component/trigger": "^2.0.0", - "classnames": "2.x", - "rc-motion": "^2.4.3", - "rc-overflow": "^1.3.1", - "rc-util": "^5.27.0" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-motion": { - "version": "2.9.5", - "resolved": "https://registry.npmjs.org/rc-motion/-/rc-motion-2.9.5.tgz", - "integrity": "sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.11.1", - "classnames": "^2.2.1", - "rc-util": "^5.44.0" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-notification": { - "version": "5.6.4", - "resolved": "https://registry.npmjs.org/rc-notification/-/rc-notification-5.6.4.tgz", - "integrity": "sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.1", - "classnames": "2.x", - "rc-motion": "^2.9.0", - "rc-util": "^5.20.1" - }, - "engines": { - "node": ">=8.x" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-overflow": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/rc-overflow/-/rc-overflow-1.5.0.tgz", - "integrity": "sha512-Lm/v9h0LymeUYJf0x39OveU52InkdRXqnn2aYXfWmo8WdOonIKB2kfau+GF0fWq6jPgtdO9yMqveGcK6aIhJmg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.11.1", - "classnames": "^2.2.1", - "rc-resize-observer": "^1.0.0", - "rc-util": "^5.37.0" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-pagination": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/rc-pagination/-/rc-pagination-5.1.0.tgz", - "integrity": "sha512-8416Yip/+eclTFdHXLKTxZvn70duYVGTvUUWbckCCZoIl3jagqke3GLsFrMs0bsQBikiYpZLD9206Ej4SOdOXQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.1", - "classnames": "^2.3.2", - "rc-util": "^5.38.0" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-picker": { - "version": "4.11.3", - "resolved": "https://registry.npmjs.org/rc-picker/-/rc-picker-4.11.3.tgz", - "integrity": "sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.24.7", - "@rc-component/trigger": "^2.0.0", - "classnames": "^2.2.1", - "rc-overflow": "^1.3.2", - "rc-resize-observer": "^1.4.0", - "rc-util": "^5.43.0" - }, - "engines": { - "node": ">=8.x" - }, - "peerDependencies": { - "date-fns": ">= 2.x", - "dayjs": ">= 1.x", - "luxon": ">= 3.x", - "moment": ">= 2.x", - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - }, - "peerDependenciesMeta": { - "date-fns": { - "optional": true - }, - "dayjs": { - "optional": true - }, - "luxon": { - "optional": true - }, - "moment": { - "optional": true - } - } - }, - "node_modules/rc-progress": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/rc-progress/-/rc-progress-4.0.0.tgz", - "integrity": "sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.1", - "classnames": "^2.2.6", - "rc-util": "^5.16.1" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-rate": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/rc-rate/-/rc-rate-2.13.1.tgz", - "integrity": "sha512-QUhQ9ivQ8Gy7mtMZPAjLbxBt5y9GRp65VcUyGUMF3N3fhiftivPHdpuDIaWIMOTEprAjZPC08bls1dQB+I1F2Q==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.1", - "classnames": "^2.2.5", - "rc-util": "^5.0.1" - }, - "engines": { - "node": ">=8.x" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-resize-observer": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/rc-resize-observer/-/rc-resize-observer-1.4.3.tgz", - "integrity": "sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.20.7", - "classnames": "^2.2.1", - "rc-util": "^5.44.1", - "resize-observer-polyfill": "^1.5.1" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-segmented": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rc-segmented/-/rc-segmented-2.7.1.tgz", - "integrity": "sha512-izj1Nw/Dw2Vb7EVr+D/E9lUTkBe+kKC+SAFSU9zqr7WV2W5Ktaa9Gc7cB2jTqgk8GROJayltaec+DBlYKc6d+g==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.11.1", - "classnames": "^2.2.1", - "rc-motion": "^2.4.4", - "rc-util": "^5.17.0" - }, - "peerDependencies": { - "react": ">=16.0.0", - "react-dom": ">=16.0.0" - } - }, - "node_modules/rc-select": { - "version": "14.16.8", - "resolved": "https://registry.npmjs.org/rc-select/-/rc-select-14.16.8.tgz", - "integrity": "sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.1", - "@rc-component/trigger": "^2.1.1", - "classnames": "2.x", - "rc-motion": "^2.0.1", - "rc-overflow": "^1.3.1", - "rc-util": "^5.16.1", - "rc-virtual-list": "^3.5.2" - }, - "engines": { - "node": ">=8.x" - }, - "peerDependencies": { - "react": "*", - "react-dom": "*" - } - }, - "node_modules/rc-slider": { - "version": "11.1.9", - "resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-11.1.9.tgz", - "integrity": "sha512-h8IknhzSh3FEM9u8ivkskh+Ef4Yo4JRIY2nj7MrH6GQmrwV6mcpJf5/4KgH5JaVI1H3E52yCdpOlVyGZIeph5A==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.1", - "classnames": "^2.2.5", - "rc-util": "^5.36.0" - }, - "engines": { - "node": ">=8.x" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-steps": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/rc-steps/-/rc-steps-6.0.1.tgz", - "integrity": "sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.16.7", - "classnames": "^2.2.3", - "rc-util": "^5.16.1" - }, - "engines": { - "node": ">=8.x" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-switch": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/rc-switch/-/rc-switch-4.1.0.tgz", - "integrity": "sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.21.0", - "classnames": "^2.2.1", - "rc-util": "^5.30.0" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-table": { - "version": "7.54.0", - "resolved": "https://registry.npmjs.org/rc-table/-/rc-table-7.54.0.tgz", - "integrity": "sha512-/wDTkki6wBTjwylwAGjpLKYklKo9YgjZwAU77+7ME5mBoS32Q4nAwoqhA2lSge6fobLW3Tap6uc5xfwaL2p0Sw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.1", - "@rc-component/context": "^1.4.0", - "classnames": "^2.2.5", - "rc-resize-observer": "^1.1.0", - "rc-util": "^5.44.3", - "rc-virtual-list": "^3.14.2" - }, - "engines": { - "node": ">=8.x" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-tabs": { - "version": "15.7.0", - "resolved": "https://registry.npmjs.org/rc-tabs/-/rc-tabs-15.7.0.tgz", - "integrity": "sha512-ZepiE+6fmozYdWf/9gVp7k56PKHB1YYoDsKeQA1CBlJ/POIhjkcYiv0AGP0w2Jhzftd3AVvZP/K+V+Lpi2ankA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.11.2", - "classnames": "2.x", - "rc-dropdown": "~4.2.0", - "rc-menu": "~9.16.0", - "rc-motion": "^2.6.2", - "rc-resize-observer": "^1.0.0", - "rc-util": "^5.34.1" - }, - "engines": { - "node": ">=8.x" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-textarea": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/rc-textarea/-/rc-textarea-1.10.2.tgz", - "integrity": "sha512-HfaeXiaSlpiSp0I/pvWpecFEHpVysZ9tpDLNkxQbMvMz6gsr7aVZ7FpWP9kt4t7DB+jJXesYS0us1uPZnlRnwQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.1", - "classnames": "^2.2.1", - "rc-input": "~1.8.0", - "rc-resize-observer": "^1.0.0", - "rc-util": "^5.27.0" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-tooltip": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-6.4.0.tgz", - "integrity": "sha512-kqyivim5cp8I5RkHmpsp1Nn/Wk+1oeloMv9c7LXNgDxUpGm+RbXJGL+OPvDlcRnx9DBeOe4wyOIl4OKUERyH1g==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.11.2", - "@rc-component/trigger": "^2.0.0", - "classnames": "^2.3.1", - "rc-util": "^5.44.3" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-tree": { - "version": "5.13.1", - "resolved": "https://registry.npmjs.org/rc-tree/-/rc-tree-5.13.1.tgz", - "integrity": "sha512-FNhIefhftobCdUJshO7M8uZTA9F4OPGVXqGfZkkD/5soDeOhwO06T/aKTrg0WD8gRg/pyfq+ql3aMymLHCTC4A==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.1", - "classnames": "2.x", - "rc-motion": "^2.0.1", - "rc-util": "^5.16.1", - "rc-virtual-list": "^3.5.1" - }, - "engines": { - "node": ">=10.x" - }, - "peerDependencies": { - "react": "*", - "react-dom": "*" - } - }, - "node_modules/rc-tree-select": { - "version": "5.27.0", - "resolved": "https://registry.npmjs.org/rc-tree-select/-/rc-tree-select-5.27.0.tgz", - "integrity": "sha512-2qTBTzwIT7LRI1o7zLyrCzmo5tQanmyGbSaGTIf7sYimCklAToVVfpMC6OAldSKolcnjorBYPNSKQqJmN3TCww==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.25.7", - "classnames": "2.x", - "rc-select": "~14.16.2", - "rc-tree": "~5.13.0", - "rc-util": "^5.43.0" - }, - "peerDependencies": { - "react": "*", - "react-dom": "*" - } - }, - "node_modules/rc-upload": { - "version": "4.11.0", - "resolved": "https://registry.npmjs.org/rc-upload/-/rc-upload-4.11.0.tgz", - "integrity": "sha512-ZUyT//2JAehfHzjWowqROcwYJKnZkIUGWaTE/VogVrepSl7AFNbQf4+zGfX4zl9Vrj/Jm8scLO0R6UlPDKK4wA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "classnames": "^2.2.5", - "rc-util": "^5.2.0" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-util": { - "version": "5.44.4", - "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.44.4.tgz", - "integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "react-is": "^18.2.0" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-virtual-list": { - "version": "3.19.2", - "resolved": "https://registry.npmjs.org/rc-virtual-list/-/rc-virtual-list-3.19.2.tgz", - "integrity": "sha512-Ys6NcjwGkuwkeaWBDqfI3xWuZ7rDiQXlH1o2zLfFzATfEgXcqpk8CkgMfbJD81McqjcJVez25a3kPxCR807evA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.20.0", - "classnames": "^2.2.6", - "rc-resize-observer": "^1.0.0", - "rc-util": "^5.36.0" - }, - "engines": { - "node": ">=8.x" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/react-draggable": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz", - "integrity": "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==", - "license": "MIT", - "dependencies": { - "clsx": "^2.1.1", - "prop-types": "^15.8.1" - }, - "peerDependencies": { - "react": ">= 16.3.0", - "react-dom": ">= 16.3.0" - } - }, - "node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT" - }, - "node_modules/react-refresh": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", - "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-resizable": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.1.3.tgz", - "integrity": "sha512-liJBNayhX7qA4tBJiBD321FDhJxgGTJ07uzH5zSORXoE8h7PyEZ8mLqmosST7ppf6C4zUsbd2gzDMmBCfFp9Lw==", - "license": "MIT", - "dependencies": { - "prop-types": "15.x", - "react-draggable": "^4.5.0" - }, - "peerDependencies": { - "react": ">= 16.3", - "react-dom": ">= 16.3" - } - }, - "node_modules/react-router": { - "version": "6.30.3", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", - "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", - "license": "MIT", - "dependencies": { - "@remix-run/router": "1.23.2" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "react": ">=16.8" - } - }, - "node_modules/react-router-dom": { - "version": "6.30.3", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", - "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", - "license": "MIT", - "dependencies": { - "@remix-run/router": "1.23.2", - "react-router": "6.30.3" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" - } - }, - "node_modules/resize-observer-polyfill": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", - "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", - "license": "MIT" - }, - "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", - "fsevents": "~2.3.2" - } - }, - "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/scroll-into-view-if-needed": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", - "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", - "license": "MIT", - "dependencies": { - "compute-scroll-into-view": "^3.0.2" - } - }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/socket.io-client": { - "version": "4.8.3", - "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", - "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", - "license": "MIT", - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.4.1", - "engine.io-client": "~6.6.1", - "socket.io-parser": "~4.2.4" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/socket.io-parser": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", - "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", - "license": "MIT", - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.4.1" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/string-convert": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", - "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==", - "license": "MIT" - }, - "node_modules/stylis": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", - "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", - "license": "MIT" - }, - "node_modules/throttle-debounce": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", - "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", - "license": "MIT", - "engines": { - "node": ">=12.22" - } - }, - "node_modules/toggle-selection": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", - "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", - "license": "MIT" - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/xmlhttprequest-ssl": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", - "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, - "node_modules/zustand": { - "version": "4.5.7", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", - "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", - "license": "MIT", - "dependencies": { - "use-sync-external-store": "^1.2.2" - }, - "engines": { - "node": ">=12.7.0" - }, - "peerDependencies": { - "@types/react": ">=16.8", - "immer": ">=9.0.6", - "react": ">=16.8" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - } - } - } - } -} diff --git a/frontend/package.json b/frontend/package.json index 53c725ab..75b87a17 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "1.0.0", + "version": "0.23.0", "private": true, "dependencies": { "@ant-design/icons": "^5.2.6", diff --git a/frontend/public/earth/_backup/dock-centered-20260326/base.css b/frontend/public/earth/_backup/dock-centered-20260326/base.css new file mode 100644 index 00000000..5337baa4 --- /dev/null +++ b/frontend/public/earth/_backup/dock-centered-20260326/base.css @@ -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); +} diff --git a/frontend/public/earth/_backup/dock-centered-20260326/controls.js b/frontend/public/earth/_backup/dock-centered-20260326/controls.js new file mode 100644 index 00000000..81333aa4 --- /dev/null +++ b/frontend/public/earth/_backup/dock-centered-20260326/controls.js @@ -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; +} diff --git a/frontend/public/earth/_backup/dock-centered-20260326/index.html b/frontend/public/earth/_backup/dock-centered-20260326/index.html new file mode 100644 index 00000000..476511b6 --- /dev/null +++ b/frontend/public/earth/_backup/dock-centered-20260326/index.html @@ -0,0 +1,227 @@ + + + + + + 智能星球计划 - 现实层宇宙全息感知 + + + + + + + + +
+
+

智能星球计划

+
现实层宇宙全息感知系统 | 卫星 · 海底光缆 · 算力基础设施
+ + + +
+
+ +
+
+
+ + + + + + + +
+ +
+ + 100%重置缩放到100% + + +
+
+
+ +
+

坐标信息

+
+ 经度: + 0.00° +
+
+ 纬度: + 0.00° +
+
缩放: 1.0x
+
鼠标位置: 无
+
+ +
+

图例

+
+
+ Americas II +
+
+
+ AU Aleutian A +
+
+
+ AU Aleutian B +
+
+
+ 其他电缆 +
+
+ +
+

地球信息

+
+ 电缆系统: + 0个 +
+
+ 状态: + - +
+
+ 登陆点: + 0个 +
+
+ 地形: + 开启 +
+
+ 卫星: + 0 颗 +
+
+ 视角距离: + 300 km +
+
+ 纹理质量: + 8K 卫星图 +
+
+ +
+
+
正在初始化全球态势数据...
+
同步卫星、海底光缆与登陆点数据
+
+ +
+
+ + + + diff --git a/frontend/public/earth/assets/earth_clouds_1024.png b/frontend/public/earth/assets/earth_clouds_1024.png new file mode 100644 index 00000000..5c6b17b7 Binary files /dev/null and b/frontend/public/earth/assets/earth_clouds_1024.png differ diff --git a/frontend/public/earth/assets/icons/cables.svg b/frontend/public/earth/assets/icons/cables.svg new file mode 100644 index 00000000..62090bb8 --- /dev/null +++ b/frontend/public/earth/assets/icons/cables.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/earth/assets/icons/info.svg b/frontend/public/earth/assets/icons/info.svg new file mode 100644 index 00000000..32f0c133 --- /dev/null +++ b/frontend/public/earth/assets/icons/info.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/earth/assets/icons/layout-collapse.svg b/frontend/public/earth/assets/icons/layout-collapse.svg new file mode 100644 index 00000000..f5fa1845 --- /dev/null +++ b/frontend/public/earth/assets/icons/layout-collapse.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/public/earth/assets/icons/layout.svg b/frontend/public/earth/assets/icons/layout.svg new file mode 100644 index 00000000..87936404 --- /dev/null +++ b/frontend/public/earth/assets/icons/layout.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/public/earth/assets/icons/pause.svg b/frontend/public/earth/assets/icons/pause.svg new file mode 100644 index 00000000..fb64ef64 --- /dev/null +++ b/frontend/public/earth/assets/icons/pause.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/earth/assets/icons/play.svg b/frontend/public/earth/assets/icons/play.svg new file mode 100644 index 00000000..9c6738f8 --- /dev/null +++ b/frontend/public/earth/assets/icons/play.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/earth/assets/icons/reload.svg b/frontend/public/earth/assets/icons/reload.svg new file mode 100644 index 00000000..59ff89a2 --- /dev/null +++ b/frontend/public/earth/assets/icons/reload.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/earth/assets/icons/reset-view.svg b/frontend/public/earth/assets/icons/reset-view.svg new file mode 100644 index 00000000..987b3ca7 --- /dev/null +++ b/frontend/public/earth/assets/icons/reset-view.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/public/earth/assets/icons/satellite.svg b/frontend/public/earth/assets/icons/satellite.svg new file mode 100644 index 00000000..a8f3415f --- /dev/null +++ b/frontend/public/earth/assets/icons/satellite.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/public/earth/assets/icons/search.svg b/frontend/public/earth/assets/icons/search.svg new file mode 100644 index 00000000..0804dceb --- /dev/null +++ b/frontend/public/earth/assets/icons/search.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/earth/assets/icons/terrain.svg b/frontend/public/earth/assets/icons/terrain.svg new file mode 100644 index 00000000..54e1f4b7 --- /dev/null +++ b/frontend/public/earth/assets/icons/terrain.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/earth/assets/icons/trails.svg b/frontend/public/earth/assets/icons/trails.svg new file mode 100644 index 00000000..025eb143 --- /dev/null +++ b/frontend/public/earth/assets/icons/trails.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/earth/assets/icons/zoom.svg b/frontend/public/earth/assets/icons/zoom.svg new file mode 100644 index 00000000..34efeff4 --- /dev/null +++ b/frontend/public/earth/assets/icons/zoom.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/earth/css/base.css b/frontend/public/earth/css/base.css index 7448c835..d7160d58 100644 --- a/frontend/public/earth/css/base.css +++ b/frontend/public/earth/css/base.css @@ -13,6 +13,23 @@ body { overflow: hidden; } +:root { + --hud-border: rgba(210, 237, 255, 0.32); + --hud-border-hover: rgba(232, 246, 255, 0.48); + --hud-border-active: rgba(245, 251, 255, 0.62); + --glass-fill-top: rgba(255, 255, 255, 0.18); + --glass-fill-bottom: rgba(115, 180, 255, 0.08); + --glass-sheen: rgba(255, 255, 255, 0.34); + --glass-shadow: 0 14px 30px rgba(0, 0, 0, 0.22); + --glass-glow: 0 0 26px rgba(120, 200, 255, 0.16); +} + +@property --float-offset { + syntax: ''; + inherits: false; + initial-value: 0px; +} + #container { position: relative; width: 100vw; @@ -23,85 +40,103 @@ body { cursor: grabbing; } -/* Right Toolbar Group */ +/* Bottom Dock */ #right-toolbar-group { position: absolute; - bottom: 20px; - right: 290px; + bottom: 18px; + left: 50%; + transform: translateX(-50%); display: flex; - flex-direction: column; - align-items: flex-end; - gap: 10px; + flex-direction: row; + align-items: center; + justify-content: center; z-index: 200; } -/* Zoom Toolbar - Right side, vertical */ -#zoom-toolbar { +#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; +} + +#info-panel, +#coordinates-display, +#legend, +#earth-stats, +#satellite-info { + position: absolute; + overflow: hidden; + isolation: isolate; + background: + radial-gradient(circle at 24% 12%, rgba(255, 255, 255, 0.12), transparent 28%), + radial-gradient(circle at 78% 115%, rgba(255, 255, 255, 0.06), transparent 32%), + linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(110, 176, 255, 0.06)), + rgba(7, 18, 36, 0.28); + border: 1px solid rgba(225, 242, 255, 0.2); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.14), + inset 0 -1px 0 rgba(255, 255, 255, 0.04), + 0 18px 40px rgba(0, 0, 0, 0.24), + 0 0 32px rgba(120, 200, 255, 0.12); + backdrop-filter: blur(20px) saturate(145%); + -webkit-backdrop-filter: blur(20px) saturate(145%); +} + +#info-panel::before, +#coordinates-display::before, +#legend::before, +#earth-stats::before, +#satellite-info::before { + content: ''; + position: absolute; + inset: 1px 1px 24% 1px; + border-radius: inherit; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0.05) 26%, transparent 70%); + opacity: 0.46; + pointer-events: none; +} + +#info-panel::after, +#coordinates-display::after, +#legend::after, +#earth-stats::after, +#satellite-info::after { + content: ''; + position: absolute; + inset: -1px; + padding: 1.4px; + border-radius: inherit; + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.3), rgba(170, 223, 255, 0.2) 34%, rgba(88, 169, 255, 0.14) 68%, rgba(255, 255, 255, 0.24)); + opacity: 0.78; + pointer-events: none; + filter: url(#liquid-glass-distortion) blur(0.35px); + -webkit-mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + mask-composite: exclude; +} + +#info-panel > *, +#coordinates-display > *, +#legend > *, +#earth-stats > *, +#satellite-info > * { position: relative; - bottom: auto; - right: auto; - display: flex; - flex-direction: column; - align-items: center; - gap: 6px; - background: rgba(10, 10, 30, 0.9); - padding: 8px 4px; - border-radius: 24px; - border: 1px solid rgba(77, 184, 255, 0.3); - box-shadow: 0 0 20px rgba(77, 184, 255, 0.2); -} - -#zoom-toolbar #zoom-slider { - width: 4px; - height: 50px; - margin: 4px 0; - writing-mode: vertical-lr; - direction: rtl; - -webkit-appearance: slider-vertical; -} - -#zoom-toolbar .zoom-percent { - font-size: 0.75rem; - font-weight: 600; - color: #4db8ff; - min-width: 30px; - 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; -} - -#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); + z-index: 1; } #loading { @@ -185,16 +220,28 @@ input[type="range"]::-webkit-slider-thumb { .status-message { position: absolute; top: 20px; - right: 260px; + left: 50%; + transform: translate(-50%, -18px); background-color: rgba(10, 10, 30, 0.85); border-radius: 10px; padding: 10px 15px; - z-index: 10; + z-index: 210; 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); + text-align: center; + min-width: 180px; + opacity: 0; + transition: + transform 0.28s ease, + opacity 0.28s ease; +} + +.status-message.visible { + transform: translate(-50%, 0); + opacity: 1; } .status-message.success { @@ -227,71 +274,172 @@ input[type="range"]::-webkit-slider-thumb { user-select: none; } -/* Control Toolbar - Stellarium/Star Walk style */ +/* Floating toolbar dock */ #control-toolbar { position: relative; - bottom: auto; - right: auto; display: flex; align-items: center; - background: rgba(10, 10, 30, 0.9); - border-radius: 24px; - padding: 8px; - 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; -} - -#control-toolbar.collapsed { - padding: 8px; -} - -#control-toolbar.collapsed .toolbar-items { - width: 0; - padding: 0; - margin: 0; - overflow: hidden; - opacity: 0; -} - -#toolbar-toggle { - min-width: 28px; - line-height: 1; - transition: all 0.3s ease; - flex-shrink: 0; + justify-content: center; + gap: 0; background: transparent; border: none; -} - -.toggle-arrow { - font-size: 14px; - color: #4db8ff; - transition: transform 0.3s ease; -} - -#control-toolbar.collapsed .toggle-arrow { - transform: rotate(0deg); -} - -#control-toolbar:not(.collapsed) .toggle-arrow { - transform: rotate(180deg); -} - -#control-toolbar.collapsed #toolbar-toggle { - background: transparent; + box-shadow: none; + padding: 0; } .toolbar-items { display: flex; - gap: 6px; - width: auto; - padding: 0 4px 0 2px; - overflow: visible; - opacity: 1; - transition: all 0.3s ease; - border-right: 1px solid rgba(77, 184, 255, 0.3); - margin-right: 4px; - flex-shrink: 0; + gap: 10px; + align-items: center; + flex-wrap: nowrap; +} + +.floating-popover-group { + position: relative; +} + +.floating-popover-group::before { + content: ''; + position: absolute; + left: 50%; + bottom: 100%; + transform: translateX(-50%); + width: 56px; + height: 16px; + background: transparent; +} + +.floating-popover-group > .stack-toolbar { + position: absolute; + left: 50%; + top: auto; + right: auto; + bottom: calc(100% + 12px); + transform: translate(-50%, 10px); + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: + opacity 0.22s ease, + transform 0.22s ease, + visibility 0.22s ease; + z-index: 220; +} + +.toolbar-btn.floating-btn { + width: 42px; + height: 42px; + min-width: 42px; + min-height: 42px; + border-radius: 50%; + overflow: hidden; +} + +.liquid-glass-surface { + --elastic-x: 0px; + --elastic-y: 0px; + --tilt-x: 0deg; + --tilt-y: 0deg; + --btn-scale: 1; + --press-offset: 0px; + --float-offset: 0px; + --glow-opacity: 0.24; + --glow-x: 50%; + --glow-y: 22%; + position: relative; + isolation: isolate; + transform-style: preserve-3d; + overflow: hidden; + background: + radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.16), transparent 34%), + radial-gradient(circle at 50% 118%, rgba(255, 255, 255, 0.08), transparent 30%), + linear-gradient(180deg, var(--glass-fill-top), var(--glass-fill-bottom)), + rgba(8, 20, 38, 0.22); + border: 1px solid var(--hud-border); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.14), + inset 0 -1px 0 rgba(255, 255, 255, 0.05), + var(--glass-shadow), + var(--glass-glow); + backdrop-filter: blur(18px) saturate(145%); + -webkit-backdrop-filter: blur(18px) saturate(145%); + transform: + translate3d(var(--elastic-x), calc(var(--float-offset) + var(--press-offset) + var(--elastic-y)), 0) + scale(var(--btn-scale)); + transition: + transform 0.22s ease, + box-shadow 0.22s ease, + background 0.22s ease, + opacity 0.18s ease, + border-color 0.22s ease; + animation: floatDock 3.8s ease-in-out infinite; +} + +.liquid-glass-surface::before { + content: ''; + position: absolute; + inset: 1px 1px 18px 1px; + border-radius: inherit; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0.05) 28%, transparent 68%); + opacity: 0.5; + pointer-events: none; + transform: + perspective(120px) + rotateX(calc(var(--tilt-x) * 0.7)) + rotateY(calc(var(--tilt-y) * 0.7)) + translate3d(calc(var(--elastic-x) * 0.22), calc(var(--elastic-y) * 0.22), 0); + transition: opacity 0.18s ease, transform 0.18s ease; +} + +.liquid-glass-surface::after { + content: ''; + position: absolute; + inset: -1px; + padding: 1.35px; + border-radius: inherit; + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.36), rgba(168, 222, 255, 0.22) 34%, rgba(96, 175, 255, 0.16) 66%, rgba(255, 255, 255, 0.28)); + opacity: 0.82; + pointer-events: none; + filter: url(#liquid-glass-distortion) blur(0.35px); + transform: + perspective(120px) + rotateX(calc(var(--tilt-x) * 0.5)) + rotateY(calc(var(--tilt-y) * 0.5)) + translate3d(calc(var(--elastic-x) * 0.16), calc(var(--elastic-y) * 0.16), 0); + -webkit-mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + mask-composite: exclude; + transition: opacity 0.18s ease, transform 0.18s ease; +} + +.toolbar-items > :nth-child(2n).floating-btn, +.toolbar-items > :nth-child(2n) .floating-btn { + animation-delay: 0.18s; +} + +.toolbar-items > :nth-child(3n).floating-btn, +.toolbar-items > :nth-child(3n) .floating-btn { + animation-delay: 0.34s; +} + +@keyframes floatDock { + 0%, 100% { + --float-offset: 0px; + } + 50% { + --float-offset: -4px; + } } .toolbar-btn { @@ -299,38 +447,317 @@ input[type="range"]::-webkit-slider-thumb { width: 28px; height: 28px; border: none; - border-radius: 50%; - background: rgba(77, 184, 255, 0.15); + border-radius: 0; + background: transparent; 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; + overflow: visible; + appearance: none; + -webkit-appearance: none; } -.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:not(.liquid-glass-surface)::after { + content: none; } -.toolbar-btn:active { - transform: scale(0.95); +.toolbar-btn .icon { + display: inline-flex; + align-items: center; + justify-content: center; + position: relative; + z-index: 1; + transform: translateZ(0); + transition: transform 0.16s ease, opacity 0.16s ease; + backface-visibility: hidden; + -webkit-backface-visibility: hidden; + line-height: 1; } -.toolbar-btn.active { - background: rgba(77, 184, 255, 0.4); - box-shadow: 0 0 10px rgba(77, 184, 255, 0.4) inset; +.liquid-glass-surface:hover { + --btn-scale: 1.035; + --press-offset: -1px; + --glow-opacity: 0.32; + background: + radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.18), transparent 34%), + radial-gradient(circle at 50% 118%, rgba(255, 255, 255, 0.1), transparent 30%), + linear-gradient(180deg, rgba(255, 255, 255, 0.18), rgba(128, 198, 255, 0.1)), + rgba(8, 20, 38, 0.2); + border-color: var(--hud-border-hover); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.2), + inset 0 -1px 0 rgba(255, 255, 255, 0.08), + 0 18px 36px rgba(0, 0, 0, 0.24), + 0 0 28px rgba(145, 214, 255, 0.22); +} + +.liquid-glass-surface:hover::before { + opacity: 0.62; + transform: scale(1.01); +} + +.liquid-glass-surface:hover::after { + opacity: 0.96; + transform: scale(1.01); +} + +.liquid-glass-surface:active, +.liquid-glass-surface.is-pressed { + --btn-scale: 0.942; + --press-offset: 2px; + --glow-opacity: 0.2; + background: + radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.24), transparent 34%), + radial-gradient(circle at 50% 118%, rgba(255, 255, 255, 0.14), transparent 30%), + linear-gradient(180deg, rgba(255, 255, 255, 0.24), rgba(146, 210, 255, 0.16)), + rgba(10, 24, 44, 0.24); + border-color: rgba(240, 249, 255, 0.58); + box-shadow: + inset 0 2px 10px rgba(0, 0, 0, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.16), + 0 4px 10px rgba(0, 0, 0, 0.18), + 0 0 14px rgba(176, 226, 255, 0.18); +} + +.liquid-glass-surface:active::before, +.liquid-glass-surface.is-pressed::before { + opacity: 0.46; + transform: translateY(2px) scale(0.985); +} + +.liquid-glass-surface:active::after, +.liquid-glass-surface.is-pressed::after { + opacity: 0.78; + transform: scale(0.985); +} + +.liquid-glass-surface:active .icon, +.liquid-glass-surface.is-pressed .icon { + transform: translateY(1.5px); +} + +.liquid-glass-surface:active img, +.liquid-glass-surface.is-pressed img, +.liquid-glass-surface:active .material-symbols-rounded, +.liquid-glass-surface.is-pressed .material-symbols-rounded { + transform: translateY(1.5px); + transition: transform 0.16s ease, opacity 0.16s ease; +} + +#zoom-control-group #zoom-toolbar .zoom-btn:active, +#zoom-control-group #zoom-toolbar .zoom-btn.is-pressed, +#zoom-control-group #zoom-toolbar .zoom-percent:active, +#zoom-control-group #zoom-toolbar .zoom-percent.is-pressed { + letter-spacing: -0.01em; +} + +.liquid-glass-surface.active { + background: + radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.18), transparent 34%), + linear-gradient(180deg, rgba(255, 255, 255, 0.2), rgba(118, 200, 255, 0.14)), + rgba(11, 34, 58, 0.26); + border-color: var(--hud-border-active); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.22), + inset 0 0 18px rgba(160, 220, 255, 0.14), + 0 18px 34px rgba(0, 0, 0, 0.24), + 0 0 30px rgba(145, 214, 255, 0.24); +} + +.toolbar-btn svg { + width: 20px; + height: 20px; + stroke: currentColor; + stroke-width: 2.1; + fill: none; + stroke-linecap: round; + stroke-linejoin: round; +} + +.toolbar-btn .material-symbols-rounded { + font-size: 21px; + line-height: 1; + font-variation-settings: + 'FILL' 0, + 'wght' 500, + 'GRAD' 0, + 'opsz' 24; + color: currentColor; + display: inline-flex; + align-items: center; + justify-content: center; + user-select: none; + pointer-events: none; + text-rendering: geometricPrecision; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.toolbar-btn img { + width: 20px; + height: 20px; + display: block; + user-select: none; + pointer-events: none; + shape-rendering: geometricPrecision; + image-rendering: -webkit-optimize-contrast; + backface-visibility: hidden; + -webkit-backface-visibility: hidden; +} + +#rotate-toggle .icon-play, +#rotate-toggle.is-stopped .icon-pause, +#layout-toggle .layout-collapse, +#layout-toggle.active .layout-expand { + display: none; +} + +#rotate-toggle.is-stopped .icon-play, +#layout-toggle.active .layout-collapse { + display: inline-flex; +} + +#zoom-control-group:hover #zoom-toolbar, +#zoom-control-group:focus-within #zoom-toolbar, +#zoom-control-group.open #zoom-toolbar, +#info-control-group:hover #info-toolbar, +#info-control-group:focus-within #info-toolbar, +#info-control-group.open #info-toolbar { + opacity: 1; + visibility: visible; + pointer-events: auto; + transform: translate(-50%, 0); +} + +#zoom-control-group.force-closed #zoom-toolbar, +#info-control-group.force-closed #info-toolbar { + opacity: 0; + visibility: hidden; + pointer-events: none; + transform: translate(-50%, 8px); +} + +#zoom-control-group #zoom-toolbar .zoom-percent { + min-width: 0; + width: 42px; + display: inline-flex; + align-items: center; + justify-content: center; + height: 42px; + padding: 0; + font-size: 0.68rem; + border-radius: 50%; + color: #4db8ff; + animation: floatDock 3.8s ease-in-out infinite; + animation-delay: 0.18s; +} + +#zoom-control-group #zoom-toolbar .zoom-percent:hover { +} + +#zoom-control-group #zoom-toolbar, +#info-control-group #info-toolbar { + top: auto; + right: auto; + left: 50%; + bottom: calc(100% + 12px); + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + gap: 8px; +} + +#info-toolbar .toolbar-btn:nth-child(1) { + animation-delay: 0.34s; +} + +#info-toolbar .toolbar-btn:nth-child(2) { + animation-delay: 0.18s; +} + +#info-toolbar .toolbar-btn:nth-child(3) { + animation-delay: 0.1s; +} + +#info-toolbar .toolbar-btn:nth-child(4) { + animation-delay: 0s; +} + +#zoom-control-group #zoom-toolbar .zoom-btn { + width: 42px; + height: 42px; + min-width: 42px; + border-radius: 50%; + color: #4db8ff; + animation: floatDock 3.8s ease-in-out infinite; +} + +#zoom-toolbar .zoom-btn:nth-child(1) { + animation-delay: 0s; +} + +#zoom-toolbar .zoom-btn:nth-child(3) { + animation-delay: 0.34s; +} + +#zoom-control-group #zoom-toolbar .zoom-btn:hover { +} + +#zoom-control-group #zoom-toolbar .zoom-btn:active, +#zoom-control-group #zoom-toolbar .zoom-percent:active { +} + +#zoom-control-group #zoom-toolbar .tooltip { + bottom: calc(100% + 10px); +} + +#zoom-control-group #zoom-toolbar .tooltip::after { + top: 100%; + left: 50%; + transform: translateX(-50%); + border: 6px solid transparent; + border-top-color: rgba(77, 184, 255, 0.4); +} + +#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: 18px; + transform: translateX(-50%); } .toolbar-btn .tooltip { position: absolute; - bottom: 50px; + bottom: 56px; left: 50%; transform: translateX(-50%); background: rgba(10, 10, 30, 0.95); @@ -347,10 +774,12 @@ input[type="range"]::-webkit-slider-thumb { z-index: 100; } -.toolbar-btn:hover .tooltip { +.toolbar-btn:hover .tooltip, +.floating-popover-group:hover > .toolbar-btn .tooltip, +.floating-popover-group:focus-within > .toolbar-btn .tooltip { opacity: 1; visibility: visible; - bottom: 52px; + bottom: 58px; } .toolbar-btn .tooltip::after { diff --git a/frontend/public/earth/css/coordinates-display.css b/frontend/public/earth/css/coordinates-display.css index 07fe73f4..011cc8c9 100644 --- a/frontend/public/earth/css/coordinates-display.css +++ b/frontend/public/earth/css/coordinates-display.css @@ -1,18 +1,13 @@ /* coordinates-display */ #coordinates-display { - position: absolute; top: 20px; right: 20px; - background-color: rgba(10, 10, 30, 0.85); - border-radius: 10px; + border-radius: 18px; 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; min-width: 180px; - backdrop-filter: blur(5px); } #coordinates-display .coord-item { diff --git a/frontend/public/earth/css/earth-stats.css b/frontend/public/earth/css/earth-stats.css index d7a1458a..9118063f 100644 --- a/frontend/public/earth/css/earth-stats.css +++ b/frontend/public/earth/css/earth-stats.css @@ -1,18 +1,13 @@ /* earth-stats */ #earth-stats { - position: absolute; bottom: 20px; right: 20px; - background-color: rgba(10, 10, 30, 0.85); - border-radius: 10px; + border-radius: 18px; padding: 15px; width: 250px; 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; - backdrop-filter: blur(5px); } #earth-stats .stats-item { @@ -31,18 +26,13 @@ } #satellite-info { - position: absolute; bottom: 20px; right: 290px; - background-color: rgba(10, 10, 30, 0.9); - border-radius: 10px; + border-radius: 18px; padding: 15px; width: 220px; z-index: 10; - box-shadow: 0 0 20px rgba(0, 229, 255, 0.3); - border: 1px solid rgba(0, 229, 255, 0.3); font-size: 0.85rem; - backdrop-filter: blur(5px); } #satellite-info .stats-item { diff --git a/frontend/public/earth/css/info-panel.css b/frontend/public/earth/css/info-panel.css index a358db03..203e53ab 100644 --- a/frontend/public/earth/css/info-panel.css +++ b/frontend/public/earth/css/info-panel.css @@ -1,17 +1,12 @@ /* info-panel */ #info-panel { - position: absolute; top: 20px; left: 20px; - background-color: rgba(10, 10, 30, 0.85); - border-radius: 10px; + border-radius: 18px; padding: 20px; width: 320px; z-index: 10; - box-shadow: 0 0 20px rgba(0, 150, 255, 0.3); - border: 1px solid rgba(0, 150, 255, 0.2); - backdrop-filter: blur(5px); } #info-panel h1 { @@ -19,14 +14,34 @@ margin-bottom: 5px; color: #4db8ff; text-shadow: 0 0 10px rgba(77, 184, 255, 0.5); + text-align: center; } #info-panel .subtitle { - color: #aaa; margin-bottom: 20px; - font-size: 0.9rem; border-bottom: 1px solid rgba(255,255,255,0.1); - padding-bottom: 10px; + padding-bottom: 12px; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} + +#info-panel .subtitle-main { + color: #d7e7f5; + font-size: 0.95rem; + line-height: 1.35; + font-weight: 500; + letter-spacing: 0.02em; +} + +#info-panel .subtitle-meta { + color: #8ea5bc; + font-size: 0.74rem; + line-height: 1.3; + letter-spacing: 0.08em; + text-transform: uppercase; } #info-panel .cable-info { @@ -159,10 +174,17 @@ /* Info Card - Unified details panel (inside info-panel) */ .info-card { margin-top: 15px; - background: rgba(0, 0, 0, 0.3); - border-radius: 8px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(110, 176, 255, 0.04)), + rgba(7, 18, 36, 0.2); + border-radius: 14px; + border: 1px solid rgba(225, 242, 255, 0.12); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.08), + 0 10px 24px rgba(0, 0, 0, 0.16); padding: 0; overflow: hidden; + pointer-events: auto; } .info-card.no-border { @@ -174,7 +196,7 @@ display: flex; align-items: center; padding: 10px 12px; - background: rgba(77, 184, 255, 0.1); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.09), rgba(77, 184, 255, 0.06)); gap: 8px; } @@ -189,17 +211,38 @@ color: #4db8ff; } -.info-card-content { +#info-card-content { padding: 10px 12px; - max-height: 200px; + max-height: 40vh; overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: rgba(160, 220, 255, 0.45) transparent; + pointer-events: auto; +} + +#info-card-content::-webkit-scrollbar { + width: 6px; +} + +#info-card-content::-webkit-scrollbar-track { + background: transparent; +} + +#info-card-content::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, rgba(210, 237, 255, 0.32), rgba(110, 176, 255, 0.34)); + border-radius: 999px; +} + +#info-card-content::-webkit-scrollbar-thumb:hover { + background: linear-gradient(180deg, rgba(232, 246, 255, 0.42), rgba(128, 198, 255, 0.46)); } .info-card-property { display: flex; justify-content: space-between; - padding: 6px 0; + padding: 6px; border-bottom: 1px solid rgba(255, 255, 255, 0.05); + pointer-events: auto; } .info-card-property:last-child { @@ -209,6 +252,12 @@ .info-card-label { color: #aaa; font-size: 0.85rem; + cursor: pointer; + transition: color 0.18s ease; +} + +.info-card-label:hover { + color: #d9f1ff; } .info-card-value { diff --git a/frontend/public/earth/css/legend.css b/frontend/public/earth/css/legend.css index b268ce00..cc1a3f21 100644 --- a/frontend/public/earth/css/legend.css +++ b/frontend/public/earth/css/legend.css @@ -1,28 +1,59 @@ /* legend */ #legend { - position: absolute; bottom: 20px; left: 20px; - background-color: rgba(10, 10, 30, 0.85); - border-radius: 10px; + border-radius: 18px; padding: 15px; width: 220px; z-index: 10; - box-shadow: 0 0 20px rgba(0, 150, 255, 0.3); - border: 1px solid rgba(0, 150, 255, 0.2); - backdrop-filter: blur(5px); +} + +#legend .legend-title { + color: #4db8ff; + margin-bottom: 10px; + font-size: 1.1rem; +} + +#legend .legend-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 202px; + overflow-y: auto; + padding-right: 4px; + scrollbar-width: thin; + scrollbar-color: rgba(160, 220, 255, 0.4) transparent; +} + +#legend .legend-list::-webkit-scrollbar { + width: 6px; +} + +#legend .legend-list::-webkit-scrollbar-track { + background: transparent; +} + +#legend .legend-list::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, rgba(210, 237, 255, 0.28), rgba(110, 176, 255, 0.34)); + border-radius: 999px; } #legend .legend-item { display: flex; align-items: center; - margin-bottom: 8px; + padding: 6px 8px; + border-radius: 10px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(120, 180, 255, 0.02)); + border: 1px solid rgba(225, 242, 255, 0.06); } #legend .legend-color { width: 20px; height: 20px; - border-radius: 3px; + border-radius: 6px; margin-right: 10px; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.2), + 0 0 12px rgba(77, 184, 255, 0.18); } diff --git a/frontend/public/earth/index.html b/frontend/public/earth/index.html index c46ab8bb..7209bc5e 100644 --- a/frontend/public/earth/index.html +++ b/frontend/public/earth/index.html @@ -18,12 +18,25 @@ + +

智能星球计划

-
现实层宇宙全息感知系统 | 卫星 · 海底光缆 · 算力基础设施
+
+ 现实层宇宙全息感知系统 + 卫星 · 海底光缆 · 算力基础设施 +
-

图例

-
-
- Americas II -
-
-
- AU Aleutian A -
-
-
- AU Aleutian B -
-
-
- 其他电缆 +

线缆图例

+
+
+
+ Americas II +
+
+
+ AU Aleutian A +
+
+
+ AU Aleutian B +
+
+
+ 其他电缆 +
@@ -113,6 +203,18 @@ 卫星: 0 颗
+
+ BGP事件: + 0 条 +
+
+ 观测站: + 0 个 +
+
+ BGP态势: + 暂无观测数据 +
视角距离: 300 km @@ -125,8 +227,8 @@
-
正在加载3D地球和电缆数据...
-
使用8K高分辨率卫星纹理 | 大陆轮廓更清晰
+
正在初始化全球态势数据...
+
同步卫星、海底光缆、登陆点与BGP态势数据
diff --git a/frontend/public/earth/js/bgp.js b/frontend/public/earth/js/bgp.js new file mode 100644 index 00000000..08927270 --- /dev/null +++ b/frontend/public/earth/js/bgp.js @@ -0,0 +1,1859 @@ +import * as THREE from "three"; + +import { BGP_CONFIG, CONFIG, PATHS } from "./constants.js"; +import { latLonToVector3 } from "./utils.js"; + +const bgpGroup = new THREE.Group(); +const bgpOverlayGroup = new THREE.Group(); +const collectorMarkers = []; +const anomalyMarkers = []; +const activeEventCountByCollector = new Map(); + +let showBGP = true; +let totalAnomalyCount = 0; +let totalIncidentCount = 0; +let textureCache = null; +let eventRingTextureCache = null; +let collectorTextureCache = null; +const eventTextureCache = new Map(); +let activeEventOverlay = null; +let activeCollectorOverlayContext = null; +const relativeTimeFormatter = new Intl.RelativeTimeFormat("zh-CN", { + numeric: "auto", +}); +const collectorWorldPosition = new THREE.Vector3(); +const collectorSurfaceNormal = new THREE.Vector3(); +const collectorNorthPole = new THREE.Vector3(0, 1, 0); +const collectorFallbackForward = new THREE.Vector3(0, 0, 1); +const collectorNorthTangent = new THREE.Vector3(); +const collectorEastTangent = new THREE.Vector3(); +const collectorOrientationMatrix = new THREE.Matrix4(); +const colorScratchA = new THREE.Color(); +const colorScratchB = new THREE.Color(); +const COLLECTOR_SCAN_SPEED_RAD = 0.00018; +const COLLECTOR_SCAN_REBUILD_MS = 80; +const MATERIAL_ACCESS_POINT_PATH = "M4.93 4.93A9.97 9.97 0 0 0 2 12c0 2.76 1.12 5.26 2.93 7.07l1.41-1.41A7.94 7.94 0 0 1 4 12c0-2.21.89-4.22 2.34-5.66zm14.14 0l-1.41 1.41A7.96 7.96 0 0 1 20 12c0 2.22-.89 4.22-2.34 5.66l1.41 1.41A9.97 9.97 0 0 0 22 12c0-2.76-1.12-5.26-2.93-7.07M7.76 7.76A5.98 5.98 0 0 0 6 12c0 1.65.67 3.15 1.76 4.24l1.41-1.41A4 4 0 0 1 8 12c0-1.11.45-2.11 1.17-2.83zm8.48 0l-1.41 1.41A4 4 0 0 1 16 12c0 1.11-.45 2.11-1.17 2.83l1.41 1.41A5.98 5.98 0 0 0 18 12c0-1.65-.67-3.15-1.76-4.24M12 10a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2"; + +function getMarkerTexture() { + if (textureCache) return textureCache; + + const canvas = document.createElement("canvas"); + canvas.width = 128; + canvas.height = 128; + + const context = canvas.getContext("2d"); + if (!context) { + textureCache = new THREE.Texture(canvas); + return textureCache; + } + + const gradient = context.createRadialGradient(64, 64, 8, 64, 64, 56); + gradient.addColorStop(0, "rgba(255,255,255,1)"); + gradient.addColorStop(0.24, "rgba(255,255,255,0.92)"); + gradient.addColorStop(0.58, "rgba(255,255,255,0.35)"); + gradient.addColorStop(1, "rgba(255,255,255,0)"); + + context.fillStyle = gradient; + context.beginPath(); + context.arc(64, 64, 56, 0, Math.PI * 2); + context.fill(); + + textureCache = new THREE.CanvasTexture(canvas); + return textureCache; +} + +function getEventRingTexture() { + if (eventRingTextureCache) return eventRingTextureCache; + + const canvas = document.createElement("canvas"); + canvas.width = 128; + canvas.height = 128; + const context = canvas.getContext("2d"); + if (!context) { + eventRingTextureCache = new THREE.Texture(canvas); + return eventRingTextureCache; + } + + context.clearRect(0, 0, 128, 128); + context.strokeStyle = "rgba(255,255,255,0.98)"; + context.lineWidth = 6; + context.beginPath(); + context.arc(64, 64, 44, 0, Math.PI * 2); + context.stroke(); + + eventRingTextureCache = new THREE.CanvasTexture(canvas); + return eventRingTextureCache; +} + +function getCollectorTexture() { + if (collectorTextureCache) return collectorTextureCache; + + const canvas = document.createElement("canvas"); + canvas.width = 128; + canvas.height = 128; + const context = canvas.getContext("2d"); + if (!context) { + collectorTextureCache = new THREE.Texture(canvas); + return collectorTextureCache; + } + + context.clearRect(0, 0, 128, 128); + + context.strokeStyle = BGP_CONFIG.collectorIcon.ringStroke; + context.lineWidth = BGP_CONFIG.collectorIcon.ringLineWidth; + context.beginPath(); + context.arc(64, 64, BGP_CONFIG.collectorIcon.ringRadius, 0, Math.PI * 2); + context.stroke(); + + context.save(); + context.translate(16, 16); + context.scale(4, 4); + const path = new Path2D(MATERIAL_ACCESS_POINT_PATH); + context.lineJoin = "round"; + context.lineCap = "round"; + context.lineWidth = BGP_CONFIG.collectorIcon.pathLineWidth; + context.strokeStyle = BGP_CONFIG.collectorIcon.pathStroke; + context.stroke(path); + context.fillStyle = BGP_CONFIG.collectorIcon.pathFill; + context.shadowBlur = 0; + context.fill(path); + context.fillStyle = BGP_CONFIG.collectorIcon.centerFill; + context.beginPath(); + context.arc(12, 12, BGP_CONFIG.collectorIcon.centerRadius, 0, Math.PI * 2); + context.fill(); + context.restore(); + + collectorTextureCache = new THREE.CanvasTexture(canvas); + return collectorTextureCache; +} + +function getEventSymbolKind(anomalyType) { + const value = String(anomalyType || "").toLowerCase(); + if (value.includes("origin")) return "triangle"; + if (value.includes("withdraw")) return "exclamation"; + if (value.includes("specific") || value.includes("burst")) return "burst"; + if (value.includes("flap")) return "wave"; + if (value.includes("leak")) return "leak"; + return "dot"; +} + +function drawTriangleSymbol(context) { + context.beginPath(); + context.moveTo(64, 18); + context.lineTo(110, 106); + context.lineTo(18, 106); + context.closePath(); + context.fill(); +} + +function drawExclamationSymbol(context) { + context.beginPath(); + context.roundRect(52, 22, 24, 62, 12); + context.fill(); + context.beginPath(); + context.arc(64, 102, 10, 0, Math.PI * 2); + context.fill(); +} + +function drawWaveSymbol(context) { + context.lineWidth = 12; + context.lineCap = "round"; + context.beginPath(); + context.moveTo(18, 76); + context.bezierCurveTo(34, 46, 46, 46, 64, 76); + context.bezierCurveTo(80, 106, 94, 106, 110, 76); + context.stroke(); +} + +function drawBurstSymbol(context) { + context.lineWidth = 10; + context.lineCap = "round"; + for (let index = 0; index < 6; index += 1) { + const angle = (Math.PI * 2 * index) / 6; + const inner = 26; + const outer = 48; + context.beginPath(); + context.moveTo(64 + Math.cos(angle) * inner, 64 + Math.sin(angle) * inner); + context.lineTo(64 + Math.cos(angle) * outer, 64 + Math.sin(angle) * outer); + context.stroke(); + } + context.beginPath(); + context.arc(64, 64, 16, 0, Math.PI * 2); + context.fill(); +} + +function drawLeakSymbol(context) { + context.lineWidth = 10; + context.lineCap = "round"; + context.beginPath(); + context.moveTo(28, 96); + context.lineTo(64, 28); + context.lineTo(100, 96); + context.stroke(); + context.beginPath(); + context.moveTo(40, 82); + context.lineTo(64, 54); + context.lineTo(88, 82); + context.stroke(); +} + +function drawDotSymbol(context) { + context.beginPath(); + context.arc(64, 64, 28, 0, Math.PI * 2); + context.fill(); +} + +function getEventTexture(anomalyType) { + const kind = getEventSymbolKind(anomalyType); + if (eventTextureCache.has(kind)) return eventTextureCache.get(kind); + + const canvas = document.createElement("canvas"); + canvas.width = 128; + canvas.height = 128; + const context = canvas.getContext("2d"); + if (!context) { + const fallback = new THREE.Texture(canvas); + eventTextureCache.set(kind, fallback); + return fallback; + } + + context.clearRect(0, 0, 128, 128); + context.fillStyle = "rgba(255,255,255,0.96)"; + context.strokeStyle = "rgba(255,255,255,0.96)"; + context.shadowBlur = 0; + context.lineJoin = "round"; + + if (kind === "triangle") { + drawTriangleSymbol(context); + } else if (kind === "exclamation") { + drawExclamationSymbol(context); + } else if (kind === "wave") { + drawWaveSymbol(context); + } else if (kind === "burst") { + drawBurstSymbol(context); + } else if (kind === "leak") { + drawLeakSymbol(context); + } else { + drawDotSymbol(context); + } + + const texture = new THREE.CanvasTexture(canvas); + eventTextureCache.set(kind, texture); + return texture; +} + +function normalizeSeverity(severity) { + const value = String(severity || "").trim().toLowerCase(); + + if (value === "critical") return "critical"; + if (value === "high" || value === "major") return "high"; + if (value === "medium" || value === "moderate" || value === "warning") { + return "medium"; + } + if (value === "low" || value === "info" || value === "informational") { + return "low"; + } + + return "medium"; +} + +function getSeverityColor(severity) { + return BGP_CONFIG.severityColors[normalizeSeverity(severity)]; +} + +function getSeverityScale(severity) { + return BGP_CONFIG.severityScales[normalizeSeverity(severity)]; +} + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); +} + +function blendHexColors(fromHex, toHex, ratio) { + colorScratchA.setHex(fromHex); + colorScratchB.setHex(toHex); + colorScratchA.lerp(colorScratchB, clamp(ratio, 0, 1)); + return colorScratchA.getHex(); +} + +function getCollectorDistanceScale(marker, camera) { + if (!marker || !camera || BGP_CONFIG.sizeStabilization?.enabled === false) return 1; + + marker.getWorldPosition(collectorWorldPosition); + const distanceToCamera = camera.position.distanceTo(collectorWorldPosition); + const referenceDistance = CONFIG.defaultCameraZ - CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset; + const referenceFovRad = (75 * Math.PI) / 180; + const cameraFovRad = ((camera.fov || 75) * Math.PI) / 180; + const min = Number(BGP_CONFIG.sizeStabilization?.collectorMin ?? 0.6); + const max = Number(BGP_CONFIG.sizeStabilization?.collectorMax ?? 1.9); + const worldPerPixel = + distanceToCamera * Math.tan(cameraFovRad / 2); + const referenceWorldPerPixel = + referenceDistance * Math.tan(referenceFovRad / 2); + + return clamp(worldPerPixel / referenceWorldPerPixel, min, max); +} + +function getEventDistanceScale(marker, camera) { + if (!marker || !camera || BGP_CONFIG.sizeStabilization?.enabled === false) return 1; + + marker.getWorldPosition(collectorWorldPosition); + const distanceToCamera = camera.position.distanceTo(collectorWorldPosition); + const referenceDistance = CONFIG.defaultCameraZ - CONFIG.earthRadius + BGP_CONFIG.altitudeOffset; + const referenceFovRad = (75 * Math.PI) / 180; + const cameraFovRad = ((camera.fov || 75) * Math.PI) / 180; + const min = Number(BGP_CONFIG.sizeStabilization?.eventMin ?? 0.7); + const max = Number(BGP_CONFIG.sizeStabilization?.eventMax ?? 1.9); + const worldPerPixel = + distanceToCamera * Math.tan(cameraFovRad / 2); + const referenceWorldPerPixel = + referenceDistance * Math.tan(referenceFovRad / 2); + + return clamp(worldPerPixel / referenceWorldPerPixel, min, max); +} + +function orientCollectorMarkerToSurface(marker, position) { + collectorSurfaceNormal.copy(position).normalize(); + collectorNorthTangent + .copy(collectorNorthPole) + .projectOnPlane(collectorSurfaceNormal); + + if (collectorNorthTangent.lengthSq() < 1e-6) { + collectorNorthTangent + .copy(collectorFallbackForward) + .projectOnPlane(collectorSurfaceNormal); + } + + collectorNorthTangent.normalize(); + collectorEastTangent + .copy(collectorNorthTangent) + .cross(collectorSurfaceNormal) + .normalize(); + + collectorOrientationMatrix.makeBasis( + collectorEastTangent, + collectorNorthTangent, + collectorSurfaceNormal, + ); + marker.quaternion.setFromRotationMatrix(collectorOrientationMatrix); +} + +function getCollectorActivityProfile(markerData) { + const recent24h = Number(markerData?.recent_24h_observation_count || 0); + const recent7d = Number(markerData?.recent_7d_observation_count || 0); + const prefixes = Number(markerData?.prefix_count || 0); + const origins = Number(markerData?.origin_asn_count || 0); + + const activityScore = + recent24h * 1.7 + + recent7d * 0.3 + + prefixes * 0.16 + + origins * 0.12; + + let tier = "idle"; + if (activityScore >= 50 || recent24h >= 24) tier = "hot"; + else if (activityScore >= 20 || recent24h >= 10) tier = "high"; + else if (activityScore >= 8 || recent24h >= 4) tier = "medium"; + else if (activityScore > 0) tier = "low"; + + const scaleBoost = clamp(1 + Math.log2(activityScore + 1) * 0.12, 1, 1.55); + const haloScale = + BGP_CONFIG.halo.collectorScale + + clamp(Math.log2(recent24h + prefixes + 1) * 2.2, 0, 12); + const pulseHaloScale = + BGP_CONFIG.halo.collectorPulseScale + + clamp(Math.log2(recent24h + recent7d + 1) * 2.8, 0, 14); + const coverageHaloScale = + BGP_CONFIG.halo.collectorCoverageScale + + clamp(Math.log2(prefixes + origins + 1) * 3.4, 0, 18); + + return { + tier, + color: BGP_CONFIG.collectorHeatColors[tier] || BGP_CONFIG.collectorColor, + scaleBoost, + haloScale, + pulseHaloScale, + coverageHaloScale, + activityScore, + }; +} + +function formatLocalDateTime(value) { + if (!value) return "-"; + + const date = new Date(value); + if (Number.isNaN(date.getTime())) return String(value); + + return `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, "0")}/${String(date.getDate()).padStart(2, "0")} ${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}:${String(date.getSeconds()).padStart(2, "0")}`; +} + +function toDate(value) { + if (!value) return null; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return null; + return date; +} + +function formatRelativeTime(value) { + const date = toDate(value); + if (!date) return null; + + const diffMs = date.getTime() - Date.now(); + const absMs = Math.abs(diffMs); + + if (absMs < 60 * 1000) { + return relativeTimeFormatter.format(Math.round(diffMs / 1000), "second"); + } + if (absMs < 60 * 60 * 1000) { + return relativeTimeFormatter.format(Math.round(diffMs / (60 * 1000)), "minute"); + } + if (absMs < 24 * 60 * 60 * 1000) { + return relativeTimeFormatter.format(Math.round(diffMs / (60 * 60 * 1000)), "hour"); + } + return relativeTimeFormatter.format( + Math.round(diffMs / (24 * 60 * 60 * 1000)), + "day", + ); +} + +export function formatBGPSeverityLabel(severity) { + const normalized = normalizeSeverity(severity); + switch (normalized) { + case "critical": + return "严重"; + case "high": + return "高"; + case "medium": + return "中"; + case "low": + return "低"; + default: + return "中"; + } +} + +export function formatBGPAnomalyTypeLabel(type) { + const value = String(type || "").trim().toLowerCase(); + if (!value) return "-"; + + if (value.includes("hijack")) return "前缀劫持"; + if (value.includes("leak")) return "路由泄露"; + if (value.includes("withdraw")) return "大规模撤销"; + if (value.includes("subprefix") || value.includes("more_specific")) { + return "更具体前缀异常"; + } + if (value.includes("path")) return "路径突变"; + if (value.includes("flap")) return "路由抖动"; + + return String(type); +} + +export function formatBGPStatusLabel(status) { + const value = String(status || "").trim().toLowerCase(); + if (!value) return "-"; + if (value === "active") return "活跃"; + if (value === "resolved") return "已恢复"; + if (value === "suppressed") return "已抑制"; + return String(status); +} + +export function formatBGPCollectorStatus(status) { + const value = String(status || "").trim().toLowerCase(); + if (!value) return "在线"; + if (value === "online") return "在线"; + if (value === "offline") return "离线"; + return String(status); +} + +export function formatBGPConfidence(value) { + if (value === null || value === undefined || value === "") return "-"; + const number = Number(value); + if (!Number.isFinite(number)) return String(value); + if (number >= 0 && number <= 1) { + return `${Math.round(number * 100)}%`; + } + return `${Math.round(number)}%`; +} + +export function formatBGPLocation(city, country) { + const cityText = city || ""; + const countryText = country || ""; + if (cityText && countryText) return `${cityText}, ${countryText}`; + return cityText || countryText || "-"; +} + +export function formatBGPRouteChange(originAsn, newOriginAsn) { + const from = originAsn ?? "-"; + const to = newOriginAsn ?? "-"; + + if ((from === "-" || from === "" || from === null) && (to === "-" || to === "" || to === null)) { + return "-"; + } + if (to === "-" || to === "" || to === null) { + return `AS${from}`; + } + return `AS${from} -> AS${to}`; +} + +export function formatBGPObservedTime(value) { + const absolute = formatLocalDateTime(value); + const relative = formatRelativeTime(value); + if (!relative || absolute === "-") return absolute; + return `${relative} (${absolute})`; +} + +export function formatBGPASPath(asPath) { + if (!Array.isArray(asPath) || asPath.length === 0) return "-"; + return asPath.map((asn) => `AS${asn}`).join(" -> "); +} + +export function formatBGPObservedBy(collectors) { + if (!Array.isArray(collectors) || collectors.length === 0) return "-"; + const preview = collectors.slice(0, 3).join(", "); + if (collectors.length <= 3) { + return `${collectors.length}个观测站 (${preview})`; + } + return `${collectors.length}个观测站 (${preview} 等)`; +} + +export function formatBGPImpactedScope(regions) { + if (!Array.isArray(regions) || regions.length === 0) return "-"; + const labels = regions + .map((region) => { + const city = region?.city || ""; + const country = region?.country || ""; + return city && country ? `${city}, ${country}` : city || country || ""; + }) + .filter(Boolean); + + if (labels.length === 0) return "-"; + if (labels.length <= 3) return labels.join(" / "); + return `${labels.slice(0, 3).join(" / ")} 等${labels.length}地`; +} + +export function formatBGPRelatedCables(items) { + if (!Array.isArray(items) || items.length === 0) return "-"; + + const labels = items + .slice(0, 3) + .map((item) => { + const landing = item?.landing_point || ""; + const cables = Array.isArray(item?.cable_names) ? item.cable_names : []; + const cableText = cables.length > 0 ? cables.slice(0, 2).join(", ") : "附近登陆点"; + const distance = item?.distance_km !== undefined ? ` ${item.distance_km}km` : ""; + return `${landing || cableText} (${cableText}${distance})`; + }) + .filter(Boolean); + + if (labels.length === 0) return "-"; + if (items.length <= 3) return labels.join(" / "); + return `${labels.join(" / ")} 等${items.length}处`; +} + +export function formatBGPScope(scope) { + const countries = Array.isArray(scope?.countries) ? scope.countries : []; + const cities = Array.isArray(scope?.cities) ? scope.cities : []; + const cityText = cities.slice(0, 3).join(" / "); + const countryText = countries.slice(0, 3).join(" / "); + + if (cityText && countryText) { + return `${cityText} | ${countryText}`; + } + return cityText || countryText || "-"; +} + +export function formatBGPTopEventTypes(items) { + if (!Array.isArray(items) || items.length === 0) return "-"; + return items + .slice(0, 3) + .map((item) => `${item?.event_type || "-"} x${item?.count || 0}`) + .join(" / "); +} + +export function formatBGPCollectorCoverageHalo(markerData) { + const prefixes = Number( + markerData?.recent_24h_prefix_count || + markerData?.recent_7d_prefix_count || + markerData?.prefix_count || + 0, + ); + const observations = Number( + markerData?.recent_24h_observation_count || + markerData?.recent_7d_observation_count || + markerData?.observation_count || + 0, + ); + if (prefixes <= 0 && observations <= 0) return "静态观测站"; + return `近24h ${observations}条事件 / ${prefixes}个前缀`; +} + +function buildCollectorFeatureData(feature) { + const coordinates = feature?.geometry?.coordinates || []; + const [longitude, latitude] = coordinates; + if ( + typeof latitude !== "number" || + typeof longitude !== "number" || + Number.isNaN(latitude) || + Number.isNaN(longitude) + ) { + return null; + } + + const properties = feature?.properties || {}; + return { + latitude, + longitude, + collector: properties.collector || "-", + city: properties.city || "-", + country: properties.country || "-", + status: properties.status || "online", + observation_count: properties.observation_count || 0, + recent_24h_observation_count: properties.recent_24h_observation_count || 0, + recent_7d_observation_count: properties.recent_7d_observation_count || 0, + prefix_count: properties.prefix_count || 0, + recent_24h_prefix_count: properties.recent_24h_prefix_count || 0, + recent_7d_prefix_count: properties.recent_7d_prefix_count || 0, + origin_asn_count: properties.origin_asn_count || 0, + latest_observed_at: properties.latest_observed_at || null, + latest_event_type: properties.latest_event_type || null, + top_event_types: Array.isArray(properties.top_event_types) + ? properties.top_event_types + : [], + baseline_scope: properties.baseline_scope || { countries: [], cities: [] }, + }; +} + +function spreadCollectorPositions(markers) { + const groups = new Map(); + + markers.forEach((marker) => { + const key = `${marker.latitude.toFixed(4)}|${marker.longitude.toFixed(4)}`; + if (!groups.has(key)) { + groups.set(key, []); + } + groups.get(key).push(marker); + }); + + groups.forEach((group) => { + if (group.length <= 1) return; + + const radius = 1.4; + group.forEach((marker, index) => { + const angle = (Math.PI * 2 * index) / group.length; + marker.displayLatitude = + marker.latitude + Math.sin(angle) * radius * 0.28; + marker.displayLongitude = + marker.longitude + Math.cos(angle) * radius * 0.28; + marker.isSpread = true; + marker.groupSize = group.length; + }); + }); + + markers.forEach((marker) => { + if (marker.displayLatitude === undefined) { + marker.displayLatitude = marker.latitude; + marker.displayLongitude = marker.longitude; + marker.isSpread = false; + marker.groupSize = 1; + } + }); + + return markers; +} + +function buildAnomalyFeatureData(feature) { + const point = extractFeaturePoint(feature); + if (!point) return null; + const { latitude, longitude } = point; + const properties = feature?.properties || {}; + const meta = extractFeatureMeta(properties, properties.created_at || null); + + return { + latitude, + longitude, + rawSeverity: meta.rawSeverity, + severity: meta.severity, + collector: properties.collector || "-", + city: properties.city || "-", + country: properties.country || "-", + source: properties.source || "-", + anomaly_type: properties.anomaly_type || "-", + status: properties.status || "-", + prefix: properties.prefix || "-", + origin_asn: properties.origin_asn ?? "-", + new_origin_asn: properties.new_origin_asn ?? "-", + as_path: Array.isArray(properties.as_path) ? properties.as_path : [], + collectors: Array.isArray(properties.collectors) ? properties.collectors : [], + collector_count: properties.collector_count ?? 1, + impacted_regions: Array.isArray(properties.impacted_regions) + ? properties.impacted_regions + : [], + confidence: properties.confidence ?? "-", + summary: properties.summary || "-", + created_at: meta.createdAt, + created_at_raw: meta.createdAtRaw, + id: + properties.id || + `${properties.collector || "unknown"}-${latitude}-${longitude}`, + }; +} + +function buildIncidentFeatureData(feature) { + const point = extractFeaturePoint(feature); + if (!point) return null; + const { latitude, longitude } = point; + const properties = feature?.properties || {}; + const startedAt = properties.started_at || properties.created_at || null; + const meta = extractFeatureMeta(properties, startedAt); + const affectedPrefixes = Array.isArray(properties.affected_prefixes) + ? properties.affected_prefixes + : []; + const affectedAsns = Array.isArray(properties.affected_asns) + ? properties.affected_asns + : []; + const affectedCollectors = Array.isArray(properties.affected_collectors) + ? properties.affected_collectors + : []; + const affectedRegions = Array.isArray(properties.affected_regions) + ? properties.affected_regions + : []; + + const primaryRegion = affectedRegions[0] || {}; + + return { + latitude, + longitude, + rawSeverity: meta.rawSeverity, + severity: meta.severity, + collector: affectedCollectors[0] || primaryRegion.collector || "-", + city: primaryRegion.city || "-", + country: primaryRegion.country || "-", + source: "bgp_incident", + anomaly_type: properties.incident_type || properties.title || "-", + incident_type: properties.incident_type || "-", + incident_key: properties.incident_key || "-", + status: properties.status || "-", + prefix: affectedPrefixes[0] || "-", + prefixes: affectedPrefixes, + origin_asn: affectedAsns[0] ?? "-", + new_origin_asn: affectedAsns[1] ?? "-", + affected_asns: affectedAsns, + as_path: [], + collectors: affectedCollectors, + collector_count: affectedCollectors.length || 1, + impacted_regions: affectedRegions, + related_cables: Array.isArray(properties.related_cables) + ? properties.related_cables + : [], + related_ixps: Array.isArray(properties.related_ixps) + ? properties.related_ixps + : [], + confidence: properties.confidence ?? "-", + summary: properties.summary || properties.title || "-", + created_at: meta.createdAt, + created_at_raw: meta.createdAtRaw, + route_change: + affectedAsns.length > 1 + ? affectedAsns.slice(0, 2).map((asn) => `AS${asn}`).join(" -> ") + : affectedPrefixes.length > 1 + ? `${affectedPrefixes.length}个前缀簇` + : properties.incident_type || "-", + observed_by: formatBGPObservedBy(affectedCollectors), + impacted_scope: formatBGPImpactedScope(affectedRegions), + location: formatBGPLocation(primaryRegion.city, primaryRegion.country), + id: + properties.id || + properties.incident_key || + `${properties.incident_type || "incident"}-${latitude}-${longitude}`, + }; +} + +function extractFeaturePoint(feature) { + const coordinates = feature?.geometry?.coordinates || []; + const [longitude, latitude] = coordinates; + if ( + typeof latitude !== "number" || + typeof longitude !== "number" || + Number.isNaN(latitude) || + Number.isNaN(longitude) + ) { + return null; + } + return { latitude, longitude }; +} + +function extractFeatureMeta(properties, createdAtRaw) { + const severity = normalizeSeverity(properties.severity); + return { + rawSeverity: properties.severity || severity, + severity, + createdAt: formatLocalDateTime(createdAtRaw), + createdAtRaw: createdAtRaw, + }; +} + +function clearMarkerArray(markers) { + while (markers.length > 0) { + const marker = markers.pop(); + while (marker.children.length > 0) { + const child = marker.children.pop(); + child.material?.dispose(); + } + marker.material?.dispose(); + bgpGroup.remove(marker); + } +} + +function clearGroup(group) { + while (group.children.length > 0) { + const child = group.children[group.children.length - 1]; + group.remove(child); + if (child.geometry) child.geometry.dispose(); + if (child.material) child.material.dispose(); + } +} + +function createSpriteMaterial({ color, opacity }) { + return new THREE.SpriteMaterial({ + map: getMarkerTexture(), + color, + transparent: true, + opacity, + depthWrite: false, + depthTest: true, + blending: THREE.AdditiveBlending, + }); +} + +function createOverlaySprite({ color, opacity, scale }) { + const sprite = new THREE.Sprite(createSpriteMaterial({ color, opacity })); + sprite.scale.setScalar(scale); + return sprite; +} + +function projectLatLon(lat, lon, bearingDeg, distanceDeg) { + const latRad = (lat * Math.PI) / 180; + const lonRad = (lon * Math.PI) / 180; + const bearing = (bearingDeg * Math.PI) / 180; + const angularDistance = (distanceDeg * Math.PI) / 180; + + const targetLat = Math.asin( + Math.sin(latRad) * Math.cos(angularDistance) + + Math.cos(latRad) * Math.sin(angularDistance) * Math.cos(bearing), + ); + const targetLon = + lonRad + + Math.atan2( + Math.sin(bearing) * Math.sin(angularDistance) * Math.cos(latRad), + Math.cos(angularDistance) - Math.sin(latRad) * Math.sin(targetLat), + ); + + return { + latitude: (targetLat * 180) / Math.PI, + longitude: ((((targetLon * 180) / Math.PI) + 540) % 360) - 180, + }; +} + +function createCoverageBoundaryLine(points, color, opacity = 0.3) { + const geometry = new THREE.BufferGeometry().setFromPoints(points); + const material = new THREE.LineBasicMaterial({ + color, + transparent: true, + opacity, + depthWrite: false, + blending: THREE.AdditiveBlending, + }); + return new THREE.Line(geometry, material); +} + +function createCoverageSector(points, color, opacity = 0.12) { + const center = points[0]; + const positions = []; + + for (let index = 1; index < points.length - 1; index += 1) { + const current = points[index]; + const next = points[index + 1]; + positions.push( + center.x, center.y, center.z, + current.x, current.y, current.z, + next.x, next.y, next.z, + ); + } + + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); + geometry.computeVertexNormals(); + + const material = new THREE.MeshBasicMaterial({ + color, + transparent: true, + opacity, + side: THREE.DoubleSide, + depthWrite: false, + blending: THREE.AdditiveBlending, + }); + + return new THREE.Mesh(geometry, material); +} + +function createCoverageSectorMesh( + anchorLatitude, + anchorLongitude, + startBearingDeg, + endBearingDeg, + reachDeg, + altitude, + color, + opacity = 0.12, + radialSegments = 8, + angularSegments = 28, +) { + const positions = []; + + for (let radialIndex = 0; radialIndex < radialSegments; radialIndex += 1) { + const innerDistance = (reachDeg * radialIndex) / radialSegments; + const outerDistance = (reachDeg * (radialIndex + 1)) / radialSegments; + + for (let angularIndex = 0; angularIndex < angularSegments; angularIndex += 1) { + const startProgress = angularIndex / angularSegments; + const endProgress = (angularIndex + 1) / angularSegments; + const startBearing = startBearingDeg + (endBearingDeg - startBearingDeg) * startProgress; + const endBearing = startBearingDeg + (endBearingDeg - startBearingDeg) * endProgress; + + const innerStart = projectLatLon(anchorLatitude, anchorLongitude, startBearing, innerDistance); + const innerEnd = projectLatLon(anchorLatitude, anchorLongitude, endBearing, innerDistance); + const outerStart = projectLatLon(anchorLatitude, anchorLongitude, startBearing, outerDistance); + const outerEnd = projectLatLon(anchorLatitude, anchorLongitude, endBearing, outerDistance); + + const innerStartVector = latLonToVector3(innerStart.latitude, innerStart.longitude, altitude); + const innerEndVector = latLonToVector3(innerEnd.latitude, innerEnd.longitude, altitude); + const outerStartVector = latLonToVector3(outerStart.latitude, outerStart.longitude, altitude); + const outerEndVector = latLonToVector3(outerEnd.latitude, outerEnd.longitude, altitude); + + positions.push( + innerStartVector.x, innerStartVector.y, innerStartVector.z, + outerStartVector.x, outerStartVector.y, outerStartVector.z, + outerEndVector.x, outerEndVector.y, outerEndVector.z, + ); + positions.push( + innerStartVector.x, innerStartVector.y, innerStartVector.z, + outerEndVector.x, outerEndVector.y, outerEndVector.z, + innerEndVector.x, innerEndVector.y, innerEndVector.z, + ); + } + } + + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); + geometry.computeVertexNormals(); + + const material = new THREE.MeshBasicMaterial({ + color, + transparent: true, + opacity, + side: THREE.DoubleSide, + depthWrite: false, + polygonOffset: true, + polygonOffsetFactor: 1, + polygonOffsetUnits: 1, + blending: THREE.AdditiveBlending, + }); + + return new THREE.Mesh(geometry, material); +} + +function createRadialBoundaryPoints( + anchorLatitude, + anchorLongitude, + bearingDeg, + reachDeg, + altitude, + segments = 18, +) { + const points = []; + + for (let step = 0; step <= segments; step += 1) { + const progress = step / segments; + const projected = projectLatLon( + anchorLatitude, + anchorLongitude, + bearingDeg, + reachDeg * progress, + ); + points.push( + latLonToVector3( + projected.latitude, + projected.longitude, + altitude, + ), + ); + } + + return points; +} + +function createCollectorMarker(markerData) { + const activity = getCollectorActivityProfile(markerData); + const baseColor = activity.color; + const idleColor = blendHexColors( + BGP_CONFIG.collectorIcon.idleBaseColor, + baseColor, + BGP_CONFIG.collectorIcon.idleBlend, + ); + const marker = new THREE.Mesh( + new THREE.PlaneGeometry(1, 1), + new THREE.MeshBasicMaterial({ + map: getCollectorTexture(), + color: idleColor, + transparent: true, + opacity: BGP_CONFIG.collectorIcon.idleOpacity, + depthWrite: false, + depthTest: true, + side: THREE.DoubleSide, + }), + ); + + const position = latLonToVector3( + markerData.displayLatitude, + markerData.displayLongitude, + CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset, + ); + + marker.position.copy(position); + marker.scale.set(BGP_CONFIG.marker.collectorBaseScale * 0.88 * activity.scaleBoost, BGP_CONFIG.marker.collectorBaseScale * 1.08 * activity.scaleBoost, 1); + marker.renderOrder = 3; + marker.visible = showBGP; + orientCollectorMarkerToSurface(marker, position); + + const heatHalo = createOverlaySprite({ + color: activity.color, + opacity: 0.0, + scale: activity.haloScale * 0.58, + }); + heatHalo.renderOrder = 1; + marker.add(heatHalo); + + const pulseHalo = createOverlaySprite({ + color: activity.color, + opacity: 0.0, + scale: activity.pulseHaloScale * 0.48, + }); + pulseHalo.renderOrder = 0; + marker.add(pulseHalo); + + const statusCore = createOverlaySprite({ + color: activity.color, + opacity: 0.0, + scale: Math.max( + BGP_CONFIG.marker.collectorBaseScale * + BGP_CONFIG.marker.collectorStatusCoreBaseScale, + BGP_CONFIG.marker.collectorStatusCoreMinScale, + ), + }); + statusCore.position.set(0, 0, 0.02); + statusCore.renderOrder = 4; + marker.add(statusCore); + + const coverageHalo = createOverlaySprite({ + color: BGP_CONFIG.regionColor, + opacity: 0.0, + scale: activity.coverageHaloScale * 0.7, + }); + coverageHalo.renderOrder = 0; + coverageHalo.scale.set(activity.coverageHaloScale * 0.82, activity.coverageHaloScale * 0.56, 1); + marker.add(coverageHalo); + + marker.userData = { + type: "bgp_collector", + state: "normal", + baseScale: BGP_CONFIG.marker.collectorBaseScale * activity.scaleBoost, + baseColor, + idleColor, + pulseOffset: Math.random() * Math.PI * 2, + anomaly_count: 0, + activity, + heatHalo, + pulseHalo, + statusCore, + coverageHalo, + ...markerData, + }; + + collectorMarkers.push(marker); + bgpGroup.add(marker); +} + +function createAnomalyMarker(markerData) { + const sprite = new THREE.Sprite( + new THREE.SpriteMaterial({ + map: getEventTexture(markerData.incident_type || markerData.anomaly_type), + color: getSeverityColor(markerData.severity), + transparent: true, + opacity: BGP_CONFIG.opacity.normal, + depthWrite: false, + depthTest: true, + blending: THREE.NormalBlending, + }), + ); + + const position = latLonToVector3( + markerData.latitude, + markerData.longitude, + CONFIG.earthRadius + BGP_CONFIG.altitudeOffset, + ); + + const baseScale = BGP_CONFIG.marker.eventBaseScale * getSeverityScale(markerData.severity); + sprite.position.copy(position); + sprite.scale.setScalar(baseScale); + sprite.renderOrder = 5; + sprite.visible = showBGP; + sprite.userData = { + type: "bgp", + state: "normal", + baseScale, + baseColor: getSeverityColor(markerData.severity), + pulseOffset: Math.random() * Math.PI * 2, + ...markerData, + }; + + const ringA = new THREE.Sprite( + new THREE.SpriteMaterial({ + map: getEventRingTexture(), + color: getSeverityColor(markerData.severity), + transparent: true, + opacity: 0, + depthWrite: false, + depthTest: true, + blending: THREE.AdditiveBlending, + }), + ); + ringA.scale.setScalar(baseScale * BGP_CONFIG.ring.scaleA); + ringA.position.set(0, 0, -0.01); + sprite.add(ringA); + + const ringB = new THREE.Sprite( + new THREE.SpriteMaterial({ + map: getEventRingTexture(), + color: getSeverityColor(markerData.severity), + transparent: true, + opacity: 0, + depthWrite: false, + depthTest: true, + blending: THREE.AdditiveBlending, + }), + ); + ringB.scale.setScalar(baseScale * BGP_CONFIG.ring.scaleB); + ringB.position.set(0, 0, -0.02); + sprite.add(ringB); + + sprite.userData.ringA = ringA; + sprite.userData.ringB = ringB; + + anomalyMarkers.push(sprite); + bgpGroup.add(sprite); +} + +function dedupeAnomalies(features) { + const latestByLocation = new Map(); + + features.forEach((feature) => { + const data = buildAnomalyFeatureData(feature); + if (!data) return; + + activeEventCountByCollector.set( + data.collector, + (activeEventCountByCollector.get(data.collector) || 0) + 1, + ); + + const dedupeKey = `${data.latitude.toFixed(3)}|${data.longitude.toFixed(3)}`; + const previous = latestByLocation.get(dedupeKey); + const currentTime = data.created_at_raw + ? new Date(data.created_at_raw).getTime() + : 0; + const previousTime = previous?.created_at_raw + ? new Date(previous.created_at_raw).getTime() + : 0; + const currentSeverity = getSeverityScale(data.severity); + const previousSeverity = previous ? getSeverityScale(previous.severity) : 0; + + if ( + !previous || + currentSeverity > previousSeverity || + (currentSeverity === previousSeverity && currentTime >= previousTime) + ) { + latestByLocation.set(dedupeKey, data); + } + }); + + return Array.from(latestByLocation.values()) + .sort((a, b) => { + const severityDiff = getSeverityScale(b.severity) - getSeverityScale(a.severity); + if (severityDiff !== 0) return severityDiff; + const timeA = a.created_at_raw ? new Date(a.created_at_raw).getTime() : 0; + const timeB = b.created_at_raw ? new Date(b.created_at_raw).getTime() : 0; + return timeB - timeA; + }) + .slice(0, BGP_CONFIG.maxRenderedMarkers); +} + +function dedupeIncidents(features) { + const latestByLocation = new Map(); + + features.forEach((feature) => { + const data = buildIncidentFeatureData(feature); + if (!data) return; + + data.collectors.forEach((collector) => { + activeEventCountByCollector.set( + collector, + (activeEventCountByCollector.get(collector) || 0) + 1, + ); + }); + + const dedupeKey = `${data.latitude.toFixed(3)}|${data.longitude.toFixed(3)}`; + const previous = latestByLocation.get(dedupeKey); + const currentTime = data.created_at_raw + ? new Date(data.created_at_raw).getTime() + : 0; + const previousTime = previous?.created_at_raw + ? new Date(previous.created_at_raw).getTime() + : 0; + const currentSeverity = getSeverityScale(data.severity); + const previousSeverity = previous ? getSeverityScale(previous.severity) : 0; + + if ( + !previous || + currentSeverity > previousSeverity || + (currentSeverity === previousSeverity && currentTime >= previousTime) + ) { + latestByLocation.set(dedupeKey, data); + } + }); + + return Array.from(latestByLocation.values()) + .sort((a, b) => { + const severityDiff = getSeverityScale(b.severity) - getSeverityScale(a.severity); + if (severityDiff !== 0) return severityDiff; + const timeA = a.created_at_raw ? new Date(a.created_at_raw).getTime() : 0; + const timeB = b.created_at_raw ? new Date(b.created_at_raw).getTime() : 0; + return timeB - timeA; + }) + .slice(0, BGP_CONFIG.maxRenderedMarkers); +} + +function applyCollectorCounts() { + collectorMarkers.forEach((marker) => { + marker.userData.anomaly_count = + activeEventCountByCollector.get(marker.userData.collector) || 0; + }); +} + +async function fetchGeoJSONWithTimeout(url, timeoutMs, warningMessage, fallbackPayload) { + try { + const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + return await response.json(); + } catch (error) { + console.warn(warningMessage, error); + return fallbackPayload; + } +} + +function selectBGPEventFeatures(incidentPayload, anomalyPayload) { + const incidentFeatures = Array.isArray(incidentPayload?.features) + ? incidentPayload.features + : []; + if (incidentFeatures.length > 0) { + return { + features: incidentFeatures, + totalIncidentCount: incidentPayload?.count ?? incidentFeatures.length, + totalAnomalyCount: anomalyPayload?.count ?? 0, + mode: "incident", + }; + } + + const anomalyFeatures = Array.isArray(anomalyPayload?.features) + ? anomalyPayload.features + : []; + return { + features: anomalyFeatures, + totalIncidentCount: 0, + totalAnomalyCount: anomalyPayload?.count ?? anomalyFeatures.length, + mode: "anomaly", + }; +} + +export async function loadBGPAnomalies(scene, earth) { + clearBGPData(earth); + + const collectorsResponse = await fetch(PATHS.bgpCollectorsApi); + if (!collectorsResponse.ok) { + throw new Error(`BGP collectors HTTP ${collectorsResponse.status}`); + } + + const emptyPayload = { type: "FeatureCollection", features: [], count: 0 }; + const anomaliesPayload = await fetchGeoJSONWithTimeout( + `${PATHS.bgpApi}?limit=${BGP_CONFIG.defaultFetchLimit}`, + 5000, + "BGP anomalies unavailable, falling back to collectors only:", + emptyPayload, + ); + const incidentsPayload = await fetchGeoJSONWithTimeout( + `${PATHS.bgpIncidentsApi}?limit=${BGP_CONFIG.defaultFetchLimit}`, + 5000, + "BGP incidents unavailable, falling back to anomalies:", + emptyPayload, + ); + + const collectorsPayload = await collectorsResponse.json(); + const collectorFeatures = Array.isArray(collectorsPayload?.features) + ? collectorsPayload.features + : []; + const selectedEventData = selectBGPEventFeatures(incidentsPayload, anomaliesPayload); + totalAnomalyCount = selectedEventData.totalAnomalyCount; + totalIncidentCount = selectedEventData.totalIncidentCount; + activeEventCountByCollector.clear(); + + spreadCollectorPositions( + collectorFeatures + .map(buildCollectorFeatureData) + .filter(Boolean), + ).forEach(createCollectorMarker); + + if (selectedEventData.mode === "incident") { + dedupeIncidents(selectedEventData.features).forEach(createAnomalyMarker); + } else { + dedupeAnomalies(selectedEventData.features).forEach(createAnomalyMarker); + } + applyCollectorCounts(); + + if (!bgpGroup.parent) { + earth.add(bgpGroup); + } + if (!bgpOverlayGroup.parent) { + earth.add(bgpOverlayGroup); + } + + bgpGroup.visible = showBGP; + bgpOverlayGroup.visible = showBGP; + + if (scene && !scene.children.includes(earth)) { + scene.add(earth); + } + + return { + totalCount: totalIncidentCount, + anomalyCount: totalAnomalyCount, + renderedCount: anomalyMarkers.length, + collectorCount: collectorMarkers.length, + }; +} + +export function updateBGPVisualState(lockedObjectType, lockedObject, camera) { + const now = performance.now(); + updateCollectorOverlayScan(lockedObjectType, lockedObject); + const hasLockedLayer = Boolean( + lockedObject && ["cable", "satellite", "bgp", "bgp_collector"].includes(lockedObjectType), + ); + + collectorMarkers.forEach((marker) => { + const isLocked = + (lockedObjectType === "bgp_collector" || lockedObjectType === "bgp") && + lockedObject?.userData?.collector === marker.userData.collector; + const isHovered = + marker.userData.state === "hover" || marker.userData.state === "linked"; + const pulse = + 0.5 + + 0.5 * + Math.sin( + now * BGP_CONFIG.pulse.collectorSpeed + marker.userData.pulseOffset, + ); + + let scale = marker.userData.baseScale * getCollectorDistanceScale(marker, camera); + let opacity = BGP_CONFIG.collectorIcon.idleOpacity; + let haloOpacity = 0.0; + let pulseOpacity = 0.0; + let coverageOpacity = 0.0; + let markerColor = + marker.userData.idleColor || + blendHexColors( + BGP_CONFIG.collectorIcon.idleBaseColor, + marker.userData.baseColor || BGP_CONFIG.collectorColor, + BGP_CONFIG.collectorIcon.idleBlend, + ); + + if (isLocked) { + scale *= 1.1 + 0.14 * pulse; + opacity = BGP_CONFIG.opacity.collectorHover; + haloOpacity = 0.022; + pulseOpacity = 0.012; + coverageOpacity = 0.02; + markerColor = blendHexColors( + BGP_CONFIG.collectorIcon.lockedNeutralColor, + marker.userData.baseColor || BGP_CONFIG.collectorColor, + BGP_CONFIG.collectorIcon.lockedBlend, + ); + } else if (isHovered) { + scale *= 1.08; + opacity = BGP_CONFIG.opacity.collectorHover; + haloOpacity = 0.016; + pulseOpacity = 0.008; + coverageOpacity = 0.014; + markerColor = blendHexColors( + BGP_CONFIG.collectorIcon.hoverNeutralColor, + marker.userData.baseColor || BGP_CONFIG.collectorColor, + BGP_CONFIG.collectorIcon.hoverBlend, + ); + } else if (hasLockedLayer) { + scale *= 0.98; + opacity = BGP_CONFIG.collectorIcon.idleOpacity; + haloOpacity = 0.0; + pulseOpacity = 0.0; + coverageOpacity = 0.0; + markerColor = marker.userData.idleColor || markerColor; + } else { + scale *= 1 + 0.05 * pulse; + } + + marker.scale.setScalar(scale); + marker.material.color.setHex(markerColor); + marker.material.opacity = opacity; + marker.visible = showBGP; + + if (marker.userData.heatHalo) { + marker.userData.heatHalo.material.opacity = haloOpacity; + marker.userData.heatHalo.material.color.setHex(marker.userData.baseColor || BGP_CONFIG.collectorColor); + marker.userData.heatHalo.scale.setScalar( + marker.userData.activity?.haloScale * 0.58 * (1 + pulse * 0.01), + ); + } + if (marker.userData.pulseHalo) { + marker.userData.pulseHalo.material.opacity = pulseOpacity; + marker.userData.pulseHalo.material.color.setHex(marker.userData.baseColor || BGP_CONFIG.collectorColor); + marker.userData.pulseHalo.scale.setScalar( + marker.userData.activity?.pulseHaloScale * 0.48 * (1 + pulse * 0.02), + ); + } + if (marker.userData.statusCore) { + marker.userData.statusCore.material.opacity = + isLocked ? 0.58 : isHovered ? 0.4 : hasLockedLayer ? 0.0 : 0.18; + marker.userData.statusCore.material.color.setHex(marker.userData.baseColor || BGP_CONFIG.collectorColor); + marker.userData.statusCore.scale.setScalar( + Math.max( + BGP_CONFIG.marker.collectorBaseScale * + BGP_CONFIG.marker.collectorStatusCoreBaseScale, + BGP_CONFIG.marker.collectorStatusCoreMinScale, + ) * (isLocked ? 1.08 : isHovered ? 1.04 : 0.92), + ); + } + if (marker.userData.coverageHalo) { + marker.userData.coverageHalo.material.opacity = coverageOpacity; + marker.userData.coverageHalo.scale.set( + marker.userData.activity?.coverageHaloScale * 0.82 * (1 + pulse * 0.012), + marker.userData.activity?.coverageHaloScale * 0.56 * (1 + pulse * 0.012), + 1, + ); + } + }); + + anomalyMarkers.forEach((marker) => { + const isLocked = lockedObjectType === "bgp" && lockedObject === marker; + const isLinkedCollectorLocked = + lockedObjectType === "bgp_collector" && + lockedObject?.userData?.collector === marker.userData.collector; + const isOtherLocked = hasLockedLayer && !isLocked && !isLinkedCollectorLocked; + const isHovered = marker.userData.state === "hover"; + const pulse = + 0.5 + + 0.5 * Math.sin(now * BGP_CONFIG.pulse.eventSpeed + marker.userData.pulseOffset); + + const iconAnchorScale = + marker.userData.baseScale * getEventDistanceScale(marker, camera); + let scale = iconAnchorScale; + let opacity = BGP_CONFIG.opacity.normal; + let markerColor = marker.userData.baseColor || getSeverityColor(marker.userData.severity); + const isIncidentMarker = marker.userData.source === "bgp_incident"; + let ringBaseOpacity = isIncidentMarker + ? BGP_CONFIG.ring.opacity + : BGP_CONFIG.ring.opacity * 0.45; + + if (isLocked || isLinkedCollectorLocked) { + scale *= 1 + BGP_CONFIG.pulse.lockedAmplitude * pulse; + opacity = + BGP_CONFIG.opacity.lockedMin + + (BGP_CONFIG.opacity.lockedMax - BGP_CONFIG.opacity.lockedMin) * pulse; + ringBaseOpacity *= 1.2; + } else if (isHovered) { + scale *= BGP_CONFIG.marker.hoverScale; + opacity = BGP_CONFIG.opacity.hover; + ringBaseOpacity *= 1.05; + } else if (isOtherLocked) { + scale *= BGP_CONFIG.marker.dimmedScale; + opacity = 0.1; + markerColor = 0x7d8ca3; + ringBaseOpacity = 0.02; + } else { + scale *= 1 + BGP_CONFIG.pulse.normalAmplitude * pulse; + opacity = isIncidentMarker ? 0.7 : 0.62; + } + + marker.scale.setScalar(scale); + marker.material.color.setHex(markerColor); + marker.material.opacity = opacity; + marker.visible = showBGP; + + const ringPhaseA = (now * BGP_CONFIG.ring.speed + marker.userData.pulseOffset) % 1; + const applyRingState = (ring, phase, maxScale) => { + if (!ring) return; + const progress = Math.max(0, Math.min(1, phase)); + const minScale = 1.28; + const desiredWorldScale = + iconAnchorScale * (minScale + progress * (maxScale - minScale)); + const parentScale = Math.max(scale, 0.0001); + const localRingScale = desiredWorldScale / parentScale; + const fadeIn = Math.max(0, Math.min(1, (progress - 0.08) / 0.14)); + const fadeOut = 1 - progress; + const visibility = fadeIn * fadeOut; + ring.scale.setScalar(localRingScale); + ring.material.color.setHex(markerColor); + ring.material.opacity = showBGP ? ringBaseOpacity * visibility : 0; + ring.visible = showBGP; + }; + + applyRingState(marker.userData.ringA, ringPhaseA, BGP_CONFIG.ring.scaleA); + if (marker.userData.ringB) { + marker.userData.ringB.material.opacity = 0; + marker.userData.ringB.visible = false; + } + }); +} + +export function setBGPMarkerState(marker, state = "normal") { + if (!marker?.userData) return; + if (marker.userData.type !== "bgp" && marker.userData.type !== "bgp_collector") { + return; + } + marker.userData.state = state; +} + +export function clearBGPSelection() { + collectorMarkers.forEach((marker) => { + marker.userData.state = "normal"; + }); + anomalyMarkers.forEach((marker) => { + marker.userData.state = "normal"; + }); + clearBGPEventOverlay(); +} + +export function clearBGPData(earth) { + clearMarkerArray(collectorMarkers); + clearMarkerArray(anomalyMarkers); + clearBGPEventOverlay(); + activeEventCountByCollector.clear(); + totalAnomalyCount = 0; + totalIncidentCount = 0; + + if (earth && bgpGroup.parent === earth) { + earth.remove(bgpGroup); + } + if (earth && bgpOverlayGroup.parent === earth) { + earth.remove(bgpOverlayGroup); + } +} + +export function toggleBGP(show) { + showBGP = Boolean(show); + bgpGroup.visible = showBGP; + bgpOverlayGroup.visible = showBGP; + collectorMarkers.forEach((marker) => { + marker.visible = showBGP; + }); + anomalyMarkers.forEach((marker) => { + marker.visible = showBGP; + }); +} + +export function getShowBGP() { + return showBGP; +} + +export function getBGPMarkers() { + return [...anomalyMarkers, ...collectorMarkers]; +} + +export function getBGPAnomalyMarkers() { + return anomalyMarkers; +} + +export function getBGPCollectorMarkers() { + return collectorMarkers; +} + +export function getBGPCount() { + return totalIncidentCount; +} + +export function getBGPCollectorCount() { + return collectorMarkers.length; +} + +export function getBGPStatusSummary() { + if (totalIncidentCount > 0 && totalAnomalyCount > 0) { + return `${totalIncidentCount} 起活跃事件 / ${totalAnomalyCount} 条异常`; + } + if (totalIncidentCount > 0) { + return `${totalIncidentCount} 起活跃事件`; + } + if (totalAnomalyCount > 0) { + return `${totalAnomalyCount} 条活跃异常`; + } + if (collectorMarkers.length > 0) { + return "当前无活跃事件"; + } + return "暂无观测数据"; +} + +export function showBGPEventOverlay(marker, earth) { + if (!marker?.userData || marker.userData.type !== "bgp" || !earth) return; + + clearBGPEventOverlay(); + + const impactedRegions = + Array.isArray(marker.userData.impacted_regions) && + marker.userData.impacted_regions.length > 0 + ? marker.userData.impacted_regions + : [ + { + collector: marker.userData.collector, + city: marker.userData.city, + country: marker.userData.country, + latitude: marker.userData.latitude, + longitude: marker.userData.longitude, + }, + ]; + + const validRegions = impactedRegions.filter( + (region) => + typeof region?.latitude === "number" && + typeof region?.longitude === "number", + ); + if (validRegions.length === 0) return; + const overlayItems = []; + + validRegions.forEach((region) => { + const halo = createOverlaySprite({ + color: BGP_CONFIG.regionColor, + opacity: 0.24, + scale: BGP_CONFIG.regionScale, + }); + halo.position.copy( + latLonToVector3( + region.latitude, + region.longitude, + CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset - 0.1, + ), + ); + halo.renderOrder = 2; + bgpOverlayGroup.add(halo); + overlayItems.push(halo); + }); + + activeEventOverlay = overlayItems; + activeCollectorOverlayContext = null; + bgpOverlayGroup.visible = showBGP; +} + +export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) { + if (!marker?.userData || marker.userData.type !== "bgp_collector" || !earth) return; + + clearBGPEventOverlay(); + + const prefixCount = Number( + marker.userData.recent_24h_prefix_count || + marker.userData.recent_7d_prefix_count || + marker.userData.prefix_count || + 0, + ); + const observationCount = Number( + marker.userData.recent_24h_observation_count || + marker.userData.recent_7d_observation_count || + marker.userData.observation_count || + 0, + ); + const scaleBoost = Math.min(10, Math.log2(prefixCount + observationCount + 1) * 1.8); + const haloScale = BGP_CONFIG.regionScale * 0.7 + scaleBoost; + const pulseHaloScale = haloScale * 1.32; + + const halo = createOverlaySprite({ + color: BGP_CONFIG.regionColor, + opacity: 0.11, + scale: haloScale * 0.78, + }); + halo.position.copy( + latLonToVector3( + marker.userData.displayLatitude ?? marker.userData.latitude, + marker.userData.displayLongitude ?? marker.userData.longitude, + CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset - 0.15, + ), + ); + halo.renderOrder = 2; + bgpOverlayGroup.add(halo); + + const pulseHalo = createOverlaySprite({ + color: BGP_CONFIG.collectorColor, + opacity: 0.065, + scale: pulseHaloScale * 0.82, + }); + pulseHalo.position.copy(halo.position); + pulseHalo.renderOrder = 1; + bgpOverlayGroup.add(pulseHalo); + const innerRing = createOverlaySprite({ + color: BGP_CONFIG.collectorColor, + opacity: 0.12, + scale: Math.max(haloScale * 0.34, 5.5), + }); + innerRing.position.copy(halo.position); + innerRing.renderOrder = 3; + bgpOverlayGroup.add(innerRing); + + const overlayItems = [halo, pulseHalo, innerRing]; + const anchorLatitude = marker.userData.displayLatitude ?? marker.userData.latitude; + const anchorLongitude = marker.userData.displayLongitude ?? marker.userData.longitude; + const activityMagnitude = Math.log2(prefixCount + observationCount + 1); + const corridorReach = Math.min(30, 12 + activityMagnitude * 3.2); + const orientationSeed = Array.from(marker.userData.collector || "") + .reduce((sum, char) => sum + char.charCodeAt(0), 0); + const baseRotation = ((orientationSeed % 140) - 70) * (Math.PI / 180); + const sectorRotation = baseRotation + (options.rotationOffsetRad || 0); + const sectorHalfWidth = Math.PI * 0.22; + const startBearing = (sectorRotation - sectorHalfWidth) * (180 / Math.PI); + const endBearing = (sectorRotation + sectorHalfWidth) * (180 / Math.PI); + const coverageColor = marker.userData.baseColor || BGP_CONFIG.collectorColor; + const boundaryAltitude = CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset + 0.44; + const fillAltitude = CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset + 0.4; + const leftBoundaryPoints = createRadialBoundaryPoints( + anchorLatitude, + anchorLongitude, + startBearing, + corridorReach, + boundaryAltitude, + ); + const rightBoundaryPoints = createRadialBoundaryPoints( + anchorLatitude, + anchorLongitude, + endBearing, + corridorReach, + boundaryAltitude, + ); + const outerArcPoints = []; + const outerArcSteps = 28; + for (let step = 0; step <= outerArcSteps; step += 1) { + const progress = step / outerArcSteps; + const bearingDeg = startBearing + (endBearing - startBearing) * progress; + const projected = projectLatLon(anchorLatitude, anchorLongitude, bearingDeg, corridorReach); + outerArcPoints.push( + latLonToVector3( + projected.latitude, + projected.longitude, + boundaryAltitude, + ), + ); + } + + const sectorFill = createCoverageSectorMesh( + anchorLatitude, + anchorLongitude, + startBearing, + endBearing, + corridorReach, + fillAltitude, + coverageColor, + 0.12, + ); + sectorFill.renderOrder = 2; + bgpOverlayGroup.add(sectorFill); + overlayItems.push(sectorFill); + + const outerArc = createCoverageBoundaryLine( + outerArcPoints, + coverageColor, + 0.9, + ); + outerArc.renderOrder = 3; + bgpOverlayGroup.add(outerArc); + overlayItems.push(outerArc); + + const leftBoundary = createCoverageBoundaryLine( + leftBoundaryPoints, + coverageColor, + 0.76, + ); + leftBoundary.renderOrder = 3; + bgpOverlayGroup.add(leftBoundary); + overlayItems.push(leftBoundary); + + const rightBoundary = createCoverageBoundaryLine( + rightBoundaryPoints, + coverageColor, + 0.76, + ); + rightBoundary.renderOrder = 3; + bgpOverlayGroup.add(rightBoundary); + overlayItems.push(rightBoundary); + + activeEventOverlay = overlayItems; + activeCollectorOverlayContext = { + marker, + earth, + baseRotation, + lastRebuildAt: performance.now(), + }; + bgpOverlayGroup.visible = showBGP; +} + +export function clearBGPEventOverlay() { + activeEventOverlay = null; + activeCollectorOverlayContext = null; + clearGroup(bgpOverlayGroup); +} + +function updateCollectorOverlayScan(lockedObjectType, lockedObject) { + if ( + lockedObjectType !== "bgp_collector" || + !lockedObject || + !activeCollectorOverlayContext || + activeCollectorOverlayContext.marker !== lockedObject + ) { + return; + } + + const now = performance.now(); + if (now - activeCollectorOverlayContext.lastRebuildAt < COLLECTOR_SCAN_REBUILD_MS) { + return; + } + + const rotationOffsetRad = now * COLLECTOR_SCAN_SPEED_RAD; + showBGPCollectorCoverageOverlay( + activeCollectorOverlayContext.marker, + activeCollectorOverlayContext.earth, + { rotationOffsetRad }, + ); +} + +export function getBGPLegendItems() { + return [ + { color: "#6db7ff", label: "静态观测站" }, + { color: "#fbbf24", label: "中活跃观测站" }, + { color: "#ff5f57", label: "高活跃观测站" }, + { color: "#6db7ff", label: "观测范围示意" }, + { color: "#8af5ff", label: "事件连线 / 枢纽" }, + { color: "#2dd4bf", label: "影响区域" }, + { color: "#ff4d4f", label: "严重事件" }, + { color: "#ff9f43", label: "高危事件" }, + { color: "#ffd166", label: "中危事件" }, + { color: "#4dabf7", label: "低危事件" }, + ]; +} diff --git a/frontend/public/earth/js/cables.js b/frontend/public/earth/js/cables.js index 8bdfa33f..b03cb448 100644 --- a/frontend/public/earth/js/cables.js +++ b/frontend/public/earth/js/cables.js @@ -1,339 +1,506 @@ // cables.js - Cable loading and rendering module -import * as THREE from 'three'; +import * as THREE from "three"; -import { CONFIG, CABLE_COLORS, PATHS, CABLE_STATE } from './constants.js'; -import { latLonToVector3 } from './utils.js'; -import { updateEarthStats, showStatusMessage } from './ui.js'; -import { showInfoCard } from './info-card.js'; +import { + CONFIG, + CABLE_COLORS, + PATHS, + CABLE_STATE, + CABLE_CONFIG, +} from "./constants.js"; +import { latLonToVector3 } from "./utils.js"; +import { updateEarthStats, showStatusMessage } from "./ui.js"; +import { showInfoCard } from "./info-card.js"; +import { setLegendItems, setLegendMode } from "./legend.js"; export let cableLines = []; export let landingPoints = []; export let lockedCable = null; let cableIdMap = new Map(); +let cableStates = new Map(); let cablesVisible = true; +const landingPointWorldPosition = new THREE.Vector3(); + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); +} + +function getLandingPointDistanceScale(point, camera) { + if ( + !point || + !camera || + CABLE_CONFIG.landingPointSizeStabilization?.enabled === false + ) return 1; + point.getWorldPosition(landingPointWorldPosition); + const distanceToCamera = camera.position.distanceTo(landingPointWorldPosition); + const referenceDistance = + CONFIG.defaultCameraZ - + CONFIG.earthRadius + + CABLE_CONFIG.landingPoint.altitudeOffset; + const referenceFovDeg = + CABLE_CONFIG.landingPointSizeStabilization?.referenceFov || 75; + const referenceFovRad = (referenceFovDeg * Math.PI) / 180; + const cameraFovRad = + (((camera.fov || referenceFovDeg)) * Math.PI) / 180; + const worldPerPixel = distanceToCamera * Math.tan(cameraFovRad / 2); + const referenceWorldPerPixel = referenceDistance * Math.tan(referenceFovRad / 2); + return clamp( + worldPerPixel / referenceWorldPerPixel, + CABLE_CONFIG.landingPointSizeStabilization?.min ?? 0.12, + CABLE_CONFIG.landingPointSizeStabilization?.max ?? 3.0, + ); +} + +function disposeMaterial(material) { + if (!material) return; + + if (Array.isArray(material)) { + material.forEach(disposeMaterial); + return; + } + + if (material.map) { + material.map.dispose(); + } + material.dispose(); +} + +function disposeObject(object, parent) { + if (!object) return; + const owner = parent || object.parent; + if (owner) { + owner.remove(object); + } + if (object.geometry) { + object.geometry.dispose(); + } + if (object.material) { + disposeMaterial(object.material); + } +} function getCableColor(properties) { if (properties.color) { - if (typeof properties.color === 'string' && properties.color.startsWith('#')) { + if ( + typeof properties.color === "string" && + properties.color.startsWith("#") + ) { return parseInt(properties.color.substring(1), 16); - } else if (typeof properties.color === 'number') { + } + if (typeof properties.color === "number") { return properties.color; } } - - const cableName = properties.Name || properties.cableName || properties.shortname || ''; - if (cableName.includes('Americas II')) { - return CABLE_COLORS['Americas II']; - } else if (cableName.includes('AU Aleutian A')) { - return CABLE_COLORS['AU Aleutian A']; - } else if (cableName.includes('AU Aleutian B')) { - return CABLE_COLORS['AU Aleutian B']; + + const cableName = + properties.Name || + properties.name || + properties.cableName || + properties.shortname || + ""; + if (cableName.includes("Americas II")) { + return CABLE_COLORS["Americas II"]; } - + if (cableName.includes("AU Aleutian A")) { + return CABLE_COLORS["AU Aleutian A"]; + } + if (cableName.includes("AU Aleutian B")) { + return CABLE_COLORS["AU Aleutian B"]; + } + return CABLE_COLORS.default; } -function createCableLine(points, color, properties, earthObj) { +function createCableLine(points, color, properties) { if (points.length < 2) return null; - + const lineGeometry = new THREE.BufferGeometry().setFromPoints(points); - - const lineMaterial = new THREE.LineBasicMaterial({ - color: color, - linewidth: 1, + lineGeometry.computeBoundingSphere(); + + const lineMaterial = new THREE.LineBasicMaterial({ + color, + linewidth: CABLE_CONFIG.line.lineWidth, transparent: true, - opacity: 1.0, + opacity: CABLE_CONFIG.line.opacity, depthTest: true, - depthWrite: true + depthWrite: true, }); - + const cableLine = new THREE.Line(lineGeometry, lineMaterial); - const cableId = properties.cable_id || properties.id || properties.Name || Math.random().toString(36); + const cableId = + properties.cable_id || + properties.id || + properties.Name || + properties.name || + Math.random().toString(36); cableLine.userData = { - type: 'cable', - cableId: cableId, - name: properties.Name || properties.cableName || 'Unknown', - owner: properties.owner || properties.owners || '-', - status: properties.status || '-', - length: properties.length || '-', - coords: '-', - rfs: properties.rfs || '-', - originalColor: color + type: "cable", + cableId, + name: + properties.Name || + properties.name || + properties.cableName || + properties.shortname || + "Unknown", + owner: properties.owner || properties.owners || "-", + status: properties.status || "-", + length: properties.length || "-", + coords: "-", + rfs: properties.rfs || "-", + originalColor: color, + localCenter: + lineGeometry.boundingSphere?.center?.clone() || new THREE.Vector3(), }; - cableLine.renderOrder = 1; - + cableLine.renderOrder = CABLE_CONFIG.line.renderOrder; + if (!cableIdMap.has(cableId)) { cableIdMap.set(cableId, []); } cableIdMap.get(cableId).push(cableLine); - + return cableLine; } -function calculateGreatCirclePoints(lat1, lon1, lat2, lon2, radius, segments = 50) { +function calculateGreatCirclePoints( + lat1, + lon1, + lat2, + lon2, + radius, + segments = 50, +) { const points = []; - const phi1 = lat1 * Math.PI / 180; - const lambda1 = lon1 * Math.PI / 180; - const phi2 = lat2 * Math.PI / 180; - const lambda2 = lon2 * Math.PI / 180; - - const dLambda = Math.min(Math.abs(lambda2 - lambda1), 2 * Math.PI - Math.abs(lambda2 - lambda1)); - const cosDelta = Math.sin(phi1) * Math.sin(phi2) + Math.cos(phi1) * Math.cos(phi2) * Math.cos(dLambda); - + const phi1 = (lat1 * Math.PI) / 180; + const lambda1 = (lon1 * Math.PI) / 180; + const phi2 = (lat2 * Math.PI) / 180; + const lambda2 = (lon2 * Math.PI) / 180; + + const dLambda = Math.min( + Math.abs(lambda2 - lambda1), + 2 * Math.PI - Math.abs(lambda2 - lambda1), + ); + const cosDelta = + Math.sin(phi1) * Math.sin(phi2) + + Math.cos(phi1) * Math.cos(phi2) * Math.cos(dLambda); + let delta = Math.acos(Math.max(-1, Math.min(1, cosDelta))); - - if (delta < 0.01) { + + if (delta < CABLE_CONFIG.line.nearPointThreshold) { const p1 = latLonToVector3(lat1, lon1, radius); const p2 = latLonToVector3(lat2, lon2, radius); return [p1, p2]; } - + for (let i = 0; i <= segments; i++) { const t = i / segments; const sinDelta = Math.sin(delta); const A = Math.sin((1 - t) * delta) / sinDelta; const B = Math.sin(t * delta) / sinDelta; - + const x1 = Math.cos(phi1) * Math.cos(lambda1); const y1 = Math.cos(phi1) * Math.sin(lambda1); const z1 = Math.sin(phi1); - + const x2 = Math.cos(phi2) * Math.cos(lambda2); const y2 = Math.cos(phi2) * Math.sin(lambda2); const z2 = Math.sin(phi2); - + let x = A * x1 + B * x2; let y = A * y1 + B * y2; let z = A * z1 + B * z2; - - const norm = Math.sqrt(x*x + y*y + z*z); - x = x / norm * radius; - y = y / norm * radius; - z = z / norm * radius; - - const lat = Math.asin(z / radius) * 180 / Math.PI; - let lon = Math.atan2(y, x) * 180 / Math.PI; - + + const norm = Math.sqrt(x * x + y * y + z * z); + x = (x / norm) * radius; + y = (y / norm) * radius; + z = (z / norm) * radius; + + const lat = (Math.asin(z / radius) * 180) / Math.PI; + let lon = (Math.atan2(y, x) * 180) / Math.PI; + if (lon > 180) lon -= 360; if (lon < -180) lon += 360; - - const point = latLonToVector3(lat, lon, radius); - points.push(point); + + points.push(latLonToVector3(lat, lon, radius)); } - + return points; } +export function clearCableLines(earthObj = null) { + cableLines.forEach((line) => disposeObject(line, earthObj)); + cableLines = []; + cableIdMap = new Map(); + cableStates.clear(); +} + +export function clearLandingPoints(earthObj = null) { + landingPoints.forEach((point) => disposeObject(point, earthObj)); + landingPoints = []; +} + +export function clearCableData(earthObj = null) { + clearCableSelection(); + clearCableLines(earthObj); + clearLandingPoints(earthObj); +} + export async function loadGeoJSONFromPath(scene, earthObj) { - try { - console.log('正在加载电缆数据...'); - showStatusMessage('正在加载电缆数据...', 'warning'); - - const response = await fetch(PATHS.cablesApi); - if (!response.ok) { - throw new Error(`HTTP错误: ${response.status}`); - } - - const data = await response.json(); - - cableLines.forEach(line => earthObj.remove(line)); - cableLines = []; - - if (!data.features || !Array.isArray(data.features)) { - throw new Error('无效的GeoJSON格式'); - } - - const cableCount = data.features.length; - document.getElementById('cable-count').textContent = cableCount + '个'; - - const inServiceCount = data.features.filter( - feature => feature.properties && feature.properties.status === 'In Service' - ).length; - - const statusEl = document.getElementById('cable-status-summary'); - if (statusEl) { - statusEl.textContent = `${inServiceCount}/${cableCount} 运行中`; - } - - for (const feature of data.features) { - const geometry = feature.geometry; - const properties = feature.properties || {}; - - if (!geometry || !geometry.coordinates) continue; - - const color = getCableColor(properties); - console.log('电缆 properties:', JSON.stringify(properties)); - - if (geometry.type === 'MultiLineString') { - for (const lineCoords of geometry.coordinates) { - if (!lineCoords || lineCoords.length < 2) continue; - - const points = []; - for (let i = 0; i < lineCoords.length - 1; i++) { - const lon1 = lineCoords[i][0]; - const lat1 = lineCoords[i][1]; - const lon2 = lineCoords[i + 1][0]; - const lat2 = lineCoords[i + 1][1]; - - const segment = calculateGreatCirclePoints(lat1, lon1, lat2, lon2, 100.2, 50); - if (i === 0) { - points.push(...segment); - } else { - points.push(...segment.slice(1)); - } - } - - if (points.length >= 2) { - const line = createCableLine(points, color, properties, earthObj); - if (line) { - cableLines.push(line); - earthObj.add(line); - console.log('添加线缆成功'); - } - } - } - } else if (geometry.type === 'LineString') { - const allCoords = geometry.coordinates; + console.log("正在加载电缆数据..."); + showStatusMessage("正在加载电缆数据...", "warning"); + + const response = await fetch(PATHS.cablesApi); + if (!response.ok) { + throw new Error(`电缆接口返回 HTTP ${response.status}`); + } + + const data = await response.json(); + if (!data.features || !Array.isArray(data.features)) { + throw new Error("无效的电缆 GeoJSON 格式"); + } + + clearCableLines(earthObj); + + for (const feature of data.features) { + const geometry = feature.geometry; + const properties = feature.properties || {}; + + if (!geometry || !geometry.coordinates) continue; + + const color = getCableColor(properties); + + if (geometry.type === "MultiLineString") { + for (const lineCoords of geometry.coordinates) { + if (!lineCoords || lineCoords.length < 2) continue; + const points = []; - - for (let i = 0; i < allCoords.length - 1; i++) { - const lon1 = allCoords[i][0]; - const lat1 = allCoords[i][1]; - const lon2 = allCoords[i + 1][0]; - const lat2 = allCoords[i + 1][1]; - - const segment = calculateGreatCirclePoints(lat1, lon1, lat2, lon2, 100.2, 50); - if (i === 0) { - points.push(...segment); - } else { - points.push(...segment.slice(1)); - } + for (let i = 0; i < lineCoords.length - 1; i++) { + const lon1 = lineCoords[i][0]; + const lat1 = lineCoords[i][1]; + const lon2 = lineCoords[i + 1][0]; + const lat2 = lineCoords[i + 1][1]; + + const segment = calculateGreatCirclePoints( + lat1, + lon1, + lat2, + lon2, + CONFIG.earthRadius + CABLE_CONFIG.line.altitudeOffset, + CABLE_CONFIG.line.greatCircleSegments, + ); + points.push(...(i === 0 ? segment : segment.slice(1))); } - - if (points.length >= 2) { - const line = createCableLine(points, color, properties, earthObj); - if (line) { - cableLines.push(line); - earthObj.add(line); - } + + const line = createCableLine(points, color, properties); + if (line) { + cableLines.push(line); + earthObj.add(line); } } + } else if (geometry.type === "LineString") { + const points = []; + for (let i = 0; i < geometry.coordinates.length - 1; i++) { + const lon1 = geometry.coordinates[i][0]; + const lat1 = geometry.coordinates[i][1]; + const lon2 = geometry.coordinates[i + 1][0]; + const lat2 = geometry.coordinates[i + 1][1]; + + const segment = calculateGreatCirclePoints( + lat1, + lon1, + lat2, + lon2, + CONFIG.earthRadius + CABLE_CONFIG.line.altitudeOffset, + CABLE_CONFIG.line.greatCircleSegments, + ); + points.push(...(i === 0 ? segment : segment.slice(1))); + } + + const line = createCableLine(points, color, properties); + if (line) { + cableLines.push(line); + earthObj.add(line); + } } - - updateEarthStats({ - cableCount: cableLines.length, - landingPointCount: landingPoints.length, - terrainOn: false, - textureQuality: '8K 卫星图' - }); - - showStatusMessage(`成功加载 ${cableLines.length} 条电缆`, 'success'); - document.getElementById('loading').style.display = 'none'; - - } catch (error) { - console.error('加载电缆数据失败:', error); - showStatusMessage('加载电缆数据失败: ' + error.message, 'error'); } + + const cableCount = data.features.length; + const inServiceCount = data.features.filter( + (feature) => + feature.properties && feature.properties.status === "In Service", + ).length; + + const cableCountEl = document.getElementById("cable-count"); + const statusEl = document.getElementById("cable-status-summary"); + if (cableCountEl) cableCountEl.textContent = cableCount + "个"; + if (statusEl) statusEl.textContent = `${inServiceCount}/${cableCount} 运行中`; + + updateEarthStats({ + cableCount: cableLines.length, + landingPointCount: landingPoints.length, + terrainOn: false, + textureQuality: "8K 卫星图", + }); + + showStatusMessage(`成功加载 ${cableLines.length} 条电缆`, "success"); + return cableLines.length; } export async function loadLandingPoints(scene, earthObj) { + console.log("正在加载登陆点数据..."); + + const response = await fetch(PATHS.landingPointsApi); + if (!response.ok) { + throw new Error(`登陆点接口返回 HTTP ${response.status}`); + } + + const data = await response.json(); + if (!data.features || !Array.isArray(data.features)) { + throw new Error("无效的登陆点 GeoJSON 格式"); + } + + clearLandingPoints(earthObj); + + const sphereGeometry = new THREE.SphereGeometry( + CABLE_CONFIG.landingPoint.radius, + CABLE_CONFIG.landingPoint.widthSegments, + CABLE_CONFIG.landingPoint.heightSegments, + ); + let validCount = 0; + try { - console.log('正在加载登陆点数据...'); - - const response = await fetch(PATHS.landingPointsApi); - if (!response.ok) { - console.error('HTTP错误:', response.status); - return; - } - - const data = await response.json(); - - if (!data.features || !Array.isArray(data.features)) { - console.error('无效的GeoJSON格式'); - return; - } - - landingPoints = []; - let validCount = 0; - - const sphereGeometry = new THREE.SphereGeometry(0.4, 16, 16); - const sphereMaterial = new THREE.MeshStandardMaterial({ - color: 0xffaa00, - emissive: 0x442200, - emissiveIntensity: 0.5 - }); - for (const feature of data.features) { if (!feature.geometry || !feature.geometry.coordinates) continue; - + const [lon, lat] = feature.geometry.coordinates; const properties = feature.properties || {}; - - if (typeof lon !== 'number' || typeof lat !== 'number' || - isNaN(lon) || isNaN(lat) || - Math.abs(lat) > 90 || Math.abs(lon) > 180) { + + if ( + typeof lon !== "number" || + typeof lat !== "number" || + Number.isNaN(lon) || + Number.isNaN(lat) || + Math.abs(lat) > 90 || + Math.abs(lon) > 180 + ) { continue; } - - const position = latLonToVector3(lat, lon, 100.1); - - if (isNaN(position.x) || isNaN(position.y) || isNaN(position.z)) { + + const position = latLonToVector3( + lat, + lon, + CONFIG.earthRadius + CABLE_CONFIG.landingPoint.altitudeOffset, + ); + if ( + Number.isNaN(position.x) || + Number.isNaN(position.y) || + Number.isNaN(position.z) + ) { continue; } - - const sphere = new THREE.Mesh(sphereGeometry, sphereMaterial.clone()); + + const sphere = new THREE.Mesh( + sphereGeometry.clone(), + new THREE.MeshStandardMaterial({ + color: CABLE_CONFIG.landingPoint.color, + emissive: CABLE_CONFIG.landingPoint.emissive, + emissiveIntensity: CABLE_CONFIG.landingPoint.emissiveIntensity, + transparent: true, + opacity: CABLE_CONFIG.landingPoint.opacity, + }), + ); sphere.position.copy(position); sphere.userData = { - type: 'landingPoint', - name: properties.name || '未知登陆站', + type: "landingPoint", + name: properties.name || "未知登陆站", cableNames: properties.cable_names || [], - country: properties.country || '未知国家', - status: properties.status || 'Unknown' + country: properties.country || "未知国家", + status: properties.status || "Unknown", + baseScale: CABLE_CONFIG.landingPoint.baseScale, }; - + earthObj.add(sphere); landingPoints.push(sphere); validCount++; } - - console.log(`成功创建 ${validCount} 个登陆点标记`); - showStatusMessage(`成功加载 ${validCount} 个登陆点`, 'success'); - - const lpCountEl = document.getElementById('landing-point-count'); - if (lpCountEl) { - lpCountEl.textContent = validCount + '个'; - } - - } catch (error) { - console.error('加载登陆点数据失败:', error); + } finally { + sphereGeometry.dispose(); } + + const landingPointCountEl = document.getElementById("landing-point-count"); + if (landingPointCountEl) { + landingPointCountEl.textContent = validCount + "个"; + } + + showStatusMessage(`成功加载 ${validCount} 个登陆点`, "success"); + return validCount; } export function handleCableClick(cable) { lockedCable = cable; - + setLegendItems("cables", getCableLegendItems()); + const data = cable.userData; - showInfoCard('cable', { + setLegendMode("cables"); + showInfoCard("cable", { name: data.name, owner: data.owner, status: data.status, length: data.length, coords: data.coords, - rfs: data.rfs + rfs: data.rfs, }); - - showStatusMessage(`已锁定: ${data.name}`, 'info'); + + showStatusMessage(`已锁定: ${data.name}`, "info"); } export function clearCableSelection() { lockedCable = null; + setLegendItems("cables", getCableLegendItems()); } export function getCableLines() { return cableLines; } +export function getCableLegendItems() { + const legendMap = new Map(); + + cableLines.forEach((cable) => { + const color = cable.userData?.originalColor; + const label = cable.userData?.name || "未知线缆"; + + if (typeof color === "number" && !legendMap.has(label)) { + legendMap.set(label, { + label, + color: `#${color.toString(16).padStart(6, "0")}`, + }); + } + }); + + if (legendMap.size === 0) { + return [{ label: "其他电缆", color: "#ffff44" }]; + } + + const items = Array.from(legendMap.values()).sort((a, b) => + a.label.localeCompare(b.label, "zh-CN"), + ); + + const selectedName = lockedCable?.userData?.name; + if (!selectedName) { + return items; + } + + const selectedIndex = items.findIndex((item) => item.label === selectedName); + if (selectedIndex <= 0) { + return items; + } + + const [selectedItem] = items.splice(selectedIndex, 1); + items.unshift(selectedItem); + return items; +} + export function getCablesById(cableId) { return cableIdMap.get(cableId) || []; } @@ -342,8 +509,6 @@ export function getLandingPoints() { return landingPoints; } -const cableStates = new Map(); - export function getCableState(cableId) { return cableStates.get(cableId) || CABLE_STATE.NORMAL; } @@ -365,55 +530,83 @@ export function getCableStateInfo() { } export function getLandingPointsByCableName(cableName) { - return landingPoints.filter(lp => lp.userData.cableNames?.includes(cableName)); + return landingPoints.filter((lp) => + lp.userData.cableNames?.includes(cableName), + ); } export function getAllLandingPoints() { return landingPoints; } -export function applyLandingPointVisualState(lockedCableName, dimAll = false) { - const pulse = (Math.sin(Date.now() * 0.003) + 1) * 0.5; - const brightness = 0.3; - - landingPoints.forEach(lp => { - const isRelated = !dimAll && lp.userData.cableNames?.includes(lockedCableName); - +export function applyLandingPointVisualState(lockedCableName, dimAll = false, camera = null) { + const pulse = + (Math.sin(Date.now() * CABLE_CONFIG.landingPointVisual.pulseSpeed) + 1) * 0.5; + const brightness = CABLE_CONFIG.landingPointVisual.dimBrightness; + const relatedNames = Array.isArray(lockedCableName) + ? lockedCableName.filter(Boolean) + : lockedCableName + ? [lockedCableName] + : []; + + landingPoints.forEach((lp) => { + const isRelated = + !dimAll && + Array.isArray(lp.userData.cableNames) && + lp.userData.cableNames.some((name) => relatedNames.includes(name)); + if (isRelated) { - lp.material.color.setHex(0xffaa00); - lp.material.emissive.setHex(0x442200); - lp.material.emissiveIntensity = 0.5 + pulse * 0.5; - lp.material.opacity = 0.8 + pulse * 0.2; - lp.scale.setScalar(1.2 + pulse * 0.3); + lp.material.color.setHex(CABLE_CONFIG.landingPoint.color); + lp.material.emissive.setHex(CABLE_CONFIG.landingPoint.emissive); + lp.material.emissiveIntensity = + CABLE_CONFIG.landingPointVisual.related.emissiveIntensityBase + + pulse * CABLE_CONFIG.landingPointVisual.related.emissiveIntensityPulse; + lp.material.opacity = + CABLE_CONFIG.landingPointVisual.related.opacityBase + + pulse * CABLE_CONFIG.landingPointVisual.related.opacityPulse; + const distanceScale = getLandingPointDistanceScale(lp, camera); + const baseScale = lp.userData?.baseScale || CABLE_CONFIG.landingPoint.baseScale; + lp.scale.setScalar( + (CABLE_CONFIG.landingPointVisual.related.scaleBase + + pulse * CABLE_CONFIG.landingPointVisual.related.scalePulse) * + baseScale * + distanceScale, + ); } else { - const r = 255 * brightness; - const g = 170 * brightness; - const b = 0 * brightness; + const dimColor = CABLE_CONFIG.landingPointVisual.dimmed.colorRGB; + const r = dimColor.r * brightness; + const g = dimColor.g * brightness; + const b = dimColor.b * brightness; lp.material.color.setRGB(r / 255, g / 255, b / 255); - lp.material.emissive.setHex(0x000000); - lp.material.emissiveIntensity = 0; - lp.material.opacity = 0.3; - lp.scale.setScalar(1.0); + lp.material.emissive.setHex(CABLE_CONFIG.landingPointVisual.dimmed.emissive); + lp.material.emissiveIntensity = + CABLE_CONFIG.landingPointVisual.dimmed.emissiveIntensity; + lp.material.opacity = CABLE_CONFIG.landingPointVisual.dimmed.opacity; + const distanceScale = getLandingPointDistanceScale(lp, camera); + const baseScale = lp.userData?.baseScale || CABLE_CONFIG.landingPoint.baseScale; + lp.scale.setScalar(baseScale * distanceScale); } }); } -export function resetLandingPointVisualState() { - landingPoints.forEach(lp => { - lp.material.color.setHex(0xffaa00); - lp.material.emissive.setHex(0x442200); - lp.material.emissiveIntensity = 0.5; - lp.material.opacity = 1.0; - lp.scale.setScalar(1.0); +export function resetLandingPointVisualState(camera = null) { + landingPoints.forEach((lp) => { + lp.material.color.setHex(CABLE_CONFIG.landingPoint.color); + lp.material.emissive.setHex(CABLE_CONFIG.landingPoint.emissive); + lp.material.emissiveIntensity = CABLE_CONFIG.landingPoint.emissiveIntensity; + lp.material.opacity = CABLE_CONFIG.landingPoint.opacity; + const distanceScale = getLandingPointDistanceScale(lp, camera); + const baseScale = lp.userData?.baseScale || CABLE_CONFIG.landingPoint.baseScale; + lp.scale.setScalar(baseScale * distanceScale); }); } export function toggleCables(show) { cablesVisible = show; - cableLines.forEach(cable => { + cableLines.forEach((cable) => { cable.visible = cablesVisible; }); - landingPoints.forEach(lp => { + landingPoints.forEach((lp) => { lp.visible = cablesVisible; }); } diff --git a/frontend/public/earth/js/constants.js b/frontend/public/earth/js/constants.js index fbb027a7..05008c6b 100644 --- a/frontend/public/earth/js/constants.js +++ b/frontend/public/earth/js/constants.js @@ -7,6 +7,9 @@ export const CONFIG = { maxZoom: 5.0, earthRadius: 100, rotationSpeed: 0.0005, + dragRotationFactorBase: 0.005, + dragRotationScaleMin: 0.28, + dragRotationScaleMax: 2.0, }; // Earth coordinate constants @@ -26,8 +29,9 @@ export const EARTH_CONFIG = { export const PATHS = { cablesApi: '/api/v1/visualization/geo/cables', landingPointsApi: '/api/v1/visualization/geo/landing-points', - geoJSON: './geo.json', - landingPointsStatic: './landing-point-geo.geojson', + bgpApi: '/api/v1/visualization/geo/bgp-anomalies', + bgpIncidentsApi: '/api/v1/visualization/geo/bgp-incidents', + bgpCollectorsApi: '/api/v1/visualization/geo/bgp-collectors', }; // Cable colors mapping @@ -44,7 +48,50 @@ export const CABLE_CONFIG = { otherOpacity: 0.5, otherBrightness: 0.6, pulseSpeed: 0.008, - pulseCoefficient: 0.4 + pulseCoefficient: 0.4, + line: { + altitudeOffset: 0.2, + greatCircleSegments: 50, + nearPointThreshold: 0.01, + lineWidth: 1, + opacity: 1.0, + renderOrder: 1, + }, + landingPoint: { + altitudeOffset: 0.1, + radius: 0.4, + widthSegments: 16, + heightSegments: 16, + baseScale: 2.5, + color: 0xffaa00, + emissive: 0x442200, + emissiveIntensity: 0.5, + opacity: 1.0, + }, + landingPointSizeStabilization: { + enabled: true, + referenceFov: 75, + min: 0.12, + max: 3.0, + }, + landingPointVisual: { + pulseSpeed: 0.003, + dimBrightness: 0.3, + related: { + emissiveIntensityBase: 0.5, + emissiveIntensityPulse: 0.5, + opacityBase: 0.8, + opacityPulse: 0.2, + scaleBase: 1.2, + scalePulse: 0.3, + }, + dimmed: { + colorRGB: { r: 255, g: 170, b: 0 }, + emissive: 0x000000, + emissiveIntensity: 0, + opacity: 0.3, + }, + }, }; export const CABLE_STATE = { @@ -54,7 +101,7 @@ export const CABLE_STATE = { }; export const SATELLITE_CONFIG = { - maxCount: 5000, + maxCount: -1, trailLength: 10, dotSize: 4, ringSize: 0.07, @@ -69,6 +116,94 @@ export const SATELLITE_CONFIG = { dotOpacityMax: 1.0 }; +export const BGP_CONFIG = { + defaultFetchLimit: 200, + maxRenderedMarkers: 200, + altitudeOffset: 2.1, + collectorAltitudeOffset: 1.6, + marker: { + eventBaseScale: 6.2, + collectorBaseScale: 7.4, + hoverScale: 1.16, + dimmedScale: 0.92, + collectorStatusCoreBaseScale: 0.12, + collectorStatusCoreMinScale: 1.2, + }, + pulse: { + eventSpeed: 0.0045, + collectorSpeed: 0.0024, + normalAmplitude: 0.03, + lockedAmplitude: 0.16, + }, + regionScale: 11.5, + opacity: { + normal: 0.78, + hover: 1.0, + dimmed: 0.24, + collector: 0.62, + collectorHover: 0.9, + lockedMin: 0.65, + lockedMax: 1.0 + }, + severityColors: { + critical: 0xff4d4f, + high: 0xff9f43, + medium: 0xffd166, + low: 0x4dabf7 + }, + severityScales: { + critical: 1.18, + high: 1.08, + medium: 1.0, + low: 0.94 + }, + collectorColor: 0x6db7ff, + collectorHeatColors: { + idle: 0x6db7ff, + low: 0x60a5fa, + medium: 0xfbbf24, + high: 0xfb923c, + hot: 0xff5f57 + }, + collectorIcon: { + ringStroke: "rgba(111, 160, 197, 0.34)", + ringLineWidth: 1.2, + ringRadius: 22, + pathStroke: "rgba(64, 106, 136, 0.74)", + pathLineWidth: 0.9, + pathFill: "rgba(214,224,233,0.88)", + centerFill: "rgba(222,231,239,0.72)", + centerRadius: 0.85, + idleBaseColor: 0xb7c4cf, + idleBlend: 0.56, + idleOpacity: 0.74, + hoverBlend: 0.42, + hoverNeutralColor: 0xc8d5e0, + lockedBlend: 0.48, + lockedNeutralColor: 0xd8e4ed, + dimmedColor: 0x7d8ca3, + }, + regionColor: 0x2dd4bf, + ring: { + scaleA: 2.5, + scaleB: 3.4, + opacity: 0.5, + speed: 0.001, + }, + halo: { + collectorScale: 11.5, + collectorPulseScale: 16.5, + collectorCoverageScale: 22.5, + }, + sizeStabilization: { + enabled: true, + collectorMin: 0.12, + collectorMax: 3.0, + eventMin: 0.12, + eventMax: 3.0, + }, +}; + export const PREDICTED_ORBIT_CONFIG = { sampleInterval: 10, opacity: 0.8 diff --git a/frontend/public/earth/js/controls.js b/frontend/public/earth/js/controls.js index 6ef89950..ea07bf13 100644 --- a/frontend/public/earth/js/controls.js +++ b/frontend/public/earth/js/controls.js @@ -1,25 +1,129 @@ // 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'; +import { CONFIG, EARTH_CONFIG } from "./constants.js"; +import { updateZoomDisplay, showStatusMessage } from "./ui.js"; +import { toggleTerrain } from "./earth.js"; +import { + reloadData, + clearLockedObject, + clearLockedObjectAndInfo, + setCablesEnabled, + setSatellitesEnabled, +} from "./main.js"; +import { + toggleTrails, + getShowSatellites, + getSatelliteCount, +} from "./satellites.js"; +import { getShowCables } from "./cables.js"; +import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.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 getFloatingGroups() { + return [ + document.getElementById("zoom-control-group"), + document.getElementById("info-control-group"), + ].filter(Boolean); +} + +function isFloatingMenuVisible() { + return getFloatingGroups().some((group) => { + return ( + group.classList.contains("open") || + group.matches(":hover") || + group.matches(":focus-within") + ); + }); +} + +function closeFloatingMenus() { + getFloatingGroups().forEach((group) => { + group.classList.remove("open"); + group.classList.add("force-closed"); + }); + + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } +} + +function clearForcedFloatingClose() { + getFloatingGroups().forEach((group) => { + group.classList.remove("force-closed"); + }); +} + +function clearForcedFloatingCloseIfPointerOutside() { + getFloatingGroups().forEach((group) => { + if (!group.matches(":hover")) { + group.classList.remove("force-closed"); + } + }); +} + +function setFloatingMenuOpen(group, shouldOpen) { + closeFloatingMenus(); + clearForcedFloatingClose(); + group?.classList.toggle("open", shouldOpen); +} + +function setButtonTooltip(button, text) { + const tooltip = button?.querySelector(".tooltip"); + if (tooltip) { + tooltip.textContent = text; + } +} + +function clearSelectionIfHiding(shouldHide) { + if (shouldHide) { + clearLockedObject(); + } +} + +function bindFloatingMenu(trigger, group) { + bindListener(trigger, "click", (event) => { + event.stopPropagation(); + const shouldOpen = !group?.classList.contains("open"); + setFloatingMenuOpen(group, shouldOpen); + }); + + bindListener(group, "click", (event) => { + event.stopPropagation(); + }); +} + +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(); + setupLiquidGlassInteractions(); + setupKeyboardControls(); } function setupZoomControls(camera) { @@ -29,39 +133,40 @@ function setupZoomControls(camera) { 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; - + 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 = setInterval(() => { + zoomInterval = window.setInterval(() => { doContinuousZoom(direction); }, LONG_PRESS_TICK); } - + function stopZoom() { if (zoomInterval) { clearInterval(zoomInterval); @@ -72,15 +177,15 @@ function setupZoomControls(camera) { holdTimeout = null; } } - + function handleMouseDown(direction) { startTime = Date.now(); stopZoom(); - holdTimeout = setTimeout(() => { + holdTimeout = window.setTimeout(() => { startContinuousZoom(direction); }, HOLD_THRESHOLD); } - + function handleMouseUp(direction) { const heldTime = Date.now() - startTime; stopZoom(); @@ -88,48 +193,72 @@ function setupZoomControls(camera) { doZoomStep(direction); } } - - document.getElementById('zoom-in').addEventListener('mousedown', () => handleMouseDown(1)); - document.getElementById('zoom-in').addEventListener('mouseup', () => handleMouseUp(1)); - document.getElementById('zoom-in').addEventListener('mouseleave', stopZoom); - document.getElementById('zoom-in').addEventListener('touchstart', (e) => { e.preventDefault(); handleMouseDown(1); }); - document.getElementById('zoom-in').addEventListener('touchend', () => handleMouseUp(1)); - - document.getElementById('zoom-out').addEventListener('mousedown', () => handleMouseDown(-1)); - document.getElementById('zoom-out').addEventListener('mouseup', () => handleMouseUp(-1)); - document.getElementById('zoom-out').addEventListener('mouseleave', stopZoom); - document.getElementById('zoom-out').addEventListener('touchstart', (e) => { e.preventDefault(); handleMouseDown(-1); }); - document.getElementById('zoom-out').addEventListener('touchend', () => handleMouseUp(-1)); - - document.getElementById('zoom-value').addEventListener('click', function() { + + 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'); - }); + + 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) { - renderer.domElement.addEventListener('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 }); + 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) { @@ -140,136 +269,292 @@ function applyZoom(camera) { 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 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'); - }); + + 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 } + (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); - } - - if (typeof window.clearLockedCable === 'function') { - window.clearLockedCable(); + animateToView( + EARTH_CONFIG.chinaLat, + EARTH_CONFIG.chinaLon, + EARTH_CONFIG.chinaRotLon, + ); } + + clearLockedObject(); } -function setupRotateControls(camera, earth) { - const rotateBtn = document.getElementById('rotate-toggle'); - - rotateBtn.addEventListener('click', function() { +function setupRotateControls(camera) { + const rotateBtn = document.getElementById("rotate-toggle"); + const resetViewBtn = document.getElementById("reset-view"); + + bindListener(rotateBtn, "click", () => { const isRotating = toggleAutoRotate(); - showStatusMessage(isRotating ? '自动旋转已开启' : '自动旋转已暂停', 'info'); + showStatusMessage(isRotating ? "自动旋转已开启" : "自动旋转已暂停", "info"); }); - + updateRotateUI(); - - document.getElementById('reset-view').addEventListener('click', function() { + + bindListener(resetViewBtn, "click", () => { resetView(camera); }); } function setupTerrainControls() { - document.getElementById('toggle-terrain').addEventListener('click', function() { + const container = document.getElementById("container"); + const searchBtn = document.getElementById("search-action"); + const infoGroup = document.getElementById("info-control-group"); + const infoTrigger = document.getElementById("info-trigger"); + const terrainBtn = document.getElementById("toggle-terrain"); + const satellitesBtn = document.getElementById("toggle-satellites"); + const bgpBtn = document.getElementById("toggle-bgp"); + const trailsBtn = document.getElementById("toggle-trails"); + const cablesBtn = document.getElementById("toggle-cables"); + const layoutBtn = document.getElementById("layout-toggle"); + const reloadBtn = document.getElementById("reload-data"); + const zoomGroup = document.getElementById("zoom-control-group"); + const zoomTrigger = document.getElementById("zoom-trigger"); + + if (trailsBtn) { + trailsBtn.classList.add("active"); + setButtonTooltip(trailsBtn, "隐藏轨迹"); + } + + bindListener(searchBtn, "click", () => { + showStatusMessage("搜索功能待开发", "info"); + }); + + bindListener(terrainBtn, "click", function () { showTerrain = !showTerrain; toggleTerrain(showTerrain); - this.classList.toggle('active', showTerrain); - this.querySelector('.tooltip').textContent = showTerrain ? '隐藏地形' : '显示地形'; - document.getElementById('terrain-status').textContent = showTerrain ? '开启' : '关闭'; - showStatusMessage(showTerrain ? '地形已显示' : '地形已隐藏', 'info'); + this.classList.toggle("active", showTerrain); + setButtonTooltip(this, showTerrain ? "隐藏地形" : "显示地形"); + const terrainStatus = document.getElementById("terrain-status"); + if (terrainStatus) + terrainStatus.textContent = showTerrain ? "开启" : "关闭"; + showStatusMessage(showTerrain ? "地形已显示" : "地形已隐藏", "info"); }); - - document.getElementById('toggle-satellites').addEventListener('click', function() { + + bindListener(satellitesBtn, "click", async function () { const showSats = !getShowSatellites(); - if (!showSats) { - clearLockedObject(); + clearSelectionIfHiding(!showSats); + try { + await setSatellitesEnabled(showSats); + if (!showSats) { + showStatusMessage("卫星已隐藏", "info"); + } else { + const satelliteCountEl = document.getElementById("satellite-count"); + if (satelliteCountEl) { + satelliteCountEl.textContent = `${getSatelliteCount()} 颗`; + } + } + } catch (error) { + console.error("切换卫星显示失败:", error); } - toggleSatellites(showSats); - this.classList.toggle('active', showSats); - this.querySelector('.tooltip').textContent = showSats ? '隐藏卫星' : '显示卫星'; - document.getElementById('satellite-count').textContent = getSatelliteCount() + ' 颗'; - showStatusMessage(showSats ? '卫星已显示' : '卫星已隐藏', 'info'); }); - - document.getElementById('toggle-trails').addEventListener('click', function() { - const isActive = this.classList.contains('active'); - const showTrails = !isActive; - toggleTrails(showTrails); - this.classList.toggle('active', showTrails); - this.querySelector('.tooltip').textContent = showTrails ? '隐藏轨迹' : '显示轨迹'; - showStatusMessage(showTrails ? '轨迹已显示' : '轨迹已隐藏', 'info'); - }); - - document.getElementById('toggle-cables').addEventListener('click', function() { - const showCables = !getShowCables(); - if (!showCables) { - clearLockedObject(); + + bindListener(bgpBtn, "click", function () { + const showNextBGP = !getShowBGP(); + clearSelectionIfHiding(!showNextBGP); + toggleBGP(showNextBGP); + this.classList.toggle("active", showNextBGP); + setButtonTooltip(this, showNextBGP ? "隐藏BGP观测" : "显示BGP观测"); + const bgpCountEl = document.getElementById("bgp-anomaly-count"); + if (bgpCountEl) { + bgpCountEl.textContent = `${getBGPCount()} 条`; } - toggleCables(showCables); - this.classList.toggle('active', showCables); - this.querySelector('.tooltip').textContent = showCables ? '隐藏线缆' : '显示线缆'; - showStatusMessage(showCables ? '线缆已显示' : '线缆已隐藏', 'info'); + showStatusMessage(showNextBGP ? "BGP观测已显示" : "BGP观测已隐藏", "info"); }); - - document.getElementById('reload-data').addEventListener('click', async () => { + + bindListener(trailsBtn, "click", function () { + const isActive = this.classList.contains("active"); + const nextShowTrails = !isActive; + toggleTrails(nextShowTrails); + this.classList.toggle("active", nextShowTrails); + setButtonTooltip(this, nextShowTrails ? "隐藏轨迹" : "显示轨迹"); + showStatusMessage(nextShowTrails ? "轨迹已显示" : "轨迹已隐藏", "info"); + }); + + bindListener(cablesBtn, "click", async function () { + const showNextCables = !getShowCables(); + clearSelectionIfHiding(!showNextCables); + try { + await setCablesEnabled(showNextCables); + } catch (error) { + console.error("切换线缆显示失败:", error); + } + }); + + bindListener(reloadBtn, "click", async () => { await reloadData(); - showStatusMessage('数据已重新加载', 'success'); }); - - const toolbarToggle = document.getElementById('toolbar-toggle'); - const toolbar = document.getElementById('control-toolbar'); - if (toolbarToggle && toolbar) { - toolbarToggle.addEventListener('click', () => { - toolbar.classList.toggle('collapsed'); + + bindFloatingMenu(zoomTrigger, zoomGroup); + bindFloatingMenu(infoTrigger, infoGroup); + + bindListener(document, "click", (event) => { + const openGroups = [zoomGroup, infoGroup].filter((group) => + group?.classList.contains("open"), + ); + if (openGroups.length === 0) return; + + const clickedInsideOpenGroup = openGroups.some((group) => + group.contains(event.target), + ); + if (!clickedInsideOpenGroup) { + closeFloatingMenus(); + } + }); + + bindListener(document, "mousemove", () => { + clearForcedFloatingCloseIfPointerOutside(); + }); + + bindListener(layoutBtn, "click", () => { + const expanded = toggleLayoutExpanded(container); + showStatusMessage(expanded ? "布局已最大化" : "布局已恢复", "info"); + }); + + updateLayoutUI(container); +} + +function setupKeyboardControls() { + bindListener(document, "keydown", (event) => { + if (event.key !== "Escape") return; + + if (isFloatingMenuVisible()) { + closeFloatingMenus(); + return; + } + + clearLockedObjectAndInfo(); + }); +} + +function setupLiquidGlassInteractions() { + const surfaces = document.querySelectorAll(".liquid-glass-surface"); + + const resetSurface = (surface) => { + surface.style.setProperty("--elastic-x", "0px"); + surface.style.setProperty("--elastic-y", "0px"); + surface.style.setProperty("--tilt-x", "0deg"); + surface.style.setProperty("--tilt-y", "0deg"); + surface.style.setProperty("--glow-x", "50%"); + surface.style.setProperty("--glow-y", "22%"); + surface.style.setProperty("--glow-opacity", "0.24"); + surface.classList.remove("is-pressed"); + }; + + surfaces.forEach((surface) => { + resetSurface(surface); + + bindListener(surface, "pointermove", (event) => { + const rect = surface.getBoundingClientRect(); + const px = (event.clientX - rect.left) / rect.width; + const py = (event.clientY - rect.top) / rect.height; + const offsetX = (px - 0.5) * 6; + const offsetY = (py - 0.5) * 6; + const tiltX = (0.5 - py) * 8; + const tiltY = (px - 0.5) * 10; + + surface.style.setProperty("--elastic-x", `${offsetX.toFixed(2)}px`); + surface.style.setProperty("--elastic-y", `${offsetY.toFixed(2)}px`); + surface.style.setProperty("--tilt-x", `${tiltX.toFixed(2)}deg`); + surface.style.setProperty("--tilt-y", `${tiltY.toFixed(2)}deg`); + surface.style.setProperty("--glow-x", `${(px * 100).toFixed(1)}%`); + surface.style.setProperty("--glow-y", `${(py * 100).toFixed(1)}%`); + surface.style.setProperty("--glow-opacity", "0.34"); }); - } + + bindListener(surface, "pointerenter", () => { + surface.style.setProperty("--glow-opacity", "0.28"); + }); + + bindListener(surface, "pointerleave", () => { + resetSurface(surface); + }); + + bindListener(surface, "pointerdown", () => { + surface.classList.add("is-pressed"); + }); + + bindListener(surface, "pointerup", () => { + surface.classList.remove("is-pressed"); + }); + + bindListener(surface, "pointercancel", () => { + resetSurface(surface); + }); + }); +} + +export function teardownControls() { + resetCleanup(); } export function getAutoRotate() { @@ -277,12 +562,12 @@ export function getAutoRotate() { } function updateRotateUI() { - const btn = document.getElementById('rotate-toggle'); + const btn = document.getElementById("rotate-toggle"); if (btn) { - btn.classList.toggle('active', autoRotate); - btn.innerHTML = autoRotate ? '⏸️' : '▶️'; - const tooltip = btn.querySelector('.tooltip'); - if (tooltip) tooltip.textContent = autoRotate ? '暂停旋转' : '开始旋转'; + btn.classList.toggle("active", autoRotate); + btn.classList.toggle("is-stopped", !autoRotate); + const tooltip = btn.querySelector(".tooltip"); + if (tooltip) tooltip.textContent = autoRotate ? "暂停旋转" : "开始旋转"; } } @@ -294,9 +579,7 @@ export function setAutoRotate(value) { export function toggleAutoRotate() { autoRotate = !autoRotate; updateRotateUI(); - if (window.clearLockedCable) { - window.clearLockedCable(); - } + clearLockedObject(); return autoRotate; } @@ -307,3 +590,24 @@ export function getZoomLevel() { 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; +} diff --git a/frontend/public/earth/js/earth.js b/frontend/public/earth/js/earth.js index f30934d9..2522eb48 100644 --- a/frontend/public/earth/js/earth.js +++ b/frontend/public/earth/js/earth.js @@ -104,7 +104,7 @@ export function createClouds(scene, earthObj) { earthObj.add(clouds); textureLoader.load( - 'https://threejs.org/examples/textures/planets/earth_clouds_1024.png', + './assets/earth_clouds_1024.png', function(texture) { material.map = texture; material.needsUpdate = true; diff --git a/frontend/public/earth/js/info-card.js b/frontend/public/earth/js/info-card.js index e7281862..43d89247 100644 --- a/frontend/public/earth/js/info-card.js +++ b/frontend/public/earth/js/info-card.js @@ -1,4 +1,5 @@ // info-card.js - Unified info card module +import { showStatusMessage } from './ui.js'; let currentType = null; @@ -29,6 +30,52 @@ const CARD_CONFIG = { { key: 'apogee', label: '远地点', unit: 'km' } ] }, + bgp: { + icon: '📡', + title: 'BGP事件详情', + className: 'bgp', + fields: [ + { key: 'anomaly_type', label: '事件类型' }, + { key: 'severity', label: '严重度' }, + { key: 'status', label: '状态' }, + { key: 'route_change', label: '事件特征' }, + { key: 'prefix', label: '前缀' }, + { key: 'as_path_display', label: '传播路径' }, + { key: 'origin_asn', label: '涉及 ASN' }, + { key: 'new_origin_asn', label: '关联 ASN' }, + { key: 'confidence', label: '置信度' }, + { key: 'collector', label: '主观测站' }, + { key: 'observed_by', label: '观测范围' }, + { key: 'impacted_scope', label: '影响区域' }, + { key: 'related_cables', label: '附近基础设施' }, + { key: 'related_satellites', label: '附近卫星' }, + { key: 'location', label: '观测位置' }, + { key: 'created_at', label: '事件时间' }, + { key: 'summary', label: '摘要' } + ] + }, + bgp_collector: { + icon: '📍', + title: 'BGP观测站详情', + className: 'bgp', + fields: [ + { key: 'collector', label: '采集器' }, + { key: 'location', label: '观测位置' }, + { key: 'anomaly_count', label: '当前事件数' }, + { key: 'observation_count', label: '观测事件数' }, + { key: 'recent_24h_observation_count', label: '近24h事件数' }, + { key: 'recent_7d_observation_count', label: '近7d事件数' }, + { key: 'prefix_count', label: '观测前缀数' }, + { key: 'origin_asn_count', label: '观测 ASN 数' }, + { key: 'top_event_types', label: '主要事件类型' }, + { key: 'coverage_halo', label: '日常活跃度' }, + { key: 'related_satellites', label: '附近卫星' }, + { key: 'latest_event_type', label: '最近事件类型' }, + { key: 'latest_observed_at', label: '最近活跃时间' }, + { key: 'baseline_scope', label: '日常覆盖范围' }, + { key: 'status', label: '状态' } + ] + }, supercomputer: { icon: '🖥️', title: '超算详情', @@ -55,7 +102,60 @@ const CARD_CONFIG = { }; export function initInfoCard() { - // Close button removed - now uses external clear button + const card = document.getElementById('info-card'); + const content = document.getElementById('info-card-content'); + if (!card || !content) return; + + if (card.dataset.interactionBound !== 'true') { + const stopEvent = (event) => { + event.stopPropagation(); + }; + + [ + 'mousemove', + 'mousedown', + 'mouseup', + 'click', + 'dblclick', + 'wheel', + 'pointerdown', + 'pointerup', + 'pointermove', + 'touchstart', + 'touchmove', + 'touchend', + ].forEach((eventName) => { + card.addEventListener(eventName, stopEvent, { passive: false }); + }); + + card.dataset.interactionBound = 'true'; + } + + if (content.dataset.copyBound === 'true') return; + + content.addEventListener('click', async (event) => { + const label = event.target.closest('.info-card-label'); + if (!label) return; + + const property = label.closest('.info-card-property'); + const valueEl = property?.querySelector('.info-card-value'); + const value = valueEl?.textContent?.trim(); + + if (!value || value === '-') { + showStatusMessage('无可复制内容', 'warning'); + return; + } + + try { + await navigator.clipboard.writeText(value); + showStatusMessage(`已复制${label.textContent}:${value}`, 'success'); + } catch (error) { + console.error('Copy failed:', error); + showStatusMessage('复制失败', 'error'); + } + }); + + content.dataset.copyBound = 'true'; } export function setInfoCardNoBorder(noBorder = true) { diff --git a/frontend/public/earth/js/legend.js b/frontend/public/earth/js/legend.js new file mode 100644 index 00000000..94e44535 --- /dev/null +++ b/frontend/public/earth/js/legend.js @@ -0,0 +1,67 @@ +const LEGEND_MODES = { + cables: { + title: "线缆图例", + }, + satellites: { + title: "卫星图例", + }, + bgp: { + title: "BGP观测图例", + }, +}; + +let currentLegendMode = "cables"; +let legendItemsByMode = { + cables: [], + satellites: [], + bgp: [], +}; + +export function initLegend() { + renderLegend(currentLegendMode); +} + +export function setLegendMode(mode) { + const nextMode = LEGEND_MODES[mode] ? mode : "cables"; + currentLegendMode = nextMode; + renderLegend(currentLegendMode); +} + +export function getLegendMode() { + return currentLegendMode; +} + +export function refreshLegend() { + renderLegend(currentLegendMode); +} + +export function setLegendItems(mode, items) { + if (!LEGEND_MODES[mode]) return; + legendItemsByMode[mode] = Array.isArray(items) ? items : []; + if (mode === currentLegendMode) { + renderLegend(currentLegendMode); + } +} + +function renderLegend(mode) { + const legend = document.getElementById("legend"); + if (!legend) return; + + const config = LEGEND_MODES[mode] || LEGEND_MODES.cables; + const items = legendItemsByMode[mode] || []; + const itemsHtml = items + .map( + (item) => ` +
+
+ ${item.label} +
+ `, + ) + .join(""); + + legend.innerHTML = ` +

${config.title}

+
${itemsHtml}
+ `; +} diff --git a/frontend/public/earth/js/main.js b/frontend/public/earth/js/main.js index 3c1c08c0..653be064 100644 --- a/frontend/public/earth/js/main.js +++ b/frontend/public/earth/js/main.js @@ -1,31 +1,142 @@ -import * as THREE from 'three'; -import { createNoise3D } from 'simplex-noise'; +import * as THREE from "three"; +import { createNoise3D } from "simplex-noise"; -import { CONFIG, CABLE_CONFIG, CABLE_STATE } from './constants.js'; -import { latLonToVector3, vector3ToLatLon, screenToEarthCoords } from './utils.js'; -import { - showStatusMessage, - updateCoordinatesDisplay, - updateZoomDisplay, +import { CONFIG, CABLE_CONFIG, CABLE_STATE } from "./constants.js"; +import { vector3ToLatLon, screenToEarthCoords } from "./utils.js"; +import { + showStatusMessage, + updateCoordinatesDisplay, + updateZoomDisplay, updateEarthStats, setLoading, + setLoadingMessage, showTooltip, - hideTooltip -} from './ui.js'; -import { createEarth, createClouds, createTerrain, createStars, createGridLines, toggleTerrain, getEarth } from './earth.js'; -import { loadGeoJSONFromPath, loadLandingPoints, handleCableClick, clearCableSelection, getCableLines, getCablesById, lockedCable as cableLocked, getCableState, setCableState, clearAllCableStates, applyLandingPointVisualState, resetLandingPointVisualState, getAllLandingPoints, getShowCables } from './cables.js'; -import { createSatellites, loadSatellites, updateSatellitePositions, toggleSatellites, toggleTrails, getShowSatellites, getSatelliteCount, selectSatellite, getSatelliteData, getSatellitePoints, setSatelliteRingState, updateLockedRingPosition, updateHoverRingPosition, getSatellitePositions, showPredictedOrbit, hidePredictedOrbit, updateBreathingPhase, isSatelliteFrontFacing } from './satellites.js'; -import { setupControls, getAutoRotate, getShowTerrain, zoomLevel, setAutoRotate, toggleAutoRotate, resetView } from './controls.js'; -import { initInfoCard, showInfoCard, hideInfoCard, getCurrentType, setInfoCardNoBorder } from './info-card.js'; + hideTooltip, + showError, + hideError, + clearUiState, +} from "./ui.js"; +import { + createEarth, + createClouds, + createTerrain, + createStars, + createGridLines, + getEarth, +} from "./earth.js"; +import { + loadGeoJSONFromPath, + loadLandingPoints, + handleCableClick, + clearCableSelection, + getCableLines, + getCableLegendItems, + getCableState, + setCableState, + clearAllCableStates, + applyLandingPointVisualState, + resetLandingPointVisualState, + getShowCables, + clearCableData, + getLandingPoints, + toggleCables, +} from "./cables.js"; +import { + createSatellites, + loadSatellites, + updateSatellitePositions, + toggleSatellites, + getShowSatellites, + getSatelliteLegendItems, + setSelectedSatelliteLegend, + clearSelectedSatelliteLegend, + getSatelliteCount, + selectSatellite, + getSatellitePoints, + setSatelliteRingState, + updateLockedRingPosition, + updateHoverRingPosition, + getSatellitePositions, + showPredictedOrbit, + hidePredictedOrbit, + highlightRelatedSatellites, + clearRelatedSatelliteHighlights, + getRelatedSatelliteIndicesForRegions, + updateRelatedSatelliteHighlights, + updateBreathingPhase, + isSatelliteFrontFacing, + setSatelliteCamera, + setLockedSatelliteIndex, + resetSatelliteState, + clearSatelliteData, +} from "./satellites.js"; +import { + loadBGPAnomalies, + getBGPAnomalyMarkers, + getBGPCollectorMarkers, + getBGPLegendItems, + getBGPCount, + getBGPCollectorCount, + getBGPStatusSummary, + getShowBGP, + clearBGPSelection, + setBGPMarkerState, + updateBGPVisualState, + clearBGPData, + toggleBGP, + formatBGPAnomalyTypeLabel, + formatBGPASPath, + formatBGPCollectorStatus, + formatBGPConfidence, + formatBGPImpactedScope, + formatBGPLocation, + formatBGPObservedTime, + formatBGPObservedBy, + formatBGPRelatedCables, + formatBGPRouteChange, + formatBGPTopEventTypes, + formatBGPScope, + formatBGPCollectorCoverageHalo, + formatBGPSeverityLabel, + formatBGPStatusLabel, + showBGPEventOverlay, + showBGPCollectorCoverageOverlay, +} from "./bgp.js"; +import { + setupControls, + getAutoRotate, + getShowTerrain, + setAutoRotate, + resetView, + getZoomLevel, + teardownControls, +} from "./controls.js"; +import { + initInfoCard, + showInfoCard, + hideInfoCard, + setInfoCardNoBorder, +} from "./info-card.js"; +import { + initLegend, + setLegendMode, + refreshLegend, + setLegendItems, +} from "./legend.js"; + +export let scene; +export let camera; +export let renderer; -export let scene, camera, renderer; let simplex; let isDragging = false; let previousMousePosition = { x: 0, y: 0 }; +let targetRotation = { x: 0, y: 0 }; +let inertialVelocity = { x: 0, y: 0 }; let hoveredCable = null; +let hoveredBGP = null; let hoveredSatellite = null; let hoveredSatelliteIndex = null; -let cableLockedData = null; let lockedSatellite = null; let lockedSatelliteIndex = null; let lockedObject = null; @@ -35,20 +146,141 @@ let isLongDrag = false; let lastSatClickTime = 0; let lastSatClickIndex = 0; let lastSatClickPos = { x: 0, y: 0 }; +let lastBGPClickTime = 0; +let lastBGPClickCollector = null; +let lastBGPClickType = null; +let lastBGPClickPos = { x: 0, y: 0 }; +let earthTexture = null; +let animationFrameId = null; +let initialized = false; +let destroyed = false; +let isDataLoading = false; +let currentLoadToken = 0; +let cablesEnabled = true; +let satellitesEnabled = true; +let cableToggleToken = 0; +let satelliteToggleToken = 0; -export function clearLockedObject() { - hidePredictedOrbit(); +const clock = new THREE.Clock(); +const interactionRaycaster = new THREE.Raycaster(); +const interactionMouse = new THREE.Vector2(); +const scratchCameraToEarth = new THREE.Vector3(); +const scratchCableCenter = new THREE.Vector3(); +const scratchCableDirection = new THREE.Vector3(); +const scratchBGPDirection = new THREE.Vector3(); +const scratchBGPWorldPosition = new THREE.Vector3(); + +const cleanupFns = []; +const DRAG_SMOOTHING_FACTOR = 0.18; +const INERTIA_DAMPING = 0.92; +const INERTIA_MIN_VELOCITY = 0.00008; +const HUD_INTERACTIVE_SELECTORS = [ + "#info-panel", + "#info-panel *", + "#right-toolbar-group", + "#right-toolbar-group *", + "#coordinates-display", + "#coordinates-display *", + "#legend", + "#legend *", + "#earth-stats", + "#earth-stats *", +]; + +function bindListener(target, eventName, handler, options) { + if (!target) return; + target.addEventListener(eventName, handler, options); + cleanupFns.push(() => + target.removeEventListener(eventName, handler, options), + ); +} + +function isEventOnHud(event) { + const target = event?.target; + if (!(target instanceof Element)) return false; + return HUD_INTERACTIVE_SELECTORS.some((selector) => target.closest(selector)); +} + +function getDragRotationFactor() { + const zoom = Math.max(getZoomLevel(), 0.01); + const scale = THREE.MathUtils.clamp( + 1 / zoom, + CONFIG.dragRotationScaleMin, + CONFIG.dragRotationScaleMax, + ); + return CONFIG.dragRotationFactorBase * scale; +} + +function disposeMaterial(material) { + if (!material) return; + if (Array.isArray(material)) { + material.forEach(disposeMaterial); + return; + } + + if (material.map) material.map.dispose(); + if (material.alphaMap) material.alphaMap.dispose(); + if (material.aoMap) material.aoMap.dispose(); + if (material.bumpMap) material.bumpMap.dispose(); + if (material.displacementMap) material.displacementMap.dispose(); + if (material.emissiveMap) material.emissiveMap.dispose(); + if (material.envMap) material.envMap.dispose(); + if (material.lightMap) material.lightMap.dispose(); + if (material.metalnessMap) material.metalnessMap.dispose(); + if (material.normalMap) material.normalMap.dispose(); + if (material.roughnessMap) material.roughnessMap.dispose(); + if (material.specularMap) material.specularMap.dispose(); + material.dispose(); +} + +function disposeSceneObject(object) { + if (!object) return; + + for (let i = object.children.length - 1; i >= 0; i -= 1) { + disposeSceneObject(object.children[i]); + } + + if (object.geometry) { + object.geometry.dispose(); + } + + if (object.material) { + disposeMaterial(object.material); + } + + if (object.parent) { + object.parent.remove(object); + } +} + +function clearRuntimeSelection() { hoveredCable = null; + hoveredBGP = null; hoveredSatellite = null; hoveredSatelliteIndex = null; - clearAllCableStates(); - setSatelliteRingState(null, 'none', null); lockedObject = null; lockedObjectType = null; lockedSatellite = null; lockedSatelliteIndex = null; - window.lockedSatelliteIndex = null; - cableLockedData = null; + setLockedSatelliteIndex(null); + clearSelectedSatelliteLegend(); +} + +export function clearLockedObject() { + hidePredictedOrbit(); + clearAllCableStates(); + clearCableSelection(); + clearBGPSelection(); + clearRelatedSatelliteHighlights(); + setSatelliteRingState(null, "none", null); + clearRuntimeSelection(); + setLegendItems("satellites", getSatelliteLegendItems()); +} + +export function clearLockedObjectAndInfo() { + clearLockedObject(); + hideInfoCard(); + hideTooltip(); } function isSameCable(cable1, cable2) { @@ -59,499 +291,1352 @@ function isSameCable(cable1, cable2) { return id1 === id2; } +function isSameBGPMarker(marker1, marker2) { + if (!marker1 || !marker2) return false; + const type1 = marker1.userData?.type; + const type2 = marker2.userData?.type; + if (type1 !== type2) return false; + + if (type1 === "bgp") { + return marker1.userData?.id === marker2.userData?.id; + } + if (type1 === "bgp_collector") { + return marker1.userData?.collector === marker2.userData?.collector; + } + return false; +} + +function getBGPCollectorMarkerByName(collector) { + return getBGPCollectorMarkers().find( + (marker) => marker.userData?.collector === collector, + ); +} + +function resetTransientBGPStates() { + getBGPCollectorMarkers().forEach((marker) => { + if (marker !== lockedObject) { + setBGPMarkerState(marker, "normal"); + } + }); + getBGPAnomalyMarkers().forEach((marker) => { + if (marker !== lockedObject) { + setBGPMarkerState(marker, "normal"); + } + }); +} + +function clearTransientHoverState() { + resetTransientBGPStates(); + hoveredBGP = null; + + if (hoveredCable && !isSameCable(hoveredCable, lockedObject)) { + setCableState(hoveredCable.userData.cableId, CABLE_STATE.NORMAL); + } + hoveredCable = null; + + if (hoveredSatelliteIndex !== null && hoveredSatelliteIndex !== lockedSatelliteIndex) { + setSatelliteRingState(hoveredSatelliteIndex, "none", null); + } + hoveredSatellite = null; + hoveredSatelliteIndex = null; +} + +function applyBGPHoverState(marker) { + resetTransientBGPStates(); + if (!marker) { + hoveredBGP = null; + return; + } + + hoveredBGP = marker; + if (marker !== lockedObject) { + setBGPMarkerState(marker, "hover"); + } + + const relatedCollector = + marker.userData?.type === "bgp_collector" + ? marker + : getBGPCollectorMarkerByName(marker.userData?.collector); + + if (relatedCollector && relatedCollector !== lockedObject && relatedCollector !== marker) { + setBGPMarkerState(relatedCollector, "linked"); + } +} + +function getPrimaryBGPHoverTarget(bgpAnomalyIntersects, bgpCollectorIntersects) { + if (bgpAnomalyIntersects.length > 0) { + return bgpAnomalyIntersects[0].object; + } + if (bgpCollectorIntersects.length > 0) { + return bgpCollectorIntersects[0].object; + } + return null; +} + +function getPrimaryBGPClickTarget( + event, + bgpAnomalyIntersects, + bgpCollectorIntersects, +) { + const anomalyMarker = bgpAnomalyIntersects[0]?.object || null; + const collectorMarker = bgpCollectorIntersects[0]?.object || null; + if (!anomalyMarker && !collectorMarker) return null; + if (!anomalyMarker) return collectorMarker; + if (!collectorMarker) return anomalyMarker; + + const clickCollector = anomalyMarker.userData?.collector || collectorMarker.userData?.collector; + const isRepeatedClick = + clickCollector && + clickCollector === lastBGPClickCollector && + Date.now() - lastBGPClickTime < 650 && + Math.abs(event.clientX - lastBGPClickPos.x) < 28 && + Math.abs(event.clientY - lastBGPClickPos.y) < 28; + + if (isRepeatedClick) { + return lastBGPClickType === "bgp" ? collectorMarker : anomalyMarker; + } + + return anomalyMarker; +} + function showCableInfo(cable) { - showInfoCard('cable', { + setLegendMode("cables"); + showInfoCard("cable", { name: cable.userData.name, owner: cable.userData.owner, status: cable.userData.status, length: cable.userData.length, coords: cable.userData.coords, - rfs: cable.userData.rfs + rfs: cable.userData.rfs, }); } function showSatelliteInfo(props) { const meanMotion = props?.mean_motion || 0; - const period = meanMotion > 0 ? (1440 / meanMotion).toFixed(1) : '-'; + const period = meanMotion > 0 ? (1440 / meanMotion).toFixed(1) : "-"; const ecc = props?.eccentricity || 0; const perigee = (6371 * (1 - ecc)).toFixed(0); const apogee = (6371 * (1 + ecc)).toFixed(0); - - showInfoCard('satellite', { - name: props?.name || '-', + + setSelectedSatelliteLegend(props); + setLegendItems("satellites", getSatelliteLegendItems()); + setLegendMode("satellites"); + showInfoCard("satellite", { + name: props?.name || "-", norad_id: props?.norad_cat_id, - inclination: props?.inclination ? props.inclination.toFixed(2) : '-', - period: period, - perigee: perigee, - apogee: apogee + inclination: props?.inclination ? props.inclination.toFixed(2) : "-", + period, + perigee, + apogee, }); } +function showBGPInfo(marker) { + setLegendMode("bgp"); + const impactedRegions = + Array.isArray(marker.userData.impacted_regions) && + marker.userData.impacted_regions.length > 0 + ? marker.userData.impacted_regions + : [ + { + city: marker.userData.city, + country: marker.userData.country, + }, + ]; + const observedBy = + marker.userData.observed_by || + formatBGPObservedBy(marker.userData.collectors); + const impactedScope = formatBGPImpactedScope(impactedRegions); + const relatedCables = formatBGPRelatedCables(marker.userData.related_cables); + const narrative = + marker.userData.summary && marker.userData.summary !== "-" + ? marker.userData.summary + : buildBGPIncidentNarrative(marker, impactedRegions); + showInfoCard("bgp", { + anomaly_type: formatBGPAnomalyTypeLabel( + marker.userData.incident_type || marker.userData.anomaly_type, + ), + severity: formatBGPSeverityLabel( + marker.userData.rawSeverity || marker.userData.severity, + ), + status: formatBGPStatusLabel(marker.userData.status), + route_change: + marker.userData.route_change || + formatBGPRouteChange( + marker.userData.origin_asn, + marker.userData.new_origin_asn, + ), + prefix: + Array.isArray(marker.userData.prefixes) && marker.userData.prefixes.length > 1 + ? `${marker.userData.prefixes[0]} 等${marker.userData.prefixes.length}个` + : marker.userData.prefix, + as_path_display: + Array.isArray(marker.userData.as_path) && marker.userData.as_path.length > 0 + ? formatBGPASPath(marker.userData.as_path) + : "-", + origin_asn: + Array.isArray(marker.userData.affected_asns) && marker.userData.affected_asns.length > 0 + ? marker.userData.affected_asns.slice(0, 3).map((asn) => `AS${asn}`).join(", ") + : marker.userData.origin_asn, + new_origin_asn: + Array.isArray(marker.userData.affected_asns) && marker.userData.affected_asns.length > 3 + ? `共${marker.userData.affected_asns.length}个ASN` + : marker.userData.new_origin_asn, + confidence: formatBGPConfidence(marker.userData.confidence), + collector: marker.userData.collector, + observed_by: observedBy, + impacted_scope: impactedScope, + related_cables: relatedCables, + related_satellites: + marker.userData.related_satellite_count > 0 + ? `${marker.userData.related_satellite_count}颗事件附近卫星` + : "-", + location: + marker.userData.location || + formatBGPLocation(marker.userData.city, marker.userData.country), + created_at: formatBGPObservedTime(marker.userData.created_at_raw), + summary: narrative, + }); +} + +function showBGPCollectorInfo(marker) { + setLegendMode("bgp"); + showInfoCard("bgp_collector", { + collector: marker.userData.collector, + location: formatBGPLocation(marker.userData.city, marker.userData.country), + anomaly_count: marker.userData.anomaly_count ?? 0, + observation_count: marker.userData.observation_count ?? 0, + recent_24h_observation_count: marker.userData.recent_24h_observation_count ?? 0, + recent_7d_observation_count: marker.userData.recent_7d_observation_count ?? 0, + prefix_count: marker.userData.prefix_count ?? 0, + origin_asn_count: marker.userData.origin_asn_count ?? 0, + top_event_types: formatBGPTopEventTypes(marker.userData.top_event_types), + coverage_halo: formatBGPCollectorCoverageHalo(marker.userData), + related_satellites: "-", + latest_event_type: marker.userData.latest_event_type || "-", + latest_observed_at: formatBGPObservedTime(marker.userData.latest_observed_at), + baseline_scope: formatBGPScope(marker.userData.baseline_scope), + status: formatBGPCollectorStatus(marker.userData.status || "online"), + }); +} + +function getBGPRelatedCableNames(marker) { + const items = Array.isArray(marker?.userData?.related_cables) + ? marker.userData.related_cables + : []; + + const names = []; + items.forEach((item) => { + const cableNames = Array.isArray(item?.cable_names) ? item.cable_names : []; + cableNames.forEach((name) => { + if (name && !names.includes(name)) { + names.push(name); + } + }); + }); + return names; +} + +function getBGPInfrastructureSummary(marker) { + return { + cableCount: getBGPRelatedCableNames(marker).length, + regionCount: getBGPRelatedRegions(marker).length, + }; +} + +function buildBGPIncidentNarrative(marker, impactedRegions) { + const eventLabel = formatBGPAnomalyTypeLabel( + marker.userData.incident_type || marker.userData.anomaly_type, + ); + const severityLabel = formatBGPSeverityLabel( + marker.userData.rawSeverity || marker.userData.severity, + ); + const observedBy = + marker.userData.observed_by || + formatBGPObservedBy(marker.userData.collectors); + const routeLabel = + marker.userData.route_change || + formatBGPRouteChange( + marker.userData.origin_asn, + marker.userData.new_origin_asn, + ); + const cableCount = getBGPRelatedCableNames(marker).length; + const regionCount = impactedRegions.length; + + return `${eventLabel},${severityLabel};${observedBy},影响${regionCount}个区域,关联${cableCount}条海缆线索${routeLabel && routeLabel !== "-" ? `,特征 ${routeLabel}` : ""}。`; +} + +function getBGPRelatedRegions(marker) { + if (Array.isArray(marker?.userData?.impacted_regions) && marker.userData.impacted_regions.length > 0) { + return marker.userData.impacted_regions; + } + + if ( + marker?.userData?.type === "bgp_collector" && + typeof marker.userData.latitude === "number" && + typeof marker.userData.longitude === "number" + ) { + return [ + { + collector: marker.userData.collector, + city: marker.userData.city, + country: marker.userData.country, + latitude: marker.userData.latitude, + longitude: marker.userData.longitude, + }, + ]; + } + + return []; +} + function applyCableVisualState() { const allCables = getCableLines(); const pulse = (Math.sin(Date.now() * CABLE_CONFIG.pulseSpeed) + 1) * 0.5; - - allCables.forEach(c => { - const cableId = c.userData.cableId; + + allCables.forEach((cable) => { + const cableId = cable.userData.cableId; const state = getCableState(cableId); - + switch (state) { case CABLE_STATE.LOCKED: - c.material.opacity = CABLE_CONFIG.lockedOpacityMin + pulse * (CABLE_CONFIG.lockedOpacityMax - CABLE_CONFIG.lockedOpacityMin); - c.material.color.setRGB(1, 1, 1); + cable.material.opacity = + CABLE_CONFIG.lockedOpacityMin + + pulse * + (CABLE_CONFIG.lockedOpacityMax - CABLE_CONFIG.lockedOpacityMin); + cable.material.color.setRGB(1, 1, 1); break; case CABLE_STATE.HOVERED: - c.material.opacity = 1; - c.material.color.setRGB(1, 1, 1); + cable.material.opacity = 1; + cable.material.color.setRGB(1, 1, 1); break; case CABLE_STATE.NORMAL: default: - if ((lockedObjectType === 'cable' && lockedObject) || (lockedObjectType === 'satellite' && lockedSatellite)) { - c.material.opacity = CABLE_CONFIG.otherOpacity; - const origColor = c.userData.originalColor; + if ( + (lockedObjectType === "cable" && lockedObject) || + (lockedObjectType === "satellite" && lockedSatellite) || + (lockedObjectType === "bgp" && lockedObject) + ) { + cable.material.opacity = CABLE_CONFIG.otherOpacity; + const origColor = cable.userData.originalColor; const brightness = CABLE_CONFIG.otherBrightness; - c.material.color.setRGB( - ((origColor >> 16) & 255) / 255 * brightness, - ((origColor >> 8) & 255) / 255 * brightness, - (origColor & 255) / 255 * brightness + cable.material.color.setRGB( + (((origColor >> 16) & 255) / 255) * brightness, + (((origColor >> 8) & 255) / 255) * brightness, + ((origColor & 255) / 255) * brightness, ); } else { - c.material.opacity = 1; - c.material.color.setHex(c.userData.originalColor); + cable.material.opacity = 1; + cable.material.color.setHex(cable.userData.originalColor); } } }); } -window.addEventListener('error', (e) => { - console.error('全局错误:', e.error); - showStatusMessage('加载错误: ' + e.error?.message, 'error'); +function updatePointerFromEvent(event) { + interactionMouse.set( + (event.clientX / window.innerWidth) * 2 - 1, + -(event.clientY / window.innerHeight) * 2 + 1, + ); + interactionRaycaster.setFromCamera(interactionMouse, camera); +} + +function buildLoadErrorMessage(errors) { + if (errors.length === 0) return ""; + return errors + .map( + ({ label, reason }) => + `${label}加载失败: ${reason?.message || String(reason)}`, + ) + .join(";"); +} + +function updateSatelliteToggleUi(enabled, satelliteCount = getSatelliteCount()) { + const satBtn = document.getElementById("toggle-satellites"); + if (satBtn) { + satBtn.classList.toggle("active", enabled); + const tooltip = satBtn.querySelector(".tooltip"); + if (tooltip) tooltip.textContent = enabled ? "隐藏卫星" : "显示卫星"; + } + + const satelliteCountEl = document.getElementById("satellite-count"); + if (satelliteCountEl) { + satelliteCountEl.textContent = `${satelliteCount} 颗`; + } +} + +function updateCableToggleUi(enabled) { + const cableBtn = document.getElementById("toggle-cables"); + if (cableBtn) { + cableBtn.classList.toggle("active", enabled); + const tooltip = cableBtn.querySelector(".tooltip"); + if (tooltip) tooltip.textContent = enabled ? "隐藏线缆" : "显示线缆"; + } + + const cableCountEl = document.getElementById("cable-count"); + if (cableCountEl) { + cableCountEl.textContent = `${getCableLines().length}个`; + } + + const landingPointCountEl = document.getElementById("landing-point-count"); + if (landingPointCountEl) { + landingPointCountEl.textContent = `${getLandingPoints().length}个`; + } +} + +async function ensureCablesEnabled() { + if (!scene || !camera || !renderer || destroyed) { + return 0; + } + + const earth = getEarth(); + if (!earth) return 0; + + cablesEnabled = true; + if (getCableLines().length > 0 || getLandingPoints().length > 0) { + toggleCables(true); + updateCableToggleUi(true); + setLegendItems("cables", getCableLegendItems()); + refreshLegend(); + return getCableLines().length; + } + + const requestToken = ++cableToggleToken; + + clearCableData(earth); + const [cableCount] = await Promise.all([ + loadGeoJSONFromPath(scene, earth), + loadLandingPoints(scene, earth), + ]); + + if (requestToken !== cableToggleToken || !cablesEnabled || destroyed) { + clearCableData(earth); + return 0; + } + + toggleCables(true); + updateCableToggleUi(true); + setLegendItems("cables", getCableLegendItems()); + refreshLegend(); + return cableCount; +} + +function disableCables() { + cablesEnabled = false; + cableToggleToken += 1; + toggleCables(false); + updateCableToggleUi(false); + setLegendItems("cables", getCableLegendItems()); + refreshLegend(); +} + +async function ensureSatellitesEnabled() { + if (!scene || !camera || !renderer || destroyed) return 0; + + const earth = getEarth(); + if (!earth) return 0; + + satellitesEnabled = true; + const requestToken = ++satelliteToggleToken; + + if (!getSatellitePoints()) { + createSatellites(scene, earth); + } + + clearSatelliteData(); + const satelliteCount = await loadSatellites(); + + if ( + requestToken !== satelliteToggleToken || + !satellitesEnabled || + destroyed + ) { + resetSatelliteState(); + return 0; + } + + updateSatellitePositions(POSITION_UPDATE_FORCE_DELTA, true); + toggleSatellites(true); + updateSatelliteToggleUi(true, satelliteCount); + setLegendItems("satellites", getSatelliteLegendItems()); + refreshLegend(); + return satelliteCount; +} + +function disableSatellites() { + satellitesEnabled = false; + satelliteToggleToken += 1; + resetSatelliteState(); + updateSatelliteToggleUi(false, 0); + setLegendItems("satellites", getSatelliteLegendItems()); + refreshLegend(); +} + +function updateStatsSummary() { + updateEarthStats({ + cableCount: getCableLines().length, + landingPointCount: + document.getElementById("landing-point-count")?.textContent || 0, + bgpAnomalyCount: `${getBGPCount()} 条`, + bgpCollectorCount: `${getBGPCollectorCount()} 个`, + bgpStatusSummary: getBGPStatusSummary(), + terrainOn: getShowTerrain(), + textureQuality: "8K 卫星图", + }); +} + +window.addEventListener("error", (event) => { + console.error("全局错误:", event.error); }); -window.addEventListener('unhandledrejection', (e) => { - console.error('未处理的Promise错误:', e.reason); +window.addEventListener("unhandledrejection", (event) => { + console.error("未处理的 Promise 错误:", event.reason); }); export function init() { + if (initialized && !destroyed) return; + + destroyed = false; + initialized = true; simplex = createNoise3D(); - + scene = new THREE.Scene(); - - camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); + camera = new THREE.PerspectiveCamera( + 75, + window.innerWidth / window.innerHeight, + 0.1, + 1000, + ); camera.position.z = CONFIG.defaultCameraZ; - window.camera = camera; - - renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: 'high-performance' }); + setSatelliteCamera(camera); + + renderer = new THREE.WebGLRenderer({ + antialias: true, + alpha: false, + powerPreference: "high-performance", + }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setClearColor(0x0a0a1a, 1); renderer.setPixelRatio(window.devicePixelRatio); - document.getElementById('container').appendChild(renderer.domElement); - + + const container = document.getElementById("container"); + if (container) { + container.querySelector("canvas")?.remove(); + container.appendChild(renderer.domElement); + } + addLights(); initInfoCard(); + initLegend(); + setLegendItems("cables", getCableLegendItems()); + setLegendItems("satellites", getSatelliteLegendItems()); + setLegendItems("bgp", getBGPLegendItems()); const earthObj = createEarth(scene); + targetRotation = { + x: earthObj.rotation.x, + y: earthObj.rotation.y, + }; + inertialVelocity = { x: 0, y: 0 }; createClouds(scene, earthObj); createTerrain(scene, earthObj, simplex); createStars(scene); createGridLines(scene, earthObj); createSatellites(scene, earthObj); - + setupControls(camera, renderer, scene, earthObj); resetView(camera); - setupEventListeners(camera, renderer); - + setupEventListeners(); + + clock.start(); loadData(); - animate(); + registerGlobalApi(); +} + +function registerGlobalApi() { + window.__planetEarth = { + reloadData, + clearSelection: () => { + hideInfoCard(); + clearLockedObject(); + }, + destroy, + init, + }; } function addLights() { - const ambientLight = new THREE.AmbientLight(0x404060); - scene.add(ambientLight); - + scene.add(new THREE.AmbientLight(0x404060)); + const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2); directionalLight.position.set(5, 3, 5); scene.add(directionalLight); - + const backLight = new THREE.DirectionalLight(0x446688, 0.3); backLight.position.set(-5, 0, -5); scene.add(backLight); - + const pointLight = new THREE.PointLight(0xffffff, 0.4); pointLight.position.set(10, 10, 10); scene.add(pointLight); } -let earthTexture = null; - async function loadData(showWhiteSphere = false) { - if (showWhiteSphere) { - const earth = getEarth(); - if (earth && earth.material) { - earthTexture = earth.material.map; - earth.material.map = null; - earth.material.color.setHex(0xffffff); - earth.material.needsUpdate = true; - } - } - + if (!scene || !camera || !renderer) return; + if (isDataLoading) return; + + const earth = getEarth(); + if (!earth) return; + + const loadToken = ++currentLoadToken; + isDataLoading = true; + hideError(); + setLoadingMessage( + showWhiteSphere ? "正在刷新全球态势数据..." : "正在初始化全球态势数据...", + showWhiteSphere + ? "重新同步卫星、海底光缆、登陆点与BGP态势数据" + : "同步卫星、海底光缆、登陆点与BGP态势数据", + ); setLoading(true); - try { - console.log('开始加载数据...'); - await Promise.all([ - (async () => { - await loadGeoJSONFromPath(scene, getEarth()); - console.log('电缆数据加载完成'); - await loadLandingPoints(scene, getEarth()); - console.log('登陆点数据加载完成'); - })(), - (async () => { - const satCount = await loadSatellites(); - console.log(`卫星数据加载完成: ${satCount} 颗`); - document.getElementById('satellite-count').textContent = satCount + ' 颗'; - updateSatellitePositions(); - console.log('卫星位置已更新'); - toggleSatellites(true); - const satBtn = document.getElementById('toggle-satellites'); - if (satBtn) { - satBtn.classList.add('active'); - satBtn.querySelector('.tooltip').textContent = '隐藏卫星'; - } - })() - ]); - } catch (error) { - console.error('加载数据失败:', error); - showStatusMessage('加载数据失败: ' + error.message, 'error'); + clearLockedObject(); + hideInfoCard(); + + if (showWhiteSphere && earth.material) { + earthTexture = earth.material.map; + earth.material.map = null; + earth.material.color.setHex(0xffffff); + earth.material.needsUpdate = true; } + + const results = await Promise.allSettled([ + cablesEnabled ? ensureCablesEnabled() : Promise.resolve(0), + satellitesEnabled ? ensureSatellitesEnabled() : Promise.resolve(0), + (async () => { + clearBGPData(earth); + const bgpResult = await loadBGPAnomalies(scene, earth); + toggleBGP(true); + const bgpBtn = document.getElementById("toggle-bgp"); + if (bgpBtn) { + bgpBtn.classList.add("active"); + const tooltip = bgpBtn.querySelector(".tooltip"); + if (tooltip) tooltip.textContent = "隐藏BGP观测"; + } + const bgpCountEl = document.getElementById("bgp-anomaly-count"); + if (bgpCountEl) { + bgpCountEl.textContent = `${bgpResult.totalCount} 起`; + } + const bgpCollectorEl = document.getElementById("bgp-collector-count"); + if (bgpCollectorEl) { + bgpCollectorEl.textContent = `${bgpResult.collectorCount} 个`; + } + const bgpStatusEl = document.getElementById("bgp-status-summary"); + if (bgpStatusEl) { + bgpStatusEl.textContent = + bgpResult.totalCount > 0 + ? `${bgpResult.totalCount} 起活跃事件` + : bgpResult.anomalyCount > 0 + ? `${bgpResult.anomalyCount} 条活跃异常` + : "当前无活跃事件"; + } + return bgpResult; + })(), + ]); + + if (loadToken !== currentLoadToken) { + isDataLoading = false; + return; + } + + const errors = []; + if (results[0].status === "rejected") { + errors.push({ label: "电缆", reason: results[0].reason }); + } + if (results[1].status === "rejected") { + errors.push({ label: "卫星", reason: results[1].reason }); + } + if (results[2].status === "rejected") { + errors.push({ label: "BGP态势", reason: results[2].reason }); + } + + if (errors.length > 0) { + const errorMessage = buildLoadErrorMessage(errors); + showError(errorMessage); + showStatusMessage(errorMessage, "error"); + } else { + hideError(); + showStatusMessage("数据已重新加载", "success"); + } + + updateStatsSummary(); + updateCableToggleUi(cablesEnabled); + updateSatelliteToggleUi(satellitesEnabled); + setLegendItems("cables", getCableLegendItems()); + setLegendItems("satellites", getSatelliteLegendItems()); + setLegendItems("bgp", getBGPLegendItems()); + refreshLegend(); setLoading(false); - - if (showWhiteSphere) { - const earth = getEarth(); - if (earth && earth.material) { - earth.material.map = earthTexture; - earth.material.color.setHex(0xffffff); - earth.material.needsUpdate = true; - } + isDataLoading = false; + + if (showWhiteSphere && earth.material) { + earth.material.map = earthTexture; + earth.material.color.setHex(0xffffff); + earth.material.needsUpdate = true; } } +const POSITION_UPDATE_FORCE_DELTA = 250; + export async function reloadData() { await loadData(true); } -function setupEventListeners(camera, renderer) { - window.addEventListener('resize', () => onWindowResize(camera, renderer)); - - renderer.domElement.addEventListener('mousemove', (e) => onMouseMove(e, camera)); - renderer.domElement.addEventListener('mousedown', onMouseDown); - renderer.domElement.addEventListener('mouseup', onMouseUp); - renderer.domElement.addEventListener('click', (e) => onClick(e, camera, renderer)); +export async function setCablesEnabled(enabled) { + if (enabled === cablesEnabled) { + updateCableToggleUi(enabled); + return getCableLines().length; + } + + if (!enabled) { + clearLockedObject(); + hideInfoCard(); + disableCables(); + showStatusMessage("线缆已隐藏", "info"); + return 0; + } + + setLoadingMessage("正在加载线缆数据...", "重建海缆与登陆点对象"); + setLoading(true); + hideError(); + + try { + const cableCount = await ensureCablesEnabled(); + showStatusMessage("线缆已显示", "info"); + return cableCount; + } catch (error) { + cablesEnabled = false; + clearCableData(getEarth()); + updateCableToggleUi(false); + const message = `线缆加载失败: ${error?.message || String(error)}`; + showError(message); + showStatusMessage(message, "error"); + throw error; + } finally { + setLoading(false); + } } -function onWindowResize(camera, renderer) { +export async function setSatellitesEnabled(enabled) { + if (enabled === satellitesEnabled) { + updateSatelliteToggleUi(enabled); + return getSatelliteCount(); + } + + if (!enabled) { + clearLockedObject(); + hideInfoCard(); + disableSatellites(); + return 0; + } + + setLoadingMessage("正在加载卫星数据...", "重建卫星点位与轨迹缓存"); + setLoading(true); + hideError(); + + try { + const satelliteCount = await ensureSatellitesEnabled(); + showStatusMessage("卫星已显示", "info"); + return satelliteCount; + } catch (error) { + satellitesEnabled = false; + resetSatelliteState(); + updateSatelliteToggleUi(false, 0); + const message = `卫星加载失败: ${error?.message || String(error)}`; + showError(message); + showStatusMessage(message, "error"); + throw error; + } finally { + setLoading(false); + } +} + +function setupEventListeners() { + const handleResize = () => onWindowResize(); + const handleMouseMove = (event) => onMouseMove(event); + const handleMouseDown = (event) => onMouseDown(event); + const handleMouseUp = () => onMouseUp(); + const handleMouseLeave = () => onMouseLeave(); + const handleClick = (event) => onClick(event); + const handlePageHide = () => destroy(); + + bindListener(window, "resize", handleResize); + bindListener(window, "pagehide", handlePageHide); + bindListener(window, "beforeunload", handlePageHide); + bindListener(window, "mousemove", handleMouseMove); + bindListener(renderer.domElement, "mousedown", handleMouseDown); + bindListener(window, "mouseup", handleMouseUp); + bindListener(renderer.domElement, "mouseleave", handleMouseLeave); + bindListener(renderer.domElement, "click", handleClick); +} + +function onWindowResize() { + if (!camera || !renderer) return; camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); } -function getFrontFacingCables(cableLines, camera) { +function getFrontFacingCables(cableLines) { const earth = getEarth(); if (!earth) return cableLines; - - const cameraDir = new THREE.Vector3(); - camera.getWorldDirection(cameraDir); - - return cableLines.filter(cable => { - const cablePos = new THREE.Vector3(); - cable.geometry.computeBoundingBox(); - const boundingBox = cable.geometry.boundingBox; - if (boundingBox) { - boundingBox.getCenter(cablePos); - cable.localToWorld(cablePos); + + scratchCameraToEarth.subVectors(camera.position, earth.position).normalize(); + + return cableLines.filter((cable) => { + if (!cable.userData.localCenter) { + return true; } - - const toCamera = new THREE.Vector3().subVectors(camera.position, earth.position).normalize(); - const toCable = new THREE.Vector3().subVectors(cablePos, earth.position).normalize(); - - return toCamera.dot(toCable) > 0; + + scratchCableCenter.copy(cable.userData.localCenter); + cable.localToWorld(scratchCableCenter); + scratchCableDirection + .subVectors(scratchCableCenter, earth.position) + .normalize(); + return scratchCameraToEarth.dot(scratchCableDirection) > 0; }); } -function onMouseMove(event, camera) { +function getFrontFacingBGPMarkers(markers) { + const earth = getEarth(); + if (!earth) return markers; + + scratchCameraToEarth.subVectors(camera.position, earth.position).normalize(); + + return markers.filter((marker) => { + scratchBGPWorldPosition.copy(marker.position); + marker.parent?.localToWorld(scratchBGPWorldPosition); + scratchBGPDirection + .subVectors(scratchBGPWorldPosition, earth.position) + .normalize(); + return scratchCameraToEarth.dot(scratchBGPDirection) > 0; + }); +} + +function onMouseMove(event) { const earth = getEarth(); if (!earth) return; - - const raycaster = new THREE.Raycaster(); - const mouse = new THREE.Vector2( - (event.clientX / window.innerWidth) * 2 - 1, - -(event.clientY / window.innerHeight) * 2 + 1 + + if (isEventOnHud(event)) { + clearTransientHoverState(); + + if (lockedObjectType === "bgp" && lockedObject) { + applyBGPHoverState(lockedObject); + showBGPInfo(lockedObject); + } else if (lockedObjectType === "bgp_collector" && lockedObject) { + applyBGPHoverState(lockedObject); + showBGPCollectorInfo(lockedObject); + } else if (lockedObjectType === "cable" && lockedObject) { + showCableInfo(lockedObject); + } else if (lockedObjectType === "satellite" && lockedSatellite) { + showSatelliteInfo(lockedSatellite.properties); + } else { + hideInfoCard(); + } + hideTooltip(); + return; + } + + if (isDragging) { + if (Date.now() - dragStartTime > 500) { + isLongDrag = true; + } + + const deltaX = event.clientX - previousMousePosition.x; + const deltaY = event.clientY - previousMousePosition.y; + const dragRotationFactor = getDragRotationFactor(); + const rotationDeltaY = deltaX * dragRotationFactor; + const rotationDeltaX = deltaY * dragRotationFactor; + + targetRotation.y += rotationDeltaY; + targetRotation.x += rotationDeltaX; + inertialVelocity.y = rotationDeltaY; + inertialVelocity.x = rotationDeltaX; + previousMousePosition = { x: event.clientX, y: event.clientY }; + hideTooltip(); + return; + } + + updatePointerFromEvent(event); + + const frontCables = getFrontFacingCables(getCableLines()); + const cableIntersects = interactionRaycaster.intersectObjects(frontCables); + const frontFacingBGPAnomalyMarkers = getFrontFacingBGPMarkers( + getBGPAnomalyMarkers(), ); - - raycaster.setFromCamera(mouse, camera); - - const allCableLines = getCableLines(); - const frontCables = getFrontFacingCables(allCableLines, camera); - const intersects = raycaster.intersectObjects(frontCables); - - const hasHoveredCable = intersects.length > 0; + const frontFacingBGPCollectorMarkers = getFrontFacingBGPMarkers( + getBGPCollectorMarkers(), + ); + const bgpAnomalyIntersects = getShowBGP() + ? interactionRaycaster.intersectObjects(frontFacingBGPAnomalyMarkers) + : []; + const bgpCollectorIntersects = getShowBGP() + ? interactionRaycaster.intersectObjects(frontFacingBGPCollectorMarkers) + : []; + let hoveredSat = null; let hoveredSatIndexFromIntersect = null; if (getShowSatellites()) { const satPoints = getSatellitePoints(); if (satPoints) { - const satIntersects = raycaster.intersectObject(satPoints); + const satIntersects = interactionRaycaster.intersectObject(satPoints); if (satIntersects.length > 0) { const satIndex = satIntersects[0].index; if (isSatelliteFrontFacing(satIndex, camera)) { hoveredSatIndexFromIntersect = satIndex; - hoveredSat = selectSatellite(hoveredSatIndexFromIntersect); + hoveredSat = selectSatellite(satIndex); } } } } - const hasHoveredSatellite = hoveredSat && hoveredSat.properties; - - if (hoveredCable) { - if (!hasHoveredCable || !isSameCable(intersects[0]?.object, hoveredCable)) { - if (!isSameCable(hoveredCable, lockedObject)) { - setCableState(hoveredCable.userData.cableId, CABLE_STATE.NORMAL); - } - hoveredCable = null; - } + + const hoveredBGPMarker = getPrimaryBGPHoverTarget( + bgpAnomalyIntersects, + bgpCollectorIntersects, + ); + + if (hoveredBGP && !isSameBGPMarker(hoveredBGP, hoveredBGPMarker)) { + clearTransientHoverState(); } - - if (hoveredSatelliteIndex !== null && hoveredSatelliteIndex !== hoveredSatIndexFromIntersect) { - if (hoveredSatelliteIndex !== lockedSatelliteIndex) { - setSatelliteRingState(hoveredSatelliteIndex, 'none', null); - } - hoveredSatelliteIndex = null; + + if ( + hoveredCable && + (!cableIntersects.length || + !isSameCable(cableIntersects[0]?.object, hoveredCable)) + ) { + clearTransientHoverState(); } - - if (hasHoveredCable && getShowCables()) { - const cable = intersects[0].object; - if (!isSameCable(cable, lockedObject)) { - hoveredCable = cable; - setCableState(cable.userData.cableId, CABLE_STATE.HOVERED); + + if ( + hoveredSatelliteIndex !== null && + hoveredSatelliteIndex !== hoveredSatIndexFromIntersect + ) { + clearTransientHoverState(); + } + + if ( + hoveredBGPMarker && + getShowBGP() && + lockedObjectType !== "bgp" && + lockedObjectType !== "bgp_collector" + ) { + applyBGPHoverState(hoveredBGPMarker); + if (hoveredBGPMarker.userData?.type === "bgp") { + showBGPInfo(hoveredBGPMarker); } else { - hoveredCable = cable; + showBGPCollectorInfo(hoveredBGPMarker); + } + setInfoCardNoBorder(true); + hideTooltip(); + } else if (cableIntersects.length > 0 && getShowCables()) { + const cable = cableIntersects[0].object; + hoveredCable = cable; + if (!isSameCable(cable, lockedObject)) { + setCableState(cable.userData.cableId, CABLE_STATE.HOVERED); } - showCableInfo(cable); setInfoCardNoBorder(true); hideTooltip(); - } else if (hasHoveredSatellite) { + } else if (hoveredSat?.properties) { hoveredSatellite = hoveredSat; hoveredSatelliteIndex = hoveredSatIndexFromIntersect; if (hoveredSatelliteIndex !== lockedSatelliteIndex) { const satPositions = getSatellitePositions(); if (satPositions && satPositions[hoveredSatelliteIndex]) { - setSatelliteRingState(hoveredSatelliteIndex, 'hover', satPositions[hoveredSatelliteIndex].current); + setSatelliteRingState( + hoveredSatelliteIndex, + "hover", + satPositions[hoveredSatelliteIndex].current, + ); } } showSatelliteInfo(hoveredSat.properties); setInfoCardNoBorder(true); - } else if (lockedObjectType === 'cable' && lockedObject) { + } else if (lockedObjectType === "bgp" && lockedObject) { + applyBGPHoverState(lockedObject); + showBGPInfo(lockedObject); + } else if (lockedObjectType === "bgp_collector" && lockedObject) { + applyBGPHoverState(lockedObject); + showBGPCollectorInfo(lockedObject); + } else if (lockedObjectType === "cable" && lockedObject) { showCableInfo(lockedObject); - } else if (lockedObjectType === 'satellite' && lockedSatellite) { - if (lockedSatelliteIndex !== null && lockedSatelliteIndex !== undefined) { - const satPositions = getSatellitePositions(); - if (satPositions && satPositions[lockedSatelliteIndex]) { - setSatelliteRingState(lockedSatelliteIndex, 'locked', satPositions[lockedSatelliteIndex].current); - } + } else if (lockedObjectType === "satellite" && lockedSatellite) { + const satPositions = getSatellitePositions(); + if (lockedSatelliteIndex !== null && satPositions?.[lockedSatelliteIndex]) { + setSatelliteRingState( + lockedSatelliteIndex, + "locked", + satPositions[lockedSatelliteIndex].current, + ); } showSatelliteInfo(lockedSatellite.properties); } else { + resetTransientBGPStates(); hideInfoCard(); } - - const earthPoint = screenToEarthCoords(event.clientX, event.clientY, camera, earth); + + const earthPoint = screenToEarthCoords( + event.clientX, + event.clientY, + camera, + earth, + document.body, + interactionRaycaster, + interactionMouse, + ); if (earthPoint) { const coords = vector3ToLatLon(earthPoint); updateCoordinatesDisplay(coords.lat, coords.lon, coords.alt); - - if (!isDragging) { - showTooltip(event.clientX + 10, event.clientY + 10, - `纬度: ${coords.lat}°
经度: ${coords.lon}°
海拔: ${coords.alt.toFixed(1)} km`); - } + showTooltip( + event.clientX + 10, + event.clientY + 10, + `纬度: ${coords.lat}°
经度: ${coords.lon}°
海拔: ${coords.alt.toFixed(1)} km`, + ); } else { hideTooltip(); } - - if (isDragging) { - if (Date.now() - dragStartTime > 500) { - isLongDrag = true; - } - - const deltaX = event.clientX - previousMousePosition.x; - const deltaY = event.clientY - previousMousePosition.y; - - earth.rotation.y += deltaX * 0.005; - earth.rotation.x += deltaY * 0.005; - - previousMousePosition = { x: event.clientX, y: event.clientY }; - } } function onMouseDown(event) { + if (isEventOnHud(event)) { + return; + } + + const earth = getEarth(); isDragging = true; dragStartTime = Date.now(); isLongDrag = false; previousMousePosition = { x: event.clientX, y: event.clientY }; - document.getElementById('container').classList.add('dragging'); + inertialVelocity = { x: 0, y: 0 }; + if (earth) { + targetRotation = { + x: earth.rotation.x, + y: earth.rotation.y, + }; + } + document.getElementById("container")?.classList.add("dragging"); hideTooltip(); } function onMouseUp() { isDragging = false; - document.getElementById('container').classList.remove('dragging'); + document.getElementById("container")?.classList.remove("dragging"); } -function onClick(event, camera, renderer) { +function onMouseLeave() { + hideTooltip(); +} + +function onClick(event) { const earth = getEarth(); if (!earth) return; - - const raycaster = new THREE.Raycaster(); - const mouse = new THREE.Vector2( - (event.clientX / window.innerWidth) * 2 - 1, - -(event.clientY / window.innerHeight) * 2 + 1 + if (isEventOnHud(event)) return; + + updatePointerFromEvent(event); + + const cableIntersects = interactionRaycaster.intersectObjects( + getFrontFacingCables(getCableLines()), ); - - raycaster.setFromCamera(mouse, camera); - - const allCableLines = getCableLines(); - const frontCables = getFrontFacingCables(allCableLines, camera); - const intersects = raycaster.intersectObjects(frontCables); - const satIntersects = getShowSatellites() ? raycaster.intersectObject(getSatellitePoints()) : []; - - if (intersects.length > 0 && getShowCables()) { + const frontFacingBGPAnomalyMarkers = getFrontFacingBGPMarkers( + getBGPAnomalyMarkers(), + ); + const frontFacingBGPCollectorMarkers = getFrontFacingBGPMarkers( + getBGPCollectorMarkers(), + ); + const bgpAnomalyIntersects = getShowBGP() + ? interactionRaycaster.intersectObjects(frontFacingBGPAnomalyMarkers) + : []; + const bgpCollectorIntersects = getShowBGP() + ? interactionRaycaster.intersectObjects(frontFacingBGPCollectorMarkers) + : []; + const satIntersects = getShowSatellites() + ? interactionRaycaster.intersectObject(getSatellitePoints()) + : []; + + const clickedBGPMarker = getShowBGP() + ? getPrimaryBGPClickTarget(event, bgpAnomalyIntersects, bgpCollectorIntersects) + : null; + + if (clickedBGPMarker?.userData?.type === "bgp") { clearLockedObject(); - - const clickedCable = intersects[0].object; + + const clickedMarker = clickedBGPMarker; + setBGPMarkerState(clickedMarker, "locked"); + + lockedObject = clickedMarker; + lockedObjectType = "bgp"; + lastBGPClickTime = Date.now(); + lastBGPClickCollector = clickedMarker.userData?.collector || null; + lastBGPClickType = "bgp"; + lastBGPClickPos = { x: event.clientX, y: event.clientY }; + setAutoRotate(false); + showBGPEventOverlay(clickedMarker, earth); + { + const relatedSatelliteIndices = getRelatedSatelliteIndicesForRegions( + getBGPRelatedRegions(clickedMarker), + { limit: 6, maxAngleDeg: 20 }, + ); + clickedMarker.userData.related_satellite_count = relatedSatelliteIndices.length; + highlightRelatedSatellites(relatedSatelliteIndices, "#7dd3fc"); + } + const incidentSummary = getBGPInfrastructureSummary(clickedMarker); + showBGPInfo(clickedMarker); + showStatusMessage( + `已选择BGP事件: ${clickedMarker.userData.collector} · ${incidentSummary.regionCount}个区域 / ${incidentSummary.cableCount}条相关海缆`, + "info", + ); + return; + } + + if (clickedBGPMarker?.userData?.type === "bgp_collector") { + clearLockedObject(); + + const clickedMarker = clickedBGPMarker; + setBGPMarkerState(clickedMarker, "locked"); + + lockedObject = clickedMarker; + lockedObjectType = "bgp_collector"; + lastBGPClickTime = Date.now(); + lastBGPClickCollector = clickedMarker.userData?.collector || null; + lastBGPClickType = "bgp_collector"; + lastBGPClickPos = { x: event.clientX, y: event.clientY }; + setAutoRotate(false); + showBGPCollectorCoverageOverlay(clickedMarker, earth); + clickedMarker.userData.related_satellite_count = 0; + showBGPCollectorInfo(clickedMarker); + showStatusMessage( + `已选择观测站: ${clickedMarker.userData.collector}`, + "info", + ); + return; + } + + if (cableIntersects.length > 0 && getShowCables()) { + clearLockedObject(); + + const clickedCable = cableIntersects[0].object; const cableId = clickedCable.userData.cableId; - setCableState(cableId, CABLE_STATE.LOCKED); - + lockedObject = clickedCable; - lockedObjectType = 'cable'; - cableLockedData = { ...clickedCable.userData }; - + lockedObjectType = "cable"; setAutoRotate(false); handleCableClick(clickedCable); - } else if (satIntersects.length > 0) { + return; + } + + if (satIntersects.length > 0) { const now = Date.now(); const clickX = event.clientX; const clickY = event.clientY; - - let selectedIndex; - const frontFacingSats = satIntersects.filter(s => isSatelliteFrontFacing(s.index, camera)); + + const frontFacingSats = satIntersects.filter((sat) => + isSatelliteFrontFacing(sat.index, camera), + ); if (frontFacingSats.length === 0) return; - - if (frontFacingSats.length > 1 && - now - lastSatClickTime < 500 && - Math.abs(clickX - lastSatClickPos.x) < 30 && - Math.abs(clickY - lastSatClickPos.y) < 30) { - const currentIdx = frontFacingSats.findIndex(s => s.index === lastSatClickIndex); - selectedIndex = frontFacingSats[(currentIdx + 1) % frontFacingSats.length].index; - } else { - selectedIndex = frontFacingSats[0].index; + + let selectedIndex = frontFacingSats[0].index; + if ( + frontFacingSats.length > 1 && + now - lastSatClickTime < 500 && + Math.abs(clickX - lastSatClickPos.x) < 30 && + Math.abs(clickY - lastSatClickPos.y) < 30 + ) { + const currentIdx = frontFacingSats.findIndex( + (sat) => sat.index === lastSatClickIndex, + ); + selectedIndex = + frontFacingSats[(currentIdx + 1) % frontFacingSats.length].index; } - + lastSatClickTime = now; lastSatClickIndex = selectedIndex; lastSatClickPos = { x: clickX, y: clickY }; - + const sat = selectSatellite(selectedIndex); - - if (sat && sat.properties) { - clearLockedObject(); - - lockedObject = sat; - lockedObjectType = 'satellite'; - lockedSatellite = sat; - lockedSatelliteIndex = selectedIndex; - window.lockedSatelliteIndex = selectedIndex; - showPredictedOrbit(sat); - setAutoRotate(false); - - const satPositions = getSatellitePositions(); - if (satPositions && satPositions[selectedIndex]) { - setSatelliteRingState(selectedIndex, 'locked', satPositions[selectedIndex].current); - } - - const props = sat.properties; - - const meanMotion = props.mean_motion || 0; - const period = meanMotion > 0 ? (1440 / meanMotion).toFixed(1) : '-'; - - const ecc = props.eccentricity || 0; - const earthRadius = 6371; - const perigee = (earthRadius * (1 - ecc)).toFixed(0); - const apogee = (earthRadius * (1 + ecc)).toFixed(0); - - showInfoCard('satellite', { - name: props.name, - norad_id: props.norad_cat_id, - inclination: props.inclination ? props.inclination.toFixed(2) : '-', - period: period, - perigee: perigee, - apogee: apogee - }); - - showStatusMessage('已选择: ' + props.name, 'info'); - } - } else { - if (!isLongDrag) { - clearLockedObject(); - setAutoRotate(true); - clearCableSelection(); + if (!sat?.properties) return; + + clearLockedObject(); + + lockedObject = sat; + lockedObjectType = "satellite"; + lockedSatellite = sat; + lockedSatelliteIndex = selectedIndex; + setLockedSatelliteIndex(selectedIndex); + showPredictedOrbit(sat); + setAutoRotate(false); + + const satPositions = getSatellitePositions(); + if (satPositions?.[selectedIndex]) { + setSatelliteRingState( + selectedIndex, + "locked", + satPositions[selectedIndex].current, + ); } + + showSatelliteInfo(sat.properties); + showStatusMessage("已选择: " + sat.properties.name, "info"); + return; + } + + if (!isLongDrag) { + clearLockedObject(); + setAutoRotate(true); } } function animate() { - requestAnimationFrame(animate); - - const earth = getEarth(); - - if (getAutoRotate() && earth) { - earth.rotation.y += CONFIG.rotationSpeed; - } - - applyCableVisualState(); - - if (lockedObjectType === 'cable' && lockedObject) { - applyLandingPointVisualState(lockedObject.userData.name, false); - } else if (lockedObjectType === 'satellite' && lockedSatellite) { - applyLandingPointVisualState(null, true); - } else { - resetLandingPointVisualState(); - } - - updateSatellitePositions(16); - - const satPositions = getSatellitePositions(); - - // 更新呼吸动画相位 - updateBreathingPhase(); + if (destroyed) return; - if (lockedObjectType === 'satellite' && lockedSatelliteIndex !== null) { - if (satPositions && satPositions[lockedSatelliteIndex]) { - updateLockedRingPosition(satPositions[lockedSatelliteIndex].current); + animationFrameId = requestAnimationFrame(animate); + + const earth = getEarth(); + const deltaTime = clock.getDelta() * 1000; + const hasInertia = + Math.abs(inertialVelocity.x) > INERTIA_MIN_VELOCITY || + Math.abs(inertialVelocity.y) > INERTIA_MIN_VELOCITY; + + if (getAutoRotate() && earth) { + earth.rotation.y += CONFIG.rotationSpeed * (deltaTime / 16); + + // Keep the drag target aligned with autorotation only when the user is not + // actively dragging and there is no residual inertial motion to preserve. + if (!isDragging && !hasInertia) { + targetRotation.y = earth.rotation.y; + targetRotation.x = earth.rotation.x; } - } else if (hoveredSatelliteIndex !== null && satPositions && satPositions[hoveredSatelliteIndex]) { + } + + if (earth) { + if (isDragging) { + // Smoothly follow the drag target to match the legacy interaction feel. + earth.rotation.x += + (targetRotation.x - earth.rotation.x) * DRAG_SMOOTHING_FACTOR; + earth.rotation.y += + (targetRotation.y - earth.rotation.y) * DRAG_SMOOTHING_FACTOR; + } else if ( + Math.abs(inertialVelocity.x) > INERTIA_MIN_VELOCITY || + Math.abs(inertialVelocity.y) > INERTIA_MIN_VELOCITY + ) { + // Continue rotating after release and gradually decay the motion. + targetRotation.x += inertialVelocity.x * (deltaTime / 16); + targetRotation.y += inertialVelocity.y * (deltaTime / 16); + earth.rotation.x += + (targetRotation.x - earth.rotation.x) * DRAG_SMOOTHING_FACTOR; + earth.rotation.y += + (targetRotation.y - earth.rotation.y) * DRAG_SMOOTHING_FACTOR; + inertialVelocity.x *= Math.pow(INERTIA_DAMPING, deltaTime / 16); + inertialVelocity.y *= Math.pow(INERTIA_DAMPING, deltaTime / 16); + } else { + inertialVelocity.x = 0; + inertialVelocity.y = 0; + targetRotation.x = earth.rotation.x; + targetRotation.y = earth.rotation.y; + } + } + + applyCableVisualState(); + updateBGPVisualState(lockedObjectType, lockedObject, camera); + + if (lockedObjectType === "cable" && lockedObject) { + applyLandingPointVisualState(lockedObject.userData.name, false, camera); + } else if ( + lockedObjectType === "satellite" && lockedSatellite + ) { + applyLandingPointVisualState(null, true, camera); + } else if (lockedObjectType === "bgp" && lockedObject) { + const relatedCableNames = getBGPRelatedCableNames(lockedObject); + clearAllCableStates(); + relatedCableNames.forEach((name) => { + getCableLines().forEach((cable) => { + if (cable.userData?.name === name) { + setCableState(cable.userData.cableId, CABLE_STATE.LOCKED); + } + }); + }); + applyLandingPointVisualState( + relatedCableNames.length > 0 ? relatedCableNames : null, + relatedCableNames.length === 0, + camera, + ); + } else if (lockedObjectType === "bgp_collector" && lockedObject) { + clearAllCableStates(); + resetLandingPointVisualState(camera); + } else { + resetLandingPointVisualState(camera); + } + + updateSatellitePositions(deltaTime); + updateBreathingPhase(deltaTime); + updateRelatedSatelliteHighlights(); + + const satPositions = getSatellitePositions(); + if ( + lockedObjectType === "satellite" && + lockedSatelliteIndex !== null && + satPositions?.[lockedSatelliteIndex] + ) { + updateLockedRingPosition(satPositions[lockedSatelliteIndex].current); + } else if ( + hoveredSatelliteIndex !== null && + satPositions?.[hoveredSatelliteIndex] + ) { updateHoverRingPosition(satPositions[hoveredSatelliteIndex].current); } - + renderer.render(scene, camera); } -window.clearLockedCable = function() { +export function destroy() { + if (destroyed) return; + destroyed = true; + currentLoadToken += 1; + isDataLoading = false; + + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + animationFrameId = null; + } + + teardownControls(); + while (cleanupFns.length) { + const cleanup = cleanupFns.pop(); + cleanup?.(); + } + clearLockedObject(); -}; + clearCableData(getEarth()); + clearBGPData(getEarth()); + resetSatelliteState(); + clearUiState(); -window.clearSelection = function() { - hideInfoCard(); - window.clearLockedCable(); -}; + if (scene) { + disposeSceneObject(scene); + } -document.addEventListener('DOMContentLoaded', init); + if (renderer) { + renderer.dispose(); + if (typeof renderer.forceContextLoss === "function") { + renderer.forceContextLoss(); + } + renderer.domElement?.remove(); + } + + scene = null; + camera = null; + renderer = null; + initialized = false; + + delete window.__planetEarth; +} + +document.addEventListener("DOMContentLoaded", init); diff --git a/frontend/public/earth/js/satellites.js b/frontend/public/earth/js/satellites.js index 597ed25a..c237210e 100644 --- a/frontend/public/earth/js/satellites.js +++ b/frontend/public/earth/js/satellites.js @@ -1,68 +1,201 @@ // satellites.js - Satellite visualization module with real SGP4 positions and animations -import * as THREE from 'three'; -import { twoline2satrec, sgp4, propagate, degreesToRadians, radiansToDegrees, eciToGeodetic } from 'satellite.js'; -import { CONFIG, SATELLITE_CONFIG } from './constants.js'; +import * as THREE from "three"; +import { twoline2satrec, propagate } from "satellite.js"; +import { CONFIG, SATELLITE_CONFIG } from "./constants.js"; +import { latLonToVector3 } from "./utils.js"; let satellitePoints = null; let satelliteTrails = null; let satelliteData = []; let showSatellites = false; let showTrails = true; -let animationTime = 0; let selectedSatellite = null; let satellitePositions = []; let hoverRingSprite = null; let lockedRingSprite = null; let lockedDotSprite = null; -export let breathingPhase = 0; +let predictedOrbitLine = null; +let relatedSatelliteSprites = []; +let earthObjRef = null; +let sceneRef = null; +let cameraRef = null; +let lockedSatelliteIndex = null; +let hoveredSatelliteIndex = null; +let positionUpdateAccumulator = 0; +let satelliteCapacity = 0; +let selectedSatelliteLegendKey = null; -export function updateBreathingPhase() { - breathingPhase += SATELLITE_CONFIG.breathingSpeed; -} - -const SATELLITE_API = SATELLITE_CONFIG.apiPath + '?limit=' + SATELLITE_CONFIG.maxCount; -const MAX_SATELLITES = SATELLITE_CONFIG.maxCount; const TRAIL_LENGTH = SATELLITE_CONFIG.trailLength; const DOT_TEXTURE_SIZE = 32; +const POSITION_UPDATE_INTERVAL_MS = 250; + +const scratchWorldSatellitePosition = new THREE.Vector3(); +const scratchToCamera = new THREE.Vector3(); +const scratchToSatellite = new THREE.Vector3(); + +export let breathingPhase = 0; + +const SATELLITE_LEGEND_RULES = [ + { + key: "starlink", + label: "Starlink", + color: "#00e6ff", + match: (props) => (props?.name || "").includes("STARLINK"), + }, + { + key: "geo", + label: "GEO / 倾角 20-30", + color: "#ffcc00", + match: (props) => { + const inclination = props?.inclination || 53; + return inclination > 20 && inclination < 30; + }, + }, + { + key: "iridium", + label: "Iridium", + color: "#ff8000", + match: (props) => (props?.name || "").includes("IRIDIUM"), + }, + { + key: "mid-inclination", + label: "倾角 50-70", + color: "#00ff4d", + match: (props) => { + const inclination = props?.inclination || 53; + return inclination > 50 && inclination < 70; + }, + }, + { + key: "other", + label: "其他卫星", + color: "#ffffff", + match: () => true, + }, +]; + +export function updateBreathingPhase(deltaTime = 16) { + breathingPhase += SATELLITE_CONFIG.breathingSpeed * (deltaTime / 16); +} + +export function getSatelliteLegendItems() { + const presentKeys = new Set(); + + satelliteData.forEach((satellite) => { + const props = satellite?.properties || {}; + const rule = SATELLITE_LEGEND_RULES.find((item) => item.match(props)); + if (rule) { + presentKeys.add(rule.key); + } + }); + + if (presentKeys.size === 0) { + return SATELLITE_LEGEND_RULES.map(({ label, color }) => ({ label, color })); + } + + const items = SATELLITE_LEGEND_RULES + .filter((item) => presentKeys.has(item.key)) + .map(({ key, label, color }) => ({ key, label, color })); + + if (!selectedSatelliteLegendKey) { + return items.map(({ label, color }) => ({ label, color })); + } + + const selectedIndex = items.findIndex( + (item) => item.key === selectedSatelliteLegendKey, + ); + + if (selectedIndex > 0) { + const [selectedItem] = items.splice(selectedIndex, 1); + items.unshift(selectedItem); + } + + return items.map(({ label, color }) => ({ label, color })); +} + +export function setSelectedSatelliteLegend(props) { + const rule = SATELLITE_LEGEND_RULES.find((item) => + item.match(props || {}), + ); + selectedSatelliteLegendKey = rule?.key || null; +} + +export function clearSelectedSatelliteLegend() { + selectedSatelliteLegendKey = null; +} + +function disposeMaterial(material) { + if (!material) return; + if (Array.isArray(material)) { + material.forEach(disposeMaterial); + return; + } + if (material.map) { + material.map.dispose(); + } + material.dispose(); +} + +function disposeObject3D(object, parent = earthObjRef) { + if (!object) return; + if (parent) { + parent.remove(object); + } else if (object.parent) { + object.parent.remove(object); + } + if (object.geometry) { + object.geometry.dispose(); + } + if (object.material) { + disposeMaterial(object.material); + } +} function createDotTexture() { - const canvas = document.createElement('canvas'); + const canvas = document.createElement("canvas"); canvas.width = DOT_TEXTURE_SIZE; canvas.height = DOT_TEXTURE_SIZE; - const ctx = canvas.getContext('2d'); + const ctx = canvas.getContext("2d"); const center = DOT_TEXTURE_SIZE / 2; const radius = center - 2; - - const gradient = ctx.createRadialGradient(center, center, 0, center, center, radius); - gradient.addColorStop(0, 'rgba(255, 255, 255, 1)'); - gradient.addColorStop(0.5, 'rgba(255, 255, 255, 0.8)'); - gradient.addColorStop(1, 'rgba(255, 255, 255, 0)'); - + + const gradient = ctx.createRadialGradient( + center, + center, + 0, + center, + center, + radius, + ); + gradient.addColorStop(0, "rgba(255, 255, 255, 1)"); + gradient.addColorStop(0.5, "rgba(255, 255, 255, 0.8)"); + gradient.addColorStop(1, "rgba(255, 255, 255, 0)"); + ctx.fillStyle = gradient; ctx.beginPath(); ctx.arc(center, center, radius, 0, Math.PI * 2); ctx.fill(); - + const texture = new THREE.CanvasTexture(canvas); texture.needsUpdate = true; return texture; } -function createRingTexture(innerRadius, outerRadius, color = '#ffffff') { +function createRingTexture(innerRadius, outerRadius, color = "#ffffff") { const size = DOT_TEXTURE_SIZE * 2; - const canvas = document.createElement('canvas'); + const canvas = document.createElement("canvas"); canvas.width = size; canvas.height = size; - const ctx = canvas.getContext('2d'); + const ctx = canvas.getContext("2d"); const center = size / 2; - + ctx.strokeStyle = color; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(center, center, (innerRadius + outerRadius) / 2, 0, Math.PI * 2); ctx.stroke(); - + const texture = new THREE.CanvasTexture(canvas); texture.needsUpdate = true; return texture; @@ -70,16 +203,10 @@ function createRingTexture(innerRadius, outerRadius, color = '#ffffff') { export function createSatellites(scene, earthObj) { initSatelliteScene(scene, earthObj); - - const positions = new Float32Array(MAX_SATELLITES * 3); - const colors = new Float32Array(MAX_SATELLITES * 3); - const dotTexture = createDotTexture(); - + const pointsGeometry = new THREE.BufferGeometry(); - pointsGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); - pointsGeometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); - + const pointsMaterial = new THREE.PointsMaterial({ size: SATELLITE_CONFIG.dotSize, map: dotTexture, @@ -87,269 +214,400 @@ export function createSatellites(scene, earthObj) { transparent: true, opacity: 0.9, sizeAttenuation: false, - alphaTest: 0.1 + alphaTest: 0.1, }); - + satellitePoints = new THREE.Points(pointsGeometry, pointsMaterial); satellitePoints.visible = false; - satellitePoints.userData = { type: 'satellitePoints' }; - + satellitePoints.userData = { type: "satellitePoints" }; + const originalScale = { x: 1, y: 1, z: 1 }; - satellitePoints.onBeforeRender = (renderer, scene, camera, geometry, material) => { + satellitePoints.onBeforeRender = () => { if (earthObj && earthObj.scale.x !== 1) { satellitePoints.scale.set( originalScale.x / earthObj.scale.x, originalScale.y / earthObj.scale.y, - originalScale.z / earthObj.scale.z + originalScale.z / earthObj.scale.z, ); } else { - satellitePoints.scale.set(originalScale.x, originalScale.y, originalScale.z); + satellitePoints.scale.set( + originalScale.x, + originalScale.y, + originalScale.z, + ); } }; - + earthObj.add(satellitePoints); - - const trailPositions = new Float32Array(MAX_SATELLITES * TRAIL_LENGTH * 3); - const trailColors = new Float32Array(MAX_SATELLITES * TRAIL_LENGTH * 3); - + const trailGeometry = new THREE.BufferGeometry(); - trailGeometry.setAttribute('position', new THREE.BufferAttribute(trailPositions, 3)); - trailGeometry.setAttribute('color', new THREE.BufferAttribute(trailColors, 3)); - + const trailMaterial = new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, opacity: 0.3, - blending: THREE.AdditiveBlending + blending: THREE.AdditiveBlending, }); - + satelliteTrails = new THREE.LineSegments(trailGeometry, trailMaterial); satelliteTrails.visible = false; - satelliteTrails.userData = { type: 'satelliteTrails' }; + satelliteTrails.userData = { type: "satelliteTrails" }; earthObj.add(satelliteTrails); - - satellitePositions = []; - for (let i = 0; i < MAX_SATELLITES; i++) { - satellitePositions.push({ - current: new THREE.Vector3(), - trail: [], - trailIndex: 0, - trailCount: 0 - }); - } - + ensureSatelliteCapacity(0); + + positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS; return satellitePoints; } +function getRequestedSatelliteLimit() { + return SATELLITE_CONFIG.maxCount < 0 ? null : SATELLITE_CONFIG.maxCount; +} + +function createSatellitePositionState() { + return { + current: new THREE.Vector3(), + trail: [], + trailIndex: 0, + trailCount: 0, + }; +} + +function ensureSatelliteCapacity(count) { + if (!satellitePoints || !satelliteTrails) return; + + const nextCapacity = Math.max(count, 0); + if (nextCapacity === satelliteCapacity) return; + + const positions = new Float32Array(nextCapacity * 3); + const colors = new Float32Array(nextCapacity * 3); + satellitePoints.geometry.setAttribute( + "position", + new THREE.BufferAttribute(positions, 3), + ); + satellitePoints.geometry.setAttribute( + "color", + new THREE.BufferAttribute(colors, 3), + ); + satellitePoints.geometry.setDrawRange(0, 0); + + const trailPositions = new Float32Array(nextCapacity * TRAIL_LENGTH * 3); + const trailColors = new Float32Array(nextCapacity * TRAIL_LENGTH * 3); + satelliteTrails.geometry.setAttribute( + "position", + new THREE.BufferAttribute(trailPositions, 3), + ); + satelliteTrails.geometry.setAttribute( + "color", + new THREE.BufferAttribute(trailColors, 3), + ); + + satellitePositions = Array.from( + { length: nextCapacity }, + createSatellitePositionState, + ); + satelliteCapacity = nextCapacity; +} + function computeSatellitePosition(satellite, time) { try { const props = satellite.properties; if (!props || !props.norad_cat_id) { return null; } - - const noradId = props.norad_cat_id; - const inclination = props.inclination || 53; - const raan = props.raan || 0; - const eccentricity = props.eccentricity || 0.0001; - const argOfPerigee = props.arg_of_perigee || 0; - const meanAnomaly = props.mean_anomaly || 0; - const meanMotion = props.mean_motion || 15; - const epoch = props.epoch || ''; - - // Simplified epoch calculation - let epochDate = epoch && epoch.length >= 10 ? new Date(epoch) : time; - const epochYear = epochDate.getUTCFullYear() % 100; - const startOfYear = new Date(Date.UTC(epochDate.getUTCFullYear(), 0, 1)); - const dayOfYear = Math.floor((epochDate - startOfYear) / 86400000) + 1; - const msOfDay = epochDate.getUTCHours() * 3600000 + epochDate.getUTCMinutes() * 60000 + epochDate.getUTCSeconds() * 1000 + epochDate.getUTCMilliseconds(); - const dayFraction = msOfDay / 86400000; - const epochStr = String(epochYear).padStart(2, '0') + String(dayOfYear).padStart(3, '0') + '.' + dayFraction.toFixed(8).substring(2); - - // Format eccentricity as "0.0001652" (7 chars after decimal) - const eccStr = '0' + eccentricity.toFixed(7); - const tleLine1 = `1 ${noradId.toString().padStart(5)}U 00001A ${epochStr} .00000000 00000-0 00000-0 0 9999`; - const tleLine2 = `2 ${noradId.toString().padStart(5)} ${raan.toFixed(4).padStart(8)} ${inclination.toFixed(4).padStart(8)} ${eccStr.substring(1)} ${argOfPerigee.toFixed(4).padStart(8)} ${meanAnomaly.toFixed(4).padStart(8)} ${meanMotion.toFixed(8).padStart(11)} 0 9999`; - - const satrec = twoline2satrec(tleLine1, tleLine2); + + const satrec = buildSatrecFromProperties(props, time); if (!satrec || satrec.error) { return null; } - + const positionAndVelocity = propagate(satrec, time); if (!positionAndVelocity || !positionAndVelocity.position) { return null; } - + const x = positionAndVelocity.position.x; const y = positionAndVelocity.position.y; const z = positionAndVelocity.position.z; - - if (!x || !y || !z) { + + if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) { return null; } - + const r = Math.sqrt(x * x + y * y + z * z); - const earthRadius = 6371; - const displayRadius = CONFIG.earthRadius * (earthRadius / 6371) * 1.05; - + const displayRadius = CONFIG.earthRadius * 1.05; const scale = displayRadius / r; - + return new THREE.Vector3(x * scale, y * scale, z * scale); - } catch (e) { + } catch (error) { return null; } } +function buildSatrecFromProperties(props, fallbackTime) { + if (props.tle_line1 && props.tle_line2) { + // Prefer source-provided TLE lines so the client does not need to rebuild them. + const satrec = twoline2satrec(props.tle_line1, props.tle_line2); + if (!satrec.error) { + return satrec; + } + } + + const tleLines = buildTleLinesFromElements(props, fallbackTime); + if (!tleLines) { + return null; + } + + return twoline2satrec(tleLines.line1, tleLines.line2); +} + +function computeTleChecksum(line) { + let sum = 0; + + for (const char of line.slice(0, 68)) { + if (char >= "0" && char <= "9") { + sum += Number(char); + } else if (char === "-") { + sum += 1; + } + } + + return String(sum % 10); +} + +function buildTleLinesFromElements(props, fallbackTime) { + if (!props?.norad_cat_id) { + return null; + } + + const requiredValues = [ + props.inclination, + props.raan, + props.eccentricity, + props.arg_of_perigee, + props.mean_anomaly, + props.mean_motion, + ]; + if (requiredValues.some((value) => value === null || value === undefined)) { + return null; + } + + const epochDate = + props.epoch && String(props.epoch).length >= 10 + ? new Date(props.epoch) + : fallbackTime; + if (Number.isNaN(epochDate.getTime())) { + return null; + } + + const epochYear = epochDate.getUTCFullYear() % 100; + const startOfYear = new Date(Date.UTC(epochDate.getUTCFullYear(), 0, 1)); + const dayOfYear = Math.floor((epochDate - startOfYear) / 86400000) + 1; + const msOfDay = + epochDate.getUTCHours() * 3600000 + + epochDate.getUTCMinutes() * 60000 + + epochDate.getUTCSeconds() * 1000 + + epochDate.getUTCMilliseconds(); + const dayFraction = msOfDay / 86400000; + const epochStr = + String(epochYear).padStart(2, "0") + + String(dayOfYear).padStart(3, "0") + + dayFraction.toFixed(8).slice(1); + + const eccentricityDigits = Math.round(Number(props.eccentricity) * 1e7) + .toString() + .padStart(7, "0"); + + // Keep a local fallback for historical rows that do not have stored TLE lines yet. + const line1Core = `1 ${String(props.norad_cat_id).padStart(5, "0")}U 00001A ${epochStr} .00000000 00000-0 00000-0 0 999`; + const line2Core = `2 ${String(props.norad_cat_id).padStart(5, "0")} ${Number( + props.inclination, + ) + .toFixed(4) + .padStart( + 8, + )} ${Number(props.raan).toFixed(4).padStart(8)} ${eccentricityDigits} ${Number( + props.arg_of_perigee, + ) + .toFixed(4) + .padStart(8)} ${Number(props.mean_anomaly).toFixed(4).padStart(8)} ${Number( + props.mean_motion, + ) + .toFixed(8) + .padStart(11)}00000`; + + return { + line1: line1Core + computeTleChecksum(line1Core), + line2: line2Core + computeTleChecksum(line2Core), + }; +} + function generateFallbackPosition(satellite, index, total) { const radius = CONFIG.earthRadius + 5; - + const noradId = satellite.properties?.norad_cat_id || index; const inclination = satellite.properties?.inclination || 53; const raan = satellite.properties?.raan || 0; const meanAnomaly = satellite.properties?.mean_anomaly || 0; - - const hash = String(noradId).split('').reduce((a, b) => a + b.charCodeAt(0), 0); + + const hash = String(noradId) + .split("") + .reduce((a, b) => a + b.charCodeAt(0), 0); const randomOffset = (hash % 1000) / 1000; - + const normalizedIndex = index / total; - const theta = normalizedIndex * Math.PI * 2 * 10 + (raan * Math.PI / 180); - const phi = (inclination * Math.PI / 180) + (meanAnomaly * Math.PI / 180 * 0.1); - + const theta = normalizedIndex * Math.PI * 2 * 10 + (raan * Math.PI) / 180; + const phi = + (inclination * Math.PI) / 180 + ((meanAnomaly * Math.PI) / 180) * 0.1; + const adjustedPhi = Math.abs(phi % Math.PI); const adjustedTheta = theta + randomOffset * Math.PI * 2; - + const x = radius * Math.sin(adjustedPhi) * Math.cos(adjustedTheta); const y = radius * Math.cos(adjustedPhi); const z = radius * Math.sin(adjustedPhi) * Math.sin(adjustedTheta); - + return new THREE.Vector3(x, y, z); } export async function loadSatellites() { - try { - const response = await fetch(SATELLITE_API); - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - - const data = await response.json(); - satelliteData = data.features || []; - - console.log(`Loaded ${satelliteData.length} satellites`); - return satelliteData.length; - } catch (error) { - console.error('Failed to load satellites:', error); - return []; + const limit = getRequestedSatelliteLimit(); + const url = new URL(SATELLITE_CONFIG.apiPath, window.location.origin); + if (limit !== null) { + url.searchParams.set("limit", String(limit)); } + + const response = await fetch(url.toString()); + if (!response.ok) { + throw new Error(`卫星接口返回 HTTP ${response.status}`); + } + + const data = await response.json(); + satelliteData = data.features || []; + ensureSatelliteCapacity(satelliteData.length); + positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS; + return satelliteData.length; } -export function updateSatellitePositions(deltaTime = 0) { +export function updateSatellitePositions(deltaTime = 0, force = false) { if (!satellitePoints || satelliteData.length === 0) return; - - animationTime += deltaTime * 0.001; - + + const shouldUpdateTrails = + showSatellites || showTrails || lockedSatelliteIndex !== null; + positionUpdateAccumulator += deltaTime; + + if (!force && positionUpdateAccumulator < POSITION_UPDATE_INTERVAL_MS) { + return; + } + + const elapsedMs = Math.max( + positionUpdateAccumulator, + POSITION_UPDATE_INTERVAL_MS, + ); + positionUpdateAccumulator = 0; + const positions = satellitePoints.geometry.attributes.position.array; const colors = satellitePoints.geometry.attributes.color.array; - const trailPositions = satelliteTrails.geometry.attributes.position.array; const trailColors = satelliteTrails.geometry.attributes.color.array; - - const baseTime = new Date(); - const count = Math.min(satelliteData.length, MAX_SATELLITES); - + const baseTime = new Date(Date.now() + elapsedMs); + const count = Math.min(satelliteData.length, satelliteCapacity); + for (let i = 0; i < count; i++) { const satellite = satelliteData[i]; const props = satellite.properties; - const timeOffset = (i / count) * 2 * Math.PI * 0.1; - const adjustedTime = new Date(baseTime.getTime() + timeOffset * 1000 * 60 * 10); - + const adjustedTime = new Date( + baseTime.getTime() + timeOffset * 1000 * 60 * 10, + ); + let pos = computeSatellitePosition(satellite, adjustedTime); - if (!pos) { pos = generateFallbackPosition(satellite, i, count); } - + satellitePositions[i].current.copy(pos); - - const satPos = satellitePositions[i]; - if (i !== window.lockedSatelliteIndex) { + + if (shouldUpdateTrails && i !== lockedSatelliteIndex) { + const satPos = satellitePositions[i]; satPos.trail[satPos.trailIndex] = pos.clone(); satPos.trailIndex = (satPos.trailIndex + 1) % TRAIL_LENGTH; if (satPos.trailCount < TRAIL_LENGTH) satPos.trailCount++; } - + positions[i * 3] = pos.x; positions[i * 3 + 1] = pos.y; positions[i * 3 + 2] = pos.z; - + const inclination = props?.inclination || 53; - const name = props?.name || ''; - const isStarlink = name.includes('STARLINK'); + const name = props?.name || ""; + const isStarlink = name.includes("STARLINK"); const isGeo = inclination > 20 && inclination < 30; - const isIridium = name.includes('IRIDIUM'); - - let r, g, b; + const isIridium = name.includes("IRIDIUM"); + + let r; + let g; + let b; if (isStarlink) { - r = 0.0; g = 0.9; b = 1.0; + r = 0.0; + g = 0.9; + b = 1.0; } else if (isGeo) { - r = 1.0; g = 0.8; b = 0.0; + r = 1.0; + g = 0.8; + b = 0.0; } else if (isIridium) { - r = 1.0; g = 0.5; b = 0.0; + r = 1.0; + g = 0.5; + b = 0.0; } else if (inclination > 50 && inclination < 70) { - r = 0.0; g = 1.0; b = 0.3; + r = 0.0; + g = 1.0; + b = 0.3; } else { - r = 1.0; g = 1.0; b = 1.0; + r = 1.0; + g = 1.0; + b = 1.0; } - + colors[i * 3] = r; colors[i * 3 + 1] = g; colors[i * 3 + 2] = b; - - const sp = satellitePositions[i]; - const trail = sp.trail; - const tc = sp.trailCount; - const ti = sp.trailIndex; - + + const satPosition = satellitePositions[i]; for (let j = 0; j < TRAIL_LENGTH; j++) { const trailIdx = (i * TRAIL_LENGTH + j) * 3; - - if (j < tc) { - const idx = (ti - tc + j + TRAIL_LENGTH) % TRAIL_LENGTH; - const t = trail[idx]; - if (t) { - trailPositions[trailIdx] = t.x; - trailPositions[trailIdx + 1] = t.y; - trailPositions[trailIdx + 2] = t.z; - const alpha = (j + 1) / tc; + + if (j < satPosition.trailCount) { + const idx = + (satPosition.trailIndex - satPosition.trailCount + j + TRAIL_LENGTH) % + TRAIL_LENGTH; + const trailPoint = satPosition.trail[idx]; + if (trailPoint) { + trailPositions[trailIdx] = trailPoint.x; + trailPositions[trailIdx + 1] = trailPoint.y; + trailPositions[trailIdx + 2] = trailPoint.z; + const alpha = (j + 1) / satPosition.trailCount; trailColors[trailIdx] = r * alpha; trailColors[trailIdx + 1] = g * alpha; trailColors[trailIdx + 2] = b * alpha; - } else { - trailPositions[trailIdx] = pos.x; - trailPositions[trailIdx + 1] = pos.y; - trailPositions[trailIdx + 2] = pos.z; - trailColors[trailIdx] = 0; - trailColors[trailIdx + 1] = 0; - trailColors[trailIdx + 2] = 0; + continue; } - } else { - trailPositions[trailIdx] = pos.x; - trailPositions[trailIdx + 1] = pos.y; - trailPositions[trailIdx + 2] = pos.z; - trailColors[trailIdx] = 0; - trailColors[trailIdx + 1] = 0; - trailColors[trailIdx + 2] = 0; } + + trailPositions[trailIdx] = pos.x; + trailPositions[trailIdx + 1] = pos.y; + trailPositions[trailIdx + 2] = pos.z; + trailColors[trailIdx] = 0; + trailColors[trailIdx + 1] = 0; + trailColors[trailIdx + 2] = 0; } } - - for (let i = count; i < MAX_SATELLITES; i++) { + + for (let i = count; i < satelliteCapacity; i++) { positions[i * 3] = 0; positions[i * 3 + 1] = 0; positions[i * 3 + 2] = 0; - + for (let j = 0; j < TRAIL_LENGTH; j++) { const trailIdx = (i * TRAIL_LENGTH + j) * 3; trailPositions[trailIdx] = 0; @@ -357,13 +615,24 @@ export function updateSatellitePositions(deltaTime = 0) { trailPositions[trailIdx + 2] = 0; } } - + satellitePoints.geometry.attributes.position.needsUpdate = true; satellitePoints.geometry.attributes.color.needsUpdate = true; satellitePoints.geometry.setDrawRange(0, count); - + satelliteTrails.geometry.attributes.position.needsUpdate = true; satelliteTrails.geometry.attributes.color.needsUpdate = true; + + // Keep the hover ring synced with the propagated satellite position even + // when the pointer stays still and no new hover event is emitted. + if ( + hoveredSatelliteIndex !== null && + hoveredSatelliteIndex >= 0 && + hoveredSatelliteIndex < count && + hoveredSatelliteIndex !== lockedSatelliteIndex + ) { + updateHoverRingPosition(satellitePositions[hoveredSatelliteIndex].current); + } } export function toggleSatellites(visible) { @@ -415,254 +684,437 @@ export function getSatellitePositions() { return satellitePositions; } -export function isSatelliteFrontFacing(index, camera) { - if (!earthObjRef || !camera) return true; - const positions = satellitePositions; - if (!positions || !positions[index]) return true; - - const satPos = positions[index].current; - if (!satPos) return true; - - const worldSatPos = satPos.clone().applyMatrix4(earthObjRef.matrixWorld); - const toCamera = new THREE.Vector3().subVectors(camera.position, earthObjRef.position).normalize(); - const toSat = new THREE.Vector3().subVectors(worldSatPos, earthObjRef.position).normalize(); - - return toCamera.dot(toSat) > 0; +export function setSatelliteCamera(camera) { + cameraRef = camera; } -let earthObjRef = null; -let sceneRef = null; +export function setLockedSatelliteIndex(index) { + lockedSatelliteIndex = index; +} -export function showHoverRing(position, isLocked = false) { - if (!sceneRef || !earthObjRef) return; - - const ringTexture = createRingTexture(8, 12, isLocked ? '#ffcc00' : '#ffffff'); - const spriteMaterial = new THREE.SpriteMaterial({ - map: ringTexture, - transparent: true, - opacity: 0.8, - depthTest: false, - sizeAttenuation: false - }); - - const ringSize = SATELLITE_CONFIG.ringSize; - const sprite = new THREE.Sprite(spriteMaterial); - sprite.position.copy(position); - - const camera = window.camera; - const cameraDistance = camera ? camera.position.distanceTo(position) : 400; - const scale = ringSize; - sprite.scale.set(scale, scale, 1); - - earthObjRef.add(sprite); - - if (isLocked) { - if (lockedRingSprite) { - earthObjRef.remove(lockedRingSprite); - } - lockedRingSprite = sprite; - - if (lockedDotSprite) { - earthObjRef.remove(lockedDotSprite); - } - const dotCanvas = createBrighterDotCanvas(); - const dotTexture = new THREE.CanvasTexture(dotCanvas); - dotTexture.needsUpdate = true; - const dotMaterial = new THREE.SpriteMaterial({ - map: dotTexture, - transparent: true, - opacity: 1.0, - depthTest: false - }); - lockedDotSprite = new THREE.Sprite(dotMaterial); - lockedDotSprite.position.copy(position); - lockedDotSprite.scale.set(4 * cameraDistance / 200, 4 * cameraDistance / 200, 1); - - earthObjRef.add(lockedDotSprite); - } else { - if (hoverRingSprite) { - earthObjRef.remove(hoverRingSprite); - } - hoverRingSprite = sprite; - } - - return sprite; +export function setHoveredSatelliteIndex(index) { + hoveredSatelliteIndex = index; +} + +export function isSatelliteFrontFacing(index, camera = cameraRef) { + if (!earthObjRef || !camera) return true; + if (!satellitePositions || !satellitePositions[index]) return true; + + const satPos = satellitePositions[index].current; + if (!satPos) return true; + + scratchWorldSatellitePosition + .copy(satPos) + .applyMatrix4(earthObjRef.matrixWorld); + scratchToCamera.subVectors(camera.position, earthObjRef.position).normalize(); + scratchToSatellite + .subVectors(scratchWorldSatellitePosition, earthObjRef.position) + .normalize(); + + return scratchToCamera.dot(scratchToSatellite) > 0; } function createBrighterDotCanvas() { const size = DOT_TEXTURE_SIZE * 2; - const canvas = document.createElement('canvas'); + const canvas = document.createElement("canvas"); canvas.width = size; canvas.height = size; - const ctx = canvas.getContext('2d'); + const ctx = canvas.getContext("2d"); const center = size / 2; - const gradient = ctx.createRadialGradient(center, center, 0, center, center, center); - gradient.addColorStop(0, 'rgba(255, 255, 200, 1)'); - gradient.addColorStop(0.3, 'rgba(255, 220, 100, 0.9)'); - gradient.addColorStop(0.7, 'rgba(255, 180, 50, 0.5)'); - gradient.addColorStop(1, 'rgba(255, 150, 0, 0)'); + const gradient = ctx.createRadialGradient( + center, + center, + 0, + center, + center, + center, + ); + gradient.addColorStop(0, "rgba(255, 255, 200, 1)"); + gradient.addColorStop(0.3, "rgba(255, 220, 100, 0.9)"); + gradient.addColorStop(0.7, "rgba(255, 180, 50, 0.5)"); + gradient.addColorStop(1, "rgba(255, 150, 0, 0)"); ctx.fillStyle = gradient; ctx.fillRect(0, 0, size, size); return canvas; } +function createRingSprite(position, isLocked = false) { + if (!earthObjRef) return null; + + const ringTexture = createRingTexture( + 8, + 12, + isLocked ? "#ffcc00" : "#ffffff", + ); + const spriteMaterial = new THREE.SpriteMaterial({ + map: ringTexture, + transparent: true, + opacity: 0.8, + depthTest: false, + sizeAttenuation: false, + }); + + const sprite = new THREE.Sprite(spriteMaterial); + sprite.position.copy(position); + sprite.scale.set(SATELLITE_CONFIG.ringSize, SATELLITE_CONFIG.ringSize, 1); + earthObjRef.add(sprite); + return sprite; +} + +function createRelatedSatelliteSprite(position, color = "#7dd3fc") { + if (!earthObjRef) return null; + + const ringTexture = createRingTexture(7, 11, color); + const spriteMaterial = new THREE.SpriteMaterial({ + map: ringTexture, + transparent: true, + opacity: 0.55, + depthTest: false, + sizeAttenuation: false, + }); + + const sprite = new THREE.Sprite(spriteMaterial); + sprite.position.copy(position); + sprite.scale.set(SATELLITE_CONFIG.ringSize * 0.8, SATELLITE_CONFIG.ringSize * 0.8, 1); + earthObjRef.add(sprite); + return sprite; +} + +export function showHoverRing(position, isLocked = false) { + if (!earthObjRef || !position) return null; + + if (isLocked) { + hideLockedRing(); + lockedRingSprite = createRingSprite(position, true); + + const dotCanvas = createBrighterDotCanvas(); + const dotTexture = new THREE.CanvasTexture(dotCanvas); + const dotMaterial = new THREE.SpriteMaterial({ + map: dotTexture, + transparent: true, + opacity: 1.0, + depthTest: false, + }); + lockedDotSprite = new THREE.Sprite(dotMaterial); + lockedDotSprite.position.copy(position); + lockedDotSprite.scale.set(4, 4, 1); + earthObjRef.add(lockedDotSprite); + return lockedRingSprite; + } + + hideHoverRings(); + hoverRingSprite = createRingSprite(position, false); + return hoverRingSprite; +} + export function hideHoverRings() { - if (!earthObjRef) return; - if (hoverRingSprite) { - earthObjRef.remove(hoverRingSprite); + disposeObject3D(hoverRingSprite); hoverRingSprite = null; } } export function hideLockedRing() { - if (!earthObjRef) return; if (lockedRingSprite) { - earthObjRef.remove(lockedRingSprite); + disposeObject3D(lockedRingSprite); lockedRingSprite = null; } if (lockedDotSprite) { - earthObjRef.remove(lockedDotSprite); + disposeObject3D(lockedDotSprite); lockedDotSprite = null; } } export function updateLockedRingPosition(position) { - const ringSize = SATELLITE_CONFIG.ringSize; - const camera = window.camera; - const cameraDistance = camera ? camera.position.distanceTo(position) : 400; - if (lockedRingSprite && position) { + if (!position) return; + if (lockedRingSprite) { lockedRingSprite.position.copy(position); - const breathScale = 1 + Math.sin(breathingPhase) * SATELLITE_CONFIG.breathingScaleAmplitude; - lockedRingSprite.scale.set(ringSize * breathScale, ringSize * breathScale, 1); - const breathOpacity = SATELLITE_CONFIG.breathingOpacityMin + Math.sin(breathingPhase) * (SATELLITE_CONFIG.breathingOpacityMax - SATELLITE_CONFIG.breathingOpacityMin); - lockedRingSprite.material.opacity = breathOpacity; + const breathScale = + 1 + Math.sin(breathingPhase) * SATELLITE_CONFIG.breathingScaleAmplitude; + lockedRingSprite.scale.set( + SATELLITE_CONFIG.ringSize * breathScale, + SATELLITE_CONFIG.ringSize * breathScale, + 1, + ); + lockedRingSprite.material.opacity = + SATELLITE_CONFIG.breathingOpacityMin + + Math.sin(breathingPhase) * + (SATELLITE_CONFIG.breathingOpacityMax - + SATELLITE_CONFIG.breathingOpacityMin); } - if (lockedDotSprite && position) { + + if (lockedDotSprite) { lockedDotSprite.position.copy(position); - const dotBreathScale = 1 + Math.sin(breathingPhase) * SATELLITE_CONFIG.dotBreathingScaleAmplitude; - lockedDotSprite.scale.set(4 * cameraDistance / 200 * dotBreathScale, 4 * cameraDistance / 200 * dotBreathScale, 1); - lockedDotSprite.material.opacity = SATELLITE_CONFIG.dotOpacityMin + Math.sin(breathingPhase) * (SATELLITE_CONFIG.dotOpacityMax - SATELLITE_CONFIG.dotOpacityMin); + const dotBreathScale = + 1 + + Math.sin(breathingPhase) * SATELLITE_CONFIG.dotBreathingScaleAmplitude; + lockedDotSprite.scale.set(4 * dotBreathScale, 4 * dotBreathScale, 1); + lockedDotSprite.material.opacity = + SATELLITE_CONFIG.dotOpacityMin + + Math.sin(breathingPhase) * + (SATELLITE_CONFIG.dotOpacityMax - SATELLITE_CONFIG.dotOpacityMin); } } export function updateHoverRingPosition(position) { - const ringSize = SATELLITE_CONFIG.ringSize; - const camera = window.camera; - const cameraDistance = camera ? camera.position.distanceTo(position) : 400; - const scale = ringSize; if (hoverRingSprite && position) { hoverRingSprite.position.copy(position); - hoverRingSprite.scale.set(scale, scale, 1); + hoverRingSprite.scale.set( + SATELLITE_CONFIG.ringSize, + SATELLITE_CONFIG.ringSize, + 1, + ); } } export function setSatelliteRingState(index, state, position) { switch (state) { - case 'hover': + case "hover": + hoveredSatelliteIndex = index; hideHoverRings(); showHoverRing(position, false); break; - case 'locked': + case "locked": + hoveredSatelliteIndex = null; hideHoverRings(); showHoverRing(position, true); break; - case 'none': + case "none": + hoveredSatelliteIndex = null; hideHoverRings(); hideLockedRing(); break; } } +export function clearRelatedSatelliteHighlights() { + relatedSatelliteSprites.forEach((item) => { + if (item.sprite) { + disposeObject3D(item.sprite); + } + }); + relatedSatelliteSprites = []; +} + +export function highlightRelatedSatellites(indices, color = "#7dd3fc") { + clearRelatedSatelliteHighlights(); + if (!Array.isArray(indices) || indices.length === 0) return; + + indices.forEach((index) => { + const pos = satellitePositions?.[index]?.current; + if (!pos) return; + const sprite = createRelatedSatelliteSprite(pos, color); + if (!sprite) return; + relatedSatelliteSprites.push({ index, sprite, color }); + }); +} + +export function updateRelatedSatelliteHighlights() { + if (relatedSatelliteSprites.length === 0) return; + relatedSatelliteSprites = relatedSatelliteSprites.filter((item) => { + const pos = satellitePositions?.[item.index]?.current; + if (!pos || !item.sprite) return false; + item.sprite.position.copy(pos); + return true; + }); +} + +export function getRelatedSatelliteIndicesForRegions( + regions, + { limit = 6, maxAngleDeg = 22 } = {}, +) { + if (!Array.isArray(regions) || regions.length === 0 || satellitePositions.length === 0) { + return []; + } + + const regionVectors = regions + .filter( + (region) => + typeof region?.latitude === "number" && + typeof region?.longitude === "number", + ) + .map((region) => + latLonToVector3(region.latitude, region.longitude, CONFIG.earthRadius + 1) + .clone() + .normalize(), + ); + + if (regionVectors.length === 0) return []; + + const threshold = Math.cos((maxAngleDeg * Math.PI) / 180); + const ranked = []; + + satellitePositions.forEach((item, index) => { + const current = item?.current; + if (!current || current.lengthSq() === 0) return; + const satVector = current.clone().normalize(); + let bestDot = -1; + regionVectors.forEach((regionVector) => { + bestDot = Math.max(bestDot, satVector.dot(regionVector)); + }); + if (bestDot >= threshold) { + ranked.push({ index, score: bestDot }); + } + }); + + return ranked + .sort((a, b) => b.score - a.score) + .slice(0, limit) + .map((item) => item.index); +} + export function initSatelliteScene(scene, earth) { sceneRef = scene; earthObjRef = earth; } -let predictedOrbitLine = null; - function calculateOrbitalPeriod(meanMotion) { return 86400 / meanMotion; } -function calculatePredictedOrbit(satellite, periodSeconds, sampleInterval = 10) { +function calculatePredictedOrbit( + satellite, + periodSeconds, + sampleInterval = 10, +) { const points = []; const samples = Math.ceil(periodSeconds / sampleInterval); const now = new Date(); - - // Full orbit: from now to now+period (complete circle forward) + for (let i = 0; i <= samples; i++) { const time = new Date(now.getTime() + i * sampleInterval * 1000); const pos = computeSatellitePosition(satellite, time); if (pos) points.push(pos); } - - // If we don't have enough points, use fallback orbit + if (points.length < samples * 0.5) { points.length = 0; const radius = CONFIG.earthRadius + 5; - const noradId = satellite.properties?.norad_cat_id || 0; const inclination = satellite.properties?.inclination || 53; const raan = satellite.properties?.raan || 0; - const meanAnomaly = satellite.properties?.mean_anomaly || 0; - + for (let i = 0; i <= samples; i++) { const theta = (i / samples) * Math.PI * 2; - const phi = (inclination * Math.PI / 180); - const x = radius * Math.sin(phi) * Math.cos(theta + raan * Math.PI / 180); + const phi = (inclination * Math.PI) / 180; + const x = + radius * Math.sin(phi) * Math.cos(theta + (raan * Math.PI) / 180); const y = radius * Math.cos(phi); - const z = radius * Math.sin(phi) * Math.sin(theta + raan * Math.PI / 180); + const z = + radius * Math.sin(phi) * Math.sin(theta + (raan * Math.PI) / 180); points.push(new THREE.Vector3(x, y, z)); } } - + return points; } export function showPredictedOrbit(satellite) { hidePredictedOrbit(); - - const props = satellite.properties; - const meanMotion = props?.mean_motion || 15; + if (!earthObjRef) return; + + const meanMotion = satellite.properties?.mean_motion || 15; const periodSeconds = calculateOrbitalPeriod(meanMotion); - const points = calculatePredictedOrbit(satellite, periodSeconds); if (points.length < 2) return; - + const positions = new Float32Array(points.length * 3); const colors = new Float32Array(points.length * 3); - + for (let i = 0; i < points.length; i++) { positions[i * 3] = points[i].x; positions[i * 3 + 1] = points[i].y; positions[i * 3 + 2] = points[i].z; - + const t = i / (points.length - 1); colors[i * 3] = 1 - t * 0.4; colors[i * 3 + 1] = 1 - t * 0.6; colors[i * 3 + 2] = t; } - + const geometry = new THREE.BufferGeometry(); - geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); - geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); - + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3)); + const material = new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, opacity: 0.8, - blending: THREE.AdditiveBlending + blending: THREE.AdditiveBlending, }); - + predictedOrbitLine = new THREE.Line(geometry, material); earthObjRef.add(predictedOrbitLine); } export function hidePredictedOrbit() { if (predictedOrbitLine) { - earthObjRef.remove(predictedOrbitLine); - predictedOrbitLine.geometry.dispose(); - predictedOrbitLine.material.dispose(); + disposeObject3D(predictedOrbitLine); predictedOrbitLine = null; } } + +export function clearSatelliteData() { + satelliteData = []; + selectedSatellite = null; + lockedSatelliteIndex = null; + hoveredSatelliteIndex = null; + positionUpdateAccumulator = 0; + + satellitePositions.forEach((position) => { + position.current.set(0, 0, 0); + position.trail = []; + position.trailIndex = 0; + position.trailCount = 0; + }); + + if (satellitePoints) { + const positionAttr = satellitePoints.geometry.attributes.position; + const colorAttr = satellitePoints.geometry.attributes.color; + if (positionAttr?.array) { + positionAttr.array.fill(0); + positionAttr.needsUpdate = true; + } + if (colorAttr?.array) { + colorAttr.array.fill(0); + colorAttr.needsUpdate = true; + } + satellitePoints.geometry.setDrawRange(0, 0); + } + + if (satelliteTrails) { + const trailPositionAttr = satelliteTrails.geometry.attributes.position; + const trailColorAttr = satelliteTrails.geometry.attributes.color; + if (trailPositionAttr?.array) { + trailPositionAttr.array.fill(0); + trailPositionAttr.needsUpdate = true; + } + if (trailColorAttr?.array) { + trailColorAttr.array.fill(0); + trailColorAttr.needsUpdate = true; + } + } + + hideHoverRings(); + hideLockedRing(); + hidePredictedOrbit(); + clearRelatedSatelliteHighlights(); +} + +export function resetSatelliteState() { + clearSatelliteData(); + + if (satellitePoints) { + disposeObject3D(satellitePoints); + satellitePoints = null; + } + + if (satelliteTrails) { + disposeObject3D(satelliteTrails); + satelliteTrails = null; + } + + satellitePositions = []; + satelliteCapacity = 0; + showSatellites = false; + showTrails = true; +} diff --git a/frontend/public/earth/js/ui.js b/frontend/public/earth/js/ui.js index da36d359..d472a18f 100644 --- a/frontend/public/earth/js/ui.js +++ b/frontend/public/earth/js/ui.js @@ -1,71 +1,178 @@ // ui.js - UI update functions +let statusTimeoutId = null; +let statusHideTimeoutId = null; +let statusReplayTimeoutId = null; + // Show status message -export function showStatusMessage(message, type = 'info') { - const statusEl = document.getElementById('status-message'); - statusEl.textContent = message; - statusEl.className = `status-message ${type}`; - statusEl.style.display = 'block'; - - setTimeout(() => { - statusEl.style.display = 'none'; - }, 3000); +export function showStatusMessage(message, type = "info") { + const statusEl = document.getElementById("status-message"); + if (!statusEl) return; + + if (statusTimeoutId) { + clearTimeout(statusTimeoutId); + statusTimeoutId = null; + } + + if (statusHideTimeoutId) { + clearTimeout(statusHideTimeoutId); + statusHideTimeoutId = null; + } + + if (statusReplayTimeoutId) { + clearTimeout(statusReplayTimeoutId); + statusReplayTimeoutId = null; + } + + const startShow = () => { + statusEl.textContent = message; + statusEl.className = `status-message ${type}`; + statusEl.style.display = "block"; + statusEl.offsetHeight; + statusEl.classList.add("visible"); + + statusTimeoutId = setTimeout(() => { + statusEl.classList.remove("visible"); + statusHideTimeoutId = setTimeout(() => { + statusEl.style.display = "none"; + statusEl.textContent = ""; + statusHideTimeoutId = null; + }, 280); + statusTimeoutId = null; + }, 3000); + }; + + if (statusEl.classList.contains("visible")) { + statusEl.classList.remove("visible"); + statusReplayTimeoutId = setTimeout(() => { + startShow(); + statusReplayTimeoutId = null; + }, 180); + return; + } + + startShow(); } // Update coordinates display export function updateCoordinatesDisplay(lat, lon, alt = 0) { - document.getElementById('longitude-value').textContent = lon.toFixed(2) + '°'; - document.getElementById('latitude-value').textContent = lat.toFixed(2) + '°'; - document.getElementById('mouse-coords').textContent = - `鼠标: ${lat.toFixed(2)}°, ${lon.toFixed(2)}°`; + const longitudeEl = document.getElementById("longitude-value"); + const latitudeEl = document.getElementById("latitude-value"); + const mouseCoordsEl = document.getElementById("mouse-coords"); + + if (longitudeEl) longitudeEl.textContent = lon.toFixed(2) + "°"; + if (latitudeEl) latitudeEl.textContent = lat.toFixed(2) + "°"; + if (mouseCoordsEl) { + mouseCoordsEl.textContent = `鼠标: ${lat.toFixed(2)}°, ${lon.toFixed(2)}°`; + } } // Update zoom display export function updateZoomDisplay(zoomLevel, distance) { const percent = Math.round(zoomLevel * 100); - document.getElementById('zoom-value').textContent = percent + '%'; - document.getElementById('zoom-level').textContent = '缩放: ' + percent + '%'; - const slider = document.getElementById('zoom-slider'); + const zoomValueEl = document.getElementById("zoom-value"); + const zoomLevelEl = document.getElementById("zoom-level"); + const slider = document.getElementById("zoom-slider"); + const cameraDistanceEl = document.getElementById("camera-distance"); + + if (zoomValueEl) zoomValueEl.textContent = percent + "%"; + if (zoomLevelEl) zoomLevelEl.textContent = "缩放: " + percent + "%"; if (slider) slider.value = zoomLevel; - document.getElementById('camera-distance').textContent = distance + ' km'; + if (cameraDistanceEl) cameraDistanceEl.textContent = distance + " km"; } // Update earth stats export function updateEarthStats(stats) { - document.getElementById('cable-count').textContent = stats.cableCount || 0; - document.getElementById('landing-point-count').textContent = stats.landingPointCount || 0; - document.getElementById('terrain-status').textContent = stats.terrainOn ? '开启' : '关闭'; - document.getElementById('texture-quality').textContent = stats.textureQuality || '8K 卫星图'; + const cableCountEl = document.getElementById("cable-count"); + const landingPointCountEl = document.getElementById("landing-point-count"); + const bgpAnomalyCountEl = document.getElementById("bgp-anomaly-count"); + const bgpCollectorCountEl = document.getElementById("bgp-collector-count"); + const bgpStatusSummaryEl = document.getElementById("bgp-status-summary"); + const terrainStatusEl = document.getElementById("terrain-status"); + const textureQualityEl = document.getElementById("texture-quality"); + + if (cableCountEl) cableCountEl.textContent = stats.cableCount || 0; + if (landingPointCountEl) + landingPointCountEl.textContent = stats.landingPointCount || 0; + if (bgpAnomalyCountEl) + bgpAnomalyCountEl.textContent = stats.bgpAnomalyCount || 0; + if (bgpCollectorCountEl) + bgpCollectorCountEl.textContent = stats.bgpCollectorCount || 0; + if (bgpStatusSummaryEl) + bgpStatusSummaryEl.textContent = stats.bgpStatusSummary || "-"; + if (terrainStatusEl) + terrainStatusEl.textContent = stats.terrainOn ? "开启" : "关闭"; + if (textureQualityEl) + textureQualityEl.textContent = stats.textureQuality || "8K 卫星图"; } // Show/hide loading export function setLoading(loading) { - const loadingEl = document.getElementById('loading'); - loadingEl.style.display = loading ? 'block' : 'none'; + const loadingEl = document.getElementById("loading"); + if (!loadingEl) return; + loadingEl.style.display = loading ? "block" : "none"; +} + +export function setLoadingMessage(title, subtitle = "") { + const titleEl = document.getElementById("loading-title"); + const subtitleEl = document.getElementById("loading-subtitle"); + + if (titleEl) { + titleEl.textContent = title; + } + + if (subtitleEl) { + subtitleEl.textContent = subtitle; + } } // Show tooltip export function showTooltip(x, y, content) { - const tooltip = document.getElementById('tooltip'); + const tooltip = document.getElementById("tooltip"); + if (!tooltip) return; tooltip.innerHTML = content; - tooltip.style.left = x + 'px'; - tooltip.style.top = y + 'px'; - tooltip.style.display = 'block'; + tooltip.style.left = x + "px"; + tooltip.style.top = y + "px"; + tooltip.style.display = "block"; } // Hide tooltip export function hideTooltip() { - document.getElementById('tooltip').style.display = 'none'; + const tooltip = document.getElementById("tooltip"); + if (tooltip) { + tooltip.style.display = "none"; + } } // Show error message export function showError(message) { - const errorEl = document.getElementById('error-message'); + const errorEl = document.getElementById("error-message"); + if (!errorEl) return; errorEl.textContent = message; - errorEl.style.display = 'block'; + errorEl.style.display = "block"; } // Hide error message export function hideError() { - document.getElementById('error-message').style.display = 'none'; + const errorEl = document.getElementById("error-message"); + if (errorEl) { + errorEl.style.display = "none"; + errorEl.textContent = ""; + } +} + +export function clearUiState() { + if (statusTimeoutId) { + clearTimeout(statusTimeoutId); + statusTimeoutId = null; + } + + const statusEl = document.getElementById("status-message"); + if (statusEl) { + statusEl.style.display = "none"; + statusEl.textContent = ""; + } + + hideTooltip(); + hideError(); } diff --git a/frontend/public/earth/js/utils.js b/frontend/public/earth/js/utils.js index dcc70ebe..1bd3ee94 100644 --- a/frontend/public/earth/js/utils.js +++ b/frontend/public/earth/js/utils.js @@ -1,8 +1,8 @@ // utils.js - Utility functions for coordinate conversion -import * as THREE from 'three'; +import * as THREE from "three"; -import { CONFIG } from './constants.js'; +import { CONFIG } from "./constants.js"; // Convert latitude/longitude to 3D vector export function latLonToVector3(lat, lon, radius = CONFIG.earthRadius) { @@ -18,26 +18,33 @@ export function latLonToVector3(lat, lon, radius = CONFIG.earthRadius) { // Convert 3D vector to latitude/longitude export function vector3ToLatLon(vector) { - const radius = Math.sqrt(vector.x * vector.x + vector.y * vector.y + vector.z * vector.z); - const lat = 90 - (Math.acos(vector.y / radius) * 180 / Math.PI); - - let lon = (Math.atan2(vector.z, -vector.x) * 180 / Math.PI) - 180; - + const radius = Math.sqrt( + vector.x * vector.x + vector.y * vector.y + vector.z * vector.z, + ); + const lat = 90 - (Math.acos(vector.y / radius) * 180) / Math.PI; + + let lon = (Math.atan2(vector.z, -vector.x) * 180) / Math.PI - 180; + while (lon <= -180) lon += 360; while (lon > 180) lon -= 360; return { lat: parseFloat(lat.toFixed(4)), lon: parseFloat(lon.toFixed(4)), - alt: radius - CONFIG.earthRadius + alt: radius - CONFIG.earthRadius, }; } // Convert screen coordinates to Earth surface 3D coordinates -export function screenToEarthCoords(clientX, clientY, camera, earth, domElement = document.body) { - const raycaster = new THREE.Raycaster(); - const mouse = new THREE.Vector2(); - +export function screenToEarthCoords( + clientX, + clientY, + camera, + earth, + domElement = document.body, + raycaster = new THREE.Raycaster(), + mouse = new THREE.Vector2(), +) { if (domElement === document.body) { mouse.x = (clientX / window.innerWidth) * 2 - 1; mouse.y = -(clientY / window.innerHeight) * 2 + 1; @@ -60,17 +67,26 @@ export function screenToEarthCoords(clientX, clientY, camera, earth, domElement } // Calculate accurate spherical distance between two points (Haversine formula) -export function calculateDistance(lat1, lon1, lat2, lon2, radius = CONFIG.earthRadius) { +export function calculateDistance( + lat1, + lon1, + lat2, + lon2, + radius = CONFIG.earthRadius, +) { const toRad = (angle) => (angle * Math.PI) / 180; - + const dLat = toRad(lat2 - lat1); const dLon = toRad(lon2 - lon1); - - const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + - Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * - Math.sin(dLon / 2) * Math.sin(dLon / 2); - + + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(toRad(lat1)) * + Math.cos(toRad(lat2)) * + Math.sin(dLon / 2) * + Math.sin(dLon / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); - + return radius * c; } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index aeb7a8e2..d4457f0a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import DataSources from './pages/DataSources/DataSources' import DataList from './pages/DataList/DataList' import Earth from './pages/Earth/Earth' import Settings from './pages/Settings/Settings' +import BGP from './pages/BGP/BGP' function App() { const { token } = useAuthStore() @@ -18,14 +19,15 @@ function App() { return ( - } /> } /> + } /> } /> } /> } /> } /> + } /> } /> - } /> + } /> ) } diff --git a/frontend/src/components/AppLayout/AppLayout.tsx b/frontend/src/components/AppLayout/AppLayout.tsx index 74167315..83fe521e 100644 --- a/frontend/src/components/AppLayout/AppLayout.tsx +++ b/frontend/src/components/AppLayout/AppLayout.tsx @@ -6,11 +6,13 @@ import { UserOutlined, SettingOutlined, BarChartOutlined, + DeploymentUnitOutlined, MenuUnfoldOutlined, MenuFoldOutlined, } from '@ant-design/icons' -import { Link, useLocation } from 'react-router-dom' +import { useLocation, useNavigate } from 'react-router-dom' import { useAuthStore } from '../../stores/auth' +import packageJson from '../../../package.json' const { Sider, Content } = Layout const { Text } = Typography @@ -21,16 +23,19 @@ interface AppLayoutProps { function AppLayout({ children }: AppLayoutProps) { const location = useLocation() + const navigate = useNavigate() const { user, logout } = useAuthStore() const [collapsed, setCollapsed] = useState(false) const showBanner = true + const appVersion = `v${packageJson.version}` const menuItems = [ - { key: '/', icon: , label: 仪表盘 }, - { key: '/datasources', icon: , label: 数据源 }, - { key: '/data', icon: , label: 采集数据 }, - { key: '/users', icon: , label: 用户管理 }, - { key: '/settings', icon: , label: 系统配置 }, + { key: '/admin', icon: , label: '仪表盘' }, + { key: '/datasources', icon: , label: '数据源' }, + { key: '/data', icon: , label: '采集数据' }, + { key: '/bgp', icon: , label: 'BGP观测' }, + { key: '/users', icon: , label: '用户管理' }, + { key: '/settings', icon: , label: '系统配置' }, ] return ( @@ -64,6 +69,11 @@ function AppLayout({ children }: AppLayoutProps) { mode="inline" selectedKeys={[location.pathname]} items={menuItems} + onClick={({ key }) => { + if (key !== location.pathname) { + navigate(key) + } + }} />
@@ -74,6 +84,10 @@ function AppLayout({ children }: AppLayoutProps) { 当前账号 {user?.username}
+
+ 版本号 + {appVersion} +
diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts index 47d92e27..ec96810e 100644 --- a/frontend/src/hooks/useWebSocket.ts +++ b/frontend/src/hooks/useWebSocket.ts @@ -1,7 +1,38 @@ import { useEffect, useRef, useState, useCallback } from 'react' import { useAuthStore } from '../stores/auth' -const WS_URL = (import.meta as any).env?.VITE_WS_URL || 'ws://localhost:8000/ws' +const DEFAULT_WS_URL = (() => { + if (typeof window === 'undefined') { + return 'ws://localhost:8000/ws' + } + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + return `${protocol}//${window.location.host}/ws` +})() + +const WS_URL = (import.meta as any).env?.VITE_WS_URL || DEFAULT_WS_URL + +function buildWebSocketCandidates(): string[] { + if ((import.meta as any).env?.VITE_WS_URL) { + return [WS_URL] + } + + if (typeof window === 'undefined') { + return ['ws://localhost:8000/ws'] + } + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const sameOrigin = `${protocol}//${window.location.host}/ws` + const candidates = [sameOrigin] + + if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') { + const directBackend = `${protocol}//${window.location.hostname}:8000/ws` + if (!candidates.includes(directBackend)) { + candidates.push(directBackend) + } + } + + return candidates +} interface WebSocketMessage { type: string @@ -41,71 +72,130 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet } = options const { token } = useAuthStore() - console.log('[WebSocket] Token present:', !!token, token ? token.substring(0, 20) + '...' : 'none') - console.log('[WebSocket] autoConnect:', autoConnect) const wsRef = useRef(null) const [connected, setConnected] = useState(false) const [lastMessage, setLastMessage] = useState(null) const reconnectTimeoutRef = useRef | null>(null) const heartbeatTimerRef = useRef | null>(null) + const activeWsUrlRef = useRef(null) + const intentionalCloseRef = useRef(false) + const pendingCloseSocketRef = useRef(null) + const autoSubscribeRef = useRef(autoSubscribe) + const onMessageRef = useRef(onMessage) + const onConnectRef = useRef(onConnect) + const onDisconnectRef = useRef(onDisconnect) + const onErrorRef = useRef(onError) + + useEffect(() => { + autoSubscribeRef.current = autoSubscribe + onMessageRef.current = onMessage + onConnectRef.current = onConnect + onDisconnectRef.current = onDisconnect + onErrorRef.current = onError + }, [autoSubscribe, onMessage, onConnect, onDisconnect, onError]) const connect = useCallback(() => { if (!token) { - console.log('[WebSocket] No token, skipping connect') return } - const wsUrl = `${WS_URL}?token=${token}` - console.log('[WebSocket] Connecting to:', wsUrl) - const ws = new WebSocket(wsUrl) + intentionalCloseRef.current = false + const candidates = buildWebSocketCandidates() + let candidateIndex = 0 + let opened = false - ws.onopen = () => { - console.log('[WebSocket] Connected!') - setConnected(true) - if (autoSubscribe.length > 0) { - ws.send(JSON.stringify({ type: 'subscribe', data: { channels: autoSubscribe } })) + const tryConnect = () => { + const baseUrl = candidates[candidateIndex] + const wsUrl = `${baseUrl}?token=${token}` + activeWsUrlRef.current = baseUrl + const ws = new WebSocket(wsUrl) + + ws.onopen = () => { + if (intentionalCloseRef.current || pendingCloseSocketRef.current === ws) { + pendingCloseSocketRef.current = null + ws.close() + return + } + opened = true + setConnected(true) + if (autoSubscribeRef.current.length > 0) { + ws.send(JSON.stringify({ type: 'subscribe', data: { channels: autoSubscribeRef.current } })) + } + onConnectRef.current?.() } - onConnect?.() + + ws.onmessage = (event) => { + try { + const message: WebSocketMessage = JSON.parse(event.data) + setLastMessage(message) + onMessageRef.current?.(message) + } catch { + console.error('Failed to parse WebSocket message') + } + } + + ws.onclose = () => { + if (wsRef.current === ws) { + wsRef.current = null + } + if (pendingCloseSocketRef.current === ws) { + pendingCloseSocketRef.current = null + } + setConnected(false) + if (heartbeatTimerRef.current) { + clearInterval(heartbeatTimerRef.current) + heartbeatTimerRef.current = null + } + + if (!opened && candidateIndex < candidates.length - 1) { + candidateIndex += 1 + tryConnect() + return + } + + if (intentionalCloseRef.current) { + return + } + + onDisconnectRef.current?.() + + if (autoConnect && token) { + reconnectTimeoutRef.current = setTimeout(() => { + connect() + }, 3000) + } + } + + ws.onerror = (error) => { + setConnected(false) + if (intentionalCloseRef.current || ws.readyState === WebSocket.CLOSING || ws.readyState === WebSocket.CLOSED) { + return + } + if (candidateIndex >= candidates.length - 1) { + console.warn('[WebSocket] Connection error', { url: baseUrl, error }) + onErrorRef.current?.(new Error('WebSocket error')) + } + } + + wsRef.current = ws } - ws.onmessage = (event) => { - console.log('[WebSocket] Received:', event.data) - try { - const message: WebSocketMessage = JSON.parse(event.data) - setLastMessage(message) - onMessage?.(message) - } catch { - console.error('Failed to parse WebSocket message') - } - } - - ws.onclose = (event) => { - console.log('[WebSocket] Disconnected:', event.code, event.reason) + try { + tryConnect() + } catch (error) { setConnected(false) - if (heartbeatTimerRef.current) { - clearInterval(heartbeatTimerRef.current) - heartbeatTimerRef.current = null - } - onDisconnect?.() - + console.warn('[WebSocket] Failed to initialize connection', { url: activeWsUrlRef.current, error }) if (autoConnect && token) { reconnectTimeoutRef.current = setTimeout(() => { - console.log('[WebSocket] Reconnecting...') connect() }, 3000) } } - - ws.onerror = (error) => { - console.error('[WebSocket] Error:', error) - onError?.(new Error('WebSocket error')) - } - - wsRef.current = ws - }, [token, autoConnect, autoSubscribe, onConnect, onDisconnect, onError]) + }, [token, autoConnect]) const disconnect = useCallback(() => { + intentionalCloseRef.current = true if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current) reconnectTimeoutRef.current = null @@ -114,8 +204,14 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet clearInterval(heartbeatTimerRef.current) heartbeatTimerRef.current = null } - wsRef.current?.close() - wsRef.current = null + const socket = wsRef.current + if (socket) { + if (socket.readyState === WebSocket.CONNECTING) { + pendingCloseSocketRef.current = socket + } else if (socket.readyState === WebSocket.OPEN) { + socket.close() + } + } setConnected(false) }, []) @@ -131,7 +227,6 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet }, []) useEffect(() => { - console.log('[WebSocket] useEffect triggered, autoConnect:', autoConnect, 'token:', !!token) if (autoConnect && token) { connect() } diff --git a/frontend/src/index.css b/frontend/src/index.css index ae02ec64..fff1a20e 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -173,7 +173,6 @@ body { flex: 1 1 auto; min-width: 0; min-height: 0; - height: 100%; display: flex; flex-direction: column; overflow: hidden; @@ -239,12 +238,112 @@ body { gap: 12px; } +.data-source-builtin-tab { + gap: 12px; +} + .data-source-custom-toolbar { flex: 0 0 auto; display: flex; justify-content: flex-end; } +.data-source-bulk-toolbar { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 16px; + border-radius: 14px; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.96) 0%, rgba(245, 247, 250, 0.96) 100%); + border: 1px solid rgba(5, 5, 5, 0.08); + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06); +} + +.data-source-bulk-toolbar__meta { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.data-source-bulk-toolbar__title { + font-size: 15px; + font-weight: 600; + color: #1f1f1f; +} + +.data-source-bulk-toolbar__stats { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.data-source-bulk-toolbar__stat-pill { + display: inline-flex; + align-items: baseline; + gap: 8px; + padding: 6px 10px; + border: 1px solid #e5e7eb; + border-radius: 999px; + background: #ffffff; + color: #475467; + line-height: 1; +} + +.data-source-bulk-toolbar__stat-pill strong { + color: #111827; + font-size: 13px; + font-weight: 700; +} + +.data-source-bulk-toolbar__stat-label { + font-size: 12px; + color: #667085; +} + +.data-source-bulk-toolbar__stat-pill--success { + border-color: #b7eb8f; + background: #f6ffed; +} + +.data-source-bulk-toolbar__stat-pill--success strong { + color: #237804; +} + +.data-source-bulk-toolbar__stat-pill--danger { + border-color: #ffccc7; + background: #fff2f0; +} + +.data-source-bulk-toolbar__stat-pill--danger strong { + color: #cf1322; +} + +.data-source-bulk-toolbar__progress { + display: flex; + flex-direction: column; + gap: 8px; + max-width: 520px; +} + +.data-source-bulk-toolbar__progress-copy { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + color: #595959; + font-size: 13px; +} + +.data-source-bulk-toolbar__progress-copy strong { + color: #1677ff; + font-size: 18px; + line-height: 1; +} + .data-source-table-region { flex: 1 1 auto; min-height: 0; @@ -261,6 +360,10 @@ body { min-height: 0; } +.users-table-region .ant-table-body { + height: auto !important; +} + .data-source-table-region .ant-table-wrapper, .data-source-table-region .ant-spin-nested-loading, .data-source-table-region .ant-spin-container { @@ -357,8 +460,8 @@ body { .table-scroll-region .ant-table-body::-webkit-scrollbar, .table-scroll-region .ant-table-content::-webkit-scrollbar { - width: 10px; - height: 10px; + width: 8px; + height: 8px; } .table-scroll-region .ant-table-body::-webkit-scrollbar-thumb, @@ -380,6 +483,32 @@ body { background: transparent; } +.data-list-controls-shell { + scrollbar-width: thin; + scrollbar-color: rgba(148, 163, 184, 0.82) transparent; +} + +.data-list-controls-shell::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.data-list-controls-shell::-webkit-scrollbar-thumb { + background: rgba(148, 163, 184, 0.82); + border-radius: 999px; + border: 2px solid transparent; + background-clip: padding-box; +} + +.data-list-controls-shell::-webkit-scrollbar-thumb:hover { + background: rgba(100, 116, 139, 0.9); + background-clip: padding-box; +} + +.data-list-controls-shell::-webkit-scrollbar-track { + background: transparent; +} + .settings-shell, .settings-tabs-shell, .settings-tabs, @@ -532,6 +661,8 @@ body { overflow-y: auto; overflow-x: hidden; scrollbar-gutter: stable; + scrollbar-width: thin; + scrollbar-color: rgba(148, 163, 184, 0.82) transparent; } .data-list-summary-card .ant-card-head, @@ -545,6 +676,39 @@ body { .data-list-summary-card-inner { min-height: 100%; + display: flex; + flex-direction: column; + gap: 12px; +} + +.data-list-summary-kpis { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.data-list-summary-kpi { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; + padding: 12px; + border-radius: 14px; + background: linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%); + border: 1px solid rgba(148, 163, 184, 0.22); +} + +.data-list-summary-kpi__head, +.data-list-summary-section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-width: 0; +} + +.data-list-summary-section-head { + margin-top: 4px; } .data-list-right-column { @@ -580,6 +744,22 @@ body { overflow: hidden; } +.data-list-treemap-tile--compact { + align-items: center; + justify-content: center; + gap: 8px; + text-align: center; +} + +.data-list-treemap-tile--compact .data-list-treemap-head { + justify-content: center; +} + +.data-list-treemap-tile--compact .data-list-treemap-body { + margin-top: 0; + align-items: center; +} + .data-list-treemap-tile--ocean { background: linear-gradient(135deg, #dbeafe 0%, #93c5fd 100%); } @@ -660,12 +840,24 @@ body { color: rgba(15, 23, 42, 0.72) !important; } +.data-list-summary-empty { + grid-column: 1 / -1; + display: flex; + align-items: center; + justify-content: center; + min-height: 140px; + border-radius: 14px; + background: rgba(248, 250, 252, 0.8); + border: 1px dashed rgba(148, 163, 184, 0.35); +} + .data-list-summary-card--panel .ant-card-body::-webkit-scrollbar { - width: 10px; + width: 8px; + height: 8px; } .data-list-summary-card--panel .ant-card-body::-webkit-scrollbar-thumb { - background: rgba(148, 163, 184, 0.8); + background: rgba(148, 163, 184, 0.82); border-radius: 999px; border: 2px solid transparent; background-clip: padding-box; @@ -868,6 +1060,13 @@ body { gap: 10px; } + .data-list-controls-shell { + overflow-y: auto; + overflow-x: hidden; + min-height: 0; + scrollbar-gutter: stable; + } + .data-list-topbar { align-items: flex-start; flex-direction: column; @@ -886,11 +1085,22 @@ body { height: auto; } + .data-list-summary-card--panel, + .data-list-summary-card--panel .ant-card-body, + .data-list-table-shell, + .data-list-table-shell .ant-card-body { + height: auto; + } + .data-list-summary-treemap { grid-template-columns: repeat(2, minmax(0, 1fr)); grid-auto-rows: minmax(88px, 1fr); } + .data-list-summary-kpis { + grid-template-columns: 1fr; + } + .data-list-filter-grid { flex-wrap: wrap; } @@ -915,6 +1125,11 @@ body { min-width: 100%; } + .data-list-summary-section-head { + align-items: flex-start; + flex-direction: column; + } + } .data-list-detail-modal { @@ -1062,6 +1277,28 @@ body { align-items: center; } +.dashboard-quick-entry { + width: 100%; + align-items: center; + text-align: center; +} + +.dashboard-quick-entry__copy { + display: flex; + flex-direction: column; + gap: 4px; + align-items: center; +} + +.dashboard-quick-entry__title.ant-typography { + margin: 0; +} + +.dashboard-quick-entry__link { + display: inline-flex; + justify-content: center; +} + .dashboard-status-tag { margin-inline-end: 0 !important; padding-inline: 10px; @@ -1069,7 +1306,7 @@ body { line-height: 24px; } -.dashboard-refresh-button.ant-btn { +.dashboard-action-button.ant-btn { height: 26px; padding-inline: 12px; border-radius: 999px; @@ -1077,11 +1314,89 @@ body { background: #ffffff; color: rgba(0, 0, 0, 0.88); box-shadow: none; + font-size: 12px; + font-weight: 600; + line-height: 24px; } -.dashboard-refresh-button.ant-btn:hover, -.dashboard-refresh-button.ant-btn:focus { +.dashboard-action-button.ant-btn:hover, +.dashboard-action-button.ant-btn:focus { border-color: #bfbfbf; background: #ffffff; color: rgba(0, 0, 0, 0.88); } + +.dashboard-restart-modal { + display: flex; + flex-direction: column; + gap: 16px; +} + +.dashboard-restart-section { + display: flex; + flex-direction: column; + gap: 8px; +} + +.dashboard-restart-toolbar { + display: flex; + flex-direction: column; + gap: 12px; + padding: 14px; + border: 1px solid #f0f0f0; + border-radius: 12px; + background: #fafafa; +} + +.dashboard-restart-toolbar__field { + display: flex; + flex-direction: column; + gap: 8px; +} + +.dashboard-restart-toolbar__meta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.dashboard-restart-toolbar__item { + display: flex; + flex-direction: column; + gap: 4px; +} + +.dashboard-restart-section__label { + font-size: 12px; + font-weight: 600; + color: rgba(0, 0, 0, 0.45); + letter-spacing: 0.02em; +} + +.dashboard-restart-log { + max-height: 180px; + overflow-y: auto; + padding: 10px 12px; + border-radius: 12px; + background: #0f172a; + color: #e2e8f0; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + line-height: 1.6; + scrollbar-width: thin; +} + +.dashboard-restart-log::-webkit-scrollbar { + width: 8px; +} + +.dashboard-restart-log::-webkit-scrollbar-thumb { + border-radius: 999px; + background: rgba(148, 163, 184, 0.55); +} + +@media (max-width: 768px) { + .dashboard-restart-toolbar__meta { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/pages/Alerts/Alerts.tsx b/frontend/src/pages/Alerts/Alerts.tsx index e3225336..eeb55004 100644 --- a/frontend/src/pages/Alerts/Alerts.tsx +++ b/frontend/src/pages/Alerts/Alerts.tsx @@ -3,6 +3,7 @@ import { Table, Tag, Card, Row, Col, Statistic, Button, Modal, Space, Descriptio import { AlertOutlined, InfoCircleOutlined, ReloadOutlined } from '@ant-design/icons' import { useAuthStore } from '../../stores/auth' import AppLayout from '../../components/AppLayout/AppLayout' +import { formatDateTimeZhCN } from '../../utils/datetime' interface Alert { id: number @@ -105,7 +106,7 @@ function Alerts() { title: '时间', dataIndex: 'created_at', key: 'created_at', - render: (t: string) => new Date(t).toLocaleString('zh-CN'), + render: (t: string) => formatDateTimeZhCN(t), }, { title: '操作', @@ -201,15 +202,15 @@ function Alerts() { {selectedAlert.datasource_name} {selectedAlert.message} - {new Date(selectedAlert.created_at).toLocaleString('zh-CN')} + {formatDateTimeZhCN(selectedAlert.created_at)} {selectedAlert.acknowledged_at && ( - {new Date(selectedAlert.acknowledged_at).toLocaleString('zh-CN')} + {formatDateTimeZhCN(selectedAlert.acknowledged_at)} )} {selectedAlert.resolved_at && ( - {new Date(selectedAlert.resolved_at).toLocaleString('zh-CN')} + {formatDateTimeZhCN(selectedAlert.resolved_at)} )} diff --git a/frontend/src/pages/BGP/BGP.tsx b/frontend/src/pages/BGP/BGP.tsx new file mode 100644 index 00000000..8dfc7a45 --- /dev/null +++ b/frontend/src/pages/BGP/BGP.tsx @@ -0,0 +1,362 @@ +import { useEffect, useState } from 'react' +import { Alert, Card, Col, Row, Space, Statistic, Table, Tag, Typography } from 'antd' +import AppLayout from '../../components/AppLayout/AppLayout' +import { formatDateTimeZhCN } from '../../utils/datetime' +import { + getSituationalAwarenessGateway, + type BGPAnomaly, + type BGPCollectorCoverage, + type BGPEvent, + type BGPIncident, + type CollectorSummary, + type EventSummary, + type Summary, +} from '../../services/situational-awareness' + +const { Title, Text } = Typography +const situationalAwarenessGateway = getSituationalAwarenessGateway() + +function severityColor(severity: string) { + if (severity === 'critical') return 'red' + if (severity === 'high') return 'orange' + if (severity === 'medium') return 'gold' + return 'blue' +} + +function BGP() { + const [loading, setLoading] = useState(false) + const [incidents, setIncidents] = useState([]) + const [anomalies, setAnomalies] = useState([]) + const [events, setEvents] = useState([]) + const [collectors, setCollectors] = useState([]) + const [incidentSummary, setIncidentSummary] = useState(null) + const [eventSummary, setEventSummary] = useState(null) + const [collectorSummary, setCollectorSummary] = useState(null) + + useEffect(() => { + const load = async () => { + setLoading(true) + try { + const snapshot = await situationalAwarenessGateway.getBGPOverview({ + incidentPageSize: 50, + anomalyPageSize: 100, + eventPageSize: 20, + }) + setIncidents(snapshot.incidents) + setIncidentSummary(snapshot.incidentSummary) + setAnomalies(snapshot.anomalies) + setEvents(snapshot.events) + setEventSummary(snapshot.eventSummary) + setCollectors(snapshot.collectors) + setCollectorSummary(snapshot.collectorSummary) + } catch (error) { + console.error('Failed to load BGP overview:', error) + } finally { + setLoading(false) + } + } + + load() + }, []) + + return ( + + +
+ BGP观测 + 先看事件态势,再下钻到原子异常明细。 +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + rowKey="collector" + loading={loading} + dataSource={collectors} + pagination={{ pageSize: 8 }} + columns={[ + { + title: '观测站', + dataIndex: 'collector', + width: 120, + }, + { + title: '位置', + width: 180, + render: (_, record) => [record.city, record.country].filter(Boolean).join(', ') || '-', + }, + { + title: '近24h事件数', + dataIndex: 'recent_24h_observation_count', + width: 120, + }, + { + title: '近7d事件数', + dataIndex: 'recent_7d_observation_count', + width: 120, + }, + { + title: '前缀数', + dataIndex: 'prefix_count', + width: 120, + }, + { + title: 'Origin ASN 数', + dataIndex: 'origin_asn_count', + width: 140, + }, + { + title: '最近事件', + width: 220, + render: (_, record) => { + const time = formatDateTimeZhCN(record.latest_observed_at) + return record.latest_event_type ? `${record.latest_event_type} @ ${time}` : time + }, + }, + { + title: '日常覆盖范围', + dataIndex: 'baseline_scope', + render: (value: BGPCollectorCoverage['baseline_scope']) => { + const cities = value?.cities?.slice(0, 3).join(' / ') || '' + const countries = value?.countries?.slice(0, 3).join(' / ') || '' + return cities && countries ? `${cities} | ${countries}` : cities || countries || '-' + }, + }, + ]} + /> + + + + + rowKey="id" + loading={loading} + dataSource={incidents} + pagination={{ pageSize: 8 }} + columns={[ + { + title: '开始时间', + dataIndex: 'started_at', + width: 180, + render: (value: string | null) => formatDateTimeZhCN(value), + }, + { + title: '类型', + dataIndex: 'incident_type', + width: 180, + }, + { + title: '严重度', + dataIndex: 'severity', + width: 120, + render: (value: string) => {value}, + }, + { + title: '影响前缀', + dataIndex: 'affected_prefixes', + width: 200, + render: (value: string[]) => (value && value.length > 0 ? value.join(', ') : '-'), + }, + { + title: '观测站', + dataIndex: 'affected_collectors', + width: 180, + render: (value: string[]) => (value && value.length > 0 ? `${value.length}个 (${value.slice(0, 3).join(', ')})` : '-'), + }, + { + title: '区域', + dataIndex: 'affected_regions', + width: 220, + render: (value: Array<{ country?: string; city?: string }>) => { + if (!value || value.length === 0) return '-' + return value + .slice(0, 3) + .map((item) => [item.city, item.country].filter(Boolean).join(', ')) + .join(' / ') + }, + }, + { + title: '附近基础设施', + dataIndex: 'related_cables', + width: 260, + render: (value: BGPIncident['related_cables']) => { + if (!value || value.length === 0) return '-' + return value + .slice(0, 2) + .map((item) => { + const landing = item.landing_point || [item.city, item.country].filter(Boolean).join(', ') + const cable = item.cable_names && item.cable_names.length > 0 ? item.cable_names[0] : '附近登陆点' + const distance = item.distance_km !== undefined ? ` ${item.distance_km}km` : '' + return `${landing} (${cable}${distance})` + }) + .join(' / ') + }, + }, + { + title: '置信度', + dataIndex: 'confidence', + width: 120, + render: (value: number) => `${Math.round((value || 0) * 100)}%`, + }, + { + title: '摘要', + dataIndex: 'summary', + }, + ]} + /> + + + + + rowKey="id" + loading={loading} + dataSource={anomalies} + pagination={{ pageSize: 10 }} + columns={[ + { + title: '时间', + dataIndex: 'created_at', + width: 180, + render: (value: string | null) => formatDateTimeZhCN(value), + }, + { + title: '类型', + dataIndex: 'anomaly_type', + width: 180, + }, + { + title: '严重度', + dataIndex: 'severity', + width: 120, + render: (value: string) => {value}, + }, + { + title: '前缀', + dataIndex: 'prefix', + width: 180, + render: (value: string | null) => value || '-', + }, + { + title: 'ASN', + key: 'asn', + width: 160, + render: (_, record) => { + if (record.origin_asn && record.new_origin_asn) { + return `AS${record.origin_asn} -> AS${record.new_origin_asn}` + } + if (record.origin_asn) { + return `AS${record.origin_asn}` + } + return '-' + }, + }, + { + title: '来源', + dataIndex: 'source', + width: 140, + }, + { + title: '置信度', + dataIndex: 'confidence', + width: 120, + render: (value: number) => `${Math.round((value || 0) * 100)}%`, + }, + { + title: '摘要', + dataIndex: 'summary', + }, + ]} + /> + + + + + rowKey="id" + loading={loading} + dataSource={events} + pagination={{ pageSize: 8 }} + columns={[ + { + title: '时间', + dataIndex: 'observed_at', + width: 180, + render: (value: string | null) => formatDateTimeZhCN(value), + }, + { + title: '观测站', + dataIndex: 'collector', + width: 140, + render: (value: string | null) => value || '-', + }, + { + title: '类型', + dataIndex: 'event_type', + width: 120, + }, + { + title: '前缀', + dataIndex: 'prefix', + width: 200, + render: (value: string | null) => value || '-', + }, + { + title: 'Origin ASN', + dataIndex: 'origin_asn', + width: 140, + render: (value: number | null) => (value ? `AS${value}` : '-'), + }, + { + title: 'Peer ASN', + dataIndex: 'peer_asn', + width: 140, + render: (value: number | null) => (value ? `AS${value}` : '-'), + }, + ]} + /> + +
+
+ ) +} + +export default BGP diff --git a/frontend/src/pages/Dashboard/Dashboard.tsx b/frontend/src/pages/Dashboard/Dashboard.tsx index 9202ddd4..4f70b910 100644 --- a/frontend/src/pages/Dashboard/Dashboard.tsx +++ b/frontend/src/pages/Dashboard/Dashboard.tsx @@ -1,15 +1,21 @@ import { useEffect, useState } from 'react' -import { Card, Row, Col, Statistic, Typography, Button, Tag, Spin, Space } from 'antd' +import { Card, Row, Col, Statistic, Typography, Button, Tag, Spin, Space, Modal, Alert, Select } from 'antd' import { DatabaseOutlined, BarChartOutlined, AlertOutlined, + GlobalOutlined, + PoweroffOutlined, WifiOutlined, DisconnectOutlined, ReloadOutlined, } from '@ant-design/icons' +import { Link } from 'react-router-dom' +import axios from 'axios' import { useAuthStore } from '../../stores/auth' import AppLayout from '../../components/AppLayout/AppLayout' +import { useWebSocket } from '../../hooks/useWebSocket' +import { formatDateTimeZhCN } from '../../utils/datetime' const { Title, Text } = Typography @@ -26,19 +32,116 @@ interface Stats { } } +interface RestartTask { + task_id: string + action: string + status: string + stage: string + message: string + created_at: string + updated_at: string +} + +interface RestartTaskLogs { + task_id: string + lines: string[] +} + +type RestartAction = 'restart-backend' | 'restart-ai-provider' | 'restart-database' | 'restart-system' +type RestartStage = 'confirming' | 'waiting_for_shutdown' | 'waiting_for_recovery' | 'recovered' | 'failed' | 'timeout' + +const RESTART_ACTION_OPTIONS: Array<{ value: RestartAction; label: string; description: string; command: string }> = [ + { + value: 'restart-backend', + label: '重启后端', + description: '只重启后端服务,页面通常会短暂失联后自动恢复。', + command: './planet.sh restart -b', + }, + { + value: 'restart-ai-provider', + label: '重启 AI Provider', + description: '只重启 AI Provider 适配服务,前端页面通常保持在线。', + command: './planet.sh restart -a', + }, + { + value: 'restart-database', + label: '重启数据库', + description: '重启 PostgreSQL 和 Redis 容器,前端页面保持在线。', + command: './planet.sh restart -d', + }, + { + value: 'restart-system', + label: '完全重启', + description: '重启前后端和相关服务,页面会短暂不可用,恢复后自动刷新。', + command: './planet.sh restart', + }, +] + +const RESTART_GUIDE_LINES: Record = { + 'restart-backend': [ + '[ctl] preparing backend restart task', + '[ctl] handing restart to detached runner', + '[ctl] waiting for backend health recovery', + ], + 'restart-ai-provider': [ + '[ctl] preparing ai provider restart task', + '[ctl] handing restart to detached runner', + '[ctl] waiting for ai provider health recovery', + ], + 'restart-database': [ + '[ctl] preparing database restart task', + '[ctl] restarting PostgreSQL and Redis containers', + '[ctl] waiting for containers to settle', + ], + 'restart-system': [ + '[ctl] preparing full system restart', + '[ctl] notifying operator that frontend may disconnect', + '[ctl] stopping frontend and backend services', + '[ctl] restarting platform services', + '[ctl] polling for frontend re-entry window', + ], +} + +let cachedDashboardStats: Stats | null = null + +function getRestartConfirmMessage(action: RestartAction): string { + if (action === 'restart-ai-provider') { + return '将重启 AI Provider 适配服务,页面通常保持在线,但 AI 分析请求会短暂不可用。' + } + if (action === 'restart-database') { + return '将重启 PostgreSQL 和 Redis,页面通常保持在线,但相关请求可能短暂波动。' + } + if (action === 'restart-system') { + return '将完全重启前后端和相关服务,页面会短暂不可用,恢复后会自动刷新。' + } + return '将重启后端服务,页面会短暂不可用。' +} + function Dashboard() { - const { token, clearAuth } = useAuthStore() - const [stats, setStats] = useState(null) - const [loading, setLoading] = useState(true) + const { token, clearAuth, user } = useAuthStore() + const [stats, setStats] = useState(cachedDashboardStats) + const [loading, setLoading] = useState(cachedDashboardStats === null) const [wsConnected, setWsConnected] = useState(false) const [error, setError] = useState(null) + const [restartModalOpen, setRestartModalOpen] = useState(false) + const [restartSubmitting, setRestartSubmitting] = useState(false) + const [restartAction, setRestartAction] = useState('restart-backend') + const [restartTaskId, setRestartTaskId] = useState(null) + const [restartMessage, setRestartMessage] = useState(getRestartConfirmMessage('restart-backend')) + const [restartStage, setRestartStage] = useState('confirming') + const [restartLogs, setRestartLogs] = useState([]) + const [restartStartedAt, setRestartStartedAt] = useState(null) + const isSuperAdmin = user?.role === 'super_admin' + const selectedRestartAction = RESTART_ACTION_OPTIONS.find((item) => item.value === restartAction) ?? RESTART_ACTION_OPTIONS[0] useEffect(() => { if (!token) return const fetchStats = async () => { try { - setLoading(true) + if (!cachedDashboardStats) { + setLoading(true) + } const res = await fetch('/api/v1/dashboard/stats', { headers: { Authorization: `Bearer ${token}` }, }) @@ -48,6 +151,7 @@ function Dashboard() { return } const data = await res.json() + cachedDashboardStats = data setStats(data) setError(null) } catch (err) { @@ -61,61 +165,203 @@ function Dashboard() { fetchStats() }, [token, clearAuth]) - useEffect(() => { - if (!token) return - - let ws: WebSocket | null = null - let reconnectTimer: ReturnType | null = null - - const connectWs = () => { - try { - ws = new WebSocket(`ws://localhost:8000/ws?token=${token}`) - - ws.onopen = () => { - setWsConnected(true) - ws?.send(JSON.stringify({ type: 'subscribe', data: { channels: ['dashboard'] } })) - } - - ws.onmessage = (event) => { - try { - const msg = JSON.parse(event.data) - if (msg.type === 'data_frame' && msg.channel === 'dashboard') { - setStats(msg.payload?.stats as Stats) - } - } catch (e) { - console.error('Parse WS message error:', e) - } - } - - ws.onclose = () => { - setWsConnected(false) - reconnectTimer = setTimeout(connectWs, 3000) - } - - ws.onerror = () => { - setWsConnected(false) - } - } catch (e) { - console.error('WS connect error:', e) + const { connected: dashboardSocketConnected } = useWebSocket({ + autoConnect: true, + autoSubscribe: ['dashboard'], + onMessage: (message) => { + if (message.type === 'data_frame' && message.channel === 'dashboard' && message.payload?.stats) { + const nextStats = message.payload.stats as Stats + cachedDashboardStats = nextStats + setStats(nextStats) } - } + }, + }) - connectWs() - - return () => { - ws?.close() - if (reconnectTimer) clearTimeout(reconnectTimer) - } - }, [token]) + useEffect(() => { + setWsConnected(dashboardSocketConnected) + }, [dashboardSocketConnected]) const handleRetry = () => { window.location.reload() } + const openRestartModal = () => { + setRestartAction('restart-backend') + setRestartTaskId(null) + setRestartLogs([]) + setRestartSubmitting(false) + setRestartStartedAt(null) + setRestartStage('confirming') + setRestartMessage(getRestartConfirmMessage('restart-backend')) + setRestartModalOpen(true) + } + + const closeRestartModal = () => { + if (restartSubmitting || restartStage === 'waiting_for_shutdown' || restartStage === 'waiting_for_recovery') { + return + } + setRestartModalOpen(false) + } + + const handleRestartAction = async () => { + setRestartSubmitting(true) + setRestartLogs(RESTART_GUIDE_LINES[restartAction].slice(0, restartAction === 'restart-system' ? 3 : 1)) + try { + const res = await axios.post('/api/v1/system/restart-tasks', { action: restartAction }) + setRestartTaskId(res.data.task_id) + setRestartStartedAt(Date.now()) + setRestartStage('waiting_for_shutdown') + setRestartMessage( + restartAction === 'restart-system' + ? '已发送完全重启指令,页面可能暂时失联,恢复后会自动刷新。' + : restartAction === 'restart-ai-provider' + ? '已发送 AI Provider 重启指令,正在等待 AI 服务恢复。' + : '已发送重启指令,正在等待服务进入重启流程。' + ) + setRestartLogs((current) => [...current, `任务已创建: ${res.data.task_id}`]) + } catch (restartError: unknown) { + const err = restartError as { response?: { data?: { detail?: string } } } + setRestartStage('failed') + setRestartMessage(err.response?.data?.detail || '提交重启任务失败') + setRestartLogs((current) => [...current, '提交重启任务失败']) + } finally { + setRestartSubmitting(false) + } + } + + useEffect(() => { + if (!restartModalOpen || restartStage !== 'confirming') return + setRestartMessage(getRestartConfirmMessage(restartAction)) + }, [restartAction, restartModalOpen, restartStage]) + + useEffect(() => { + if (!restartModalOpen || !restartTaskId || restartStartedAt === null) return + + let cancelled = false + let sawUnhealthy = false + let healthyStreak = 0 + let frontendHealthyStreak = 0 + let pollTimer: number | null = null + + const appendLog = (line: string) => { + setRestartLogs((current) => (current[current.length - 1] === line ? current : [...current, line].slice(-8))) + } + + const poll = async () => { + if (cancelled) return + + const elapsed = Date.now() - restartStartedAt + if (elapsed > 90_000) { + setRestartStage('timeout') + setRestartMessage('恢复超时,请手动检查后端服务状态。') + appendLog('恢复超时,请手动检查服务状态') + return + } + + try { + const taskRes = await axios.get(`/api/v1/system/restart-tasks/${restartTaskId}`, { timeout: 1500 }) + const task = taskRes.data + if (!cancelled && task?.message) { + setRestartMessage(task.message) + } + if (!cancelled && restartAction !== 'restart-system') { + const logsRes = await axios.get(`/api/v1/system/restart-tasks/${restartTaskId}/logs`, { timeout: 1500 }) + if (logsRes.data.lines.length > 0) { + setRestartLogs(logsRes.data.lines.slice(-8)) + } + } + if (!cancelled && typeof task?.stage === 'string') { + if (task.stage === 'healthy') { + setRestartStage('recovered') + setRestartMessage('服务已恢复,正在刷新页面。') + appendLog('后端已恢复,正在刷新页面') + window.setTimeout(() => window.location.reload(), 600) + return + } + if (task.status === 'failed' || task.status === 'timeout') { + setRestartStage(task.status === 'timeout' ? 'timeout' : 'failed') + setRestartMessage(task.message || '重启任务失败') + appendLog(task.message || '重启任务失败') + return + } + } + } catch { + // Backend may be temporarily down during restart; handled by health polling below. + } + + if (restartAction === 'restart-system') { + try { + const rootRes = await fetch(`/?restart_probe=${Date.now()}`, { cache: 'no-store' }) + if (rootRes.ok) { + frontendHealthyStreak += 1 + if (sawUnhealthy && frontendHealthyStreak >= 2) { + setRestartStage('recovered') + setRestartMessage('系统已恢复,正在刷新页面。') + appendLog('[ctl] frontend entrypoint reachable again') + window.setTimeout(() => window.location.reload(), 600) + return + } + } else { + sawUnhealthy = true + frontendHealthyStreak = 0 + setRestartStage('waiting_for_recovery') + setRestartMessage('系统正在完全重启,正在等待前端恢复访问。') + appendLog('[ctl] frontend is temporarily unavailable') + } + } catch { + sawUnhealthy = true + frontendHealthyStreak = 0 + setRestartStage('waiting_for_recovery') + setRestartMessage('系统正在完全重启,正在等待前端恢复访问。') + appendLog('[ctl] frontend is temporarily unavailable') + } + + pollTimer = window.setTimeout(poll, 1500) + return + } + + try { + const healthRes = await fetch('/health', { cache: 'no-store' }) + if (healthRes.ok) { + healthyStreak += 1 + if (sawUnhealthy && healthyStreak >= 2) { + setRestartStage('recovered') + setRestartMessage('服务已恢复,正在刷新页面。') + appendLog('健康检查已恢复,正在刷新页面') + window.setTimeout(() => window.location.reload(), 600) + return + } + } else { + sawUnhealthy = true + healthyStreak = 0 + setRestartStage('waiting_for_recovery') + setRestartMessage('后端已停止响应,正在等待服务恢复。') + appendLog('检测到后端已停止响应') + } + } catch { + sawUnhealthy = true + healthyStreak = 0 + setRestartStage('waiting_for_recovery') + setRestartMessage('后端已停止响应,正在等待服务恢复。') + appendLog('检测到后端已停止响应') + } + + pollTimer = window.setTimeout(poll, 1500) + } + + pollTimer = window.setTimeout(poll, 1200) + return () => { + cancelled = true + if (pollTimer !== null) { + window.clearTimeout(pollTimer) + } + } + }, [restartAction, restartModalOpen, restartStartedAt, restartTaskId]) + if (loading && !stats) { return ( -
- +
+
) } @@ -134,7 +380,17 @@ function Dashboard() { ) : ( } color="default">离线 )} - + {isSuperAdmin ? ( + + ) : null} +
@@ -168,6 +424,21 @@ function Dashboard() { + + + +
+ 快捷入口 + 快速访问地球可视化页面 +
+ + + +
+
+ } /> @@ -187,10 +458,88 @@ function Dashboard() { {stats?.last_updated && (
- 最后更新: {new Date(stats.last_updated).toLocaleString('zh-CN')} + 最后更新: {formatDateTimeZhCN(stats.last_updated)} {wsConnected && 实时同步中}
)} + + + {restartStage === 'confirming' ? '取消' : '关闭'} + , + , + ]} + > +
+
+
+ 重启动作 + - + + + + +
名称
+ + + +
模块
+ + + +
优先级
+ + + +
频率
+ + + +
数据量
+ + + +
采集器
+ + +
+
- - - +
+ + + + +