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 = """
+
+ | Rank | System | Cores | Rmax | Rpeak | Power |
+
+ | 1 |
+ Test Supercomputer, Test Corp\nTest Site\nUSA |
+ 100000 |
+ 100 PFLOP/s |
+ 150 PFLOP/s |
+ 5000 |
+
+
+ """
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 = """
+
+ | Rank | System | Cores | Rmax | Rpeak | Power |
+
+ | 1 |
+ Valid\nVendor\nSite\nUSA |
+ 1000 |
+ 10 PFLOP/s |
+ 12 PFLOP/s |
+ 100 |
+
+
+ | - |
+ Invalid |
+ 1000 |
+ 10 PFLOP/s |
+ 12 PFLOP/s |
+ 100 |
+
+
+ """
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 @@
+
+
+
+
+
+ 智能星球计划 - 现实层宇宙全息感知
+
+
+
+
+
+
+
+
+
+
+
智能星球计划
+
现实层宇宙全息感知系统 | 卫星 · 海底光缆 · 算力基础设施
+
+
+
+
+
+
+
+
+
+
坐标信息
+
+ 经度:
+ 0.00°
+
+
+ 纬度:
+ 0.00°
+
+
缩放: 1.0x
+
鼠标位置: 无
+
+
+
+
+
+
地球信息
+
+ 电缆系统:
+ 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 @@
+
+
智能星球计划
-
现实层宇宙全息感知系统 | 卫星 · 海底光缆 · 算力基础设施
+
+ 现实层宇宙全息感知系统
+ 卫星 · 海底光缆 · 算力基础设施
+
-
图例
-
-
-
-
@@ -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) => `
+
+ `,
+ )
+ .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">离线
)}
-
} onClick={handleRetry}>刷新
+ {isSuperAdmin ? (
+
}
+ danger
+ onClick={openRestartModal}
+ >
+ 重启
+
+ ) : null}
+
} onClick={handleRetry}>刷新
@@ -168,6 +424,21 @@ function Dashboard() {
+
+
+
+
+
快捷入口
+ 快速访问地球可视化页面
+
+
+ } className="dashboard-quick-entry__button">
+ 访问 Earth
+
+
+
+
+
} />
@@ -187,10 +458,88 @@ function Dashboard() {
{stats?.last_updated && (
- 最后更新: {new Date(stats.last_updated).toLocaleString('zh-CN')}
+ 最后更新: {formatDateTimeZhCN(stats.last_updated)}
{wsConnected && 实时同步中}
)}
+
+
+ {restartStage === 'confirming' ? '取消' : '关闭'}
+ ,
+ ,
+ ]}
+ >
+
+
+
+ 重启动作
+
+
+
+ 执行命令
+ {selectedRestartAction.command}
+
+
+ 任务 ID
+ {restartTaskId || '等待创建'}
+
+
+
+
+
+
+
+
终端输出
+
+ {restartLogs.length > 0 ? restartLogs.map((line, index) => (
+
{line}
+ )) :
等待操作
}
+
+
+
+
)
diff --git a/frontend/src/pages/DataList/DataList.tsx b/frontend/src/pages/DataList/DataList.tsx
index 4f2f548c..abedf157 100644
--- a/frontend/src/pages/DataList/DataList.tsx
+++ b/frontend/src/pages/DataList/DataList.tsx
@@ -1,16 +1,18 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import {
- Table, Tag, Space, Card, Select, Input, Button,
+ Table, Tag, Space, Card, Select, Input, Button, Segmented,
Modal, Spin, Empty, Tooltip, Typography, Grid
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import type { CustomTagProps } from 'rc-select/lib/BaseSelect'
import {
DatabaseOutlined, GlobalOutlined, CloudServerOutlined,
- AppstoreOutlined, EyeOutlined, SearchOutlined, FilterOutlined, ReloadOutlined
+ AppstoreOutlined, EyeOutlined, SearchOutlined, FilterOutlined, ReloadOutlined,
+ ApartmentOutlined, EnvironmentOutlined
} from '@ant-design/icons'
import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout'
+import { formatDateTimeZhCN, formatDateZhCN, parseBackendDate } from '../../utils/datetime'
const { Title, Text } = Typography
const { useBreakpoint } = Grid
@@ -18,6 +20,7 @@ const { useBreakpoint } = Grid
interface CollectedData {
id: number
source: string
+ source_name: string
source_id: string
data_type: string
name: string
@@ -41,8 +44,15 @@ interface CollectedData {
interface Summary {
total_records: number
+ overall_total_records: number
by_source: Record>
- source_totals: Array<{ source: string; count: number }>
+ source_totals: Array<{ source: string; source_name: string; count: number }>
+ type_totals: Array<{ data_type: string; count: number }>
+}
+
+interface SourceOption {
+ source: string
+ source_name: string
}
const DETAIL_FIELD_LABELS: Record = {
@@ -111,12 +121,15 @@ function formatDetailValue(key: string, value: unknown) {
}
if (key === 'collected_at' || key === 'reference_date') {
- const date = new Date(String(value))
+ const date = parseBackendDate(String(value))
+ if (!date) {
+ return String(value)
+ }
return Number.isNaN(date.getTime())
? String(value)
: key === 'reference_date'
- ? date.toLocaleDateString('zh-CN')
- : date.toLocaleString('zh-CN')
+ ? formatDateZhCN(String(value))
+ : formatDateTimeZhCN(String(value))
}
if (typeof value === 'boolean') {
@@ -130,6 +143,13 @@ function formatDetailValue(key: string, value: unknown) {
return String(value)
}
+function getDetailFieldValue(detailData: CollectedData, key: string): unknown {
+ if (key === 'source') {
+ return detailData.source_name || detailData.source
+ }
+ return detailData[key as keyof CollectedData]
+}
+
function NameMarquee({ text }: { text: string }) {
const containerRef = useRef(null)
const textRef = useRef(null)
@@ -222,6 +242,56 @@ function estimateTreemapRows(
return Math.max(occupancy.length, 1)
}
+function getTreemapSpan(value: number, maxValue: number, columns: number) {
+ if (columns <= 1) return 1
+
+ const normalized = Math.log10(value + 1) / Math.log10(maxValue + 1)
+
+ if (columns >= 4 && normalized >= 0.94) return 3
+ if (normalized >= 0.62) return 2
+ return 1
+}
+
+function isCompactTreemapItem(item: { colSpan: number; rowSpan: number }) {
+ return item.colSpan === 1 && item.rowSpan === 1
+}
+
+function getTreemapColumnCount(
+ width: number,
+ minCellSize: number,
+ gap: number,
+ isCompact: boolean
+) {
+ const visualCap = isCompact ? 4 : 8
+ if (width <= 0) return Math.min(visualCap, isCompact ? 2 : 4)
+
+ const maxColumnsByWidth = Math.max(1, Math.floor((width + gap) / (minCellSize + gap)))
+ return Math.max(1, Math.min(maxColumnsByWidth, visualCap))
+}
+
+function getTreemapBaseSize(width: number, columns: number, gap: number, minCellSize: number) {
+ const fittedSize = Math.floor((Math.max(width, 0) - Math.max(0, columns - 1) * gap) / columns)
+ return Math.max(minCellSize, fittedSize || minCellSize)
+}
+
+function getTreemapTypography(rowHeight: number) {
+ const tilePadding = rowHeight <= 72 ? 8 : rowHeight <= 84 ? 10 : 12
+ const labelSize = rowHeight <= 72 ? 10 : rowHeight <= 84 ? 11 : 12
+ const valueSize = rowHeight <= 72 ? 13 : rowHeight <= 84 ? 15 : 16
+
+ return { tilePadding, labelSize, valueSize }
+}
+
+function getTreemapItemValueSize(
+ item: { colSpan: number; rowSpan: number },
+ baseValueSize: number
+) {
+ if (isCompactTreemapItem(item)) {
+ return Math.max(11, baseValueSize - 2)
+ }
+ return baseValueSize
+}
+
function DataList() {
const screens = useBreakpoint()
const isCompact = !screens.lg
@@ -239,6 +309,7 @@ function DataList() {
const [tableHeaderHeight, setTableHeaderHeight] = useState(0)
const [leftPanelWidth, setLeftPanelWidth] = useState(360)
const [summaryBodyHeight, setSummaryBodyHeight] = useState(0)
+ const [summaryBodyWidth, setSummaryBodyWidth] = useState(0)
const [data, setData] = useState([])
const [loading, setLoading] = useState(false)
@@ -249,11 +320,12 @@ function DataList() {
const [sourceFilter, setSourceFilter] = useState([])
const [typeFilter, setTypeFilter] = useState([])
const [searchText, setSearchText] = useState('')
- const [sources, setSources] = useState([])
+ const [sources, setSources] = useState([])
const [types, setTypes] = useState([])
const [detailVisible, setDetailVisible] = useState(false)
const [detailData, setDetailData] = useState(null)
const [detailLoading, setDetailLoading] = useState(false)
+ const [treemapDimension, setTreemapDimension] = useState<'source' | 'type'>('source')
useEffect(() => {
const updateLayout = () => {
@@ -262,6 +334,7 @@ function DataList() {
setRightColumnHeight(rightColumnRef.current?.offsetHeight || 0)
setTableHeaderHeight(tableHeaderRef.current?.offsetHeight || 0)
setSummaryBodyHeight(summaryBodyRef.current?.offsetHeight || 0)
+ setSummaryBodyWidth(summaryBodyRef.current?.offsetWidth || 0)
}
updateLayout()
@@ -289,7 +362,7 @@ function DataList() {
const minLeft = 260
const minRight = 360
const maxLeft = Math.max(minLeft, mainAreaWidth - minRight - 12)
- const preferredLeft = Math.max(minLeft, Math.min(Math.round((mainAreaWidth - 12) / 4), maxLeft))
+ const preferredLeft = Math.max(minLeft, Math.min(Math.round((mainAreaWidth - 12) / 3), maxLeft))
setLeftPanelWidth((current) => {
if (!hasCustomLeftWidthRef.current) {
@@ -347,7 +420,13 @@ function DataList() {
const fetchSummary = async () => {
try {
- const res = await axios.get('/api/v1/collected/summary')
+ const params = new URLSearchParams()
+ if (sourceFilter.length > 0) params.append('source', sourceFilter.join(','))
+ if (typeFilter.length > 0) params.append('data_type', typeFilter.join(','))
+ if (searchText) params.append('search', searchText)
+
+ const query = params.toString()
+ const res = await axios.get(query ? `/api/v1/collected/summary?${query}` : '/api/v1/collected/summary')
setSummary(res.data)
} catch (error) {
console.error('Failed to fetch summary:', error)
@@ -368,17 +447,18 @@ function DataList() {
}
useEffect(() => {
- fetchSummary()
fetchFilters()
}, [])
useEffect(() => {
fetchData()
+ fetchSummary()
}, [page, pageSize, sourceFilter, typeFilter])
const handleSearch = () => {
setPage(1)
fetchData()
+ fetchSummary()
}
const handleReset = () => {
@@ -387,6 +467,7 @@ function DataList() {
setSearchText('')
setPage(1)
setTimeout(fetchData, 0)
+ setTimeout(fetchSummary, 0)
}
const handleViewDetail = async (id: number) => {
@@ -414,16 +495,67 @@ function DataList() {
return iconMap[source] ||
}
+ const getDataTypeIcon = (dataType: string) => {
+ const iconMap: Record = {
+ supercomputer: ,
+ gpu_cluster: ,
+ model: ,
+ dataset: ,
+ space: ,
+ submarine_cable: ,
+ cable_landing_point: ,
+ cable_landing_relation: ,
+ ixp: ,
+ network: ,
+ facility: ,
+ generic: ,
+ }
+
+ return iconMap[dataType] ||
+ }
+
const getSourceTagColor = (source: string) => {
const colorMap: Record = {
top500: 'geekblue',
huggingface_models: 'purple',
huggingface_datasets: 'cyan',
huggingface_spaces: 'magenta',
+ peeringdb_ixp: 'gold',
+ peeringdb_network: 'orange',
+ peeringdb_facility: 'lime',
telegeography_cables: 'green',
+ telegeography_landing: 'green',
+ telegeography_systems: 'emerald',
+ arcgis_cables: 'blue',
+ arcgis_landing_points: 'cyan',
+ arcgis_cable_landing_relations: 'volcano',
+ fao_landing_points: 'processing',
epoch_ai_gpu: 'volcano',
+ ris_live_bgp: 'red',
+ bgpstream_bgp: 'purple',
+ cloudflare_radar_device: 'magenta',
+ cloudflare_radar_traffic: 'orange',
+ cloudflare_radar_top_as: 'gold',
}
- return colorMap[source] || 'blue'
+
+ if (colorMap[source]) {
+ return colorMap[source]
+ }
+
+ const fallbackPalette = [
+ 'blue',
+ 'geekblue',
+ 'cyan',
+ 'green',
+ 'lime',
+ 'gold',
+ 'orange',
+ 'volcano',
+ 'magenta',
+ 'purple',
+ ]
+ const hash = Array.from(source).reduce((acc, char) => acc + char.charCodeAt(0), 0)
+ return fallbackPalette[hash % fallbackPalette.length]
}
const getDataTypeTagColor = (dataType: string) => {
@@ -455,103 +587,86 @@ function DataList() {
)
}
- const getTypeColor = (type: string) => {
- const colors: Record = {
- supercomputer: 'red',
- model: 'blue',
- dataset: 'green',
- space: 'purple',
- submarine_cable: 'cyan',
- gpu_cluster: 'orange',
- ixp: 'magenta',
- network: 'gold',
- facility: 'lime',
- }
- return colors[type] || 'default'
- }
-
const activeFilterCount = useMemo(
() => [sourceFilter.length > 0, typeFilter.length > 0, searchText.trim()].filter(Boolean).length,
[sourceFilter, typeFilter, searchText]
)
- const summaryItems = useMemo(() => {
- const items = [
- { key: 'total', label: '总记录', value: summary?.total_records || 0, icon: },
+ const summaryKpis = useMemo(
+ () => [
+ { key: 'total', label: '总记录', value: summary?.overall_total_records || 0, icon: },
{ key: 'result', label: '筛选结果', value: total, icon: },
{ key: 'filters', label: '启用筛选', value: activeFilterCount, icon: },
- { key: 'sources', label: '数据源数', value: sources.length, icon: },
- ]
+ {
+ key: 'coverage',
+ label: treemapDimension === 'source' ? '覆盖数据源' : '覆盖类型',
+ value: treemapDimension === 'source'
+ ? summary?.source_totals?.length || 0
+ : summary?.type_totals?.length || 0,
+ icon: treemapDimension === 'source' ? : ,
+ },
+ ],
+ [summary, total, activeFilterCount, treemapDimension]
+ )
- for (const item of (summary?.source_totals || []).slice(0, isCompact ? 3 : 5)) {
- items.push({
- key: item.source,
- label: item.source,
+ const distributionItems = useMemo(() => {
+ if (!summary) return []
+
+ if (treemapDimension === 'type') {
+ return summary.type_totals.map((item) => ({
+ key: item.data_type,
+ label: item.data_type,
value: item.count,
- icon: getSourceIcon(item.source),
- })
+ icon: getDataTypeIcon(item.data_type),
+ }))
}
- return items
- }, [summary, total, activeFilterCount, isCompact, sources.length])
+ return summary.source_totals.map((item) => ({
+ key: item.source,
+ label: item.source_name,
+ value: item.count,
+ icon: getSourceIcon(item.source),
+ }))
+ }, [summary, treemapDimension])
+ const treemapGap = isCompact ? 8 : 10
+ const treemapMinCellSize = isCompact ? 72 : 52
const treemapColumns = useMemo(() => {
- if (isCompact) return 1
- if (leftPanelWidth < 360) return 2
- if (leftPanelWidth < 520) return 3
- return 4
- }, [isCompact, leftPanelWidth])
+ return getTreemapColumnCount(summaryBodyWidth, treemapMinCellSize, treemapGap, isCompact)
+ }, [isCompact, summaryBodyWidth, treemapGap, treemapMinCellSize])
const treemapItems = useMemo(() => {
const palette = ['ocean', 'sky', 'mint', 'amber', 'rose', 'violet', 'slate']
- const maxValue = Math.max(...summaryItems.map((item) => item.value), 1)
- const allowFeaturedTile = !isCompact && treemapColumns > 1 && summaryItems.length > 2
- const allowSecondaryTallTiles = !isCompact && leftPanelWidth >= 520
+ const maxItems = isCompact ? 6 : 10
+ const limitedItems = distributionItems.slice(0, maxItems)
+ const maxValue = Math.max(...limitedItems.map((item) => item.value), 1)
- return summaryItems.map((item, index) => {
- const ratio = item.value / maxValue
- let colSpan = 1
- let rowSpan = 1
-
- if (allowFeaturedTile && index === 0) {
- colSpan = Math.min(2, treemapColumns)
- rowSpan = 2
- } else if (allowSecondaryTallTiles && ratio >= 0.7) {
- colSpan = Math.min(2, treemapColumns)
- rowSpan = 2
- } else if (allowSecondaryTallTiles && ratio >= 0.35) {
- rowSpan = 2
- }
+ return limitedItems.map((item, index) => {
+ const span = Math.min(getTreemapSpan(item.value, maxValue, treemapColumns), treemapColumns)
return {
...item,
- colSpan,
- rowSpan,
+ colSpan: span,
+ rowSpan: span,
tone: palette[index % palette.length],
}
})
- }, [summaryItems, isCompact, leftPanelWidth, treemapColumns])
+ }, [distributionItems, isCompact, treemapColumns])
const treemapRows = useMemo(
() => estimateTreemapRows(treemapItems, treemapColumns),
[treemapColumns, treemapItems]
)
- const treemapGap = isCompact ? 8 : 10
- const treemapMinRowHeight = isCompact ? 88 : 68
- const treemapTargetRowHeight = isCompact ? 88 : leftPanelWidth < 360 ? 44 : leftPanelWidth < 520 ? 48 : 56
- const treemapAvailableHeight = Math.max(summaryBodyHeight, 0)
- const treemapAutoRowHeight = treemapRows > 0
- ? Math.floor((treemapAvailableHeight - Math.max(0, treemapRows - 1) * treemapGap) / treemapRows)
- : treemapTargetRowHeight
- const treemapRowHeight = Math.max(
- treemapMinRowHeight,
- Math.min(treemapTargetRowHeight, treemapAutoRowHeight || treemapTargetRowHeight)
+ const treemapBaseSize = Math.max(
+ treemapMinCellSize,
+ getTreemapBaseSize(summaryBodyWidth, treemapColumns, treemapGap, treemapMinCellSize)
)
+ const treemapAvailableHeight = Math.max(summaryBodyHeight, 0)
+ const treemapRowHeight = treemapBaseSize
const treemapContentHeight = treemapRows * treemapRowHeight + Math.max(0, treemapRows - 1) * treemapGap
- const treemapTilePadding = treemapRowHeight <= 72 ? 8 : treemapRowHeight <= 84 ? 10 : 12
- const treemapLabelSize = treemapRowHeight <= 72 ? 10 : treemapRowHeight <= 84 ? 11 : 12
- const treemapValueSize = treemapRowHeight <= 72 ? 13 : treemapRowHeight <= 84 ? 15 : 16
+ const { tilePadding: treemapTilePadding, labelSize: treemapLabelSize, valueSize: treemapValueSize } =
+ getTreemapTypography(treemapRowHeight)
const pageHeight = '100%'
const desktopTableHeight = rightColumnHeight - tableHeaderHeight - 132
@@ -564,7 +679,7 @@ function DataList() {
return DETAIL_BASE_FIELDS.map((key) => ({
key,
label: formatFieldLabel(key),
- value: formatDetailValue(key, detailData[key as keyof CollectedData]),
+ value: formatDetailValue(key, getDetailFieldValue(detailData, key)),
})).filter((item) => item.value !== '-')
}, [detailData])
@@ -605,11 +720,11 @@ function DataList() {
dataIndex: 'source',
key: 'source',
minWidth: 140,
- render: (value: string) => (
- value ? (
+ render: (_: string, record: CollectedData) => (
+ record.source ? (
-
- {value}
+
+ {record.source_name || record.source}
) : '-'
@@ -635,14 +750,14 @@ function DataList() {
dataIndex: 'collected_at',
key: 'collected_at',
width: 180,
- render: (time: string) => new Date(time).toLocaleString('zh-CN'),
+ render: (time: string) => formatDateTimeZhCN(time),
},
{
title: '参考日期',
dataIndex: 'reference_date',
key: 'reference_date',
width: 120,
- render: (time: string | null) => (time ? new Date(time).toLocaleDateString('zh-CN') : '-'),
+ render: (time: string | null) => formatDateZhCN(time),
},
{
title: '操作',
@@ -682,6 +797,31 @@ function DataList() {
styles={{ body: { padding: isCompact ? 12 : 16 } }}
>
+
+ {summaryKpis.map((item) => (
+
+
+ {item.icon}
+ {item.label}
+
+
+ {item.value.toLocaleString()}
+
+
+ ))}
+
+
+ 分布概览
+ setTreemapDimension(value as 'source' | 'type')}
+ options={[
+ { label: '按数据源', value: 'source' },
+ { label: '按类型', value: 'type' },
+ ]}
+ />
+
- {treemapItems.map((item) => (
+ {treemapItems.length > 0 ? treemapItems.map((item) => (
- {item.icon}
- {item.label}
+
+ {item.icon}
+
+ {!isCompactTreemapItem(item) ? (
+
+ {item.label}
+
+ ) : null}
-
+
{item.value.toLocaleString()}
- ))}
+ )) : (
+
+
+
+ )}
@@ -756,7 +910,7 @@ function DataList() {
setSourceFilter(value)
setPage(1)
}}
- options={sources.map((source) => ({ label: source, value: source }))}
+ options={sources.map((source) => ({ label: source.source_name, value: source.source }))}
tagRender={(tagProps) => renderFilterTag(tagProps, getSourceTagColor)}
style={{ width: '100%' }}
className="data-list-filter-select"
diff --git a/frontend/src/pages/DataSources/DataSources.tsx b/frontend/src/pages/DataSources/DataSources.tsx
index d8bb3bb3..70b65042 100644
--- a/frontend/src/pages/DataSources/DataSources.tsx
+++ b/frontend/src/pages/DataSources/DataSources.tsx
@@ -1,7 +1,7 @@
-import { useEffect, useRef, useState } from 'react'
+import { useCallback, useEffect, useRef, useState } from 'react'
import {
- Table, Tag, Space, message, Button, Form, Input, Select,
- Drawer, Tabs, Empty, Tooltip, Popconfirm, Collapse, InputNumber
+ Table, Tag, Space, Button, Form, Input, Select, Progress, Checkbox, message,
+ Drawer, Tabs, Empty, Tooltip, Popconfirm, Collapse, InputNumber, Row, Col, Card
} from 'antd'
import {
PlayCircleOutlined, PauseCircleOutlined, PlusOutlined,
@@ -11,6 +11,8 @@ import {
} from '@ant-design/icons'
import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout'
+import { formatDateTimeZhCN } from '../../utils/datetime'
+import { useWebSocket } from '../../hooks/useWebSocket'
interface BuiltInDataSource {
id: number
@@ -22,6 +24,10 @@ interface BuiltInDataSource {
is_active: boolean
collector_class: string
last_run: string | null
+ last_run_at?: string | null
+ last_status?: string | null
+ last_records_processed?: number | null
+ data_count?: number
is_running: boolean
task_id: number | null
progress: number | null
@@ -38,6 +44,53 @@ interface TaskTrackerState {
status?: string | null
records_processed?: number | null
total_records?: number | null
+ error_message?: string | null
+}
+
+interface BulkProgressItem {
+ task_id: number | null
+ progress: number
+ status: string | null
+ phase: string | null
+ is_running: boolean
+}
+
+interface BulkProgressBatch {
+ sourceIds: number[]
+ items: Record
+}
+
+function finalizeBulkProgressBatch(batch: BulkProgressBatch | null): BulkProgressBatch | null {
+ if (!batch || batch.sourceIds.length === 0) {
+ return null
+ }
+
+ const hasRunningItem = batch.sourceIds.some((sourceId) => batch.items[sourceId]?.is_running)
+ if (hasRunningItem) {
+ return batch
+ }
+
+ const allFinished = batch.sourceIds.every((sourceId) => {
+ const status = batch.items[sourceId]?.status
+ return Boolean(status && status !== 'running')
+ })
+
+ return allFinished ? null : batch
+}
+
+interface WebSocketTaskMessage {
+ type: string
+ channel?: string
+ payload?: {
+ datasource_id?: number
+ task_id?: number | null
+ progress?: number | null
+ phase?: string | null
+ status?: string | null
+ records_processed?: number | null
+ total_records?: number | null
+ error_message?: string | null
+ }
}
interface CustomDataSource {
@@ -68,6 +121,7 @@ interface ViewDataSource {
}
function DataSources() {
+ const [messageApi, contextHolder] = message.useMessage()
const [activeTab, setActiveTab] = useState('builtin')
const [builtInSources, setBuiltInSources] = useState([])
const [customSources, setCustomSources] = useState([])
@@ -78,6 +132,8 @@ function DataSources() {
const [viewingSource, setViewingSource] = useState(null)
const [recordCount, setRecordCount] = useState(0)
const [testing, setTesting] = useState(false)
+ const [triggerAllLoading, setTriggerAllLoading] = useState(false)
+ const [forceTriggerAll, setForceTriggerAll] = useState(false)
const [testResult, setTestResult] = useState(null)
const builtinTableRegionRef = useRef(null)
const customTableRegionRef = useRef(null)
@@ -85,7 +141,7 @@ function DataSources() {
const [customTableHeight, setCustomTableHeight] = useState(360)
const [form] = Form.useForm()
- const fetchData = async () => {
+ const fetchData = useCallback(async () => {
setLoading(true)
try {
const [builtinRes, customRes] = await Promise.all([
@@ -99,13 +155,127 @@ function DataSources() {
} finally {
setLoading(false)
}
- }
+ }, [])
const [taskProgress, setTaskProgress] = useState>({})
+ const [bulkProgressBatch, setBulkProgressBatch] = useState(null)
+ const activeBuiltInCount = builtInSources.filter((source) => source.is_active).length
+ const runningBuiltInCount = builtInSources.filter((source) => {
+ const trackedTask = taskProgress[source.id]
+ return trackedTask?.is_running || source.is_running
+ }).length
+ const runningBuiltInSources = builtInSources.filter((source) => {
+ const trackedTask = taskProgress[source.id]
+ return trackedTask?.is_running || source.is_running
+ })
+ const aggregateProgress = bulkProgressBatch && bulkProgressBatch.sourceIds.length > 0
+ ? Math.round(
+ bulkProgressBatch.sourceIds.reduce((sum, sourceId) => {
+ const item = bulkProgressBatch.items[sourceId]
+ if (!item) return sum
+ if (item.status && item.status !== 'running') {
+ return sum + 100
+ }
+ return sum + (item.progress || 0)
+ }, 0) / bulkProgressBatch.sourceIds.length
+ )
+ : runningBuiltInSources.length > 0
+ ? Math.round(
+ runningBuiltInSources.reduce((sum, source) => {
+ const trackedTask = taskProgress[source.id]
+ return sum + (trackedTask?.progress ?? source.progress ?? 0)
+ }, 0) / runningBuiltInSources.length
+ )
+ : 0
+
+ const bulkBatchRunningCount = bulkProgressBatch
+ ? bulkProgressBatch.sourceIds.filter((sourceId) => bulkProgressBatch.items[sourceId]?.is_running).length
+ : 0
+
+ const bulkBatchSuccessCount = bulkProgressBatch
+ ? bulkProgressBatch.sourceIds.filter((sourceId) => {
+ const status = bulkProgressBatch.items[sourceId]?.status
+ return status === 'success'
+ }).length
+ : 0
+
+ const bulkBatchFailedCount = bulkProgressBatch
+ ? bulkProgressBatch.sourceIds.filter((sourceId) => {
+ const status = bulkProgressBatch.items[sourceId]?.status
+ return Boolean(status && status !== 'running' && status !== 'success')
+ }).length
+ : 0
+
+ const handleTaskSocketMessage = useCallback((message: WebSocketTaskMessage) => {
+ if (message.type !== 'data_frame' || message.channel !== 'datasource_tasks' || !message.payload?.datasource_id) {
+ return
+ }
+
+ const payload = message.payload
+ const sourceId = payload.datasource_id
+ if (typeof sourceId !== 'number') {
+ return
+ }
+ const nextState: TaskTrackerState = {
+ task_id: payload.task_id ?? null,
+ progress: payload.progress ?? 0,
+ is_running: payload.status === 'running',
+ phase: payload.phase ?? null,
+ status: payload.status ?? null,
+ records_processed: payload.records_processed ?? null,
+ total_records: payload.total_records ?? null,
+ error_message: payload.error_message ?? null,
+ }
+
+ setTaskProgress((prev) => {
+ const next = {
+ ...prev,
+ [sourceId]: nextState,
+ }
+
+ if (!nextState.is_running && nextState.status !== 'running') {
+ delete next[sourceId]
+ }
+
+ return next
+ })
+
+ setBulkProgressBatch((prev) => {
+ if (!prev || !prev.sourceIds.includes(sourceId)) {
+ return prev
+ }
+
+ const nextItems = {
+ ...prev.items,
+ [sourceId]: {
+ task_id: payload.task_id ?? prev.items[sourceId]?.task_id ?? null,
+ progress: payload.status && payload.status !== 'running' ? 100 : (payload.progress ?? prev.items[sourceId]?.progress ?? 0),
+ is_running: payload.status === 'running',
+ phase: payload.phase ?? prev.items[sourceId]?.phase ?? null,
+ status: payload.status ?? prev.items[sourceId]?.status ?? null,
+ },
+ }
+
+ return finalizeBulkProgressBatch({
+ ...prev,
+ items: nextItems,
+ })
+ })
+
+ if (payload.status && payload.status !== 'running') {
+ void fetchData()
+ }
+ }, [fetchData])
+
+ const { connected: taskSocketConnected } = useWebSocket({
+ autoConnect: true,
+ autoSubscribe: ['datasource_tasks'],
+ onMessage: handleTaskSocketMessage,
+ })
useEffect(() => {
fetchData()
- }, [])
+ }, [fetchData])
useEffect(() => {
const updateHeights = () => {
@@ -130,6 +300,8 @@ function DataSources() {
}, [activeTab, builtInSources.length, customSources.length])
useEffect(() => {
+ if (taskSocketConnected) return
+
const trackedSources = builtInSources.filter((source) => {
const trackedTask = taskProgress[source.id]
return Boolean((trackedTask?.task_id ?? source.task_id) && (trackedTask?.is_running ?? source.is_running))
@@ -180,32 +352,122 @@ function DataSources() {
return next
})
+ setBulkProgressBatch((prev) => {
+ if (!prev) return prev
+
+ const nextItems = { ...prev.items }
+ for (const [sourceId, state] of Object.entries(updates)) {
+ const numericSourceId = Number(sourceId)
+ if (!prev.sourceIds.includes(numericSourceId)) continue
+ nextItems[numericSourceId] = {
+ task_id: state.task_id,
+ progress: state.status && state.status !== 'running' ? 100 : state.progress,
+ is_running: state.is_running,
+ phase: state.phase ?? null,
+ status: state.status ?? null,
+ }
+ }
+
+ return finalizeBulkProgressBatch({
+ ...prev,
+ items: nextItems,
+ })
+ })
+
if (Object.values(updates).some((state) => !state.is_running)) {
fetchData()
}
}, 2000)
return () => clearInterval(interval)
- }, [builtInSources, taskProgress])
+ }, [builtInSources, taskProgress, taskSocketConnected, fetchData])
const handleTrigger = async (id: number) => {
try {
const res = await axios.post(`/api/v1/datasources/${id}/trigger`)
- message.success('任务已触发')
- setTaskProgress(prev => ({
- ...prev,
- [id]: {
- task_id: res.data.task_id ?? null,
- progress: 0,
- is_running: true,
- phase: 'queued',
- status: 'running',
- },
- }))
+ messageApi.success('任务已触发')
+ if (res.data.task_id) {
+ setTaskProgress(prev => ({
+ ...prev,
+ [id]: {
+ task_id: res.data.task_id,
+ progress: 0,
+ is_running: true,
+ phase: 'queued',
+ status: 'running',
+ },
+ }))
+ } else {
+ window.setTimeout(() => {
+ fetchData()
+ }, 800)
+ }
fetchData()
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } }
- message.error(err.response?.data?.detail || '触发失败')
+ messageApi.error(err.response?.data?.detail || '触发失败')
+ }
+ }
+
+ const handleTriggerAll = async () => {
+ try {
+ setTriggerAllLoading(true)
+ const res = await axios.post('/api/v1/datasources/trigger-all', null, {
+ params: { force: forceTriggerAll },
+ })
+ const triggered = res.data.triggered || []
+ const skipped = res.data.skipped || []
+ const failed = res.data.failed || []
+ const skippedInWindow = skipped.filter((item: { reason?: string }) => item.reason === 'within_frequency_window')
+ const skippedOther = skipped.filter((item: { reason?: string }) => item.reason !== 'within_frequency_window')
+
+ if (triggered.length > 0) {
+ setBulkProgressBatch({
+ sourceIds: triggered.map((item: { id: number }) => item.id),
+ items: Object.fromEntries(
+ triggered.map((item: { id: number; task_id?: number | null }) => [
+ item.id,
+ {
+ task_id: item.task_id ?? null,
+ progress: 0,
+ is_running: true,
+ phase: 'queued',
+ status: 'running',
+ } satisfies BulkProgressItem,
+ ])
+ ),
+ })
+
+ setTaskProgress((prev) => {
+ const next = { ...prev }
+ for (const item of triggered) {
+ if (!item.task_id) continue
+ next[item.id] = {
+ task_id: item.task_id,
+ progress: 0,
+ is_running: true,
+ phase: 'queued',
+ status: 'running',
+ }
+ }
+ return next
+ })
+ }
+
+ const summaryParts = [
+ `已触发 ${triggered.length} 个`,
+ skippedInWindow.length > 0 ? `周期内跳过 ${skippedInWindow.length} 个` : null,
+ skippedOther.length > 0 ? `其他跳过 ${skippedOther.length} 个` : null,
+ failed.length > 0 ? `失败 ${failed.length} 个` : null,
+ ].filter(Boolean)
+
+ messageApi.success(summaryParts.join(','))
+ fetchData()
+ } catch (error: unknown) {
+ const err = error as { response?: { data?: { detail?: string } } }
+ messageApi.error(err.response?.data?.detail || '全触发失败')
+ } finally {
+ setTriggerAllLoading(false)
}
}
@@ -213,11 +475,11 @@ function DataSources() {
const endpoint = current ? 'disable' : 'enable'
try {
await axios.post(`/api/v1/datasources/${id}/${endpoint}`)
- message.success(`${current ? '已禁用' : '已启用'}`)
+ messageApi.success(`${current ? '已禁用' : '已启用'}`)
fetchData()
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } }
- message.error(err.response?.data?.detail || '操作失败')
+ messageApi.error(err.response?.data?.detail || '操作失败')
}
}
@@ -225,12 +487,12 @@ function DataSources() {
if (!viewingSource) return
try {
const res = await axios.delete(`/api/v1/datasources/${viewingSource.id}/data`)
- message.success(res.data.message || '数据已删除')
+ messageApi.success(res.data.message || '数据已删除')
setViewDrawerVisible(false)
fetchData()
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } }
- message.error(err.response?.data?.detail || '删除数据失败')
+ messageApi.error(err.response?.data?.detail || '删除数据失败')
}
}
@@ -258,7 +520,7 @@ function DataSources() {
setRecordCount(statsRes.data.total_records || 0)
setViewDrawerVisible(true)
} catch (error) {
- message.error('获取数据源信息失败')
+ messageApi.error('获取数据源信息失败')
}
}
@@ -266,11 +528,11 @@ function DataSources() {
if (!viewingSource) return
try {
await axios.post(`/api/v1/datasources/${viewingSource.id}/trigger`)
- message.success('已触发更新')
+ messageApi.success('已触发更新')
setViewDrawerVisible(false)
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } }
- message.error(err.response?.data?.detail || '更新失败')
+ messageApi.error(err.response?.data?.detail || '更新失败')
}
}
@@ -282,13 +544,13 @@ function DataSources() {
const res = await axios.post('/api/v1/datasources/configs/test', values)
setTestResult(res.data)
if (res.data.success) {
- message.success('连接测试成功')
+ messageApi.success('连接测试成功')
} else {
- message.error('连接测试失败')
+ messageApi.error('连接测试失败')
}
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string; message?: string } } }
- message.error(err.response?.data?.message || err.response?.data?.detail || '测试失败')
+ messageApi.error(err.response?.data?.message || err.response?.data?.detail || '测试失败')
} finally {
setTesting(false)
}
@@ -299,10 +561,10 @@ function DataSources() {
const values = await form.validateFields()
if (editingConfig) {
await axios.put(`/api/v1/datasources/configs/${editingConfig.id}`, values)
- message.success('配置已更新')
+ messageApi.success('配置已更新')
} else {
await axios.post('/api/v1/datasources/configs', values)
- message.success('配置已创建')
+ messageApi.success('配置已创建')
}
setDrawerVisible(false)
form.resetFields()
@@ -311,29 +573,29 @@ function DataSources() {
fetchData()
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string; message?: string } } }
- message.error(err.response?.data?.message || err.response?.data?.detail || '保存失败')
+ messageApi.error(err.response?.data?.message || err.response?.data?.detail || '保存失败')
}
}
const handleDelete = async (id: number) => {
try {
await axios.delete(`/api/v1/datasources/configs/${id}`)
- message.success('配置已删除')
+ messageApi.success('配置已删除')
fetchData()
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } }
- message.error(err.response?.data?.detail || '删除失败')
+ messageApi.error(err.response?.data?.detail || '删除失败')
}
}
const handleToggleCustom = async (id: number, current: boolean) => {
try {
await axios.put(`/api/v1/datasources/configs/${id}`, { is_active: !current })
- message.success(`${current ? '已禁用' : '已启用'}`)
+ messageApi.success(`${current ? '已禁用' : '已启用'}`)
fetchData()
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } }
- message.error(err.response?.data?.detail || '操作失败')
+ messageApi.error(err.response?.data?.detail || '操作失败')
}
}
@@ -372,9 +634,9 @@ function DataSources() {
document.execCommand('copy')
document.body.removeChild(textArea)
}
- message.success(successText)
+ messageApi.success(successText)
} catch {
- message.error('复制失败,请手动复制')
+ messageApi.error('复制失败,请手动复制')
}
}
@@ -405,8 +667,15 @@ function DataSources() {
title: '最近采集',
dataIndex: 'last_run',
key: 'last_run',
- width: 140,
- render: (lastRun: string | null) => lastRun || '-',
+ width: 180,
+ render: (_: string | null, record: BuiltInDataSource) => {
+ const label = formatDateTimeZhCN(record.last_run_at || record.last_run)
+ if (!label || label === '-') return '-'
+ if ((record.data_count || 0) === 0 && record.last_status === 'success') {
+ return `${label} (0条)`
+ }
+ return label
+ },
},
{
title: '状态',
@@ -431,7 +700,6 @@ function DataSources() {
const phase = taskState?.phase || record.phase || 'queued'
return (
- {record.is_active ? '运行中' : '已暂停'}
{phaseLabelMap[phase] || phase}
{pct > 0 ? ` ${Math.round(pct)}%` : ''}
@@ -439,7 +707,26 @@ function DataSources() {
)
}
- return {record.is_active ? '运行中' : '已暂停'}
+ const lastStatusColor =
+ record.last_status === 'success'
+ ? 'success'
+ : record.last_status === 'failed'
+ ? 'error'
+ : 'default'
+
+ return (
+
+ {record.last_status ? (
+
+ {record.last_status === 'success'
+ ? '采集成功'
+ : record.last_status === 'failed'
+ ? '采集失败'
+ : record.last_status}
+
+ ) : null}
+
+ )
},
},
{
@@ -453,6 +740,7 @@ function DataSources() {
type="link"
size="small"
icon={}
+ disabled={!record.is_active}
onClick={() => handleTrigger(record.id)}
>
触发
@@ -461,6 +749,8 @@ function DataSources() {
type="link"
size="small"
icon={record.is_active ? : }
+ danger={record.is_active}
+ style={record.is_active ? undefined : { color: '#52c41a' }}
onClick={() => handleToggle(record.id, record.is_active)}
>
{record.is_active ? '禁用' : '启用'}
@@ -536,7 +826,68 @@ function DataSources() {
key: 'builtin',
label: '内置数据源',
children: (
-
+
+
+
+
采集实时进度
+
+
+ 总体进度
+ {aggregateProgress}%
+
+
+
+
+ 内置
+ {builtInSources.length}
+
+
+ 已启用
+ {activeBuiltInCount}
+
+
+ 执行中
+ {bulkProgressBatch ? bulkBatchRunningCount : runningBuiltInCount}
+
+ {bulkProgressBatch ? (
+
+ 成功
+ {bulkBatchSuccessCount}/{bulkProgressBatch.sourceIds.length}
+
+ ) : null}
+ {bulkProgressBatch ? (
+
+ 失败
+ {bulkBatchFailedCount}
+
+ ) : null}
+
+
+
+ setForceTriggerAll(event.target.checked)}
+ >
+ 强制全部采集
+
+ }
+ loading={triggerAllLoading}
+ onClick={handleTriggerAll}
+ >
+ 一键采集
+
+
+
+ {contextHolder}
数据源管理
@@ -854,80 +1206,87 @@ function DataSources() {
}
>
{viewingSource && (
-
-
-
+
+
+
+
+ 名称
+
+
+
+ 模块
+
+
+
+ 优先级
+
+
+
+ 频率
+
+
+
+ 数据量
+
+
+
+ 采集器
+
+
+
+
-
-
-
+
+
+
+
+ }
+ onClick={() => viewingSource.endpoint && handleCopyLink(viewingSource.endpoint, '采集源 API 链接已复制')}
+ />
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- }
- onClick={() => viewingSource.endpoint && handleCopyLink(viewingSource.endpoint, '采集源 API 链接已复制')}
- />
-
-
-
-
-
-
-
- ),
- },
- {
- key: 'headers',
- label: '请求头',
- children: viewingSource.headers && Object.keys(viewingSource.headers).length > 0 ? (
-
- {JSON.stringify(viewingSource.headers, null, 2)}
-
- ) : (
- 无
- ),
- },
- {
- key: 'config',
- label: '高级配置',
- children: viewingSource.config && Object.keys(viewingSource.config).length > 0 ? (
-
- {JSON.stringify(viewingSource.config, null, 2)}
-
- ) : (
- 无
- ),
- },
- ]}
- />
-
+
+
+
+ ),
+ },
+ {
+ key: 'headers',
+ label: '请求头',
+ children: viewingSource.headers && Object.keys(viewingSource.headers).length > 0 ? (
+
+ {JSON.stringify(viewingSource.headers, null, 2)}
+
+ ) : (
+ 无
+ ),
+ },
+ {
+ key: 'config',
+ label: '高级配置',
+ children: viewingSource.config && Object.keys(viewingSource.config).length > 0 ? (
+
+ {JSON.stringify(viewingSource.config, null, 2)}
+
+ ) : (
+ 无
+ ),
+ },
+ ]}
+ />
+
+
)}
diff --git a/frontend/src/pages/Earth/Earth.tsx b/frontend/src/pages/Earth/Earth.tsx
index da2896d1..c2ab36d7 100644
--- a/frontend/src/pages/Earth/Earth.tsx
+++ b/frontend/src/pages/Earth/Earth.tsx
@@ -3,14 +3,14 @@ function Earth() {
- )
+ );
}
-export default Earth
+export default Earth;
diff --git a/frontend/src/pages/Settings/Settings.tsx b/frontend/src/pages/Settings/Settings.tsx
index b0d43f4a..670f261d 100644
--- a/frontend/src/pages/Settings/Settings.tsx
+++ b/frontend/src/pages/Settings/Settings.tsx
@@ -7,7 +7,6 @@ import {
InputNumber,
message,
Select,
- Space,
Switch,
Table,
Tabs,
@@ -16,6 +15,7 @@ import {
} from 'antd'
import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout'
+import { formatDateTimeZhCN } from '../../utils/datetime'
const { Title, Text } = Typography
@@ -75,6 +75,9 @@ function Settings() {
const [loading, setLoading] = useState(true)
const [savingCollectorId, setSavingCollectorId] = useState
(null)
const [collectors, setCollectors] = useState([])
+ const [systemSettings, setSystemSettings] = useState(null)
+ const [notificationSettings, setNotificationSettings] = useState(null)
+ const [securitySettings, setSecuritySettings] = useState(null)
const collectorTableRegionRef = useRef(null)
const [collectorTableHeight, setCollectorTableHeight] = useState(360)
const [systemForm] = Form.useForm()
@@ -85,9 +88,9 @@ function Settings() {
try {
setLoading(true)
const response = await axios.get('/api/v1/settings')
- systemForm.setFieldsValue(response.data.system)
- notificationForm.setFieldsValue(response.data.notifications)
- securityForm.setFieldsValue(response.data.security)
+ setSystemSettings(response.data.system)
+ setNotificationSettings(response.data.notifications)
+ setSecuritySettings(response.data.security)
setCollectors(response.data.collectors || [])
} catch (error) {
message.error('获取系统配置失败')
@@ -101,6 +104,24 @@ function Settings() {
fetchSettings()
}, [])
+ useEffect(() => {
+ if (!loading && systemSettings) {
+ systemForm.setFieldsValue(systemSettings)
+ }
+ }, [loading, systemForm, systemSettings])
+
+ useEffect(() => {
+ if (!loading && notificationSettings) {
+ notificationForm.setFieldsValue(notificationSettings)
+ }
+ }, [loading, notificationForm, notificationSettings])
+
+ useEffect(() => {
+ if (!loading && securitySettings) {
+ securityForm.setFieldsValue(securitySettings)
+ }
+ }, [loading, securityForm, securitySettings])
+
useEffect(() => {
const updateTableHeight = () => {
const regionHeight = collectorTableRegionRef.current?.offsetHeight || 0
@@ -220,14 +241,14 @@ function Settings() {
dataIndex: 'last_run_at',
key: 'last_run_at',
width: 180,
- render: (value: string | null) => (value ? new Date(value).toLocaleString('zh-CN') : '-'),
+ render: (value: string | null) => formatDateTimeZhCN(value),
},
{
title: '下次执行',
dataIndex: 'next_run_at',
key: 'next_run_at',
width: 180,
- render: (value: string | null) => (value ? new Date(value).toLocaleString('zh-CN') : '-'),
+ render: (value: string | null) => formatDateTimeZhCN(value),
},
{
title: '状态',
@@ -257,6 +278,7 @@ function Settings() {
{
key: 'system',
label: '系统显示',
+ forceRender: true,
children: (
-
+
{
+ const {
+ incidentPageSize = 50,
+ anomalyPageSize = 100,
+ eventPageSize = 20,
+ } = options
+
+ const [incidentsRes, incidentSummaryRes, anomaliesRes, eventsRes, eventSummaryRes, collectorsRes, collectorSummaryRes] = await Promise.all([
+ axios.get>(`${API_BASE_URL}/bgp/incidents`, { params: { page_size: incidentPageSize } }),
+ axios.get(`${API_BASE_URL}/bgp/incidents/summary`),
+ axios.get>(`${API_BASE_URL}/bgp/anomalies`, { params: { page_size: anomalyPageSize } }),
+ axios.get>(`${API_BASE_URL}/bgp/events`, { params: { page_size: eventPageSize } }),
+ axios.get(`${API_BASE_URL}/bgp/events/summary`),
+ axios.get>(`${API_BASE_URL}/bgp/collectors`),
+ axios.get(`${API_BASE_URL}/bgp/collectors/summary`),
+ ])
+
+ return {
+ incidents: incidentsRes.data.data || [],
+ incidentSummary: incidentSummaryRes.data,
+ anomalies: anomaliesRes.data.data || [],
+ events: eventsRes.data.data || [],
+ eventSummary: eventSummaryRes.data,
+ collectors: collectorsRes.data.data || [],
+ collectorSummary: collectorSummaryRes.data,
+ }
+ }
+}
diff --git a/frontend/src/services/situational-awareness/index.ts b/frontend/src/services/situational-awareness/index.ts
new file mode 100644
index 00000000..3e62a6e0
--- /dev/null
+++ b/frontend/src/services/situational-awareness/index.ts
@@ -0,0 +1,23 @@
+import type { SituationalAwarenessGateway } from './port'
+import { HttpSituationalAwarenessGateway } from './http-gateway'
+import { MockSituationalAwarenessGateway } from './mock-gateway'
+
+export * from './types'
+export type { SituationalAwarenessGateway } from './port'
+
+let singleton: SituationalAwarenessGateway | null = null
+
+export function createSituationalAwarenessGateway(): SituationalAwarenessGateway {
+ const provider = (import.meta as any).env?.VITE_SA_GATEWAY || 'http'
+ if (provider === 'mock') {
+ return new MockSituationalAwarenessGateway()
+ }
+ return new HttpSituationalAwarenessGateway()
+}
+
+export function getSituationalAwarenessGateway(): SituationalAwarenessGateway {
+ if (!singleton) {
+ singleton = createSituationalAwarenessGateway()
+ }
+ return singleton
+}
diff --git a/frontend/src/services/situational-awareness/mock-gateway.ts b/frontend/src/services/situational-awareness/mock-gateway.ts
new file mode 100644
index 00000000..9ac899bf
--- /dev/null
+++ b/frontend/src/services/situational-awareness/mock-gateway.ts
@@ -0,0 +1,35 @@
+import type { SituationalAwarenessGateway } from './port'
+import type { BGPOverviewOptions, BGPOverviewSnapshot } from './types'
+
+const EMPTY_SNAPSHOT: BGPOverviewSnapshot = {
+ incidents: [],
+ incidentSummary: {
+ total: 0,
+ by_type: {},
+ by_severity: {},
+ by_status: {},
+ },
+ anomalies: [],
+ events: [],
+ eventSummary: {
+ total: 0,
+ collector_count: 0,
+ prefix_count: 0,
+ by_type: {},
+ },
+ collectors: [],
+ collectorSummary: {
+ total: 0,
+ active_collectors: 0,
+ observed_prefixes: 0,
+ observed_origins: 0,
+ recent_24h_events: 0,
+ recent_7d_events: 0,
+ },
+}
+
+export class MockSituationalAwarenessGateway implements SituationalAwarenessGateway {
+ async getBGPOverview(_options: BGPOverviewOptions = {}): Promise {
+ return EMPTY_SNAPSHOT
+ }
+}
diff --git a/frontend/src/services/situational-awareness/port.ts b/frontend/src/services/situational-awareness/port.ts
new file mode 100644
index 00000000..358b7dde
--- /dev/null
+++ b/frontend/src/services/situational-awareness/port.ts
@@ -0,0 +1,5 @@
+import type { BGPOverviewOptions, BGPOverviewSnapshot } from './types'
+
+export interface SituationalAwarenessGateway {
+ getBGPOverview(options?: BGPOverviewOptions): Promise
+}
diff --git a/frontend/src/services/situational-awareness/types.ts b/frontend/src/services/situational-awareness/types.ts
new file mode 100644
index 00000000..41683b7e
--- /dev/null
+++ b/frontend/src/services/situational-awareness/types.ts
@@ -0,0 +1,112 @@
+export interface BGPAnomaly {
+ id: number
+ source: string
+ anomaly_type: string
+ severity: string
+ status: string
+ prefix: string | null
+ origin_asn: number | null
+ new_origin_asn: number | null
+ confidence: number
+ summary: string
+ created_at: string | null
+}
+
+export interface BGPEvent {
+ id: number
+ collector: string | null
+ event_type: string
+ prefix: string | null
+ origin_asn: number | null
+ peer_asn: number | null
+ observed_at: string | null
+}
+
+export interface BGPCollectorCoverage {
+ collector: string
+ city?: string | null
+ country?: string | null
+ observation_count: number
+ recent_24h_observation_count: number
+ recent_7d_observation_count: number
+ prefix_count: number
+ recent_24h_prefix_count: number
+ recent_7d_prefix_count: number
+ origin_asn_count: number
+ peer_asn_count: number
+ latest_observed_at: string | null
+ latest_event_type: string | null
+ baseline_scope: {
+ countries: string[]
+ cities: string[]
+ }
+}
+
+export interface BGPIncident {
+ id: number
+ incident_type: string
+ title: string
+ summary: string
+ severity: string
+ status: string
+ confidence: number
+ affected_prefixes: string[]
+ affected_asns: number[]
+ affected_collectors: string[]
+ affected_regions: Array<{ country?: string; city?: string }>
+ related_cables: Array<{
+ landing_point?: string
+ city?: string
+ country?: string
+ distance_km?: number
+ cable_names?: string[]
+ }>
+ created_at: string | null
+ started_at: string | null
+}
+
+export interface Summary {
+ total: number
+ by_type: Record
+ by_severity: Record
+ by_status: Record
+}
+
+export interface EventSummary {
+ total: number
+ collector_count: number
+ prefix_count: number
+ by_type: Record
+}
+
+export interface CollectorSummary {
+ total: number
+ active_collectors: number
+ observed_prefixes: number
+ observed_origins: number
+ recent_24h_events: number
+ recent_7d_events: number
+}
+
+export interface ListResponse {
+ total: number
+ page?: number
+ page_size?: number
+ data: T[]
+}
+
+export interface BGPOverviewSnapshot {
+ incidents: BGPIncident[]
+ incidentSummary: Summary | null
+ anomalies: BGPAnomaly[]
+ events: BGPEvent[]
+ eventSummary: EventSummary | null
+ collectors: BGPCollectorCoverage[]
+ collectorSummary: CollectorSummary | null
+}
+
+export interface BGPOverviewOptions {
+ incidentPageSize?: number
+ anomalyPageSize?: number
+ eventPageSize?: number
+}
diff --git a/frontend/src/utils/datetime.ts b/frontend/src/utils/datetime.ts
new file mode 100644
index 00000000..96e38baa
--- /dev/null
+++ b/frontend/src/utils/datetime.ts
@@ -0,0 +1,47 @@
+export function parseBackendDate(value: string | null | undefined): Date | null {
+ if (!value) return null
+
+ let normalized = value.trim()
+ if (!normalized) return null
+
+ if (normalized.includes(' ') && !normalized.includes('T')) {
+ normalized = normalized.replace(' ', 'T')
+ }
+
+ const hasTimezone = /(?:Z|[+-]\d{2}:\d{2})$/.test(normalized)
+ if (!hasTimezone) {
+ normalized = `${normalized}Z`
+ }
+
+ const date = new Date(normalized)
+ return Number.isNaN(date.getTime()) ? null : date
+}
+
+function padNumber(value: number): string {
+ return String(value).padStart(2, '0')
+}
+
+export function formatDateTimeZhCN(value: string | null | undefined): string {
+ const date = parseBackendDate(value)
+ if (!date) return '-'
+
+ const year = date.getFullYear()
+ const month = padNumber(date.getMonth() + 1)
+ const day = padNumber(date.getDate())
+ const hours = padNumber(date.getHours())
+ const minutes = padNumber(date.getMinutes())
+ const seconds = padNumber(date.getSeconds())
+
+ return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`
+}
+
+export function formatDateZhCN(value: string | null | undefined): string {
+ const date = parseBackendDate(value)
+ if (!date) return '-'
+
+ const year = date.getFullYear()
+ const month = padNumber(date.getMonth() + 1)
+ const day = padNumber(date.getDate())
+
+ return `${year}/${month}/${day}`
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 807499de..b4323c34 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -52,6 +52,11 @@ export default defineConfig({
changeOrigin: true,
secure: false,
},
+ '/health': {
+ target: 'http://localhost:8000',
+ changeOrigin: true,
+ secure: false,
+ },
},
fs: {
allow: ['..'],
@@ -64,6 +69,7 @@ export default defineConfig({
},
},
optimizeDeps: {
+ entries: ['src/main.tsx'],
exclude: ['satellite.js'],
},
})
diff --git a/kill_port.sh b/kill_port.sh
deleted file mode 100755
index 242f56dd..00000000
--- a/kill_port.sh
+++ /dev/null
@@ -1,40 +0,0 @@
-#!/bin/bash
-# kill_port.sh - 检测并杀掉占用指定端口的进程
-
-PORT=${1:-8000}
-
-echo "=========================================="
-echo "端口检测与清理脚本"
-echo "=========================================="
-echo "目标端口: $PORT"
-echo ""
-
-# 检测并杀掉占用端口的进程
-echo "[1/3] 检测端口 $PORT ..."
-if fuser $PORT/tcp >/dev/null 2>&1; then
- echo " ✓ 发现占用端口 $PORT 的进程:"
- fuser -v $PORT/tcp 2>&1
-
- echo ""
- echo "[2/3] 正在终止进程..."
- fuser -k $PORT/tcp 2>&1
- echo " ✓ 进程已终止"
-else
- echo " ✓ 端口 $PORT 未被占用"
-fi
-
-echo ""
-echo "[3/3] 清理残留Docker容器..."
-EXIT_CONTAINERS=$(docker ps -a | grep Exit | awk '{print $1}')
-if [ -n "$EXIT_CONTAINERS" ]; then
- echo " 发现残留容器,正在清理..."
- echo "$EXIT_CONTAINERS" | xargs -r docker rm -f
- echo " ✓ 残留容器已清理"
-else
- echo " ✓ 无残留容器"
-fi
-
-echo ""
-echo "=========================================="
-echo "完成!"
-echo "=========================================="
diff --git a/planet.sh b/planet.sh
index 217ad484..b487a830 100755
--- a/planet.sh
+++ b/planet.sh
@@ -11,6 +11,26 @@ YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
+BACKEND_MAX_RETRIES="${BACKEND_MAX_RETRIES:-3}"
+BACKEND_HEALTH_CHECK_ATTEMPTS="${BACKEND_HEALTH_CHECK_ATTEMPTS:-10}"
+BACKEND_HEALTH_CHECK_INTERVAL="${BACKEND_HEALTH_CHECK_INTERVAL:-2}"
+AI_PROVIDER_HEALTH_CHECK_ATTEMPTS="${AI_PROVIDER_HEALTH_CHECK_ATTEMPTS:-10}"
+AI_PROVIDER_HEALTH_CHECK_INTERVAL="${AI_PROVIDER_HEALTH_CHECK_INTERVAL:-2}"
+FRONTEND_MAX_RETRIES="${FRONTEND_MAX_RETRIES:-3}"
+FRONTEND_HEALTH_CHECK_ATTEMPTS="${FRONTEND_HEALTH_CHECK_ATTEMPTS:-10}"
+FRONTEND_HEALTH_CHECK_INTERVAL="${FRONTEND_HEALTH_CHECK_INTERVAL:-2}"
+DEFAULT_BACKEND_PORT="${DEFAULT_BACKEND_PORT:-8000}"
+DEFAULT_FRONTEND_PORT="${DEFAULT_FRONTEND_PORT:-3000}"
+DEFAULT_AI_PROVIDER_PORT="${DEFAULT_AI_PROVIDER_PORT:-8010}"
+
+compose_up() {
+ if docker compose version >/dev/null 2>&1; then
+ docker compose "$@"
+ else
+ docker-compose "$@"
+ fi
+}
+
ensure_uv_backend_deps() {
echo -e "${BLUE}📦 检查后端 uv 环境...${NC}"
@@ -53,57 +73,422 @@ ensure_frontend_deps() {
fi
}
-start() {
- echo -e "${BLUE}🚀 启动智能星球计划...${NC}"
+wait_for_http() {
+ local url="$1"
+ local attempts="$2"
+ local interval="$3"
+ local service_name="$4"
+ local attempt=1
+
+ while [ "$attempt" -le "$attempts" ]; do
+ if curl -s "$url" > /dev/null 2>&1; then
+ return 0
+ fi
+
+ echo -e "${YELLOW}⏳ 等待${service_name}就绪...${NC}"
+ sleep "$interval"
+ attempt=$((attempt + 1))
+ done
+
+ return 1
+}
+
+start_backend_with_retry() {
+ local backend_port="$1"
+ local retry=1
+
+ while [ "$retry" -le "$BACKEND_MAX_RETRIES" ]; do
+ pkill -f "uvicorn" 2>/dev/null || true
+ cd "$SCRIPT_DIR/backend"
+ PYTHONPATH="$SCRIPT_DIR/backend" nohup uv run --project "$SCRIPT_DIR" python -m uvicorn app.main:app --host 0.0.0.0 --port "$backend_port" --reload > /tmp/planet_backend.log 2>&1 &
+ BACKEND_PID=$!
+
+ if wait_for_http "http://localhost:${backend_port}/health" "$BACKEND_HEALTH_CHECK_ATTEMPTS" "$BACKEND_HEALTH_CHECK_INTERVAL" "后端"; then
+ return 0
+ fi
+
+ echo -e "${YELLOW}⚠️ 后端第 ${retry}/${BACKEND_MAX_RETRIES} 次启动未就绪,准备重试...${NC}"
+ kill "$BACKEND_PID" 2>/dev/null || true
+ retry=$((retry + 1))
+ done
+
+ return 1
+}
+
+start_ai_provider_service() {
+ local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}"
+
+ echo -e "${BLUE}🧠 启动 AI Provider...${NC}"
+ docker start planet_aiprovider 2>/dev/null || compose_up up -d aiprovider
+
+ if ! wait_for_http "http://localhost:${ai_provider_port}/health" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider"; then
+ echo -e "${RED}❌ AI Provider 启动失败${NC}"
+ exit 1
+ fi
+}
+
+start_frontend_with_retry() {
+ local frontend_port="$1"
+ local retry=1
+
+ while [ "$retry" -le "$FRONTEND_MAX_RETRIES" ]; do
+ pkill -f "vite" 2>/dev/null || true
+ pkill -f "bun run dev" 2>/dev/null || true
+ cd "$SCRIPT_DIR/frontend"
+ nohup bun run dev --port "$frontend_port" > /tmp/planet_frontend.log 2>&1 &
+ FRONTEND_PID=$!
+
+ if wait_for_http "http://localhost:${frontend_port}" "$FRONTEND_HEALTH_CHECK_ATTEMPTS" "$FRONTEND_HEALTH_CHECK_INTERVAL" "前端"; then
+ return 0
+ fi
+
+ echo -e "${YELLOW}⚠️ 前端第 ${retry}/${FRONTEND_MAX_RETRIES} 次启动未就绪,准备重试...${NC}"
+ kill "$FRONTEND_PID" 2>/dev/null || true
+ retry=$((retry + 1))
+ done
+
+ return 1
+}
+
+validate_port() {
+ local port="$1"
+
+ if ! [[ "$port" =~ ^[0-9]+$ ]] || [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then
+ echo -e "${RED}❌ 非法端口: ${port}${NC}"
+ exit 1
+ fi
+}
+
+kill_port_if_requested() {
+ local port="$1"
+ local service_name="$2"
+
+ echo -e "${YELLOW}🧹 检测 ${service_name} 端口 ${port} 占用...${NC}"
+
+ if command -v fuser >/dev/null 2>&1 && fuser "${port}/tcp" >/dev/null 2>&1; then
+ echo -e "${BLUE}🔌 发现端口 ${port} 占用,正在终止...${NC}"
+ fuser -k "${port}/tcp" >/dev/null 2>&1 || true
+ sleep 1
+ return 0
+ fi
+
+ if command -v lsof >/dev/null 2>&1; then
+ local pids
+ pids="$(lsof -ti tcp:"${port}" 2>/dev/null || true)"
+ if [ -n "$pids" ]; then
+ echo -e "${BLUE}🔌 发现端口 ${port} 占用,正在终止...${NC}"
+ kill $pids 2>/dev/null || true
+ sleep 1
+ return 0
+ fi
+ fi
+
+ echo -e "${GREEN}✅ 端口 ${port} 未被占用${NC}"
+}
+
+parse_service_args() {
+ BACKEND_PORT="$DEFAULT_BACKEND_PORT"
+ FRONTEND_PORT="$DEFAULT_FRONTEND_PORT"
+ AI_PROVIDER_PORT="$DEFAULT_AI_PROVIDER_PORT"
+ BACKEND_PORT_REQUESTED=0
+ FRONTEND_PORT_REQUESTED=0
+ AI_PROVIDER_REQUESTED=0
+ DATABASE_REQUESTED=0
+
+ while [ "$#" -gt 0 ]; do
+ case "$1" in
+ -b|--backend-port)
+ BACKEND_PORT_REQUESTED=1
+ if [ -n "$2" ] && [[ "$2" =~ ^[0-9]+$ ]]; then
+ BACKEND_PORT="$2"
+ shift 2
+ else
+ shift 1
+ fi
+ ;;
+ -f|--frontend-port)
+ FRONTEND_PORT_REQUESTED=1
+ if [ -n "$2" ] && [[ "$2" =~ ^[0-9]+$ ]]; then
+ FRONTEND_PORT="$2"
+ shift 2
+ else
+ shift 1
+ fi
+ ;;
+ -a|--ai-provider-port)
+ AI_PROVIDER_REQUESTED=1
+ if [ -n "$2" ] && [[ "$2" =~ ^[0-9]+$ ]]; then
+ AI_PROVIDER_PORT="$2"
+ shift 2
+ else
+ shift 1
+ fi
+ ;;
+ -d|--database)
+ DATABASE_REQUESTED=1
+ shift 1
+ ;;
+ *)
+ echo -e "${RED}❌ 未知参数: $1${NC}"
+ exit 1
+ ;;
+ esac
+ done
+
+ validate_port "$BACKEND_PORT"
+ validate_port "$FRONTEND_PORT"
+ validate_port "$AI_PROVIDER_PORT"
+}
+
+cleanup_exit_containers() {
+ local exit_containers
+ exit_containers="$(docker ps -a --filter status=exited -q 2>/dev/null || true)"
+ if [ -n "$exit_containers" ]; then
+ echo -e "${BLUE}🗑️ 清理残留 Exit 容器...${NC}"
+ echo "$exit_containers" | xargs -r docker rm -f >/dev/null 2>&1 || true
+ echo -e "${GREEN}✅ 残留容器已清理${NC}"
+ fi
+}
+
+stop_backend_service() {
+ pkill -f "uvicorn" 2>/dev/null || true
+}
+
+stop_ai_provider_service() {
+ docker stop planet_aiprovider 2>/dev/null || true
+}
+
+stop_frontend_service() {
+ pkill -f "vite" 2>/dev/null || true
+ pkill -f "bun run dev" 2>/dev/null || true
+}
+
+restart_database_service() {
+ echo -e "${BLUE}🗄️ 重启数据库...${NC}"
+ docker restart planet_postgres planet_redis 2>/dev/null || compose_up up -d postgres redis
+ sleep 3
+}
+
+start_backend_service() {
+ local backend_port="$1"
+ local backend_port_requested="$2"
+ local ai_provider_port="${3:-$DEFAULT_AI_PROVIDER_PORT}"
echo -e "${BLUE}🗄️ 启动数据库...${NC}"
- docker start planet_postgres planet_redis 2>/dev/null || docker-compose up -d postgres redis
-
+ docker start planet_postgres planet_redis 2>/dev/null || compose_up up -d postgres redis
sleep 3
+ start_ai_provider_service "$ai_provider_port"
+
+ if [ "$backend_port_requested" -eq 1 ]; then
+ kill_port_if_requested "$backend_port" "后端"
+ fi
+
echo -e "${BLUE}🔧 启动后端...${NC}"
ensure_uv_backend_deps
- pkill -f "uvicorn" 2>/dev/null || true
- cd "$SCRIPT_DIR/backend"
- PYTHONPATH="$SCRIPT_DIR/backend" nohup uv run --project "$SCRIPT_DIR" python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload > /tmp/planet_backend.log 2>&1 &
- BACKEND_PID=$!
-
- sleep 3
-
- if ! curl -s http://localhost:8000/health > /dev/null 2>&1; then
- echo -e "${RED}❌ 后端启动失败${NC}"
+ if ! start_backend_with_retry "$backend_port"; then
+ echo -e "${RED}❌ 后端启动失败,已重试 ${BACKEND_MAX_RETRIES} 次${NC}"
tail -10 /tmp/planet_backend.log
exit 1
fi
+}
+
+start_frontend_service() {
+ local frontend_port="$1"
+ local frontend_port_requested="$2"
+
+ if [ "$frontend_port_requested" -eq 1 ]; then
+ kill_port_if_requested "$frontend_port" "前端"
+ fi
echo -e "${BLUE}🌐 启动前端...${NC}"
ensure_frontend_deps
- pkill -f "vite" 2>/dev/null || true
- pkill -f "bun run dev" 2>/dev/null || true
- cd "$SCRIPT_DIR/frontend"
- nohup bun run dev --port 3000 > /tmp/planet_frontend.log 2>&1 &
+ if ! start_frontend_with_retry "$frontend_port"; then
+ echo -e "${RED}❌ 前端启动失败,已重试 ${FRONTEND_MAX_RETRIES} 次${NC}"
+ tail -10 /tmp/planet_frontend.log
+ exit 1
+ fi
+}
- sleep 3
+create_user() {
+ local username
+ local password
+ local password_confirm
+ local email
+ local generated_email
+ local role="viewer"
+ local is_admin
+
+ ensure_uv_backend_deps
+
+ echo -e "${BLUE}🗄️ 启动数据库...${NC}"
+ docker start planet_postgres 2>/dev/null || compose_up up -d postgres
+ sleep 2
+
+ echo -e "${BLUE}👤 创建用户${NC}"
+ read -r -p "用户名: " username
+
+ if [ -z "$username" ]; then
+ echo -e "${RED}❌ 用户名不能为空${NC}"
+ exit 1
+ fi
+
+ generated_email="${username}@planet.local"
+ read -r -p "邮箱(留空自动使用 ${generated_email}): " email
+ if [ -z "$email" ]; then
+ email="$generated_email"
+ fi
+
+ read -r -s -p "密码: " password
+ echo ""
+ read -r -s -p "确认密码: " password_confirm
+ echo ""
+
+ if [ -z "$password" ]; then
+ echo -e "${RED}❌ 密码不能为空${NC}"
+ exit 1
+ fi
+
+ if [ "${#password}" -lt 8 ]; then
+ echo -e "${RED}❌ 密码长度不能少于 8 位${NC}"
+ exit 1
+ fi
+
+ if [ "$password" != "$password_confirm" ]; then
+ echo -e "${RED}❌ 两次输入的密码不一致${NC}"
+ exit 1
+ fi
+
+ read -r -p "是否为管理员? (Y/N): " is_admin
+ if [[ "$is_admin" =~ ^[Yy]$ ]]; then
+ role="super_admin"
+ fi
+
+ cd "$SCRIPT_DIR/backend"
+ PLANET_CREATEUSER_USERNAME="$username" \
+ PLANET_CREATEUSER_PASSWORD="$password" \
+ PLANET_CREATEUSER_EMAIL="$email" \
+ PLANET_CREATEUSER_ROLE="$role" \
+ PYTHONPATH="$SCRIPT_DIR/backend" \
+ "$SCRIPT_DIR/.venv/bin/python" - <<'PY'
+import asyncio
+import os
+
+from sqlalchemy import text
+
+from app.core.security import get_password_hash
+from app.db.session import async_session_factory
+from app.models.user import User
+
+
+async def main():
+ username = os.environ["PLANET_CREATEUSER_USERNAME"].strip()
+ password = os.environ["PLANET_CREATEUSER_PASSWORD"]
+ email = os.environ["PLANET_CREATEUSER_EMAIL"].strip()
+ role = os.environ["PLANET_CREATEUSER_ROLE"].strip()
+
+ async with async_session_factory() as session:
+ result = await session.execute(
+ text("SELECT id FROM users WHERE username = :username OR email = :email"),
+ {"username": username, "email": email},
+ )
+ if result.fetchone():
+ raise SystemExit("用户名已存在")
+
+ user = User(
+ username=username,
+ email=email,
+ password_hash=get_password_hash(password),
+ role=role,
+ is_active=True,
+ )
+ session.add(user)
+ await session.commit()
+
+
+asyncio.run(main())
+PY
+
+ echo -e "${GREEN}✅ 用户创建成功${NC}"
+ echo " 用户名: ${username}"
+ echo " 角色: ${role}"
+ echo " 邮箱: ${email}"
+}
+
+start() {
+ parse_service_args "$@"
+ cleanup_exit_containers
+
+ echo -e "${BLUE}🚀 启动智能星球计划...${NC}"
+
+ start_backend_service "$BACKEND_PORT" "$BACKEND_PORT_REQUESTED" "$AI_PROVIDER_PORT"
+ start_frontend_service "$FRONTEND_PORT" "$FRONTEND_PORT_REQUESTED"
echo ""
echo -e "${GREEN}✅ 启动完成!${NC}"
- echo " 前端: http://localhost:3000"
- echo " 后端: http://localhost:8000"
+ echo " 前端: http://localhost:${FRONTEND_PORT}"
+ echo " 后端: http://localhost:${BACKEND_PORT}"
+ echo " AI Provider: http://localhost:${AI_PROVIDER_PORT}"
}
stop() {
echo -e "${YELLOW}🛑 停止服务...${NC}"
- pkill -f "uvicorn" 2>/dev/null || true
- pkill -f "vite" 2>/dev/null || true
- pkill -f "bun run dev" 2>/dev/null || true
+ stop_backend_service
+ stop_ai_provider_service
+ stop_frontend_service
docker stop planet_postgres planet_redis 2>/dev/null || true
echo -e "${GREEN}✅ 已停止${NC}"
}
restart() {
- stop
- sleep 1
- start
+ parse_service_args "$@"
+ cleanup_exit_containers
+
+ if [ "$BACKEND_PORT_REQUESTED" -eq 0 ] && [ "$FRONTEND_PORT_REQUESTED" -eq 0 ] && [ "$AI_PROVIDER_REQUESTED" -eq 0 ] && [ "$DATABASE_REQUESTED" -eq 0 ]; then
+ stop
+ sleep 1
+ start
+ return 0
+ fi
+
+ echo -e "${YELLOW}🔄 按需重启服务...${NC}"
+
+ if [ "$DATABASE_REQUESTED" -eq 1 ]; then
+ restart_database_service
+ fi
+
+ if [ "$AI_PROVIDER_REQUESTED" -eq 1 ]; then
+ stop_ai_provider_service
+ sleep 1
+ start_ai_provider_service "$AI_PROVIDER_PORT"
+ fi
+
+ if [ "$BACKEND_PORT_REQUESTED" -eq 1 ]; then
+ stop_backend_service
+ sleep 1
+ start_backend_service "$BACKEND_PORT" 1 "$AI_PROVIDER_PORT"
+ fi
+
+ if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then
+ stop_frontend_service
+ sleep 1
+ start_frontend_service "$FRONTEND_PORT" 1
+ fi
+
+ echo ""
+ echo -e "${GREEN}✅ 重启完成!${NC}"
+ if [ "$DATABASE_REQUESTED" -eq 1 ]; then
+ echo " 数据库: planet_postgres, planet_redis"
+ fi
+ if [ "$AI_PROVIDER_REQUESTED" -eq 1 ]; then
+ echo " AI Provider: http://localhost:${AI_PROVIDER_PORT}"
+ fi
+ if [ "$BACKEND_PORT_REQUESTED" -eq 1 ]; then
+ echo " 后端: http://localhost:${BACKEND_PORT}"
+ fi
+ if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then
+ echo " 前端: http://localhost:${FRONTEND_PORT}"
+ fi
}
health() {
@@ -118,6 +503,12 @@ health() {
echo -e " 后端: ${RED}❌ 未运行${NC}"
fi
+ if curl -s "http://localhost:${DEFAULT_AI_PROVIDER_PORT}/health" > /dev/null 2>&1; then
+ echo -e " AI Provider: ${GREEN}✅ 运行中${NC}"
+ else
+ echo -e " AI Provider: ${RED}❌ 未运行${NC}"
+ fi
+
if curl -s http://localhost:3000 > /dev/null 2>&1; then
echo -e " 前端: ${GREEN}✅ 运行中${NC}"
else
@@ -135,10 +526,16 @@ log() {
echo "📝 后端日志 (Ctrl+C 退出):"
tail -f /tmp/planet_backend.log
;;
+ -a|--ai-provider)
+ echo "📝 AI Provider 日志 (Ctrl+C 退出):"
+ docker logs -f planet_aiprovider
+ ;;
*)
echo "📝 最近日志:"
echo "--- 后端 ---"
tail -20 /tmp/planet_backend.log 2>/dev/null || echo "无日志"
+ echo "--- AI Provider ---"
+ docker logs --tail 20 planet_aiprovider 2>/dev/null || echo "无日志"
echo "--- 前端 ---"
tail -20 /tmp/planet_frontend.log 2>/dev/null || echo "无日志"
;;
@@ -147,13 +544,18 @@ log() {
case "$1" in
start)
- start
+ shift
+ start "$@"
;;
stop)
stop
;;
restart)
- restart
+ shift
+ restart "$@"
+ ;;
+ createuser)
+ create_user
;;
health)
health
@@ -162,15 +564,17 @@ case "$1" in
log "$2"
;;
*)
- echo "用法: ./planet.sh {start|stop|restart|health|log}"
+ echo "用法: ./planet.sh {start|stop|restart|createuser|health|log}"
echo ""
echo "命令:"
- echo " start 启动服务"
+ echo " start 启动服务,可选: -b <后端端口> -f <前端端口> -a "
echo " stop 停止服务"
- echo " restart 重启服务"
+ echo " restart 重启服务,可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] -d"
+ echo " createuser 交互创建用户"
echo " health 检查健康状态"
echo " log 查看日志"
echo " log -f 查看前端日志"
echo " log -b 查看后端日志"
+ echo " log -a 查看 AI Provider 日志"
;;
esac
diff --git a/project_context.md b/project_context.md
index f0ab3a5b..ce1d4f98 100644
--- a/project_context.md
+++ b/project_context.md
@@ -99,7 +99,8 @@
│ │ ├── api/
│ │ ├── unit/
│ │ └── conftest.py
-│ ├── requirements.txt
+│ ├── pyproject.toml
+│ ├── uv.lock
│ └── alembic/
│
├── frontend/ # React Admin
@@ -192,10 +193,10 @@
### Backend
```bash
+uv sync --group dev
cd backend
-pip install -r requirements.txt
-python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
-docker-compose up -d backend
+uv run --project .. python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
+docker compose up -d backend
ruff check . && black --check .
pytest -v && pytest tests/api/test_auth.py::test_login -v
```
@@ -203,11 +204,10 @@ pytest -v && pytest tests/api/test_auth.py::test_login -v
### Frontend
```bash
cd frontend
-npm install
-npm run dev
-npm run build
-npm run lint && npm run lint:fix
-npm test -- --testPathPattern="api" --testNamePattern="login"
+bun install
+bun run dev
+bun run build
+bun run preview
```
### Docker
diff --git a/pyproject.toml b/pyproject.toml
index bb8cc87c..621946fb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "planet"
-version = "1.0.0"
+version = "0.23.0"
description = "智能星球计划 - 态势感知系统"
requires-python = ">=3.14"
dependencies = [
diff --git a/rules.md b/rules.md
index 9db5a652..99705a68 100644
--- a/rules.md
+++ b/rules.md
@@ -139,7 +139,7 @@ git commit -m "docs: update API documentation"
# Before opening PR
git fetch origin && git rebase origin/main
-pytest && npm run lint
+./.venv/bin/python -m pytest -s backend/tests && bun run build
```
**Rules:**
@@ -155,8 +155,8 @@ pytest && npm run lint
**Rules:**
- Verify package legitimacy before adding
- Prefer well-maintained, widely-used libraries
-- Pin dependency versions in `requirements.txt` and `package.json`
-- Review security advisories with `pip-audit` and `npm audit`
+- Pin dependency versions in `pyproject.toml`, `uv.lock`, and `package.json`
+- Review security advisories with `pip-audit` and `bun audit`
- **NEVER** add unknown packages
---
diff --git a/scripts/bootstrap-dev.sh b/scripts/bootstrap-dev.sh
new file mode 100755
index 00000000..3f0da561
--- /dev/null
+++ b/scripts/bootstrap-dev.sh
@@ -0,0 +1,124 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
+
+BLUE='\033[0;34m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+RED='\033[0;31m'
+NC='\033[0m'
+
+have_cmd() {
+ command -v "$1" >/dev/null 2>&1
+}
+
+ensure_local_bin_on_path() {
+ export PATH="$HOME/.local/bin:$PATH"
+ export PATH="$HOME/.bun/bin:$PATH"
+}
+
+install_uv_if_needed() {
+ if have_cmd uv; then
+ echo -e "${GREEN}uv 已存在: $(command -v uv)${NC}"
+ return
+ fi
+
+ echo -e "${BLUE}安装 uv...${NC}"
+ curl -LsSf https://astral.sh/uv/install.sh | sh
+ ensure_local_bin_on_path
+
+ if ! have_cmd uv; then
+ echo -e "${RED}uv 安装失败,请手动检查 ~/.local/bin 是否加入 PATH${NC}"
+ exit 1
+ fi
+}
+
+install_bun_if_needed() {
+ if have_cmd bun; then
+ echo -e "${GREEN}bun 已存在: $(command -v bun)${NC}"
+ return
+ fi
+
+ echo -e "${BLUE}安装 bun...${NC}"
+ curl -fsSL https://bun.sh/install | bash
+ ensure_local_bin_on_path
+
+ if ! have_cmd bun; then
+ echo -e "${RED}bun 安装失败,请手动检查 ~/.bun/bin 是否加入 PATH${NC}"
+ exit 1
+ fi
+}
+
+ensure_python_alias() {
+ mkdir -p "$HOME/.local/bin"
+ if [ ! -e "$HOME/.local/bin/python" ]; then
+ ln -sf /usr/bin/python3 "$HOME/.local/bin/python"
+ fi
+ ensure_local_bin_on_path
+}
+
+sync_python_env() {
+ echo -e "${BLUE}同步 Python 依赖...${NC}"
+ cd "$ROOT_DIR"
+ uv python install 3.14
+ uv sync --group dev
+}
+
+sync_frontend_env() {
+ echo -e "${BLUE}同步前端依赖...${NC}"
+ cd "$ROOT_DIR/frontend"
+ bun install
+}
+
+ensure_env_files() {
+ echo -e "${BLUE}检查环境变量模板...${NC}"
+
+ if [ ! -f "$ROOT_DIR/backend/.env" ] && [ -f "$ROOT_DIR/backend/.env.example" ]; then
+ cp "$ROOT_DIR/backend/.env.example" "$ROOT_DIR/backend/.env"
+ echo -e "${YELLOW}已创建 backend/.env,请按需修改 SECRET_KEY / 数据库 / AI 服务配置${NC}"
+ fi
+
+ if [ ! -f "$ROOT_DIR/aiprovider/.env" ] && [ -f "$ROOT_DIR/aiprovider/.env.example" ]; then
+ cp "$ROOT_DIR/aiprovider/.env.example" "$ROOT_DIR/aiprovider/.env"
+ echo -e "${YELLOW}已创建 aiprovider/.env,请按需修改 provider/model/api key${NC}"
+ fi
+
+ if [ ! -f "$ROOT_DIR/frontend/.env.local" ] && [ -f "$ROOT_DIR/frontend/.env.example" ]; then
+ cp "$ROOT_DIR/frontend/.env.example" "$ROOT_DIR/frontend/.env.local"
+ echo -e "${YELLOW}已创建 frontend/.env.local,可按需覆盖 VITE_API_URL / VITE_WS_URL${NC}"
+ fi
+}
+
+report_optional_tools() {
+ if have_cmd docker; then
+ echo -e "${GREEN}docker 已存在: $(command -v docker)${NC}"
+ else
+ echo -e "${YELLOW}未检测到 docker。如需容器方式启动 backend/aiprovider,请安装 Docker。${NC}"
+ fi
+}
+
+print_next_steps() {
+ echo ""
+ echo -e "${GREEN}开发环境引导完成${NC}"
+ echo "下一步建议:"
+ echo " 1. cd \"$ROOT_DIR\""
+ echo " 2. uv run pytest backend/tests/test_api.py -q -s"
+ echo " 3. ./planet.sh start"
+}
+
+main() {
+ ensure_local_bin_on_path
+ install_uv_if_needed
+ install_bun_if_needed
+ ensure_python_alias
+ sync_python_env
+ sync_frontend_env
+ ensure_env_files
+ report_optional_tools
+ print_next_steps
+}
+
+main "$@"
diff --git a/uv.lock b/uv.lock
index 6ce747bb..2604d004 100644
--- a/uv.lock
+++ b/uv.lock
@@ -475,7 +475,7 @@ wheels = [
[[package]]
name = "planet"
-version = "1.0.0"
+version = "0.23.0"
source = { virtual = "." }
dependencies = [
{ name = "aiofiles" },